dodil/erp-brick-contracts

tests/test_rules.py

"""The rules with no database: one test per sentence a business person would agree to (modules/contracts stories and
domain_model invariants), against the domain objects alone."""
from datetime import date, datetime, timedelta, timezone

import pytest

from brick_contracts.domain import packs
from brick_contracts.domain.contract import Contract, ExecutedCopy, Section
from brick_contracts.domain.contract_type import ContractType
from brick_contracts.domain.errors import Invalid, NotFound, RuleBroken
from brick_contracts.domain.library import Clause, Template, VariableDef, slots_for
from brick_contracts.domain.policies import (ApprovalPolicy, ApprovalRule, Configuration, Consumer, RetentionRule)
from brick_contracts.domain.services import (CLAIM, FORBIDDEN_CLAIMS, CertificateBuilder, ChainVerifier, DeviationCheck,
                                             Resolver)
from brick_contracts.domain.values import (ClausePosition, ConsentText, Fingerprint, VariableValue, contract_id_for,
                                           namespace_of)

T0 = datetime(2026, 10, 19, 9, 0, tzinfo=timezone.utc)
CONSENT = ConsentText("v1", "I agree to sign this document electronically.")
POLICY = ApprovalPolicy("standard", (ApprovalRule(1, "*", "legal", "director", 2), ApprovalRule(2, "bound", "finance",
                                                                                                 "director", 2)))


def config():
    return Configuration(policies={"standard": POLICY}, retention={"simple-6y": RetentionRule("simple-6y", 6, 30),
                                                                   "deed-12y": RetentionRule("deed-12y", 12, 30)})


def contract(**kw):
    c = Contract("ct-1", "nda", "nda-std", 1, 42, "manual:F1", "owner.1@x", "owner.1@x", T0, title="NDA")
    c.sections = [Section(1, "confidentiality", "conf_std", 1, "PREFERRED", 0, "Confidentiality", "Keep it secret."),
                  Section(2, "liability_cap", "liability_std", 1, "PREFERRED", 0, "Liability", "Capped.")]
    c.add_signer(kind="EXTERNAL", side="THEM", signing_order=1, email="sam@nine.example", full_name="Sam Nine")
    c.add_signer(kind="INTERNAL", side="US", signing_order=2, email="dana@acme.example", full_name="Dana Director")
    for k, v in kw.items():
        setattr(c, k, v)
    c.review([], [], now=T0, by="owner.1@x")
    return c


def sent(c=None):
    c = c or contract()
    c.freeze(object_key="k", sha256="a" * 64, size_bytes=10, text_body="words", now=T0, by="owner",
             link_lifetime=timedelta(days=14))
    return c


def fallback(c):
    fb = Clause.new("liability_fb", "liability_cap", "FALLBACK", 1, "Liability", "Twice the fees.")
    c.choose_position(2, fb)
    c.review([], DeviationCheck(POLICY, None).check(c.sections, c.variables), now=T0, by="owner.1@x")
    return c


# ── identity and values ────────────────────────────────────────────────────────────────────

def test_ct_50_the_contract_id_is_derived_from_source_ref_and_type():
    assert contract_id_for("hr:onboarding:1#3", "employment_contract") == contract_id_for("hr:onboarding:1#3",
                                                                                          "employment_contract")
    assert contract_id_for("hr:onboarding:1#3", "nda") != contract_id_for("hr:onboarding:1#3", "employment_contract")
    assert contract_id_for("x", "y").startswith("ct-") and len(contract_id_for("x", "y")) == 19
    assert namespace_of("hr:onboarding:1#3") == "hr" and namespace_of("manual:F1") == "manual"


