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"]