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:
1 parent
e2f33684c8
commit
bc41c737e5
4 files changed
+97
-5
No files matched your search
+6
-2
@@ -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 |
|
||||||
|---|---|
|
|---|---|
|
||||||
|
|||||||
@@ -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())
|
||||||
@@ -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
@@ -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
|
||||||
],
|
],
|
||||||
|
|||||||
Reference in new issue
Block a user