def test_money_is_an_exact_decimal_with_its_currency_never_a_float():
    v = VariableValue.parse("fee", "money", "750", "PAY", currency="gbp")
    assert (v.value, v.currency, v.display()) == ("750.00", "GBP", "GBP 750.00")
    with pytest.raises(Invalid):
        VariableValue.parse("fee", "money", 750.5, "PAY", currency="GBP")
    with pytest.raises(Invalid):
        VariableValue.parse("fee", "money", "750.123", "PAY", currency="GBP")
    with pytest.raises(Invalid):
        VariableValue.parse("fee", "money", "750", "PAY")
    assert VariableValue.parse("rate", "rate", "412.3077", "PAY", currency="GBP").value == "412.3077"


def test_an_enum_takes_the_callers_words_through_its_aliases():
    v = VariableValue.parse("client_size_regime", "enum", "medium", "CALLER", enum_values=("SMALL", "MEDIUM_LARGE"),
                            aliases={"medium": "MEDIUM_LARGE"})
    assert v.value == "MEDIUM_LARGE"
    with pytest.raises(Invalid):
        VariableValue.parse("x", "enum", "huge", "CALLER", enum_values=("SMALL",))


def test_positions_carry_their_deviation_levels():
    assert not ClausePosition("PREFERRED", 0).deviates
    assert ClausePosition("FALLBACK", 1).deviates and ClausePosition.override().deviation_level == 2
    for bad in (("PREFERRED", 1), ("FALLBACK", 0), ("OVERRIDE", 1)):
        with pytest.raises(Invalid):
            ClausePosition(*bad)


# ── types and policies (CT-02) ─────────────────────────────────────────────────────────────

def test_ct_02_a_deed_type_is_never_signed_online():
    t = ContractType.define({"type_code": "deed", "label": "deed", "allowed_bp_roles": ["supplier"],
                             "execution": "DEED", "esign_allowed": True, "retention_rule": "deed-12y",
                             "approval_policy": "standard"}, config())
    assert t.esign_allowed is False and not t.can_esign()


def test_ct_02_every_type_names_a_retention_rule_and_an_approval_policy():
    for row in ({"retention_rule": "nope", "approval_policy": "standard"},
                {"retention_rule": "simple-6y", "approval_policy": "nope"}):
        with pytest.raises(RuleBroken):
            ContractType.define({"type_code": "t", "label": "t", "allowed_bp_roles": ["customer"], **row}, config())


def test_ct_02_a_freelance_contract_with_a_customer_only_partner_is_refused():
    t = ContractType.define({"type_code": "freelance_agreement", "label": "freelance services agreement",
                             "allowed_bp_roles": ["worker", "supplier"], "retention_rule": "simple-6y",
                             "approval_policy": "standard"}, config())
    with pytest.raises(RuleBroken, match="needs a partner with the supplier or worker role"):
        t.check_counterparty({"roles": ["customer"], "kind": "organisation"})
    t.check_counterparty({"roles": ["supplier"], "kind": "organisation"})


def test_a_starter_without_a_role_yet_is_allowed_where_the_type_says_so():
    row = {"type_code": "employment_contract", "label": "employment contract", "allowed_bp_roles": ["employee"],
           "retention_rule": "simple-6y", "approval_policy": "standard"}
    with pytest.raises(RuleBroken):
        ContractType.define(row, config()).check_counterparty({"roles": [], "kind": "person"})
    ContractType.define({**row, "allow_roleless_person": True}, config()).check_counterparty({"roles": [], "kind": "person"})
    with pytest.raises(RuleBroken, match="employee role"):
        ContractType.define({**row, "allow_roleless_person": True}, config()).check_counterparty(
            {"roles": ["supplier"], "kind": "person"})


def test_an_approval_policy_has_a_mandatory_fallback_and_a_catch_all():
    with pytest.raises(Invalid, match="fallback"):
        ApprovalRule(1, "*", "legal", "")
    with pytest.raises(Invalid, match="catch-all"):
        ApprovalPolicy("p", (ApprovalRule(1, "bound", "finance", "director"),))
    assert POLICY.rule_for("bound", "rate_amount").approver_group == "finance"
    assert POLICY.rule_for("position", "liability_cap", "FALLBACK").approver_group == "legal"


