Files
FabledGrimoire/tests/test_lesson_rule_link_ui.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

176 lines
7.2 KiB
Python

"""Both records show the lesson → rule link in the web UI.
The frontend has no component test runner, so these read the source — and
each is written against something the backend owns, so a drift on either
side fails here rather than rendering a page that quietly reads wrong:
- the client's judgment and link-state unions are the service's and the
model's own values, so a renamed state cannot leave a branch that never
matches;
- the lesson page branches on every judgment, with the unjudged answer as
the fall-through, so "not yet judged" is stated rather than left blank;
- the write-level list the pages hide controls by is the server's
PERMISSION_RANK from editor up, which is the drift that had the snippet
page hiding its edit controls from shared editors (`"edit"` is not a level
the server sends).
"""
from __future__ import annotations
import inspect
import re
from pathlib import Path
from scribe.models.lesson_rule_link import LINK_STATES
from scribe.services import lesson_rules as links_svc
from scribe.services.access import PERMISSION_RANK
FRONTEND = Path(__file__).resolve().parents[1] / "frontend" / "src"
def _read(rel: str) -> str:
return (FRONTEND / rel).read_text()
def _union(source: str, name: str) -> set[str]:
"""The string members of `export type <name> = "a" | "b" …;`."""
m = re.search(rf"export type {name}\s*=\s*([^;]+);", source)
assert m, f"type {name} not found"
return set(re.findall(r'"([^"]+)"', m.group(1)))
def _template(view: str) -> str:
return view[view.index("<template>"):view.rindex("</template>")]
def test_the_client_judgment_union_is_the_services_three_answers():
assert _union(_read("api/lessons.ts"), "RuleJudgment") == {
links_svc.LINKED, links_svc.NO_RULE, links_svc.UNJUDGED,
}
def test_the_client_link_states_are_the_models():
assert _union(_read("api/lessons.ts"), "LinkState") == set(LINK_STATES)
def test_the_lesson_page_answers_all_three_judgments():
"""`linked` and `no_rule` are tested by name; `unjudged` is the v-else
that closes the same chain, so a fourth state the server might add reads
as unjudged rather than as nothing."""
tpl = _template(_read("views/LessonDetailView.vue"))
linked = tpl.index(f"lesson.rule_judgment === '{links_svc.LINKED}'")
no_rule = tpl.index(f"lesson.rule_judgment === '{links_svc.NO_RULE}'")
assert linked < no_rule
after = tpl[no_rule:]
# The no-rule answer renders its reason, and the chain closes on a v-else.
para_end = after.index("</p>")
assert "lesson.no_rule?.why" in after[:para_end]
closing = after[para_end + len("</p>"):]
assert re.match(r"\s*<p v-else\b", closing), "the unjudged branch is not the chain's v-else"
assert "Not yet judged" in closing[:closing.index("</p>")]
def test_the_panel_is_gated_on_the_judgment_being_attached():
"""An absent `rule_judgment` is "the links could not be read" — never
"no rule" — so the panel must not render a state for it."""
tpl = _template(_read("views/LessonDetailView.vue"))
assert '<section v-if="lesson.rule_judgment"' in tpl
def test_both_pages_link_through_to_the_other_record():
lesson_tpl = _read("views/LessonDetailView.vue")
assert re.search(r'path:\s*"/rules",\s*query:\s*\{\s*rule:', lesson_tpl)
rule_tpl = _template(_read("components/rules/RuleEditorSlideOver.vue"))
assert ':to="`/lessons/${l.id}`"' in rule_tpl
def test_the_kind_and_state_markers_reuse_rule_chip():
"""No new chip style where `.rule-chip` serves: both components load the
shared sheet and neither re-declares the class in its own block."""
for rel in (
"views/LessonDetailView.vue",
"components/rules/RuleEditorSlideOver.vue",
"views/LessonEditorView.vue",
):
src = _read(rel)
assert 'class="rule-chip' in _template(src), rel
assert '<style src="@/assets/rules-shared.css" />' in src, rel
scoped = src[src.index("<style scoped>"):]
scoped = scoped[:scoped.index("</style>")]
assert ".rule-chip" not in scoped, f"{rel} re-spells the chip"
def test_the_write_levels_are_the_servers():
src = _read("utils/permission.ts")
m = re.search(r"WRITE_LEVELS[^=]*=\s*\[([^\]]*)\]", src)
assert m, "WRITE_LEVELS not found"
client = set(re.findall(r'"([^"]+)"', m.group(1)))
server = {p for p, rank in PERMISSION_RANK.items() if rank >= PERMISSION_RANK["editor"]}
assert client == server
def test_no_page_spells_its_own_write_check():
"""The helper exists so pages agree; a page comparing against a share
level by hand is the copy that drifted to `"edit"`. Matched on the names
only a share level uses — `admin` and `owner` are also user roles, and
`role === "admin"` is not this."""
offenders = [
str(p.relative_to(FRONTEND))
for p in [*FRONTEND.rglob("*.vue"), *FRONTEND.rglob("*.ts")]
if p != FRONTEND / "utils" / "permission.ts"
and re.search(r'[!=]==\s*"(?:viewer|editor|edit)"', p.read_text())
]
assert offenders == []
# ── the web editor records the answer ────────────────────────────────────────
def _payload_fn(view: str) -> str:
start = view.index("function answerPayload(")
return view[start:view.index("\n}\n", start)]
def test_the_editor_sends_the_answer_under_the_names_both_routes_read():
"""The contract check: a field the form sends under another name is
dropped without an error, and the lesson saves unjudged."""
from scribe.routes import lessons as routes
body = _payload_fn(_read("views/LessonEditorView.vue"))
assert "rule_ids:" in body and "no_rule:" in body
for route in (routes.create_lesson_route, routes.update_lesson_route):
src = inspect.getsource(route)
assert 'data.get("rule_ids")' in src or 'data["rule_ids"]' in src, route.__name__
assert 'data.get("no_rule")' in src, route.__name__
def test_the_editor_offers_the_three_answers():
tpl = _template(_read("views/LessonEditorView.vue"))
offered = set(re.findall(r'v-model="answer" type="radio" value="([a-z_]+)"', tpl))
assert offered == {"rules", "no_rule", "open"}
def test_leaving_it_open_after_linking_clears_the_links_rather_than_nothing():
"""`rule_ids` omitted leaves the links alone; an empty list is what turns a
linked lesson back into an open one. The editor must send the list."""
body = _payload_fn(_read("views/LessonEditorView.vue"))
assert "{ rule_ids: [] }" in body
def test_the_candidates_route_is_where_the_client_asks_and_above_the_id_route():
from scribe.routes import lessons as routes
src = inspect.getsource(routes)
assert src.index('"/rule-candidates"') < src.index('"/<int:lesson_id>"')
assert "/api/lessons/rule-candidates" in _read("api/lessons.ts")
def test_the_candidates_route_keeps_unavailable_apart_from_none():
"""The service returns None when the search could not run. The route has
to pass that through as null, not coerce it to an empty list, because
"nothing resembles it" is a claim the editor shows to the writer."""
from scribe.routes import lessons as routes
src = inspect.getsource(routes.lesson_rule_candidates_route)
assert 'jsonify({"candidates": candidates})' in src
assert "or []" not in src