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.
This commit is contained in:
@@ -0,0 +1,808 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user