mirror of
https://github.com/saphid/frame-control.git
synced 2026-10-25 17:01:38 +01:00
517 lines
24 KiB
Python
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()
|