/photos, /photos/exif and unified search take date_from_day / date_to_day (YYYY-MM-DD): the server turns them into the first day's start and the last day's end in the owner's display zone, the same days /memories groups by. Each replaces its instant counterpart. Clients stop computing midnights in the device's zone, which drifted by the travel offset whenever the phone's zone differed from display_tz. e2e check: every fixture is included by its display-zone day and excluded by the days either side. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
104 lines
4.5 KiB
Python
Executable File
104 lines
4.5 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Regression check for capture dates (ADR-0001 `wall_clock` / `true_instant`).
|
|
|
|
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), `/photos/gps-summary` and
|
|
`/photos/exif`; and `/image/metadata` / `/photos/exif`'s `true_instant` must
|
|
equal the expected instant. The `/photos/exif` date filter must also treat
|
|
that instant as a range edge (#152): `[t, t]` includes the file, and a range
|
|
ending at `t - 1` or starting at `t + 1` excludes it; and filtering by the
|
|
file's calendar day in the owner's display zone (`date_from_day` /
|
|
`date_to_day`) includes it, while the day before or after excludes it. One
|
|
disagreement
|
|
anywhere is exactly the class of bug these fields exist 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 datetime import datetime, timedelta, timezone
|
|
from pathlib import Path
|
|
from zoneinfo import ZoneInfo
|
|
|
|
E2E = Path(__file__).with_name("e2e.sh")
|
|
# The owner's display zone in the e2e env (no prefs row → the app default).
|
|
DISPLAY_ZONE = ZoneInfo("America/New_York")
|
|
|
|
|
|
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"]}
|
|
exif = {
|
|
e["file_path"]: e
|
|
for e in api(f"photos/exif?date_from=0&date_to={2**62}")["photos"]
|
|
}
|
|
|
|
def in_exif_range(path: str, lo: int, hi: int) -> bool:
|
|
rows = api(f"photos/exif?date_from={lo}&date_to={hi}")["photos"]
|
|
return any(r["file_path"] == path for r in rows)
|
|
|
|
def in_exif_days(path: str, first: str, last: str) -> bool:
|
|
rows = api(f"photos/exif?date_from_day={first}&date_to_day={last}")["photos"]
|
|
return any(r["file_path"] == path for r in rows)
|
|
|
|
failures = 0
|
|
for item in items:
|
|
path, want = item["path"], item["wall_clock"]
|
|
meta = api("image/metadata?path=" + urllib.parse.quote(path))
|
|
got = {"metadata": meta.get("wall_clock")}
|
|
if path in memories:
|
|
got["memories"] = memories[path]
|
|
if path in gps:
|
|
got["gps-summary"] = gps[path]
|
|
if path in exif:
|
|
got["photos/exif"] = exif[path].get("wall_clock")
|
|
bad = {k: v for k, v in got.items() if v != want}
|
|
# The matching anchor (SMS / location windows) must be the true instant.
|
|
t = item["true_instant"]
|
|
if meta.get("true_instant") != t:
|
|
bad["metadata true_instant"] = (meta.get("true_instant"), "expected", t)
|
|
if path in exif:
|
|
if exif[path].get("true_instant") != t:
|
|
bad["photos/exif true_instant"] = (exif[path].get("true_instant"), "expected", t)
|
|
# Range edges on the true instant (#152).
|
|
if not in_exif_range(path, t, t):
|
|
bad["photos/exif [t, t]"] = "excluded"
|
|
if in_exif_range(path, t - 86_400, t - 1):
|
|
bad["photos/exif [.., t-1]"] = "included"
|
|
if in_exif_range(path, t + 1, t + 86_400):
|
|
bad["photos/exif [t+1, ..]"] = "included"
|
|
# Calendar days are the owner's display-zone days (#152).
|
|
day = datetime.fromtimestamp(t, timezone.utc).astimezone(DISPLAY_ZONE).date()
|
|
iso = lambda d: d.isoformat()
|
|
if not in_exif_days(path, iso(day), iso(day)):
|
|
bad[f"photos/exif day {day}"] = "excluded"
|
|
for other in (day - timedelta(days=1), day + timedelta(days=1)):
|
|
if in_exif_days(path, iso(other), iso(other)):
|
|
bad[f"photos/exif day {other}"] = "included"
|
|
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 + instant "
|
|
f"({len(memories)} in memories, {len(gps)} in gps-summary, {len(exif)} in photos/exif)")
|
|
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())
|