theaio/theaio-boards

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

A remix template · 0 remixes · Remix theaio/theaio-boards

tests/test_domain.py

"""The domain alone: no repository, no doors."""
from datetime import date

import pytest

from boards.domain.model import (Board, Card, Forbidden, Invalid, Person, Role, blocking_path,
                                 clean_labels, parse_due, reorder)

ANA, BO, CY = Person("ana@x.io"), Person("bo@x.io"), Person("cy@x.io")
TODAY = date(2026, 11, 2)


def board():
    b = Board.new("b1", "Launch", ANA, ["l1", "l2", "l3"])
    b.members = {BO.email: Role.MEMBER, CY.email: Role.VIEWER}
    return b


def test_a_new_board_has_three_ordered_lists_and_the_last_is_done():
    b = board()
    assert [l.name for l in b.ordered()] == ["To do", "Doing", "Done"]
    assert b.done_list.name == "Done"


def test_roles():
    b = board()
    b.require_edit(BO)
    with pytest.raises(Forbidden):
        b.require_edit(CY)
    with pytest.raises(Forbidden):
        b.require_view(Person("dee@x.io"))
    with pytest.raises(Forbidden):
        b.invite(BO, "eve@x.io", Role.MEMBER)
    with pytest.raises(Invalid):
        b.invite(ANA, "eve@x.io", Role.OWNER)
    with pytest.raises(Invalid):
        b.remove(ANA, ANA.email)


def test_assignee_must_be_owner_or_member():
    b = board()
    b.require_assignable(BO.email)
    b.require_assignable(ANA.email)
    with pytest.raises(Invalid, match="viewer"):
        b.require_assignable(CY.email)
    with pytest.raises(Invalid, match="not on this board"):
        b.require_assignable("dee@x.io")


def test_card_update_reports_only_real_changes():
    c = Card("b1:1", "b1", 1, "l1", "Write copy")
    changes = c.update(title="Write copy", labels=["copy", " Copy ", "web"], due_date=TODAY)
    assert [(ch.field, ch.old, ch.new) for ch in changes] == [
        ("labels", "", "copy, web"), ("due_date", "", "2026-11-02")]


def test_list_names_are_unique_and_lists_reorder():
    b = board()
    with pytest.raises(Invalid):
        b.add_list(BO, "l4", "doing")
    review = b.add_list(BO, "l4", "Review")            # goes before the done list
    assert [l.name for l in b.ordered()] == ["To do", "Doing", "Review", "Done"]
    b.move_list(BO, review, 0)
    assert [l.name for l in b.ordered()] == ["Review", "To do", "Doing", "Done"]


def test_parse_due():
    assert parse_due("2026-12-01", TODAY) == date(2026, 12, 1)
    assert parse_due("tomorrow", TODAY) == date(2026, 11, 3)
    assert parse_due("+7", TODAY) == date(2026, 11, 9)
    assert parse_due("in 3 days", TODAY) == date(2026, 11, 5)
    assert parse_due("", TODAY) is None
    with pytest.raises(Invalid):
        parse_due("next-ish", TODAY)


def test_labels_are_limited():
    with pytest.raises(Invalid):
        clean_labels([f"l{i}" for i in range(9)])
    with pytest.raises(Invalid):
        clean_labels(["x" * 25])


def test_reorder_renumbers():
    class P:
        def __init__(self, n):
            self.n, self.pos = n, 0
    a, b, c = P("a"), P("b"), P("c")
    assert [i.n for i in reorder([a, b, c], c, 0)] == ["c", "a", "b"]
    assert (c.pos, a.pos, b.pos) == (0, 1, 2)


def test_blocking_path_finds_loops():
    links = [("2", "1"), ("3", "2")]          # 2 waits on 1, 3 waits on 2
    assert blocking_path(links, "1", "3") == ["3", "2", "1"]   # 1 waiting on 3 closes a loop
    assert blocking_path(links, "4", "3") is None
    assert blocking_path(links, "1", "1") == ["1"]