CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 13s
CI & Build / TypeScript typecheck (push) Successful in 55s
CI & Build / integration (push) Successful in 59s
CI & Build / Python tests (push) Successful in 1m55s
CI & Build / Build & push image (push) Successful in 37s
The lesson editor now asks the question the create tool asks, while the writer still has the situation in mind. It offers three answers: - an instance of a rule, ticked from the rules the lesson resembles; - "No rule fits", with a required reason; - leave it open. The rules are fetched before the save from a new GET /api/lessons/rule-candidates. It runs the same rule_candidates search the create door replies with, and passes "search unavailable" through as null, apart from "nothing resembles it". An edit sends an answer only when it changed. Re-sending the same rules would re-stamp their judgments. Worse, a shared editor who cannot see the owner's rule would reject it by sending a list without it. Leaving a linked lesson open sends rule_ids=[], which is what clears the links. "Leave it open" is not offered once "no rule fits" is on file: the server has no way to take that answer back except by naming a rule. When "no rule fits" completes a convergence group, the editor says so in a toast, since the page it goes to does not recompute it. Guards check that the payload uses the names both routes read, that the three answers are offered, that the candidates route sits above the id route and keeps null apart from [], and that the editor reuses .rule-chip. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
176 lines
7.3 KiB
Python
176 lines
7.3 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 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 == []
|
|
|
|
|
|
# ── #4658: 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():
|
|
"""Rule 33's 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
|