diff --git a/tests/CLAUDE.md b/tests/CLAUDE.md index 36e155ef3..2bfcc9146 100644 --- a/tests/CLAUDE.md +++ b/tests/CLAUDE.md @@ -217,6 +217,15 @@ vendored page and use its HTTP API. A render-gate test should call helper directly only when the helper itself carries a contract that would otherwise be hard to diagnose, such as the traffic wait reaching its deadline. +A reading the layer makes from declarations belongs on a widget that declares them, +not on whichever shipped entry currently happens to. `serve` takes `layer_registry` +and `layer_widgets` for that: a project-package entry and its module, written as a +`render_cases_*` constant and reachable from any test module. Borrowing a shipped tag +reads the same for as long as the default package carries the declaration and goes red +the day it stops, with nothing about the reading having changed — which is how the two +guards over `seat_with_agent` came to rest on `lf-options` and failed when its +`x-conversation` came off. + Re-vendor before trusting a result that depends on runtime, theme, registry, or widget changes. A page directory owns the layer copied into it by `page init`; it does not read the checkout's current assets. A green render against a stale page is diff --git a/tests/render_cases_interaction.py b/tests/render_cases_interaction.py index c3231c7d2..fa08b7a2d 100644 --- a/tests/render_cases_interaction.py +++ b/tests/render_cases_interaction.py @@ -1254,3 +1254,85 @@ def resolve(a, b): "", }, ] + +# A widget the shipped packages no longer have: one the reader owes an answer on that +# also seats a conversation of its own. Those two facts together are what produce the +# layer's one split between the reader's list and the unanswered decisions — a thread +# standing in the seat while the agent has the next word takes the widget off the list +# without answering it (`seat_with_agent`, `seatWithAgent`). `lf-options` supplied the +# pair until 292de9c made the reader's cell an add form and dropped `x-conversation`, and +# both halves of the split are still implemented, still described, and reachable by any +# package that declares both. So the guard is declared here rather than borrowed from +# whichever shipped entry happens to carry it: the reading under test is the layer's, and +# the tag it runs on is the layer's business rather than the test's. +SEATED_ASK_TAG = "lf-verdict" +SEATED_ASK_ENTRY = { + "description": ( + "A proposal the reader settles with one press and may talk over first in a seat " + "of its own. `asks` opens the decision; the press settles it." + ), + "type": "object", + "properties": { + "id": {"type": "string", "pattern": "^[a-z0-9][a-z0-9-]*$"}, + "asks": {"type": "boolean"}, + "restated": {"type": "boolean"}, + }, + "required": ["id"], + "additionalProperties": False, + "x-content": "prose", + "x-upgrade": True, + "x-state": { + "settle": { + "detail": { + "type": "object", + "properties": {"answer": {"type": "string", "enum": ["yes", "no"]}}, + "required": ["answer"], + "additionalProperties": False, + }, + "facet": "verdict", + "unit": "widget", + # The prerequisite the split is about. Read off the reader's list this + # refuses a press on a widget whose seat is mid-conversation; read off the + # unanswered decisions, which is what the door owes it, it lets one through. + "requires": {"target": "self", "awaiting": True}, + } + }, + "x-awaits": {"when": {"asks": [True]}, "answers": ["settle"]}, + "x-conversation": {"when": {"asks": [True]}}, + "x-example": 'Ship it?', +} +# The press paints before it sends, which is what `lf-options` does with a pick and the +# reason the browser door matters as much as the POST one: with the wrong list read here +# the answer is already on the page, so a refusal is not a refusal the reader can see — +# the control flips, nothing is logged, and the next poll puts it back saying nothing. +SEATED_ASK_MODULE = """\ +import { conversationBox, offer, once, sendAction } from "/runtime/widget-api.js"; + +customElements.define( + "lf-verdict", + class extends HTMLElement { + connectedCallback() { + if (!once(this)) return; + this.press = offer("button", "lf-settle", "Accept"); + this.press.onclick = () => { + this.settled(); + sendAction(this, "settle", { answer: "yes" }); + }; + this.append(this.press); + const seat = conversationBox(this, "Say something about this"); + if (seat) this.append(seat); + } + + settled() { + this.press.textContent = "Accepted"; + this.press.setAttribute("aria-pressed", "true"); + } + + applyAction(action) { + if (action === "settle") this.settled(); + } + }, +); +""" +SEATED_ASK_LAYER = {SEATED_ASK_TAG: SEATED_ASK_ENTRY} +SEATED_ASK_WIDGETS = {f"{SEATED_ASK_TAG}.js": SEATED_ASK_MODULE} diff --git a/tests/render_support.py b/tests/render_support.py index 5a39520c0..be0c9c9bf 100644 --- a/tests/render_support.py +++ b/tests/render_support.py @@ -51,6 +51,8 @@ ROOMS, ROSTER_PAGE, SCROLL_SETTLED, + SEATED_ASK_LAYER, + SEATED_ASK_WIDGETS, SEATED_QUESTION_PAGE, SETTLED_DECISION_PAGE, SHORT_SUGGESTION, @@ -508,6 +510,8 @@ "SCROLL_SETTLED", "SCROLL_SETTLE_MS", "SCROLL_STILL", + "SEATED_ASK_LAYER", + "SEATED_ASK_WIDGETS", "SEATED_QUESTION_PAGE", "SENTENCE", "SETTLED_DECISION_PAGE", diff --git a/tests/test_render_controls.py b/tests/test_render_controls.py index 7cbb2fef3..e8b55deaa 100644 --- a/tests/test_render_controls.py +++ b/tests/test_render_controls.py @@ -36,6 +36,8 @@ SCROLL_SETTLE_MS, SCROLL_STILL, SCROLLED, + SEATED_ASK_LAYER, + SEATED_ASK_WIDGETS, SUGGESTION_PAGE, TOKEN, UNBREAKABLE_PAGE, @@ -1344,35 +1346,34 @@ def test_a_self_eligibility_check_reads_state_before_its_optimistic_gesture( page.close() -def test_a_seat_conversation_leaves_the_pick_it_is_about_live( - browser, serve, tmp_path, monkeypatch -): +def test_a_seat_conversation_leaves_the_pick_it_is_about_live(browser, serve): """The reader's own remark must not lock the control it is a remark about. - A conversation standing in the group's seat takes the decision off the reader's - list — the banner stops counting it — but answers nothing, so the pick that - would answer it is still live. This is the browser half of the split, and the - half the reader meets first: the POST door only sees a hand-posted event, while - here `actionAvailable` paints the control and `sendAction` guards the press, and - `lf-options` has already painted the pick by the time either runs. Reading the - reader's list at this door therefore does not refuse the press so much as - swallow it — the option flips, nothing is logged, no toast fires, and the next - poll puts it back with nothing anywhere saying why.""" - monkeypatch.chdir(tmp_path) - overlay = tmp_path / ".leaf" - overlay.mkdir() - standard = json.loads((schema_model.DEFAULT_PACKAGE / "registry.json").read_text()) - options = standard["lf-options"] - options["x-state"]["choose"]["requires"] = {"target": "self", "awaiting": True} - (overlay / "registry.json").write_text(json.dumps({"lf-options": options})) + A conversation standing in the widget's seat takes the decision off the reader's + list — the banner stops counting it — but answers nothing, so the press that would + answer it is still live. This is the browser half of the split, and the half the + reader meets first: the POST door only sees a hand-posted event, while here + `actionAvailable` paints the control and `sendAction` guards the press, and the + module has already painted the answer by the time either runs. Reading the reader's + list at this door therefore does not refuse the press so much as swallow it — the + widget flips, nothing is logged, no toast fires, and the next poll puts it back with + nothing anywhere saying why. + + The subject is the project widget SEATED_ASK_ENTRY declares rather than an entry out + of the default package, because the pair the split needs — a visible ask and a seat + of the widget's own — is a pair of declarations and not a tag. No shipped entry has + carried both since 292de9c took `x-conversation` off `lf-options`, and the reading + under test never asked which widget it was.""" url = serve( leaf_page( "seated eligibility", - '

