Files
ImageApi/dev/e2e/check_wall_clock.py
T
Cameron CordesandClaude Opus 5.5 19fbf43d40 feat(dates): photo date filters on the true instant (#152)
date_from / date_to on /photos, /photos/exif and unified search ran on
the legacy mixed-convention date_taken, so a photo could display inside
a range yet be filtered out of it by up to its UTC offset (or by years,
for rows the filename class re-dated).

The filter lives in the shared ExifDao::query_by_exif, so it's fixed
once there:

- query_by_exif takes Option<date_view::InstantRange> (bounds + display
  and server zones) instead of raw date_from / date_to.
- SQL pre-filter: converted rows on date_utc exactly; unconverted rows
  (drain pending, permanent floor) on date_taken widened by
  LEGACY_INSTANT_BAND_SECS (±15 h, now shared with /memories). Converted
  rows are deliberately not bounded by date_taken: a re-dated row's
  legacy value can sit years from its instant.
- Exact pass in Rust via InstantRange::contains → row_true_instant, so
  every returned row's true instant is inside the range.

Calendar days now mean the owner's days: nl_query::iso_to_unix builds
day bounds in the display zone (it used UTC midnights, which only lined
up with the legacy wall-as-UTC column), and unified search's "today" is
the owner's today rather than UTC's. The midnight-with-DST-gap fallback
moves from memories_anchor into date_view::day_start_instant.

Compatibility: clients already sending real instants (Apollo's
/photos/exif windows) get correct edges with no change. Clients sending
UTC-midnight day bounds see ranges shift by up to the UTC offset until
they send display-zone bounds.

No index on date_utc; the OR clause scans, which is milliseconds at
personal-library scale (same as /memories today).

dev/e2e check gains /photos/exif wall_clock + true_instant assertions and
range-edge checks ([t, t] in, [.., t-1] and [t+1, ..] out). Not run here:
the harness needs the WSL + Windows toolchain.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 21:52:48 -04:00

85 lines
3.5 KiB
Python

#!/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. 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 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"]}
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)
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"
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())