def test_a_consumer_is_matched_by_sub_role_or_email():
    hr = Consumer("hr", ("sub:hr-system", "role:hr_service"))
    assert hr.matches({"sub": "hr-system"}) and hr.matches({"sub": "x", "roles": ["hr_service"]})
    assert not hr.matches({"sub": "crm-adapter", "roles": ["service"]})


# ── the library (CT-01, CT-03, CT-04) ──────────────────────────────────────────────────────

def test_ct_01_amending_cuts_a_new_version_and_old_ones_stay():
    c = Clause.new("ip_full", "ip_assignment", "PREFERRED", 0, "IP", "Assigned on creation.")
    assert c.amend(None, "Assigned on creation, worldwide.", by="sam", at=T0) == 2
    assert c.versions[1].body == "Assigned on creation." and c.current_version == 2
    assert c.amend(None, "Assigned on creation, worldwide.", by="sam", at=T0) == 2      # the same words: one version


def test_ct_01_editing_a_version_a_contract_holds_is_refused():
    c = Clause.new("ip_full", "ip_assignment", "PREFERRED", 0, "IP", "Assigned on creation.")
    with pytest.raises(RuleBroken, match="version 1 is on 3 contracts, amend it into version 2"):
        c.edit_version(1, None, "new words", contracts_holding=3, templates_pinning=[])


def test_ct_01_a_required_clause_cannot_be_retired_while_a_template_requires_it():
    c = Clause.new("ip_full", "ip_assignment", "PREFERRED", 0, "IP", "Assigned on creation.")
    with pytest.raises(RuleBroken, match="required by template"):
        c.retire(["freelance-std@1"])
    c.retire([])
    assert c.status == "retired"


def _template():
    t = Template.new("t1", "nda", "NDA")
    t.keep_draft([{"slot_no": 1, "family": "ip_assignment", "clause_id": "ip_full", "clause_version": 1}],
                 [{"name": "start_date", "type": "date"}], by="sam", at=T0)
    return t


def test_ct_04_the_author_may_not_approve_their_own_template_version():
    t = _template()
    lookup = lambda cid: Clause.new("ip_full", "ip_assignment", "PREFERRED", 0, "IP", "words")    # noqa: E731
    with pytest.raises(RuleBroken, match="someone else approves it"):
        t.approve(1, by="sam", at=T0, clause_lookup=lookup)
    t.approve(1, by="priya", at=T0, clause_lookup=lookup)
    assert t.current_approved().version == 1


def test_ct_04_an_approved_version_is_immutable_an_edit_is_a_new_version():
    t = _template()
    t.approve(1, by="priya", at=T0, clause_lookup=lambda cid: Clause.new(cid, "ip_assignment", "PREFERRED", 0, "IP", "w"))
    n = t.keep_draft([{"slot_no": 1, "family": "ip_assignment", "clause_id": "ip_full", "clause_version": 2}], [],
                     by="sam", at=T0)
    assert n == 2 and t.versions[1].slots[0].clause_version == 1 and t.versions[2].status == "DRAFT"


def test_ct_03_a_condition_chooses_the_alternative_slot():
    t = Template.new("t1", "freelance_agreement", "F")
    t.keep_draft([{"slot_no": 1, "family": "sub", "clause_id": "std", "clause_version": 1},
                  {"slot_no": 2, "family": "sub", "kind": "alternative_of", "alternative_of_slot": 1,
                   "clause_id": "psc", "clause_version": 1, "condition": {"var": "engagement_via", "eq": "PSC"}}],
                 [], by="sam", at=T0)
    v = t.versions[1]
    assert [s.clause_id for s in slots_for(v, {"engagement_via": "SOLE_TRADER"})] == ["std"]
    assert [s.clause_id for s in slots_for(v, {"engagement_via": "PSC"})] == ["psc"]


# ── resolving values (CT-05, CT-51) ────────────────────────────────────────────────────────