Choose

Which option?

' - '' - 'A' - 'B
', - ) + '

Choose

' + "

Cap the retries?

" + 'Three attempts, then stop.' + "
", + ), + layer_registry=SEATED_ASK_LAYER, + layer_widgets=SEATED_ASK_WIDGETS, ) events_model.append_event( serve.page_dir, @@ -1388,14 +1389,14 @@ def test_a_seat_conversation_leaves_the_pick_it_is_about_live( # Off the reader's list, which is the whole reason the two readings differ here. expect(page.locator(".lf-decisions")).to_have_text("Asks (0)") - page.get_by_role("checkbox", name=re.compile(r"^choose one: A")).click() + page.get_by_role("button", name="Accept").click() round_trip(page) - expect(page.locator("#pick-a")).to_have_attribute("chosen", "") - # The log is what holds this, and the attribute above cannot: the module paints - # the pick before either guard runs, so with the wrong reading at this door the - # option wears `chosen` exactly as it does here and the log stays empty. - assert [event["action"] for event in actions(serve.page_dir)] == ["choose"] + expect(page.get_by_role("button", name="Accepted")).to_have_count(1) + # The log is what holds this, and the control above cannot: the module paints the + # answer before either guard runs, so with the wrong reading at this door the press + # reads exactly as it does here and the log stays empty. + assert [event["action"] for event in actions(serve.page_dir)] == ["settle"] assert errors == [] page.close() diff --git a/tests/test_render_navigation.py b/tests/test_render_navigation.py index f0240da53..9ce457a69 100644 --- a/tests/test_render_navigation.py +++ b/tests/test_render_navigation.py @@ -23,6 +23,8 @@ PANEL_PAGE, RENDERED, ROOT, + SEATED_ASK_LAYER, + SEATED_ASK_WIDGETS, TARGETS_PAGE, TOKEN, WHERE_I_STAND_PAGE, @@ -4528,32 +4530,34 @@ def drop(): def test_the_ring_holds_on_a_seat_the_agent_has_still_to_answer(browser, serve): """Where the reader is standing and what the reader still owes are two facts, and a widget mid-conversation with the agent is where they part. Its seat holds the words - the reader just wrote, its pick is unmade and its controls are live, and it has left + the reader just wrote, its answer is unmade and its controls are live, and it has left the banner and the tray because the next word there is the agent's — but the reader is standing in it all the same, and it is still the question they are working. Read off the reader's list, both the ring and `c` went with the count: the moment the - remark was sent the ring left from under the reader, and `c` slid from the seat they - were writing in down to whichever option their focus rested on. That is a different - conversation, not a shorter way into the same one — `{section: "shape"}` is the seat's - own anchor and `{section: "sh-steel"}` is not — so the next line of a remark landed - somewhere the first line was not. The agent's reply moved both back. Nothing the - reader did moved either, which is the whole of the complaint; the reply phase here is - what says the ring has stopped tracking the count rather than merely tracking it late. + remark was sent the ring left from under the reader, and `c` fell through from the + question to whichever item their focus happened to rest in. That is a different + conversation, not a shorter way into the same one — a remark on the widget is filed + where a remark on the question the widget stands as is not — so the next line of a + remark landed somewhere the first line was not. The agent's reply moved both back. + Nothing the reader did moved either, which is the whole of the complaint; the reply + phase here is what says the ring has stopped tracking the count rather than merely + tracking it late. A picked group is the control on the other side. It is answered, so it is off both readings and must stay off: the switch is about a seat the reader is mid-sentence in, - not about reopening what a pick has closed.""" + not about reopening what a pick has closed. + + The seat and the ask are the project widget SEATED_ASK_ENTRY declares, for the reason + test_a_seat_conversation_leaves_the_pick_it_is_about_live gives: the split is between + two declarations, and since 292de9c no shipped entry carries both.""" url = serve( leaf_page( "mid-sentence", """

