phase: 04_reachable_share_cta
All verification is complete. The pending working-tree changes are exactly the phase 04 work (CSS, tests, the README narrow-screen line, the AGENTS.md "Two→Three pages" fix) — all accurate. ## Final report **Verified (final pass — all 4 task files present in `complete/`):** - Gate 1: `uv run ruff check . && uv run ruff format --check .` → all checks passed, 29 files formatted - Gate 2: `uv run pyright` → 0 errors - Gate 3: `uv run pytest` → **269 passed, 4 skipped** (realdisplay-gated), coverage 95% - Gate 4: `MIRROR_REALDISPLAY=1 uv run pytest -m realdisplay` → **3 passed, 1 skipped** (host delivers no audio source — documented in module docstring) **Completion criteria:** 1. `#start` elementFromPoint at the 11 required sizes — ✅ `TestIdleShareButtonReachable` sweeps 12 viewports (superset); 12/12 passed 2. `#stop` clickable while sharing — ✅ `TestLiveStopButtonReachable` (320×568, 390×844, 1280×450) passed, incl. placeholder-hidden + livetag assertions 3. Single-cell stage guard — ✅ `test_the_share_stage_is_one_cell` + `test_the_stage_has_a_floor_and_a_ceiling` passed; CSS has `grid-template: 1fr / 1fr`, `.stage > * { grid-area: 1 / 1 }`, `min-height: 264px`, `max-height: min(70svh, 640px)` + vh fallback, `.placeholder { max-height: 100%; overflow: auto }`, `align-content: safe center` 4. Live video fills stage, rounded corners, `#livetag` top-left — ✅ verified by live-browser probe: video fills stage (decoded), stage radius 14px, livetag top-left, placeholder hidden 5. README accurate, AGENTS.md current, suite 100% green — ✅ 6. Phases 01–03 unchanged — ✅ `test_keyboard_wall` + `test_audio_share` + `test_sender_fidelity`: 48 passed **Documentation:** README.md reviewed in full (page table, quickstart, all 12 config defaults vs `config.py`, CLI flags, secure-context/wayland/audio sections, kiosk key table vs `watch.js`/`share.js`, dev commands) — **consistent, no fixes needed**. AGENTS.md verified statement-by-statement (commands, routes, test names, browser/realdisplay flags) — **no stale statements; no changes needed** (the pending "Two→Three pages" fix is correct). **Decisions:** criterion 4 had no dedicated test, so verified directly with a throwaway probe reusing the e2e harness (script in /tmp, not committed). No live infrastructure touched. No defects found in prior work. **Next pending phase:** none — `todo/` is empty of other phases.
This commit is contained in:
@@ -8,6 +8,7 @@ from __future__ import annotations
|
||||
|
||||
import re
|
||||
import time
|
||||
from typing import ClassVar
|
||||
|
||||
import pytest
|
||||
from playwright.sync_api import expect
|
||||
@@ -294,6 +295,93 @@ class TestChromeVisibility:
|
||||
expect(sharer.locator("#start")).not_to_be_visible()
|
||||
expect(sharer.locator("#placeholder")).not_to_be_visible()
|
||||
|
||||
|
||||
class TestStageSizing:
|
||||
"""The stage has a floor, a ceiling, and an escape hatch.
|
||||
|
||||
``aspect-ratio: 16 / 10`` alone made the stage 180 px tall at 320 px wide
|
||||
(the idle placeholder needed ~240 px there) and 543 px tall in a 480 px
|
||||
window (the Share button ended up below the fold). These tests pin the
|
||||
six shapes measured in ``00_phase.md``: at each one ``#start`` must be
|
||||
fully inside *both* the stage box and the viewport — inside the stage or
|
||||
``overflow: hidden`` clips it, and inside the viewport or a finger can
|
||||
never reach it. The 1080p case pins the ceiling: the Session panel must
|
||||
be reachable without scrolling past a 964 px preview.
|
||||
"""
|
||||
|
||||
# The three clip cases (button clipped out of the stage) and the two
|
||||
# below-the-fold cases (stage taller than the viewport), plus 360x640,
|
||||
# the fourth measured narrow shape.
|
||||
FAILURE_VIEWPORTS: ClassVar[list[tuple[int, int]]] = [
|
||||
(320, 568),
|
||||
(360, 640),
|
||||
(390, 844),
|
||||
(480, 800),
|
||||
(900, 480),
|
||||
(1280, 450),
|
||||
]
|
||||
|
||||
STAGE_PROBE = """() => {
|
||||
const s = document.querySelector('#preview-stage').getBoundingClientRect();
|
||||
const r = document.querySelector('#start').getBoundingClientRect();
|
||||
return {
|
||||
stage_h: s.height,
|
||||
stage_bottom: s.bottom,
|
||||
start: {top: r.top, bottom: r.bottom, right: r.right},
|
||||
in_stage: r.bottom <= s.bottom + 1 && r.top >= s.top - 1,
|
||||
in_viewport: r.top >= 0 && r.bottom <= innerHeight && r.right <= innerWidth,
|
||||
stage_in_viewport: s.bottom <= innerHeight + 1,
|
||||
};
|
||||
}"""
|
||||
|
||||
@pytest.mark.parametrize("viewport", FAILURE_VIEWPORTS)
|
||||
def test_the_share_button_is_inside_the_stage_and_the_viewport(
|
||||
self, base_url, browser, request, viewport
|
||||
):
|
||||
context = browser.new_context(viewport={"width": viewport[0], "height": viewport[1]})
|
||||
request.addfinalizer(context.close)
|
||||
page = context.new_page()
|
||||
page.goto(f"{base_url}/mirror", wait_until="domcontentloaded")
|
||||
expect(page.locator("#start")).to_be_visible(timeout=10_000)
|
||||
result = page.evaluate(self.STAGE_PROBE)
|
||||
message = f"{viewport[0]}x{viewport[1]}: {result}"
|
||||
# The floor (264px, the measured idle placeholder + slack) and the
|
||||
# ceiling (min(70svh, 640px)) must both hold: the box is never so
|
||||
# short that the CTA clips out, and never so tall that it leaves
|
||||
# the viewport.
|
||||
assert result["stage_h"] >= 263, f"floor failed: {message}"
|
||||
assert result["in_stage"], f"#start is clipped by the stage: {message}"
|
||||
assert result["in_viewport"], f"#start is below the fold: {message}"
|
||||
# The stage itself must also fit: on the two short-window shapes the
|
||||
# aspect-ratio box used to run 543/564 px tall in a 480/450 px window,
|
||||
# leaving only a scroll to reach the bottom of the page.
|
||||
assert result["stage_in_viewport"], f"the stage itself overflows the viewport: {message}"
|
||||
|
||||
def test_the_ceiling_keeps_the_session_panel_reachable_at_1080p(
|
||||
self, base_url, browser, request
|
||||
):
|
||||
"""1920x1080: the aspect ratio alone wanted a 964 px stage, which
|
||||
pushed the Session panel off-screen. The 640 px ceiling must keep
|
||||
the panel on screen — reachable without scrolling."""
|
||||
context = browser.new_context(viewport={"width": 1920, "height": 1080})
|
||||
request.addfinalizer(context.close)
|
||||
page = context.new_page()
|
||||
page.goto(f"{base_url}/mirror", wait_until="domcontentloaded")
|
||||
expect(page.locator("#start")).to_be_visible(timeout=10_000)
|
||||
result = page.evaluate(
|
||||
"""() => {
|
||||
const s = document.querySelector('#preview-stage').getBoundingClientRect();
|
||||
const p = document.querySelector('.panel').getBoundingClientRect();
|
||||
return {stage_h: s.height, stage_w: s.width, panel_bottom: p.bottom};
|
||||
}"""
|
||||
)
|
||||
assert result["stage_h"] <= 641, (
|
||||
f"the 640px ceiling failed (aspect ratio alone wanted 964px): {result}"
|
||||
)
|
||||
assert result["panel_bottom"] <= 1080, (
|
||||
f"the Session panel is not reachable without scrolling: {result}"
|
||||
)
|
||||
|
||||
def test_exit_only_appears_on_the_tile_that_is_on_stage(self, device):
|
||||
sharer = device("/mirror")
|
||||
start_sharing(sharer)
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
"""End-to-end: the Share CTA is a *hit*, at every realistic viewport.
|
||||
|
||||
The bug this file pins: ``#preview-stage`` was a two-row grid (the idle
|
||||
``<video>`` reserved a ~150 px row, the placeholder stacked below it and ran
|
||||
into ``overflow: hidden``), and its height was an unbounded function of its
|
||||
width (180 px tall at 320 px wide; 543 px in a 480 px window, pushing the
|
||||
button below the fold). On the reference host,
|
||||
``document.elementFromPoint`` at the button centre named ``H2`` at 320x568,
|
||||
``#name`` at 360x640, ``LABEL`` at 390x844, ``#share-link`` at 480x800, and
|
||||
``null`` at 900x480 / 1280x450 — a finger or cursor would never have hit the
|
||||
button.
|
||||
|
||||
The assertion is a **hit test, not a size check**:
|
||||
``document.elementFromPoint(cx, cy).closest("#start")`` must be the button.
|
||||
That is the only in-browser statement that means *a finger or a cursor would
|
||||
hit it*, and it is exactly the check that failed. (A ``page.click`` would start
|
||||
a real capture and need teardown; the hit test is the same claim with no side
|
||||
effects. The live-state sweep below still exercises a real ``start_sharing()``
|
||||
once per shape, so the interaction path is not untested.)
|
||||
|
||||
320 px is the floor of "realistic": below it the page is expected to scroll,
|
||||
not to fit. The containment comparisons carry a +/-1 px tolerance for
|
||||
sub-pixel layout at fractional device pixel ratios.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import ClassVar
|
||||
|
||||
import pytest
|
||||
from playwright.sync_api import expect
|
||||
|
||||
from .conftest import start_sharing
|
||||
|
||||
pytestmark = [pytest.mark.e2e, pytest.mark.timeout(180)]
|
||||
|
||||
# The sweep: the clip cases (button clipped out of the stage), the
|
||||
# below-the-fold cases (stage taller than the viewport), and the control
|
||||
# shapes in between — exactly the list in 03_reachable_cta_e2e.md.
|
||||
VIEWPORTS = [
|
||||
(320, 568),
|
||||
(360, 640),
|
||||
(390, 844),
|
||||
(430, 932),
|
||||
(480, 800), # the clip cases
|
||||
(700, 900),
|
||||
(900, 700),
|
||||
(1024, 600),
|
||||
(1280, 800),
|
||||
(1920, 1080),
|
||||
(900, 480),
|
||||
(1280, 450), # the below-the-fold cases
|
||||
]
|
||||
|
||||
# The probe that reproduced the bug — kept verbatim so the next reader can
|
||||
# re-run the diagnosis. ``hit`` named the culprit during diagnosis: ``H2`` at
|
||||
# 320x568, ``#name`` at 360x640, ``LABEL`` at 390x844, ``#share-link`` at
|
||||
# 480x800, ``null`` at 900x480 / 1280x450.
|
||||
HIT_PROBE = """() => {
|
||||
const b = document.querySelector("#start");
|
||||
const s = document.querySelector("#preview-stage").getBoundingClientRect();
|
||||
const r = b.getBoundingClientRect();
|
||||
const hit = document.elementFromPoint(r.x + r.width / 2, r.y + r.height / 2);
|
||||
return {
|
||||
disabled: b.disabled,
|
||||
in_stage: r.bottom <= s.bottom + 1 && r.top >= s.top - 1,
|
||||
in_viewport: r.bottom <= innerHeight && r.right <= innerWidth && r.top >= 0,
|
||||
hit_is_button: !!hit && !!hit.closest("#start"),
|
||||
hit: hit ? hit.id || hit.tagName : null,
|
||||
no_h_scroll: document.documentElement.scrollWidth <= innerWidth,
|
||||
};
|
||||
}"""
|
||||
|
||||
# The same hit test, parameterised on the button. Used for #stop while a share
|
||||
# is live: #stop lives in the Session panel, not the stage, so "inside the
|
||||
# stage" does not apply to it — clickability is the hit test plus the viewport.
|
||||
STOP_PROBE = """(selector) => {
|
||||
const b = document.querySelector(selector);
|
||||
const r = b.getBoundingClientRect();
|
||||
const hit = document.elementFromPoint(r.x + r.width / 2, r.y + r.height / 2);
|
||||
return {
|
||||
disabled: b.disabled,
|
||||
in_viewport: r.bottom <= innerHeight && r.right <= innerWidth && r.top >= 0,
|
||||
hit_is_button: !!hit && !!hit.closest(selector),
|
||||
hit: hit ? hit.id || hit.tagName : null,
|
||||
no_h_scroll: document.documentElement.scrollWidth <= innerWidth,
|
||||
};
|
||||
}"""
|
||||
|
||||
# The live-state invariants: the placeholder is hidden, the LIVE badge is
|
||||
# visible and sits top-left inside the stage.
|
||||
LIVE_STATE_PROBE = """() => {
|
||||
const s = document.querySelector("#preview-stage").getBoundingClientRect();
|
||||
const l = document.querySelector("#livetag").getBoundingClientRect();
|
||||
return {
|
||||
placeholder_hidden: document.querySelector("#placeholder").hidden,
|
||||
livetag_visible: !document.querySelector("#livetag").hidden,
|
||||
livetag_in_stage:
|
||||
l.left >= s.left - 1 && l.top >= s.top - 1 &&
|
||||
l.right <= s.right + 1 && l.bottom <= s.bottom + 1,
|
||||
};
|
||||
}"""
|
||||
|
||||
|
||||
def _open_page(base_url, browser, request, width: int, height: int):
|
||||
context = browser.new_context(viewport={"width": width, "height": height})
|
||||
request.addfinalizer(context.close)
|
||||
page = context.new_page()
|
||||
page.goto(f"{base_url}/mirror", wait_until="domcontentloaded")
|
||||
expect(page.locator("#net")).to_have_attribute("data-state", "online", timeout=10_000)
|
||||
return page
|
||||
|
||||
|
||||
class TestIdleShareButtonReachable:
|
||||
"""``#start`` must be hit-testable at every realistic viewport, idle.
|
||||
|
||||
A layout change that stacks the stage's children again (or un-bounds the
|
||||
box's height) fails at least one of these: the clipped shapes fail
|
||||
``in_stage``/``hit_is_button`` (the old bug), the short shapes fail
|
||||
``in_viewport`` (the old bug), and ``no_h_scroll`` catches a layout that
|
||||
hides the button behind horizontal scroll instead.
|
||||
"""
|
||||
|
||||
@pytest.mark.parametrize("viewport", VIEWPORTS)
|
||||
def test_the_share_button_is_hit_testable(self, base_url, browser, request, viewport):
|
||||
page = _open_page(base_url, browser, request, viewport[0], viewport[1])
|
||||
expect(page.locator("#start")).to_be_visible(timeout=10_000)
|
||||
result = page.evaluate(HIT_PROBE)
|
||||
label = f"{viewport[0]}x{viewport[1]}: {result}"
|
||||
# A disabled button would make the hit test vacuous.
|
||||
assert result["disabled"] is False, f"#start is disabled: {label}"
|
||||
# The point that a finger or cursor would land on must be the button.
|
||||
assert result["hit_is_button"], (
|
||||
f"#start is not hit-testable, elementFromPoint named {result['hit']!r}: {label}"
|
||||
)
|
||||
# Inside the stage or `overflow: hidden` clips it; inside the viewport
|
||||
# or a finger can never reach it; no horizontal scroll to reach it.
|
||||
assert result["in_stage"], f"#start is clipped by the stage: {label}"
|
||||
assert result["in_viewport"], f"#start is outside the viewport: {label}"
|
||||
assert result["no_h_scroll"], f"the button is behind a horizontal scroll: {label}"
|
||||
|
||||
|
||||
class TestLiveStopButtonReachable:
|
||||
"""The live state must not regress: while sharing, ``#stop`` stays
|
||||
hit-testable, ``#placeholder`` is hidden, and ``#livetag`` sits top-left
|
||||
inside the stage. The three shapes are the ones measured live in
|
||||
``00_phase.md`` (``hit_stop: true`` after the fix).
|
||||
"""
|
||||
|
||||
LIVE_VIEWPORTS: ClassVar[list[tuple[int, int]]] = [
|
||||
(320, 568),
|
||||
(390, 844),
|
||||
(1280, 450),
|
||||
]
|
||||
|
||||
@pytest.mark.parametrize("viewport", LIVE_VIEWPORTS)
|
||||
def test_stop_is_hit_testable_while_sharing(self, base_url, browser, request, viewport):
|
||||
page = _open_page(base_url, browser, request, viewport[0], viewport[1])
|
||||
start_sharing(page)
|
||||
|
||||
stop = page.evaluate(STOP_PROBE, "#stop")
|
||||
label = f"{viewport[0]}x{viewport[1]}: {stop}"
|
||||
assert stop["disabled"] is False, f"#stop is disabled: {label}"
|
||||
assert stop["hit_is_button"], (
|
||||
f"#stop is not hit-testable, elementFromPoint named {stop['hit']!r}: {label}"
|
||||
)
|
||||
assert stop["in_viewport"], f"#stop is outside the viewport: {label}"
|
||||
assert stop["no_h_scroll"], f"#stop is behind a horizontal scroll: {label}"
|
||||
|
||||
state = page.evaluate(LIVE_STATE_PROBE)
|
||||
label = f"{viewport[0]}x{viewport[1]}: {state}"
|
||||
assert state["placeholder_hidden"], f"#placeholder is still shown while sharing: {label}"
|
||||
assert state["livetag_visible"], f"#livetag is not visible while sharing: {label}"
|
||||
assert state["livetag_in_stage"], f"#livetag is not inside the stage: {label}"
|
||||
Reference in New Issue
Block a user