def _version(*defs):
    t = Template.new("t1", "nda", "NDA")
    t.keep_draft([{"slot_no": 1, "family": "f", "clause_id": "c", "clause_version": 1}], list(defs), by="s", at=T0)
    return t.versions[1]


def test_ct_51_the_counterparty_comes_from_the_partner_never_from_what_is_typed():
    v = _version({"name": "counterparty_name", "type": "text", "source": "COUNTERPARTY"})
    r = Resolver(v, {"legal_name": "Studio Nine Ltd"}, {"counterparty_name": "Typed Ltd"}, at=T0).resolve()
    assert r.variables["counterparty_name"].value == "Studio Nine Ltd"


def test_ct_05_missing_required_values_are_listed_and_defaults_fill_the_rest():
    v = _version({"name": "start_date", "type": "date", "source": "DATES"},
                 {"name": "cont", "type": "date", "source": "DATES", "validation": {"default_from": "start_date"}},
                 {"name": "holiday", "type": "text", "source": "TEMPLATE"},
                 {"name": "hours", "type": "text", "source": "TEMPLATE", "default_value": "37.5"})
    r = Resolver(v, {}, {"start_date": "2026-11-02"}, at=T0).resolve()
    assert r.missing == ["holiday"] and r.variables["cont"].value == "2026-11-02" and r.variables["hours"].value == "37.5"


def test_ct_07_a_value_above_its_band_and_a_fallback_are_deviations_with_their_approvers():
    v = _version({"name": "rate_amount", "type": "rate", "source": "PAY",
                  "validation": {"min": "300", "max": "600", "currency": "GBP"}})
    vars_ = {"rate_amount": VariableValue.parse("rate_amount", "rate", "750", "PAY", currency="GBP")}
    c = fallback(contract())
    devs = DeviationCheck(POLICY, v).check(c.sections, vars_)
    assert [(d.kind, d.needs) for d in devs] == [("position", "legal"), ("bound", "finance")]
    assert "liability cap is a Fallback" in devs[0].what and "above its band (300–600 GBP)" in devs[1].what


# ── approval (CT-07) ───────────────────────────────────────────────────────────────────────

def test_ct_07_an_approval_binds_to_the_fingerprint_and_an_edit_voids_it():
    c = fallback(contract())
    f1 = c.draft_fingerprint
    a = c.open_approvals()[0]
    assert c.state == "IN_APPROVAL" and a.draft_fingerprint == f1 and a.approver_group == "legal"
    c.decide(a.seq, True, by="lee@legal", groups={"legal"}, now=T0)
    assert c.state == "APPROVED" and c.approval_granted()
    c.override_section(1, None, "Keep it very secret.")
    c.review([], DeviationCheck(POLICY, None).check(c.sections, c.variables), now=T0, by="owner.1@x")
    assert c.draft_fingerprint != f1 and a.state == "void" and c.state == "IN_APPROVAL" and not c.approval_granted()


def test_ct_07_an_owner_cannot_approve_their_own_deviation():
    c = fallback(contract())
    with pytest.raises(RuleBroken, match="you own this draft"):
        c.decide(c.open_approvals()[0].seq, True, by="owner.1@x", groups={"legal"}, now=T0)


def test_ct_07_an_undecided_approval_goes_to_the_fallback_group_and_is_theirs():
    c = fallback(contract())
    a = c.open_approvals()[0]
    assert c.escalate_due(a.due_at + timedelta(minutes=1)) == [a] and a.state == "escalated"
    with pytest.raises(RuleBroken, match="went to director"):
        c.decide(a.seq, True, by="lee", groups={"legal"}, now=T0)
    c.decide(a.seq, True, by="dana", groups={"director"}, now=T0)
    assert c.state == "APPROVED"


def test_ct_07_a_rejection_says_why_and_returns_to_draft():
    c = fallback(contract())
    with pytest.raises(Invalid):
        c.decide(c.open_approvals()[0].seq, False, by="lee", groups={"legal"}, now=T0)
    c.decide(c.open_approvals()[0].seq, False, by="lee", groups={"legal"}, now=T0, reason="too generous")
    assert c.state == "DRAFT" and "too generous" in c.last_note