Mid-sentence

-

Which material?

- - Steel Galvanised, drop-in. - Cedar Cheap; needs sealing. -
+

Galvanised steel for the frame?

+Drop-in, and it needs no sealing.

Should we keep it?

Keep it Settled by a pick. @@ -4565,7 +4569,9 @@ def test_the_ring_holds_on_a_seat_the_agent_has_still_to_answer(browser, serve): Refill when the camera shows it half-empty.

""", - ) + ), + layer_registry=SEATED_ASK_LAYER, + layer_widgets=SEATED_ASK_WIDGETS, ) d = serve.page_dir events_model.append_event( @@ -4583,8 +4589,8 @@ def test_the_ring_holds_on_a_seat_the_agent_has_still_to_answer(browser, serve): line = page.locator(".lf-keyline") decisions = page.locator(".lf-decisions") - # The premise, from the reader's list itself: the group has left it, the picked group - # was never on it, and the suggestion is what remains to be counted. + # The premise, from the reader's list itself: the seated ask has left it, the picked + # group was never on it, and the suggestion is what remains to be counted. expect(decisions).to_have_text("Asks (1)") decisions.click() expect(page.locator("button.lf-decisions-row")).to_have_count(1) @@ -4598,7 +4604,7 @@ def test_the_ring_holds_on_a_seat_the_agent_has_still_to_answer(browser, serve): # the scroll that brings a row into view is the tray's own reading. So the decision wears # the ring alone, and the tray goes on listing what the reader owes rather than # gaining a row for where they happen to be standing. - page.locator("#shape .lf-pick").first.focus() + page.locator("#shape .lf-settle").focus() expect(page.locator("#shape-decision")).to_have_attribute("data-lf-decision", "1") expect(page.locator("button.lf-decisions-row")).to_have_count(1) assert page.locator(".lf-decisions-row[data-lf-decision]").count() == 0, ( @@ -4608,7 +4614,7 @@ def test_the_ring_holds_on_a_seat_the_agent_has_still_to_answer(browser, serve): decisions.click() # And with it shut, which is every other reading below. - page.locator("#shape .lf-pick").first.focus() + page.locator("#shape .lf-settle").focus() expect(page.locator("#shape-decision")).to_have_attribute("data-lf-decision", "1") expect(line).to_contain_text("comment on the decision") @@ -4634,7 +4640,7 @@ def test_the_ring_holds_on_a_seat_the_agent_has_still_to_answer(browser, serve): ) told(page) expect(decisions).to_have_text("Asks (2)") - expect(page.locator("#shape .lf-pick").first).to_be_focused() + expect(page.locator("#shape .lf-settle")).to_be_focused() expect(page.locator("#shape-decision")).to_have_attribute("data-lf-decision", "1") expect(line).to_contain_text("comment on the decision") page.evaluate("() => document.activeElement?.blur()")