eduard/cobra-boards

Cobra Boards, team task boards you can run from Claude or ChatGPT. FastAPI + DataK3 + appid + Dodil Models + MCP.

A remix template · 1 remixes · Remix eduard/cobra-boards

tests/test_rules.py

"""The rest of the board's rules, one at a time: the words people use for dates, the checks on
every field, who may touch members and lists, the history and version of every write, blockers in
odd shapes, checklists, and the AI extras when the model is slow, wrong or missing."""
from datetime import date, timedelta

import pytest
from conftest import MEMBER, OWNER, STRANGER, TODAY, VIEWER, FakeAI

from boards.application.use_cases import Boards, NotFound, parse_due
from boards.domain.model import (
    Conflict,
    Forbidden,
    Invalid,
    changes_between,
    clean_labels,
    describe,
    would_cycle,
)
from boards.infrastructure.memory import MemoryBoards


def board_service(assistant=None, repo=None):
    s = Boards(repo or MemoryBoards(), assistant, today=lambda: TODAY)
    bid = s.create_board(OWNER, "Launch")["id"]
    s.invite(OWNER, bid, MEMBER.email, "member")
    s.invite(OWNER, bid, VIEWER.email, "viewer")
    return s, bid


# ---------------------------------------------------------------- dates in words
@pytest.mark.parametrize("words, days", [
    ("today", 0), ("Tomorrow", 1), ("next week", 7), ("in a week", 7), ("+3d", 3), ("3d", 3),
    ("10 days", 10), ("1 day", 1)])
def test_dates_in_words(words, days):
    assert parse_due(words, TODAY) == TODAY + timedelta(days=days)


@pytest.mark.parametrize("value", [None, "", "none", "No date", "clear", "remove"])
def test_words_that_clear_a_date(value):
    assert parse_due(value, TODAY) is None


@pytest.mark.parametrize("value", ["someday", "next month", "32/13/2026", "2026-13-01", "soon"])
def test_dates_that_are_not_dates_are_refused(value):
    with pytest.raises(Invalid, match="is not a date"):
        parse_due(value, TODAY)


def test_iso_dates_and_date_objects_pass_through():
    assert parse_due("2026-12-01", TODAY) == date(2026, 12, 1)
    assert parse_due("2026-12-01T09:00:00Z", TODAY) == date(2026, 12, 1)
    assert parse_due(date(2027, 1, 2), TODAY) == date(2027, 1, 2)


# ---------------------------------------------------------------- members
@pytest.mark.parametrize("email", ["", "nope", "@cobra.test", "eve@", "eve smith@cobra.test"])
def test_invite_refuses_what_is_not_an_email(svc, email):
    s, bid = svc
    with pytest.raises(Invalid, match="not an email"):
        s.invite(OWNER, bid, email, "member")


def test_invite_tidies_the_email(svc):
    s, bid = svc
    assert s.invite(OWNER, bid, "  EVE@Cobra.Test ", "viewer")["email"] == "eve@cobra.test"


@pytest.mark.parametrize("role", ["admin", "boss", ""])
def test_invite_takes_only_member_or_viewer(svc, role):
    s, bid = svc
    with pytest.raises(Invalid):
        s.invite(OWNER, bid, "eve@cobra.test", role)


def test_the_owner_cannot_be_invited_or_removed(svc):
    s, bid = svc
    with pytest.raises(Invalid):
        s.invite(OWNER, bid, OWNER.email, "viewer")
    with pytest.raises(Invalid):
        s.remove_member(OWNER, bid, OWNER.email)
    with pytest.raises(Invalid, match="not on this board"):
        s.remove_member(OWNER, bid, STRANGER.email)


def test_changing_a_role_is_written_down(svc):
    s, bid = svc
    s.invite(OWNER, bid, VIEWER.email, "member")
    summaries = [e["summary"] for e in s.board_activity(OWNER, bid)]
    assert f"changed {VIEWER.email} from viewer to member" in summaries
    assert f"added {MEMBER.email} as member" in summaries
    assert "created the board 'Launch'" in summaries


def test_a_removed_member_invited_back_sees_the_board_again(svc):
    s, bid = svc
    s.remove_member(OWNER, bid, MEMBER.email)
    with pytest.raises(NotFound):
        s.show_board(MEMBER, bid)
    s.invite(OWNER, bid, MEMBER.email, "member")
    assert s.show_board(MEMBER, bid)["role"] == "member"


