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
7231383fa6
commit
8fc512fef0
4 files changed
+97
-5
No files matched your search
+29
-3
@@ -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
|
||||
],
|
||||
|
||||
Reference in new issue
Block a user