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"