@@ -37,13 +37,31 @@ def _count_frames(path: Path) -> int:
3737 return sum (1 for _ in r ) # ty: ignore[not-iterable]
3838
3939
40+ def _rec (tmp_path : Path , ** overrides : Any ) -> EpisodeVideoRecorder :
41+ """Construct a recorder with stable test-suite defaults.
42+
43+ The recorder itself has no defaults for ``filename`` /
44+ ``required_context`` (every benchmark spells them out explicitly).
45+ Tests don't need to repeat that boilerplate, so this helper picks
46+ the same ``task_name``/``episode_idx`` template the original
47+ test data assumed.
48+ """
49+ kwargs : dict [str , Any ] = {
50+ "output_dir" : tmp_path ,
51+ "filename" : "{task_name}_ep{episode_idx}_{status}.mp4" ,
52+ "required_context" : ("task_name" , "episode_idx" ),
53+ }
54+ kwargs .update (overrides )
55+ return EpisodeVideoRecorder (** kwargs )
56+
57+
4058# ---------------------------------------------------------------------------
4159# Happy path
4260# ---------------------------------------------------------------------------
4361
4462
4563def test_save_writes_mp4_with_correct_framecount (tmp_path : Path ) -> None :
46- rec = EpisodeVideoRecorder ( output_dir = tmp_path , fps = 10 )
64+ rec = _rec ( tmp_path , fps = 10 )
4765 rec .start ({"task_name" : "PickCube" , "episode_idx" : 0 })
4866 for _ in range (5 ):
4967 rec .record (_frame ())
@@ -56,7 +74,7 @@ def test_save_writes_mp4_with_correct_framecount(tmp_path: Path) -> None:
5674
5775
5876def test_save_uses_status_in_filename (tmp_path : Path ) -> None :
59- rec = EpisodeVideoRecorder ( output_dir = tmp_path )
77+ rec = _rec ( tmp_path )
6078 rec .start ({"task_name" : "T" , "episode_idx" : 7 })
6179 rec .record (_frame ())
6280 final = rec .save (status = "fail" )
@@ -66,7 +84,7 @@ def test_save_uses_status_in_filename(tmp_path: Path) -> None:
6684
6785
6886def test_active_flag_tracks_lifecycle (tmp_path : Path ) -> None :
69- rec = EpisodeVideoRecorder ( output_dir = tmp_path )
87+ rec = _rec ( tmp_path )
7088 assert rec .active is False
7189 rec .start ({"task_name" : "T" , "episode_idx" : 0 })
7290 assert rec .active is True
@@ -75,7 +93,7 @@ def test_active_flag_tracks_lifecycle(tmp_path: Path) -> None:
7593
7694
7795def test_consecutive_episodes_each_produce_their_own_file (tmp_path : Path ) -> None :
78- rec = EpisodeVideoRecorder ( output_dir = tmp_path )
96+ rec = _rec ( tmp_path )
7997 for ep in range (3 ):
8098 rec .start ({"task_name" : "T" , "episode_idx" : ep })
8199 rec .record (_frame ())
@@ -125,18 +143,20 @@ def naming(ctx: Mapping[str, Any]) -> str:
125143 assert final == tmp_path / "abc-3-ok.mp4"
126144
127145
128- def test_save_with_missing_template_key_is_handled (tmp_path : Path ) -> None :
146+ def test_save_with_template_key_not_in_required_context_is_handled (tmp_path : Path ) -> None :
147+ # required_context is the caller's contract for what must be present at
148+ # start(); it's permitted to be a subset of the keys the template uses
149+ # (e.g. an optional `seed`). When a template key is genuinely missing
150+ # at save() time, resolution should fail gracefully rather than raise.
129151 rec = EpisodeVideoRecorder (
130152 output_dir = tmp_path ,
131153 filename = "{task_name}_{seed}_{status}.mp4" ,
154+ required_context = ("task_name" ,),
132155 )
133- rec .start ({"task_name" : "T" , "episode_idx" : 0 }) # `seed` missing
156+ rec .start ({"task_name" : "T" }) # `seed` missing
134157 rec .record (_frame ())
135- # Resolution happens at save() time; a missing key logs and returns None
136- # rather than raising.
137158 final = rec .save (status = "success" )
138159 assert final is None
139- # Tempfile must have been cleaned up.
140160 assert list (tmp_path .glob (".recorder-*.mp4" )) == []
141161
142162
@@ -146,31 +166,31 @@ def test_save_with_missing_template_key_is_handled(tmp_path: Path) -> None:
146166
147167
148168def test_start_missing_required_context_raises (tmp_path : Path ) -> None :
149- rec = EpisodeVideoRecorder ( output_dir = tmp_path )
169+ rec = _rec ( tmp_path )
150170 with pytest .raises (ValueError , match = "missing required context keys" ):
151171 rec .start ({"task_name" : "T" }) # episode_idx missing
152172 assert rec .active is False
153173
154174
155175def test_record_before_start_is_noop (tmp_path : Path ) -> None :
156- rec = EpisodeVideoRecorder ( output_dir = tmp_path )
176+ rec = _rec ( tmp_path )
157177 rec .record (_frame ()) # must not raise
158178 assert rec .active is False
159179
160180
161181def test_save_before_start_returns_none (tmp_path : Path ) -> None :
162- rec = EpisodeVideoRecorder ( output_dir = tmp_path )
182+ rec = _rec ( tmp_path )
163183 assert rec .save () is None
164184
165185
166186def test_discard_before_start_is_noop (tmp_path : Path ) -> None :
167- rec = EpisodeVideoRecorder ( output_dir = tmp_path )
187+ rec = _rec ( tmp_path )
168188 rec .discard () # must not raise
169189 assert rec .active is False
170190
171191
172192def test_writer_open_failure_leaves_recorder_inactive (tmp_path : Path ) -> None :
173- rec = EpisodeVideoRecorder ( output_dir = tmp_path )
193+ rec = _rec ( tmp_path )
174194 with patch ("imageio.get_writer" , side_effect = RuntimeError ("nope" )):
175195 rec .start ({"task_name" : "T" , "episode_idx" : 0 })
176196 assert rec .active is False
@@ -187,13 +207,14 @@ def test_writer_open_failure_leaves_recorder_inactive(tmp_path: Path) -> None:
187207
188208
189209def test_start_again_without_save_discards_prior_episode (tmp_path : Path ) -> None :
190- rec = EpisodeVideoRecorder ( output_dir = tmp_path )
210+ rec = _rec ( tmp_path )
191211 rec .start ({"task_name" : "T" , "episode_idx" : 0 })
192212 rec .record (_frame ())
193213 # Simulate orchestrator skipping save() / discard() and starting next ep:
194214 rec .start ({"task_name" : "T" , "episode_idx" : 1 })
195215 rec .record (_frame ())
196216 final = rec .save (status = "success" )
217+ assert final is not None
197218 assert final == tmp_path / "T_ep1_success.mp4"
198219 # Only ep1 mp4 should exist; ep0's tempfile was cleaned up.
199220 mp4s = sorted (p .name for p in tmp_path .glob ("*.mp4" ))
@@ -202,7 +223,7 @@ def test_start_again_without_save_discards_prior_episode(tmp_path: Path) -> None
202223
203224
204225def test_discard_cleans_up_tempfile (tmp_path : Path ) -> None :
205- rec = EpisodeVideoRecorder ( output_dir = tmp_path )
226+ rec = _rec ( tmp_path )
206227 rec .start ({"task_name" : "T" , "episode_idx" : 0 })
207228 rec .record (_frame ())
208229 rec .discard ()
@@ -219,7 +240,7 @@ def test_discard_cleans_up_tempfile(tmp_path: Path) -> None:
219240def test_output_dir_created_lazily (tmp_path : Path ) -> None :
220241 target = tmp_path / "nested" / "videos"
221242 assert not target .exists ()
222- rec = EpisodeVideoRecorder ( output_dir = target )
243+ rec = _rec ( target )
223244 rec .start ({"task_name" : "T" , "episode_idx" : 0 })
224245 rec .record (_frame ())
225246 final = rec .save ()
@@ -229,7 +250,7 @@ def test_output_dir_created_lazily(tmp_path: Path) -> None:
229250
230251
231252def test_str_path_accepted (tmp_path : Path ) -> None :
232- rec = EpisodeVideoRecorder ( output_dir = str (tmp_path ))
253+ rec = _rec ( tmp_path , output_dir = str (tmp_path ))
233254 rec .start ({"task_name" : "T" , "episode_idx" : 0 })
234255 rec .record (_frame ())
235256 final = rec .save ()
0 commit comments