scriptcast 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,434 @@
1
+ # tests/test_generator.py
2
+ import json
3
+
4
+ from scriptcast.generator import generate_from_sc, generate_from_sc_text
5
+
6
+
7
+ def _make_sc(*events, width=80, height=24):
8
+ header = {"version": 1, "width": width, "height": height,
9
+ "directive-prefix": "SC", "pipeline-version": 3}
10
+ lines = [json.dumps(header)]
11
+ ts = 1.0
12
+ for typ, text in events:
13
+ lines.append(json.dumps([ts, typ, text]))
14
+ ts += 0.001
15
+ return "\n".join(lines) + "\n"
16
+
17
+
18
+ def _zero_sc(*events):
19
+ zero = [
20
+ ("dir", "set type_speed 0"),
21
+ ("dir", "set cmd_wait 0"),
22
+ ("dir", "set exit_wait 0"),
23
+ ("dir", "set enter_wait 0"),
24
+ ("dir", "set input_wait 0"),
25
+ ]
26
+ return _make_sc(*zero, *events)
27
+
28
+
29
+ def _cast(path):
30
+ lines = [ln for ln in path.read_text().strip().splitlines() if ln]
31
+ return json.loads(lines[0]), [json.loads(ln) for ln in lines[1:]]
32
+
33
+
34
+ def test_cast_header_version(tmp_path):
35
+ paths = generate_from_sc_text(_zero_sc(), tmp_path)
36
+ header, _ = _cast(paths[0])
37
+ assert header["version"] == 2
38
+
39
+
40
+ def test_cast_header_dimensions(tmp_path):
41
+ sc = _make_sc(width=120, height=30)
42
+ paths = generate_from_sc_text(sc, tmp_path)
43
+ header, _ = _cast(paths[0])
44
+ assert header["width"] == 120
45
+ assert header["height"] == 30
46
+
47
+
48
+ def test_cmd_event_typed_as_output(tmp_path):
49
+ paths = generate_from_sc_text(_zero_sc(("cmd", "echo hi")), tmp_path)
50
+ _, cast = _cast(paths[0])
51
+ assert "echo hi" in "".join(e[2] for e in cast)
52
+
53
+
54
+ def test_output_event_in_cast(tmp_path):
55
+ paths = generate_from_sc_text(_zero_sc(("cmd", "echo hi"), ("out", "hi")), tmp_path)
56
+ _, cast = _cast(paths[0])
57
+ assert "hi" in "".join(e[2] for e in cast)
58
+
59
+
60
+ def test_exit_wait_at_scene_end(tmp_path):
61
+ sc = _make_sc(
62
+ ("dir", "set type_speed 0"),
63
+ ("dir", "set cmd_wait 0"),
64
+ ("dir", "set enter_wait 0"),
65
+ ("dir", "set exit_wait 400"),
66
+ ("cmd", "echo x"),
67
+ )
68
+ paths = generate_from_sc_text(sc, tmp_path)
69
+ _, cast = _cast(paths[0])
70
+ assert max(e[0] for e in cast) >= 0.4
71
+
72
+
73
+ def test_expect_input_advances_cursor(tmp_path):
74
+ sc = _make_sc(
75
+ ("dir", "set type_speed 0"),
76
+ ("dir", "set cmd_wait 0"),
77
+ ("dir", "set exit_wait 0"),
78
+ ("dir", "set enter_wait 0"),
79
+ ("dir", "set input_wait 300"),
80
+ ("dir", "expect-input secret"),
81
+ )
82
+ paths = generate_from_sc_text(sc, tmp_path)
83
+ _, cast = _cast(paths[0])
84
+ assert max(e[0] for e in cast) >= 0.3
85
+
86
+
87
+ def test_sleep_directive_advances_cursor(tmp_path):
88
+ sc = _make_sc(
89
+ ("dir", "set type_speed 0"),
90
+ ("dir", "set cmd_wait 0"),
91
+ ("dir", "set exit_wait 0"),
92
+ ("dir", "set enter_wait 0"),
93
+ ("cmd", "a"),
94
+ ("dir", "sleep 500"),
95
+ ("cmd", "b"),
96
+ )
97
+ paths = generate_from_sc_text(sc, tmp_path)
98
+ _, cast = _cast(paths[0])
99
+ assert max(e[0] for e in cast) >= 0.5
100
+
101
+
102
+ def test_scene_split_mode(tmp_path):
103
+ sc = _zero_sc(
104
+ ("dir", "scene intro"),
105
+ ("cmd", "echo a"),
106
+ ("dir", "scene outro"),
107
+ ("cmd", "echo b"),
108
+ )
109
+ paths = generate_from_sc_text(sc, tmp_path, split_scenes=True)
110
+ assert {p.stem for p in paths} == {"intro", "outro"}
111
+
112
+
113
+ def test_single_cast_default(tmp_path):
114
+ sc = _zero_sc(
115
+ ("dir", "scene first"),
116
+ ("cmd", "echo a"),
117
+ ("dir", "scene second"),
118
+ ("cmd", "echo b"),
119
+ )
120
+ paths = generate_from_sc_text(sc, tmp_path, output_stem="demo")
121
+ assert len(paths) == 1
122
+ assert paths[0].name == "demo.cast"
123
+
124
+
125
+ def test_single_cast_timestamps_continuous(tmp_path):
126
+ sc = _make_sc(
127
+ ("dir", "set type_speed 0"),
128
+ ("dir", "set cmd_wait 0"),
129
+ ("dir", "set enter_wait 0"),
130
+ ("dir", "set exit_wait 200"),
131
+ ("dir", "scene a"),
132
+ ("cmd", "x"),
133
+ ("dir", "scene b"),
134
+ ("cmd", "y"),
135
+ )
136
+ paths = generate_from_sc_text(sc, tmp_path, output_stem="out")
137
+ _, cast = _cast(paths[0])
138
+ times = [e[0] for e in cast]
139
+ assert times == sorted(times)
140
+ assert max(times) > 0.2
141
+
142
+
143
+ def test_generate_from_sc_reads_file(tmp_path):
144
+ sc = _zero_sc(("cmd", "echo hello"))
145
+ sc_file = tmp_path / "demo.sc"
146
+ sc_file.write_text(sc)
147
+ out = tmp_path / "out"
148
+ out.mkdir()
149
+ paths = generate_from_sc(sc_file, out)
150
+ _, cast = _cast(paths[0])
151
+ assert "echo hello" in "".join(e[2] for e in cast)
152
+
153
+
154
+ def test_word_speed_adds_pause_between_words(tmp_path):
155
+ # "echo hello world" has 2 spaces → 2 word pauses of 200ms each = 400ms
156
+ sc = _make_sc(
157
+ ("dir", "set type_speed 0"),
158
+ ("dir", "set cmd_wait 0"),
159
+ ("dir", "set exit_wait 0"),
160
+ ("dir", "set enter_wait 0"),
161
+ ("dir", "set word_speed 200"),
162
+ ("cmd", "echo hello world"),
163
+ )
164
+ paths = generate_from_sc_text(sc, tmp_path)
165
+ _, cast = _cast(paths[0])
166
+ assert max(e[0] for e in cast) >= 0.4
167
+
168
+
169
+ def test_word_speed_none_mirrors_type_speed(tmp_path):
170
+ # word_speed=None → word pause = type_speed (100ms here); "a b" has 1 space
171
+ # chars: 'a' at 0.1, ' ' at 0.2 + word_pause 0.1 = 0.3, 'b' at 0.4, prompt/sentinel at 0.5
172
+ sc = _make_sc(
173
+ ("dir", "set type_speed 100"),
174
+ ("dir", "set cmd_wait 0"),
175
+ ("dir", "set exit_wait 0"),
176
+ ("dir", "set enter_wait 0"),
177
+ ("cmd", "a b"),
178
+ )
179
+ paths = generate_from_sc_text(sc, tmp_path)
180
+ _, cast = _cast(paths[0])
181
+ assert max(e[0] for e in cast) >= 0.5
182
+
183
+
184
+ def test_word_speed_applies_to_expect_input(tmp_path):
185
+ # "foo bar" has 1 space → 1 word pause of 300ms
186
+ sc = _make_sc(
187
+ ("dir", "set type_speed 0"),
188
+ ("dir", "set cmd_wait 0"),
189
+ ("dir", "set exit_wait 0"),
190
+ ("dir", "set enter_wait 0"),
191
+ ("dir", "set input_wait 0"),
192
+ ("dir", "set word_speed 300"),
193
+ ("dir", "expect-input foo bar"),
194
+ )
195
+ paths = generate_from_sc_text(sc, tmp_path)
196
+ _, cast = _cast(paths[0])
197
+ assert max(e[0] for e in cast) >= 0.3
198
+
199
+
200
+ def test_quoted_prompt_in_pre_scene_set(tmp_path):
201
+ # bash traces `"$ "` as `'$ '`; shlex.split must handle shell quoting in the
202
+ # pre-scene directive loop so the cast shows "$ " not "'$"
203
+ sc = _make_sc(
204
+ ("dir", "set prompt '$ '"),
205
+ ("dir", "scene main"),
206
+ ("cmd", "echo hi"),
207
+ )
208
+ paths = generate_from_sc_text(sc, tmp_path)
209
+ _, cast = _cast(paths[0])
210
+ prompt_events = [e for e in cast if e[2] == "$ "]
211
+ assert prompt_events, f"expected '$ ' prompt in cast, got: {cast}"
212
+
213
+
214
+ def test_build_config_from_sc_text_reads_header():
215
+ import json
216
+
217
+ from scriptcast.generator import build_config_from_sc_text
218
+ header = json.dumps({"version": 1, "shell": "bash", "width": 80, "height": 24,
219
+ "directive-prefix": "SC"})
220
+ sc = header + "\n"
221
+ cfg = build_config_from_sc_text(sc)
222
+ assert cfg.width == 80
223
+ assert cfg.height == 24
224
+ assert cfg.directive_prefix == "SC"
225
+
226
+
227
+ def test_build_config_from_sc_text_applies_pre_scene_set():
228
+ import json
229
+
230
+ from scriptcast.generator import build_config_from_sc_text
231
+ header = json.dumps({"version": 1, "width": 100, "height": 28, "directive-prefix": "SC"})
232
+ events = [
233
+ json.dumps([0.0, "dir", "set type_speed 20"]),
234
+ json.dumps([0.1, "dir", "set theme-radius 16"]),
235
+ json.dumps([0.2, "dir", "scene main"]),
236
+ json.dumps([0.3, "cmd", "echo hello"]),
237
+ ]
238
+ sc = header + "\n" + "\n".join(events)
239
+ cfg = build_config_from_sc_text(sc)
240
+ assert cfg.type_speed == 20
241
+ assert cfg.theme.radius == 16
242
+
243
+
244
+ def test_build_config_from_sc_text_stops_at_scene():
245
+ import json
246
+
247
+ from scriptcast.generator import build_config_from_sc_text
248
+ header = json.dumps({"version": 1, "width": 100, "height": 28, "directive-prefix": "SC"})
249
+ events = [
250
+ json.dumps([0.0, "dir", "scene main"]),
251
+ json.dumps([0.1, "dir", "set type_speed 99"]),
252
+ ]
253
+ sc = header + "\n" + "\n".join(events)
254
+ cfg = build_config_from_sc_text(sc)
255
+ assert cfg.type_speed == 40 # default, not overridden after scene
256
+
257
+
258
+ def test_build_config_from_sc_text_empty_returns_defaults():
259
+ from scriptcast.generator import build_config_from_sc_text
260
+ cfg = build_config_from_sc_text("")
261
+ assert cfg.type_speed == 40
262
+ assert cfg.theme.frame is True
263
+
264
+
265
+ # --- build_config_from_sc_text: base parameter ---
266
+
267
+ def test_build_config_from_sc_text_base_provides_defaults():
268
+ import json
269
+
270
+ from scriptcast.config import ScriptcastConfig
271
+ from scriptcast.generator import build_config_from_sc_text
272
+ base = ScriptcastConfig(prompt="THEME_PROMPT")
273
+ base.theme.radius = 99
274
+ header = json.dumps({"version": 1, "width": 80, "height": 24, "directive-prefix": "SC"})
275
+ sc = header + "\n"
276
+ cfg = build_config_from_sc_text(sc, base=base)
277
+ assert cfg.prompt == "THEME_PROMPT"
278
+ assert cfg.theme.radius == 99
279
+
280
+
281
+ def test_build_config_from_sc_text_sc_overrides_base():
282
+ import json
283
+
284
+ from scriptcast.config import ScriptcastConfig
285
+ from scriptcast.generator import build_config_from_sc_text
286
+ base = ScriptcastConfig(prompt="THEME_PROMPT")
287
+ header = json.dumps({"version": 1, "width": 80, "height": 24, "directive-prefix": "SC"})
288
+ events = [json.dumps([0.0, "dir", "set prompt SC_PROMPT"])]
289
+ sc = header + "\n" + "\n".join(events)
290
+ cfg = build_config_from_sc_text(sc, base=base)
291
+ assert cfg.prompt == "SC_PROMPT"
292
+
293
+
294
+ def test_build_config_from_sc_text_base_none_is_default():
295
+ import json
296
+
297
+ from scriptcast.generator import build_config_from_sc_text
298
+ header = json.dumps({"version": 1, "width": 80, "height": 24, "directive-prefix": "SC"})
299
+ sc = header + "\n"
300
+ cfg = build_config_from_sc_text(sc, base=None)
301
+ assert cfg.prompt == "$ "
302
+
303
+
304
+ # --- generate_from_sc_text: base parameter ---
305
+
306
+ def test_generate_from_sc_text_base_prompt_in_cast(tmp_path):
307
+ from scriptcast.config import ScriptcastConfig
308
+ base = ScriptcastConfig(prompt="THEME>")
309
+ sc = _zero_sc(
310
+ ("dir", "scene main"),
311
+ ("cmd", "echo hi"),
312
+ )
313
+ paths = generate_from_sc_text(sc, tmp_path, base=base)
314
+ _, cast = _cast(paths[0])
315
+ prompt_text = "".join(e[2] for e in cast)
316
+ assert "THEME>" in prompt_text, f"Expected 'THEME>' in cast output, got: {prompt_text!r}"
317
+
318
+
319
+ def test_generate_from_sc_text_sc_prompt_overrides_base(tmp_path):
320
+ from scriptcast.config import ScriptcastConfig
321
+ base = ScriptcastConfig(prompt="THEME>")
322
+ sc = _zero_sc(
323
+ ("dir", "set prompt SCRIPT>"),
324
+ ("dir", "scene main"),
325
+ ("cmd", "echo hi"),
326
+ )
327
+ paths = generate_from_sc_text(sc, tmp_path, base=base)
328
+ _, cast = _cast(paths[0])
329
+ prompt_text = "".join(e[2] for e in cast)
330
+ assert "SCRIPT>" in prompt_text, "Expected 'SCRIPT>' prompt override in cast"
331
+ assert "THEME>" not in prompt_text, "Theme prompt should be overridden"
332
+
333
+
334
+ def test_generate_from_sc_text_base_type_speed(tmp_path):
335
+ from scriptcast.config import ScriptcastConfig
336
+ # base sets type_speed=200; sc has no override; "ab" = 2 chars → 400ms
337
+ base = ScriptcastConfig(cmd_wait=0, exit_wait=0, enter_wait=0, type_speed=200)
338
+ sc = _make_sc(("cmd", "ab"))
339
+ paths = generate_from_sc_text(sc, tmp_path, base=base)
340
+ _, cast = _cast(paths[0])
341
+ assert max(e[0] for e in cast) >= 0.4
342
+
343
+
344
+ def test_generate_from_sc_reads_file_with_base(tmp_path):
345
+ from scriptcast.config import ScriptcastConfig
346
+ base = ScriptcastConfig(prompt="FILE>")
347
+ sc = _zero_sc(("dir", "scene main"), ("cmd", "echo hi"))
348
+ sc_file = tmp_path / "demo.sc"
349
+ sc_file.write_text(sc)
350
+ out = tmp_path / "out"
351
+ out.mkdir()
352
+ paths = generate_from_sc(sc_file, out, base=base)
353
+ _, cast = _cast(paths[0])
354
+ prompt_text = "".join(e[2] for e in cast)
355
+ assert "FILE>" in prompt_text
356
+
357
+
358
+ def test_expect_input_directive_advances_cursor(tmp_path):
359
+ sc = _make_sc(
360
+ ("dir", "set type_speed 0"),
361
+ ("dir", "set cmd_wait 0"),
362
+ ("dir", "set exit_wait 0"),
363
+ ("dir", "set enter_wait 0"),
364
+ ("dir", "set input_wait 300"),
365
+ ("dir", "expect-input secret"),
366
+ )
367
+ paths = generate_from_sc_text(sc, tmp_path)
368
+ _, cast = _cast(paths[0])
369
+ assert max(e[0] for e in cast) >= 0.3
370
+
371
+
372
+ def test_expect_input_directive_emits_chars(tmp_path):
373
+ sc = _make_sc(
374
+ ("dir", "set type_speed 0"),
375
+ ("dir", "set cmd_wait 0"),
376
+ ("dir", "set exit_wait 0"),
377
+ ("dir", "set enter_wait 0"),
378
+ ("dir", "set input_wait 0"),
379
+ ("dir", "expect-input hi"),
380
+ )
381
+ paths = generate_from_sc_text(sc, tmp_path)
382
+ _, cast = _cast(paths[0])
383
+ all_text = "".join(e[2] for e in cast)
384
+ assert "h" in all_text
385
+ assert "i" in all_text
386
+
387
+
388
+ def test_out_event_no_suffix_added(tmp_path):
389
+ # Generator must NOT append \r\n — text is emitted verbatim
390
+ paths = generate_from_sc_text(_zero_sc(("out", "hello")), tmp_path)
391
+ _, cast = _cast(paths[0])
392
+ # There must be a cast event with exactly "hello" (no \r\n appended)
393
+ assert any(e[2] == "hello" for e in cast), \
394
+ "expected 'hello' verbatim; old code would emit 'hello\\r\\n'"
395
+
396
+
397
+ def test_cr_delay_splits_bare_cr(tmp_path):
398
+ # out event with bare \r: with cr_delay=80, two cast events emitted at different times
399
+ sc = _make_sc(
400
+ ("dir", "set type_speed 0"),
401
+ ("dir", "set cmd_wait 0"),
402
+ ("dir", "set exit_wait 0"),
403
+ ("dir", "set enter_wait 0"),
404
+ ("dir", "set cr_delay 80"),
405
+ ("out", "Loading\rDone\n"),
406
+ )
407
+ paths = generate_from_sc_text(sc, tmp_path)
408
+ _, cast = _cast(paths[0])
409
+ all_text = "".join(e[2] for e in cast)
410
+ assert "Loading" in all_text
411
+ assert "\rDone" in all_text
412
+ # The \r split creates two events at different timestamps
413
+ loading_events = [e for e in cast if "Loading" in e[2] and "\r" not in e[2]]
414
+ done_events = [e for e in cast if "\rDone" in e[2]]
415
+ assert loading_events and done_events
416
+ assert done_events[0][0] >= loading_events[0][0] + 0.079
417
+
418
+
419
+ def test_crlf_not_split_by_cr_delay(tmp_path):
420
+ # \r\n is a line ending — cr_delay must NOT split it
421
+ sc = _make_sc(
422
+ ("dir", "set type_speed 0"),
423
+ ("dir", "set cmd_wait 0"),
424
+ ("dir", "set exit_wait 0"),
425
+ ("dir", "set enter_wait 0"),
426
+ ("dir", "set cr_delay 80"),
427
+ ("out", "hello\r\n"),
428
+ )
429
+ paths = generate_from_sc_text(sc, tmp_path)
430
+ _, cast = _cast(paths[0])
431
+ # hello\r\n should appear as a single event (no split)
432
+ hello_events = [e for e in cast if "hello" in e[2]]
433
+ assert len(hello_events) == 1
434
+ assert hello_events[0][2] == "hello\r\n"
@@ -0,0 +1,97 @@
1
+ # tests/test_integration.py
2
+ """Integration test using examples/tutorial.sh as a real end-to-end fixture."""
3
+ import json
4
+ import shutil
5
+ from pathlib import Path
6
+
7
+ import pytest
8
+ from click.testing import CliRunner
9
+
10
+ from scriptcast.__main__ import cli
11
+
12
+ requires_agg = pytest.mark.skipif(shutil.which("agg") is None, reason="agg not installed")
13
+
14
+ TUTORIAL_SCRIPT = Path(__file__).parent.parent / "examples" / "tutorial.sh"
15
+
16
+
17
+ @requires_agg
18
+ def test_basic_example_end_to_end(tmp_path):
19
+ """examples/tutorial.sh produces a single tutorial.cast with valid asciinema v2 header."""
20
+ runner = CliRunner()
21
+ result = runner.invoke(cli, ["--output-dir", str(tmp_path), str(TUTORIAL_SCRIPT)])
22
+ assert result.exit_code == 0, result.output
23
+
24
+ cast_file = tmp_path / "tutorial.cast"
25
+ assert cast_file.exists()
26
+
27
+ lines = cast_file.read_text().splitlines()
28
+ header = json.loads(lines[0])
29
+ assert header["version"] == 2
30
+ assert header["width"] == 80
31
+ assert header["height"] == 8
32
+
33
+
34
+ @requires_agg
35
+ def test_basic_example_end_to_end_split_mode(tmp_path):
36
+ """--split-scenes produces one .cast per scene."""
37
+ runner = CliRunner()
38
+ result = runner.invoke(
39
+ cli,
40
+ ["--output-dir", str(tmp_path), "--split-scenes", str(TUTORIAL_SCRIPT)],
41
+ )
42
+ assert result.exit_code == 0, result.output
43
+
44
+ cast_files = sorted(tmp_path.glob("*.cast"))
45
+ names = {f.stem for f in cast_files}
46
+ expected = {"intro", "mock", "expect", "filter", "comment", "sleep", "word_speed", "record"}
47
+ assert names == expected
48
+
49
+
50
+ def test_basic_example_record_stage(tmp_path):
51
+ """scriptcast --no-export on tutorial.sh produces a .sc file with JSONL header."""
52
+ import json as _json
53
+ runner = CliRunner()
54
+ result = runner.invoke(
55
+ cli, ["--output-dir", str(tmp_path), "--no-export", str(TUTORIAL_SCRIPT)],
56
+ )
57
+ assert result.exit_code == 0, result.output
58
+
59
+ sc_file = tmp_path / "tutorial.sc"
60
+ assert sc_file.exists()
61
+ header = _json.loads(sc_file.read_text().splitlines()[0])
62
+ assert header["version"] == 1
63
+ assert "shell" in header
64
+ assert header["directive-prefix"] == "SC"
65
+
66
+
67
+ @requires_agg
68
+ def test_basic_example_mock_scene_shows_command(tmp_path):
69
+ """`tutorial.cast` contains the mock deploy command as a typed command trace."""
70
+ runner = CliRunner()
71
+ result = runner.invoke(cli, ["--output-dir", str(tmp_path), str(TUTORIAL_SCRIPT)])
72
+ assert result.exit_code == 0, result.output
73
+
74
+ cast_file = tmp_path / "tutorial.cast"
75
+ content = cast_file.read_text()
76
+ # "deploy" should appear as typed output (the mock command)
77
+ all_text = "".join(
78
+ json.loads(ln)[2]
79
+ for ln in content.splitlines()[1:]
80
+ if ln.strip()
81
+ )
82
+ assert "deploy" in all_text
83
+ assert "Deploying to production" in all_text
84
+
85
+
86
+ @requires_agg
87
+ def test_basic_example_filter_applied(tmp_path):
88
+ """`tutorial.cast` must not contain the raw working directory path."""
89
+ runner = CliRunner()
90
+ result = runner.invoke(cli, ["--output-dir", str(tmp_path), str(TUTORIAL_SCRIPT)])
91
+ assert result.exit_code == 0, result.output
92
+
93
+ cast_file = tmp_path / "tutorial.cast"
94
+ assert cast_file.exists()
95
+ content = cast_file.read_text()
96
+ assert "/workspaces/scriptcast" not in content
97
+ assert "<project>" in content