Files
saphid--frame-control/tests/test_autosync.py
T

517 lines
24 KiB
Python

"""Copying new screenshots and clips to the computer on their own (ui/frame_autosync.py).
The headset is a fake: lists of shots and clips, and save/export functions that
record what they were asked to copy. No SSH, no headset.
"""
import sandbox # noqa: F401 (first: keeps tests off real data and services)
import errno
import json
import shutil
import subprocess
import sys
import tempfile
import threading
import unittest
from pathlib import Path
from unittest import mock
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "ui"))
import frame_autosync as fa # noqa: E402
NOW = 1_800_000_000.0
GB = 1024**3
def shot(n, t, saved=False):
return {"id": f"1/620/20261020{n:06d}_1.jpg", "time": t, "saved": saved}
def clip(n, t, idle=600, kind="clip", saved=False):
return {"id": f"1/clips/clip_620_{n}/video/bg", "kind": kind, "time": t, "idle": idle, "saved": saved}
class FakeFrame:
def __init__(self):
self.shots, self.clips = [], []
self.saved, self.exported, self.calls = [], [], []
self.device, self.busy, self.down = "frame-a", False, False
self.save_error = self.export_error = None
def list_shots(self, device):
assert device == self.device
self.calls.append("list_shots")
if self.down:
raise RuntimeError("ssh: connect to host frame port 22: Connection timed out")
return {"shots": list(self.shots)}
def list_clips(self, device):
assert device == self.device
self.calls.append("list_clips")
if self.down:
raise RuntimeError("ssh: connect to host frame port 22: Connection timed out")
return {"clips": list(self.clips)}
def save_shots(self, device, ids):
self.calls.append("save_shots")
if self.save_error:
raise self.save_error
self.saved.append((device, list(ids)))
for s in self.shots:
if s["id"] in ids:
s["saved"] = True
def export_clip(self, device, clip_id):
self.calls.append("export_clip")
if self.export_error:
raise self.export_error
self.exported.append((device, clip_id))
for c in self.clips:
if c["id"] == clip_id:
c["saved"] = True
class AutoSyncTest(unittest.TestCase):
def setUp(self):
self.dir = Path(tempfile.mkdtemp())
self.addCleanup(shutil.rmtree, self.dir, ignore_errors=True)
self.frame = FakeFrame()
self.now = [NOW]
self.free = [100 * GB]
self.sync = self.make()
def make(self):
f = self.frame
return fa.AutoSync(f.list_shots, f.save_shots, f.list_clips, f.export_clip,
lambda: f.device, lambda: f.busy,
offline=lambda e: "The Frame isn't answering." if "timed out" in str(e) else None,
shots_folder=self.dir / "Pictures", clips_folder=self.dir / "Videos",
path=self.dir / "auto-sync.json", clock=lambda: self.now[0],
space=lambda folder: self.free[0])
def test_off_by_default_and_does_nothing(self):
self.frame.shots = [shot(1, NOW + 10)]
self.frame.clips = [clip(1, NOW + 10)]
s = self.sync.state()
self.assertFalse(s["screenshots"] or s["clips"])
self.assertEqual(self.sync.tick(), fa.INTERVAL)
self.assertEqual(self.frame.calls, []) # not even a listing
self.assertFalse((self.dir / "auto-sync.json").exists())
def test_settings_persist(self):
self.sync.update({"screenshots": True})
again = self.make().state()
self.assertTrue(again["screenshots"])
self.assertFalse(again["clips"])
self.assertEqual(again["since"], NOW)
saved = json.loads((self.dir / "auto-sync.json").read_text())
self.assertEqual((saved["screenshots"], saved["clips"]), (True, False))
def test_only_what_is_newer_than_turning_it_on(self):
self.frame.shots = [shot(1, NOW - 3600), shot(2, NOW + 5), shot(3, NOW + 50, saved=True)]
self.frame.clips = [clip(1, NOW - 3600), clip(2, NOW + 5)]
self.sync.update({"screenshots": True, "clips": True})
self.assertEqual(self.sync.tick(), fa.INTERVAL)
self.assertEqual(self.frame.saved, [("frame-a", [shot(2, 0)["id"]])])
self.assertEqual(self.frame.exported, [("frame-a", clip(2, 0)["id"])])
s = self.sync.state()
self.assertEqual((s["copied"], s["copied_time"], s["checked"], s["error"]), (2, NOW, NOW, None))
def test_watermark_moves_when_turned_off_and_on_again(self):
self.sync.update({"screenshots": True})
self.now[0] += 1000
self.sync.update({"clips": True}) # already on: the watermark stays
self.assertEqual(self.sync.state()["since"], NOW)
self.sync.update({"screenshots": False, "clips": False})
self.sync.update({"screenshots": True})
self.assertEqual(self.sync.state()["since"], NOW + 1000)
def test_copy_everything_now_once(self):
self.frame.shots = [shot(1, NOW - 3600), shot(2, NOW - 60)]
self.frame.clips = [clip(1, NOW - 3600), clip(2, NOW - 60, kind="background")]
self.sync.copy_all() # works with the toggles off too
self.assertTrue(self.sync.state()["pending"])
self.sync.tick()
self.assertEqual(self.frame.saved, [("frame-a", [shot(1, 0)["id"], shot(2, 0)["id"]])])
self.assertEqual(self.frame.exported, [("frame-a", clip(1, 0)["id"])]) # never the background recording
self.assertFalse(self.sync.state()["pending"])
self.frame.calls.clear()
self.sync.tick()
self.assertEqual(self.frame.calls, []) # off again afterwards
def test_not_copied_again_after_deleting_it_here(self):
self.sync.update({"screenshots": True, "clips": True})
self.frame.shots = [shot(1, NOW + 5)]
self.frame.clips = [clip(1, NOW + 5)]
self.sync.tick()
for s in self.frame.shots + self.frame.clips:
s["saved"] = False # the person deleted the files
self.frame.saved.clear()
self.frame.exported.clear()
self.make().tick() # remembered across restarts too
self.assertEqual((self.frame.saved, self.frame.exported), ([], []))
def test_saved_by_hand_counts_as_copied(self):
self.sync.update({"screenshots": True})
self.frame.shots = [shot(1, NOW + 5, saved=True)]
self.sync.tick()
self.frame.shots[0]["saved"] = False
self.sync.tick()
self.assertEqual(self.frame.saved, [])
def test_remembered_per_headset(self):
self.sync.update({"screenshots": True})
self.frame.shots = [shot(1, NOW + 5)]
self.sync.tick()
self.frame.device = "frame-b"
self.frame.shots = [shot(1, NOW + 5)] # the same name on another headset
self.sync.tick()
self.assertEqual([d for d, _ in self.frame.saved], ["frame-a", "frame-b"])
def test_clip_still_being_recorded_waits(self):
self.sync.update({"clips": True})
self.frame.clips = [clip(1, NOW + 5, idle=4), clip(2, NOW + 6, idle=None)]
self.sync.tick()
self.assertEqual(self.frame.exported, [])
self.assertEqual(self.sync.state()["waiting"], 2)
self.frame.clips[0]["idle"] = fa.SETTLE + 1
self.sync.tick()
self.assertEqual(self.frame.exported, [("frame-a", clip(1, 0)["id"])])
self.assertEqual(self.sync.state()["waiting"], 1)
def test_no_headset_no_work(self):
self.sync.update({"screenshots": True, "clips": True})
self.frame.device = None
self.assertEqual(self.sync.tick(), fa.INTERVAL)
self.assertEqual(self.frame.calls, [])
def test_waits_for_other_work(self):
self.sync.update({"screenshots": True, "clips": True})
self.frame.shots = [shot(1, NOW + 5)]
self.frame.busy = True
self.assertEqual(self.sync.tick(), fa.BUSY_WAIT)
self.assertEqual(self.frame.calls, [])
self.frame.busy = False
self.sync.tick()
self.assertEqual(len(self.frame.saved), 1)
def test_stops_between_items_when_other_work_starts(self):
self.sync.update({"clips": True})
self.frame.clips = [clip(1, NOW + 5), clip(2, NOW + 6)]
export = self.frame.export_clip
def then_busy(device, clip_id):
export(device, clip_id)
self.frame.busy = True # an install started meanwhile
self.frame.export_clip = then_busy
sync = self.make()
self.assertEqual(sync.tick(), fa.BUSY_WAIT)
self.assertEqual(len(self.frame.exported), 1)
def test_backs_off_while_unreachable(self):
self.sync.update({"screenshots": True})
self.frame.down = True
waits = [self.sync.tick() for _ in range(6)]
self.assertEqual(waits[:4], [120, 240, 480, 900])
self.assertEqual(waits[-1], fa.MAX_BACKOFF)
self.assertEqual(self.sync.state()["error"], "The Frame isn't answering.")
self.frame.down = False
self.assertEqual(self.sync.tick(), fa.INTERVAL)
self.assertIsNone(self.sync.state()["error"])
self.frame.down = True
self.assertEqual(self.sync.tick(), 120) # starts over after a good look
def test_pauses_when_this_computer_is_nearly_full(self):
self.sync.update({"screenshots": True})
self.frame.shots = [shot(1, NOW + 5)]
self.free[0] = 10 * 1024**2
self.sync.tick()
s = self.sync.state()
self.assertIn("out of space", s["paused"])
self.assertEqual(self.frame.saved, [])
self.free[0] = 100 * GB
self.frame.calls.clear()
self.sync.tick()
self.assertEqual(self.frame.calls, []) # stays paused, also after a restart
self.assertTrue(self.make().state()["paused"])
self.sync.resume()
self.sync.tick()
self.assertEqual(len(self.frame.saved), 1)
self.assertIsNone(self.sync.state()["paused"])
def test_pauses_when_a_copy_runs_out_of_space(self):
self.sync.update({"screenshots": True, "clips": True})
self.frame.clips = [clip(1, NOW + 5)]
self.frame.export_error = RuntimeError("Not enough free space on this computer: it needs 900 MB")
self.sync.tick()
self.assertIn("ran out of space", self.sync.state()["paused"])
self.sync.resume()
self.frame.export_error = None
self.frame.shots = [shot(1, NOW + 5)]
self.frame.save_error = OSError(errno.ENOSPC, "No space left on device")
self.sync.tick()
self.assertTrue(self.sync.state()["paused"])
def test_space_on_the_frame_is_not_a_pause(self):
self.sync.update({"clips": True})
self.frame.clips = [clip(1, NOW + 5)]
self.frame.export_error = RuntimeError("Not enough free space on the Frame to export it: it needs 900 MB")
self.sync.tick()
s = self.sync.state()
self.assertIsNone(s["paused"])
self.assertIn("Not enough free space on the Frame", s["error"])
def test_an_item_that_keeps_failing_is_left_alone(self):
self.sync.update({"clips": True})
self.frame.clips = [clip(1, NOW + 5)]
self.frame.export_error = RuntimeError("ffmpeg couldn't join the recording")
for _ in range(fa.TRIES + 2):
self.sync.tick()
self.assertEqual(self.frame.calls.count("export_clip"), fa.TRIES)
self.assertIn("ffmpeg couldn't join", self.sync.state()["error"] or "")
self.assertIn("Resume", self.sync.state()["error"] or "")
def test_explicit_retry_recovers_without_recopying_successful_items(self):
for kind in ("shots", "clips"):
for action in ("resume", "copy_all"):
with self.subTest(kind=kind, action=action):
self.frame = FakeFrame()
f = self.frame
sync = self.make()
sync.update({"screenshots": True, "clips": True})
f.shots, f.clips = [shot(1, NOW + 5)], [clip(1, NOW + 5)]
error_attr = "save_error" if kind == "shots" else "export_error"
copy_call = "save_shots" if kind == "shots" else "export_clip"
setattr(f, error_attr, RuntimeError("source not ready"))
for _ in range(fa.TRIES + 2):
sync.tick()
self.assertEqual(f.calls.count(copy_call), fa.TRIES)
self.assertIn("source not ready", sync.state()["error"] or "")
setattr(f, error_attr, None)
sync.tick() # recovery alone must not create unlimited automatic retries
self.assertEqual(f.calls.count(copy_call), fa.TRIES)
getattr(sync, action)()
sync.tick()
self.assertEqual(f.saved, [("frame-a", [shot(1, 0)["id"]])])
self.assertEqual(f.exported, [("frame-a", clip(1, 0)["id"])])
self.assertIsNone(sync.state()["error"])
# Resume preserves the per-headset ledger even if a local file was deleted.
for item in f.shots + f.clips:
item["saved"] = False
sync.resume()
sync.tick()
self.assertEqual((len(f.saved), len(f.exported)), (1, 1))
self.sync.path.unlink(missing_ok=True)
def test_explicit_retry_is_still_bounded(self):
self.sync.update({"clips": True})
self.frame.clips = [clip(1, NOW + 5)]
self.frame.export_error = RuntimeError("bad source")
for action in ("resume", "copy_all"):
for _ in range(fa.TRIES + 2):
self.sync.tick()
before = self.frame.calls.count("export_clip")
getattr(self.sync, action)()
for _ in range(fa.TRIES + 2):
self.sync.tick()
self.assertEqual(self.frame.calls.count("export_clip") - before, fa.TRIES)
self.assertIn("bad source", self.sync.state()["error"] or "")
def test_failed_item_saved_by_hand_clears_error(self):
self.sync.update({"clips": True})
self.frame.clips = [clip(1, NOW + 5)]
self.frame.export_error = RuntimeError("bad source")
for _ in range(fa.TRIES + 1):
self.sync.tick()
self.frame.clips[0]["saved"] = True
self.sync.tick()
self.assertIsNone(self.sync.state()["error"])
self.assertIn(clip(1, 0)["id"], self.sync._synced("frame-a", "clips"))
def test_switch_after_capture_does_not_list_the_new_headset(self):
self.sync.update({"screenshots": True, "clips": True})
def switched():
self.frame.device = "frame-b"
return False
self.sync.busy = switched # tick has already captured frame-a
self.assertEqual(self.sync.tick(), fa.BUSY_WAIT)
self.assertEqual(self.frame.calls, [])
def test_list_callbacks_receive_the_captured_device(self):
self.sync.update({"screenshots": True, "clips": True})
self.sync.list_shots = mock.Mock(return_value={"shots": []})
self.sync.list_clips = mock.Mock(return_value={"clips": []})
self.sync.tick()
self.sync.list_shots.assert_called_once_with("frame-a")
self.sync.list_clips.assert_called_once_with("frame-a")
def test_switch_during_listing_does_not_mark_or_copy_its_results(self):
for kind, item in (("shots", shot(1, NOW + 5)), ("clips", clip(1, NOW + 5))):
with self.subTest(kind=kind):
self.frame.device = "frame-a"
self.sync.update({"screenshots": kind == "shots", "clips": kind == "clips"})
def listing(*_args):
self.frame.device = "frame-b"
return {kind: [dict(item, saved=True), dict(item, id="uncopied", saved=False)]}
setattr(self.sync, "list_" + kind, listing)
self.assertEqual(self.sync.tick(), fa.BUSY_WAIT)
self.assertEqual(self.sync._synced("frame-a", kind), set())
self.assertEqual(self.sync._synced("frame-b", kind), set())
self.assertEqual((self.frame.saved, self.frame.exported), ([], []))
def test_switch_between_kinds_does_not_read_clips_or_mark_transfer(self):
self.sync.update({"screenshots": True, "clips": True})
self.frame.shots = [shot(1, NOW + 5)]
self.frame.clips = [clip(1, NOW + 5, saved=True)]
save = self.sync.save_shots
def then_switch(device, ids):
save(device, ids)
self.frame.device = "frame-b"
self.sync.save_shots = then_switch
self.assertEqual(self.sync.tick(), fa.BUSY_WAIT)
self.assertNotIn("list_clips", self.frame.calls)
self.assertEqual(self.sync._synced("frame-a", "clips"), set())
self.assertEqual(self.sync._synced("frame-a", "shots"), set())
def test_switch_after_bookkeeping_stops_before_next_kind(self):
self.sync.update({"screenshots": True, "clips": True})
self.frame.shots = [shot(1, NOW + 5, saved=True)]
mark = self.sync._mark
def then_switch(device, kind, ids):
mark(device, kind, ids)
self.frame.device = "frame-b"
self.sync._mark = then_switch
self.assertEqual(self.sync.tick(), fa.BUSY_WAIT)
self.assertNotIn("list_clips", self.frame.calls)
def test_switch_before_transfer_does_not_copy(self):
for kind, item in (("shots", shot(1, NOW + 5)), ("clips", clip(1, NOW + 5))):
with self.subTest(kind=kind):
self.frame.device = "frame-a"
self.sync.update({"screenshots": kind == "shots", "clips": kind == "clips"})
setattr(self.frame, kind, [item])
def switched_space(_folder):
self.frame.device = "frame-b"
return 100 * GB
self.sync.space = switched_space
self.assertEqual(self.sync.tick(), fa.BUSY_WAIT)
self.assertEqual((self.frame.saved, self.frame.exported), ([], []))
def test_worker_stops(self):
stop = threading.Event()
t = threading.Thread(target=self.sync.run, args=(stop,), daemon=True)
t.start()
stop.set()
self.sync.update({}) # wakes it
t.join(5)
self.assertFalse(t.is_alive())
class Server(unittest.TestCase):
def test_switch_at_list_callback_entry_does_not_read_or_mark_the_new_headset(self):
import server
with tempfile.TemporaryDirectory() as folder:
for kind in ("shots", "clips"):
with self.subTest(kind=kind):
active = {"id": "frame-a"}
link = mock.Mock()
link.active_device.side_effect = lambda: dict(active)
listing = mock.Mock(return_value={kind: [
shot(1, NOW + 5, saved=True) if kind == "shots" else clip(1, NOW + 5, saved=True)]})
def switch_then_list(device):
active["id"] = "frame-b"
return server._sync_list(listing)(device)
with mock.patch.object(server, "LINK", link), mock.patch.object(server, "_work", [0]):
sync = fa.AutoSync(switch_then_list, mock.Mock(), switch_then_list, mock.Mock(),
lambda: active["id"], lambda: False,
path=Path(folder) / (kind + ".json"), clock=lambda: NOW)
sync.update({"screenshots": kind == "shots", "clips": kind == "clips"})
sync.tick()
listing.assert_not_called()
sync.save_shots.assert_not_called()
sync.export_clip.assert_not_called()
self.assertEqual(sync._synced("frame-a", kind), set())
self.assertEqual(sync._synced("frame-b", kind), set())
self.assertIn("switched headsets", sync.state()["error"])
self.assertEqual(server._work[0], 0)
def test_listing_rejects_switch_before_reserving_work(self):
import server
link = mock.Mock()
link.active_device.return_value = {"id": "frame-b"}
listing = mock.Mock(return_value={"shots": [shot(1, NOW, saved=True)]})
with mock.patch.object(server, "LINK", link), mock.patch.object(server, "_work", [0]):
with self.assertRaisesRegex(server.Failure, "switched headsets"):
server._sync_list(listing)("frame-a")
listing.assert_not_called()
self.assertEqual(server._work[0], 0)
def test_listing_reserves_work_on_the_captured_device(self):
import server
link = mock.Mock()
link.active_device.return_value = {"id": "frame-a", "name": "Frame A"}
def listing():
self.assertGreater(server._work[0], 0)
with self.assertRaises(server.Failure):
server.devices_post({"action": "use", "id": "frame-b"})
return {"shots": []}
with mock.patch.object(server, "LINK", link), \
mock.patch.object(server, "PRIVATE", False), mock.patch.object(server, "_work", [0]):
self.assertEqual(server._sync_list(listing)("frame-a"), {"shots": []})
self.assertEqual(server._work[0], 0)
def test_copy_rejects_switch_after_listing(self):
import server
link = mock.Mock()
link.active_device.return_value = {"id": "frame-a"}
with mock.patch.object(server, "LINK", link), mock.patch.object(server, "_work", [0]), \
mock.patch.object(server, "save_shots") as save, \
mock.patch.object(server.frame_clips, "export_clip") as export:
server._sync_list(lambda: {"shots": []})("frame-a")
link.active_device.return_value = {"id": "frame-b"}
for copy in (lambda: server._sync_shots("frame-a", [shot(1, NOW)["id"]]),
lambda: server._sync_clip("frame-a", clip(1, NOW)["id"])):
with self.assertRaisesRegex(server.Failure, "switched headsets"):
copy()
save.assert_not_called()
export.assert_not_called()
self.assertEqual(server._work[0], 0)
def test_route_and_settings(self):
import server
self.assertIn("/api/autosync", server.POST)
with self.assertRaises(server.Failure):
server.auto_sync({"action": "explode"})
state = server.auto_sync({"action": "settings", "screenshots": "yes"}) # not a bool: ignored
self.assertFalse(state["screenshots"])
if server.LINK is None:
with self.assertRaises(server.Failure):
server.auto_sync({"action": "now"}) # nothing connected
@unittest.skipUnless(shutil.which("node"), "Node exercises the Settings renderer")
class Settings(unittest.TestCase):
def test_resume_is_available_after_a_copy_error(self):
page = (Path(__file__).resolve().parents[1] / "ui/index.html").read_text(encoding="utf-8")
script = page.split("const autoSync =", 1)[1].split("async function loadAutoSync", 1)[0]
harness = r'''
const assert = require('node:assert/strict');
const nodes = {};
const $ = id => nodes[id] ||= {};
const HOST = {mobile: false};
const esc = value => value;
'''
checks = r'''
for (const state of [{error: 'Could not copy clip'}, {paused: 'Disk full'}, {}]) {
renderAutoSync(state);
assert.equal($('syncResume').hidden, !(state.error || state.paused));
if (state.error) assert.match($('syncStatus').textContent, /Could not copy clip/);
}
'''
result = subprocess.run(["node", "-e", harness + "const autoSync =" + script + checks],
capture_output=True, text=True, timeout=10)
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
if __name__ == "__main__":
unittest.main()