def test_a_stranger_cannot_even_reach_the_board_to_ask(svc):
    s, bid = svc
    for attempt in (lambda: s.invite(STRANGER, bid, "eve@cobra.test", "member"),
                    lambda: s.add_card(STRANGER, bid, "x"),
                    lambda: s.add_list(STRANGER, bid, "x"),
                    lambda: s.board_activity(STRANGER, bid),
                    lambda: s.daily_summary(STRANGER, bid)):
        with pytest.raises(NotFound):  # 404, never 403: the board doesn't exist for them
            attempt()


# ---------------------------------------------------------------- boards
def test_a_new_board_has_three_lists_and_done_is_finished(svc):
    s, _ = svc
    b = s.create_board(MEMBER, "Ops")
    view = s.show_board(MEMBER, b["id"])
    assert [(l["name"], l["is_done"]) for l in view["lists"]] == [
        ("To do", False), ("Doing", False), ("Done", True)]
    assert view["role"] == "owner" and view["owner"] == MEMBER.email


@pytest.mark.parametrize("title", ["", "   ", "x" * 81])
def test_board_titles_are_checked(svc, title):
    s, bid = svc
    with pytest.raises(Invalid):
        s.create_board(OWNER, title)
    with pytest.raises(Invalid):
        s.rename_board(OWNER, bid, title)


def test_the_owner_renames_the_board_and_it_is_logged(svc):
    s, bid = svc
    assert s.rename_board(OWNER, bid, "Launch 2027")["title"] == "Launch 2027"
    assert s.show_board(VIEWER, "launch 2027")["id"] == bid
    assert any("renamed the board" in e["summary"] for e in s.board_activity(OWNER, bid))


def test_a_board_is_found_by_part_of_its_title(svc):
    s, bid = svc
    assert s.show_board(MEMBER, "laun")["id"] == bid


def test_an_exact_title_wins_over_a_partial_one(svc):
    s, _ = svc
    ops = s.create_board(OWNER, "Ops")
    s.create_board(OWNER, "Ops south")
    assert s.show_board(OWNER, "ops")["id"] == ops["id"]


def test_an_empty_board_name_is_a_question(svc):
    s, _ = svc
    with pytest.raises(Invalid, match="which board"):
        s.show_board(OWNER, "  ")


# ---------------------------------------------------------------- lists
def test_list_names_forgive_case_and_spacing(svc):
    s, bid = svc
    card = s.add_card(OWNER, bid, "Plan")
    assert s.move_card(OWNER, card["id"], "  DOING ")["list"] == "Doing"


def test_lists_can_be_reordered(svc):
    s, bid = svc
    s.update_list(OWNER, bid, "Done", position=0.5)
    assert [l["name"] for l in s.show_board(VIEWER, bid)["lists"]] == ["Done", "To do", "Doing"]


def test_a_list_cannot_take_another_lists_name(svc):
    s, bid = svc
    with pytest.raises(Invalid, match="already has a list"):
        s.update_list(OWNER, bid, "Doing", name="done")
    with pytest.raises(Invalid):
        s.add_list(OWNER, bid, "x" * 61)


def test_marking_a_list_done_ends_its_overdue_cards(svc):
    s, bid = svc
    late = s.add_card(OWNER, bid, "Late", list_name="Doing", due_date="2026-11-01")
    assert s.card_detail(OWNER, late["id"])["overdue"] is True
    s.update_list(MEMBER, bid, "Doing", is_done=True)
    detail = s.card_detail(OWNER, late["id"])
    assert detail["overdue"] is False and detail["done"] is True
    assert s.overdue(OWNER) == []


def test_a_new_done_list_counts_as_finished(svc):
    s, bid = svc
    s.add_list(MEMBER, bid, "Shipped", is_done=True)
    card = s.add_card(OWNER, bid, "Old", due_date="2026-10-01")
    assert s.move_card(MEMBER, card["id"], "shipped")["done"] is True


# ---------------------------------------------------------------- card fields
def test_card_titles_up_to_200_characters(svc):
    s, bid = svc
    assert len(s.add_card(OWNER, bid, "x" * 200)["title"]) == 200
    with pytest.raises(Invalid):
        s.add_card(OWNER, bid, "card", description="x" * 5001)


