From 35b9a26b63df3a20def82c9dd6eb5ef4010c0393 Mon Sep 17 00:00:00 2001
From: leaf-agent <318509791+leaf-agent@users.noreply.github.com>
Date: Mon, 31 Aug 2026 00:15:02 +0000
Subject: [PATCH] Retarget the seated-decision tests onto a widget that
declares the seat
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The two guards over the split between the reader's list and the unanswered
decisions — a thread standing in a widget's own `x-conversation` seat takes it
off the reader's list without answering it — rested on `lf-options`, the one
shipped entry that both awaited and seated. 292de9c made the reader's cell an
add form and dropped `x-conversation`, so the pair could no longer be produced
and both tests have failed on every `ci` run since.
The split is layer-generic on both sides (`seat_with_agent`, `seatWithAgent`),
so declare the pair in the test layer rather than borrowing whichever shipped
tag happens to carry it: a project-package entry and module supplied through
`serve`'s `layer_registry`/`layer_widgets`, the way `lf-feed` and `lf-tally`
already are. Whether `lf-options` should seat a conversation again is left open.
Closes #147
---
tests/CLAUDE.md | 9 ++++
tests/render_cases_interaction.py | 82 +++++++++++++++++++++++++++++++
tests/render_support.py | 4 ++
tests/test_render_controls.py | 61 ++++++++++++-----------
tests/test_render_navigation.py | 46 +++++++++--------
5 files changed, 152 insertions(+), 50 deletions(-)
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()")