# ── send (CT-08) ───────────────────────────────────────────────────────────────────────────

def test_ct_08_send_is_refused_in_words_for_every_reason_at_once():
    ct = ContractType("deed", "deed of assignment", ("supplier",), "DEED", False)
    c = contract()
    c.signers = [s for s in c.signers if s.side == "THEM"]
    c.missing = ["start_date"]
    out = c.send_refusals(ct, ["an outside determination … (sds_issued_on)"])
    assert any("not signed online here" in r for r in out) and "missing: start_date" in out
    assert "nobody on our side is named, so this cannot be complete" in out and out[-1].startswith("an outside")


def test_ct_08_freezing_makes_version_n_and_a_resend_is_n_plus_1():
    c = sent()
    assert c.state == "OUT_FOR_SIGNATURE" and c.current_version == 1
    c.recall("typo", now=T0)
    c2 = sent(c)
    assert c2.current_version == 2 and c2.versions[1].status == "SUPERSEDED" and c2.versions[1].sha256 == "a" * 64


def test_editing_out_for_signature_is_refused_recall_it_first():
    c = sent()
    with pytest.raises(RuleBroken, match="version 1 is out for signature; recall it to"):
        c.override_section(1, None, "new")


# ── signing (CT-09, CT-17) ─────────────────────────────────────────────────────────────────

def sign(c, n, via, **kw):
    return c.sign(n, version=kw.pop("version", c.current_version), sha_shown=kw.pop("sha", c.current.sha256),
                  typed_name=kw.pop("name", "Sam Nine"), consented=kw.pop("consented", True), consent=CONSENT, now=T0,
                  via=via, **kw)


def test_ct_09_signing_out_of_order_is_refused_naming_who_it_waits_on():
    c = sent()
    with pytest.raises(RuleBroken, match="waiting on Sam Nine"):
        sign(c, 2, "INTERNAL", name="Dana Director")


def test_ct_09_a_stale_version_is_refused():
    c = sent()
    with pytest.raises(RuleBroken, match="re-issued: open the new link"):
        sign(c, 1, "EXTERNAL", version=0)
    with pytest.raises(RuleBroken, match="re-issued"):
        sign(c, 1, "EXTERNAL", sha="b" * 64)


def test_ct_09_consent_and_a_typed_name_are_required_and_twice_is_one_signature():
    c = sent()
    with pytest.raises(Invalid):
        sign(c, 1, "EXTERNAL", consented=False)
    with pytest.raises(Invalid):
        sign(c, 1, "EXTERNAL", name=" ")
    assert sign(c, 1, "EXTERNAL", ip="198.51.100.7", user_agent="Firefox") is True
    assert sign(c, 1, "EXTERNAL") is False
    ev = [e for e in c.events if e.kind == "SIGNED"]
    assert len(ev) == 1 and ev[0].ip == "198.51.100.7" and ev[0].user_agent == "Firefox" and ev[0].version == 1
    assert ev[0].version_sha256_shown == "a" * 64 and ev[0].auth_method == "EMAIL_LINK"


def test_ct_17_a_link_never_signs_for_our_side_and_the_app_never_for_theirs():
    c = sent()
    with pytest.raises(NotFound):
        sign(c, 2, "EXTERNAL")
    with pytest.raises(NotFound):
        sign(c, 1, "INTERNAL")


def test_our_side_signs_as_its_pool_identity():
    c = sent()
    sign(c, 1, "EXTERNAL")
    sign(c, 2, "INTERNAL", pool_sub="dana.sub")
    ev = [e for e in c.events if e.kind == "SIGNED"][-1]
    assert (ev.signer_kind, ev.auth_method, ev.pool_sub) == ("INTERNAL", "POOL_SESSION", "dana.sub") and c.all_signed


