|
@@ -0,0 +1,177 @@
|
|
|
|
|
+"""Unit tests for the timelapse-by-timestamp matcher used by /archives/scan.
|
|
|
|
|
+
|
|
|
|
|
+Regression coverage for #1278: when the printer cannot reach NTP (LAN-Only mode),
|
|
|
|
|
+its clock is offset from the server's, and an older video's filename can land just
|
|
|
|
|
+before a later print's completion. The previous matcher:
|
|
|
|
|
+
|
|
|
|
|
+1. Treated the filename as either start- or end-time evidence — semantically wrong
|
|
|
|
|
+ for a filename that's always print-start.
|
|
|
|
|
+2. Probed a dense set of timezone offsets, so an unrelated video could
|
|
|
|
|
+ coincidentally land within minutes of a later print at *some* offset.
|
|
|
|
|
+
|
|
|
|
|
+The new matcher matches only against start time and refuses to auto-pick when the
|
|
|
|
|
+top two candidates (from different videos) are within an ambiguity margin —
|
|
|
|
|
+forcing the manual-selection fallback the reporter explicitly asked for.
|
|
|
|
|
+"""
|
|
|
|
|
+
|
|
|
|
|
+from __future__ import annotations
|
|
|
|
|
+
|
|
|
|
|
+from datetime import datetime, timedelta
|
|
|
|
|
+
|
|
|
|
|
+import pytest
|
|
|
|
|
+
|
|
|
|
|
+from backend.app.api.routes.archives import _match_timelapse_by_timestamp
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def _video(name: str, mtime: datetime | None = None) -> dict:
|
|
|
|
|
+ return {
|
|
|
|
|
+ "name": name,
|
|
|
|
|
+ "path": f"/timelapse/{name}",
|
|
|
|
|
+ "is_directory": False,
|
|
|
|
|
+ "size": 1024,
|
|
|
|
|
+ "mtime": mtime,
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+class TestMatchTimelapseByTimestamp:
|
|
|
|
|
+ """Cover the bug from issue #1278 plus baseline cases."""
|
|
|
|
|
+
|
|
|
|
|
+ def test_issue_1278_archive2_refuses_to_auto_pick_ambiguous(self):
|
|
|
|
|
+ """Archive 2 (start 16:39:09) used to wrongly attach the older 09-41-29 video.
|
|
|
|
|
+
|
|
|
|
|
+ The wrong video matches at offset -7 (diff 2m20s), the correct video at
|
|
|
|
|
+ offset +8 (diff 3m33s). The two are within ~1 minute of each other —
|
|
|
|
|
+ too close to call. Matcher must return None so the route surfaces the
|
|
|
|
|
+ manual-selection list to the user.
|
|
|
|
|
+ """
|
|
|
|
|
+ videos = [
|
|
|
|
|
+ _video("video_2026-05-08_09-41-29.mp4"), # belongs to Archive 1
|
|
|
|
|
+ _video("video_2026-05-09_00-42-42.mp4"), # belongs to Archive 2 — correct
|
|
|
|
|
+ ]
|
|
|
|
|
+ archive_start = datetime(2026, 5, 8, 16, 39, 9)
|
|
|
|
|
+
|
|
|
|
|
+ match, diff = _match_timelapse_by_timestamp(videos, archive_start)
|
|
|
|
|
+
|
|
|
|
|
+ assert match is None
|
|
|
|
|
+ assert diff is None
|
|
|
|
|
+
|
|
|
|
|
+ def test_issue_1278_archive1_still_matches_unambiguously(self):
|
|
|
|
|
+ """Archive 1 (start 01:27:14) — only one candidate within tolerance,
|
|
|
|
|
+ so the matcher should still pick it cleanly."""
|
|
|
|
|
+ videos = [
|
|
|
|
|
+ _video("video_2026-05-08_09-41-29.mp4"), # correct
|
|
|
|
|
+ _video("video_2026-05-09_00-42-42.mp4"), # 15h+ away at any common offset
|
|
|
|
|
+ ]
|
|
|
|
|
+ archive_start = datetime(2026, 5, 8, 1, 27, 14)
|
|
|
|
|
+
|
|
|
|
|
+ match, diff = _match_timelapse_by_timestamp(videos, archive_start)
|
|
|
|
|
+
|
|
|
|
|
+ assert match is not None
|
|
|
|
|
+ assert match["name"] == "video_2026-05-08_09-41-29.mp4"
|
|
|
|
|
+ assert diff is not None
|
|
|
|
|
+ assert diff < timedelta(minutes=20)
|
|
|
|
|
+
|
|
|
|
|
+ def test_archive2_resolves_when_stale_video_removed(self):
|
|
|
|
|
+ """If the user has cleaned up the stale Archive-1 video, Archive 2's correct
|
|
|
|
|
+ video is the only candidate and auto-match should succeed."""
|
|
|
|
|
+ videos = [_video("video_2026-05-09_00-42-42.mp4")]
|
|
|
|
|
+ archive_start = datetime(2026, 5, 8, 16, 39, 9)
|
|
|
|
|
+
|
|
|
|
|
+ match, diff = _match_timelapse_by_timestamp(videos, archive_start)
|
|
|
|
|
+
|
|
|
|
|
+ assert match is not None
|
|
|
|
|
+ assert match["name"] == "video_2026-05-09_00-42-42.mp4"
|
|
|
|
|
+ assert diff is not None
|
|
|
|
|
+ assert diff < timedelta(minutes=5)
|
|
|
|
|
+
|
|
|
|
|
+ def test_no_match_when_outside_tolerance(self):
|
|
|
|
|
+ """All candidates outside the 4h tolerance → no match."""
|
|
|
|
|
+ videos = [_video("video_2026-05-08_09-41-29.mp4")]
|
|
|
|
|
+ # A week later, far beyond any offset's reach
|
|
|
|
|
+ archive_start = datetime(2026, 5, 15, 12, 0, 0)
|
|
|
|
|
+
|
|
|
|
|
+ match, diff = _match_timelapse_by_timestamp(videos, archive_start)
|
|
|
|
|
+
|
|
|
|
|
+ assert match is None
|
|
|
|
|
+ assert diff is None
|
|
|
|
|
+
|
|
|
|
|
+ def test_returns_none_when_started_at_missing(self):
|
|
|
|
|
+ """No archive start time = no signal; should return None."""
|
|
|
|
|
+ videos = [_video("video_2026-05-08_09-41-29.mp4")]
|
|
|
|
|
+
|
|
|
|
|
+ match, diff = _match_timelapse_by_timestamp(videos, None)
|
|
|
|
|
+
|
|
|
|
|
+ assert match is None
|
|
|
|
|
+ assert diff is None
|
|
|
|
|
+
|
|
|
|
|
+ def test_zero_offset_when_clocks_agree(self):
|
|
|
|
|
+ """When printer and server clocks agree, offset=0 should pick the video cleanly."""
|
|
|
|
|
+ videos = [_video("video_2026-05-08_16-40-00.mp4")]
|
|
|
|
|
+ archive_start = datetime(2026, 5, 8, 16, 39, 0)
|
|
|
|
|
+
|
|
|
|
|
+ match, diff = _match_timelapse_by_timestamp(videos, archive_start)
|
|
|
|
|
+
|
|
|
|
|
+ assert match is not None
|
|
|
|
|
+ assert match["name"] == "video_2026-05-08_16-40-00.mp4"
|
|
|
|
|
+ assert diff == timedelta(minutes=1)
|
|
|
|
|
+
|
|
|
|
|
+ def test_skips_videos_without_timestamp_in_name(self):
|
|
|
|
|
+ """Non-standard names (e.g., manually uploaded) should be skipped, not crash."""
|
|
|
|
|
+ videos = [
|
|
|
|
|
+ _video("my_custom_video.mp4"),
|
|
|
|
|
+ _video("video_2026-05-08_16-40-00.mp4"),
|
|
|
|
|
+ ]
|
|
|
|
|
+ archive_start = datetime(2026, 5, 8, 16, 39, 0)
|
|
|
|
|
+
|
|
|
|
|
+ match, _diff = _match_timelapse_by_timestamp(videos, archive_start)
|
|
|
|
|
+
|
|
|
|
|
+ assert match is not None
|
|
|
|
|
+ assert match["name"] == "video_2026-05-08_16-40-00.mp4"
|
|
|
|
|
+
|
|
|
|
|
+ def test_empty_video_list_returns_none(self):
|
|
|
|
|
+ match, diff = _match_timelapse_by_timestamp([], datetime(2026, 5, 8, 0, 0, 0))
|
|
|
|
|
+ assert match is None
|
|
|
|
|
+ assert diff is None
|
|
|
|
|
+
|
|
|
|
|
+ @pytest.mark.parametrize("offset_hours", [0, 1, -1, 7, -7, 8, -8])
|
|
|
|
|
+ def test_supports_common_timezone_offsets_with_single_candidate(self, offset_hours: int):
|
|
|
|
|
+ """Each offset in the search list must be able to produce a match when
|
|
|
|
|
+ only one video exists (so ambiguity check is vacuous)."""
|
|
|
|
|
+ archive_start = datetime(2026, 5, 8, 12, 0, 0)
|
|
|
|
|
+ # Printer's filename reflects archive_start in printer-local time
|
|
|
|
|
+ printer_time = archive_start + timedelta(hours=offset_hours)
|
|
|
|
|
+ videos = [_video(printer_time.strftime("video_%Y-%m-%d_%H-%M-%S.mp4"))]
|
|
|
|
|
+
|
|
|
|
|
+ match, diff = _match_timelapse_by_timestamp(videos, archive_start)
|
|
|
|
|
+
|
|
|
|
|
+ assert match is not None
|
|
|
|
|
+ assert diff == timedelta(0)
|
|
|
|
|
+
|
|
|
|
|
+ def test_returns_match_when_runner_up_is_same_video_different_offset(self):
|
|
|
|
|
+ """A single video matching at two offsets is not ambiguous — pick it."""
|
|
|
|
|
+ videos = [_video("video_2026-05-08_09-41-29.mp4")]
|
|
|
|
|
+ # +7h adjusted = 02:41:29; +8h adjusted = 01:41:29. Both within 4h of 01:27:14.
|
|
|
|
|
+ archive_start = datetime(2026, 5, 8, 1, 27, 14)
|
|
|
|
|
+
|
|
|
|
|
+ match, diff = _match_timelapse_by_timestamp(videos, archive_start)
|
|
|
|
|
+
|
|
|
|
|
+ assert match is not None
|
|
|
|
|
+ assert match["name"] == "video_2026-05-08_09-41-29.mp4"
|
|
|
|
|
+ # Best is offset +8 → diff 14m15s
|
|
|
|
|
+ assert diff is not None
|
|
|
|
|
+ assert diff < timedelta(minutes=20)
|
|
|
|
|
+
|
|
|
|
|
+ def test_unambiguous_when_runner_up_is_well_separated(self):
|
|
|
|
|
+ """If the next-best different video is comfortably outside the ambiguity
|
|
|
|
|
+ margin, auto-pick the winner."""
|
|
|
|
|
+ videos = [
|
|
|
|
|
+ _video("video_2026-05-08_09-41-29.mp4"), # +8h → 01:41:29, diff 14m15s
|
|
|
|
|
+ _video("video_2026-05-08_12-00-00.mp4"), # +8h → 04:00:00, diff 2h32m
|
|
|
|
|
+ ]
|
|
|
|
|
+ archive_start = datetime(2026, 5, 8, 1, 27, 14)
|
|
|
|
|
+
|
|
|
|
|
+ match, diff = _match_timelapse_by_timestamp(videos, archive_start)
|
|
|
|
|
+
|
|
|
|
|
+ assert match is not None
|
|
|
|
|
+ assert match["name"] == "video_2026-05-08_09-41-29.mp4"
|
|
|
|
|
+ assert diff is not None
|