Dates: true_instant on metadata responses for instant matching
The mobile viewer anchored its "messages around this photo" window on the legacy date_taken (a wall clock stored as UTC for EXIF rows), while SMS-API dates are true instants: the window was off by the photo's UTC offset (4-5 h for photos taken at home; ImageApi #113 user story 3). Verified SMS-API side: importers store backup epochs as UTC, and 40/40 sampled rows equal their source epochs exactly. `/image/metadata` (+ GPS / date mutation responses) now carries `true_instant` from `date_view::file_true_instant`: `row_true_instant`, else the filename-class instant, else the earliest fs time. Server-side for the same reason as `wall_clock`: the drain's permanent floor rows have no `date_utc`. `set_capture_dates` fills both fields at all four response sites. dev/e2e: the manifest records each fixture's expected instant (the wall read in its GPS zone, else America/New_York) and `e2e.sh check` asserts it (97/97). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
8fc512fef0
commit
e3477a4710
5 files changed
+117
-18
No files matched your search
@@ -1,9 +1,10 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Regression check for the displayed capture time (ADR-0001 `wall_clock`).
|
||||
"""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) and `/photos/gps-summary`.
|
||||
`/image/metadata`, `/memories` (today's day span) and `/photos/gps-summary`;
|
||||
and `/image/metadata`'s `true_instant` must equal the expected instant.
|
||||
One disagreement anywhere is exactly the class of bug this field exists to
|
||||
end, so any mismatch fails the run.
|
||||
|
||||
@@ -35,17 +36,21 @@ def main() -> int:
|
||||
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")}
|
||||
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]
|
||||
bad = {k: v for k, v in got.items() if v != want}
|
||||
# The matching anchor (SMS / location windows) must be the true instant.
|
||||
if meta.get("true_instant") != item["true_instant"]:
|
||||
bad["metadata true_instant"] = (meta.get("true_instant"), "expected", item["true_instant"])
|
||||
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 "
|
||||
print(f"{len(items) - failures}/{len(items)} fixtures report the expected wall clock + instant "
|
||||
f"({len(memories)} in memories, {len(gps)} in gps-summary)")
|
||||
return 1 if failures else 0
|
||||
|
||||
|
||||
@@ -47,6 +47,15 @@ PLACES = {
|
||||
"tokyo": (35.6595, 139.7005),
|
||||
}
|
||||
|
||||
# IANA zone of each place — what the server's GPS rung of the offset cascade
|
||||
# resolves, so the expected true instant is the wall read in this zone.
|
||||
PLACE_ZONES = {
|
||||
"santa_monica": "America/Los_Angeles",
|
||||
"paris": "Europe/Paris",
|
||||
"yosemite": "America/Los_Angeles",
|
||||
"tokyo": "Asia/Tokyo",
|
||||
}
|
||||
|
||||
PALETTE = [
|
||||
"0x1f4e79", "0x7a3e9d", "0x2e7d32", "0xb71c1c", "0xe65100",
|
||||
"0x006064", "0x4e342e", "0x283593", "0x827717", "0xad1457",
|
||||
@@ -182,6 +191,15 @@ def expected_wall_clock(it: Item) -> str:
|
||||
return wall.strftime("%Y-%m-%dT%H:%M:%S")
|
||||
|
||||
|
||||
def expected_true_instant(it: Item) -> int:
|
||||
"""The true instant the server should report: the expected wall read in
|
||||
the zone the camera was in (GPS place, else HOME_TZ — the server's
|
||||
assumed zone). Filename walls outrank EXIF, but still take GPS evidence."""
|
||||
wall = dt.datetime.fromisoformat(expected_wall_clock(it))
|
||||
zone = ZoneInfo(PLACE_ZONES[it.gps]) if it.gps else HOME_TZ
|
||||
return int(wall.replace(tzinfo=zone).timestamp())
|
||||
|
||||
|
||||
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:
|
||||
@@ -332,6 +350,7 @@ def main() -> int:
|
||||
"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)),
|
||||
"true_instant": expected_true_instant(by_path.get(it.copy_of, it)),
|
||||
}
|
||||
for it in items
|
||||
],
|
||||
|
||||
+6
-1
@@ -263,6 +263,10 @@ pub struct MetadataResponse {
|
||||
/// (`2018-09-29T14:00:00`) — `date_view::file_wall_clock`. Render it
|
||||
/// verbatim; never zone-convert it (ADR-0001).
|
||||
pub wall_clock: Option<String>,
|
||||
/// True instant of capture (unix seconds) — `date_view::file_true_instant`.
|
||||
/// For instant-to-instant matching (SMS / location windows); never
|
||||
/// render it, render `wall_clock`.
|
||||
pub true_instant: Option<i64>,
|
||||
}
|
||||
|
||||
impl From<fs::Metadata> for MetadataResponse {
|
||||
@@ -281,7 +285,8 @@ impl From<fs::Metadata> for MetadataResponse {
|
||||
filename_date: None, // Will be set in endpoint handler
|
||||
library_id: None,
|
||||
library_name: None,
|
||||
wall_clock: None, // Set in the endpoint handler
|
||||
wall_clock: None, // Set in the endpoint handler
|
||||
true_instant: None, // Set in the endpoint handler
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -243,6 +243,26 @@ pub fn file_wall_clock(
|
||||
wall.map(format_wall_clock)
|
||||
}
|
||||
|
||||
/// True instant for a file that may have no `image_exif` row yet — the
|
||||
/// [`file_wall_clock`] precedence (row, then filename-class date, then
|
||||
/// earliest fs time). The anchor for instant-to-instant matching (the
|
||||
/// "messages around this photo" window), never for display.
|
||||
pub fn file_true_instant(
|
||||
row: Option<&ImageExif>,
|
||||
rel_path: &str,
|
||||
fs_earliest: Option<i64>,
|
||||
display_zone: Tz,
|
||||
server_zone: Option<Tz>,
|
||||
) -> Option<i64> {
|
||||
if let Some(instant) = row.and_then(|r| row_true_instant(r, display_zone, server_zone)) {
|
||||
return Some(instant);
|
||||
}
|
||||
match crate::memories::extract_date_from_filename(rel_path) {
|
||||
Some(fd) => filename_true_instant(&fd, server_zone),
|
||||
None => fs_earliest,
|
||||
}
|
||||
}
|
||||
|
||||
/// Render a unix instant as a naive wall in `zone`.
|
||||
fn render_in_zone_opt(instant: i64, zone: Tz) -> Option<NaiveDateTime> {
|
||||
zone.timestamp_opt(instant, 0)
|
||||
@@ -721,4 +741,43 @@ mod tests {
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn file_true_instant_uses_the_row_then_filename_then_fs_time() {
|
||||
// Row: the converted instant, not the wall (Tokyo 14:00 = 05:00Z).
|
||||
let r = row(
|
||||
"otd.jpg",
|
||||
Some(utc_epoch(2018, 9, 29, 14, 0)),
|
||||
Some("exif"),
|
||||
Some(utc_epoch(2018, 9, 29, 5, 0)),
|
||||
Some(540),
|
||||
);
|
||||
assert_eq!(
|
||||
file_true_instant(Some(&r), "otd.jpg", Some(0), ny(), Some(ny())),
|
||||
Some(utc_epoch(2018, 9, 29, 5, 0))
|
||||
);
|
||||
// No row: a wall-pattern filename is read in the server zone
|
||||
// (10:15 EDT = 14:15Z).
|
||||
assert_eq!(
|
||||
file_true_instant(
|
||||
None,
|
||||
"Screenshots/Screenshot_20220315-101500.png",
|
||||
Some(0),
|
||||
ny(),
|
||||
Some(ny())
|
||||
),
|
||||
Some(utc_epoch(2022, 3, 15, 14, 15))
|
||||
);
|
||||
// No row, no filename date: fs time is already an instant.
|
||||
assert_eq!(
|
||||
file_true_instant(
|
||||
None,
|
||||
"Undated/scan.jpg",
|
||||
Some(1_425_229_200),
|
||||
ny(),
|
||||
Some(ny())
|
||||
),
|
||||
Some(1_425_229_200)
|
||||
);
|
||||
}
|
||||
}
|
||||
+24
-13
@@ -534,12 +534,11 @@ pub async fn get_file_metadata(
|
||||
.lock()
|
||||
.ok()
|
||||
.and_then(|mut dao| dao.get_exif(&span_context, &path.path).ok().flatten());
|
||||
response.wall_clock = crate::date_view::file_wall_clock(
|
||||
set_capture_dates(
|
||||
&mut response,
|
||||
row.as_ref(),
|
||||
&path.path,
|
||||
earliest_fs_time(&response),
|
||||
crate::date_view::owner_display_zone(&app_state.user_ai_prefs_dao, &span_context),
|
||||
crate::camera_tz::server_zone(),
|
||||
);
|
||||
response.exif = row.map(Into::into);
|
||||
|
||||
@@ -724,14 +723,14 @@ pub async fn set_image_gps(
|
||||
library_id: None,
|
||||
library_name: None,
|
||||
wall_clock: None,
|
||||
true_instant: None,
|
||||
},
|
||||
};
|
||||
response.wall_clock = crate::date_view::file_wall_clock(
|
||||
set_capture_dates(
|
||||
&mut response,
|
||||
Some(&row),
|
||||
&normalized_path,
|
||||
earliest_fs_time(&response),
|
||||
crate::date_view::owner_display_zone(&app_state.user_ai_prefs_dao, &span_context),
|
||||
crate::camera_tz::server_zone(),
|
||||
);
|
||||
response.exif = Some(row.into());
|
||||
response.library_id = Some(resolved_library.id);
|
||||
@@ -893,15 +892,10 @@ fn build_metadata_response_for_date_mutation(
|
||||
library_id: None,
|
||||
library_name: None,
|
||||
wall_clock: None,
|
||||
true_instant: None,
|
||||
},
|
||||
};
|
||||
response.wall_clock = crate::date_view::file_wall_clock(
|
||||
Some(&exif),
|
||||
rel_path,
|
||||
earliest_fs_time(&response),
|
||||
display_zone,
|
||||
crate::camera_tz::server_zone(),
|
||||
);
|
||||
set_capture_dates(&mut response, Some(&exif), rel_path, display_zone);
|
||||
response.exif = Some(exif.into());
|
||||
response.library_id = Some(library.id);
|
||||
response.library_name = Some(library.name.clone());
|
||||
@@ -910,6 +904,23 @@ fn build_metadata_response_for_date_mutation(
|
||||
response
|
||||
}
|
||||
|
||||
/// Fill the capture-date fields every metadata response carries (ADR-0001):
|
||||
/// `wall_clock` to display and `true_instant` to match against. Needs the
|
||||
/// fs times already on the response (the unindexed-file fallback).
|
||||
fn set_capture_dates(
|
||||
response: &mut MetadataResponse,
|
||||
row: Option<&ImageExif>,
|
||||
rel_path: &str,
|
||||
display_zone: chrono_tz::Tz,
|
||||
) {
|
||||
let server_zone = crate::camera_tz::server_zone();
|
||||
let fs_earliest = earliest_fs_time(response);
|
||||
response.wall_clock =
|
||||
crate::date_view::file_wall_clock(row, rel_path, fs_earliest, display_zone, server_zone);
|
||||
response.true_instant =
|
||||
crate::date_view::file_true_instant(row, rel_path, fs_earliest, display_zone, server_zone);
|
||||
}
|
||||
|
||||
/// Earlier of the file's created / modified times — the ingest waterfall's
|
||||
/// `fs_time` rule — for the wall-clock fallback of unindexed files.
|
||||
fn earliest_fs_time(response: &MetadataResponse) -> Option<i64> {
|
||||
|
||||
Reference in new issue
Block a user