Skip to content
Closed
Show file tree
Hide file tree
Changes from 2 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 plugins/leaf/skills/leaf/assets/leaf.js
Original file line number Diff line number Diff line change
Expand Up @@ -919,6 +919,14 @@ export async function sendAction(el, action, detail, { attempt } = {}) {
return null;
}
sending.set(el.id, (sending.get(el.id) ?? 0) + 1);
// Both edges repaint the line, the way undoLast does for its own record: this store
// is half of unrecordedGesture, which is what `z`'s row asks, so a send starting or
// ending moves whether that key works and no focus event reports it. Without them
// the line's answer trails the dispatcher's by however long until the next paint
// some other signal happens to schedule — the line offering a press the dispatcher
// would refuse, which is the one thing "a key on screen is a key that works" is
// there to rule out. paintHere coalesces to a frame, so the pair costs nothing.
paintKeys();
Comment thread
leaf-agent marked this conversation as resolved.
Resolved
try {
return await post({
kind: "action",
Expand All @@ -932,6 +940,7 @@ export async function sendAction(el, action, detail, { attempt } = {}) {
const left = sending.get(el.id) - 1;
if (left) sending.set(el.id, left);
else sending.delete(el.id);
paintKeys();
}
}

Expand Down
30 changes: 30 additions & 0 deletions tests/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,20 @@ machine's load rather than the product's behaviour: the reading it took was
whichever transient it was quick enough to catch. The scroll stopping is the
fact to wait on; where it stopped is the assertion.

A press the dispatcher can refuse needs the same fact before it, and reads as
neither wait nor assertion until it is given one. `z` is the one press whose
subject is read rather than pointed at, so it is dead while the page holds a
gesture the log has not taken — its predecessor's trip included. Every other press
can be made the moment the paint is there, and the paint arrives a turn before the
trip it was made on is over, so a `z` made on it is refused; what the refusal
leaves behind is the page an assertion on the un-undone state would find anyway.
Nothing distinguishes that from an undo that did the wrong thing, and the suite's
undo tests were failing that way one at a time, a different one per run. The line
and the dispatcher ask one predicate (`live`), so the offer standing on the line is
the page saying this press will be taken — which is what `undo(page)` waits for
before pressing. Where a gesture has a liveness of its own, wait for the page to
state it rather than for the gesture before it to have painted.

Where nothing will happen, there is no fact to consume and polling has no end. An
assertion of absence holds a window instead, and the window's length is derived
from the mechanism rather than picked: a manually launched server must outlive
Expand Down Expand Up @@ -415,6 +429,22 @@ for the reason it gave. What the second press would leave is a line in the log
once the queue drains, which is where a gate on it reads. Before asserting that a
gesture did not travel, ask what would have stopped it anyway.

The same question is owed to a paint, and there it is asked of whoever else was
going to draw the frame. What the line says the instant a gesture leaves is the
door's own statement (`sendAction`), and the first gate written on it was a pick,
made with the send held in the wire and the line read a frame later — which passed
with the door's paint deleted. A pick announces `lf-answered` before its send
leaves, and the paint that announcement schedules runs *after* the handler that
started the send, so it drew the answer the gate was there to demand. Every
neighbouring gesture had a writer of its own: a click focuses what it lands on and
focusin paints, a dropped card repaints its grips, a poll two seconds later paints
whatever else did not. So the gesture the gate ended up on is the one whose widget
does nothing until the log answers (a suggestion's accept, pressed on a control
already focused, on a page settled first), and the reading is taken once rather
than through a retrying `expect`, which the next poll would answer either way.
Where the fact under test is what the page says at an instant, list who else
paints that frame before believing the green.

## An assertion that nothing moved must straddle the change that could move it

A geometry assertion is worth only as much as the transition it is measured
Expand Down
193 changes: 152 additions & 41 deletions tests/test_render.py
Original file line number Diff line number Diff line change
Expand Up @@ -617,6 +617,37 @@ def told(page):
_until(page, lambda t: t.heard > asked, "finished a poll that went out from here")


# `z` is the one press whose subject is read rather than pointed at, so the dispatcher
# holds it dead while the page holds a gesture the log has not taken — this press's own
# trip included (unrecordedGesture). Every other press acts on what is under the reader
# and can be made the moment the paint is there; the paint arrives a turn before the
# trip it was made on is over, so a `z` made on it is a press the dispatcher refuses,
# and what the refusal leaves behind is the page an assertion on the un-undone state
# would find anyway. A test cannot tell that from an undo that did the wrong thing: it
# reads the no-op as the wrong answer, or waits its budget out and calls it a hang.
#
# So the press waits on the fact the page states rather than on the gesture before it
# having painted. The line and the dispatcher ask one predicate (`live`), so no surface
# can promise a press the dispatcher refuses — which makes the offer standing on the
# line the page saying this press will be taken. Then the trip: the press's own send is
# not counted at the moment it is made, and a `round_trip` that reads the counters
# before the browser has reported the request is a wait on a page that has not started
# moving.
#
# The line answers about room as well as about liveness: renderLine drops chips from the
# end when the window is too narrow to hold them, and the end is the outside of the
# stack, where a page-level key like this one sits. So the wait needs the suite's
# default 1200×900 or something near it; under a viewport set narrow on purpose it would
# run its budget out on a press that is perfectly live.
def undo(page):
"""Take the last gesture back, from the moment the line offers to."""
expect(page.locator(".lf-keyline")).to_contain_text("undo")
sent = _traffic(page).sends
page.keyboard.press("z")
_until(page, lambda t: t.sends > sent, "put the withdrawal in the wire")
round_trip(page)


# A poll a test stops is cancelled rather than failed. The page cannot tell the two
# apart — both reject the fetch the runtime awaits and leave it on the same `catch` —
# and `requestfailed` fires for either, so the trip still counts as over and every wait
Expand Down Expand Up @@ -6110,8 +6141,7 @@ def test_a_thread_reopened_mid_fold_folds_again_when_it_settles(browser, serve):
folds = page.evaluate("window.__lfHeld.length")
assert folds == before + 1, "the press drew something other than its one fold"

page.keyboard.press("z")
round_trip(page)
undo(page)
expect(thread).to_have_count(1)
expect(going).to_have_count(0)
# News the thread takes while it is open again, which the node the first fold was
Expand Down Expand Up @@ -14471,10 +14501,8 @@ def test_z_takes_back_the_thread_the_reader_just_resolved(browser, serve):

page.locator(f'.lf-thread[data-id="{comment}"] .lf-resolve').click()
round_trip(page)
expect(page.locator(".lf-keyline")).to_contain_text("undo")

page.keyboard.press("z")
round_trip(page)
undo(page)
expect(
page.locator(f'.lf-threads > .lf-thread[data-id="{comment}"]')
).to_have_count(1)
Expand Down Expand Up @@ -14514,8 +14542,7 @@ def test_z_puts_a_card_back_where_the_version_had_it(browser, serve):
round_trip(page)
expect(page.locator("#col-done #card-baffle")).to_have_count(1)

page.keyboard.press("z")
round_trip(page)
undo(page)
expect(page.locator("#col-todo #card-baffle")).to_have_count(1)
# Second of the two, as the version wrote it — not merely back on the list.
assert page.eval_on_selector_all(
Expand Down Expand Up @@ -14617,12 +14644,7 @@ def hold(route):

held[0].continue_()
round_trip(page)
# The response reaching the browser precedes sendAction's final continuation.
# The undo row is the page's own statement that the move is in the log and the
# next press can read it, rather than a timing guess over that last turn.
expect(page.locator(".lf-keyline")).to_contain_text("undo")
page.keyboard.press("z")
round_trip(page)
undo(page)
# The newest move, which is the one the reader would have meant — and the older
# one still stands, where taking back the wrong gesture would have reversed it.
expect(page.locator("#col-todo #card-baffle")).to_have_count(1)
Expand All @@ -14631,6 +14653,102 @@ def hold(route):
page.close()


# What the next two read is the line at one instant, and both of them had to be written
# around the ways a gate on that instant is born vacuous — every one of which is some
# other writer painting for the door and the assertion never noticing.
#
# The press is a key on a control already focused, never a click: a click focuses what it
# lands on, and focusin's paint is scheduled before the handler that starts the send and
# runs after it, so the line would draw the moved store whichever way the door behaved.
# The widget is the suggestion for the same reason from the other side — a pick announces
# `lf-answered` and a dropped card repaints its own grips, both of them before their send
# leaves, where a decision's whole choreography waits for the log. And the page is settled
# first (`page_at_rest`), because a paint already scheduled when the press lands reads the
# moved store on the door's behalf too.
#
# The reading itself is taken once, a frame after the press, rather than through a
# retrying `expect`: every poll ends in a paint, so a retry is answered inside two seconds
# whether or not the door painted anything — on a line that spent that window saying the
# wrong thing, which is the whole of what these two are here to catch.
def decided(page, sug):
"""Press the accept the reader is standing on, and say where they were standing."""
accept = page.locator(f"[data-lf-for='{sug}'] .lf-sug-accept")
accept.focus()
page_at_rest(page)
expect(accept).to_be_focused()
page.keyboard.press("Enter")


def test_the_line_drops_undo_as_the_gesture_goes_into_the_wire(browser, serve):
"""`sending` moves the moment a gesture leaves, and the dispatcher reads it at once
— so between that instant and whatever paints next, the line stands offering a press
the page would refuse. Nothing else reports the store moving: it is not the DOM, and
no focus event fires for it, so the paint is owed by the door the gesture goes out
through (sendAction) rather than by whoever happens past next."""
page, errors = open_page(browser, serve(SUGGESTION_PAGE))
line = page.locator(".lf-keyline")
decided(page, "sug-refill")
round_trip(page)
expect(page.locator("#sug-refill lf-old")).to_be_hidden()
page_at_rest(page)
assert "undo" in line.inner_text()

held = []
page.route("**/api/event", lambda route: held.append(route))
# Held in the wire, so the store stays moved for as long as the reading takes — and
# the reading is taken where the reader's next press would land, a poll interval away
# from the nearest thing that paints for its own reasons.
with page.expect_request("**/api/event"):
decided(page, "sug-thistle")
page.evaluate(RENDERED)
assert "undo" not in line.inner_text(), (
"the line offered a press the dispatcher was already refusing"
)

held[0].continue_()
round_trip(page)
page.unroute("**/api/event")
assert errors == []
page.close()


def test_the_line_offers_undo_again_when_a_send_the_server_refused_ends(browser, serve):
"""The other edge of the same store, and the one path where it is the door that has
to report it: a send the server takes is answered by a poll, and that poll paints on
its own account. A refused send returns before ever polling — the page says so with a
toast and puts back whatever the gesture painted — so the press that was live the
whole time goes unoffered until some later signal happens past.

Live is the log's word and the refusal wrote nothing to it: the decision standing in
it is the earlier one, which is the gesture `z` would take back."""
page, errors = open_page(browser, serve(SUGGESTION_PAGE))
line = page.locator(".lf-keyline")
decided(page, "sug-refill")
round_trip(page)
expect(page.locator("#sug-refill lf-old")).to_be_hidden()
page_at_rest(page)
assert "undo" in line.inner_text()

# Aborted plainly rather than cancelled: the send failing is this test's subject, and
# the console entry it leaves is one of the things the page says about it.
page.route("**/api/event", lambda route: route.abort())
decided(page, "sug-thistle")
# The toast is the page stating the send is over, and post writes it on the way out —
# before the door's own edge, which is what makes it a fact to read the line from.
expect(page.locator(".lf-toast")).to_contain_text("Couldn't send")
page.evaluate(RENDERED)
assert "undo" in line.inner_text(), (
"the line withheld a press the dispatcher was ready to take"
)
# And nothing was decided by the press that failed, so what the offer names is the
# accept before it.
expect(page.locator("#sug-thistle lf-new")).to_be_visible()
Comment thread
leaf-agent marked this conversation as resolved.
Resolved
assert [e["action"] for e in actions(serve.page_dir)] == ["accept"]
page.unroute("**/api/event")
assert [e for e in errors if "net::ERR_FAILED" not in e] == []
Comment thread
leaf-agent marked this conversation as resolved.
Resolved
page.close()


def test_z_walks_back_through_gestures_rather_than_toggling_one(browser, serve):
"""The walk steps past what it has already taken and reaches the gesture before
it — the edit here, whose authored text comes back with its paragraphs, where
Expand All @@ -14651,15 +14769,13 @@ def test_z_walks_back_through_gestures_rather_than_toggling_one(browser, serve):
round_trip(page)
expect(page.locator("lf-option[chosen]")).to_have_attribute("id", "opt-a")

page.keyboard.press("z")
round_trip(page)
undo(page)
expect(page.locator("lf-option[chosen]")).to_have_attribute("id", "opt-b")
# Replay restoring the option and the undo send finishing are adjacent but
# distinct facts. Wait for the register to offer the earlier gesture before
# pressing it, as a reader would see it offered.
expect(page.locator(".lf-keyline")).to_contain_text("undo")
page.keyboard.press("z")
round_trip(page)
undo(page)
# The arrival first, read the way every assertion after a trip reads it, and then
# the reading a retry cannot make: `to_have_text` collapses the blank line, which
# is the very thing the authored text is here to bring back.
expect(body).to_have_text(authored)
assert body.inner_text() == authored
# Two gestures and two words taking them back, newest first: nothing in the log
# claims the reader chose opt-b or typed the authored draft, because they did
Expand Down Expand Up @@ -14700,8 +14816,7 @@ def test_z_takes_back_a_decision_no_state_can_state(browser, serve):
expect(old).to_be_hidden()
expect(page.locator(".lf-asks")).to_have_text("Asks (2)")

page.keyboard.press("z")
round_trip(page)
undo(page)
# Pending again, in every reading of it: the retired half is back on the page,
# the control offers the decision rather than recording it, and the banner
# counts the question among the ones still waiting on the reader.
Expand Down Expand Up @@ -14763,8 +14878,7 @@ def test_a_rebuild_hands_back_the_place_and_the_marks(browser, serve):
page.wait_for_function(f"{marks} === 0")
expect(accept).to_be_focused()

page.keyboard.press("z")
round_trip(page)
undo(page)
page.wait_for_function(f"{marks} === 1")
expect(page.locator("[data-lf-for='sug-refill'] .lf-sug-accept")).to_be_focused()
assert errors == []
Expand All @@ -14781,8 +14895,7 @@ def test_a_rebuild_leaves_a_reader_standing_elsewhere_where_they_are(browser, se
round_trip(page)
page.evaluate("() => document.body.focus()")

page.keyboard.press("z")
round_trip(page)
undo(page)
expect(page.locator("#sug-refill lf-old")).to_be_visible()
assert page.evaluate("() => document.activeElement === document.body")
assert errors == []
Expand Down Expand Up @@ -14813,8 +14926,7 @@ def test_a_withdrawal_waits_for_a_widget_that_cannot_take_it_yet(browser, serve)
# The second tab is now holding words of its own, so the log may not write over it.
two.locator("lf-draft .lf-draft-pencil").click()
expect(two.locator("lf-draft textarea")).to_be_focused()
one.keyboard.press("z")
round_trip(one)
undo(one)
expect(one.locator(body)).to_have_text(authored)

# The withdrawal has to reach the second tab *while* its editor stands — heard
Expand Down Expand Up @@ -14877,9 +14989,13 @@ def test_a_withdrawal_restores_what_still_stands_not_what_stood_then(browser, se
heard, errors_heard = open_page(browser, url)
assert heard.eval_on_selector_all(*order) == standing

# Not undo(): the helper ends in round_trip, and this tab's polls are stopped, so
# what it sends can never come back to it. The offer the helper waits on first is
# the part that still applies — the line and the dispatcher ask one predicate, so
# a press made before it stands is one the dispatcher refuses.
expect(stale.locator(".lf-keyline")).to_contain_text("undo")
stale.keyboard.press("z")
# Not round_trip: this tab's polls are stopped, so what it sent can never come
# back to it. The server answering the post is the fact this wait consumes.
# The server answering the post is the fact this wait consumes.
_until(stale, lambda t: t.acked >= 2, "heard the server take the undo")
stale.unroute("**/api/state")
told(heard)
Expand Down Expand Up @@ -14937,8 +15053,7 @@ def test_a_withdrawal_is_heard_by_a_tab_reading_a_later_version(browser, serve):
# Replay carries the v1 move onto v2, so this tab is showing it.
expect(moved_on.locator("#col-done #card-baffle")).to_have_count(1)

pinned.keyboard.press("z")
round_trip(pinned)
undo(pinned)
told(moved_on)
told(moved_on)
expect(moved_on.locator("#col-todo #card-baffle")).to_have_count(1)
Expand Down Expand Up @@ -14971,8 +15086,7 @@ def test_a_second_tab_takes_the_decision_back_too(browser, serve):
round_trip(one)
expect(two.locator("#sug-refill lf-old")).to_be_hidden()

one.keyboard.press("z")
round_trip(one)
undo(one)
expect(two.locator("#sug-refill lf-old")).to_be_visible()
expect(two.locator(".lf-asks")).to_have_text("Asks (3)")
# Everything the change had when it was pending, including what the theme paints
Expand Down Expand Up @@ -15023,8 +15137,7 @@ def test_a_rebuild_keeps_what_the_reader_did_inside_the_change(browser, serve):

page.locator("[data-lf-for='sug-thistle'] .lf-sug-accept").click()
round_trip(page)
page.keyboard.press("z")
round_trip(page)
undo(page)

expect(page.locator("#sug-thistle lf-old, #sug-thistle lf-new")).to_have_count(1)
expect(page.locator("[data-lf-for='sug-thistle'] .lf-sug-accept")).to_have_text(
Expand All @@ -15045,8 +15158,7 @@ def test_a_withdrawn_decision_is_still_withdrawn_after_a_reload(browser, serve):
page.locator("[data-lf-for='sug-refill'] .lf-sug-accept").click()
round_trip(page)
expect(page.locator("#sug-refill lf-old")).to_be_hidden()
page.keyboard.press("z")
round_trip(page)
undo(page)
page.close()

again, errors = open_page(browser, url)
Expand Down Expand Up @@ -21582,8 +21694,7 @@ def test_the_thread_follows_the_decision_that_still_stands(browser, serve):
# were in is not something the log records, and not something a withdrawal
# could turn on. The widget goes back to the markup and the surviving log is
# replayed onto it, so what the press restores is the accept, not a blank slate.
page.keyboard.press("z")
round_trip(page)
undo(page)
expect(page.locator("#sug-fix")).to_have_attribute("data-lf-state", "accept")
expect(reopened).to_have_count(0)
expect(page.locator(".lf-details summary")).to_have_text("Resolved (1)")
Expand Down
Loading