trainUs/scripts/screenshots/combo_timers.py
David 352a3d5e88
Some checks are pending
CI / Lint & Format (push) Waiting to run
CI / Build (push) Waiting to run
CI / Tests (${{ matrix.browser }}) (chromium) (push) Waiting to run
CI / Tests (${{ matrix.browser }}) (firefox) (push) Waiting to run
Initial commit
2026-06-18 13:06:57 +02:00

242 lines
9.3 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
Capture screenshots of EMOM and TABATA combo timers in Execution Mode,
including the TABATA rest screen — not covered by all_pages.py (which only
seeds an AMRAP combo).
The app is driven in English by default (use --lang fr for French
screenshots) with a seeded database so each screenshot shows meaningful
content rather than empty states.
Intended for visual investigations and documentation figures — not a test.
Prerequisites:
- Playwright for Python installed (see `tests/requirements.txt`)
- Playwright browsers installed: `python -m playwright install firefox chromium`
- Vite dev server running: `npm run dev -- --port 5199 --strictPort`
Usage (from project root):
python scripts/screenshots/combo_timers.py
python scripts/screenshots/combo_timers.py --browser chromium
python scripts/screenshots/combo_timers.py --lang fr
python scripts/screenshots/combo_timers.py --out-dir /tmp/screenshots
Options:
--url URL App URL (default: http://localhost:5199)
--browser NAME firefox | chromium | webkit (default: firefox)
--lang LANG en | fr (default: en)
--width N Viewport width in px (default: 1920)
--height N Viewport height in px (default: 1080)
--out-dir PATH Output directory (default: tmp/screenshots/combo-timers-<lang>-<browser>-<W>x<H>)
Exit code: 0 on success, 1 on failure.
"""
from __future__ import annotations
import argparse
import os
import sys
import time
import traceback
from pathlib import Path
from playwright.sync_api import Page, sync_playwright
# ---------------------------------------------------------------------------
# Seed helpers
# ---------------------------------------------------------------------------
# TABATA work/rest kept short (6s / 5s) so the script doesn't have to wait
# long to reach the rest screen.
_SEED_SCRIPT = """
async () => {
const uuid = await window.__repos.settings.get('user_uuid');
const ex1 = await window.__repos.exercises.create({
title: 'Jumping Jacks',
description: 'Cardio warm-up movement.',
asymmetric: false, alternate: false, image_urls: [], video_urls: [],
default_reps: 20, default_weight: 0, created_by: uuid
});
const ex2 = await window.__repos.exercises.create({
title: 'Mountain Climbers',
description: 'Core and cardio movement.',
asymmetric: false, alternate: false, image_urls: [], video_urls: [],
default_reps: 20, default_weight: 0, created_by: uuid
});
// ── EMOM combo — work phase is always a 60s slot per exercise ─────────
const emomComboId = await window.__repos.combos.create({
title: 'EMOM Cardio', description: '3-minute EMOM.',
type: 'EMOM', timer_config: {}, created_by: uuid
});
await window.__repos.combos.setExercises(emomComboId, [
{ exerciseId: ex1, position: 0, defaultReps: 20, defaultWeight: 0 }
]);
const emomSessId = await window.__repos.sessions.create({
title: 'EMOM Session', description: '', created_by: uuid
});
await window.__repos.sessions.setItems(emomSessId, [
{ item_id: emomComboId, item_type: 'combo', repetitions: 3, weight: 0.0 }
]);
// ── TABATA combo ────────────────────────────────────────────────────
const tabataComboId = await window.__repos.combos.create({
title: 'TABATA Blast', description: 'Work/rest intervals.',
type: 'TABATA', timer_config: { workTime: 6, restTime: 5 }, created_by: uuid
});
await window.__repos.combos.setExercises(tabataComboId, [
{ exerciseId: ex1, position: 0, defaultReps: 0, defaultWeight: 0 },
{ exerciseId: ex2, position: 1, defaultReps: 0, defaultWeight: 0 }
]);
const tabataSessId = await window.__repos.sessions.create({
title: 'TABATA Session', description: '', created_by: uuid
});
await window.__repos.sessions.setItems(tabataSessId, [
{ item_id: tabataComboId, item_type: 'combo', repetitions: 4, weight: 0.0 }
]);
return { emomSessId, tabataSessId };
}
"""
_COUNT_SCRIPT = """
async () => (await window.__repos.exercises.getAll()).length
"""
def wait_for_db(page: Page, url: str) -> None:
page.goto(url)
page.wait_for_function(
"document.documentElement.getAttribute('data-db-ready') === 'true'",
timeout=15000,
)
def ensure_language(page: Page, lang: str) -> None:
current = page.evaluate("async () => window.__repos.settings.get('language')")
if current != lang:
page.evaluate(f"async () => window.__repos.settings.set('language', '{lang}')")
page.reload()
page.wait_for_function(
"document.documentElement.getAttribute('data-db-ready') === 'true'",
timeout=15000,
)
def has_data(page: Page) -> bool:
return page.evaluate(_COUNT_SCRIPT) > 0
# ---------------------------------------------------------------------------
# Screenshot helpers
# ---------------------------------------------------------------------------
def shot(page: Page, out_dir: Path, name: str, full_page: bool = True) -> None:
time.sleep(0.3)
path = out_dir / f"{name}.png"
page.screenshot(path=str(path), full_page=full_page)
print(f"{path.name}")
def goto(page: Page, url: str, route: str, wait_selector: str | None = None) -> None:
page.goto(f"{url}/#/{route}", wait_until="domcontentloaded")
if wait_selector:
page.wait_for_selector(wait_selector, timeout=8000)
else:
time.sleep(0.3)
# ---------------------------------------------------------------------------
# Main capture flow
# ---------------------------------------------------------------------------
def capture(page: Page, url: str, out_dir: Path, lang: str) -> None:
wait_for_db(page, url)
ensure_language(page, lang)
if not has_data(page):
print(" Seeding database…")
ids = page.evaluate(_SEED_SCRIPT)
print(" Seed complete.")
else:
raise RuntimeError("DB already has data — start with a fresh browser context")
# ── EMOM — work phase starts immediately ───────────────────────────────
goto(page, url, f"sessions/{ids['emomSessId']}/execute", '[data-testid="execute-timer"]')
shot(page, out_dir, "combo-emom")
# ── TABATA — work phase ─────────────────────────────────────────────
# Navigate away first so Vue Router remounts SessionExecuteView (it reuses
# the component when only params change, see all_pages.py for the same
# workaround).
goto(page, url, "sessions", '[data-testid="session-new-btn"]')
goto(page, url, f"sessions/{ids['tabataSessId']}/execute", '[data-testid="execute-timer"]')
shot(page, out_dir, "combo-tabata-work")
# ── TABATA — rest phase (work duration is 6s in the seed above) ────────
page.wait_for_selector('[data-testid="execute-tabata-rest"]', timeout=12000)
shot(page, out_dir, "combo-tabata-rest")
# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------
def parse_args(argv: list[str]) -> argparse.Namespace:
parser = argparse.ArgumentParser(
description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter,
)
parser.add_argument("--url", default=os.environ.get("APP_URL", "http://localhost:5199"))
parser.add_argument("--browser", default="firefox", choices=["firefox", "chromium", "webkit"])
parser.add_argument("--lang", default="en", choices=["en", "fr"])
parser.add_argument("--width", type=int, default=1920)
parser.add_argument("--height", type=int, default=1080)
parser.add_argument("--out-dir", default=None)
return parser.parse_args(argv)
def default_out_dir(browser: str, lang: str, width: int, height: int) -> Path:
project_root = Path(__file__).resolve().parents[2]
prefix = f"combo-timers-{browser}" if lang == "en" else f"combo-timers-{lang}-{browser}"
return project_root / "tmp" / "screenshots" / f"{prefix}-{width}x{height}"
def main(argv: list[str] | None = None) -> int:
args = parse_args(argv or sys.argv[1:])
out_dir = (
Path(args.out_dir)
if args.out_dir
else default_out_dir(args.browser, args.lang, args.width, args.height)
)
out_dir.mkdir(parents=True, exist_ok=True)
print(f"\n── {args.browser} {args.width}×{args.height}{out_dir}")
with sync_playwright() as p:
browser_type = getattr(p, args.browser)
browser = browser_type.launch(headless=True)
try:
context = browser.new_context(viewport={"width": args.width, "height": args.height})
page = context.new_page()
page.on("dialog", lambda dialog: dialog.accept())
capture(page, args.url, out_dir, args.lang)
count = len(list(out_dir.glob("*.png")))
print(f"\n Done — {count} screenshots saved")
except Exception as exc:
print(f"\n ERROR: {exc}", file=sys.stderr)
traceback.print_exc()
return 1
finally:
browser.close()
return 0
if __name__ == "__main__":
sys.exit(main())