Files
ImageApi/src/date_view.rs
T
Cameron CordesandClaude Opus 5.5 7231383fa6 Dates: serve the display wall clock (wall_clock) to clients
Every payload a client renders a capture time from now carries
`wall_clock`, a naive ISO-8601 string computed by the existing
`date_view::row_display_wall`: the camera's wall clock (true instant +
camera offset; the owner's display zone without an offset), with the
per-source fallback for unconverted rows. Clients render it verbatim and
do no zone math, which is the FileViewer-React #33 / Apollo #37 flip with
the rule kept server-side: the drain's permanent `date_utc IS NULL` floor
(#120: edited/exported files) would otherwise force every client to
carry the per-source legacy conventions forever, and insight prompts
already use the same function, so prompt and UI agree by construction.

- `/image/metadata` (+ the GPS / date / date-clear mutation responses):
  `file_wall_clock` — the row's wall, else the filename-class date, else
  the earliest fs time in the display zone (unindexed files).
- `/memories` items and `/photos/gps-summary` photos: `row_wall_clock`.
- `get_all_with_gps` returns full rows instead of a 7-tuple that dropped
  `date_taken_source`, which unconverted rows need.
- `date_view::owner_display_zone` resolves prefs -> app default per request.

Legacy fields are untouched, so current clients see byte-identical
responses (additive; safe to deploy ahead of the client flip, per #120).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 13:50:23 -04:00

725 lines
25 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Read-side views of the true-instant date contract (ADR-0001).
//!
//! [`crate::date_resolver`] is the ingest-side counterpart: it *writes* the
//! `(date_utc, camera_offset_minutes, date_taken_tz_source)` triple. This
//! module is what *reads* it. For a row — converted or not yet — it answers:
//!
//! 1. **What true instant does the row name?** — [`row_true_instant`].
//! Anchors time-window queries (SMS messages, location history, calendar
//! events) with zero offset arithmetic: the window is centered on the
//! instant, never on a wall clock.
//! 2. **What wall clock does the owner see?** — [`row_display_wall`].
//! The date insight prompts tell the model (camera wall clock when the
//! row carries an offset, display-zone rendering when it doesn't). Must
//! equal the wall the metadata endpoint reports, so prompt and UI agree.
//! 3. **Which owner day does the row land on?** — [`row_display_day`].
//! The grouping rule for memories buckets and reels: a photo taken in
//! Japan at 21:00 JST lands in the *owner's* day, not the camera's.
//!
//! Converted rows read the new triple directly. Unconverted rows (the
//! `backfill_utc_dates` drain has not reached them yet) fall back to the
//! legacy `date_taken` under its per-source convention (ADR-0001):
//!
//! | source | legacy holds |
//! |---|---|
//! | `exif` | camera wall clock, reinterpreted as UTC |
//! | `exiftool` (video) | the container's UTC-rendered naive, rendered in the server zone |
//! | `exiftool` (image) | camera wall clock, rendered in the server zone |
//! | `filename` (wall patterns) | wall clock, reinterpreted as UTC |
//! | `filename` (unix-timestamp) | a true instant |
//! | `fs_time` | a true instant |
//! | `manual` | the display-zone wall the operator picked, reinterpreted as UTC |
//!
//! so mixed rows mid-drain still group into the right day and the insight
//! prompts stay correct row-by-row as the drain converts.
use chrono::{NaiveDate, NaiveDateTime, TimeZone};
use chrono_tz::Tz;
use crate::camera_tz;
use crate::database::models::ImageExif;
/// Best-effort true instant (unix seconds) the row names.
///
/// `date_utc` when the drain has converted the row; otherwise derived from
/// the legacy value under its per-source convention. `server_zone` is the
/// fallback rung the drain would use without offset-tag or GPS evidence
/// (the camera was probably carried in the operator's zone); `display_zone`
/// interprets manual rows.
pub fn row_true_instant(row: &ImageExif, display_zone: Tz, server_zone: Option<Tz>) -> Option<i64> {
let legacy = row.date_taken?;
// Converted rows name their instant directly.
if let Some(utc) = row.date_utc {
return Some(utc);
}
match row.date_taken_source.as_deref() {
// Camera wall reinterpreted as UTC: the wall is the legacy value
// itself; the instant is that wall in the assumed zone.
Some("exif") => wall_in_zone(legacy, server_zone),
// exiftool rendered a naive string in the server zone. Videos hold
// the container's UTC-rendered creation time (a true instant behind
// one server-zone render); photos hold the camera wall.
Some("exiftool") => {
if crate::file_types::is_video_file(std::path::Path::new(&row.file_path)) {
// legacy = server.render(naive_utc) → recover naive_utc and
// read it back as UTC.
let naive = render_in_zone_fallback(legacy, server_zone);
Some(naive.and_utc().timestamp())
} else {
// legacy = server.render(wall); the instant is that wall in
// the server zone, which is `legacy` by construction.
Some(legacy)
}
}
// Filename class: wall patterns name a wall clock, the
// unix-timestamp pattern names a true instant directly.
Some("filename") => match crate::memories::match_filename_date(&row.file_path) {
Some(m) if matches!(m.date, crate::memories::FilenameDate::Wall(_)) => {
wall_in_zone(legacy, server_zone)
}
_ => Some(legacy),
},
Some("fs_time") => Some(legacy),
// The operator picked a display-zone wall; interpret it there.
Some("manual") => camera_tz::manual_date_resolution(Some(display_zone.name()), legacy)
.map(|(instant, _)| instant),
// Unknown / absent source: treat the legacy value as a true instant
// (the most common convention) rather than a wall clock.
_ => Some(legacy),
}
}
/// The wall clock the owner sees for this row — camera wall when the row
/// carries an offset (a Japan photo shows the Japan wall), display-zone
/// rendering when it doesn't. Unconverted rows use their per-source
/// convention (see module docs).
pub fn row_display_wall(
row: &ImageExif,
display_zone: Tz,
server_zone: Option<Tz>,
) -> Option<NaiveDateTime> {
if let Some(utc) = row.date_utc {
// Converted: wall = instant + camera offset (offset = local − UTC).
if let Some(offset_min) = row.camera_offset_minutes {
let wall = chrono::DateTime::from_timestamp(utc + offset_min as i64 * 60, 0)?;
return Some(wall.naive_utc());
}
return render_in_zone_opt(utc, display_zone);
}
let legacy = row.date_taken?;
match row.date_taken_source.as_deref() {
// The wall clock itself, stored as naive-as-UTC.
Some("exif") => Some(legacy_as_naive(legacy)),
Some("exiftool") => {
if crate::file_types::is_video_file(std::path::Path::new(&row.file_path)) {
// No camera wall exists for a container; show the owner's
// zone rendering of the recovered instant.
let naive = render_in_zone_fallback(legacy, server_zone);
let instant = naive.and_utc().timestamp();
render_in_zone_opt(instant, display_zone)
} else {
// The camera wall, recovered from the server-zone render.
Some(render_in_zone_fallback(legacy, server_zone))
}
}
Some("filename") => match crate::memories::match_filename_date(&row.file_path) {
Some(m) if matches!(m.date, crate::memories::FilenameDate::Wall(_)) => {
Some(legacy_as_naive(legacy))
}
_ => render_in_zone_opt(legacy, display_zone),
},
Some("fs_time") => render_in_zone_opt(legacy, display_zone),
Some("manual") => Some(legacy_as_naive(legacy)),
_ => render_in_zone_opt(legacy, display_zone),
}
}
/// The true instant a filename-class date names, for callers that fall
/// back to the filename when the row carries no date at all: wall patterns
/// are read in `server_zone` (the zone the camera was probably in),
/// unix-timestamp patterns are true instants outright.
pub fn filename_true_instant(
fd: &crate::memories::FilenameDate,
server_zone: Option<Tz>,
) -> Option<i64> {
match fd {
crate::memories::FilenameDate::Utc(dt) => Some(dt.timestamp()),
crate::memories::FilenameDate::Wall(wall) => match server_zone {
Some(z) => z
.from_local_datetime(wall)
.single()
.map(|dt| dt.timestamp()),
None => Some(wall.and_utc().timestamp()),
},
}
}
/// The owner's calendar day for this row: the day of the true instant in
/// the owner's display zone. This is the grouping key — a photo taken in
/// Japan at 21:00 JST lands in the owner's day, not the camera's.
pub fn row_display_day(
row: &ImageExif,
display_zone: Tz,
server_zone: Option<Tz>,
) -> Option<NaiveDate> {
let instant = row_true_instant(row, display_zone, server_zone)?;
Some(
display_zone
.timestamp_opt(instant, 0)
.single()?
.date_naive(),
)
}
/// Wire format for a wall clock: naive ISO-8601, no zone designator
/// (`2018-09-29T14:00:00`). Clients render it verbatim — no zone math —
/// so the UI shows exactly the wall [`row_display_wall`] computed and
/// insight prompts use (ADR-0001: consumers never re-anchor).
pub fn format_wall_clock(wall: NaiveDateTime) -> String {
wall.format("%Y-%m-%dT%H:%M:%S").to_string()
}
/// The owner's display zone for a request: prefs `display_tz`, else the
/// app default (never fails — an unreadable prefs row is the default).
pub fn owner_display_zone(
prefs_dao: &std::sync::Mutex<Box<dyn crate::database::UserAiPrefsDao>>,
context: &opentelemetry::Context,
) -> Tz {
use std::str::FromStr;
let stored = prefs_dao
.lock()
.ok()
.and_then(|mut dao| dao.get_prefs(context).ok().flatten())
.and_then(|p| p.display_tz);
Tz::from_str(&camera_tz::display_zone_for(stored.as_deref()))
.unwrap_or(chrono_tz::Tz::America__New_York)
}
/// [`row_display_wall`] in wire format.
pub fn row_wall_clock(
row: &ImageExif,
display_zone: Tz,
server_zone: Option<Tz>,
) -> Option<String> {
row_display_wall(row, display_zone, server_zone).map(format_wall_clock)
}
/// Wall clock for a file that may have no `image_exif` row yet (not yet
/// ingested, or excluded): the row's wall when it has one, else the
/// filename-class date (a wall pattern *is* the wall; a unix-timestamp
/// pattern renders in the display zone), else the file's earliest
/// filesystem time rendered in the display zone. The same precedence the
/// ingest waterfall applies, so the value doesn't jump once the row lands.
pub fn file_wall_clock(
row: Option<&ImageExif>,
rel_path: &str,
fs_earliest: Option<i64>,
display_zone: Tz,
server_zone: Option<Tz>,
) -> Option<String> {
if let Some(wall) = row.and_then(|r| row_display_wall(r, display_zone, server_zone)) {
return Some(format_wall_clock(wall));
}
let wall = match crate::memories::extract_date_from_filename(rel_path) {
Some(crate::memories::FilenameDate::Wall(wall)) => Some(wall),
Some(crate::memories::FilenameDate::Utc(dt)) => {
render_in_zone_opt(dt.timestamp(), display_zone)
}
None => fs_earliest.and_then(|t| render_in_zone_opt(t, display_zone)),
};
wall.map(format_wall_clock)
}
/// 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)
.single()
.map(|dt| dt.naive_local())
}
/// Render a unix instant as a naive wall in `zone`, falling back to the
/// naive-as-UTC reading when the zone is unknown.
fn render_in_zone_fallback(instant: i64, zone: Option<Tz>) -> NaiveDateTime {
match zone {
Some(z) => z
.timestamp_opt(instant, 0)
.single()
.map(|dt| dt.naive_local())
.unwrap_or_else(|| legacy_as_naive(instant)),
None => legacy_as_naive(instant),
}
}
/// Read a stored wall-as-UTC epoch back as its naive wall.
fn legacy_as_naive(legacy: i64) -> NaiveDateTime {
chrono::DateTime::from_timestamp(legacy, 0)
.expect("valid epoch")
.naive_utc()
}
/// The true instant of a wall clock assumed to have been read in `zone`;
/// naive-as-UTC when the zone is unknown.
fn wall_in_zone(legacy_wall_epoch: i64, zone: Option<Tz>) -> Option<i64> {
let wall = legacy_as_naive(legacy_wall_epoch);
match zone {
Some(z) => z
.from_local_datetime(&wall)
.single()
.map(|dt| dt.timestamp()),
None => Some(legacy_wall_epoch),
}
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::{NaiveDate, TimeZone};
use chrono_tz::Tz;
fn ny() -> Tz {
Tz::America__New_York
}
/// Build a minimal `ImageExif` row for date-field tests.
fn row(
path: &str,
date_taken: Option<i64>,
source: Option<&str>,
date_utc: Option<i64>,
offset: Option<i32>,
) -> ImageExif {
ImageExif {
id: 1,
library_id: 1,
file_path: path.to_string(),
camera_make: None,
camera_model: None,
lens_model: None,
width: None,
height: None,
orientation: None,
gps_latitude: None,
gps_longitude: None,
gps_altitude: None,
focal_length: None,
aperture: None,
shutter_speed: None,
iso: None,
date_taken,
created_time: 0,
last_modified: 0,
content_hash: None,
size_bytes: None,
phash_64: None,
dhash_64: None,
duplicate_of_hash: None,
duplicate_decided_at: None,
date_taken_source: source.map(str::to_string),
original_date_taken: None,
original_date_taken_source: None,
clip_embedding: None,
clip_model_version: None,
date_utc,
camera_offset_minutes: offset,
date_taken_tz_source: None,
original_date_utc: None,
original_camera_offset_minutes: None,
original_date_taken_tz_source: None,
}
}
fn utc_epoch(y: i32, mo: u32, d: u32, h: u32, mi: u32) -> i64 {
NaiveDate::from_ymd_opt(y, mo, d)
.unwrap()
.and_hms_opt(h, mi, 0)
.unwrap()
.and_utc()
.timestamp()
}
/// The AC-3 fixture: three rows naming the same true instant
/// (2024-07-05 01:30 UTC = 2024-07-04 21:30 EDT — the owner's day is
/// July 4), one per source convention. Server zone = display zone = NY.
fn ac3_fixtures() -> (ImageExif, ImageExif, ImageExif, i64) {
let instant = utc_epoch(2024, 7, 5, 1, 30);
let exif = row(
"photos/IMG_1234.jpg",
Some(utc_epoch(2024, 7, 4, 21, 30)), // wall 21:30 as naive-as-UTC
Some("exif"),
None,
None,
);
let fs = row(
"photos/scan.pdf.jpg",
Some(instant),
Some("fs_time"),
None,
None,
);
// Container: exiftool rendered the UTC naive "2024-07-05 01:30" in
// the server zone (EDT) → instant + 4h.
let container = row(
"videos/clip.mp4",
Some(instant + 4 * 3600),
Some("exiftool"),
None,
None,
);
(exif, fs, container, instant)
}
#[test]
fn ac3_same_instant_three_sources_group_into_one_owner_day() {
let (exif, fs, container, instant) = ac3_fixtures();
let d = ny();
for (name, r) in [
("exif", &exif),
("fs_time", &fs),
("exiftool-video", &container),
] {
assert_eq!(
row_true_instant(r, d, Some(d)),
Some(instant),
"{name}: wrong instant"
);
assert_eq!(
row_display_day(r, d, Some(d)).map(|dt| dt.to_string()),
Some("2024-07-04".to_string()),
"{name}: wrong owner day"
);
}
}
#[test]
fn ac3_old_client_offset_grouping_would_have_misplaced_the_container() {
// Regression guard for the double-shift: rendering the container's
// legacy value in the client (NY) offset lands on July 5, a day
// after the owner's.
let (_, _, container, _) = ac3_fixtures();
let legacy = container.date_taken.unwrap();
let old_grouping_day = chrono::FixedOffset::east_opt(-4 * 3600)
.unwrap()
.timestamp_opt(legacy, 0)
.single()
.unwrap()
.date_naive();
assert_eq!(old_grouping_day.to_string(), "2024-07-05");
}
#[test]
fn converted_row_with_offset_shows_camera_wall() {
// Japan (UTC+9) photo: instant 2024-07-05 12:00 UTC, wall 21:00 JST.
let r = row(
"japan/IMG_0001.jpg",
Some(utc_epoch(2024, 7, 5, 21, 0)),
Some("exif"),
Some(utc_epoch(2024, 7, 5, 12, 0)),
Some(9 * 60),
);
let d = ny();
assert_eq!(
row_display_wall(&r, d, Some(d)).map(|w| w.to_string()),
Some("2024-07-05 21:00:00".to_string())
);
// ...but groups into the owner's (NY) day: 12:00 UTC = 08:00 EDT.
assert_eq!(
row_display_day(&r, d, Some(d)).map(|dt| dt.to_string()),
Some("2024-07-05".to_string())
);
assert_eq!(
row_true_instant(&r, d, Some(d)),
Some(utc_epoch(2024, 7, 5, 12, 0))
);
}
#[test]
fn converted_row_without_offset_renders_in_display_zone() {
// fs_time row, no camera offset: wall = display-zone rendering.
// 2024-01-15 04:30 UTC = 23:30 Jan 14 EST (DST-aware).
let r = row(
"fs/IMG_9.jpg",
Some(utc_epoch(2024, 1, 15, 4, 30)),
Some("fs_time"),
Some(utc_epoch(2024, 1, 15, 4, 30)),
None,
);
let d = ny();
assert_eq!(
row_display_wall(&r, d, Some(d)).map(|w| w.to_string()),
Some("2024-01-14 23:30:00".to_string())
);
assert_eq!(
row_display_day(&r, d, Some(d)).map(|dt| dt.to_string()),
Some("2024-01-14".to_string())
);
}
#[test]
fn unconverted_exif_wall_is_the_legacy_naive_and_instant_uses_server_zone() {
// Wall 2024-01-15 21:30 (EST era), legacy = 21:30 naive-as-UTC.
// Instant = 21:30 EST = 2024-01-16 02:30 UTC (DST-aware, not +4h).
let r = row(
"photo.jpg",
Some(utc_epoch(2024, 1, 15, 21, 30)),
Some("exif"),
None,
None,
);
let d = ny();
assert_eq!(
row_display_wall(&r, d, Some(d)).map(|w| w.to_string()),
Some("2024-01-15 21:30:00".to_string())
);
assert_eq!(
row_true_instant(&r, d, Some(d)),
Some(utc_epoch(2024, 1, 16, 2, 30))
);
assert_eq!(
row_display_day(&r, d, Some(d)).map(|dt| dt.to_string()),
Some("2024-01-15".to_string())
);
}
#[test]
fn unconverted_exiftool_photo_wall_is_server_zone_render() {
// Camera wall 2024-07-04 12:00 rendered in server zone EDT →
// legacy = 16:00 UTC. The wall comes back out the other side.
let r = row(
"raw/IMG_5.NEF",
Some(utc_epoch(2024, 7, 4, 16, 0)),
Some("exiftool"),
None,
None,
);
let d = ny();
assert_eq!(
row_display_wall(&r, d, Some(d)).map(|w| w.to_string()),
Some("2024-07-04 12:00:00".to_string())
);
// Instant = that wall in the server zone = the legacy value itself.
assert_eq!(
row_true_instant(&r, d, Some(d)),
Some(utc_epoch(2024, 7, 4, 16, 0))
);
}
#[test]
fn unconverted_manual_row_interprets_wall_in_display_zone() {
// Operator picked 2024-07-04 21:30 in their display zone (NY/EDT):
// legacy = 21:30 naive-as-UTC, instant = 01:30 UTC July 5.
let r = row(
"manual.jpg",
Some(utc_epoch(2024, 7, 4, 21, 30)),
Some("manual"),
None,
None,
);
let d = ny();
assert_eq!(
row_display_wall(&r, d, Some(d)).map(|w| w.to_string()),
Some("2024-07-04 21:30:00".to_string())
);
assert_eq!(
row_true_instant(&r, d, Some(d)),
Some(utc_epoch(2024, 7, 5, 1, 30))
);
assert_eq!(
row_display_day(&r, d, Some(d)).map(|dt| dt.to_string()),
Some("2024-07-04".to_string())
);
}
#[test]
fn unconverted_filename_wall_and_utc_patterns_diverge() {
// Wall pattern (screenshot): legacy = wall as naive-as-UTC.
let wall = row(
"Screenshot_2024-07-04-21-30-00.png",
Some(utc_epoch(2024, 7, 4, 21, 30)),
Some("filename"),
None,
None,
);
// Unix-timestamp pattern: legacy is the true instant itself.
let ts = row(
"1720128600.jpg",
Some(utc_epoch(2024, 7, 5, 1, 30)),
Some("filename"),
None,
None,
);
let d = ny();
assert_eq!(
row_true_instant(&wall, d, Some(d)),
Some(utc_epoch(2024, 7, 5, 1, 30)),
"wall 21:30 EDT → 01:30 UTC"
);
assert_eq!(
row_true_instant(&ts, d, Some(d)),
Some(utc_epoch(2024, 7, 5, 1, 30)),
"timestamp pattern is already the instant"
);
// Both land on the owner's July 4.
for r in [&wall, &ts] {
assert_eq!(
row_display_day(r, d, Some(d)).map(|dt| dt.to_string()),
Some("2024-07-04".to_string())
);
}
}
#[test]
fn display_day_is_dst_correct_across_the_year() {
// Same UTC instant, winter vs summer: the owner's day differs by
// the DST shift. 04:30 UTC = 23:30 previous day EST, 00:30 same
// day EDT.
let winter = row(
"w.jpg",
Some(utc_epoch(2024, 1, 15, 4, 30)),
Some("fs_time"),
Some(utc_epoch(2024, 1, 15, 4, 30)),
None,
);
let summer = row(
"s.jpg",
Some(utc_epoch(2024, 7, 15, 4, 30)),
Some("fs_time"),
Some(utc_epoch(2024, 7, 15, 4, 30)),
None,
);
let d = ny();
assert_eq!(
row_display_day(&winter, d, Some(d)).map(|dt| dt.to_string()),
Some("2024-01-14".to_string())
);
assert_eq!(
row_display_day(&summer, d, Some(d)).map(|dt| dt.to_string()),
Some("2024-07-15".to_string())
);
}
#[test]
fn display_day_respects_a_non_new_york_display_zone() {
// Owner in Tokyo: 2024-07-05 01:30 UTC = 10:30 JST July 5 (not
// the July 4 a NY owner sees).
let (_, fs, _, instant) = ac3_fixtures();
let tokyo = Tz::Asia__Tokyo;
assert_eq!(
row_display_day(&fs, tokyo, Some(ny())).map(|dt| dt.to_string()),
Some("2024-07-05".to_string())
);
assert_eq!(row_true_instant(&fs, tokyo, Some(ny())), Some(instant));
}
#[test]
fn wall_clock_is_the_camera_wall_for_a_travel_zone_photo() {
// 14:00 in Tokyo (+09:00) = 05:00Z. The owner is in New York, but
// the wall clock is the camera's: 14:00, not 01:00.
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!(
row_wall_clock(&r, ny(), Some(ny())),
Some("2018-09-29T14:00:00".to_string())
);
}
#[test]
fn wall_clock_without_offset_renders_the_instant_in_the_display_zone() {
// Container date (utc_direct): 12:30Z is 08:30 in New York (EDT).
let r = row(
"summit.mp4",
None,
Some("exiftool"),
Some(utc_epoch(2022, 8, 20, 12, 30)),
None,
);
assert_eq!(
row_wall_clock(&r, ny(), Some(ny())),
Some("2022-08-20T08:30:00".to_string())
);
}
#[test]
fn wall_clock_serves_unconverted_rows_by_their_legacy_convention() {
// The drain's permanent floor never gets date_utc; an `exif` legacy
// value is the wall stored as UTC and must come back unshifted.
let r = row(
"edited.jpg",
Some(utc_epoch(2021, 5, 12, 10, 30)),
Some("exif"),
None,
None,
);
assert_eq!(
row_wall_clock(&r, ny(), Some(ny())),
Some("2021-05-12T10:30:00".to_string())
);
}
#[test]
fn file_wall_clock_prefers_the_row() {
let r = row(
"Screenshots/Screenshot_20220315-101500.png",
Some(utc_epoch(2022, 3, 15, 10, 15)),
Some("filename"),
Some(utc_epoch(2022, 3, 15, 14, 15)),
Some(-240),
);
assert_eq!(
file_wall_clock(Some(&r), &r.file_path, Some(0), ny(), Some(ny())),
Some("2022-03-15T10:15:00".to_string())
);
}
#[test]
fn file_wall_clock_without_a_row_uses_the_filename_wall() {
assert_eq!(
file_wall_clock(
None,
"Screenshots/Screenshot_20220315-101500.png",
Some(0),
ny(),
Some(ny())
),
Some("2022-03-15T10:15:00".to_string())
);
}
#[test]
fn file_wall_clock_without_a_row_or_filename_date_uses_fs_time_in_display_zone() {
// 2015-03-01 17:00Z = 12:00 in New York (EST).
assert_eq!(
file_wall_clock(
None,
"Undated/scan_01.jpg",
Some(utc_epoch(2015, 3, 1, 17, 0)),
ny(),
Some(ny())
),
Some("2015-03-01T12:00:00".to_string())
);
assert_eq!(
file_wall_clock(None, "Undated/scan_01.jpg", None, ny(), Some(ny())),
None
);
}
}