feat(lessons): the web editor records which rule a lesson is an instance of (milestone 440, #4658)
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>
This commit is contained in:
2026-10-01 16:36:43 -04:00
co-authored by Claude Opus 5.5
parent 75cefe60e4
commit cf4206469b
4 changed files with 354 additions and 3 deletions
+59 -1
View File
@@ -16,6 +16,7 @@ side fails here rather than rendering a page that quietly reads wrong:
"""
from __future__ import annotations
import inspect
import re
from pathlib import Path
@@ -85,7 +86,11 @@ def test_both_pages_link_through_to_the_other_record():
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"):
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
@@ -115,3 +120,56 @@ def test_no_page_spells_its_own_write_check():
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