Files
booth/tests/test_antislop_s5b_browser.py
T
vh 0233ca64fb fix(as-S5b): focus survives a swap, a status line you can see, drafts that ask before they go
The in-place client half of the anti-slop interaction work (guidelines
G1, G2, G4, G13). It still never re-POSTs, still serializes saves, and a
batch still never reloads.
- Focus: the focused element is recorded by identity (its region, its
  key, which same-key element it was) and the fresh one is focused
  without scrolling. If an answered pick's form folds into a closed
  <details>, focus goes to its summary; if nothing is left, to the region
  (tabindex=-1, set by the script). Focus outside the swapped regions
  is not touched.
- One status line per page (_status.html). It floats at the bottom
  centre, above the fixed review stage, so it moves nothing and is in view
  wherever the reader is. Wider than 900px, the letterhead and footer the
  review covers leave the Tab order (visibility:hidden, CSS only).
- The line is never hidden: empty, it takes no space and stays displayed.
  "Saving…" at the press, "Still saving…" on a repeat press, "Saved." when
  the swap lands (cleared after 2s if still the same write), and warnings
  with data-tone="warn". Every write sets or clears the tone. The form in
  flight carries aria-busy until its save settles.
- The client never reloads over a draft: both of its reloads run only
  when every in-place form is clean except the one just sent, unchanged
  since its press, asked again at the reload beat; otherwise it says so
  and stays. A beforeunload guard asks when an in-place form is dirty (its
  own reload does not ask). The embed asks when one of our answers is
  unsent, and skips the pressed form on its own one-form submit.
- Six booth-dev browser tests read the line's hidden state; they read
  its words and tone instead. Two r2_submit_all.toml rows are re-anchored
  to the same failure in the moved code.
Folded from the heid bug-hunt (panel 4/4, thread 01M3MRTNTWEPJHTN4APRR81KH4):
- aria-busy mirrors which forms are in flight on the LIVE page. It is set
  at the press and re-synced whenever a save settles, so it ends on every
  path (a stale tile the swap never replaced included), and a queued form
  replaced by an earlier swap is marked busy again.
- A press inside the reload beat cancels the reload.
- A failure that stayed is said again after an unrelated save, rather
  than buried under "Saved.".
- A 204 followed by a failed page GET is "Saved.", never "could not save".
- An edit made while its save flew is said to be unsaved.
- A focused <summary> has a key.
- The queue settles on rejection.
- The embed's skip covers the one navigation its submit starts; a
  cancelled submit, or one that leaves the page in place, is guarded
  again.
- Pinned: no in-place form holds a control dirty() cannot read, and no
  region nests in another.
Folded from this slice's gate: the status line floats (fixed, bottom
centre, above the review stage) instead of sitting at the top of <main>
or under the viewer's bar. In the flow, every save's "Saving…" moved the
page; booth-dev's test_a_flag_lands_in_place_and_every_region_catches_up
caught a 50px jump. The viewers' grids are back as they were.

Contract: as_antislop S5b (heid contract review and bug-hunt folded).
Falsifiers: antislop.toml S5b sections.
2026-09-28 15:42:37 -07:00

809 lines
33 KiB
Python