def test_labels_are_tidied_and_limited(svc):
    s, bid = svc
    assert clean_labels(["Urgent", " urgent ", "Design", ""]) == ["urgent", "design"]
    with pytest.raises(Invalid, match="at most 10"):
        s.add_card(OWNER, bid, "x", labels=[f"l{i}" for i in range(11)])
    with pytest.raises(Invalid):
        s.add_card(OWNER, bid, "x", labels=["y" * 31])


def test_new_cards_go_to_the_bottom_of_their_list(svc):
    s, bid = svc
    a = s.add_card(OWNER, bid, "a", list_name="Doing")
    b = s.add_card(OWNER, bid, "b", list_name="Doing")
    c = s.add_card(OWNER, bid, "c")
    assert b["position"] > a["position"] and c["list"] == "To do"


@pytest.mark.parametrize("nobody", [None, "", "nobody", "none"])
def test_unassigning_takes_any_way_of_saying_nobody(svc, nobody):
    s, bid = svc
    card = s.add_card(OWNER, bid, "x", assignee=MEMBER.email)
    assert s.update_card(OWNER, card["id"], assignee=nobody)["assignee"] is None


def test_a_card_cannot_be_given_to_someone_off_the_board(svc):
    s, bid = svc
    card = s.add_card(OWNER, bid, "x")
    with pytest.raises(Invalid, match="not on this board"):
        s.update_card(OWNER, card["id"], assignee=STRANGER.email)


# ---------------------------------------------------------------- finding cards
def test_a_card_answers_to_its_number_its_hash_and_its_id(svc):
    s, bid = svc
    for t in "abc":
        s.add_card(OWNER, bid, t)
    assert {s.card_detail(OWNER, ref)["title"] for ref in ("3", "#3", f"{bid}-3", " #3 ")} == {"c"}


def test_an_unknown_card_is_not_found(svc):
    s, bid = svc
    with pytest.raises(NotFound):
        s.card_detail(OWNER, "99")
    with pytest.raises(NotFound):
        s.card_detail(OWNER, "nope-1")


def test_a_card_on_someone_elses_board_is_not_found(svc):
    s, _ = svc
    other = s.create_board(STRANGER, "Private")
    card = s.add_card(STRANGER, other["id"], "Secret")
    for attempt in (lambda: s.card_detail(OWNER, card["id"]),
                    lambda: s.card_history(OWNER, card["id"]),
                    lambda: s.move_card(OWNER, card["id"], "Done"),
                    lambda: s.what_is_blocking(OWNER, card["id"])):
        with pytest.raises(NotFound):
            attempt()


# ---------------------------------------------------------------- history & versions
def test_every_write_bumps_the_version_and_a_no_op_does_not(svc):
    s, bid = svc
    card = s.add_card(OWNER, bid, "Plan")
    assert card["version"] == 1
    assert s.update_card(OWNER, card["id"], title="Plan B")["version"] == 2
    assert s.move_card(OWNER, card["id"], "Doing")["version"] == 3
    assert s.update_card(OWNER, card["id"], title="Plan B")["version"] == 3


def test_history_starts_with_what_the_card_was_created_as(svc):
    s, bid = svc
    card = s.add_card(MEMBER, bid, "Send invoice", labels=["finance"])
    created = s.card_history(VIEWER, card["id"])[-1]
    assert created["action"] == "created" and created["actor"] == MEMBER.email
    assert created["changes"]["title"] == [None, "Send invoice"]
    assert created["changes"]["labels"] == [None, ["finance"]]


def test_history_tells_a_move_from_an_edit(svc):
    s, bid = svc
    card = s.add_card(OWNER, bid, "Plan")
    s.move_card(OWNER, card["id"], "Doing")
    s.update_card(OWNER, card["id"], description="Details")
    actions = [e["action"] for e in s.card_history(OWNER, card["id"])]
    assert actions == ["updated", "moved", "created"]


