Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions tests/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
82 changes: 82 additions & 0 deletions tests/render_cases_interaction.py
Original file line number Diff line number Diff line change
Expand Up @@ -1254,3 +1254,85 @@ def resolve(a, b):
"</lf-options></lf-decision>",
},
]

# 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": '<lf-verdict id="verdict-example" asks>Ship it?</lf-verdict>',
}
# 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}
4 changes: 4 additions & 0 deletions tests/render_support.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,8 @@
ROOMS,
ROSTER_PAGE,
SCROLL_SETTLED,
SEATED_ASK_LAYER,
SEATED_ASK_WIDGETS,
SEATED_QUESTION_PAGE,
SETTLED_DECISION_PAGE,
SHORT_SUGGESTION,
Expand Down Expand Up @@ -508,6 +510,8 @@
"SCROLL_SETTLED",
"SCROLL_SETTLE_MS",
"SCROLL_STILL",
"SEATED_ASK_LAYER",
"SEATED_ASK_WIDGETS",
"SEATED_QUESTION_PAGE",
"SENTENCE",
"SETTLED_DECISION_PAGE",
Expand Down
61 changes: 31 additions & 30 deletions tests/test_render_controls.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@
SCROLL_SETTLE_MS,
SCROLL_STILL,
SCROLLED,
SEATED_ASK_LAYER,
SEATED_ASK_WIDGETS,
SUGGESTION_PAGE,
TOKEN,
UNBREAKABLE_PAGE,
Expand Down Expand Up @@ -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",
'<h1 id="heading">Choose</h1><lf-decision id="pick-decision"><h2>Which option?</h2>'
'<lf-options id="pick" choose>'
'<lf-option id="pick-a">A</lf-option>'
'<lf-option id="pick-b">B</lf-option></lf-options></lf-decision>',
)
'<h1 id="heading">Choose</h1><lf-decision id="pick-decision">'
"<h2>Cap the retries?</h2>"
'<lf-verdict id="pick" asks>Three attempts, then stop.</lf-verdict>'
"</lf-decision>",
),
layer_registry=SEATED_ASK_LAYER,
layer_widgets=SEATED_ASK_WIDGETS,
)
events_model.append_event(
serve.page_dir,
Expand All @@ -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()

Expand Down
46 changes: 26 additions & 20 deletions tests/test_render_navigation.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@
PANEL_PAGE,
RENDERED,
ROOT,
SEATED_ASK_LAYER,
SEATED_ASK_WIDGETS,
TARGETS_PAGE,
TOKEN,
WHERE_I_STAND_PAGE,
Expand Down Expand Up @@ -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",
"""
<h1 id="t">Mid-sentence</h1>
<lf-decision id="shape-decision"><h2>Which material?</h2>
<lf-options id="shape" choose>
<lf-option id="sh-steel"><strong>Steel</strong> Galvanised, drop-in.</lf-option>
<lf-option id="sh-cedar"><strong>Cedar</strong> Cheap; needs sealing.</lf-option>
</lf-options></lf-decision>
<lf-decision id="shape-decision"><h2>Galvanised steel for the frame?</h2>
<lf-verdict id="shape" asks>Drop-in, and it needs no sealing.</lf-verdict></lf-decision>
<lf-decision id="picked-decision"><h2>Should we keep it?</h2>
<lf-options id="picked" choose>
<lf-option id="pk-keep" chosen><strong>Keep it</strong> Settled by a pick.</lf-option>
Expand All @@ -4565,7 +4569,9 @@ def test_the_ring_holds_on_a_seat_the_agent_has_still_to_answer(browser, serve):
<lf-new>Refill when the camera shows it half-empty.</lf-new>
</lf-suggestion></p>
""",
)
),
layer_registry=SEATED_ASK_LAYER,
layer_widgets=SEATED_ASK_WIDGETS,
)
d = serve.page_dir
events_model.append_event(
Expand All @@ -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)
Expand All @@ -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, (
Expand All @@ -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")

Expand All @@ -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()")
Expand Down
Loading