Files
FabledScribe/tests/test_family_adoption.py
T
bvandeusenandClaude Opus 5.5 07169e1274
CI & Build / Plugin hooks (push) Successful in 15s
CI & Build / Python lint (push) Successful in 2s
CI & Build / TypeScript typecheck (push) Successful in 1m9s
CI & Build / integration (push) Successful in 1m21s
CI & Build / Python tests (push) Successful in 2m2s
CI & Build / Build & push image (push) Successful in 1m29s
feat(family): an idea links to the rule topic holding its norms - set_family_topic, the readouts name the rules, the idea row shows the link (milestone 463 step 1 gap, found in step 7, #4993)
Step 1 added family_ideas.topic_id but nothing could set it, so the note<->topic
link the milestone promised existed only as a column. set_family_topic links or
unlinks (0) a topic in a rulebook the caller owns, one idea per topic; the
version does not move. get_family_idea and get_family_adoption list the topic's
rules; list_family_ideas and the Family page name the topic. The model
docstrings no longer claim the topic's rules are platform-scoped in retrieval.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 14:27:33 -04:00

187 lines
8.3 KiB
Python

"""The adoption ledger without a database (milestone 463 step 4).
The parts that decide are pure and pinned here: what an assessment needs
before it can be recorded, what each ground of the conflict order needs —
including that every ground ABOVE the deciding one was said not to apply —
when an answer needs a recheck, how a losing side is folded into the note,
and which reference an owed task points at. Beside them: the outcomes and
the order the agent is told are the ones enforced. The state machine against
Postgres is in tests/test_integration_family_adoption.py.
"""
from __future__ import annotations
import pytest
from scribe.mcp.server import _READ_ONLY_TOOLS, _WRITE_TOOLS
from scribe.services.family_adoption import (
CONFLICT_KEYS, CONFLICT_ORDER, OUTCOME_KEYS, _fold_section, _reference_line,
assessment_problems, conflict_problems, needs_recheck,
)
from tests.helpers import tool_doc
# --- what an assessment needs ------------------------------------------------------
@pytest.mark.parametrize("outcome", OUTCOME_KEYS)
def test_every_outcome_needs_a_reason(outcome):
problems = assessment_problems(outcome=outcome, reason=" ", evidence=["src/x.py"])
assert len(problems) == 1 and outcome in problems[0]
def test_adopted_needs_evidence_naming_where():
assert assessment_problems(outcome="adopted", reason="done", evidence=[]) == [
"adopted needs evidence naming where the project does it"]
assert assessment_problems(outcome="adopted", reason="done", evidence=[" "]) != []
assert assessment_problems(outcome="adopted", reason="done", evidence=["ci run 12"]) == []
@pytest.mark.parametrize("outcome", ("exempt", "variant", "owed"))
def test_the_other_outcomes_need_no_evidence(outcome):
assert assessment_problems(outcome=outcome, reason="a fact", evidence=None) == []
def test_an_unknown_outcome_is_refused_by_name():
problems = assessment_problems(outcome="ignored", reason="x", evidence=[])
assert problems and all(k in problems[0] for k in OUTCOME_KEYS)
# --- the conflict order, branch by branch ----------------------------------------
def _checked(upto: str) -> dict:
return {k: f"{k} did not decide" for k in CONFLICT_KEYS[:CONFLICT_KEYS.index(upto)]}
GOOD = {
"operator_stance": dict(evidence=["rule: one keystore per app"]),
"covers_failure": dict(evidence=["incident #12: update refused, signature mismatch"]),
"split_by_condition": dict(conditions={"canon": "the app self-updates",
"other": "a store installs it"}),
"most_recent_complete": dict(evidence=["verified on a device 2026-10-01"]),
}
@pytest.mark.parametrize("ground", CONFLICT_KEYS)
def test_each_ground_holds_when_its_needs_are_met(ground):
kw = {"evidence": None, "conditions": None, **GOOD[ground]}
assert conflict_problems(ground=ground, grounds_checked=_checked(ground),
fold="their reasoning", **kw) == []
@pytest.mark.parametrize("ground", CONFLICT_KEYS[1:])
def test_a_ground_is_refused_while_any_ground_above_it_is_unanswered(ground):
above = CONFLICT_KEYS[:CONFLICT_KEYS.index(ground)]
for skipped in above:
checked = {k: v for k, v in _checked(ground).items() if k != skipped}
problems = conflict_problems(ground=ground, grounds_checked=checked,
fold="x", **{"evidence": None, "conditions": None,
**GOOD[ground]})
assert len(problems) == 1 and f"'{skipped}'" in problems[0]
def test_the_first_ground_needs_nothing_checked_above_it():
assert conflict_problems(ground="operator_stance", grounds_checked=None, fold="x",
evidence=["rule 4"], conditions=None) == []
@pytest.mark.parametrize("ground", ("operator_stance", "covers_failure", "most_recent_complete"))
def test_grounds_one_two_and_four_need_their_evidence(ground):
problems = conflict_problems(ground=ground, grounds_checked=_checked(ground), fold="x",
evidence=[" "], conditions=None)
assert len(problems) == 1 and "evidence" in problems[0]
def test_a_split_needs_both_conditions():
for conds in (None, {"canon": "self-updates"}, {"canon": "", "other": "store"}):
problems = conflict_problems(ground="split_by_condition",
grounds_checked=_checked("split_by_condition"),
fold="x", evidence=None, conditions=conds)
assert len(problems) == 1 and "conditions" in problems[0]
def test_the_losing_reasoning_is_required():
problems = conflict_problems(ground="operator_stance", grounds_checked=None, fold=" ",
evidence=["rule 4"], conditions=None)
assert len(problems) == 1 and "never dropped" in problems[0]
def test_an_unknown_ground_names_the_order():
problems = conflict_problems(ground="seniority", grounds_checked=None, fold="x",
evidence=[], conditions=None)
assert problems == [f"ground must be one of, in order: {', '.join(CONFLICT_KEYS)}"]
# --- recheck -------------------------------------------------------------------------
@pytest.mark.parametrize("row_status,row_version,idea_status,idea_version,expected", [
("adopted", 1, "canon", 2, True),
("owed", 1, "canon", 2, True),
("adopted", 2, "canon", 2, False),
# An undone revision leaves answers AHEAD of the idea: still not agreeing.
("adopted", 3, "canon", 2, True),
("unassessed", None, "canon", 2, False),
("adopted", 1, "retired", 2, False),
])
def test_needs_recheck(row_status, row_version, idea_status, idea_version, expected):
assert needs_recheck(row_status, row_version, idea_status, idea_version) is expected
# --- folding the losing side into the note ------------------------------------------
@pytest.mark.parametrize("ground,heading", [
("operator_stance", "### Alternative — two's approach"),
("covers_failure", "### Trap — two's approach"),
("most_recent_complete", "### Alternative — two's approach"),
("split_by_condition", "### When a store installs it — two's approach"),
])
def test_the_losing_side_is_folded_as_its_ground_says(ground, heading):
text = _fold_section(ground=ground, fold="Their reasoning.",
conditions=GOOD["split_by_condition"]["conditions"],
canon_title="one", other_title="two", when="2026-10-06")
assert text.startswith(heading)
assert "Their reasoning." in text and "one's approach" in text
# --- the reference an owed task points at -----------------------------------------
REFS = [
{"id": 10, "title": "Update installer", "language": "kotlin"},
{"id": 11, "title": "Update installer", "language": "dart"},
]
def test_the_reference_in_the_projects_language_is_named():
line = _reference_line(REFS, ["dart"], idea_id=5)
assert "#11" in line and "#10" not in line
def test_no_reference_in_the_language_names_the_ones_that_exist():
line = _reference_line(REFS, ["python"], idea_id=5)
assert "python" in line and "#10" in line and "#11" in line
def test_no_reference_at_all_points_at_the_idea():
assert "#5" in _reference_line([], ["python"], idea_id=5)
# --- the product text and the doors ---------------------------------------------------
def test_the_outcomes_the_agent_is_told_are_the_ones_enforced():
"""Rule 119: the outcomes and the order are product text. The tool
docstrings must name every outcome and every ground the service checks,
in the service's order."""
assess_doc = tool_doc("scribe.mcp.tools.family", "assess_family_adoption")
for n, key in enumerate(OUTCOME_KEYS, start=1):
assert f"{n}. {key} —" in assess_doc, f"outcome {n} is not taught as {key}"
resolve_doc = tool_doc("scribe.mcp.tools.family", "resolve_family_conflict")
for n, key in enumerate(CONFLICT_KEYS, start=1):
assert f"{n}. {key} —" in resolve_doc, f"ground {n} is not taught as {key}"
assert [c["fold_as"] for c in CONFLICT_ORDER] == ["alternative", "trap", "condition", "alternative"]
def test_every_adoption_tool_is_classified():
reads = {"get_family_adoption", "list_family_adoptions"}
writes = {"assess_family_adoption", "resolve_family_conflict", "revise_family_idea",
"set_family_references", "set_family_topic"}
assert reads <= _READ_ONLY_TOOLS
assert writes <= _WRITE_TOOLS