From bc41c737e5cc4aba6c35d3e84f7008f1cb8536a6 Mon Sep 17 00:00:00 2001 From: Cameron Cordes Date: Tue, 29 Sep 2026 13:50:24 -0400 Subject: [PATCH] dev/e2e: wall_clock regression check + deterministic fixture dates `e2e.sh check` compares every indexed fixture's manifest wall clock with the `wall_clock` reported by /image/metadata, /memories and /photos/gps-summary; any disagreement fails. - Fixture videos are stamped like phone containers: the America/New_York wall as a UTC creation_time (the label previously showed the UTC time). - mtimes are pinned in America/New_York instead of the host's zone. - The e2e env sets TZ=America/New_York (the #120 preflight): the Windows exe has no /etc/timezone, so server_zone() was otherwise None. Co-Authored-By: Claude Opus 5.5 --- dev/e2e/README.md | 8 ++++-- dev/e2e/check_wall_clock.py | 55 +++++++++++++++++++++++++++++++++++++ dev/e2e/e2e.sh | 7 +++++ dev/e2e/gen_fixtures.py | 32 +++++++++++++++++++-- 4 files changed, 97 insertions(+), 5 deletions(-) create mode 100644 dev/e2e/check_wall_clock.py diff --git a/dev/e2e/README.md b/dev/e2e/README.md index 709bd62..f53f2ff 100644 --- a/dev/e2e/README.md +++ b/dev/e2e/README.md @@ -7,6 +7,7 @@ end-to-end and agent-driven UI testing of the SynologyFileViewer app. dev/e2e/e2e.sh up # build + fixtures + reset + start; blocks until ingest is done dev/e2e/e2e.sh reset # back to the pristine baseline (after tests mutate things) dev/e2e/e2e.sh stop +dev/e2e/e2e.sh check # every fixture's displayed capture time (wall_clock) vs the manifest ``` Log in from the app with **server** `http://10.0.2.2:8090` (Android @@ -32,8 +33,11 @@ server. Everything under `.work/` is gitignored and safe to delete. `gen_fixtures.py` renders every image with its relative path, date and scenario burned into the pixels — a screenshot tells you exactly which fixture is on screen. `.work/state/manifest.json` lists every item with its -expected date, GPS, camera, tags and duplicate relationship, plus expected -counts; assert against it rather than hardcoding. +expected date, GPS, camera, tags, duplicate relationship and **expected +`wall_clock`** (the capture time the UI must show), plus expected counts; +assert against it rather than hardcoding. Naive fixture times are walls in +America/New_York (the e2e display and server zone); videos are stamped the +way phones write containers (that wall, as a UTC `creation_time`). | Folder | Covers | |---|---| diff --git a/dev/e2e/check_wall_clock.py b/dev/e2e/check_wall_clock.py new file mode 100644 index 0000000..1efd5ca --- /dev/null +++ b/dev/e2e/check_wall_clock.py @@ -0,0 +1,55 @@ +#!/usr/bin/env python3 +"""Regression check for the displayed capture time (ADR-0001 `wall_clock`). + +For every indexed fixture, the manifest's expected wall clock must equal the +`wall_clock` the server reports on each endpoint that includes the file: +`/image/metadata`, `/memories` (today's day span) and `/photos/gps-summary`. +One disagreement anywhere is exactly the class of bug this field exists to +end, so any mismatch fails the run. + +Usage: check_wall_clock.py (run via `e2e.sh check`) +""" + +import json +import os +import subprocess +import sys +import urllib.parse +from pathlib import Path + +E2E = Path(__file__).with_name("e2e.sh") + + +def api(path: str) -> dict: + return json.loads(subprocess.check_output([str(E2E), "api", "GET", path])) + + +def main() -> int: + manifest = json.load(open(sys.argv[1])) + excluded = tuple(d.strip("/") + "/" for d in manifest["excluded_dirs"]) + items = [i for i in manifest["items"] if not i["path"].startswith(excluded)] + + memories = {m["path"]: m.get("wall_clock") for m in api("memories?span=day")["items"]} + gps = {g["path"]: g.get("wall_clock") for g in api("photos/gps-summary?path=")["photos"]} + + failures = 0 + for item in items: + path, want = item["path"], item["wall_clock"] + got = {"metadata": api("image/metadata?path=" + urllib.parse.quote(path)).get("wall_clock")} + if path in memories: + got["memories"] = memories[path] + if path in gps: + got["gps-summary"] = gps[path] + bad = {k: v for k, v in got.items() if v != want} + if bad: + failures += 1 + print(f"MISMATCH {path}: expected {want}, got {bad}") + + print(f"{len(items) - failures}/{len(items)} fixtures report the expected wall clock " + f"({len(memories)} in memories, {len(gps)} in gps-summary)") + return 1 if failures else 0 + + +if __name__ == "__main__": + os.environ.setdefault("E2E_TOKEN", subprocess.check_output([str(E2E), "token"], text=True).strip()) + sys.exit(main()) diff --git a/dev/e2e/e2e.sh b/dev/e2e/e2e.sh index 25ccc1a..617c58d 100644 --- a/dev/e2e/e2e.sh +++ b/dev/e2e/e2e.sh @@ -87,6 +87,10 @@ LOCAL_BIND_URL= CORS_ALLOWED_ORIGINS=http://localhost:8081,http://localhost:19006 SECRET_KEY=e2e-dev-only-secret-not-for-production RUST_LOG=info +# Pin the server zone (ImageApi #120 preflight): the Windows exe has no +# /etc/timezone, so without this server_zone() is None and assumed_server +# rows fall back to the process-local offset. +TZ=America/New_York # Fast ingest so a fresh reset is browsable within seconds. WATCH_QUICK_INTERVAL_SECONDS=3 WATCH_FULL_INTERVAL_SECONDS=60 @@ -227,6 +231,8 @@ Usage: dev/e2e/e2e.sh real folder in as media/imported/; the source is read-only) reset stop, wipe db/thumbs/hls, re-copy media, recreate user start | stop | restart | status + check verify every fixture's displayed capture time (wall_clock) + on metadata, memories and gps-summary against the manifest token print a fresh JWT for the e2e user api METHOD PATH [JSON] authenticated request, e.g. api GET 'photos?path=&recursive=true' @@ -248,6 +254,7 @@ case "${1:-help}" in restart) cmd_stop; cmd_start ;; status) cmd_status ;; token) login ;; + check) E2E_TOKEN="$(login)" python3 "$HERE/check_wall_clock.py" "$STATE/manifest.json" ;; api) shift; api "$@" ;; logs) tail -f "$LOG" ;; info) cmd_info ;; diff --git a/dev/e2e/gen_fixtures.py b/dev/e2e/gen_fixtures.py index e92c090..765a0d6 100644 --- a/dev/e2e/gen_fixtures.py +++ b/dev/e2e/gen_fixtures.py @@ -28,6 +28,7 @@ import sys from concurrent.futures import ThreadPoolExecutor from dataclasses import dataclass, field from pathlib import Path +from zoneinfo import ZoneInfo FFMPEG = shutil.which("ffmpeg.exe") or shutil.which("ffmpeg") EXIFTOOL = shutil.which("exiftool.exe") or shutil.which("exiftool") @@ -35,6 +36,10 @@ EXIFTOOL = shutil.which("exiftool.exe") or shutil.which("exiftool") # the drive letter must be escaped inside a filter argument. FONT = "C\\:/Windows/Fonts/consola.ttf" +# The owner's display zone and the server zone the e2e env pins (TZ=...). +# Naive fixture datetimes without GPS are walls in this zone. +HOME_TZ = ZoneInfo("America/New_York") + PLACES = { "santa_monica": (34.0100, -118.4960), "paris": (48.8584, 2.2945), @@ -53,7 +58,9 @@ class Item: rel: str # path relative to the library root, forward slashes kind: str # "image" | "video" size: tuple[int, int] = (1600, 1200) - taken: dt.datetime | None = None # written to EXIF / video creation_time + # Capture wall clock: EXIF DateTimeOriginal as-is; for videos, the wall in + # HOME_TZ, stamped as UTC creation_time the way phones write containers. + taken: dt.datetime | None = None gps: str | None = None # key into PLACES camera: tuple[str, str] | None = None # (Make, Model) mtime: dt.datetime | None = None # pinned file mtime (undated fallback) @@ -160,6 +167,21 @@ def build_items(today: dt.date) -> list[Item]: return items +def expected_wall_clock(it: Item) -> str: + """The wall clock the server should report (`wall_clock`): the filename + date for the filename class (it outranks EXIF), else the capture wall, + else the pinned mtime rendered in HOME_TZ.""" + import re + name = it.rel.rsplit("/", 1)[-1] + m = (re.search(r"(\d{4})(\d{2})(\d{2})[_-](\d{2})(\d{2})(\d{2})", name) + or re.search(r"(\d{4})-(\d{2})-(\d{2})_(\d{2})-(\d{2})-(\d{2})", name)) + if m: + wall = dt.datetime(*map(int, m.groups())) + else: + wall = it.taken or it.mtime or dt.datetime(2024, 1, 1) + return wall.strftime("%Y-%m-%dT%H:%M:%S") + + def _same_day(today: dt.date, year: int, hour: int) -> dt.datetime: # Feb 29 in a non-leap year would be invalid; fall back to Feb 28. try: @@ -200,7 +222,8 @@ def render(item: Item, idx: int, media: Path) -> None: "-c:a", "aac", "-shortest", ] if item.taken: - cmd += ["-metadata", f"creation_time={item.taken.isoformat()}Z"] + utc = item.taken.replace(tzinfo=HOME_TZ).astimezone(dt.timezone.utc) + cmd += ["-metadata", f"creation_time={utc.strftime('%Y-%m-%dT%H:%M:%S')}Z"] subprocess.run(cmd + [win(out)], check=True) @@ -271,7 +294,7 @@ def main() -> int: # Pin mtimes last: exiftool rewrites (and so touches) the files. for it in items: when = it.mtime or it.taken or dt.datetime(2024, 1, 1) - ts = when.timestamp() + ts = when.replace(tzinfo=HOME_TZ).timestamp() # not WSL's local zone os.utime(media / it.rel, (ts, ts)) imported = 0 @@ -280,6 +303,7 @@ def main() -> int: shutil.copytree(a.import_dir, dest) # copy: the source is never touched imported = sum(1 for p in dest.rglob("*") if p.is_file()) + by_path = {it.rel: it for it in items} tags: dict[str, list[str]] = {} for it in items: for t in it.tags: @@ -306,6 +330,8 @@ def main() -> int: "taken": it.taken.isoformat() if it.taken else None, "gps": it.gps, "camera": it.camera[1] if it.camera else None, "tags": it.tags, "duplicate_of": it.copy_of, + # A duplicate carries its source's bytes, EXIF included. + "wall_clock": expected_wall_clock(by_path.get(it.copy_of, it)), } for it in items ],