stubborn-otter/otter-boards
Otter Boards, a team task board you can run from the web or from Claude and ChatGPT. FastAPI + DataK3 + appid + Models + MCP.
A remix template · 1 remixes · Remix stubborn-otter/otter-boards
tests/test_extras.py
"""The extras: blocked-by, checklists, finding similar cards, daily summary."""
import pytest
from conftest import MEMBER, OWNER, STRANGER, VIEWER
from boards.application.ports import AssistantUnavailable
from boards.application.use_cases import Boards, NotFound
from boards.domain.model import Forbidden, Invalid
def cards(s, bid, *titles):
return [s.add_card(OWNER, bid, t)["id"] for t in titles]
def test_what_is_blocking_follows_the_chain_and_skips_finished_work(svc):
s, bid = svc
launch, site, copy_, old = cards(s, bid, "The launch", "Build site", "Write copy", "Old thing")
s.add_blocker(OWNER, launch, site)
s.add_blocker(OWNER, launch, old)
s.add_blocker(OWNER, site, copy_)
s.move_card(OWNER, old, "Done")
report = s.what_is_blocking(VIEWER, "the launch")
assert report["blocked"] is True
assert [(c["title"], c["steps_away"]) for c in report["open_blockers"]] == [
("Build site", 1), ("Write copy", 2)]
assert [c["title"] for c in s.what_is_blocking(OWNER, site)["holding_up"]] == ["The launch"]
def test_a_card_stops_being_blocked_when_its_blocker_finishes(svc):
s, bid = svc
a, b = cards(s, bid, "a", "b")
assert s.add_blocker(OWNER, a, b)["blocked"] is True
s.move_card(OWNER, b, "Done")
assert s.show_card(OWNER, a)["blocked"] is False
assert s.show_card(OWNER, a)["blocked_by"] == [2]
def test_blockers_cannot_form_a_loop(svc):
s, bid = svc
a, b, c = cards(s, bid, "a", "b", "c")
s.add_blocker(OWNER, a, b)
s.add_blocker(OWNER, b, c)
for blocked, by in ((c, a), (b, a), (a, a)):
with pytest.raises(Invalid, match="can't wait on itself"):
s.add_blocker(OWNER, blocked, by)
def test_blockers_stay_on_one_board(svc):
s, bid = svc
other = s.create_board(OWNER, "Other")["id"]
here = cards(s, bid, "here")[0]
there = s.add_card(OWNER, other, "there")["id"]
with pytest.raises(NotFound):
s.add_blocker(OWNER, here, there)
def test_blocker_changes_are_history_and_removal_works(svc):
s, bid = svc
a, b = cards(s, bid, "a", "b")
s.add_blocker(MEMBER, a, b)
s.add_blocker(MEMBER, a, b) # the same link again changes nothing
s.remove_blocker(MEMBER, a, b)
log = [e["action"] for e in s.card_history(OWNER, a)]
assert log == ["added the card to To do", "set blocked by to #2", "cleared blocked by (was #2)"]
assert s.show_card(OWNER, a)["blocked_by"] == []
def test_archiving_a_blocker_unblocks_the_card(svc):
s, bid = svc
a, b = cards(s, bid, "a", "b")
s.add_blocker(OWNER, a, b)
s.archive_card(OWNER, b)
assert s.show_card(OWNER, a)["blocked"] is False
def test_checklist_suggest_changes_nothing_then_save_keeps_it(svc):
s, bid = svc
card = cards(s, bid, "Launch")[0]
suggestion = s.suggest_checklist(MEMBER, card)["suggested_checklist"]
assert suggestion == ["step one", "step two"]
assert s.show_card(OWNER, card)["checklist"] == []
saved = s.save_checklist(MEMBER, card, suggestion[:1] + ["my own step"])
assert saved["checklist"] == [{"text": "step one", "done": False},
{"text": "my own step", "done": False}]
ticked = s.tick_checklist_item(MEMBER, card, "my own")
assert ticked["checklist"][1]["done"] is True
assert s.tick_checklist_item(MEMBER, card, "2", False)["checklist"][1]["done"] is False
def test_checklist_keeps_ticks_when_saved_as_a_whole_and_refuses_junk(svc):
s, bid = svc
card = cards(s, bid, "x")[0]
saved = s.save_checklist(OWNER, card, [{"text": "a", "done": True}])
assert saved["checklist"] == [{"text": "a", "done": True}]
with pytest.raises(Invalid):
s.save_checklist(OWNER, card, ["ok", " "])
with pytest.raises(Invalid):
s.tick_checklist_item(OWNER, card, "nothing like it")
def test_checklist_with_ai_down_says_so(svc, ai):
s, bid = svc
card = cards(s, bid, "x")[0]
ai.down = True
with pytest.raises(AssistantUnavailable):
s.suggest_checklist(OWNER, card)
def test_no_ai_configured_says_so(repo):
s = Boards(repo, None)
bid = s.create_board(OWNER, "b")["id"]
card = s.add_card(OWNER, bid, "x marks the spot")["id"]
s.add_card(OWNER, bid, "the spot")
with pytest.raises(AssistantUnavailable):
s.suggest_checklist(OWNER, card)
assert s.board_summary(OWNER, bid)["source"] == "facts"
assert s.find_similar(OWNER, card)["method"] == "keywords"
def test_similar_finds_meaning_not_just_words(svc):
s, bid = svc
invoice = cards(s, bid, "Send invoice", "Pay the bill", "Bake a cake", "Fix homepage css")[0]
found = s.find_similar(VIEWER, invoice)
assert found["method"] == "meaning"
assert found["matches"][0]["title"] == "Pay the bill"
assert found["matches"][0]["score"] == 1.0
assert all(m["number"] != 1 for m in found["matches"])
def test_similar_only_embeds_new_or_changed_text(svc, ai):
s, bid = svc
first = cards(s, bid, "Send invoice", "Pay the bill")[0]
s.find_similar(OWNER, first)
assert len(ai.embedded) == 2
s.find_similar(OWNER, first)
assert len(ai.embedded) == 2
s.update_card(OWNER, first, {"title": "Send receipt"})
s.find_similar(OWNER, first)
assert ai.embedded[-1] == "Send receipt" and len(ai.embedded) == 3
def test_similar_falls_back_to_shared_words(svc, ai):
s, bid = svc
first = cards(s, bid, "Launch the website", "Website copy", "Bake a cake")[0]
ai.down = True
found = s.find_similar(OWNER, first)
assert found["method"] == "keywords"
assert [m["title"] for m in found["matches"]] == ["Website copy"]
def test_similar_with_nothing_else_on_the_board(svc):
s, bid = svc
assert s.find_similar(OWNER, cards(s, bid, "alone")[0])["matches"] == []
def test_summary_is_built_from_the_board_and_ai_words_it(svc):
s, bid = svc
s.add_card(OWNER, bid, "Late", due_date="2026-06-01")
s.add_card(OWNER, bid, "Tomorrow", due_date="2026-06-11")
a, b = cards(s, bid, "Stuck", "Reason")
s.add_blocker(OWNER, a, b)
out = s.board_summary(VIEWER, bid)
assert out["source"] == "ai" and out["summary"].startswith("AI summary: Board 'Launch'")
assert out["facts"]["overdue"] == ["#1 Late"]
assert out["facts"]["due_soon"] == ["#2 Tomorrow"]
assert out["facts"]["blocked"] == ["#3 Stuck"]
assert out["facts"]["lists"]["To do"] == 4
assert out["facts"]["changes_last_24h"] > 4
def test_summary_without_ai_is_still_a_summary(svc, ai):
s, bid = svc
s.add_card(OWNER, bid, "Late", due_date="2026-06-01")
ai.down = True
out = s.board_summary(OWNER, bid)
assert out["source"] == "facts"
assert "Overdue: #1 Late." in out["summary"]
def test_ai_actions_keep_the_roles(svc):
s, bid = svc
card = cards(s, bid, "x")[0]
with pytest.raises(Forbidden):
s.suggest_checklist(VIEWER, card)
for fn in (s.suggest_checklist, s.find_similar):
with pytest.raises(NotFound):
fn(STRANGER, card)
with pytest.raises(NotFound):
s.board_summary(STRANGER, bid)