Files
bambuddy/backend/tests/unit/test_timelapse_match.py
maziggy 4ee4bdb0d3 fix(archives): scan_timelapse picked stale video at false offset (#1278)
scan_timelapse's Strategy 2 matched filename timestamps against both
  archive.started_at and archive.completed_at across seven hypothesised tz
  offsets. The filename is always print-START time, so the end-time branch
  was a semantic mistake — and the dense offset set [0, +-1, +-7, +-8]
  let an unrelated video coincidentally land within minutes of any later
  archive at some offset.

  Extract Strategy 2 into _match_timelapse_by_timestamp(): compare only
  against start time, and refuse to auto-pick when the next-best different
  video is within a 15-minute ambiguity margin. The route then returns
  available_files and the frontend's existing manual-selection dialog
  takes over — which is the fallback the reporter explicitly asked for.

  Surfaces in LAN-Only mode where the printer can't reach NTP and its
  clock drifts (e.g. P2S filenames in CST while server is in UTC, the
  8h offset that exposed this bug).
2026-05-12 09:20:53 +02:00

178 lines
7.2 KiB
Python

"""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