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.
tests/test_export.py ADDED
@@ -0,0 +1,986 @@
1
+ # tests/test_export.py
2
+
3
+ import pytest
4
+
5
+
6
+ # ------------------------------------------------------------------ ThemeConfig
7
+ def test_theme_config_has_frame_bar():
8
+ from scriptcast.config import ThemeConfig
9
+ assert ThemeConfig().frame_bar is True
10
+
11
+
12
+ def test_theme_config_has_frame_bar_title():
13
+ from scriptcast.config import ThemeConfig
14
+ assert ThemeConfig().frame_bar_title == "Terminal"
15
+
16
+
17
+ def test_theme_config_frame_bar_color_default():
18
+ from scriptcast.config import ThemeConfig
19
+ assert ThemeConfig().frame_bar_color == "252535"
20
+
21
+
22
+ def test_theme_config_frame_bar_buttons_default():
23
+ from scriptcast.config import ThemeConfig
24
+ assert ThemeConfig().frame_bar_buttons is True
25
+
26
+
27
+ def test_theme_config_shadow_offset_x_default():
28
+ from scriptcast.config import ThemeConfig
29
+ assert ThemeConfig().shadow_offset_x == 0
30
+
31
+
32
+ def test_theme_config_no_title_field():
33
+ from scriptcast.config import ThemeConfig
34
+ assert not hasattr(ThemeConfig(), "title")
35
+
36
+
37
+ def test_theme_config_default_background_is_aurora():
38
+ from scriptcast.config import ThemeConfig
39
+ assert ThemeConfig().background == "1e1b4b,0d3b66"
40
+
41
+
42
+ def test_theme_config_default_frame_is_true():
43
+ from scriptcast.config import ThemeConfig
44
+ assert ThemeConfig().frame is True
45
+
46
+
47
+ def test_theme_config_default_colors_have_no_hash():
48
+ from scriptcast.config import ThemeConfig
49
+ cfg = ThemeConfig()
50
+ assert not cfg.border_color.startswith("#")
51
+ assert not cfg.frame_bar_color.startswith("#")
52
+ assert not cfg.shadow_color.startswith("#")
53
+ assert not cfg.watermark_color.startswith("#")
54
+
55
+
56
+ # ------------------------------------------------------------------ Layout
57
+ def test_layout_basic_no_margin_no_border():
58
+ from scriptcast.config import ThemeConfig
59
+ from scriptcast.export import build_layout
60
+ config = ThemeConfig(
61
+ padding_left=14, padding_right=14, padding_top=14, padding_bottom=14,
62
+ border_width=0,
63
+ margin_top=None, margin_right=None, margin_bottom=None, margin_left=None,
64
+ background=None,
65
+ frame_bar=True,
66
+ )
67
+ layout = build_layout(200, 100, config)
68
+ assert layout.content_w == 200
69
+ assert layout.content_h == 100
70
+ assert layout.window_w == 200 # no padding expansion
71
+ assert layout.window_h == 28 + 100 # title bar + content only
72
+ assert layout.canvas_w == 200
73
+ assert layout.canvas_h == 128
74
+ assert layout.content_x == 0 # no padding offset
75
+ assert layout.content_y == 28 # title bar only
76
+ assert layout.title_bar_h == 28
77
+
78
+
79
+ def test_layout_with_margin():
80
+ from scriptcast.config import ThemeConfig
81
+ from scriptcast.export import build_layout
82
+ config = ThemeConfig(
83
+ padding_left=14, padding_right=14, padding_top=14, padding_bottom=14,
84
+ border_width=0,
85
+ margin_top=82, margin_right=82, margin_bottom=82, margin_left=82,
86
+ background="#ff0000",
87
+ frame_bar=True,
88
+ )
89
+ layout = build_layout(200, 100, config)
90
+ assert layout.window_x == 82
91
+ assert layout.window_y == 82
92
+ assert layout.canvas_w == 82 + 200 + 82 # 364
93
+ assert layout.canvas_h == 82 + 128 + 82 # 292
94
+
95
+
96
+ def test_layout_border_shifts_window_and_canvas():
97
+ from scriptcast.config import ThemeConfig
98
+ from scriptcast.export import build_layout
99
+ config = ThemeConfig(
100
+ padding_left=14, padding_right=14, padding_top=14, padding_bottom=14,
101
+ border_width=10,
102
+ margin_top=None, margin_right=None, margin_bottom=None, margin_left=None,
103
+ background=None,
104
+ frame_bar=True,
105
+ )
106
+ layout = build_layout(200, 100, config)
107
+ assert layout.half_bw == 5.0
108
+ assert layout.window_x == 5.0
109
+ assert layout.window_y == 5.0
110
+ # canvas = 5 + 220 + 5 = 230 (border expands window_w from 200 to 220)
111
+ assert layout.canvas_w == 230
112
+ assert layout.canvas_h == 148 # 5 + 138 + 5 (border expands window_h from 128 to 138)
113
+ # content_x = window_x + left_padding = 5 + 10 = 15
114
+ assert layout.content_x == 15
115
+ assert layout.content_y == int(5 + 28) # 33
116
+
117
+
118
+ def test_layout_frame_bar_false_removes_title_bar_height():
119
+ from scriptcast.config import ThemeConfig
120
+ from scriptcast.export import build_layout
121
+ config = ThemeConfig(
122
+ padding_left=14, padding_right=14, padding_top=14, padding_bottom=14,
123
+ border_width=0,
124
+ margin_top=None, margin_right=None, margin_bottom=None, margin_left=None,
125
+ background=None,
126
+ frame_bar=False,
127
+ )
128
+ layout = build_layout(200, 100, config)
129
+ assert layout.title_bar_h == 0
130
+ assert layout.window_h == 100 # content only, no title bar, no padding
131
+ assert layout.content_y == 0 # no title bar contribution
132
+
133
+
134
+ def test_layout_border_zero_is_simple():
135
+ from scriptcast.config import ThemeConfig
136
+ from scriptcast.export import build_layout
137
+ config = ThemeConfig(
138
+ padding_left=0, padding_right=0, padding_top=0, padding_bottom=0,
139
+ border_width=0,
140
+ margin_top=None, margin_right=None, margin_bottom=None, margin_left=None,
141
+ background=None,
142
+ frame_bar=False,
143
+ )
144
+ layout = build_layout(80, 40, config)
145
+ assert layout.window_x == 0
146
+ assert layout.canvas_w == 80
147
+ assert layout.canvas_h == 40
148
+ assert layout.content_x == 0
149
+ assert layout.content_y == 0
150
+
151
+
152
+ # ------------------------------------------------------------------ _hex_rgba
153
+ def test_hex_rgba_six_chars():
154
+ from scriptcast.export import _hex_rgba
155
+ assert _hex_rgba("#ff5f57") == (255, 95, 87, 255)
156
+
157
+
158
+ def test_hex_rgba_eight_chars():
159
+ from scriptcast.export import _hex_rgba
160
+ assert _hex_rgba("#0000004d") == (0, 0, 0, 77)
161
+
162
+
163
+ def test_hex_rgba_uppercase():
164
+ from scriptcast.export import _hex_rgba
165
+ assert _hex_rgba("#FF0000") == (255, 0, 0, 255)
166
+
167
+
168
+ def test_hex_rgba_invalid_length_raises():
169
+ import pytest
170
+
171
+ from scriptcast.export import _hex_rgba
172
+ with pytest.raises(ValueError, match="_hex_rgba"):
173
+ _hex_rgba("#fff")
174
+
175
+
176
+ # ------------------------------------------------------------------ _resolve_margin_sides
177
+ def test_resolve_margin_sides_no_background():
178
+ from scriptcast.config import ThemeConfig
179
+ from scriptcast.export import _resolve_margin_sides
180
+ assert _resolve_margin_sides(ThemeConfig(background=None)) == (0, 0, 0, 0)
181
+
182
+
183
+ def test_resolve_margin_sides_with_background_auto():
184
+ from scriptcast.config import ThemeConfig
185
+ from scriptcast.export import _resolve_margin_sides
186
+ assert _resolve_margin_sides(ThemeConfig(background="#ff0000")) == (82, 82, 82, 82)
187
+
188
+
189
+ def test_resolve_margin_sides_explicit_override():
190
+ from scriptcast.config import ThemeConfig
191
+ from scriptcast.export import _resolve_margin_sides
192
+ config = ThemeConfig(
193
+ background="#ff0000",
194
+ margin_top=10, margin_right=20, margin_bottom=30, margin_left=20,
195
+ )
196
+ assert _resolve_margin_sides(config) == (10, 20, 30, 20)
197
+
198
+
199
+ def test_resolve_margin_sides_partial_override():
200
+ from scriptcast.config import ThemeConfig
201
+ from scriptcast.export import _resolve_margin_sides
202
+ config = ThemeConfig(background="#ff0000", margin_bottom=120)
203
+ t, r, b, left = _resolve_margin_sides(config)
204
+ assert t == 82 and r == 82 and b == 120 and left == 82
205
+
206
+
207
+ # ------------------------------------------------------------------ removal guards
208
+
209
+ # ------------------------------------------------------------------ _build_bg_shadow
210
+ def test_bg_shadow_none_background_transparent():
211
+ pytest.importorskip("PIL")
212
+ from scriptcast.config import ThemeConfig
213
+ from scriptcast.export import _build_bg_shadow, build_layout
214
+ config = ThemeConfig(background=None, shadow=False)
215
+ layout = build_layout(100, 50, config)
216
+ result = _build_bg_shadow(layout, config)
217
+ assert result.size == (layout.canvas_w, layout.canvas_h)
218
+ assert result.mode == "RGBA"
219
+ assert result.getpixel((layout.canvas_w // 2, layout.canvas_h // 2)) == (0, 0, 0, 0)
220
+
221
+
222
+ def test_bg_shadow_solid_color():
223
+ pytest.importorskip("PIL")
224
+ from scriptcast.config import ThemeConfig
225
+ from scriptcast.export import _build_bg_shadow, build_layout
226
+ config = ThemeConfig(background="#ff0000", shadow=False)
227
+ layout = build_layout(100, 50, config)
228
+ result = _build_bg_shadow(layout, config)
229
+ r, g, b, a = result.getpixel((layout.canvas_w // 2, layout.canvas_h // 2))
230
+ assert r == 255 and g == 0 and b == 0 and a == 255
231
+
232
+
233
+ def test_bg_shadow_gradient_left_color1():
234
+ pytest.importorskip("PIL")
235
+ from scriptcast.config import ThemeConfig
236
+ from scriptcast.export import _build_bg_shadow, build_layout
237
+ config = ThemeConfig(background="#ff0000,#0000ff", shadow=False)
238
+ layout = build_layout(100, 50, config)
239
+ result = _build_bg_shadow(layout, config)
240
+ r, g, b, a = result.getpixel((0, layout.canvas_h // 2))
241
+ assert r == 255 and b == 0
242
+
243
+
244
+ def test_bg_shadow_gradient_right_color2():
245
+ pytest.importorskip("PIL")
246
+ from scriptcast.config import ThemeConfig
247
+ from scriptcast.export import _build_bg_shadow, build_layout
248
+ config = ThemeConfig(background="#ff0000,#0000ff", shadow=False)
249
+ layout = build_layout(100, 50, config)
250
+ result = _build_bg_shadow(layout, config)
251
+ r, g, b, a = result.getpixel((layout.canvas_w - 1, layout.canvas_h // 2))
252
+ assert b == 255 and r == 0
253
+
254
+
255
+ def test_bg_shadow_gradient_too_many_stops_raises():
256
+ pytest.importorskip("PIL")
257
+ from scriptcast.config import ThemeConfig
258
+ from scriptcast.export import _build_bg_shadow, build_layout
259
+ config = ThemeConfig(background="#ff0000,#00ff00,#0000ff", shadow=False)
260
+ layout = build_layout(100, 50, config)
261
+ with pytest.raises(ValueError, match="2 color stops"):
262
+ _build_bg_shadow(layout, config)
263
+
264
+
265
+ def test_bg_shadow_adds_alpha_near_window():
266
+ pytest.importorskip("PIL")
267
+ from PIL import Image
268
+
269
+ from scriptcast.config import ThemeConfig
270
+ from scriptcast.export import _build_bg_shadow, build_layout
271
+ config = ThemeConfig(
272
+ background="#ffffff",
273
+ shadow=True, shadow_radius=5, shadow_offset_y=10, shadow_offset_x=0,
274
+ shadow_color="#000000ff", radius=0,
275
+ padding_left=14, padding_right=14, padding_top=14, padding_bottom=14,
276
+ margin_top=40, margin_right=40, margin_bottom=40, margin_left=40,
277
+ border_width=0,
278
+ )
279
+ layout = build_layout(100, 50, config)
280
+ plain = Image.new("RGBA", (layout.canvas_w, layout.canvas_h), (255, 255, 255, 255))
281
+ result = _build_bg_shadow(layout, config)
282
+ assert result.tobytes() != plain.tobytes()
283
+
284
+
285
+ def test_bg_shadow_disabled_no_change():
286
+ pytest.importorskip("PIL")
287
+ from scriptcast.config import ThemeConfig
288
+ from scriptcast.export import _build_bg_shadow, build_layout
289
+ config = ThemeConfig(background="#aabbcc", shadow=False)
290
+ layout = build_layout(100, 50, config)
291
+ r1 = _build_bg_shadow(layout, config)
292
+ r2 = _build_bg_shadow(layout, config)
293
+ assert r1.tobytes() == r2.tobytes()
294
+
295
+
296
+ # ------------------------------------------------------------------ _preprocess_frames
297
+ def test_preprocess_frames_padded_size():
298
+ pytest.importorskip("PIL")
299
+ from PIL import Image
300
+
301
+ from scriptcast.config import ThemeConfig
302
+ from scriptcast.export import _preprocess_frames
303
+ frame = Image.new("RGBA", (100, 50), (40, 42, 54, 255))
304
+ config = ThemeConfig(padding_left=10, padding_right=10, padding_top=5, padding_bottom=5)
305
+ padded, bg = _preprocess_frames([frame], config)
306
+ assert padded[0].size == (120, 60) # 10+100+10 wide, 5+50+5 tall
307
+
308
+
309
+ def test_preprocess_frames_detects_bg_from_top_center():
310
+ pytest.importorskip("PIL")
311
+ from PIL import Image
312
+
313
+ from scriptcast.config import ThemeConfig
314
+ from scriptcast.export import _preprocess_frames
315
+ frame = Image.new("RGBA", (100, 50), (0, 0, 0, 255))
316
+ frame.putpixel((50, 1), (40, 42, 54, 255)) # distinctive colour at sample point
317
+ config = ThemeConfig(padding_left=0, padding_right=0, padding_top=0, padding_bottom=0)
318
+ _, bg = _preprocess_frames([frame], config)
319
+ assert bg == (40, 42, 54)
320
+
321
+
322
+ def test_preprocess_frames_transparent_corners_show_bg():
323
+ pytest.importorskip("PIL")
324
+ from PIL import Image
325
+
326
+ from scriptcast.config import ThemeConfig
327
+ from scriptcast.export import _preprocess_frames
328
+ w, h = 50, 30
329
+ frame = Image.new("RGBA", (w, h), (255, 255, 255, 255))
330
+ frame.putpixel((0, 0), (0, 0, 0, 0)) # transparent top-left (like agg)
331
+ bg_color = (40, 42, 54)
332
+ frame.putpixel((w // 2, 1), (*bg_color, 255)) # set the bg-sample pixel
333
+ config = ThemeConfig(padding_left=5, padding_right=5, padding_top=5, padding_bottom=5)
334
+ padded_frames, _ = _preprocess_frames([frame], config)
335
+ padded = padded_frames[0]
336
+ # (pad_left + 0, pad_top + 0) = (5, 5) — transparent corner must not overwrite bg
337
+ assert padded.getpixel((5, 5))[:3] == bg_color
338
+
339
+
340
+ def test_preprocess_frames_padding_filled_with_bg():
341
+ pytest.importorskip("PIL")
342
+ from PIL import Image
343
+
344
+ from scriptcast.config import ThemeConfig
345
+ from scriptcast.export import _preprocess_frames
346
+ w, h = 50, 30
347
+ frame = Image.new("RGBA", (w, h), (255, 255, 255, 255))
348
+ bg_color = (40, 42, 54)
349
+ frame.putpixel((w // 2, 1), (*bg_color, 255))
350
+ config = ThemeConfig(padding_left=10, padding_right=10, padding_top=10, padding_bottom=10)
351
+ padded_frames, _ = _preprocess_frames([frame], config)
352
+ padded = padded_frames[0]
353
+ assert padded.getpixel((0, 0))[:3] == bg_color # outer corner is padding bg
354
+ assert padded.getpixel((5, 5))[:3] == bg_color # still in padding region
355
+
356
+
357
+ # ------------------------------------------------------------------ _build_chrome
358
+ def test_chrome_returns_tuple():
359
+ pytest.importorskip("PIL")
360
+ from scriptcast.config import ThemeConfig
361
+ from scriptcast.export import _build_chrome, build_layout
362
+ config = ThemeConfig(background=None, shadow=False, border_width=0)
363
+ layout = build_layout(100, 50, config)
364
+ result = _build_chrome(layout, config)
365
+ assert isinstance(result, tuple) and len(result) == 2
366
+
367
+
368
+ def test_chrome_image_is_rgba_correct_size():
369
+ pytest.importorskip("PIL")
370
+ from scriptcast.config import ThemeConfig
371
+ from scriptcast.export import _build_chrome, build_layout
372
+ config = ThemeConfig(background=None, shadow=False, border_width=0)
373
+ layout = build_layout(100, 50, config)
374
+ chrome, mask = _build_chrome(layout, config)
375
+ assert chrome.mode == "RGBA"
376
+ assert chrome.size == (layout.canvas_w, layout.canvas_h)
377
+
378
+
379
+ def test_chrome_mask_is_L_mode_correct_size():
380
+ pytest.importorskip("PIL")
381
+ from scriptcast.config import ThemeConfig
382
+ from scriptcast.export import _build_chrome, build_layout
383
+ config = ThemeConfig(background=None, shadow=False, border_width=0)
384
+ layout = build_layout(100, 50, config)
385
+ chrome, mask = _build_chrome(layout, config)
386
+ assert mask.mode == "L"
387
+ assert mask.size == (layout.canvas_w, layout.canvas_h)
388
+
389
+
390
+ def test_chrome_content_area_has_window_bg_color():
391
+ pytest.importorskip("PIL")
392
+ from scriptcast.config import ThemeConfig
393
+ from scriptcast.export import _build_chrome, build_layout
394
+ config = ThemeConfig(background=None, shadow=False, border_width=0, frame_bar=True)
395
+ layout = build_layout(100, 50, config)
396
+ window_bg = (20, 20, 40) # arbitrary test colour
397
+ chrome, mask = _build_chrome(layout, config, window_bg=window_bg)
398
+ cx = layout.content_x + layout.content_w // 2
399
+ cy = layout.content_y + layout.content_h // 2
400
+ assert chrome.getpixel((cx, cy))[:3] == window_bg
401
+
402
+
403
+ def test_chrome_outside_window_is_transparent():
404
+ pytest.importorskip("PIL")
405
+ from scriptcast.config import ThemeConfig
406
+ from scriptcast.export import _build_chrome, build_layout
407
+ config = ThemeConfig(background=None, shadow=False, border_width=0,
408
+ margin_top=40, margin_left=40, margin_right=40, margin_bottom=40)
409
+ layout = build_layout(100, 50, config)
410
+ chrome, mask = _build_chrome(layout, config)
411
+ assert chrome.getpixel((0, 0))[3] == 0
412
+
413
+
414
+ def test_chrome_window_bg_area_is_opaque():
415
+ pytest.importorskip("PIL")
416
+ from scriptcast.config import ThemeConfig
417
+ from scriptcast.export import _build_chrome, build_layout
418
+ config = ThemeConfig(background=None, shadow=False, border_width=0,
419
+ frame_bar=False, padding_left=20, padding_right=20,
420
+ padding_top=20, padding_bottom=20)
421
+ layout = build_layout(100, 50, config)
422
+ chrome, mask = _build_chrome(layout, config)
423
+ px = int(layout.window_x) + 5
424
+ py = layout.content_y + layout.content_h // 2
425
+ assert chrome.getpixel((px, py))[3] == 255
426
+
427
+
428
+ def test_chrome_mask_content_center_is_255():
429
+ pytest.importorskip("PIL")
430
+ from scriptcast.config import ThemeConfig
431
+ from scriptcast.export import _build_chrome, build_layout
432
+ config = ThemeConfig(background=None, shadow=False, border_width=0)
433
+ layout = build_layout(100, 50, config)
434
+ chrome, mask = _build_chrome(layout, config)
435
+ cx = layout.content_x + layout.content_w // 2
436
+ cy = layout.content_y + layout.content_h // 2
437
+ assert mask.getpixel((cx, cy)) == 255
438
+
439
+
440
+ def test_chrome_mask_outside_content_is_0():
441
+ pytest.importorskip("PIL")
442
+ from scriptcast.config import ThemeConfig
443
+ from scriptcast.export import _build_chrome, build_layout
444
+ config = ThemeConfig(background=None, shadow=False, border_width=0,
445
+ margin_top=40, margin_left=40, margin_right=40, margin_bottom=40)
446
+ layout = build_layout(100, 50, config)
447
+ chrome, mask = _build_chrome(layout, config)
448
+ # Top-left corner of canvas is well outside the content area
449
+ assert mask.getpixel((0, 0)) == 0
450
+
451
+
452
+ def test_chrome_mask_top_corners_square_with_title_bar():
453
+ """When there is a title bar the top corners of the content area must be square."""
454
+ pytest.importorskip("PIL")
455
+ from scriptcast.config import ThemeConfig
456
+ from scriptcast.export import _build_chrome, build_layout
457
+ config = ThemeConfig(background=None, shadow=False, border_width=0,
458
+ frame_bar=True, radius=10)
459
+ layout = build_layout(200, 100, config)
460
+ chrome, mask = _build_chrome(layout, config)
461
+ # Top-left pixel of the content rect should be in the mask (255), not rounded away
462
+ assert mask.getpixel((layout.content_x, layout.content_y)) == 255
463
+ # Top-right
464
+ assert mask.getpixel((layout.content_x + layout.content_w - 1, layout.content_y)) == 255
465
+
466
+
467
+ def test_chrome_mask_top_corners_rounded_without_title_bar_and_no_padding():
468
+ """When content is flush with window top (no title bar, no top padding) top corners round."""
469
+ pytest.importorskip("PIL")
470
+ from scriptcast.config import ThemeConfig
471
+ from scriptcast.export import _build_chrome, build_layout
472
+ config = ThemeConfig(background=None, shadow=False, border_width=0,
473
+ frame_bar=False, padding_top=0, padding_left=0,
474
+ padding_right=0, padding_bottom=0, radius=10)
475
+ layout = build_layout(200, 100, config)
476
+ # content_y == window_y when no title bar and no top padding
477
+ assert layout.content_y == int(layout.window_y)
478
+ chrome, mask = _build_chrome(layout, config)
479
+ # The very top-left pixel should be rounded away (0), not square
480
+ assert mask.getpixel((layout.content_x, layout.content_y)) == 0
481
+
482
+
483
+ def test_chrome_title_bar_absent_when_frame_bar_false():
484
+ pytest.importorskip("PIL")
485
+ from scriptcast.config import ThemeConfig
486
+ from scriptcast.export import _build_chrome, build_layout
487
+ config_on = ThemeConfig(background=None, shadow=False, border_width=0, frame_bar=True)
488
+ config_off = ThemeConfig(background=None, shadow=False, border_width=0, frame_bar=False)
489
+ layout_on = build_layout(100, 50, config_on)
490
+ layout_off = build_layout(100, 50, config_off)
491
+ chrome_on, _ = _build_chrome(layout_on, config_on)
492
+ chrome_off, _ = _build_chrome(layout_off, config_off)
493
+ assert chrome_off.size[1] < chrome_on.size[1]
494
+
495
+
496
+ def test_chrome_no_traffic_lights_when_buttons_false():
497
+ pytest.importorskip("PIL")
498
+ from scriptcast.config import ThemeConfig
499
+ from scriptcast.export import _build_chrome, build_layout
500
+ config_yes = ThemeConfig(background=None, shadow=False, border_width=0,
501
+ frame_bar=True, frame_bar_buttons=True)
502
+ config_no = ThemeConfig(background=None, shadow=False, border_width=0,
503
+ frame_bar=True, frame_bar_buttons=False)
504
+ layout = build_layout(200, 100, config_yes)
505
+ chrome_yes, _ = _build_chrome(layout, config_yes)
506
+ chrome_no, _ = _build_chrome(layout, config_no)
507
+ assert chrome_yes.tobytes() != chrome_no.tobytes()
508
+
509
+
510
+ # ------------------------------------------------------------------ Watermarks
511
+ def test_watermark_none_returns_unchanged():
512
+ pytest.importorskip("PIL")
513
+ from PIL import Image
514
+
515
+ from scriptcast.config import ThemeConfig
516
+ from scriptcast.export import _apply_watermark
517
+ base = Image.new("RGBA", (200, 200), (20, 20, 20, 255))
518
+ result = _apply_watermark(base, ThemeConfig(watermark=None))
519
+ assert result.tobytes() == base.tobytes()
520
+
521
+
522
+ def test_watermark_text_modifies_image():
523
+ pytest.importorskip("PIL")
524
+ from PIL import Image
525
+
526
+ from scriptcast.config import ThemeConfig
527
+ from scriptcast.export import _apply_watermark
528
+ base = Image.new("RGBA", (200, 200), (20, 20, 20, 255))
529
+ result = _apply_watermark(base, ThemeConfig(watermark="hello", watermark_size=14))
530
+ assert result.tobytes() != base.tobytes()
531
+
532
+
533
+ def test_scriptcast_watermark_disabled_returns_unchanged():
534
+ pytest.importorskip("PIL")
535
+ from PIL import Image
536
+
537
+ from scriptcast.config import ThemeConfig
538
+ from scriptcast.export import _apply_scriptcast_watermark
539
+ base = Image.new("RGBA", (200, 200), (20, 20, 20, 255))
540
+ result = _apply_scriptcast_watermark(base, ThemeConfig(scriptcast_watermark=False))
541
+ assert result.tobytes() == base.tobytes()
542
+
543
+
544
+ def test_scriptcast_watermark_enabled_modifies_image():
545
+ pytest.importorskip("PIL")
546
+ from PIL import Image
547
+
548
+ from scriptcast.config import ThemeConfig
549
+ from scriptcast.export import _apply_scriptcast_watermark
550
+ base = Image.new("RGBA", (200, 200), (20, 20, 20, 255))
551
+ result = _apply_scriptcast_watermark(base, ThemeConfig(scriptcast_watermark=True))
552
+ assert result.tobytes() != base.tobytes()
553
+
554
+
555
+ # ------------------------------------------------------------------ apply_export
556
+ def _make_tiny_gif(path, width=40, height=20):
557
+ """Create a minimal 2-frame RGBA GIF for testing."""
558
+ from PIL import Image
559
+ f1 = Image.new("RGB", (width, height), (30, 30, 30))
560
+ f2 = Image.new("RGB", (width, height), (40, 40, 40))
561
+ f1.quantize(colors=256).save(
562
+ path, save_all=True, append_images=[f2.quantize(colors=256)],
563
+ duration=100, loop=0,
564
+ )
565
+
566
+
567
+ def test_apply_export_gif_produces_gif(tmp_path):
568
+ pytest.importorskip("PIL")
569
+ from scriptcast.config import ThemeConfig
570
+ from scriptcast.export import apply_export
571
+ gif = tmp_path / "out.gif"
572
+ _make_tiny_gif(gif, 40, 20)
573
+ config = ThemeConfig(background=None, shadow=False, border_width=0, frame_bar=False,
574
+ scriptcast_watermark=False)
575
+ apply_export(gif, config, format="gif")
576
+ assert gif.exists()
577
+ from PIL import Image
578
+ img = Image.open(gif)
579
+ assert img.format in ("GIF",)
580
+
581
+
582
+ def test_apply_export_png_produces_png_file(tmp_path):
583
+ pytest.importorskip("PIL")
584
+ from scriptcast.config import ThemeConfig
585
+ from scriptcast.export import apply_export
586
+ gif = tmp_path / "out.gif"
587
+ _make_tiny_gif(gif, 40, 20)
588
+ config = ThemeConfig(background=None, shadow=False, border_width=0, frame_bar=False,
589
+ scriptcast_watermark=False)
590
+ apply_export(gif, config, format="png")
591
+ png = tmp_path / "out.png"
592
+ assert png.exists()
593
+
594
+
595
+ def test_apply_export_with_frame_expands_canvas(tmp_path):
596
+ pytest.importorskip("PIL")
597
+ from PIL import Image
598
+
599
+ from scriptcast.config import ThemeConfig
600
+ from scriptcast.export import apply_export
601
+ gif = tmp_path / "out.gif"
602
+ _make_tiny_gif(gif, 40, 20)
603
+ config = ThemeConfig(
604
+ background=None, shadow=False, border_width=0,
605
+ padding_left=10, padding_right=10, padding_top=10, padding_bottom=10,
606
+ frame_bar=True, scriptcast_watermark=False,
607
+ )
608
+ apply_export(gif, config, format="gif")
609
+ result = Image.open(gif)
610
+ # Canvas must be larger than original content (40x20)
611
+ assert result.size[0] > 40
612
+ assert result.size[1] > 20
613
+
614
+
615
+ # ------------------------------------------------------------------ generate_export
616
+ def test_generate_export_calls_agg(tmp_path):
617
+ from unittest.mock import MagicMock, patch
618
+
619
+ from scriptcast.export import generate_export
620
+ cast_file = tmp_path / "scene.cast"
621
+ cast_file.write_text('{"version":2}\n')
622
+ with patch("scriptcast.export.subprocess.run") as mock_run:
623
+ mock_run.return_value = MagicMock(returncode=0)
624
+ with patch("scriptcast.export.shutil.which", return_value="/usr/bin/agg"):
625
+ result = generate_export(cast_file)
626
+ mock_run.assert_called_once()
627
+ args = mock_run.call_args[0][0]
628
+ assert "agg" in args[0]
629
+ assert str(cast_file) in args
630
+ assert result == tmp_path / "scene.gif"
631
+
632
+
633
+ def test_generate_export_missing_agg_raises():
634
+ from pathlib import Path
635
+ from unittest.mock import patch
636
+
637
+ import pytest
638
+
639
+ from scriptcast.export import AggNotFoundError, generate_export
640
+ with patch("shutil.which", return_value=None):
641
+ with pytest.raises(AggNotFoundError, match="agg"):
642
+ generate_export(Path("scene.cast"))
643
+
644
+
645
+ def test_generate_export_calls_apply_export_when_config_provided(tmp_path):
646
+ from unittest.mock import MagicMock, patch
647
+
648
+ from scriptcast.config import ThemeConfig
649
+ from scriptcast.export import generate_export
650
+ cast_file = tmp_path / "scene.cast"
651
+ cast_file.write_text('{"version":2}\n')
652
+ config = ThemeConfig()
653
+
654
+ def fake_run(*args, **kwargs):
655
+ # Write to the temp gif path that agg would write to (second arg)
656
+ from PIL import Image
657
+ temp_path = args[0][2] # The temp gif path is the third argument to agg
658
+ frame = Image.new("RGB", (80, 24), (30, 30, 30))
659
+ frame.save(temp_path, format="GIF")
660
+ return MagicMock(returncode=0)
661
+
662
+ with patch("scriptcast.export.shutil.which", return_value="/usr/bin/agg"):
663
+ with patch("scriptcast.export.subprocess.run", side_effect=fake_run):
664
+ with patch("scriptcast.export.apply_export") as mock_apply:
665
+ result = generate_export(cast_file, config)
666
+
667
+ # apply_export should be called once with config and format
668
+ mock_apply.assert_called_once()
669
+ call_args = mock_apply.call_args
670
+ assert call_args[0][1] == config
671
+ assert call_args[1]["format"] == "gif"
672
+ # Result should be the final gif path in the cast directory
673
+ assert result == tmp_path / "scene.gif"
674
+
675
+
676
+ def test_generate_export_skips_apply_export_when_no_config(tmp_path):
677
+ from unittest.mock import patch
678
+
679
+ from scriptcast.export import generate_export
680
+ cast_file = tmp_path / "scene.cast"
681
+ cast_file.write_text('{"version":2}\n')
682
+
683
+ with patch("shutil.which", return_value="/usr/bin/agg"):
684
+ with patch("subprocess.run"):
685
+ with patch("scriptcast.export.apply_export") as mock_apply:
686
+ try:
687
+ generate_export(cast_file)
688
+ except Exception:
689
+ pass
690
+
691
+ mock_apply.assert_not_called()
692
+
693
+
694
+ def test_generate_export_png_format_no_temp_files_in_cast_dir(tmp_path):
695
+ """format='png': final .png is in cast dir, no temp gifs or pngs left behind."""
696
+ from unittest.mock import patch
697
+
698
+ from scriptcast.config import ThemeConfig
699
+ from scriptcast.export import generate_export
700
+
701
+ cast_path = tmp_path / "demo.cast"
702
+ cast_path.write_text('{"version":2,"width":80,"height":24}\n')
703
+
704
+ def fake_agg(cmd, **kwargs):
705
+ from PIL import Image
706
+ frame = Image.new("RGB", (80, 24), (30, 30, 30))
707
+ frame.save(str(cmd[2]), format="GIF")
708
+
709
+ def fake_apply_export(gif_path, config, format, on_frame=None):
710
+ # Simulate what apply_export does: write .png next to the gif
711
+ from PIL import Image
712
+ frame = Image.new("RGBA", (80, 24), (30, 30, 30, 255))
713
+ out = gif_path.with_suffix(".png")
714
+ frame.save(str(out), format="PNG")
715
+
716
+ config = ThemeConfig(frame=True, scriptcast_watermark=False, shadow=False, background=None)
717
+ with patch("scriptcast.export.subprocess.run", side_effect=fake_agg), \
718
+ patch("scriptcast.export.shutil.which", return_value="/usr/bin/agg"), \
719
+ patch("scriptcast.export.apply_export", side_effect=fake_apply_export):
720
+ result = generate_export(cast_path, frame_config=config, format="png")
721
+
722
+ assert result == tmp_path / "demo.png"
723
+ assert (tmp_path / "demo.png").exists()
724
+ # No stray temp files in cast dir
725
+ assert not list(tmp_path.glob("*.gif"))
726
+ assert len(list(tmp_path.glob("*.png"))) == 1
727
+
728
+
729
+ # ------------------------------------------------------------------ CLI: export command
730
+ def _sc_content() -> str:
731
+ import json
732
+ return json.dumps({"version": 1, "width": 80, "height": 24,
733
+ "directive-prefix": "SC"}) + "\n"
734
+
735
+
736
+ def test_export_command_exists():
737
+ """The CLI help text describes export capability (no longer a subcommand)."""
738
+ from click.testing import CliRunner
739
+
740
+ from scriptcast.__main__ import cli
741
+ runner = CliRunner()
742
+ result = runner.invoke(cli, ["--help"])
743
+ assert result.exit_code == 0
744
+ assert "export" in result.output.lower()
745
+
746
+
747
+ def test_export_command_default_frame_passes_config(tmp_path):
748
+ from unittest.mock import patch
749
+
750
+ from click.testing import CliRunner
751
+
752
+ from scriptcast.__main__ import cli
753
+ from scriptcast.config import ThemeConfig
754
+
755
+ sc_file = tmp_path / "demo.sc"
756
+ sc_file.write_text(_sc_content())
757
+ fake_cast = tmp_path / "demo.cast"
758
+ fake_gif = tmp_path / "demo.gif"
759
+
760
+ runner = CliRunner()
761
+ with patch("scriptcast.__main__.generate_from_sc", return_value=[fake_cast]):
762
+ with patch("scriptcast.__main__.generate_export", return_value=fake_gif) as mock_exp:
763
+ with patch("scriptcast.__main__.apply_scriptcast_watermark"):
764
+ result = runner.invoke(cli, ["--output-dir", str(tmp_path), str(sc_file)])
765
+ assert result.exit_code == 0, result.output
766
+ assert isinstance(mock_exp.call_args[0][1], ThemeConfig)
767
+
768
+
769
+ def test_export_command_error_is_clean(tmp_path):
770
+ from unittest.mock import patch
771
+
772
+ from click.testing import CliRunner
773
+
774
+ from scriptcast.__main__ import cli
775
+
776
+ sc_file = tmp_path / "demo.sc"
777
+ sc_file.write_text(_sc_content())
778
+ fake_cast = tmp_path / "demo.cast"
779
+
780
+ runner = CliRunner()
781
+ with patch("scriptcast.__main__.generate_from_sc", return_value=[fake_cast]):
782
+ with patch(
783
+ "scriptcast.__main__.generate_export",
784
+ side_effect=RuntimeError("Pillow not installed"),
785
+ ):
786
+ result = runner.invoke(cli, ["--output-dir", str(tmp_path), str(sc_file)])
787
+ assert result.exit_code == 1
788
+ assert "Pillow not installed" in result.output
789
+
790
+
791
+ def test_apply_export_content_visible_in_content_area(tmp_path):
792
+ """Content pixels must appear at the content position in the output."""
793
+ pytest.importorskip("PIL")
794
+ from PIL import Image
795
+
796
+ from scriptcast.config import ThemeConfig
797
+ from scriptcast.export import apply_export, build_layout
798
+
799
+ # Create a GIF with a solid bright-red frame
800
+ gif = tmp_path / "red.gif"
801
+ f = Image.new("RGB", (80, 40), (255, 0, 0))
802
+ f.quantize(colors=256).save(gif, save_all=True, duration=100, loop=0)
803
+
804
+ config = ThemeConfig(
805
+ background=None, shadow=False, border_width=0,
806
+ frame_bar=False, padding_left=0, padding_right=0,
807
+ padding_top=0, padding_bottom=0,
808
+ scriptcast_watermark=False,
809
+ )
810
+ apply_export(gif, config, format="png")
811
+ png = tmp_path / "red.png"
812
+ result = Image.open(png).convert("RGBA")
813
+
814
+ layout = build_layout(80, 40, config)
815
+ cx = layout.content_x + layout.content_w // 2
816
+ cy = layout.content_y + layout.content_h // 2
817
+ r, g, b, a = result.getpixel((cx, cy))
818
+ # Content (red) must be visible at the content centre
819
+ assert r > 200 and g < 50 and b < 50
820
+
821
+
822
+ def test_apply_watermark_centered_in_margin():
823
+ """With margin_bottom set, watermark position differs from the no-margin default."""
824
+ pytest.importorskip("PIL")
825
+ from PIL import Image
826
+
827
+ from scriptcast.config import ThemeConfig
828
+ from scriptcast.export import _apply_watermark
829
+
830
+ img = Image.new("RGBA", (400, 300), (30, 30, 30, 255))
831
+ config = ThemeConfig(watermark="test", watermark_size=20)
832
+
833
+ result_with_margin = _apply_watermark(img, config, margin_bottom=82)
834
+ result_no_margin = _apply_watermark(img, config, margin_bottom=0)
835
+
836
+ assert result_with_margin.size == (400, 300)
837
+ assert result_no_margin.size == (400, 300)
838
+ # Different margin_bottom values must produce different watermark positions
839
+ assert result_with_margin.tobytes() != result_no_margin.tobytes()
840
+
841
+
842
+ def test_apply_export_png_format_writes_png_file(tmp_path):
843
+ """apply_export with format='png' writes a PNG file with RGBA (not quantized palette)."""
844
+ pytest.importorskip("PIL")
845
+ from PIL import Image
846
+
847
+ from scriptcast.config import ThemeConfig
848
+ from scriptcast.export import apply_export
849
+
850
+ # Create a minimal single-frame GIF
851
+ frame = Image.new("RGBA", (80, 24), (30, 30, 30, 255))
852
+ gif_path = tmp_path / "test.gif"
853
+ frame.convert("RGB").save(str(gif_path), format="GIF")
854
+
855
+ config = ThemeConfig(frame=False, scriptcast_watermark=False, shadow=False, background=None)
856
+ apply_export(gif_path, config, format="png")
857
+
858
+ # Should write to test.png
859
+ png_path = tmp_path / "test.png"
860
+ assert png_path.exists(), "format='png' should write .png file"
861
+
862
+ # The output should be RGBA PNG (not quantized palette mode P)
863
+ output = Image.open(png_path)
864
+ # format='png' must produce full RGBA, not a quantized palette mode
865
+ assert output.mode == "RGBA", f"Expected RGBA PNG but got mode {output.mode}"
866
+
867
+
868
+ def test_generate_export_no_temp_gif_left_in_cast_dir(tmp_path):
869
+ """Intermediate .gif from agg must not be left in the cast directory."""
870
+ from unittest.mock import patch
871
+
872
+ from scriptcast.export import generate_export
873
+
874
+ cast_path = tmp_path / "demo.cast"
875
+ cast_path.write_text('{"version":2,"width":80,"height":24}\n')
876
+
877
+ def fake_agg(cmd, check):
878
+ # Write a minimal gif to the path agg would write to (second arg)
879
+ from PIL import Image
880
+ frame = Image.new("RGB", (80, 24), (30, 30, 30))
881
+ frame.save(str(cmd[2]), format="GIF")
882
+
883
+ with patch("scriptcast.export.subprocess.run", side_effect=fake_agg), \
884
+ patch("scriptcast.export.shutil.which", return_value="/usr/bin/agg"):
885
+ generate_export(cast_path, frame_config=None, format="gif")
886
+
887
+ # Only the final .gif should exist; no stray temp files in cast dir
888
+ files_in_dir = list(tmp_path.glob("*.gif"))
889
+ assert len(files_in_dir) == 1
890
+ assert files_in_dir[0] == tmp_path / "demo.gif"
891
+
892
+
893
+ def test_generate_export_cleans_up_temp_gif_on_failure(tmp_path):
894
+ """Temp gif is cleaned up even when agg succeeds but processing fails."""
895
+ from unittest.mock import patch
896
+
897
+ from scriptcast.config import ThemeConfig
898
+ from scriptcast.export import generate_export
899
+
900
+ cast_path = tmp_path / "demo.cast"
901
+ cast_path.write_text('{"version":2,"width":80,"height":24}\n')
902
+
903
+ def fake_agg(cmd, check):
904
+ from PIL import Image
905
+ frame = Image.new("RGB", (80, 24), (30, 30, 30))
906
+ frame.save(str(cmd[2]), format="GIF")
907
+
908
+ config = ThemeConfig(frame=True, scriptcast_watermark=False, shadow=False, background=None)
909
+
910
+ with patch("scriptcast.export.subprocess.run", side_effect=fake_agg), \
911
+ patch("scriptcast.export.shutil.which", return_value="/usr/bin/agg"), \
912
+ patch("scriptcast.export.apply_export", side_effect=RuntimeError("boom")):
913
+ try:
914
+ generate_export(cast_path, frame_config=config, format="gif")
915
+ except RuntimeError:
916
+ pass
917
+
918
+ # No temp gifs should be left in the cast directory
919
+ assert not any(tmp_path.glob("*.gif"))
920
+
921
+
922
+ def test_apply_export_calls_on_frame(tmp_path):
923
+ from PIL import Image, ImageDraw
924
+
925
+ from scriptcast.config import ThemeConfig
926
+ from scriptcast.export import apply_export
927
+
928
+ # 2-frame GIF, solid dark background with slight variation
929
+ bg = (30, 30, 30)
930
+ frame1 = Image.new("RGB", (80, 24), color=bg)
931
+ frame2 = Image.new("RGB", (80, 24), color=bg)
932
+ # Add a tiny difference to frame2 so Pillow doesn't merge them
933
+ draw = ImageDraw.Draw(frame2)
934
+ draw.point((0, 0), fill=(31, 30, 30))
935
+ gif_path = tmp_path / "test.gif"
936
+ frame1.save(gif_path, save_all=True, append_images=[frame2], loop=0, duration=100)
937
+
938
+ calls = []
939
+
940
+ def on_frame(current, total):
941
+ calls.append((current, total))
942
+
943
+ config = ThemeConfig(
944
+ frame=False,
945
+ scriptcast_watermark=False,
946
+ background=None,
947
+ shadow=False,
948
+ frame_bar=False,
949
+ border_width=0,
950
+ margin_top=0, margin_right=0, margin_bottom=0, margin_left=0,
951
+ padding_top=0, padding_right=0, padding_bottom=0, padding_left=0,
952
+ )
953
+ apply_export(gif_path, config, format="gif", on_frame=on_frame)
954
+
955
+ assert len(calls) == 2
956
+ assert calls[0] == (1, 2)
957
+ assert calls[1] == (2, 2)
958
+
959
+
960
+ def test_generate_export_passes_on_frame_to_apply_export(tmp_path):
961
+ from unittest.mock import patch
962
+
963
+ from scriptcast.config import ThemeConfig
964
+ from scriptcast.export import generate_export
965
+
966
+ cast_path = tmp_path / "demo.cast"
967
+ cast_path.write_text("")
968
+ fake_gif = tmp_path / "fake.gif"
969
+ fake_gif.write_bytes(b"GIF89a") # placeholder
970
+
971
+ def on_frame(current, total):
972
+ pass
973
+
974
+ config = ThemeConfig()
975
+
976
+ with patch("scriptcast.export.subprocess.run"), \
977
+ patch("scriptcast.export.apply_export") as mock_apply, \
978
+ patch("scriptcast.export.tempfile.mkstemp", return_value=(0, str(fake_gif))), \
979
+ patch("scriptcast.export.os.close"), \
980
+ patch("scriptcast.export.shutil.move"), \
981
+ patch("scriptcast.export.shutil.which", return_value="/usr/bin/agg"):
982
+ generate_export(cast_path, frame_config=config, format="gif", on_frame=on_frame)
983
+
984
+ mock_apply.assert_called_once()
985
+ _, kwargs = mock_apply.call_args
986
+ assert kwargs.get("on_frame") is on_frame