feat(r2): C6 the review, and C7
- The zoom route becomes the review for image, video AND audio: the native player on the stage for sound and video, the Fit/1:1 toggle for pictures only. The judgment rail, the tape and the filmstrip are each a data-region. The stage never is, so a playing track survives an in-place save. - The rail shows the whole-set number, K of M in the review ring and the position in the group; then the caption, and the flag and notes, landing back here (back=view). A pick targeting this item is answerable in place. On the last item the end-of-set block lists what was seen, the flags, and every other open question. - The keys are ← → Space F N Esc. Every one is ignored in an editable field, and Esc returns to the grid at the tile you were on. - _marks.html gains picks_only/back_view, so a pick form has one renderer wherever it sits. - In-place swaps now carry an unsaved draft across. A half-typed note survives a flag, except in the form that was just sent. - The filmstrip keeps the current frame in view. - C7: no emblem in the chrome, pinned. Browser tests cover: F typed into the note stays a letter and does not flag; F outside the note flags in place and the draft survives; Space moves; Esc lands on the grid tile. 706 passed.
This commit is contained in:
@@ -476,3 +476,85 @@ def test_every_mark_dependent_element_is_a_swappable_region(tmp_path):
|
||||
_booth(tmp_path, "links", {"links.md": b"- [x](http://x/) <sub>\xc2\xb7 a \xc2\xb7 2026-09-01 10:00</sub>\n"})
|
||||
board = _client(tmp_path).get("/b/links/").text
|
||||
assert 'data-region="verdict"' not in board and 'class="lightbox"' not in board
|
||||
|
||||
|
||||
# ---- C6: the review -----------------------------------------------------------
|
||||
|
||||
def test_an_audio_item_is_reviewed_like_a_picture(tmp_path):
|
||||
"""The tracer for C6. A track gets the review page — its native player on
|
||||
the stage, no Fit/1:1 toggle (that is for images only), and the judgment
|
||||
rail with a flag that lands back here."""
|
||||
_booth(tmp_path, "g", {"a.png": PNG, "b.mp3": b"ID3", "c.mp3": b"ID3"})
|
||||
r = _client(tmp_path).get("/b/g/view?f=b.mp3", follow_redirects=False)
|
||||
assert r.status_code == 200
|
||||
body = r.text
|
||||
stage = body[body.index('id="vstage"'):]
|
||||
assert len(re.findall(r"<audio\b[^>]*\bcontrols\b", stage.split("</div>")[0])) == 1
|
||||
assert 'id="vtoggle"' not in body and ">1:1<" not in body
|
||||
rail = _region(body, "rail")
|
||||
assert 'name="back" value="view"' in rail and 'name="f" value="b.mp3"' in rail
|
||||
assert "2 of 3" in rail and "#2" in rail
|
||||
|
||||
|
||||
def test_filmstrip_and_tape_are_the_review_ring_with_seen_and_flags(tmp_path):
|
||||
"""One list, three surfaces: the filmstrip and the tape are the ring in
|
||||
set order. The tape counts seen ∩ ring — including the item being looked
|
||||
at, which is recorded before the page renders; a doc is never in it."""
|
||||
b = _booth(tmp_path, "g", {"a.png": PNG, "b.md": b"# b", "c.png": PNG, "d.mp3": b"ID3"})
|
||||
set_flag(b, "d.mp3", True)
|
||||
c = _client(tmp_path)
|
||||
c.get("/b/g/view?f=a.png")
|
||||
body = c.get("/b/g/view?f=c.png").text
|
||||
film = re.findall(r'<a class="film-f([^"]*)"\s+href="\?f=([^"]+)"', _region(body, "film"))
|
||||
assert [(rel, cls.split()) for cls, rel in film] == [
|
||||
("a.png", []), ("c.png", ["is-current"]), ("d.mp3", ["is-flagged"])]
|
||||
assert re.findall(r'class="film-ord">#(\d)<', body) == ["1", "3", "4"] # whole-set numbers
|
||||
tape = _region(body, "tape")
|
||||
assert re.findall(r'<a class="tape-s([^"]*)"', tape) == [" is-seen", " is-current", " is-flagged"]
|
||||
assert "2 of 3 seen" in tape
|
||||
|
||||
|
||||
def test_a_question_about_this_item_is_answerable_here_and_the_rest_wait_for_the_end(tmp_path):
|
||||
"""The rail offers a pick TARGETING the item; booth-level picks are a count
|
||||
and a link — until the last item, where the end of the set offers them
|
||||
all, each landing back on the review."""
|
||||
from booth.marks import declare_pick
|
||||
b = _booth(tmp_path, "g", {"a.png": PNG, "b.png": PNG})
|
||||
declare_pick(b, "about-a", {"prompt": "Is a sharp?", "options": ["yes", "no"]}, target="a.png")
|
||||
declare_pick(b, "overall", {"prompt": "Ship the set?", "options": ["yes", "no"]})
|
||||
c = _client(tmp_path)
|
||||
first = _region(c.get("/b/g/view?f=a.png").text, "rail")
|
||||
assert "Is a sharp?" in first and "Ship the set?" not in first
|
||||
assert "1 more open question on this booth" in first
|
||||
assert first.count('name="back" value="view"') >= 2 # flag + the pick
|
||||
last = _region(c.get("/b/g/view?f=b.png").text, "rail")
|
||||
assert "End of the set" in last
|
||||
assert "Ship the set?" in last and "Is a sharp?" in last
|
||||
form = re.search(r'<form class="mark-form"[^>]*>.*?Ship the set\?|Ship the set\?.*?</form>', last, re.S)
|
||||
assert form and 'name="f" value="b.png"' in last
|
||||
|
||||
|
||||
def test_only_a_picture_gets_the_fit_toggle_and_blur_stays_honest(tmp_path):
|
||||
from booth.app import set_blurred
|
||||
b = _booth(tmp_path, "g", {"a.png": PNG, "v.webm": b"\x1aE"})
|
||||
set_blurred(b, "a.png", True)
|
||||
c = _client(tmp_path)
|
||||
pic = c.get("/b/g/view?f=a.png").text
|
||||
assert 'id="vtoggle"' in pic and ">Fit<" in pic and ">1:1<" in pic
|
||||
assert 'class="vstage fit is-blurred"' in pic and "blur is cosmetic" in pic
|
||||
vid = c.get("/b/g/view?f=v.webm").text
|
||||
assert 'id="vtoggle"' not in vid and re.search(r"<video\b[^>]*\bcontrols\b", vid)
|
||||
|
||||
|
||||
# ---- C7: copy and brand -------------------------------------------------------
|
||||
|
||||
def test_no_emblem_in_the_chrome(tmp_path):
|
||||
"""Ruling `emblem=no`: the top bar carries the brand dot and the name, and
|
||||
no image at all."""
|
||||
_booth(tmp_path, "g", {"a.png": PNG})
|
||||
c = _client(tmp_path)
|
||||
for path in ("/", "/b/g/", "/b/g/view?f=a.png"):
|
||||
body = c.get(path).text
|
||||
m = re.search(r'<header class="topbar">.*?</header>', body, re.S)
|
||||
if m:
|
||||
assert "<img" not in m.group(0) and "<svg" not in m.group(0), path
|
||||
|
||||
@@ -112,3 +112,39 @@ def test_a_failed_save_says_so_reloads_and_never_re_posts(browser, live):
|
||||
page.wait_for_load_state("networkidle")
|
||||
page.close()
|
||||
assert len(posts) == 1, f"re-POSTed: {posts}"
|
||||
|
||||
|
||||
def test_the_review_keys_judge_in_place_and_stay_out_of_the_note(browser, live):
|
||||
"""At full size: F typed into the note is a letter; F outside it flags IN
|
||||
PLACE (the filmstrip underline and the tape catch up, no reload); Space
|
||||
moves on; Esc goes back to the grid at the tile you were on."""
|
||||
base, root = live
|
||||
_set(root, 4)
|
||||
page = browser.new_page(viewport={"width": 1400, "height": 900})
|
||||
page.goto(f"{base}/b/g/view?f=02.png", wait_until="networkidle")
|
||||
page.evaluate("window.__noReload = 1")
|
||||
|
||||
page.locator("#vnote-text").click()
|
||||
page.keyboard.type("fff")
|
||||
assert page.locator(".vflag-btn.is-flagged").count() == 0
|
||||
assert page.locator("#vnote-text").input_value() == "fff"
|
||||
|
||||
page.locator(".vr-where").click() # focus back on the page, not a field
|
||||
page.keyboard.press("f")
|
||||
page.wait_for_selector(".vflag-btn.is-flagged", timeout=10000)
|
||||
state = page.evaluate("""() => ({
|
||||
reload: window.__noReload !== 1,
|
||||
film: [...document.querySelectorAll('.film-f.is-flagged .film-ord')].map(e => e.textContent),
|
||||
draft: document.getElementById('vnote-text').value,
|
||||
})""")
|
||||
assert not state["reload"]
|
||||
assert state["film"] == ["#2"]
|
||||
assert state["draft"] == "fff", "an unsaved note must survive a swap it was not part of"
|
||||
|
||||
with page.expect_navigation():
|
||||
page.keyboard.press(" ")
|
||||
assert page.url.endswith("/b/g/view?f=03.png")
|
||||
with page.expect_navigation():
|
||||
page.keyboard.press("Escape")
|
||||
assert page.url.endswith("/b/g/#item-03.png")
|
||||
page.close()
|
||||
|
||||
Reference in New Issue
Block a user