def test_reordering_inside_a_list_is_recorded_as_a_reorder(svc):
    s, bid = svc
    a = s.add_card(OWNER, bid, "a")
    b = s.add_card(OWNER, bid, "b")
    s.move_card(MEMBER, b["id"], "To do", before_card=a["id"])
    last = s.card_history(OWNER, b["id"])[0]
    assert last["action"] == "moved" and "reordered" in last["summary"]
    todo = s.show_board(OWNER, bid)["lists"][0]["cards"]
    assert [c["title"] for c in todo] == ["b", "a"]


def test_dropping_above_a_card_that_is_in_another_list_is_refused(svc):
    s, bid = svc
    a = s.add_card(OWNER, bid, "a")
    b = s.add_card(OWNER, bid, "b")
    with pytest.raises(Invalid, match="is not in Doing"):
        s.move_card(OWNER, b["id"], "Doing", before_card=a["id"])


def test_a_stale_move_is_refused_too(svc):
    s, bid = svc
    card = s.add_card(OWNER, bid, "Plan")
    s.update_card(MEMBER, card["id"], title="Plan v2")
    with pytest.raises(Conflict, match="changed since you opened it"):
        s.move_card(OWNER, card["id"], "Done", expected_version=card["version"])
    assert s.card_detail(OWNER, card["id"])["list"] == "To do"


def test_a_write_lost_in_the_database_race_is_a_conflict_and_leaves_no_history(svc):
    s, bid = svc
    card = s.add_card(OWNER, bid, "Plan")
    s.repo.update_card = lambda *a: False  # someone else's UPDATE ... WHERE version won
    with pytest.raises(Conflict, match="someone else just now"):
        s.update_card(OWNER, card["id"], title="Mine")
    assert len(s.card_history(OWNER, card["id"])) == 1
    assert s.card_detail(OWNER, card["id"])["title"] == "Plan"


def test_two_people_adding_at_once_never_share_a_number():
    class Racing(MemoryBoards):
        """The first claim hits a number someone else just took, like a real race."""
        race = True

        def next_number(self, board_id):
            if self.race and self.cards:
                self.race = False
                return 1
            return super().next_number(board_id)

    s, bid = board_service(repo=Racing())
    s.add_card(OWNER, bid, "first")
    second = s.add_card(MEMBER, bid, "second")
    assert second["number"] == 2
    assert s.card_detail(OWNER, "1")["title"] == "first"  # the winner was not overwritten
    assert [e.card_id for e in s.repo.events if e.action == "created"] == [f"{bid}-1", f"{bid}-2"]


# ---------------------------------------------------------------- my work, overdue, search
def test_my_week_horizon_can_be_widened(svc):
    s, bid = svc
    s.add_card(OWNER, bid, "In 15 days", assignee=MEMBER.email, due_date="2026-11-25")
    assert [c["title"] for c in s.my_cards(MEMBER, 7)["later"]] == ["In 15 days"]
    assert [c["title"] for c in s.my_cards(MEMBER, 30)["due_soon"]] == ["In 15 days"]


def test_my_week_spans_all_my_boards_in_due_order(svc):
    s, bid = svc
    ops = s.create_board(OWNER, "Ops")
    s.invite(OWNER, ops["id"], MEMBER.email, "member")
    s.add_card(OWNER, ops["id"], "Ops task", assignee=MEMBER.email, due_date="2026-11-12")
    s.add_card(OWNER, bid, "Launch task", assignee=MEMBER.email, due_date="2026-11-11")
    soon = s.my_cards(MEMBER)["due_soon"]
    assert [(c["board"], c["title"]) for c in soon] == [("Launch", "Launch task"),
                                                         ("Ops", "Ops task")]


def test_overdue_on_one_board_only(svc):
    s, bid = svc
    ops = s.create_board(OWNER, "Ops")
    s.add_card(OWNER, ops["id"], "Late on ops", due_date="2026-11-01")
    s.add_card(OWNER, bid, "Late on launch", due_date="2026-11-01")
    assert [c["title"] for c in s.overdue(OWNER, "Ops")] == ["Late on ops"]
    assert len(s.overdue(OWNER)) == 2


@pytest.mark.parametrize("query, title", [
    ("invoice", "Send invoice"), ("INVOICE", "Send invoice"), ("net 30", "Send invoice"),
    ("finance", "Send invoice"), ("#2", "Design the logo")])
