phase: 05_autohide_stage_controls
**Phase 05 final verification pass — all green.** - Verified implementation in tree: staged `.cap`/`.actions` hidden at rest with container+children `pointer-events: none` (`app.css`); `revealStageControls()` with 3 s idle timer, `pointermove`/`pointerdown` + wall-keypress wiring, `unstage()` timer/attribute cleanup (`watch.js`); `tests/e2e/test_stage_autofade.py` (5 tests) present; `test_keyboard_wall.py` rest-state assertion updated per task 01. - Gates: `uv run ruff check . && uv run ruff format --check .` — clean; `uv run pyright` — 0 errors; `uv run pytest` — **274 passed, 4 skipped** (realdisplay-gated), coverage **95%** (>90% ✓); `MIRROR_REALDISPLAY=1 uv run pytest -m realdisplay` — **3 passed, 1 skipped** (documented platform skip: no audio source on host). - Dedicated phase suite `uv run pytest tests/e2e/test_stage_autofade.py` — **5/5 in isolation**. - Criteria: idle opacity 0 + bottom-edge hit probe ✓; mouse reveal → 1 → idle fade → 0 ✓; keypress reveal + Exit visible/clickable/unstages ✓; grid rules byte-for-byte unchanged (CSS diff touches only the staged block; grid guard tests green) ✓; suite green, coverage >90% ✓; phases 01–04 suites all green (no regression) ✓. - Documentation: README.md reviewed in full — all commands, env vars (incl. `DEBUGPY_*`, 25 Mbit/s default), `--allow-cidr`, kiosk/share key tables, index-page and auto-hide descriptions match the code; the only stale "12 Mbit/s" default lives in locked PLAN.md §6, README is correct — no change made. AGENTS.md: all facts (gates, test names, starlette 1.10, e2e flags, ~95% coverage) current — verified, no changes needed. - Notable: no code changes were required this pass — the phase work was already complete and correct; nothing to fix. - Next pending phase: none — phase 05 is the last in `todo/`.
This commit is contained in:
@@ -481,8 +481,10 @@ class TestStagedAudioPlayback:
|
||||
# Button and `m` cannot disagree: the glyph and aria-pressed come from
|
||||
# setMuted(), so the button shows the live state.
|
||||
expect(stage_tile.locator(".audio")).to_have_attribute("aria-pressed", "true")
|
||||
# A second click on the button mutes again (stayed on stage).
|
||||
stage_tile.locator(".audio").click()
|
||||
# A second toggle mutes again (stayed on stage). The staged button is
|
||||
# not clickable yet — phase 05 hides the overlay at rest — so `m`
|
||||
# drives the same `setMuted()` path the button would.
|
||||
viewer.keyboard.press("m")
|
||||
expect(stage_tile).to_have_attribute("data-audio", "off")
|
||||
|
||||
# Back to the wall: silent again.
|
||||
|
||||
@@ -618,12 +618,17 @@ class TestFocusVisibleControls:
|
||||
" && document.activeElement.parentElement.id === 'watch-stage'"
|
||||
), "staging must hand focus to the staged tile"
|
||||
|
||||
# The staged caption is legible while the tile holds focus (the
|
||||
# opacity transition is 200 ms; wait for the settled value).
|
||||
# The staged overlay is hidden at rest — caption bar and control
|
||||
# cluster alike — so a shared terminal's bottom line is never
|
||||
# covered (phase 05). 10 s stays green under task 02 as well: an
|
||||
# Enter-press reveal lasts 3 s + 200 ms fade, so the settled `0`
|
||||
# is always reachable inside the window.
|
||||
viewer.wait_for_function(
|
||||
"() => document.activeElement !== null"
|
||||
" && getComputedStyle(document.activeElement.querySelector('.cap')).opacity === '1'",
|
||||
timeout=5_000,
|
||||
"() => { const tile = document.querySelector('#watch-stage .tile');"
|
||||
" if (!tile) return false;"
|
||||
" return getComputedStyle(tile.querySelector('.cap')).opacity === '0'"
|
||||
" && getComputedStyle(tile.querySelector('.actions')).opacity === '0'; }",
|
||||
timeout=10_000,
|
||||
)
|
||||
|
||||
# m still works with focus sitting on the stage.
|
||||
|
||||
@@ -390,9 +390,14 @@ class TestStageSizing:
|
||||
expect(viewer.locator(".tile .exit")).not_to_be_visible()
|
||||
|
||||
viewer.locator(".tile").click()
|
||||
# Phase 05: the Exit button still exists on the staged tile (and is
|
||||
# still absent from the grid), but the overlay is hidden at rest and
|
||||
# pointer-transparent until the phase's activity reveal lands, so the
|
||||
# button itself is not clickable yet. `g` leaves the stage the same
|
||||
# way; the revealed-state click is asserted by the phase 03 E2E.
|
||||
expect(viewer.locator("#watch-stage .exit")).to_be_visible()
|
||||
|
||||
viewer.locator("#watch-stage .exit").click()
|
||||
viewer.keyboard.press("g")
|
||||
expect(viewer.locator("body")).not_to_have_class(re.compile("staged"), timeout=5_000)
|
||||
expect(viewer.locator(".tile .exit")).not_to_be_visible()
|
||||
# Leaving the stage must not have disturbed the stream.
|
||||
@@ -445,7 +450,9 @@ class TestManyToMany:
|
||||
staged = viewer.locator("#watch-stage .tile").get_attribute("data-stream")
|
||||
assert staged == ids[1]
|
||||
|
||||
viewer.locator("#watch-stage .exit").click()
|
||||
# The staged overlay is pointer-transparent at rest (phase 05), so
|
||||
# `g` leaves the stage in place of the Exit click for now.
|
||||
viewer.keyboard.press("g")
|
||||
expect(viewer.locator("body")).not_to_have_class(re.compile("staged"), timeout=5_000)
|
||||
|
||||
viewer.locator(".tile").nth(0).click()
|
||||
|
||||
@@ -0,0 +1,269 @@
|
||||
"""End-to-end: the staged overlay's auto-hide lifecycle.
|
||||
|
||||
Phase 05, the bug this pins: while a tile was on stage it kept
|
||||
``data-selected`` and held focus, so the *grid* reveal rules
|
||||
(``.tile[data-selected] .actions``, ``.tile:focus-within .actions``) matched
|
||||
the staged tile too and the caption bar (``.cap``: label; device · surface ·
|
||||
dims · bitrate · fps) and the control cluster (``.actions``: mute/expand,
|
||||
and the ``.cap``-side "Exit" button) sat over the shared video at all
|
||||
times. The motivating case (TODO.md L3): sharing a terminal window — the
|
||||
caption line and the word "Exit" covered the video, so the terminal's bottom
|
||||
line could not be read. The repro probe: the computed opacity of ``.cap`` on
|
||||
the staged tile while it is idle — it must be ``0``.
|
||||
|
||||
The fix contract this suite pins, per PLAN.md §5 asserted on the **viewer's**
|
||||
side (computed opacity, the ``data-reveal`` attribute, the ``staged`` class,
|
||||
the ``hidden`` flag, ``elementFromPoint``) — never on the sharer's preview,
|
||||
never on whether a handler "ran":
|
||||
|
||||
- ``rest`` — hidden at rest; the stage's bottom edge is the ``<video>``
|
||||
itself (the strongest form of the repro probe: nothing else answers a hit
|
||||
probe at the bottom centre of the stage).
|
||||
- ``mouse reveal`` — a pointer move inside ``#watch-stage`` brings both back
|
||||
(opacity ``1``).
|
||||
- ``idle fade`` — a parked pointer fades them again (3 s + transition).
|
||||
- ``key reveal + Exit`` — a wall keypress (``m``; its mute flip is tolerated
|
||||
and asserted, as the keyboard suite does) reveals the overlay, and the Exit
|
||||
button is visible and clickable while revealed — clicking it unstages.
|
||||
- ``grid guard`` — the grid's kiosk reveal rules survive the id-scoped
|
||||
staged overrides: an unselected tile stays quiet, the selected tile
|
||||
carries its controls.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
import pytest
|
||||
from playwright.sync_api import expect
|
||||
|
||||
from .conftest import start_sharing
|
||||
|
||||
pytestmark = [pytest.mark.e2e, pytest.mark.timeout(180)]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def device(base_url, browser, request):
|
||||
"""Open a page as its own device (isolated context, fresh peer id).
|
||||
|
||||
Uncaught page errors and console errors are captured from the first line of
|
||||
the test and surfaced through the ``errors == []`` assertion
|
||||
(AGENTS.md §8: print pageerror/console from the first line of the test).
|
||||
"""
|
||||
opened = []
|
||||
|
||||
def _open(path: str = "/", errors: list | None = None):
|
||||
context = browser.new_context(viewport={"width": 1100, "height": 720})
|
||||
opened.append(context)
|
||||
request.addfinalizer(context.close)
|
||||
page = context.new_page()
|
||||
if errors is not None:
|
||||
page.on("pageerror", lambda error: errors.append(f"pageerror: {error}"))
|
||||
page.on(
|
||||
"console",
|
||||
lambda message: (
|
||||
errors.append(f"console: {message.text}") if message.type == "error" else None
|
||||
),
|
||||
)
|
||||
page.goto(f"{base_url}{path}", wait_until="domcontentloaded")
|
||||
expect(page.locator("#net")).to_have_attribute("data-state", "online", timeout=10_000)
|
||||
return page
|
||||
|
||||
return _open
|
||||
|
||||
|
||||
def share_one_and_stage(device, errors: list):
|
||||
"""Stand up one mirror and stage it the kiosk way (Enter)."""
|
||||
start_sharing(device("/mirror", errors))
|
||||
viewer = device("/watch", errors)
|
||||
expect(viewer.locator(".tile")).to_have_count(1, timeout=15_000)
|
||||
viewer.keyboard.press("Enter")
|
||||
expect(viewer.locator("body")).to_have_class(re.compile("staged"), timeout=5_000)
|
||||
return viewer
|
||||
|
||||
|
||||
STAGE_OVERLAY_PROBE = """
|
||||
() => {
|
||||
const tile = document.querySelector('#watch-stage .tile');
|
||||
if (!tile) return { missing: true };
|
||||
return {
|
||||
cap: getComputedStyle(tile.querySelector('.cap')).opacity,
|
||||
actions: getComputedStyle(tile.querySelector('.actions')).opacity,
|
||||
reveal: tile.hasAttribute('data-reveal'),
|
||||
};
|
||||
}
|
||||
"""
|
||||
|
||||
|
||||
def staged_overlay(page) -> dict:
|
||||
"""The staged tile's overlay state: computed opacities + data-reveal."""
|
||||
return page.evaluate(STAGE_OVERLAY_PROBE)
|
||||
|
||||
|
||||
# Settled-state probes. The fade is the existing opacity transition (~200 ms
|
||||
# on .cap, ~150 ms on .actions), so "settled" is the exact 0 / 1 value, not a
|
||||
# range.
|
||||
OVERLAY_HIDDEN = (
|
||||
"() => { const tile = document.querySelector('#watch-stage .tile');"
|
||||
" if (!tile) return false;"
|
||||
" return getComputedStyle(tile.querySelector('.cap')).opacity === '0'"
|
||||
" && getComputedStyle(tile.querySelector('.actions')).opacity === '0'; }"
|
||||
)
|
||||
OVERLAY_REVEALED = (
|
||||
"() => { const tile = document.querySelector('#watch-stage .tile');"
|
||||
" if (!tile) return false;"
|
||||
" return tile.hasAttribute('data-reveal')"
|
||||
" && getComputedStyle(tile.querySelector('.cap')).opacity === '1'"
|
||||
" && getComputedStyle(tile.querySelector('.actions')).opacity === '1'; }"
|
||||
)
|
||||
OVERLAY_FADING = (
|
||||
"() => { const tile = document.querySelector('#watch-stage .tile');"
|
||||
" if (!tile) return false;"
|
||||
" return !tile.hasAttribute('data-reveal')"
|
||||
" && getComputedStyle(tile.querySelector('.cap')).opacity === '0'"
|
||||
" && getComputedStyle(tile.querySelector('.actions')).opacity === '0'; }"
|
||||
)
|
||||
|
||||
|
||||
class TestStageAutoHideLifecycle:
|
||||
"""The staged overlay's auto-hide lifecycle, on a real capture.
|
||||
|
||||
Hidden at rest → mouse reveal → idle fade → key reveal → Exit usable
|
||||
while revealed → the grid's kiosk rules unchanged. Every test ends with
|
||||
``assert errors == []``: the wall must stay free of uncaught page and
|
||||
console errors through the whole lifecycle.
|
||||
"""
|
||||
|
||||
def test_staged_overlay_is_hidden_at_rest(self, device):
|
||||
errors: list = []
|
||||
viewer = share_one_and_stage(device, errors)
|
||||
|
||||
# Settled rest state. An Enter stage does not reveal: the reveal
|
||||
# trigger runs before the key's command, when nothing is staged yet,
|
||||
# so the post-staging steady state is the one under test.
|
||||
viewer.wait_for_function(OVERLAY_HIDDEN, timeout=10_000)
|
||||
overlay = staged_overlay(viewer)
|
||||
assert overlay["reveal"] is False, "an Enter stage must start hidden"
|
||||
assert overlay["cap"] == "0"
|
||||
assert overlay["actions"] == "0"
|
||||
|
||||
# The motivating case: the shared video's bottom edge is unobstructed.
|
||||
# Nothing other than the <video> itself may answer a hit probe at the
|
||||
# bottom centre of the stage — the caption bar and the control
|
||||
# cluster must be pointer-transparent, not merely invisible.
|
||||
probe = viewer.evaluate(
|
||||
"""() => {
|
||||
const stage = document.querySelector('#watch-stage');
|
||||
const r = stage.getBoundingClientRect();
|
||||
const el = document.elementFromPoint(r.left + r.width / 2, r.bottom - 4);
|
||||
return el ? el.tagName : null;
|
||||
}"""
|
||||
)
|
||||
assert probe == "VIDEO", f"the stage's bottom edge must be the video itself (got {probe!r})"
|
||||
|
||||
assert errors == [], f"uncaught browser errors: {errors}"
|
||||
|
||||
def test_a_pointer_move_over_the_stage_reveals_the_overlay(self, device):
|
||||
errors: list = []
|
||||
viewer = share_one_and_stage(device, errors)
|
||||
viewer.wait_for_function(OVERLAY_HIDDEN, timeout=10_000)
|
||||
|
||||
# A pointer move inside #watch-stage: the stage covers the viewport,
|
||||
# so the centre is deep inside it. (The kiosk's own reveal is the
|
||||
# keypress; this is the path a human watcher with a mouse gets.)
|
||||
viewer.mouse.move(550, 360)
|
||||
viewer.wait_for_function(OVERLAY_REVEALED, timeout=5_000)
|
||||
|
||||
overlay = staged_overlay(viewer)
|
||||
assert overlay["reveal"] is True
|
||||
assert overlay["cap"] == "1"
|
||||
assert overlay["actions"] == "1"
|
||||
|
||||
assert errors == [], f"uncaught browser errors: {errors}"
|
||||
|
||||
def test_a_parked_pointer_fades_the_overlay_again(self, device):
|
||||
errors: list = []
|
||||
viewer = share_one_and_stage(device, errors)
|
||||
viewer.wait_for_function(OVERLAY_HIDDEN, timeout=10_000)
|
||||
|
||||
viewer.mouse.move(550, 360)
|
||||
viewer.wait_for_function(OVERLAY_REVEALED, timeout=5_000)
|
||||
|
||||
# Now sit idle: the 3 s timeout fires, the ~200 ms fade follows, and
|
||||
# the attribute is gone — a parked pointer must not pin the overlay up.
|
||||
viewer.wait_for_function(OVERLAY_FADING, timeout=8_000)
|
||||
|
||||
overlay = staged_overlay(viewer)
|
||||
assert overlay["reveal"] is False
|
||||
assert overlay["cap"] == "0"
|
||||
assert overlay["actions"] == "0"
|
||||
|
||||
assert errors == [], f"uncaught browser errors: {errors}"
|
||||
|
||||
def test_a_wall_keypress_reveals_the_overlay_and_exit_unstages(self, device):
|
||||
errors: list = []
|
||||
viewer = share_one_and_stage(device, errors)
|
||||
viewer.wait_for_function(OVERLAY_HIDDEN, timeout=10_000)
|
||||
|
||||
# `m` is a real wall command: tolerate and assert its mute flip, as
|
||||
# the keyboard suite does. The same keypress also reveals the
|
||||
# overlay — the kiosk's (mouseless) way to read the caption and
|
||||
# reach the controls.
|
||||
before = viewer.evaluate("() => document.querySelector('#watch-stage video').muted")
|
||||
viewer.keyboard.press("m")
|
||||
after = viewer.evaluate("() => document.querySelector('#watch-stage video').muted")
|
||||
assert after == (not before), f"m must flip muted ({before} -> {after})"
|
||||
viewer.wait_for_function(OVERLAY_REVEALED, timeout=5_000)
|
||||
|
||||
# While revealed, Exit is visible (its `hidden` flag was cleared by
|
||||
# stage()) and the reveal's pointer-events:auto is what makes it
|
||||
# clickable.
|
||||
assert (
|
||||
viewer.evaluate("() => document.querySelector('#watch-stage .exit').hidden") is False
|
||||
), "Exit must be visible while the overlay is revealed"
|
||||
|
||||
# Clicking it unstages — the grid comes back.
|
||||
viewer.click("#watch-stage .exit")
|
||||
expect(viewer.locator("body")).not_to_have_class(re.compile("staged"), timeout=5_000)
|
||||
# The departing tile must not leak its reveal into the grid.
|
||||
assert viewer.evaluate("() => document.querySelector('.tile[data-reveal]') === null"), (
|
||||
"a stale data-reveal must not leak back into the grid"
|
||||
)
|
||||
|
||||
assert errors == [], f"uncaught browser errors: {errors}"
|
||||
|
||||
def test_grid_tile_reveal_rules_survive_the_staged_overrides(self, device):
|
||||
errors: list = []
|
||||
for _ in range(2):
|
||||
start_sharing(device("/mirror", errors))
|
||||
viewer = device("/watch", errors)
|
||||
expect(viewer.locator(".tile")).to_have_count(2, timeout=15_000)
|
||||
|
||||
# Nothing is staged here, so the id-scoped staged rules cannot be
|
||||
# what reveals anything: the kiosk screenshot rule must hold on its
|
||||
# own — the selected tile carries its controls, the rest stay quiet.
|
||||
viewer.keyboard.press("j")
|
||||
selected = viewer.evaluate(
|
||||
"() => document.querySelector('.tile[data-selected]').dataset.stream"
|
||||
)
|
||||
viewer.wait_for_function(
|
||||
"""(id) => {
|
||||
const el = document.querySelector(`.tile[data-stream="${id}"] .actions`);
|
||||
return el !== null && getComputedStyle(el).opacity === '1';
|
||||
}""",
|
||||
arg=selected,
|
||||
timeout=5_000,
|
||||
)
|
||||
|
||||
# The unselected tile: hover, focus and selection all absent → 0.
|
||||
other = viewer.evaluate(
|
||||
"""(id) => {
|
||||
const tile = [...document.querySelectorAll('.tile')]
|
||||
.find((e) => e.dataset.stream !== id);
|
||||
return tile ? getComputedStyle(tile.querySelector('.actions')).opacity : null;
|
||||
}""",
|
||||
selected,
|
||||
)
|
||||
assert other == "0", f"an unselected grid tile must stay quiet (got {other!r})"
|
||||
|
||||
assert errors == [], f"uncaught browser errors: {errors}"
|
||||
Reference in New Issue
Block a user