"""S5b of the anti-slop fix slices (docs/contracts/as_antislop.contract.md):
the in-place client's focus, its status line, and the unsent-draft guard.
Measured in the same real uvicorn + Chromium harness as test_flow_browser.
Skips, never fails, when no usable Chromium exists. No test here hears a
screen reader: what is held is the precondition (a displayed live region whose
text changes), not the announcement.
"""
import pathlib
import sys
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent))
from test_flow_browser import PNG, _picks, _set, browser, live # noqa: E402,F401 (fixtures)
WIDE = {"width": 1400, "height": 900}
# Every POST waits for the test: window.__release() lets the oldest one through
# to the server, window.__release('fail') answers it 500 without sending it.
HOLD = """
(function () {
var real = window.fetch;
window.__held = [];
window.fetch = function (u, o) {
if (!(o && o.method === 'POST')) return real.apply(this, arguments);
var self = this, args = arguments;
return new Promise(function (res, rej) {
window.__held.push(function (how) {
if (how === 'fail') res(new Response('', {status: 500}));
else real.apply(self, args).then(res, rej);
});
});
};
window.__release = function (how) { var f = window.__held.shift(); if (f) f(how); return !!f; };
})();
"""
# Counts swaps, and marks the document so a reload is visible.
COUNT = """
document.addEventListener('booth:swapped', function () { window.__swaps = (window.__swaps || 0) + 1; });
"""
LINE = '[data-region="status"]'
def _page(browser, base, path, hold=False, viewport=WIDE):
page = browser.new_page(viewport=viewport)
page.add_init_script(COUNT)
if hold:
page.add_init_script(HOLD)
page.goto(f"{base}{path}", wait_until="networkidle")
page.evaluate("window.__same = 1")
return page
def _said(page):
return page.evaluate("""() => { const s = document.querySelector('[data-region="status"]');
return {text: s.textContent, tone: s.getAttribute('data-tone')}; }""")
def _wait_said(page, needle, timeout=10000):
page.wait_for_function(
"n => document.querySelector('[data-region=\"status\"]').textContent.includes(n)", arg=needle,
timeout=timeout)
def _wait_swaps(page, n, timeout=10000):
page.wait_for_function("n => (window.__swaps || 0) >= n", arg=n, timeout=timeout)
def _wait_held(page, n=1, timeout=5000):
page.wait_for_function("n => window.__held.length >= n", arg=n, timeout=timeout)
def _focused(page, selector, index=0):
return page.evaluate(
"([s, i]) => document.activeElement === document.querySelectorAll(s)[i]", [selector, index])
# ---- G1: focus survives a swap -------------------------------------------------------
def test_focus_returns_to_the_pressed_control(browser, live):
base, root = live
b = _set(root, 3)
page = _page(browser, base, "/b/g/")
flag = 'figure.item[data-item="02.png"] .flagtoggle button'
page.locator(flag).focus()
y = page.evaluate("scrollY")
page.keyboard.press("Enter")
page.wait_for_selector('figure.item.is-flagged[data-item="02.png"]', timeout=10000)
_wait_swaps(page, 1)
assert _focused(page, flag), page.evaluate("document.activeElement.outerHTML.slice(0, 120)")
assert abs(page.evaluate("scrollY") - y) <= 1
page.locator(".verdict .mark-add textarea").fill("a note")
page.locator(".verdict .mark-add button").focus()
page.keyboard.press("Enter")
_wait_swaps(page, 2)
assert _focused(page, ".verdict .mark-add button")
page.close()
_picks(b, ("a1",))
page = _page(browser, base, "/b/g/marks")
page.check('#mark-a1 input[type=radio][value="yes"]')
page.locator("#mark-a1 .mark-submit").focus()
page.keyboard.press("Enter")
page.wait_for_function("document.querySelectorAll('.mark-pick.is-answered').length === 1", timeout=10000)
_wait_swaps(page, 1)
# answered, the pick's form folds into a closed <details>: focus goes to the
# summary that opens it again, never to <body>
assert page.locator("#mark-a1 details.mark-formwrap").get_attribute("open") is None
assert _focused(page, "#mark-a1 details.mark-formwrap > summary"), \
page.evaluate("document.activeElement.outerHTML.slice(0, 120)")
page.close()
def test_focus_picks_the_same_one_of_two(browser, live):
from booth.marks import set_flag, write_note
base, root = live
b = _set(root, 3)
set_flag(b, "01.png", True)
write_note(b, "01.png", "one")
write_note(b, "01.png", "two")
page = _page(browser, base, "/b/g/")
same = '[data-region="verdict"] a[href="view?f=01.png"]'
assert page.locator(same).count() >= 2, "positive control: one region, two links with one key"
page.evaluate("s => document.querySelectorAll(s)[1].focus()", same)
page.evaluate("document.querySelector('figure.item[data-item=\"03.png\"] form.flagtoggle').requestSubmit()")
_wait_swaps(page, 1)
assert _focused(page, same, 1), page.evaluate("document.activeElement.outerHTML.slice(0, 120)")
page.close()
def test_focus_lands_on_the_region_when_the_control_is_gone(browser, live):
from booth.marks import write_note
base, root = live
b = _set(root, 3)
write_note(b, "01.png", "gone soon")
page = _page(browser, base, "/b/g/")
page.locator('figure.item[data-item="01.png"] .item-note .mark-x').focus()
page.keyboard.press("Enter")
page.wait_for_function(
"document.querySelectorAll('figure.item[data-item=\"01.png\"] .item-note').length === 0", timeout=10000)
_wait_swaps(page, 1)
region = '[data-region="item-01.png"]'
assert _focused(page, region), page.evaluate("document.activeElement.tagName")
page.evaluate("document.querySelector('figure.item[data-item=\"02.png\"] form.flagtoggle').requestSubmit()")
_wait_swaps(page, 2)
assert _focused(page, region), "a second swap dropped focus from the region"
assert page.evaluate("document.activeElement !== document.body")
page.close()
def test_focus_restore_does_not_scroll(browser, live):
base, root = live
_set(root, 30)
page = _page(browser, base, "/b/g/", hold=True)
flag = 'figure.item[data-item="01.png"] .flagtoggle button'
page.locator(flag).focus()
page.keyboard.press("Enter")
_wait_held(page)
page.evaluate("window.scrollTo(0, document.documentElement.scrollHeight)")
y = page.evaluate("scrollY")
assert y > 400, "positive control: the page is long enough to scroll away"
page.evaluate("window.__release()")
_wait_swaps(page, 1)
assert _focused(page, flag)
assert abs(page.evaluate("scrollY") - y) <= 1, "restoring focus scrolled the page"
page.close()
def test_focus_elsewhere_is_left_alone(browser, live):
base, root = live
_set(root, 3)
page = _page(browser, base, "/b/g/", hold=True)
page.locator('figure.item[data-item="02.png"] .flagtoggle button').focus()
page.keyboard.press("Enter")
_wait_held(page)
away = '.theme button[data-theme-choice="system"]'
page.locator(away).focus()
page.evaluate("window.__release()")
_wait_swaps(page, 1)
assert _focused(page, away)
page.close()
# ---- G2: one status line, where it can be seen -------------------------------------------
def _rows_left(page, stage):
return page.evaluate("""s => {
const v = document.querySelector('.viewer'), st = document.querySelector(s);
const others = [...v.children].filter(c => c !== st && getComputedStyle(c).display !== 'none'
&& getComputedStyle(c).position !== 'absolute'
&& getComputedStyle(c).position !== 'fixed');
const hs = others.map(c => c.getBoundingClientRect().height);
const vh = v.getBoundingClientRect().height;
return {stage: st.getBoundingClientRect().height, left: vh - hs.reduce((a, h) => a + h, 0),
tallest_other: Math.max(...hs), viewer: vh};
}""", stage)
def test_the_status_line_is_visible_on_the_review(browser, live):
base, root = live
_set(root, 3)
for path, stage, press in (("/b/g/view?f=01.png", ".review-body", "#rail form.vflag button"),
("/b/g/compare?a=01.png&b=02.png", ".cmp-body", '[data-region="flag-a"] button')):
page = browser.new_page(viewport={"width": 1280, "height": 800})
page.route("**/b/g/flag", lambda route: route.fulfill(status=500, body=""))
page.goto(f"{base}{path}", wait_until="networkidle")
rows = _rows_left(page, stage)
# the stage keeps the 1fr row: it takes the rest, and no other row grew
assert abs(rows["stage"] - rows["left"]) <= 1 and rows["tallest_other"] < rows["viewer"] / 4, (path, rows)
page.locator(press).first.click()
_wait_said(page, "Could not save in place", timeout=5000)
hit = page.evaluate("""() => { const s = document.querySelector('[data-region="status"]');
const r = s.getBoundingClientRect();
const e = document.elementFromPoint(r.left + r.width / 2, r.top + r.height / 2);
return e === s || s.contains(e); }""")
assert hit, f"{path}: the stage covers the line"
page.close()
def test_a_save_does_not_move_the_page(browser, live):
"""Every save now says something. A line that took space in the page flow
moved the page under the reader (booth-dev's
test_a_flag_lands_in_place_and_every_region_catches_up caught 50px). The
words must be in view where the reader is, and move nothing."""
base, root = live
_set(root, 30)
page = _page(browser, base, "/b/g/", hold=True, viewport={"width": 1400, "height": 800})
tile = 'figure.item[data-item="15.png"]'
page.locator(tile).scroll_into_view_if_needed()
top = lambda: page.locator(tile).evaluate("e => e.getBoundingClientRect().top") # noqa: E731
before = top()
page.locator(f"{tile} .flagtoggle button").click()
_wait_held(page)
during = top()
seen = page.evaluate("""() => { const r = document.querySelector('[data-region="status"]').getBoundingClientRect();
return r.top >= 0 && r.bottom <= innerHeight && r.height > 0; }""")
page.evaluate("window.__release()")
_wait_said(page, "Saved.")
after = top()
page.close()
assert abs(during - before) <= 1 and abs(after - before) <= 1, (before, during, after)
assert seen, "the words are out of view where the reader is"
def test_the_covered_letterhead_leaves_the_tab_order(browser, live):
base, root = live
_set(root, 3)
for path in ("/b/g/view?f=01.png", "/b/g/compare?a=01.png&b=02.png"):
for vp, want in (({"width": 1280, "height": 800}, "hidden"), ({"width": 390, "height": 844}, "visible")):
page = browser.new_page(viewport=vp)
page.goto(f"{base}{path}", wait_until="networkidle")
got = page.evaluate("""() => ['header.topbar', 'footer.foot'].map(
s => getComputedStyle(document.querySelector(s)).visibility)""")
assert got == [want, want], (path, vp, got)
page.close()
# ---- G4: the line speaks in time ------------------------------------------------------------
def test_the_status_line_is_always_displayed(browser, live):
base, root = live
_set(root, 2)
for path in ("/b/g/", "/b/g/view?f=01.png"):
page = _page(browser, base, path, hold=True)
shown = "() => { const c = getComputedStyle(document.querySelector('[data-region=\"status\"]')); return [c.display, c.visibility]; }"
empty = page.evaluate(shown)
page.locator("form.flagtoggle button, #rail form.vflag button").first.click()
_wait_said(page, "Saving")
full = page.evaluate(shown)
page.close()
for d, v in (empty, full):
assert d != "none" and v == "visible", (path, empty, full)
def test_an_empty_status_line_takes_no_space(browser, live):
base, root = live
_set(root, 2)
for path in ("/b/g/", "/b/g/view?f=01.png", "/b/g/compare?a=01.png&b=02.png"):
page = _page(browser, base, path)
h = page.evaluate("document.querySelector('[data-region=\"status\"]').getBoundingClientRect().height")
page.close()
assert h == 0, (path, h)
def test_a_save_says_saving_then_saved(browser, live):
base, root = live
_set(root, 2)
page = _page(browser, base, "/b/g/", hold=True)
page.locator('figure.item[data-item="01.png"] .flagtoggle button').click()
_wait_held(page)
assert _said(page) == {"text": "Saving…", "tone": None}
page.evaluate("window.__release()")
_wait_said(page, "Saved.")
assert _said(page) == {"text": "Saved.", "tone": None}
page.wait_for_timeout(2500)
assert _said(page) == {"text": "", "tone": None}
page.close()
def test_a_new_save_is_not_cleared_by_the_last_ones_timer(browser, live):
base, root = live
_set(root, 3)
page = _page(browser, base, "/b/g/", hold=True)
page.locator('figure.item[data-item="01.png"] .flagtoggle button').click()
_wait_held(page)
page.evaluate("window.__release()")
_wait_said(page, "Saved.")
page.locator('figure.item[data-item="02.png"] .flagtoggle button').click()
_wait_held(page)
page.wait_for_timeout(2500)
assert _said(page)["text"] == "Saving…", "the last save's timer cleared the new one's words"
page.evaluate("window.__release()")
_wait_said(page, "Saved.")
page.close()
def test_a_queued_save_keeps_saying_saving(browser, live):
base, root = live
_set(root, 3)
page = _page(browser, base, "/b/g/", hold=True)
page.locator('figure.item[data-item="01.png"] .flagtoggle button').click()
page.locator('figure.item[data-item="02.png"] .flagtoggle button').click()
_wait_held(page)
page.evaluate("window.__release()")
_wait_swaps(page, 1)
assert _said(page)["text"] == "Saving…", "the first save said Saved. while the second was queued"
_wait_held(page)
page.evaluate("window.__release()")
_wait_said(page, "Saved.")
page.close()
def test_a_batch_speaks_too(browser, live):
base, root = live
b = _set(root, 1)
_picks(b)
page = _page(browser, base, "/b/g/marks", hold=True)
page.check('#mark-a1 input[type=radio][value="yes"]')
page.check('#mark-a3 input[type=radio][value="no"]')
page.locator("#mark-a1 .mark-submit").click()
_wait_held(page)
assert _said(page) == {"text": "Saving…", "tone": None}
page.locator("#mark-a2 .mark-submit").click()
assert _said(page)["text"] == "Still saving…"
page.evaluate("window.__release()")
_wait_held(page)
page.evaluate("window.__release()")
_wait_said(page, "Saved.")
assert _said(page)["tone"] is None
page.close()
def test_a_repeat_press_says_still_saving(browser, live):
base, root = live
_set(root, 2)
page = _page(browser, base, "/b/g/", hold=True)
btn = 'figure.item[data-item="01.png"] .flagtoggle button'
page.locator(btn).click()
_wait_held(page)
page.locator(btn).click()
assert _said(page)["text"] == "Still saving…"
page.wait_for_timeout(2500)
assert _said(page)["text"] == "Still saving…", "it cleared while the save was still held"
assert page.evaluate("window.__held.length") == 1, "the repeat press sent a second POST"
page.evaluate("window.__release()")
_wait_said(page, "Saved.")
page.close()
def test_the_form_in_flight_is_busy(browser, live):
base, root = live
_set(root, 2)
page = _page(browser, base, "/b/g/", hold=True)
form = 'figure.item[data-item="01.png"] form.flagtoggle'
page.locator(f"{form} button").click()
_wait_held(page)
assert page.locator(form).get_attribute("aria-busy") == "true"
page.evaluate("window.__release()")
_wait_swaps(page, 1)
assert page.locator(form).get_attribute("aria-busy") is None
# the failure path that stays (another draft is on the page): busy ends too
page.locator(".verdict .mark-add textarea").fill("another draft")
page.locator(f"{form} button").click()
_wait_held(page)
page.evaluate("window.__release('fail')")
_wait_said(page, "Could not save in place")
assert page.locator(form).get_attribute("aria-busy") is None
page.close()
def test_a_failure_then_an_edit_then_a_save(browser, live):
"""A request trace: a failure that stays, an edit, a second press."""
from booth.marks import marks_for
base, root = live
b = _set(root, 2)
page = _page(browser, base, "/b/g/", hold=True)
page.locator(".verdict .mark-add textarea").fill("keep me")
tile = 'figure.item[data-item="01.png"]'
page.locator(f"{tile} .item-addnote summary").click()
page.locator(f"{tile} .item-addnote textarea").fill("first")
page.locator(f"{tile} .item-addnote button").click()
_wait_held(page)
page.evaluate("window.__release('fail')")
_wait_said(page, "Could not save in place. Reload to see what was saved")
page.wait_for_timeout(2500)
said = _said(page)
assert said["tone"] == "warn" and "Could not save" in said["text"], said
page.locator(f"{tile} .item-addnote textarea").fill("first, edited")
page.locator(f"{tile} .item-addnote button").click()
_wait_held(page)
assert _said(page) == {"text": "Saving…", "tone": None}
page.evaluate("window.__release()")
_wait_said(page, "Saved.")
same = page.evaluate("window.__same === 1")
kept = page.locator(".verdict .mark-add textarea").input_value()
page.close()
assert same and kept == "keep me", (same, kept)
assert [m.text for m in marks_for(b) if m.shape == "note"] == ["first, edited"]
# ---- G13: leaving with an unsent draft asks first ----------------------------------------------
def _dialogs(page):
seen = []
page.on("dialog", lambda d: (seen.append(d.type), d.dismiss()))
return seen
def test_leaving_with_a_draft_asks(browser, live):
base, root = live
_set(root, 3)
page = _page(browser, base, "/b/g/view?f=01.png")
seen = _dialogs(page)
page.locator("#vnote-text").fill("draft")
page.locator('.film a.film-f[href="?f=02.png"]').click(no_wait_after=True)
page.wait_for_timeout(600)
assert seen == ["beforeunload"], seen
assert "f=01.png" in page.url and page.locator("#vnote-text").input_value() == "draft"
page.evaluate("document.activeElement.blur()")
page.keyboard.press("ArrowRight")
page.wait_for_timeout(600)
assert seen == ["beforeunload", "beforeunload"], seen
assert "f=01.png" in page.url
page.close()
def test_leaving_a_clean_page_does_not_ask(browser, live):
base, root = live
_set(root, 3)
page = _page(browser, base, "/b/g/view?f=01.png")
seen = _dialogs(page)
page.locator('.film a.film-f[href="?f=02.png"]').click()
page.wait_for_url("**f=02.png", timeout=5000)
page.close()
assert seen == []
def test_a_saved_draft_no_longer_asks(browser, live):
base, root = live
_set(root, 3)
page = _page(browser, base, "/b/g/view?f=01.png")
seen = _dialogs(page)
page.locator("#vnote-text").fill("saved")
page.locator("form:has(#vnote-text) button[type=submit]").click()
_wait_swaps(page, 1)
page.locator('.film a.film-f[href="?f=02.png"]').click()
page.wait_for_url("**f=02.png", timeout=5000)
page.close()
assert seen == []
def test_a_failed_save_keeps_the_other_drafts(browser, live):
base, root = live
_set(root, 2)
page = _page(browser, base, "/b/g/")
page.route("**/b/g/flag", lambda route: route.fulfill(status=500, body=""))
page.locator(".verdict .mark-add textarea").fill("other")
page.locator('figure.item[data-item="01.png"] .flagtoggle button').click()
_wait_said(page, "Could not save in place. Reload to see what was saved; your other entries are still here.")
page.wait_for_timeout(1500)
said = _said(page)
same = page.evaluate("window.__same === 1")
kept = page.locator(".verdict .mark-add textarea").input_value()
page.close()
assert same and kept == "other" and said["tone"] == "warn", (same, kept, said)
def test_a_failed_save_keeps_text_typed_while_it_flew(browser, live):
base, root = live
_set(root, 2)
page = _page(browser, base, "/b/g/", hold=True)
box = ".verdict .mark-add textarea"
page.locator(box).fill("first")
page.locator(".verdict .mark-add button").click()
_wait_held(page)
page.locator(box).fill("first and more")
page.evaluate("window.__release('fail')")
_wait_said(page, "Could not save in place")
page.wait_for_timeout(1500)
same = page.evaluate("window.__same === 1")
kept = page.locator(box).input_value()
page.close()
assert same and kept == "first and more", (same, kept)
def test_a_changed_page_keeps_the_other_drafts(browser, live):
base, root = live
b = _set(root, 2)
page = _page(browser, base, "/b/g/", hold=True)
page.locator(".verdict .mark-add textarea").fill("other")
page.locator('figure.item[data-item="01.png"] .flagtoggle button').click()
_wait_held(page)
(b / "03.png").write_bytes(PNG) # a region the live page does not have
page.evaluate("window.__release()")
_wait_said(page, "Saved. The page changed meanwhile; reload to see it.")
page.wait_for_timeout(1500)
busy = page.locator('figure.item[data-item="01.png"] form.flagtoggle').get_attribute("aria-busy")
assert busy is None, "a settled save left its form busy"
said = _said(page)
same = page.evaluate("window.__same === 1")
kept = page.locator(".verdict .mark-add textarea").input_value()
page.close()
assert same and kept == "other" and said["tone"] == "warn", (same, kept, said)
def test_a_draft_typed_during_the_beat_stays(browser, live):
base, root = live
_set(root, 2)
page = _page(browser, base, "/b/g/")
page.route("**/b/g/flag", lambda route: route.fulfill(status=500, body=""))
page.locator('figure.item[data-item="01.png"] .flagtoggle button').click()
_wait_said(page, "reloading to show what was saved")
page.locator(".verdict .mark-add textarea").fill("typed in the beat")
page.wait_for_timeout(1500)
same = page.evaluate("window.__same === 1")
kept = page.locator(".verdict .mark-add textarea").input_value()
said = _said(page)
page.close()
assert same and kept == "typed in the beat", (same, kept)
assert "your other entries are still here" in said["text"], said
def test_its_own_reload_does_not_ask(browser, live):
base, root = live
_set(root, 2)
page = _page(browser, base, "/b/g/")
seen = _dialogs(page)
page.route("**/b/g/note", lambda route: route.fulfill(status=500, body=""))
page.locator(".verdict .mark-add textarea").fill("sent, and failed")
page.locator(".verdict .mark-add button").click()
_wait_said(page, "reloading to show what was saved")
page.wait_for_function("window.__same !== 1", timeout=5000)
page.close()
assert seen == [], seen
def test_embed_submit_does_not_ask(browser, live):
from booth.marks import declare_pick, marks_for
base, root = live
b = root / "r"
b.mkdir()
declare_pick(b, "winner", {"prompt": "Which render wins?", "options": ["A", "B"]})
(b / "index.html").write_text(
'<!doctype html><html><head><script src="/_booth/embed.js" defer></script></head>'
'<body><h1>Report</h1><div data-booth-ask="winner"></div></body></html>')
page = browser.new_page()
page.goto(f"{base}/b/r/", wait_until="networkidle")
page.wait_for_selector(".bk-ask-opt input[type=radio]", timeout=10000)
seen = _dialogs(page)
page.locator(".bk-ask-opt input[type=radio]").first.check()
with page.expect_navigation(timeout=10000):
page.locator(".bk-ask-go").click()
page.close()
assert seen == []
assert [m.answer for m in marks_for(b)][0], "the plain submit did not land"
def test_embed_leaving_with_a_draft_asks(browser, live):
"""The positive control for the embed's guard: a changed answer, then the
home chip, asks."""
from booth.marks import declare_pick
base, root = live
b = root / "r"
b.mkdir()
declare_pick(b, "winner", {"prompt": "Which render wins?", "options": ["A", "B"]})
(b / "index.html").write_text(
'<!doctype html><html><head><script src="/_booth/embed.js" defer></script></head>'
'<body><h1>Report</h1><div data-booth-ask="winner"></div></body></html>')
page = browser.new_page()
page.goto(f"{base}/b/r/", wait_until="networkidle")
page.wait_for_selector(".bk-ask-opt input[type=radio]", timeout=10000)
seen = _dialogs(page)
page.locator(".bk-ask-opt input[type=radio]").first.check()
page.locator(".booth-nav-home").click(no_wait_after=True)
page.wait_for_timeout(600)
page.close()
assert seen == ["beforeunload"], seen
# ---- folded from the heid bug-hunt (HNITTA, thread 01M3MRTNTWEPJHTN4APRR81KH4) -------------
def test_a_stale_tile_is_not_left_busy(browser, live):
"""R1: a save whose own tile survives the swap as a stale tile (un-flagged
under the flagged filter) settles like any other: its form is not busy."""
from booth.marks import set_flag
base, root = live
b = _set(root, 2)
set_flag(b, "01.png", True)
set_flag(b, "02.png", True)
page = _page(browser, base, "/b/g/?filter=flagged")
form = 'figure.item[data-item="01.png"] form.flagtoggle'
page.locator(f"{form} button").click()
page.wait_for_selector('figure.item.is-stale[data-item="01.png"]', timeout=10000)
page.wait_for_timeout(300)
assert page.locator(form).get_attribute("aria-busy") is None
page.close()
def test_a_queued_form_is_busy_on_the_live_page(browser, live):
"""R1's twin: an earlier save's swap replaces a queued form's node, and the
live copy is the one that must say it is busy."""
base, root = live
_set(root, 2)
page = _page(browser, base, "/b/g/", hold=True)
page.locator('figure.item[data-item="01.png"] .flagtoggle button').click()
_wait_held(page)
page.locator(".verdict .mark-add textarea").fill("queued")
page.locator(".verdict .mark-add button").click()
page.evaluate("window.__release()")
_wait_swaps(page, 1)
assert page.locator(".verdict form.mark-add").get_attribute("aria-busy") == "true"
_wait_held(page)
page.evaluate("window.__release()")
_wait_swaps(page, 2)
page.wait_for_timeout(200)
assert page.locator(".verdict form.mark-add").get_attribute("aria-busy") is None
page.close()
def test_a_resubmit_in_the_beat_is_not_reloaded_away(browser, live):
"""R2: the failure's reload is due, and the operator presses again inside
the beat. The new save owns the page; the old reload must not abort it."""
from booth.marks import marks_for
base, root = live
b = _set(root, 2)
page = _page(browser, base, "/b/g/")
calls = {"n": 0}
def first_fails(route):
calls["n"] += 1
if calls["n"] == 1:
route.fulfill(status=500, body="")
else:
route.continue_()
page.route("**/b/g/note", first_fails)
page.locator(".verdict .mark-add textarea").fill("again")
page.locator(".verdict .mark-add button").click()
_wait_said(page, "reloading to show what was saved")
page.locator(".verdict .mark-add button").click()
_wait_said(page, "Saved.")
page.wait_for_timeout(1500)
same = page.evaluate("window.__same === 1")
page.close()
assert same, "the old failure's reload fired over the new save"
assert [m.text for m in marks_for(b) if m.shape == "note"] == ["again"]
def test_an_unrelated_save_does_not_bury_a_failure(browser, live):
"""R4: a note fails and stays; a different save then lands. The line keeps
saying the note is not saved, rather than "Saved."."""
base, root = live
_set(root, 2)
page = _page(browser, base, "/b/g/", hold=True)
page.locator(".verdict .mark-add textarea").fill("another draft") # so the failure stays
tile = 'figure.item[data-item="01.png"]'
page.locator(f"{tile} .item-addnote summary").click()
page.locator(f"{tile} .item-addnote textarea").fill("did not go")
page.locator(f"{tile} .item-addnote button").click()
_wait_held(page)
page.evaluate("window.__release('fail')")
_wait_said(page, "Could not save in place. Reload to see what was saved")
page.locator('figure.item[data-item="02.png"] .flagtoggle button').click()
_wait_held(page)
page.evaluate("window.__release()")
_wait_swaps(page, 1)
page.wait_for_timeout(300)
said = _said(page)
page.close()
assert said["tone"] == "warn" and "Could not save in place" in said["text"], said
def test_a_save_whose_page_would_not_refresh_says_saved(browser, live):
"""R8: the POST lands (204) and the page GET fails. That is a save, and the
line must not say it could not save (a second press would write it twice)."""
from booth.marks import marks_for
base, root = live
b = _set(root, 2)
page = _page(browser, base, "/b/g/")
gate = {"on": False}
page.route("**/b/g/", lambda route: route.fulfill(status=500, body="")
if gate["on"] and route.request.method == "GET" else route.continue_())
page.locator(".verdict .mark-add textarea").fill("landed")
tile = 'figure.item[data-item="01.png"]'
page.locator(f"{tile} .item-addnote summary").click()
page.locator(f"{tile} .item-addnote textarea").fill("another draft")
gate["on"] = True
page.locator(".verdict .mark-add button").click()
_wait_said(page, "Saved. Could not refresh the page; reload to see it.")
page.wait_for_timeout(1500)
said = _said(page)
same = page.evaluate("window.__same === 1")
page.close()
assert said["tone"] == "warn" and same, (said, same)
assert [m.text for m in marks_for(b) if m.shape == "note"] == ["landed"]
def test_focus_on_a_summary_survives_the_next_swap(browser, live):
"""R10: focus that was restored to an answered pick's summary stays there
through the next swap."""
base, root = live
b = _set(root, 1)
_picks(b, ("a1", "a2"))
page = _page(browser, base, "/b/g/marks")
page.check('#mark-a1 input[type=radio][value="yes"]')
page.locator("#mark-a1 .mark-submit").focus()
page.keyboard.press("Enter")
_wait_swaps(page, 1)
summary = "#mark-a1 details.mark-formwrap > summary"
assert _focused(page, summary)
page.evaluate("document.querySelector('#mark-a2 input[type=radio][value=\"no\"]').checked = true;"
"document.querySelector('#mark-a2 form.mark-form').requestSubmit()")
_wait_swaps(page, 2)
assert _focused(page, summary), page.evaluate("document.activeElement.outerHTML.slice(0, 120)")
page.close()
def test_an_edit_made_while_saving_is_not_called_saved(browser, live):
"""R12: text added to a note while it was saving is on screen and not on
the server. The line says so, instead of "Saved."."""
base, root = live
_set(root, 2)
page = _page(browser, base, "/b/g/", hold=True)
box = ".verdict .mark-add textarea"
page.locator(box).fill("hello")
page.locator(".verdict .mark-add button").click()
_wait_held(page)
page.locator(box).fill("hello!")
page.evaluate("window.__release()")
_wait_swaps(page, 1)
page.wait_for_timeout(300)
said = _said(page)
kept = page.locator(box).input_value()
page.close()
assert kept == "hello!", kept
assert said["tone"] == "warn" and "not saved yet" in said["text"], said
def _embed_page(browser, base, root, host_script=""):
from booth.marks import declare_pick
b = root / "r"
b.mkdir()
declare_pick(b, "winner", {"prompt": "Which render wins?", "options": ["A", "B"]})
(b / "index.html").write_text(
'<!doctype html><html><head><script src="/_booth/embed.js" defer></script></head>'
f'<body><h1>Report</h1><div data-booth-ask="winner"></div>{host_script}</body></html>')
page = browser.new_page()
page.goto(f"{base}/b/r/", wait_until="networkidle")
page.wait_for_selector(".bk-ask-opt input[type=radio]", timeout=10000)
return page
def test_embed_a_cancelled_submit_is_guarded_again(browser, live):
"""R3: a host handler cancels our native submit. Nothing was sent, so the
answer is an unsent draft again, and leaving asks."""
base, root = live
host = ("<script>document.addEventListener('DOMContentLoaded', function () {"
" document.addEventListener('submit', function (e) { e.preventDefault(); }); });</script>")
page = _embed_page(browser, base, root, host)
seen = _dialogs(page)
page.locator(".bk-ask-opt input[type=radio]").first.check()
page.locator(".bk-ask-go").click()
page.wait_for_timeout(300)
assert "/b/r/" in page.url and seen == [], "positive control: the host cancelled it"
page.locator(".booth-nav-home").click(no_wait_after=True)
page.wait_for_timeout(600)
page.close()
assert seen == ["beforeunload"], seen
def test_embed_the_skip_covers_one_leave(browser, live):
"""R3: the skip for our own submit covers the one navigation it started. A
submit whose response does not replace the page (a 204 here; a stop or Esc
in life) leaves the answer unsent on screen, and the next leave asks."""
base, root = live
page = _embed_page(browser, base, root)
page.route("**/b/r/answer", lambda route: route.fulfill(status=204, body=""))
seen = _dialogs(page)
page.locator(".bk-ask-opt input[type=radio]").first.check()
page.locator(".bk-ask-go").click()
page.wait_for_timeout(500)
assert "/b/r/" in page.url and seen == [], "positive control: the page stayed, and our submit did not ask"
page.locator(".booth-nav-home").click(no_wait_after=True)
page.wait_for_timeout(600)
page.close()
assert seen == ["beforeunload"], seen