def test_search_looks_at_title_description_labels_and_number(svc, query, title):
    s, bid = svc
    s.add_card(OWNER, bid, "Send invoice", description="Net 30", labels=["finance"])
    s.add_card(OWNER, bid, "Design the logo")
    assert [c["title"] for c in s.search_cards(VIEWER, query)] == [title]


def test_search_never_looks_into_other_peoples_boards(svc):
    s, bid = svc
    other = s.create_board(STRANGER, "Private")
    s.add_card(STRANGER, other["id"], "Invoice the secret client")
    s.add_card(OWNER, bid, "Send invoice")
    assert [c["title"] for c in s.search_cards(OWNER, "invoice")] == ["Send invoice"]
    with pytest.raises(Invalid):
        s.search_cards(OWNER, "  ")


# ---------------------------------------------------------------- blockers
def test_adding_the_same_blocker_twice_changes_nothing(svc):
    s, bid = svc
    a, b = s.add_card(OWNER, bid, "a"), s.add_card(OWNER, bid, "b")
    s.add_blocker(OWNER, a["id"], b["id"])
    assert s.add_blocker(MEMBER, a["id"], b["id"])["already"] is True
    assert [e["action"] for e in s.card_history(OWNER, a["id"])].count("blocked") == 1


def test_a_blocker_can_be_removed(svc):
    s, bid = svc
    a, b = s.add_card(OWNER, bid, "a"), s.add_card(OWNER, bid, "b")
    s.add_blocker(OWNER, a["id"], b["id"])
    assert s.card_detail(OWNER, b["id"])["blocks"] == [1]
    s.remove_blocker(MEMBER, a["id"], b["id"])
    assert s.card_detail(OWNER, a["id"])["blocked_by"] == []
    assert s.card_history(OWNER, a["id"])[0]["action"] == "unblocked"


def test_a_finished_blocker_stops_blocking_but_is_still_shown(svc):
    s, bid = svc
    ship, qa = s.add_card(OWNER, bid, "Ship"), s.add_card(OWNER, bid, "QA")
    s.add_blocker(OWNER, ship["id"], qa["id"])
    s.move_card(OWNER, qa["id"], "Done")
    detail = s.card_detail(OWNER, ship["id"])
    assert detail["blocked_by"] == []
    assert detail["blocked_by_all"] == [{"number": 2, "title": "QA", "done": True}]
    assert s.what_is_blocking(OWNER, ship["id"])["blocked"] is False


def test_a_shared_blocker_is_listed_once_and_is_where_to_start(svc):
    s, bid = svc
    a, b, c, d = (s.add_card(OWNER, bid, t) for t in "abcd")
    s.add_blocker(OWNER, a["id"], b["id"])
    s.add_blocker(OWNER, a["id"], c["id"])
    s.add_blocker(OWNER, b["id"], d["id"])
    s.add_blocker(OWNER, c["id"], d["id"])
    out = s.what_is_blocking(OWNER, a["id"])
    assert sorted(x["number"] for x in out["chain"]) == [2, 3, 4]
    assert [x["number"] for x in out["start_with"]] == [4]


@pytest.mark.parametrize("edges, new, loops", [
    ({}, ("a", "a"), True),
    ({"b": {"a"}}, ("a", "b"), True),
    ({"b": {"c"}, "c": {"a"}}, ("a", "b"), True),
    ({"b": {"c"}}, ("a", "b"), False),
    ({"a": {"b"}}, ("c", "a"), False)])
def test_loop_detection(edges, new, loops):
    assert would_cycle(new[0], new[1], edges) is loops


# ---------------------------------------------------------------- viewers on every door
def test_a_viewer_can_read_everything(svc):
    s, bid = svc
    a, b = s.add_card(OWNER, bid, "Send invoice"), s.add_card(OWNER, bid, "Chase bill")
    s.add_blocker(OWNER, a["id"], b["id"])
    assert s.card_history(VIEWER, a["id"]) and s.board_activity(VIEWER, bid)
    assert s.what_is_blocking(VIEWER, a["id"])["blocked"] is True
    assert s.daily_summary(VIEWER, bid)["facts"]["blocked"] == [1]
    assert s.similar_cards(VIEWER, a["id"])["cards"]
    assert s.search_cards(VIEWER, "bill")