def test_our_side_must_be_internal_and_theirs_external():
    c = contract()
    with pytest.raises(RuleBroken):
        c.add_signer(kind="EXTERNAL", side="US", signing_order=1, email="a@b.c", full_name="A")
    with pytest.raises(RuleBroken):
        c.add_signer(kind="INTERNAL", side="THEM", signing_order=1, email="a@b.c", full_name="A")


# ── evidence (CT-31, CT-32) ────────────────────────────────────────────────────────────────

def test_ct_32_events_are_hash_chained_and_a_changed_row_is_found():
    c = sent()
    sign(c, 1, "EXTERNAL", ip="198.51.100.7")
    assert ChainVerifier.verify(c.contract_id, c.events) == (True, None)
    c.events[3].ip = "203.0.113.66"
    assert ChainVerifier.verify(c.contract_id, c.events) == (False, 4)


def test_ct_32_a_removed_row_breaks_the_chain_where_it_was():
    c = sent()
    sign(c, 1, "EXTERNAL")
    del c.events[1]
    assert ChainVerifier.verify(c.contract_id, c.events) == (False, 2)


def copy(provenance="GENERATED"):
    return ExecutedCopy("k2", "e" * 64, 20, provenance, 1, "x", T0, "f.pdf", "application/pdf")


def test_ct_31_signed_needs_every_signature_and_a_valid_chain():
    c = sent()
    sign(c, 1, "EXTERNAL")
    with pytest.raises(RuleBroken, match="not everyone has signed"):
        c.execute(copy(), now=T0, chain_ok=True)
    sign(c, 2, "INTERNAL")
    with pytest.raises(RuleBroken, match="chain does not verify"):
        c.execute(copy(), now=T0, chain_ok=False)
    c.execute(copy(), now=T0, chain_ok=True)
    assert c.state == "SIGNED" and c.executed.sha256 == "e" * 64 and c.current.status == "SIGNED"


def test_ct_12_signed_elsewhere_never_marks_a_signer_signed():
    c = contract()
    c.file_signed_elsewhere(copy("UPLOADED"), now=T0, by="owner")
    assert c.state == "SIGNED_ELSEWHERE" and {s.status for s in c.signers} == {"SIGNED_ELSEWHERE"}
    assert not [e for e in c.events if e.kind == "SIGNED"] and c.events[-1].kind == "SIGNED_ELSEWHERE"


def test_ct_13_the_certificate_says_signed_electronically_and_nothing_more():
    c = sent()
    sign(c, 1, "EXTERNAL", ip="198.51.100.7", user_agent="Firefox")
    lines = "\n".join(CertificateBuilder.lines(contract_id=c.contract_id, title=c.title, version=c.current,
                                               signers=c.signers, events=c.events, chain=(True, None)))
    assert CLAIM in lines and "198.51.100.7" in lines and "a" * 64 in lines
    assert not any(w in lines.lower() for w in FORBIDDEN_CLAIMS)


# ── lifecycle (CT-10, CT-14 … CT-16, CT-33, CT-34) ─────────────────────────────────────────

def test_ct_10_a_decline_revokes_the_other_links_and_changes_go_back_to_draft():
    c = sent()
    c.signers[1].link_id = "L2"
    c.signers[0].link_id = "L1"
    links = c.decline(1, "the fee is wrong", now=T0, via="EXTERNAL", version=1, sha_shown="a" * 64)
    assert c.state == "DECLINED" and set(links) == {"L1", "L2"}
    c2 = sent()
    c2.request_changes(1, "add a break clause", now=T0, via="EXTERNAL", version=1, sha_shown="a" * 64)
    assert c2.state == "DRAFT" and c2.versions[1].status == "SUPERSEDED"


def test_ct_16_a_signed_contract_cannot_be_cancelled():
    c = sent()
    sign(c, 1, "EXTERNAL")
    sign(c, 2, "INTERNAL")
    c.execute(copy(), now=T0, chain_ok=True)
    with pytest.raises(RuleBroken, match="terminate it instead"):
        c.cancel("no", now=T0)


