feat(lessons): both records show the link in the web UI (milestone 440 step 7, #4635)
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
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
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>
This commit is contained in:
@@ -0,0 +1,117 @@
|
||||
"""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 == []
|
||||
Reference in New Issue
Block a user