fix(r2): the heid bug-hunt panel (round "Nyx", 4/4) — triaged and folded
In-place client (base.html): - Saves are serialized: POST, re-fetch and swap complete before the next save starts, so an older snapshot can no longer land after a newer one. - A form already queued or in flight ignores another submit; a double-click writes one note. - Dirty controls (drafts, unsent radio choices) and disclosures carry by identity (form action + hidden ask/target/mark/f + name), not position. - Any non-tile structural difference, or a page with no region to swap, reloads instead of patching. Server and templates: - .seen is a JSON array read without following links or blocking, regular files of at most 1 MiB only; malformed, nested-too-deep or planted markers read as nothing seen. - landed_at reads symlinks by lstat and skips one unreadable entry instead of pinning the booth in "new". - The Desk counts flags on current items only; orphan flags are listed under the tray with an unmark form. - Agent-written bench and bookmark URLs link only when http(s). - Audio and video tiles carry a review link. - A rel the filesystem cannot represent is a 404, not a 500. - A non-finite Accept q-value fails to parse. - The standalone marks page has regions and updates in place. - The review's next arrow sits at the edge at phone width. Contract amended for each, plus an accepted-risks section (unlocked .seen read-modify-write, a planted .viewed symlink, Item.ordinal with no default). 741 passed. Each new browser test was mutation-checked against its fix; the serialization test forces the race with a held first refresh, since localhost alone never lost it.
This commit is contained in:
@@ -250,3 +250,127 @@ def test_on_a_narrow_screen_flags_and_notes_fold_and_on_a_wide_one_they_show(bro
|
||||
page.locator(".verdict summary.v-fold-head").first.click()
|
||||
assert tray.is_visible()
|
||||
ctx.close()
|
||||
|
||||
|
||||
# ---- fixups from the heid bug-hunt panel (round "Nyx") ------------------------
|
||||
|
||||
def test_the_link_board_still_confirms_before_removing_a_row(browser, live):
|
||||
"""Nyx N1 (2/4): the R2 rewrite of booth.html's scripts deleted the board's
|
||||
multi-select + confirmation script along with the handlers it replaced.
|
||||
Removing a row is destructive; the confirm naming it must still stand in
|
||||
front of the POST, and select-all must still select."""
|
||||
base, root = live
|
||||
board = root / "links"
|
||||
board.mkdir()
|
||||
(board / "links.md").write_text(
|
||||
"- [one](http://x/1) <sub>· a · 2026-09-01 10:00</sub>\n"
|
||||
"- [two](http://x/2) <sub>· a · 2026-09-01 10:01</sub>\n")
|
||||
page = browser.new_page()
|
||||
page.goto(f"{base}/b/links/", wait_until="networkidle")
|
||||
dialogs = []
|
||||
page.on("dialog", lambda d: (dialogs.append(d.message), d.dismiss()))
|
||||
page.locator(".board-rm-btn").first.click()
|
||||
page.wait_for_timeout(300)
|
||||
page.locator("#board-selall").check()
|
||||
ticked = page.eval_on_selector_all(".board-check", "els => els.filter(e => e.checked).length")
|
||||
page.close()
|
||||
assert dialogs and "Remove this link?" in dialogs[0]
|
||||
assert ticked == 2
|
||||
assert (board / "links.md").read_text().count("- [") == 2, "a dismissed confirm removed nothing"
|
||||
|
||||
|
||||
def test_an_unsaved_choice_survives_a_save_elsewhere_and_a_double_click_writes_once(browser, live):
|
||||
"""Nyx N5 (hulda, regin, groa): a picked-but-unsent radio was reset by any
|
||||
other in-place save, drafts were matched by POSITION, and a double-click on
|
||||
Add note wrote two notes. Now: dirty controls carry by identity, and a form
|
||||
already in flight ignores a second submit."""
|
||||
from booth.marks import declare_pick, marks_for
|
||||
base, root = live
|
||||
b = _set(root, 3)
|
||||
declare_pick(b, "q", {"prompt": "Which?", "options": ["x", "y"]})
|
||||
page = browser.new_page(viewport={"width": 1400, "height": 900})
|
||||
page.goto(f"{base}/b/g/", wait_until="networkidle")
|
||||
page.locator('.verdict input[type=radio][value="y"]').check()
|
||||
page.locator('figure.item[data-item="02.png"] .flagtoggle button').click()
|
||||
page.wait_for_selector('figure.item.is-flagged[data-item="02.png"]', timeout=10000)
|
||||
assert page.locator('.verdict input[type=radio][value="y"]').is_checked()
|
||||
|
||||
page.locator(".verdict .mark-add textarea").fill("once")
|
||||
page.locator(".verdict .mark-add button").dblclick()
|
||||
page.wait_for_timeout(1500)
|
||||
page.close()
|
||||
assert [m.text for m in marks_for(b) if m.shape == "note"] == ["once"]
|
||||
|
||||
|
||||
# The first page refresh after a save is held back 1s in the CLIENT: the server
|
||||
# renders it at once (so it carries only the first flag) and the browser sees
|
||||
# it late. Localhost alone never loses that race, so without the hold the test
|
||||
# passed with sequencing deleted — it has to be forced to be a control.
|
||||
_HOLD_FIRST_REFRESH = """
|
||||
(function () {
|
||||
var real = window.fetch, n = 0;
|
||||
window.fetch = function (u, o) {
|
||||
var p = real.apply(this, arguments);
|
||||
if ((!o || !o.method || o.method === 'GET') && n++ === 0) {
|
||||
return p.then(function (r) {
|
||||
return new Promise(function (res) { setTimeout(function () { res(r); }, 1000); });
|
||||
});
|
||||
}
|
||||
return p;
|
||||
};
|
||||
})();
|
||||
"""
|
||||
|
||||
|
||||
def test_quick_successive_flags_all_show(browser, live):
|
||||
"""Nyx (hulda): with no sequencing, an older refresh landing after a newer
|
||||
one showed the newer flag as gone. Saves are serialized."""
|
||||
base, root = live
|
||||
_set(root, 4)
|
||||
page = browser.new_page(viewport={"width": 1400, "height": 900})
|
||||
page.add_init_script(_HOLD_FIRST_REFRESH)
|
||||
page.goto(f"{base}/b/g/", wait_until="networkidle")
|
||||
for rel in ("01.png", "02.png", "03.png"):
|
||||
page.locator(f'figure.item[data-item="{rel}"] .flagtoggle button').click()
|
||||
page.wait_for_timeout(150)
|
||||
page.wait_for_function(
|
||||
"document.querySelectorAll('figure.item.is-flagged').length === 3", timeout=10000)
|
||||
page.wait_for_timeout(1500)
|
||||
n = page.locator("figure.item.is-flagged").count()
|
||||
page.close()
|
||||
assert n == 3
|
||||
|
||||
|
||||
def test_the_standalone_marks_page_updates_in_place(browser, live):
|
||||
"""Nyx (kimi): the marks page's forms are in-place, but the page had no
|
||||
region, so an answer saved and the page never showed it."""
|
||||
from booth.marks import declare_pick
|
||||
base, root = live
|
||||
b = _set(root, 1)
|
||||
declare_pick(b, "q", {"prompt": "Which?", "options": ["x", "y"]})
|
||||
page = browser.new_page()
|
||||
page.goto(f"{base}/b/g/marks", wait_until="networkidle")
|
||||
page.evaluate("window.__same_page = 1")
|
||||
page.locator('input[type=radio][value="x"]').check()
|
||||
page.locator(".mark-submit").click()
|
||||
page.wait_for_selector(".mark.is-answered", timeout=10000)
|
||||
# In place, not the reload fallback: the page's own window survived.
|
||||
same = page.evaluate("window.__same_page === 1")
|
||||
page.close()
|
||||
assert same
|
||||
|
||||
|
||||
def test_the_next_arrow_clears_the_rail_only_beside_it(browser, live):
|
||||
"""Nyx: view.html's bare `.vnext{right:360px}` came later in the page than
|
||||
base.html's narrow override and won it, parking the arrow 360px in from
|
||||
the edge of a phone. Wide: it clears the rail. Narrow: it sits at the edge."""
|
||||
base, root = live
|
||||
_set(root, 3)
|
||||
rights = {}
|
||||
for w in (1400, 390):
|
||||
page = browser.new_page(viewport={"width": w, "height": 900})
|
||||
page.goto(f"{base}/b/g/view?f=01.png", wait_until="networkidle")
|
||||
rights[w] = page.evaluate(
|
||||
"getComputedStyle(document.querySelector('.vnav.vnext')).right")
|
||||
page.close()
|
||||
assert rights == {1400: "360px", 390: "0px"}
|
||||
|
||||
Reference in New Issue
Block a user