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.
- scriptcast/__init__.py +0 -0
- scriptcast/__main__.py +371 -0
- scriptcast/assets/__init__.py +0 -0
- scriptcast/assets/fonts/DMSans-Regular.ttf +0 -0
- scriptcast/assets/fonts/Pacifico.ttf +0 -0
- scriptcast/assets/themes/aurora.sh +20 -0
- scriptcast/assets/themes/dark.sh +19 -0
- scriptcast/assets/themes/light.sh +19 -0
- scriptcast/config.py +199 -0
- scriptcast/directives.py +444 -0
- scriptcast/export.py +595 -0
- scriptcast/generator.py +265 -0
- scriptcast/recorder.py +212 -0
- scriptcast/shell/__init__.py +20 -0
- scriptcast/shell/adapter.py +13 -0
- scriptcast/shell/bash.py +11 -0
- scriptcast/shell/zsh.py +11 -0
- scriptcast-0.1.0.dist-info/METADATA +21 -0
- scriptcast-0.1.0.dist-info/RECORD +33 -0
- scriptcast-0.1.0.dist-info/WHEEL +5 -0
- scriptcast-0.1.0.dist-info/entry_points.txt +2 -0
- scriptcast-0.1.0.dist-info/top_level.txt +2 -0
- tests/__init__.py +0 -0
- tests/test_cli.py +304 -0
- tests/test_config.py +400 -0
- tests/test_directives.py +606 -0
- tests/test_export.py +986 -0
- tests/test_generator.py +434 -0
- tests/test_integration.py +97 -0
- tests/test_recorder.py +462 -0
- tests/test_registry.py +57 -0
- tests/test_shell.py +34 -0
- tests/test_theme.py +204 -0
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
|