def test_ct_15_the_notice_deadline_is_its_own_date_with_its_reminders():
    c = contract(ends_on=date(2026, 12, 31), notice_days=90, starts_on=date(2026, 1, 1))
    c.plan_key_dates(lambda k: (120, 90, 60, 30) if k == "NOTICE_DEADLINE" else (30,), "ops")
    k = next(x for x in c.key_dates if x.kind == "NOTICE_DEADLINE")
    assert k.due_on == date(2026, 10, 2) and k.next_remind_on == date(2026, 6, 4)
    assert [d.isoformat() for _, d in k.pending_slots()] == ["2026-06-04", "2026-07-04", "2026-08-03", "2026-09-02"]


def test_ct_33_active_and_expired_are_derived_from_the_term():
    c = contract(starts_on=date(2026, 11, 1), ends_on=date(2027, 3, 31))
    c.state = "SIGNED"
    assert c.derived_state(date(2026, 10, 20)) == "SIGNED"
    assert c.derived_state(date(2026, 11, 15)) == "ACTIVE" and c.derived_state(date(2027, 4, 1)) == "EXPIRED"


def test_ct_34_retention_is_term_plus_six_years_and_twelve_for_a_deed():
    c = contract(ends_on=date(2027, 3, 31))
    c.state = "SIGNED"
    assert c.retention_from(RetentionRule("s", 6, 30), date(2027, 1, 1)) == date(2033, 3, 31)
    assert c.retention_from(RetentionRule("d", 12, 30), date(2027, 1, 1)) == date(2039, 3, 31)
    c.state = "CANCELLED"
    assert c.retention_from(RetentionRule("s", 6, 30), date(2027, 1, 1)) == date(2027, 1, 31)


# ── the gb pack (CT-35) ────────────────────────────────────────────────────────────────────

def _ftype(checks=("ir35",)):
    return ContractType("freelance_agreement", "freelance", ("worker",), pack="gb", pack_checks=tuple(checks))


def _vars(**kw):
    return {k: VariableValue.parse(k, "date" if k.endswith("_on") else "enum" if k != "sds_reference" else "text", v,
                                   "CALLER") for k, v in kw.items()}


def test_ct_35_an_outside_determination_by_a_medium_or_large_client_needs_its_sds():
    c = contract()
    c.variables = _vars(client_size_regime="MEDIUM_LARGE", ir35_determination="OUTSIDE")
    assert any("status determination statement" in r for r in packs.gb.send_refusals(_ftype(), c))
    c.variables = _vars(client_size_regime="MEDIUM_LARGE", ir35_determination="OUTSIDE", sds_issued_on="2026-10-01")
    assert packs.gb.send_refusals(_ftype(), c) == []
    c.variables = _vars(client_size_regime="SMALL", ir35_determination="OUTSIDE")
    assert packs.gb.send_refusals(_ftype(), c) == []


def test_ct_35_the_era_particulars_are_required_and_the_statement_is_due_on_day_one():
    et = ContractType("employment_contract", "employment", ("employee",), pack="gb", pack_checks=("era_s1",))
    assert "holiday_entitlement" in packs.gb.draft_missing(et, {})
    c = contract(starts_on=date(2026, 11, 2))
    c.ensure_key_date("PRINCIPAL_STATEMENT_DUE", date(2026, 11, 2), "hr", (1,))
    assert packs.gb.status_warnings(et, c, date(2026, 11, 2)) == [
        "the written statement has not been given and is due today"]
    c.principal_statement_issued_at = T0
    assert packs.gb.status_warnings(et, c, date(2026, 11, 2)) == []


def test_the_fingerprint_covers_title_sections_variables_and_signers():
    c = contract()
    f = c.fingerprint()
    c.signers[0].email = "other@x.example"
    assert c.fingerprint() != f and isinstance(f, Fingerprint)