test_recorder_contract.py 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538
  1. """Recorder decisions with fake processes/CDP and byte-token captures only."""
  2. import contextlib
  3. import importlib.util
  4. import io
  5. import json
  6. import subprocess
  7. import tempfile
  8. import unittest
  9. from pathlib import Path
  10. from types import SimpleNamespace
  11. from unittest.mock import patch
  12. import fixtures
  13. SCRIPT = Path(__file__).resolve().parents[2] / "skills/proving-it-works-with-a-movie/examples/film-terminal.py"
  14. def recorder():
  15. spec = importlib.util.spec_from_file_location("recorder_contract", SCRIPT)
  16. module = importlib.util.module_from_spec(spec)
  17. spec.loader.exec_module(module)
  18. return module
  19. class Clock:
  20. def __init__(self):
  21. self.now = 0.0
  22. def monotonic(self):
  23. return self.now
  24. def sleep(self, seconds):
  25. self.now += seconds
  26. class Process:
  27. def __init__(self, pid):
  28. self.pid = pid
  29. self.returncode = None
  30. def poll(self):
  31. return self.returncode
  32. def wait(self, timeout):
  33. if self.returncode is None:
  34. raise subprocess.TimeoutExpired("fake child", timeout)
  35. return self.returncode
  36. @contextlib.contextmanager
  37. def serving(failure=None, stop_at=None, relative=False):
  38. module, clock = recorder(), Clock()
  39. with tempfile.TemporaryDirectory() as temp, contextlib.ExitStack() as stack:
  40. root = Path(temp)
  41. directory = root / "session"
  42. args = SimpleNamespace(session=directory, cwd=root, shell="bash", shell_exe=None,
  43. ttyd="fake-ttyd", browser="fake-browser")
  44. if relative:
  45. import os
  46. args.session = Path(os.path.relpath(directory))
  47. handles, children, launches, tokens = [], [], [], []
  48. original_open, original_write = Path.open, module.write_json
  49. stopped = False
  50. def open_file(path, *positional, **kwargs):
  51. if positional == ("ab",):
  52. if failure == path.name:
  53. raise OSError("injected " + path.name)
  54. handle = original_open(path, *positional, **kwargs)
  55. handles.append(handle)
  56. return handle
  57. return original_open(path, *positional, **kwargs)
  58. def popen(argv, **kwargs):
  59. index = len(children)
  60. if failure == ("ttyd launch" if index == 0 else "browser launch"):
  61. raise OSError("injected launch")
  62. child = Process(1100 + index)
  63. children.append(child)
  64. launches.append((argv, kwargs))
  65. if index == 1:
  66. (directory / "profile").mkdir()
  67. return child
  68. def kill(pid):
  69. for child in children:
  70. if child.pid == pid:
  71. if child.poll() is not None:
  72. raise AssertionError("cannot use an exited leader's PID")
  73. if failure != "child wait":
  74. child.returncode = -9
  75. def write_json(path, value):
  76. if failure == "session metadata" and path.name == "session.json":
  77. raise OSError("injected metadata failure")
  78. original_write(path, value)
  79. def stop(phase):
  80. nonlocal stopped
  81. if stop_at == phase and not stopped:
  82. (directory / "stop").write_text("")
  83. stopped = True
  84. class FakeCDP:
  85. def __init__(self, url):
  86. if failure == "connection":
  87. raise RuntimeError("injected connection failure")
  88. self.n = 0
  89. self.on_event = None
  90. self.ws = SimpleNamespace(close=lambda: None)
  91. def recv(self, timeout):
  92. clock.sleep(timeout)
  93. if (directory / "ready.json").exists():
  94. stop("ready")
  95. if failure == "exited leader":
  96. children[0].returncode = 0
  97. if failure == "later connection":
  98. raise ConnectionError("injected later disconnect")
  99. else:
  100. stop("pump" if self.n == 0 else "quiet")
  101. return None
  102. def call(self, method, params=None, **kwargs):
  103. if getattr(self, "before_call", None):
  104. self.before_call()
  105. if stop_at == "call":
  106. stop("call")
  107. clock.sleep(10)
  108. if method == "Input.dispatchKeyEvent" and params["type"] == "keyDown":
  109. self.n += 1
  110. return {}
  111. cdp = None
  112. def connect(url):
  113. nonlocal cdp
  114. cdp = FakeCDP(url)
  115. return cdp
  116. def page_url(port):
  117. stop("retry")
  118. if stop_at == "retry":
  119. raise OSError("not listening yet")
  120. return "fake-url"
  121. def tail(path):
  122. if cdp and cdp.n:
  123. return f"\x1b]0;MOVIE;{cdp.n};1;0;{root}\x07$ ".encode()
  124. return b"$ "
  125. for obj, name, value in (
  126. (module, "shell_argv", lambda *a: ["fake-bash"]),
  127. (module, "find_browser", lambda *a: "fake-browser"),
  128. (module, "free_port", lambda: 1234),
  129. (module.subprocess, "Popen", popen),
  130. (module, "kill_process_tree", kill),
  131. (module, "page_url", page_url),
  132. (module, "CDP", connect),
  133. (module, "write_json", write_json),
  134. (module, "tail", tail),
  135. (module, "screenshot", lambda *a: b"capture-token"),
  136. (module, "lit_fraction", lambda *a: 0.01),
  137. (module.time, "monotonic", clock.monotonic),
  138. (module.time, "sleep", clock.sleep),
  139. (Path, "open", open_file),
  140. (Path, "write_bytes", lambda path, token: tokens.append((path.name, token))),
  141. ):
  142. stack.enter_context(patch.object(obj, name, value))
  143. if failure == "profile removal":
  144. stack.enter_context(patch.object(module.shutil, "rmtree", side_effect=PermissionError("locked")))
  145. stack.enter_context(contextlib.redirect_stdout(io.StringIO()))
  146. stack.enter_context(contextlib.redirect_stderr(io.StringIO()))
  147. try:
  148. yield SimpleNamespace(module=module, args=args, directory=directory, children=children,
  149. handles=handles, launches=launches, clock=clock)
  150. finally:
  151. # Release the test's log handles even when ownership assertions fail.
  152. for handle in handles:
  153. handle.close()
  154. class ServeLifecycleTests(unittest.TestCase):
  155. def test_tree_cleanup_failure_keeps_failure_after_leader_exits_and_cleans_other_resources(self):
  156. with serving(stop_at="ready") as rig:
  157. browser = fixtures.load_script("browser_tools")
  158. terminated = []
  159. def taskkill(argv, **kwargs):
  160. pid = int(argv[-1])
  161. terminated.append(pid)
  162. child = next(child for child in rig.children if child.pid == pid)
  163. child.returncode = 0 if pid == 1100 else -9
  164. return subprocess.CompletedProcess(argv, 1 if pid == 1100 else 0, b"", b"tree termination failed")
  165. with patch.object(rig.module, "kill_process_tree", browser.kill_process_tree), \
  166. patch.object(browser.sys, "platform", "win32"), \
  167. patch.object(browser.subprocess, "run", taskkill):
  168. self.assertEqual(rig.module.serve(rig.args), 1)
  169. session = rig.module.read_json(rig.directory / "session.json")
  170. self.assertEqual(session["pids"], [1100, 1101])
  171. self.assertFalse(session.get("closed", False))
  172. self.assertEqual(terminated, [1100, 1101])
  173. self.assertTrue(all(child.poll() is not None for child in rig.children))
  174. self.assertTrue(all(handle.closed for handle in rig.handles))
  175. self.assertFalse((rig.directory / "profile").exists())
  176. self.assertFalse((rig.directory / "ready.json").exists())
  177. self.assertTrue(all((rig.directory / name).exists()
  178. for name in ("ttyd.log", "browser.log", "terminal.log")))
  179. def test_every_acquisition_failure_releases_owned_resources(self):
  180. for failure in ("ttyd.log", "browser.log", "ttyd launch", "browser launch",
  181. "session metadata", "terminal.log", "connection", "later connection"):
  182. with self.subTest(failure=failure), serving(failure) as rig:
  183. try:
  184. code = rig.module.serve(rig.args)
  185. except Exception as error:
  186. code = error
  187. self.assertEqual(code, 1)
  188. self.assertTrue(all(p.poll() is not None for p in rig.children))
  189. self.assertTrue(all(h.closed for h in rig.handles))
  190. self.assertFalse((rig.directory / "profile").exists())
  191. self.assertFalse((rig.directory / "ready.json").exists())
  192. for name in ("ttyd.log", "browser.log", "terminal.log"):
  193. if any(Path(h.name).name == name for h in rig.handles):
  194. self.assertTrue((rig.directory / name).exists(), "logs are evidence")
  195. def test_relative_session_path_matches_browser_profile_and_cwd(self):
  196. with serving("connection", relative=True) as rig:
  197. self.assertEqual(rig.module.serve(rig.args), 1)
  198. argv, kwargs = rig.launches[1]
  199. profile_arg = next(a.split("=", 1)[1] for a in argv if a.startswith("--user-data-dir="))
  200. self.assertEqual(Path(kwargs["cwd"]) / profile_arg, (rig.directory / "profile").resolve())
  201. self.assertTrue(Path(kwargs["cwd"]).is_absolute())
  202. def test_stop_requests_interrupt_startup_and_finish_owned_cleanup(self):
  203. for phase in ("retry", "pump", "quiet", "ready"):
  204. with self.subTest(phase=phase), serving(stop_at=phase) as rig:
  205. self.assertEqual(rig.module.serve(rig.args), 0)
  206. self.assertLess(rig.clock.now, 5)
  207. self.assertTrue(all(p.poll() is not None for p in rig.children))
  208. self.assertTrue(all(h.closed for h in rig.handles))
  209. session = rig.module.read_json(rig.directory / "session.json")
  210. self.assertEqual(session["pids"], [])
  211. self.assertTrue(session["closed"])
  212. self.assertFalse((rig.directory / "ready.json").exists())
  213. self.assertFalse((rig.directory / "profile").exists())
  214. def test_stop_during_one_startup_call_prevents_another_bounded_call(self):
  215. with serving(stop_at="call") as rig:
  216. self.assertEqual(rig.module.serve(rig.args), 0)
  217. self.assertLessEqual(rig.clock.now, 10)
  218. self.assertTrue(rig.module.read_json(rig.directory / "session.json")["closed"])
  219. def test_pid_retirement_is_atomic_and_follows_resource_cleanup(self):
  220. with serving(stop_at="ready") as rig:
  221. replace = Path.replace
  222. observed = []
  223. def publish(path, target):
  224. session = rig.module.read_json(target)
  225. observed.append(session["pids"])
  226. self.assertEqual(session["pids"], [1100, 1101])
  227. self.assertTrue(all(p.poll() is not None for p in rig.children))
  228. self.assertTrue(all(h.closed for h in rig.handles))
  229. self.assertFalse((rig.directory / "profile").exists())
  230. self.assertFalse((rig.directory / "ready.json").exists())
  231. return replace(path, target)
  232. with patch.object(Path, "replace", publish):
  233. self.assertEqual(rig.module.serve(rig.args), 0)
  234. self.assertEqual(observed, [[1100, 1101]])
  235. self.assertEqual(rig.module.read_json(rig.directory / "session.json")["pids"], [])
  236. def test_failed_cleanup_does_not_retire_owned_ids_or_claim_closed(self):
  237. for failure in ("child wait", "profile removal"):
  238. with self.subTest(failure=failure), serving(failure, stop_at="ready") as rig:
  239. self.assertEqual(rig.module.serve(rig.args), 1)
  240. session = rig.module.read_json(rig.directory / "session.json")
  241. self.assertEqual(session["pids"], [1100, 1101])
  242. self.assertFalse(session.get("closed", False))
  243. self.assertFalse((rig.directory / "ready.json").exists())
  244. def test_exited_leader_cannot_confirm_descendant_cleanup(self):
  245. with serving("exited leader", stop_at="ready") as rig:
  246. self.assertEqual(rig.module.serve(rig.args), 1)
  247. session = rig.module.read_json(rig.directory / "session.json")
  248. self.assertEqual(session["pids"], [1100, 1101])
  249. self.assertFalse(session.get("closed", False))
  250. self.assertEqual(rig.children[0].returncode, 0)
  251. self.assertEqual(rig.children[1].returncode, -9)
  252. self.assertTrue(all(h.closed for h in rig.handles))
  253. self.assertFalse((rig.directory / "ready.json").exists())
  254. class CloseContractTests(unittest.TestCase):
  255. def test_close_waits_for_owner_cleanup_and_repeated_close_never_kills(self):
  256. module, clock = recorder(), Clock()
  257. with tempfile.TemporaryDirectory() as temp:
  258. directory = Path(temp)
  259. module.write_json(directory / "session.json", {"pids": [987654]})
  260. (directory / "ready.json").write_text("{}")
  261. (directory / "profile").mkdir()
  262. (directory / "terminal.log").write_text("retain evidence")
  263. def sleep(seconds):
  264. clock.sleep(seconds)
  265. self.assertTrue((directory / "stop").exists())
  266. if clock.now >= 12:
  267. (directory / "profile").rmdir()
  268. (directory / "ready.json").unlink()
  269. module.write_json(directory / "session.json", {"pids": [], "closed": True})
  270. with patch.object(module, "kill_process_tree", side_effect=AssertionError("historical PID kill")), \
  271. patch.object(module.time, "monotonic", clock.monotonic), \
  272. patch.object(module.time, "sleep", sleep), contextlib.redirect_stdout(io.StringIO()):
  273. self.assertEqual(module.close(SimpleNamespace(session=directory)), 0)
  274. self.assertGreaterEqual(clock.now, 12)
  275. self.assertEqual(module.close(SimpleNamespace(session=directory)), 0)
  276. self.assertEqual((directory / "terminal.log").read_text(), "retain evidence")
  277. def test_incomplete_or_unavailable_cleanup_is_not_success(self):
  278. for state in ("missing", "unreadable", "unavailable owner", "profile remains", "ready remains"):
  279. with self.subTest(state=state), tempfile.TemporaryDirectory() as temp:
  280. module, clock, directory = recorder(), Clock(), Path(temp)
  281. if state != "missing":
  282. module.write_json(directory / "session.json", {"pids": [987654]})
  283. if state == "unreadable":
  284. (directory / "session.json").write_text("invalid json")
  285. if state in ("profile remains", "ready remains"):
  286. module.write_json(directory / "session.json", {"pids": [], "closed": True})
  287. if state == "profile remains":
  288. (directory / "profile").mkdir()
  289. else:
  290. (directory / "ready.json").write_text("{}")
  291. with patch.object(module, "kill_process_tree", lambda pid: None), \
  292. patch.object(module.time, "monotonic", clock.monotonic), \
  293. patch.object(module.time, "sleep", clock.sleep), \
  294. contextlib.redirect_stdout(io.StringIO()) as out, \
  295. contextlib.redirect_stderr(io.StringIO()):
  296. try:
  297. code = module.close(SimpleNamespace(session=directory))
  298. except Exception as error:
  299. code = error
  300. self.assertEqual(code, 1)
  301. self.assertNotIn('"closed": true', out.getvalue())
  302. self.assertLessEqual(clock.now, 30.1)
  303. if state == "unavailable owner":
  304. self.assertGreaterEqual(clock.now, 30)
  305. class CDPCallTests(unittest.TestCase):
  306. def test_stop_during_response_prevents_the_next_call_from_being_sent(self):
  307. module, sent, stopped = recorder(), [], []
  308. def recv():
  309. stopped.append(True)
  310. return json.dumps({"id": len(sent), "result": {}})
  311. ws = SimpleNamespace(settimeout=lambda timeout: None, recv=recv,
  312. send=lambda data: sent.append(json.loads(data)["method"]))
  313. websocket = SimpleNamespace(create_connection=lambda *a, **kw: ws,
  314. WebSocketTimeoutException=TimeoutError)
  315. def check_active():
  316. if stopped:
  317. raise InterruptedError("stop requested")
  318. with patch.dict("sys.modules", websocket=websocket):
  319. cdp = module.CDP("fake-url")
  320. cdp.before_call = check_active
  321. self.assertEqual(cdp.call("Network.enable"), {})
  322. with self.assertRaises(InterruptedError):
  323. cdp.call("Page.enable")
  324. self.assertEqual(sent, ["Network.enable"])
  325. class ObservationTests(unittest.TestCase):
  326. def observe(self, action, completed=False, seconds=0.2, cp1252=False):
  327. module, clock = recorder(), Clock()
  328. with tempfile.TemporaryDirectory() as temp:
  329. directory = Path(temp)
  330. log = directory / "terminal.log"
  331. initial = b"\x1b]0;MOVIE;1;1;0;/work\x07"
  332. final = "\x1b]0;MOVIE;2;0;7;C:/René/λ\x1b\\".encode()
  333. log.write_bytes(initial + (final if completed else b""))
  334. (directory / "ready.json").write_text("{}")
  335. module.write_json(directory / "session.json", {"pids": [1100, 1101]})
  336. calls = []
  337. def recv(timeout):
  338. calls.append(timeout)
  339. clock.sleep(timeout)
  340. if action == "disconnect":
  341. raise ConnectionError("browser connection closed")
  342. if action == "session loss":
  343. (directory / "ready.json").unlink(missing_ok=True)
  344. if action == "prompt arrives":
  345. log.write_bytes(initial + final)
  346. if action == "prompt then disconnect":
  347. log.write_bytes(initial + final)
  348. raise ConnectionError("browser connection closed")
  349. return None
  350. cdp = SimpleNamespace(recv=recv)
  351. args = SimpleNamespace(session=directory, record=None, seconds=seconds, hold=0)
  352. raw = io.BytesIO()
  353. out = io.TextIOWrapper(raw, encoding="cp1252" if cp1252 else "utf-8")
  354. with patch.object(module.time, "monotonic", clock.monotonic), \
  355. patch.object(module.time, "sleep", clock.sleep), contextlib.redirect_stdout(out):
  356. code = module.observe(args, cdp, 1)
  357. out.flush()
  358. result = json.loads(raw.getvalue().decode("ascii" if cp1252 else "utf-8"))
  359. return code, result, calls
  360. def test_disconnected_browser_is_failure_without_recording(self):
  361. code, result, calls = self.observe("disconnect")
  362. self.assertEqual(code, 1)
  363. self.assertEqual(result["outcome"], "failed")
  364. self.assertTrue(calls)
  365. def test_lost_terminal_session_is_failure_even_with_live_browser(self):
  366. code, result, calls = self.observe("session loss")
  367. self.assertEqual(code, 1)
  368. self.assertEqual(result["outcome"], "failed")
  369. self.assertTrue(calls)
  370. def test_only_live_unfinished_command_returns_two(self):
  371. for seconds in (0, 0.2):
  372. with self.subTest(seconds=seconds):
  373. code, result, calls = self.observe("alive", seconds=seconds)
  374. self.assertEqual((code, result), (2, {"outcome": "running"}))
  375. self.assertTrue(calls, "even an expired observation must establish liveness")
  376. def test_new_prompt_retains_native_failure_status(self):
  377. for action in ("prompt arrives", "prompt then disconnect"):
  378. with self.subTest(action=action):
  379. code, result, _ = self.observe(action)
  380. self.assertEqual(code, 1)
  381. self.assertEqual(result, {"outcome": "completed", "ok": False,
  382. "exit_code": 7, "cwd": "C:/René/λ"})
  383. def test_completed_prompt_is_not_consumed_by_later_disconnect(self):
  384. code, result, _ = self.observe("disconnect", completed=True)
  385. self.assertEqual((code, result["outcome"], result["exit_code"]), (1, "completed", 7))
  386. def test_stdout_json_round_trips_non_ascii_paths_on_cp1252(self):
  387. try:
  388. code, result, _ = self.observe("alive", completed=True, cp1252=True)
  389. except UnicodeError as error:
  390. self.fail(f"stdout JSON was not portable: {error}")
  391. self.assertEqual((code, result["cwd"]), (1, "C:/René/λ"))
  392. def test_capture_disconnect_during_hold_preserves_completed_command_status(self):
  393. for ok, exit_code in ((True, 0), (False, 7)):
  394. with self.subTest(ok=ok), tempfile.TemporaryDirectory() as temp:
  395. module, clock, writes = recorder(), Clock(), []
  396. marker = f"\x1b]0;MOVIE;2;{int(ok)};{exit_code};C:/René/λ\x07".encode()
  397. args = SimpleNamespace(session=Path(temp), record=Path(temp) / "take",
  398. seconds=10, hold=0.6)
  399. real_film = module.film
  400. def film(*args):
  401. return real_film(*args, clock=clock.monotonic, sleep=clock.sleep)
  402. def capture(cdp):
  403. if clock.now >= 0.2:
  404. raise ConnectionError("capture connection closed")
  405. return b"capture-token"
  406. with patch.object(module, "film", film), \
  407. patch.object(module, "screenshot", capture), \
  408. patch.object(module, "tail", lambda path: marker), \
  409. patch.object(Path, "write_bytes", lambda path, token: writes.append(token)), \
  410. patch.object(module, "write_json", side_effect=AssertionError("failed take publication")), \
  411. contextlib.redirect_stdout(io.StringIO()) as out:
  412. code = module.observe(args, SimpleNamespace(), 1)
  413. self.assertEqual(code, 1)
  414. self.assertEqual(json.loads(out.getvalue()), {
  415. "outcome": "failed", "error": "capture connection closed",
  416. "ok": ok, "exit_code": exit_code, "cwd": "C:/René/λ",
  417. })
  418. self.assertEqual(writes, [b"capture-token"])
  419. class VisibleTextTests(unittest.TestCase):
  420. def test_visible_prompt_survives_bel_and_st_title_markers(self):
  421. module = recorder()
  422. for terminator in (b"\x07", b"\x1b\\"):
  423. with self.subTest(terminator=terminator):
  424. log = b"\x1b[32m/work $ \x1b]0;MOVIE;2;1;0;/work" + terminator
  425. self.assertTrue(module.at_prompt(log))
  426. self.assertFalse(module.at_prompt(log + b"busy"))
  427. class CaptureTimingTests(unittest.TestCase):
  428. def film(self, durations, seconds=1, hold=0, complete_at=None):
  429. module, clock, writes, shots = recorder(), Clock(), [], []
  430. def capture():
  431. token = bytes([len(shots) + 1])
  432. clock.sleep(durations[len(shots)] if len(shots) < len(durations) else 0)
  433. shots.append(token)
  434. return token
  435. with tempfile.TemporaryDirectory() as temp, \
  436. patch.object(Path, "write_bytes", lambda path, token: writes.append((path.name, token))):
  437. frames = module.film(Path(temp), seconds, hold, capture,
  438. lambda: complete_at is not None and clock.now >= complete_at,
  439. clock.monotonic, clock.sleep)
  440. self.assertEqual(len(writes), frames)
  441. return frames, writes
  442. def test_capture_crossing_hard_endpoint_fills_exactly_five_slots(self):
  443. frames, writes = self.film([1.2])
  444. self.assertEqual(frames, 5)
  445. self.assertEqual(writes, [("f00000.png", b"\x01"), ("f00001.png", b"\x01"),
  446. ("f00002.png", b"\x01"), ("f00003.png", b"\x01"),
  447. ("f00004.png", b"\x01")])
  448. def test_capture_crossing_hold_endpoint_is_bounded(self):
  449. frames, writes = self.film([1.2], seconds=10, hold=0.6, complete_at=0)
  450. self.assertEqual(frames, 3)
  451. self.assertEqual([name for name, _ in writes], ["f00000.png", "f00001.png", "f00002.png"])
  452. def test_mid_capture_stall_repeats_previous_token_without_missing_slots(self):
  453. frames, writes = self.film([0.01, 0.5], seconds=1)
  454. self.assertEqual(frames, 5)
  455. self.assertEqual([token for _, token in writes], [b"\x01", b"\x02", b"\x02", b"\x03", b"\x04"])
  456. def test_completion_and_hold_keep_the_normal_grid(self):
  457. frames, _ = self.film([], seconds=10, hold=0.4, complete_at=1)
  458. self.assertEqual(frames, 7)
  459. def test_completion_without_hold_and_zero_duration_do_not_add_slots(self):
  460. self.assertEqual(self.film([], seconds=10, complete_at=0)[0], 0)
  461. self.assertEqual(self.film([], seconds=0)[0], 0)
  462. if __name__ == "__main__":
  463. unittest.main()