def test_a_viewer_can_change_nothing_anywhere(svc):
    s, bid = svc
    a, b = s.add_card(OWNER, bid, "a"), s.add_card(OWNER, bid, "b")
    s.add_blocker(OWNER, a["id"], b["id"])
    item = s.add_checklist_items(OWNER, a["id"], ["step"])["checklist"][0]
    for attempt in (lambda: s.add_blocker(VIEWER, b["id"], a["id"]),
                    lambda: s.remove_blocker(VIEWER, a["id"], b["id"]),
                    lambda: s.set_checklist_item(VIEWER, a["id"], item["id"], done=True),
                    lambda: s.update_list(VIEWER, bid, "Doing", name="Busy"),
                    lambda: s.rename_board(VIEWER, bid, "Mine"),
                    lambda: s.remove_member(VIEWER, bid, MEMBER.email),
                    lambda: s.update_card(VIEWER, a["id"], assignee="me")):
        with pytest.raises(Forbidden):
            attempt()
    assert s.card_detail(OWNER, a["id"])["checklist"][0]["done"] is False


def test_members_edit_cards_and_lists_but_not_the_board(svc):
    s, bid = svc
    s.add_list(MEMBER, bid, "Ideas")
    s.update_list(MEMBER, bid, "Ideas", name="Backlog")
    card = s.add_card(MEMBER, bid, "Mine", list_name="backlog")
    assert card["list"] == "Backlog"
    with pytest.raises(Forbidden):
        s.rename_board(MEMBER, bid, "Ours")


# ---------------------------------------------------------------- checklists
def test_checklist_steps_are_checked(svc):
    s, bid = svc
    card = s.add_card(OWNER, bid, "Launch page")
    for bad in ([], ["  "], ["x" * 201]):
        with pytest.raises(Invalid):
            s.add_checklist_items(OWNER, card["id"], bad)
    s.add_checklist_items(OWNER, card["id"], [f"step {i}" for i in range(40)])
    with pytest.raises(Invalid, match="at most 40"):
        s.add_checklist_items(OWNER, card["id"], ["one more"])


def test_a_step_can_be_ticked_unticked_edited_and_removed(svc):
    s, bid = svc
    card = s.add_card(OWNER, bid, "Launch page")
    item = s.add_checklist_items(OWNER, card["id"], ["Draft it", "Ship it"])["checklist"][0]
    s.set_checklist_item(MEMBER, card["id"], item["id"], done=True)
    s.set_checklist_item(MEMBER, card["id"], item["id"], done=False)
    s.set_checklist_item(MEMBER, card["id"], item["id"], text="Draft it twice")
    steps = s.card_detail(OWNER, card["id"])["checklist"]
    assert [(i["text"], i["done"]) for i in steps] == [("Draft it twice", False), ("Ship it", False)]
    s.set_checklist_item(MEMBER, card["id"], item["id"], remove=True)
    assert [i["text"] for i in s.card_detail(OWNER, card["id"])["checklist"]] == ["Ship it"]
    summaries = [e["summary"] for e in s.card_history(OWNER, card["id"])]
    assert "#1: checked 'Draft it'" in summaries and "#1: unchecked 'Draft it'" in summaries
    with pytest.raises(NotFound):
        s.set_checklist_item(MEMBER, card["id"], item["id"], done=True)


# ---------------------------------------------------------------- AI: missing, failing, cached
def test_without_an_ai_the_board_still_works_and_says_so():
    s, bid = board_service(assistant=None)
    card = s.add_card(OWNER, bid, "Late", due_date="2026-11-01")
    with pytest.raises(Invalid, match="no AI"):
        s.suggest_checklist(OWNER, card["id"])
    with pytest.raises(Invalid, match="no AI"):
        s.similar_cards(OWNER, card["id"])
    out = s.daily_summary(OWNER, bid)
    assert out["summary"].startswith("Date: 2026-11-10") and "#1 Late" in out["summary"]


def test_a_failing_model_falls_back_to_the_plain_facts():
    class Broken(FakeAI):
        def summary(self, board_title, facts):
            raise TimeoutError("the model took too long")

    s, bid = board_service(assistant=Broken())
    s.add_card(OWNER, bid, "Due now", due_date="today")
    out = s.daily_summary(MEMBER, bid)
    assert "Due today: #1 Due now" in out["summary"] and out["facts"]["due_today"] == [1]


