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 <noreply@anthropic.com>
This commit is contained in:
Cameron CordesandClaude Opus 5.5 committed 2026-09-29 13:50:24 -04:00
1 parent 7231383fa6
commit 8fc512fef0
4 files changed
+97 -5

No files matched your search

+6 -2
View File
@@ -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 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 reset # back to the pristine baseline (after tests mutate things)
dev/e2e/e2e.sh stop 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 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 `gen_fixtures.py` renders every image with its relative path, date and
scenario burned into the pixels — a screenshot tells you exactly which scenario burned into the pixels — a screenshot tells you exactly which
fixture is on screen. `.work/state/manifest.json` lists every item with its fixture is on screen. `.work/state/manifest.json` lists every item with its
expected date, GPS, camera, tags and duplicate relationship, plus expected expected date, GPS, camera, tags, duplicate relationship and **expected
counts; assert against it rather than hardcoding. `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 | | Folder | Covers |
|---|---| |---|---|
+55
View File
@@ -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 <manifest.json> (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())
+7
View File
@@ -87,6 +87,10 @@ LOCAL_BIND_URL=
CORS_ALLOWED_ORIGINS=http://localhost:8081,http://localhost:19006 CORS_ALLOWED_ORIGINS=http://localhost:8081,http://localhost:19006
SECRET_KEY=e2e-dev-only-secret-not-for-production SECRET_KEY=e2e-dev-only-secret-not-for-production
RUST_LOG=info 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. # Fast ingest so a fresh reset is browsable within seconds.
WATCH_QUICK_INTERVAL_SECONDS=3 WATCH_QUICK_INTERVAL_SECONDS=3
WATCH_FULL_INTERVAL_SECONDS=60 WATCH_FULL_INTERVAL_SECONDS=60
@@ -227,6 +231,8 @@ Usage: dev/e2e/e2e.sh <command>
real folder in as media/imported/; the source is read-only) real folder in as media/imported/; the source is read-only)
reset stop, wipe db/thumbs/hls, re-copy media, recreate user reset stop, wipe db/thumbs/hls, re-copy media, recreate user
start | stop | restart | status 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 token print a fresh JWT for the e2e user
api METHOD PATH [JSON] api METHOD PATH [JSON]
authenticated request, e.g. api GET 'photos?path=&recursive=true' authenticated request, e.g. api GET 'photos?path=&recursive=true'
@@ -248,6 +254,7 @@ case "${1:-help}" in
restart) cmd_stop; cmd_start ;; restart) cmd_stop; cmd_start ;;
status) cmd_status ;; status) cmd_status ;;
token) login ;; token) login ;;
check) E2E_TOKEN="$(login)" python3 "$HERE/check_wall_clock.py" "$STATE/manifest.json" ;;
api) shift; api "$@" ;; api) shift; api "$@" ;;
logs) tail -f "$LOG" ;; logs) tail -f "$LOG" ;;
info) cmd_info ;; info) cmd_info ;;
+29 -3
View File
@@ -28,6 +28,7 @@ import sys
from concurrent.futures import ThreadPoolExecutor from concurrent.futures import ThreadPoolExecutor
from dataclasses import dataclass, field from dataclasses import dataclass, field
from pathlib import Path from pathlib import Path
from zoneinfo import ZoneInfo
FFMPEG = shutil.which("ffmpeg.exe") or shutil.which("ffmpeg") FFMPEG = shutil.which("ffmpeg.exe") or shutil.which("ffmpeg")
EXIFTOOL = shutil.which("exiftool.exe") or shutil.which("exiftool") 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. # the drive letter must be escaped inside a filter argument.
FONT = "C\\:/Windows/Fonts/consola.ttf" 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 = { PLACES = {
"santa_monica": (34.0100, -118.4960), "santa_monica": (34.0100, -118.4960),
"paris": (48.8584, 2.2945), "paris": (48.8584, 2.2945),
@@ -53,7 +58,9 @@ class Item:
rel: str # path relative to the library root, forward slashes rel: str # path relative to the library root, forward slashes
kind: str # "image" | "video" kind: str # "image" | "video"
size: tuple[int, int] = (1600, 1200) 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 gps: str | None = None # key into PLACES
camera: tuple[str, str] | None = None # (Make, Model) camera: tuple[str, str] | None = None # (Make, Model)
mtime: dt.datetime | None = None # pinned file mtime (undated fallback) mtime: dt.datetime | None = None # pinned file mtime (undated fallback)
@@ -160,6 +167,21 @@ def build_items(today: dt.date) -> list[Item]:
return items 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: 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. # Feb 29 in a non-leap year would be invalid; fall back to Feb 28.
try: try:
@@ -200,7 +222,8 @@ def render(item: Item, idx: int, media: Path) -> None:
"-c:a", "aac", "-shortest", "-c:a", "aac", "-shortest",
] ]
if item.taken: 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) subprocess.run(cmd + [win(out)], check=True)
@@ -271,7 +294,7 @@ def main() -> int:
# Pin mtimes last: exiftool rewrites (and so touches) the files. # Pin mtimes last: exiftool rewrites (and so touches) the files.
for it in items: for it in items:
when = it.mtime or it.taken or dt.datetime(2024, 1, 1) 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)) os.utime(media / it.rel, (ts, ts))
imported = 0 imported = 0
@@ -280,6 +303,7 @@ def main() -> int:
shutil.copytree(a.import_dir, dest) # copy: the source is never touched shutil.copytree(a.import_dir, dest) # copy: the source is never touched
imported = sum(1 for p in dest.rglob("*") if p.is_file()) 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]] = {} tags: dict[str, list[str]] = {}
for it in items: for it in items:
for t in it.tags: for t in it.tags:
@@ -306,6 +330,8 @@ def main() -> int:
"taken": it.taken.isoformat() if it.taken else None, "taken": it.taken.isoformat() if it.taken else None,
"gps": it.gps, "camera": it.camera[1] if it.camera else None, "gps": it.gps, "camera": it.camera[1] if it.camera else None,
"tags": it.tags, "duplicate_of": it.copy_of, "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 for it in items
], ],