dodil/erp-brick-projects
tests/test_rules.py
"""The business rules, with no database — one test (or more) per story in module.yaml, named for it, written
from its Given/When/Then. Time, settings and the calendar are handed in, as the domain takes them."""
from datetime import date, datetime
import pytest
from brick_projects.domain.errors import RuleBroken
from brick_projects.domain.item import NO_EVIDENCE, Item, calendar_of, working_days_between
from brick_projects.domain.project import SoldLine, plan_sale, reconcile_lines
from brick_projects.domain.state import State
from brick_projects.domain.values import Settings, check_setting, settings_from_rows
S = Settings()
CAL = calendar_of(S)
MON = datetime(2026, 11, 2, 9, 0) # submitted on Monday 2 November 2026
def sale(*lines, settings=S, **kw):
return plan_sale(source_kind=kw.pop("source_kind", "contract"), source_id=kw.pop("source_id", "C-100"),
bp_id=7311, starts_on=date(2026, 11, 1), ends_on=date(2026, 12, 31), lines=list(lines),
settings=settings, owner="lena@agency.example", by="lena@agency.example", **kw)
PHASES = SoldLine("L1", "Implementation phase", 2, "one_off", "single")
HOSTING = SoldLine("L2", "Hosting month", 12, "recurring", "periodic")
MAINTENANCE = SoldLine("L3", "Maintenance month", 5, "recurring", "periodic")
INSTALMENTS = SoldLine("L4", "Implementation", 1, "one_off", "periodic")
def in_review(**kw) -> Item:
it = Item("pj:contract:C-100|L1|1", project_id="pj:contract:C-100", billing_kind="one_off",
billing_plan="single", title="Implementation phase (1 of 2)", **kw)
it.start()
it.submit(now=MON)
return it
# ── PJ-01, PJ-20, PJ-21: what was sold becomes the plan ─────────────────────────────────────
def test_pj_01_only_delivered_work_becomes_items_each_with_its_sold_line():
project, items = sale(PHASES, HOSTING, MAINTENANCE)
assert project.project_id == "pj:contract:C-100" and project.bp_id == 7311
assert [i.item_id for i in items] == ["pj:contract:C-100|L1|1", "pj:contract:C-100|L1|2"]
assert {i.line_id for i in items} == {"L1"}
assert [i.title for i in items] == ["Implementation phase (1 of 2)", "Implementation phase (2 of 2)"]
def test_pj_01_planning_again_names_the_same_project_and_items():
first, again = sale(PHASES, HOSTING), sale(PHASES, HOSTING)
assert first[0].project_id == again[0].project_id
assert [i.item_id for i in first[1]] == [i.item_id for i in again[1]]
def test_pj_01_a_sale_with_nothing_to_deliver_opens_no_project():
assert sale(HOSTING, MAINTENANCE) == (None, [])
def test_pj_01_instalments_are_one_item_and_the_rule_is_the_customers():
_, items = sale(INSTALMENTS)
assert len(items) == 1
per_unit = Settings(expand={"one_off/periodic": "per_unit", "one_off/single": "per_unit"})
_, items = sale(SoldLine("L4", "Workshop", 3, "one_off", "periodic"), settings=per_unit)
assert len(items) == 3
def test_pj_20_the_key_is_the_sale():
project, _ = sale(PHASES)
assert project.project_id == "pj:contract:C-100"
def test_pj_21_an_order_is_a_sale_too():
project, items = sale(PHASES, source_kind="sales_order", source_id="SO-2026-0007")
assert project.project_id == "pj:sales_order:SO-2026-0007"
assert all(i.source_kind == "sales_order" for i in items)
@pytest.mark.parametrize("lines,why", [
([SoldLine("", "x", 1, "one_off", "single")], "every sold line needs its line_id"),
([PHASES, PHASES], "line L1 is sent twice"),
([SoldLine("L|1", "x", 1, "one_off", "single")], "cannot contain '|'"),
([SoldLine("L9", "x", 0, "one_off", "single")], "sells nothing"),
])
def test_pj_20_a_malformed_sale_is_refused_in_words(lines, why):
with pytest.raises(RuleBroken, match=why.replace("|", r"\|")):
sale(*lines)
def test_pj_20_unknown_kind_of_sale_is_refused():
with pytest.raises(RuleBroken, match="a sale is a contract"):
sale(PHASES, source_kind="quote")
# ── PJ-02: waiting on the client is not our slippage ────────────────────────────────────────
def test_pj_02_blocked_days_are_added_and_slippage_leaves_them_out():
it = Item("i", due_on=date(2026, 11, 10))
it.start()
it.block("waiting for brief", now=datetime(2026, 11, 3, 10))
assert (it.state, it.blocked_reason, it.blocked_since) == ("blocked", "waiting for brief", date(2026, 11, 3))
it.unblock(now=datetime(2026, 11, 7, 10))
assert it.blocked_days == 4 and it.blocked_reason == "" and it.blocked_since is None
# 14 Nov is 4 days past the due date, all of them the client's
assert it.slippage_days(datetime(2026, 11, 14, 9)) == 0
assert it.slippage_days(datetime(2026, 11, 16, 9)) == 2
def test_pj_02_blocking_without_a_reason_is_refused():
it = Item("i")
it.start()
with pytest.raises(RuleBroken, match="say what you are waiting for"):
it.block(" ", now=MON)
# ── PJ-03, PJ-11: the review clock, the warning, deemed acceptance ──────────────────────────
def test_pj_11_the_clock_shows_days_left_and_sends_nothing():
it = in_review()
clock = it.review_clock(datetime(2026, 11, 12, 9), CAL, S) # 8 working days in
assert clock["window_ends_on"] == "2026-11-16" and clock["working_days_left"] == 2
assert clock["warning_due"] is False and clock["deemed_possible_on"] is None
assert it.review_clock(datetime(2026, 11, 17, 9), CAL, S)["warning_due"] is True
assert it.events[-1].kind == "submit" # reading the clock recorded nothing
def test_pj_03_warning_only_after_the_window():
it = in_review(evidence="https://x.example/live", evidence_kind="link")
with pytest.raises(RuleBroken, match="until 2026-11-16"):
it.send_warning(now=datetime(2026, 11, 16, 9), cal=CAL, settings=S)
it.send_warning(now=datetime(2026, 11, 17, 9), cal=CAL, settings=S)
assert it.warned_at == datetime(2026, 11, 17, 9)
assert it.send_warning(now=datetime(2026, 11, 18, 9), cal=CAL, settings=S) is None # once
def test_pj_03_deemed_only_after_the_warnings_notice_and_recorded_as_deemed():
it = in_review(evidence="https://x.example/live", evidence_kind="link")
with pytest.raises(RuleBroken, match="warn the client first"):
it.accept_deemed(now=datetime(2026, 11, 30, 9), cal=CAL, settings=S)
it.send_warning(now=datetime(2026, 11, 17, 9), cal=CAL, settings=S)
with pytest.raises(RuleBroken, match="runs until 2026-11-24"):
it.accept_deemed(now=datetime(2026, 11, 23, 9), cal=CAL, settings=S)
it.accept_deemed(now=datetime(2026, 11, 24, 9), cal=CAL, settings=S, by="lena@agency.example")
assert (it.state, it.approved_how, it.approved_by) == ("done", "deemed", "lena@agency.example")
def test_pj_03_where_deemed_acceptance_is_off_it_is_refused():
it = in_review(evidence="e", evidence_kind="link")
off = Settings(deemed_acceptance=False)
it.send_warning(now=datetime(2026, 11, 17, 9), cal=CAL, settings=off)
with pytest.raises(RuleBroken, match="switched off"):
it.accept_deemed(now=datetime(2026, 12, 1, 9), cal=CAL, settings=off)
def test_pj_03_deemed_acceptance_needs_evidence_too():
it = in_review()
it.send_warning(now=datetime(2026, 11, 17, 9), cal=CAL, settings=S)
with pytest.raises(RuleBroken, match=NO_EVIDENCE):
it.accept_deemed(now=datetime(2026, 11, 24, 9), cal=CAL, settings=S)
# ── PJ-04: done needs proof ─────────────────────────────────────────────────────────────────
def test_pj_04_acceptance_without_evidence_is_refused_and_with_it_records_who_when_how():
it = in_review()
with pytest.raises(RuleBroken, match=NO_EVIDENCE):
it.accept(now=MON, settings=S)
assert it.state == "in_review"
it.attach_evidence("https://x.example/live", "link", S)
it.accept(now=datetime(2026, 11, 5, 9), settings=S, by="lena@agency.example")
assert (it.state, it.approved_how, it.approved_by) == ("done", "recorded", "lena@agency.example")
assert it.approved_at == it.delivered_at == datetime(2026, 11, 5, 9)
def test_pj_04_evidence_is_of_a_kind_the_business_allows():
it = in_review()
with pytest.raises(RuleBroken, match="evidence is one of: link, file, sign_off"):
it.attach_evidence("a photo", "polaroid", S)
# ── PJ-07: makegoods ────────────────────────────────────────────────────────────────────────
def test_pj_07_a_makegood_has_no_sold_line_and_the_short_item_stays_short():
short = Item("pj:contract:C-100|L1|2", project_id="pj:contract:C-100", line_id="L1", title="Phase 2")
mg = Item.makegood(short, project_closed=True, by="lena@agency.example")
assert mg.item_id == "pj:contract:C-100|mg|pj:contract:C-100|L1|2"
assert (mg.source, mg.line_id, mg.makegood_for, mg.state) == ("makegood", "", short.item_id, "planned")
assert short.state == "planned"
def test_pj_07_only_after_reconciliation_and_only_for_short_sold_work():
short = Item("i", project_id="p", line_id="L1")
with pytest.raises(RuleBroken, match="once the project has been reconciled"):
Item.makegood(short, project_closed=False)
done = Item("j", project_id="p", line_id="L1", state="done")
with pytest.raises(RuleBroken, match="is not short"):
Item.makegood(done, project_closed=True)
# ── PJ-09: changes requested keep the same item ─────────────────────────────────────────────
def test_pj_09_changes_return_the_same_item_with_one_more_revision_and_stop_the_clock():
it = in_review()
it.request_changes("make the logo bigger")
assert (it.state, it.revision, it.submitted_at, it.warned_at) == ("in_progress", 1, None, None)
assert it.review_clock(MON, CAL, S) is None
assert it.request_changes("make the logo bigger") is None and it.revision == 1 # the same request again
with pytest.raises(RuleBroken, match="already requested \\('make the logo bigger'\\)"):
it.request_changes("and a new colour") # a different one is not dropped silently
assert it.changes_reason == "make the logo bigger"
planned = Item("p")
with pytest.raises(RuleBroken, match="is not in review"):
planned.request_changes("too early")
it.submit(now=MON)
with pytest.raises(RuleBroken, match="say what the client asked to change"):
it.request_changes("")
# ── PJ-10: descoping ────────────────────────────────────────────────────────────────────────
def test_pj_10_descoped_keeps_history_and_done_cannot_be_descoped():
it = Item("i")
it.descope("replaced by change order CO-3")
assert it.state == "cancelled" and it.events[-1].detail == "replaced by change order CO-3"
done = in_review(evidence="e", evidence_kind="link")
done.accept(now=MON, settings=S)
with pytest.raises(RuleBroken, match="a done item cannot be descoped"):
done.descope("no longer needed")
# ── PJ-06, PJ-12: reconciliation ────────────────────────────────────────────────────────────
def reconciled_project(states):
project, items = sale(SoldLine("L1", "Article", len(states), "one_off", "single"))
for it, state in zip(items, states):
it.state = state
project.activate()
project.reconcile(items, now=datetime(2027, 1, 1, 6))
return project, items
def test_pj_12_closes_once_and_stores_sold_delivered_short_descoped_per_line():
project, items = reconciled_project(["done"] * 4 + ["cancelled", "in_progress"])
assert project.state == "closed"
line = project.reconciliation["lines"][0]
assert line == {"line_id": "L1", "sold": 6, "delivered": 4, "short": 1, "over": 0, "descoped": 1}
assert project.reconciliation["short_items"] == ["pj:contract:C-100|L1|6"]
assert project.reconcile(items, now=datetime(2027, 1, 2, 6)) is None # once
def test_pj_06_only_accepted_counts_as_delivered_and_the_identity_holds():
_, items = sale(SoldLine("L1", "Article", 5, "one_off", "single"))
for it, state in zip(items, ["done", "in_review", "in_progress", "cancelled", "done"]):
it.state = state
extra = Item("x", line_id="L1", source="change_order", state="done")
line = reconcile_lines(items + [extra])["lines"][0]
assert line["delivered"] == 3 and line["over"] == 1 and line["short"] == 2 and line["descoped"] == 1
assert line["sold"] == line["delivered"] - line["over"] + line["short"] + line["descoped"]
def test_pj_12_only_an_active_project_closes():
project, _ = sale(PHASES)
with pytest.raises(RuleBroken, match="only an active one closes"):
project.reconcile([], now=MON)
def test_short_is_derived_never_stored():
project, items = reconciled_project(["done", "planned"])
assert [project.is_short(i) for i in items] == [False, True]
assert not any(hasattr(i, "short") for i in items)
# ── PJ-22: billing on acceptance is decided here, asked for in the use case ─────────────────
def test_pj_22_an_accepted_single_payment_line_is_left_billing_pending():
it = in_review(evidence="e", evidence_kind="link")
it.accept(now=MON, settings=S)
assert it.billing_status == "pending"
it.billing_requested()
assert it.billing_status == "requested"
assert it.billing_requested() is None # once
def test_pj_22_instalments_and_makegoods_are_never_billed_here():
inst = Item("i", billing_kind="one_off", billing_plan="periodic", evidence="e", evidence_kind="link")
inst.start(), inst.submit(now=MON), inst.accept(now=MON, settings=S)
assert inst.billing_status == "not_billed"
mg = Item("m", source="makegood", billing_kind="one_off", billing_plan="single", evidence="e",
evidence_kind="link")
mg.start(), mg.submit(now=MON), mg.accept(now=MON, settings=S)
assert mg.billing_status == "not_billed"
# ── the settings and the state names ────────────────────────────────────────────────────────
def test_settings_are_checked_and_null_is_the_reference():
assert check_setting("review.window_days", None) is None
assert check_setting("plan.expand", {"One_Off/Single": "per_unit"}) == {"one_off/single": "per_unit"}
for key, bad, why in [("review.window_days", 0, "whole number"), ("plan.expand", {"x": "all"}, "maps"),
("ai.ai_draft_plan.mode", "auto", "auto is not offered"),
("calendar.timezone", "Mars/Olympus", "not a time zone"), ("nope", 1, "no setting")]:
with pytest.raises(RuleBroken, match=why):
check_setting(key, bad)
def test_a_hand_edited_bad_row_falls_back_to_the_reference():
s = settings_from_rows({"review.window_days": "7", "review.warning_notice_days": "\"x\"", "plan.expand": "{"})
assert (s.review_window_days, s.warning_notice_days, s.expand) == (7, 5, Settings().expand)
def test_holidays_move_the_review_window():
with_holiday = Settings(holidays=("2026-11-16",))
assert in_review().window_ends_on(calendar_of(with_holiday), with_holiday) == date(2026, 11, 17)
assert working_days_between(CAL, date(2026, 11, 13), date(2026, 11, 16)) == 1 # Fri → Mon
def test_state_names_are_the_customers_but_what_they_mean_is_not():
s = State("in_review", "In review", 3)
assert s.rename(name="With client") is True and s.name == "With client"
assert (s.is_open, s.counts_as_delivered) == (True, False)
with pytest.raises(RuleBroken, match="1 to 40"):
s.rename(name="")
# ── the audit's rules (2026-10-18) ──────────────────────────────────────────────────────────
def test_work_moves_only_while_its_project_is_open():
sold, mg = Item("i", source="sold"), Item("m", source="makegood")
for state in ("planned", "active"):
sold.may_move_in(state)
with pytest.raises(RuleBroken, match="cancelled"):
sold.may_move_in("cancelled")
with pytest.raises(RuleBroken, match="closed and reconciled"):
sold.may_move_in("closed")
mg.may_move_in("closed") # a makegood is the closed project's work
with pytest.raises(RuleBroken, match="cancelled"):
mg.may_move_in("cancelled")
def test_whoever_submitted_the_work_does_not_accept_it():
it = in_review(evidence="e", evidence_kind="link")
it.submitted_by = "will@agency.example"
with pytest.raises(RuleBroken, match="You submitted this work"):
it.accept(now=MON, settings=S, by="will@agency.example")
it.accept(now=MON, settings=S, by="lena@agency.example")
assert it.state == "done"
def test_only_accepted_order_work_is_pending_for_the_order():
order = Item("o", source_kind="sales_order", source_id="SO-7", line_id="SO-L1", billing_kind="one_off",
billing_plan="single", evidence="e", evidence_kind="link")
assert order.delivery_reported() is None # not done: nothing to report
order.start(), order.submit(now=MON), order.accept(now=MON, settings=S)
assert order.delivery_status == "pending"
order.delivery_reported()
assert order.delivery_status == "reported" and order.delivery_reported() is None
contract = in_review(evidence="e", evidence_kind="link")
contract.accept(now=MON, settings=S)
assert contract.delivery_status == "not_reported"