test_timelapse_match.py 7.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177
  1. """Unit tests for the timelapse-by-timestamp matcher used by /archives/scan.
  2. Regression coverage for #1278: when the printer cannot reach NTP (LAN-Only mode),
  3. its clock is offset from the server's, and an older video's filename can land just
  4. before a later print's completion. The previous matcher:
  5. 1. Treated the filename as either start- or end-time evidence — semantically wrong
  6. for a filename that's always print-start.
  7. 2. Probed a dense set of timezone offsets, so an unrelated video could
  8. coincidentally land within minutes of a later print at *some* offset.
  9. The new matcher matches only against start time and refuses to auto-pick when the
  10. top two candidates (from different videos) are within an ambiguity margin —
  11. forcing the manual-selection fallback the reporter explicitly asked for.
  12. """
  13. from __future__ import annotations
  14. from datetime import datetime, timedelta
  15. import pytest
  16. from backend.app.api.routes.archives import _match_timelapse_by_timestamp
  17. def _video(name: str, mtime: datetime | None = None) -> dict:
  18. return {
  19. "name": name,
  20. "path": f"/timelapse/{name}",
  21. "is_directory": False,
  22. "size": 1024,
  23. "mtime": mtime,
  24. }
  25. class TestMatchTimelapseByTimestamp:
  26. """Cover the bug from issue #1278 plus baseline cases."""
  27. def test_issue_1278_archive2_refuses_to_auto_pick_ambiguous(self):
  28. """Archive 2 (start 16:39:09) used to wrongly attach the older 09-41-29 video.
  29. The wrong video matches at offset -7 (diff 2m20s), the correct video at
  30. offset +8 (diff 3m33s). The two are within ~1 minute of each other —
  31. too close to call. Matcher must return None so the route surfaces the
  32. manual-selection list to the user.
  33. """
  34. videos = [
  35. _video("video_2026-05-08_09-41-29.mp4"), # belongs to Archive 1
  36. _video("video_2026-05-09_00-42-42.mp4"), # belongs to Archive 2 — correct
  37. ]
  38. archive_start = datetime(2026, 5, 8, 16, 39, 9)
  39. match, diff = _match_timelapse_by_timestamp(videos, archive_start)
  40. assert match is None
  41. assert diff is None
  42. def test_issue_1278_archive1_still_matches_unambiguously(self):
  43. """Archive 1 (start 01:27:14) — only one candidate within tolerance,
  44. so the matcher should still pick it cleanly."""
  45. videos = [
  46. _video("video_2026-05-08_09-41-29.mp4"), # correct
  47. _video("video_2026-05-09_00-42-42.mp4"), # 15h+ away at any common offset
  48. ]
  49. archive_start = datetime(2026, 5, 8, 1, 27, 14)
  50. match, diff = _match_timelapse_by_timestamp(videos, archive_start)
  51. assert match is not None
  52. assert match["name"] == "video_2026-05-08_09-41-29.mp4"
  53. assert diff is not None
  54. assert diff < timedelta(minutes=20)
  55. def test_archive2_resolves_when_stale_video_removed(self):
  56. """If the user has cleaned up the stale Archive-1 video, Archive 2's correct
  57. video is the only candidate and auto-match should succeed."""
  58. videos = [_video("video_2026-05-09_00-42-42.mp4")]
  59. archive_start = datetime(2026, 5, 8, 16, 39, 9)
  60. match, diff = _match_timelapse_by_timestamp(videos, archive_start)
  61. assert match is not None
  62. assert match["name"] == "video_2026-05-09_00-42-42.mp4"
  63. assert diff is not None
  64. assert diff < timedelta(minutes=5)
  65. def test_no_match_when_outside_tolerance(self):
  66. """All candidates outside the 4h tolerance → no match."""
  67. videos = [_video("video_2026-05-08_09-41-29.mp4")]
  68. # A week later, far beyond any offset's reach
  69. archive_start = datetime(2026, 5, 15, 12, 0, 0)
  70. match, diff = _match_timelapse_by_timestamp(videos, archive_start)
  71. assert match is None
  72. assert diff is None
  73. def test_returns_none_when_started_at_missing(self):
  74. """No archive start time = no signal; should return None."""
  75. videos = [_video("video_2026-05-08_09-41-29.mp4")]
  76. match, diff = _match_timelapse_by_timestamp(videos, None)
  77. assert match is None
  78. assert diff is None
  79. def test_zero_offset_when_clocks_agree(self):
  80. """When printer and server clocks agree, offset=0 should pick the video cleanly."""
  81. videos = [_video("video_2026-05-08_16-40-00.mp4")]
  82. archive_start = datetime(2026, 5, 8, 16, 39, 0)
  83. match, diff = _match_timelapse_by_timestamp(videos, archive_start)
  84. assert match is not None
  85. assert match["name"] == "video_2026-05-08_16-40-00.mp4"
  86. assert diff == timedelta(minutes=1)
  87. def test_skips_videos_without_timestamp_in_name(self):
  88. """Non-standard names (e.g., manually uploaded) should be skipped, not crash."""
  89. videos = [
  90. _video("my_custom_video.mp4"),
  91. _video("video_2026-05-08_16-40-00.mp4"),
  92. ]
  93. archive_start = datetime(2026, 5, 8, 16, 39, 0)
  94. match, _diff = _match_timelapse_by_timestamp(videos, archive_start)
  95. assert match is not None
  96. assert match["name"] == "video_2026-05-08_16-40-00.mp4"
  97. def test_empty_video_list_returns_none(self):
  98. match, diff = _match_timelapse_by_timestamp([], datetime(2026, 5, 8, 0, 0, 0))
  99. assert match is None
  100. assert diff is None
  101. @pytest.mark.parametrize("offset_hours", [0, 1, -1, 7, -7, 8, -8])
  102. def test_supports_common_timezone_offsets_with_single_candidate(self, offset_hours: int):
  103. """Each offset in the search list must be able to produce a match when
  104. only one video exists (so ambiguity check is vacuous)."""
  105. archive_start = datetime(2026, 5, 8, 12, 0, 0)
  106. # Printer's filename reflects archive_start in printer-local time
  107. printer_time = archive_start + timedelta(hours=offset_hours)
  108. videos = [_video(printer_time.strftime("video_%Y-%m-%d_%H-%M-%S.mp4"))]
  109. match, diff = _match_timelapse_by_timestamp(videos, archive_start)
  110. assert match is not None
  111. assert diff == timedelta(0)
  112. def test_returns_match_when_runner_up_is_same_video_different_offset(self):
  113. """A single video matching at two offsets is not ambiguous — pick it."""
  114. videos = [_video("video_2026-05-08_09-41-29.mp4")]
  115. # +7h adjusted = 02:41:29; +8h adjusted = 01:41:29. Both within 4h of 01:27:14.
  116. archive_start = datetime(2026, 5, 8, 1, 27, 14)
  117. match, diff = _match_timelapse_by_timestamp(videos, archive_start)
  118. assert match is not None
  119. assert match["name"] == "video_2026-05-08_09-41-29.mp4"
  120. # Best is offset +8 → diff 14m15s
  121. assert diff is not None
  122. assert diff < timedelta(minutes=20)
  123. def test_unambiguous_when_runner_up_is_well_separated(self):
  124. """If the next-best different video is comfortably outside the ambiguity
  125. margin, auto-pick the winner."""
  126. videos = [
  127. _video("video_2026-05-08_09-41-29.mp4"), # +8h → 01:41:29, diff 14m15s
  128. _video("video_2026-05-08_12-00-00.mp4"), # +8h → 04:00:00, diff 2h32m
  129. ]
  130. archive_start = datetime(2026, 5, 8, 1, 27, 14)
  131. match, diff = _match_timelapse_by_timestamp(videos, archive_start)
  132. assert match is not None
  133. assert match["name"] == "video_2026-05-08_09-41-29.mp4"
  134. assert diff is not None