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)