Files
FabledGrimoire/tests/test_rule_trigger_required.py
T
bvandeusenandClaude Opus 5.5 914f31359c
CI & Build / Python lint (push) Successful in 5s
CI & Build / Plugin hooks (push) Successful in 17s
CI & Build / TypeScript typecheck (push) Successful in 57s
CI & Build / integration (push) Successful in 1m19s
CI & Build / Python tests (push) Successful in 2m2s
CI & Build / Build & push image (push) Successful in 15s
docs(6209): the remaining test files — lessons, reply moment and shape, retrieval specs and scopes, rule doors and judgments, services, snippets, search scope; five tests renamed to their property
Docstrings, section headers and assertion messages drop the record number,
milestone or step that introduced the behaviour, and incident accounts are
restated as the present-tense failure shape. A dead pointer to a design doc
that is not in the repo is removed.

Renamed to the property they check:
  test_the_old_shape_would_be_caught -> test_a_rank_then_scope_query_is_caught
  test_a_ledger_repeat_no_longer_produces_that_case_at_all -> test_a_ledger_repeat_is_counted_not_withheld
  test_the_header_no_longer_promises_once_per_session -> test_the_header_explains_the_seen_marker
  test_the_portal_case_no_longer_stamps_one_canon_onto_a_whole_file -> test_one_canon_is_not_stamped_onto_a_whole_file
  test_the_repos_own_prose_no_longer_defines_with -> test_the_repos_own_prose_does_not_define_with
  test_the_original_regression_create_note_with_content -> test_the_real_server_refuses_content_on_create_note

Fixture and example ids stay: "note 5" in a supersession chain, "rule 156"
in an illustrative question, `#2275` as the hex-versus-citation case,
`#3244` as the example of a bare number, fixed dates in date-filter tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 21:44:58 -04:00

120 lines
4.8 KiB
Python

"""A rule cannot be created, or edited into, having no trigger.
WHY THIS EXISTS
`when_to_apply` is not metadata. `rule_document` embeds a rule as
`{title} — {trigger}` / `When to apply: {trigger}\n\n{statement}`, with the
trigger appearing TWICE so purpose dominates a short vector — the shape note
2485 measured. Drop the trigger and the document silently becomes title +
statement: a DIFFERENT shape, ranked against a corpus it does not match, with
nothing to report it. Every bar and every rank in the system assumes one shape.
`create_preference` has always refused an empty trigger. `create_rule` and
`create_project_rule` defaulted it to `""` — so the shape was enforced for the
record kind that merely guides and optional for the kind that binds.
WHAT THIS PINS
The guard lives in the SERVICE, because both doors reach it: the MCP tools and
the frontend's fast path in `routes/rulebooks.py`. A guard written in either
one alone would leave the other able to create a rule that never fires, which
is the failure mode this guard exists to remove.
These call the service directly and assert on the refusal, so an empty guard
cannot make them pass by accident.
"""
from __future__ import annotations
import re
import pytest
from scribe.services import rulebooks as rulebooks_svc
TRIGGER_RE = re.compile(r"when_to_apply", re.I)
@pytest.mark.parametrize("blank", ["", " ", "\n\t "])
def test_a_rulebook_rule_needs_a_trigger(blank):
"""Whitespace is not a trigger — it embeds exactly like an empty one."""
with pytest.raises(ValueError, match=TRIGGER_RE):
rulebooks_svc._require_trigger(blank)
def test_a_real_trigger_passes():
"""The guard has to let the normal case through, or the parametrised
refusals above would pass for a guard that rejects everything."""
assert rulebooks_svc._require_trigger("before any git push") is None
@pytest.mark.asyncio
async def test_create_rule_refuses_before_it_touches_the_database():
"""The guard runs ahead of the ownership check, so a missing trigger is
reported as such rather than as whatever the session lookup says. A bogus
topic_id proves no database was reached: were the guard absent, this would
fail on the topic instead, with a different message."""
with pytest.raises(ValueError, match=TRIGGER_RE):
await rulebooks_svc.create_rule(
topic_id=999_999, user_id=1, title="t", statement="s",
)
@pytest.mark.asyncio
async def test_create_project_rule_refuses_too():
"""The second door into the same defect. Named separately because the two
functions are separate code paths that have drifted apart before."""
with pytest.raises(ValueError, match=TRIGGER_RE):
await rulebooks_svc.create_project_rule(
project_id=999_999, user_id=1, title="t", statement="s",
)
def test_both_creators_actually_call_the_guard():
"""Structural, and deliberately not satisfied by the behavioural tests
above: those would still pass if a future edit inlined a second copy of
the check. One definition is the point — a rule shape enforced in two
places is a rule shape that will be enforced differently in two places."""
import inspect
for fn in (rulebooks_svc.create_rule, rulebooks_svc.create_project_rule):
src = inspect.getsource(fn)
assert "_require_trigger(when_to_apply)" in src, (
f"{fn.__name__} no longer delegates to the shared guard"
)
def test_update_cannot_empty_a_trigger_that_exists():
"""The create guard is worth nothing if an edit can undo it, and both
doors can try: `clear=['when_to_apply']` from the MCP side and an emptied
form input from the REST side.
Asserted on the source because the check sits mid-transaction, after the
mutation and before the commit — reaching it needs a database. What must
not silently vanish is the pairing: the before-value captured, and the
raise that reads it.
"""
import inspect
src = inspect.getsource(rulebooks_svc.update_rule)
assert "trigger_before" in src, "the pre-edit trigger is no longer captured"
assert re.search(r"if trigger_before and not", src), (
"update_rule no longer refuses to empty an existing trigger"
)
def test_a_rule_that_never_had_one_is_still_editable():
"""The deliberate asymmetry, and the reason the check asks 'did this edit
REMOVE a trigger' rather than 'does one exist'.
A rule predating the guard has no trigger. Refusing to save it would
freeze exactly the records that most need fixing — the unreachable ones —
so the guard must not fire when there was nothing to remove.
"""
import inspect
src = inspect.getsource(rulebooks_svc.update_rule)
assert "trigger_before and not" in src, (
"the guard no longer keys on the BEFORE value, so a legacy rule with "
"no trigger can no longer be edited at all"
)