Files
FabledScribe/tests/test_lesson_rule_link_ui.py
T
bvandeusenandClaude Opus 5.5 75cefe60e4
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 14s
CI & Build / TypeScript typecheck (push) Successful in 57s
CI & Build / integration (push) Successful in 1m9s
CI & Build / Python tests (push) Successful in 1m53s
CI & Build / Build & push image (push) Successful in 41s
feat(lessons): both records show the link in the web UI (milestone 440 step 7, #4635)
The lesson page gains an "Instance of" panel that holds one of three answers.
Unjudged is the fall-through, so it is stated rather than left blank:
- the rule(s) it was judged an instance of;
- "No rule — <why>";
- "Not yet judged".

Suggested links show what they rest on (distinct situations, and projects
when more than one), with Confirm / Not an instance for a reader who can
write. Rejected links stay listed with their reason.

The rule slide-over lists the lessons that are instances of it, plus the
suggestions waiting on a judgment. Each entry links through to the other
record, and kind and state wear the existing .rule-chip.

The write check moves to utils/permission.ts. The copy on the snippet page
looked for "edit", which the server never sends, so shared editors saw a
read-only page (#4640 "The snippet page hid its edit controls from shared
editors"). Guards pin the client's unions and write levels to the
service's, the model's and access.py's own values.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 15:46:27 -04:00

118 lines
4.9 KiB
Python

"""Both records show the lesson → rule link in the web UI (milestone 440, #4635).
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 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"):
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 == []