def test_suggested_steps_are_trimmed_and_capped():
    class Chatty(FakeAI):
        def checklist(self, title, description):
            return ["  First  ", "", *[f"step {i}" for i in range(20)]]

    s, bid = board_service(assistant=Chatty())
    card = s.add_card(OWNER, bid, "Plan")
    steps = s.suggest_checklist(OWNER, card["id"])["suggested_steps"]
    assert steps[0] == "First" and "" not in steps and len(steps) == 8


def test_cards_are_embedded_once_and_again_only_when_their_text_changes():
    class Counting(FakeAI):
        calls = 0

        def embed(self, text):
            Counting.calls += 1
            return super().embed(text)

    s, bid = board_service(assistant=Counting())
    first = s.add_card(OWNER, bid, "Send invoice")
    s.add_card(OWNER, bid, "Design the logo")
    s.add_card(OWNER, bid, "Deploy the website")
    s.similar_cards(OWNER, text="logo")
    assert Counting.calls == 4  # three cards + the question
    s.similar_cards(OWNER, text="logo")
    assert Counting.calls == 5  # only the question
    s.update_card(OWNER, first["id"], title="Send the bill")
    s.similar_cards(OWNER, text="logo")
    assert Counting.calls == 7  # the changed card + the question


def test_similar_by_text_respects_the_limit_and_needs_something_to_compare(svc):
    s, bid = svc
    for t in ("Send invoice", "Chase unpaid bill", "Pay the invoice", "Design the logo"):
        s.add_card(OWNER, bid, t)
    out = s.similar_cards(MEMBER, text="invoice payment", limit=2)
    assert len(out["cards"]) == 2 and "invoice" in out["cards"][0]["title"].lower()
    with pytest.raises(Invalid, match="similar to which card"):
        s.similar_cards(MEMBER, text="  ")


def test_the_briefing_counts_lists_blocked_and_due_today(svc):
    s, bid = svc
    ship = s.add_card(OWNER, bid, "Ship", list_name="Doing")
    qa = s.add_card(OWNER, bid, "QA", due_date="today")
    s.add_blocker(OWNER, ship["id"], qa["id"])
    facts = s.daily_summary(OWNER, bid)["facts"]
    assert facts["per_list"] == {"To do": 1, "Doing": 1, "Done": 0}
    assert facts["blocked"] == [1] and facts["due_today"] == [2] and facts["overdue"] == []


# ---------------------------------------------------------------- the sample board
def test_the_sample_board_shows_every_feature(svc):
    s, _ = svc
    bid = s.first_visit(STRANGER)[0]["id"]
    view = s.show_board(STRANGER, bid)
    assert view["is_owner"] and view["members"] == [{"email": STRANGER.email, "role": "owner"}]
    assert len(s.overdue(STRANGER)) == 2
    qa = s.card_detail(STRANGER, "2", bid)
    assert sum(i["done"] for i in qa["checklist"]) == 2 and len(qa["checklist"]) == 4
    blocking = s.what_is_blocking(STRANGER, "1", bid)
    assert {c["title"] for c in blocking["start_with"]} == {"Fix the mobile login bug",
                                                            "Security review"}
    assert s.my_cards(STRANGER)["overdue"]


# ---------------------------------------------------------------- the words of the history
@pytest.mark.parametrize("changes, words", [
    ({"list": ["To do", "Done"]}, "moved from To do to Done"),
    ({"assignee": [None, "bo@cobra.test"]}, "assigned to bo@cobra.test"),
    ({"assignee": ["bo@cobra.test", None]}, "unassigned bo@cobra.test"),
    ({"due_date": [None, "2026-11-12"]}, "due 2026-11-12"),
    ({"due_date": ["2026-11-12", None]}, "removed the due date"),
    ({"labels": [["a"], []]}, "labels: none"),
    ({"title": ["a", "b"]}, "renamed to 'b'"),
    ({"description": ["", "x"]}, "edited the description")])
def test_history_reads_like_a_person_wrote_it(changes, words):
    assert describe(changes) == words


def test_changes_keep_only_what_changed():
    assert changes_between({"a": 1, "b": 2}, {"a": 1, "b": 3}) == {"b": [2, 3]}
    assert changes_between({"a": 1}, {"a": 1}) == {}