from __future__ import annotations
import os
import re
import sys
import types
from pathlib import Path
import pytest
if os.name == 'nt':
pytest.skip(
'POSIX-only: the demo renderer needs a pty', allow_module_level=True
)
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
import scripts.render_demos as demos
def _has_bar(line: str) -> bool:
"""Return whether `line` contains a rendered ``|...|`` bar frame.
Matched against SGR-stripped text: the default bar's fill is colored
(the red->green gradient), and BAR_RE's inner group cannot match a
fill with escape codes woven through it.
"""
return bool(demos.BAR_RE.search(demos.ANSI_SGR_RE.sub('', line)))
def _bar_widths(frames: list[list[str]]) -> list[int]:
return [
len(match.group('inner'))
for frame in frames
for line in frame
if (match := demos.BAR_RE.search(demos.ANSI_SGR_RE.sub('', line)))
]
_RAW_ASSET_BASE = (
'https://raw.githubusercontent.com/wolph/python-progressbar/develop/'
'docs/_static'
)
def test_render_svg_escapes_terminal_text(tmp_path: Path) -> None:
output = tmp_path / 'demo.svg'
demos.render_svg(
output,
title='Demo',
frames=[
['<start>', 'progress 0%'],
['done & clean', 'progress 100%'],
],
)
text = output.read_text(encoding='utf-8')
assert '<start>' in text
assert 'done & clean' in text
assert '<animate' in text
def test_render_svg_colors_progress_segments(tmp_path: Path) -> None:
output = tmp_path / 'demo.svg'
demos.render_svg(
output,
title='Demo',
frames=[
[
'Build: 50% (1 of 2) |## | ETA: 0:00:00 loss=0.5',
'log: completed step 1',
],
],
)
text = output.read_text(encoding='utf-8')
assert 'class="terminal-label">Build:</tspan>' in text
assert 'class="terminal-percent">50%</tspan>' in text
assert 'class="terminal-bar-fill">##</tspan>' in text
assert 'class="terminal-bar-empty"> </tspan>' in text
assert 'class="terminal-postfix">loss=0.5</tspan>' in text
assert 'class="terminal-log">log:</tspan>' in text
def test_render_svg_preserves_actual_ansi_truecolor_segments(
tmp_path: Path,
) -> None:
output = tmp_path / 'demo.svg'
demos.render_svg(
output,
title='Demo',
frames=[
['\x1b[38;2;255;0;0m 0%\x1b[39m \x1b[38;2;0;255;0m100%\x1b[39m'],
],
)
text = output.read_text(encoding='utf-8')
assert 'style="fill: #ff0000"> 0%</tspan>' in text
assert 'style="fill: #00ff00">100%</tspan>' in text
assert 'class="terminal-percent"' not in text
assert '\x1b' not in text
def test_render_svg_preserves_basic_16_color_ansi_segments(
tmp_path: Path,
) -> None:
output = tmp_path / 'demo.svg'
demos.render_svg(
output,
title='Demo',
frames=[
[
(
'\x1b[31mred\x1b[39m \x1b[32mgreen\x1b[39m '
'\x1b[91mbright-red\x1b[39m'
)
],
],
)
text = output.read_text(encoding='utf-8')
assert 'style="fill: #800000">red</tspan>' in text
assert 'style="fill: #008000">green</tspan>' in text
assert 'style="fill: #ff0000">bright-red</tspan>' in text
assert '\x1b' not in text
def test_styled_terminal_line_keeps_spinner_pipe_outside_bar() -> None:
styled = demos.styled_terminal_line('| |# | 31 Elapsed Time: 0:00:00')
assert styled.startswith(
'| <tspan class="terminal-bar-frame">|</tspan>'
'<tspan class="terminal-bar-fill">#</tspan>'
)
spinner_bar = (
'terminal-bar-empty"> </tspan><tspan class="terminal-bar-frame">'
)
assert spinner_bar not in styled
def test_render_svg_first_frame_is_visible_without_animation(
tmp_path: Path,
) -> None:
output = tmp_path / 'demo.svg'
demos.render_svg(
output,
title='Demo',
frames=[
['first'],
['second'],
],
)
text = output.read_text(encoding='utf-8')
assert text.count('<g opacity="1">') == 1
assert text.count('<g opacity="0">') == 1
assert '<g opacity="1"><animate' in text
def test_render_svg_uses_fast_default_animation_pacing(
tmp_path: Path,
) -> None:
output = tmp_path / 'demo.svg'
demos.render_svg(output, title='Demo', frames=[['a'], ['b'], ['c']])
text = output.read_text(encoding='utf-8')
assert 'dur="0.24s"' in text
assert 'keyTimes' not in text
assert 'dur="3.6s"' not in text
assert '3.599999999' not in text
def test_render_svg_holds_the_final_frame_when_asked(
tmp_path: Path,
) -> None:
output = tmp_path / 'demo.svg'
demos.render_svg(
output,
title='Demo',
frames=[['a'], ['b'], ['c']],
frame_seconds=0.25,
end_hold_seconds=2.0,
)
text = output.read_text(encoding='utf-8')
assert 'dur="2.75s"' in text
assert 'keyTimes="0;0.0909091;0.181818" ' in text
def test_render_svg_uses_wide_canvas_for_readable_bars(
tmp_path: Path,
) -> None:
output = tmp_path / 'demo.svg'
demos.render_svg(output, title='Demo', frames=[['a']])
text = output.read_text(encoding='utf-8')
assert 'width="1080"' in text
assert 'viewBox="0 0 1080 96"' in text
def test_render_svg_labels_root_for_accessibility(tmp_path: Path) -> None:
output = tmp_path / 'demo.svg'
demos.render_svg(
output,
title='Demo Title',
frames=[['a'], ['b']],
description='A demo description.',
)
text = output.read_text(encoding='utf-8')
assert 'role="img"' in text
title_match = re.search(r'<title id="([^"]+)">Demo Title</title>', text)
desc_match = re.search(
r'<desc id="([^"]+)">A demo description\.</desc>', text
)
assert title_match is not None, text
assert desc_match is not None, text
assert (
f'aria-labelledby="{title_match.group(1)} {desc_match.group(1)}"'
in text
)
def test_render_svg_reduced_motion_rule_freezes_on_last_frame(
tmp_path: Path,
) -> None:
output = tmp_path / 'demo.svg'
demos.render_svg(output, title='Demo', frames=[['a'], ['b'], ['c']])
text = output.read_text(encoding='utf-8')
assert '@media (prefers-reduced-motion: reduce)' in text
assert 'animate { display: none; }' in text
assert 'g { display: none !important; opacity: 0 !important; }' in text
assert (
'g:last-of-type { display: inline !important; opacity: 1 !important; }'
) in text
def test_demo_description_strips_single_backtick_markup_from_real_docstring() -> (
None
):
demo = demos.DEMOS_BY_NAME['howto/custom-widget']
description = demos.demo_description(demo)
assert '`' not in description
assert 'WidgetBase' in description
def test_demo_description_strips_double_and_single_backtick_markup(
tmp_path: Path,
) -> None:
source = tmp_path / 'mixed_markup_demo.py'
source.write_text(
'"""``Widget`` reads `config` and writes `Output` files."""\n'
)
demo = types.SimpleNamespace(
name='mixed-markup-demo', title='Mixed', path=source
)
description = demos.demo_description(demo)
assert '`' not in description
assert description == 'Widget reads config and writes Output files.'
@pytest.mark.parametrize('demo', demos.DEMOS, ids=lambda demo: demo.name)
def test_every_registered_demo_module_has_a_docstring(
demo: demos.Demo,
) -> None:
assert demos.load_example(demo).__doc__
def test_normalize_terminal_line_keeps_timing_text_verbatim() -> None:
line = 'Current Time: 13:44:08 |####| Elapsed Time: 0:00:01 ETA: 0:00:02'
assert demos.normalize_terminal_line(line) == line
def test_normalize_terminal_line_strips_stray_cursor_control_codes() -> None:
assert demos.normalize_terminal_line('\x1b[1F') == ''
assert demos.normalize_terminal_line('\x1b[2Fbuild 10%\x1b[2E') == (
'build 10%'
)
def test_parse_frames_no_longer_rewrites_timing_text() -> None:
frames = demos.parse_frames(
'Build: 50% Elapsed Time: 0:01:23 ETA: 9:08:07\n'
'Build: 100% Elapsed Time: 0:00:04 Time: 2:03:04'
)
assert frames == [
['Build: 50% Elapsed Time: 0:01:23 ETA: 9:08:07'],
['Build: 100% Elapsed Time: 0:00:04 Time: 2:03:04'],
]
def test_parse_frames_splits_carriage_return_output() -> None:
frames = demos.parse_frames('zero\rone\ntwo\rthree')
assert frames == [['zero'], ['one'], ['two'], ['three']]
def test_limit_animation_frames_keeps_more_animation_states() -> None:
frames = demos.parse_frames(
'\n'.join(f'frame {index}' for index in range(30))
)
limited = demos.limit_animation_frames(frames, 24)
assert len(limited) == 24
assert limited[0] == ['frame 0']
assert limited[-1] == ['frame 29']
def test_dedupe_consecutive_frames_collapses_repeats_only() -> None:
frames = [['a'], ['a'], ['b'], ['b'], ['b'], ['a'], ['c']]
assert demos.dedupe_consecutive_frames(frames) == [
['a'],
['b'],
['a'],
['c'],
]
def test_dedupe_consecutive_frames_handles_empty_input() -> None:
assert demos.dedupe_consecutive_frames([]) == []
def test_parse_frames_groups_multibar_redraws_by_offset() -> None:
output = (
'\x1b[2Fbuild 10%\x1b[2E\x1b[1Ftest 5%\x1b[1E\x1b[2Fbuild 20%\x1b[2E'
)
frames = demos.parse_frames(output)
assert frames == [
['build 10%'],
['build 10%', 'test 5%'],
['build 20%', 'test 5%'],
]
def test_readme_demos_are_registered_in_display_order() -> None:
readme_demos = [
demo for demo in demos.DEMOS if demo.name.startswith('readme/')
]
assert [(demo.name, demo.title) for demo in readme_demos] == [
('readme/cli', 'progressbar data.bin -o copy.bin'),
('readme/colors', 'Gradients, colors and animated markers'),
('readme/hero', 'Progress with clean logs'),
('readme/multibar', 'Multiple active jobs'),
('readme/parallel', 'progressbar.map with per-task bars'),
('readme/unknown-length', 'Unknown length'),
]
@pytest.mark.parametrize('demo', demos.DEMOS, ids=lambda demo: demo.name)
def test_every_demo_animates_across_at_least_two_frames(
demo: demos.Demo,
) -> None:
frames = demos.capture_demo(demo)
assert len(frames) >= 2, (
f'{demo.name} produced only one distinct frame -- its animation '
'never visibly changes'
)
TIME_DERIVED_WIDGET_FIELDS = {
'widgets/timer': re.compile(r'Elapsed Time:\s*(\d+:\d{2}:\d{2})'),
'widgets/eta': re.compile(r'\bETA:\s*(\d+:\d{2}:\d{2})'),
'widgets/adaptive-eta': re.compile(r'\bETA:\s*(\d+:\d{2}:\d{2})'),
'widgets/smoothing-eta': re.compile(r'\bETA:\s*(\d+:\d{2}:\d{2})'),
'widgets/absolute-eta': re.compile(
r'Estimated finish time: (\d{4}-\d{2}-\d{2} \d+:\d{2}:\d{2})'
),
'widgets/current-time': re.compile(r'Current Time:\s*(\d+:\d{2}:\d{2})'),
}
@pytest.mark.parametrize(
'demo_name, field_re',
sorted(TIME_DERIVED_WIDGET_FIELDS.items()),
)
def test_time_derived_widget_reading_visibly_changes(
demo_name: str,
field_re: re.Pattern[str],
) -> None:
demo = demos.DEMOS_BY_NAME[demo_name]
frames = demos.capture_demo(demo)
lines = (
demos.ANSI_SGR_RE.sub('', line) for frame in frames for line in frame
)
values = {
match.group(1) for line in lines if (match := field_re.search(line))
}
assert len(values) >= 2, (
f'{demo_name}: its own time-derived reading never changes -- saw '
f'only {values!r} across {len(frames)} frames'
)
README_ASSETS = (
'readme-race.svg',
'readme-colors.svg',
'readme-hero.svg',
'readme-multibar.svg',
'readme-parallel.svg',
'readme-cli.svg',
'readme-unknown-length.svg',
)
def test_readme_uses_absolute_demo_asset_urls() -> None:
readme = (demos.ROOT / 'README.md').read_text(encoding='utf-8')
for asset in README_ASSETS:
assert f'{_RAW_ASSET_BASE}/demos/{asset}' in readme
assert '](docs/_static/' not in readme
assert 'src="docs/_static/' not in readme
assert 'progressbar-hero.svg' not in readme
assert 'progressbar-multibar.svg' not in readme
assert 'progressbar-unknown-length.svg' not in readme
assert 'progressbar-ergonomics.svg' not in readme
assert 'Tqdm-style ergonomic options' not in readme
def _readme_demo_block(example_name: str) -> str:
"""Return ``docs/examples/readme/{example_name}.py`` as a Markdown fence.
README.md is rendered standalone on PyPI -- no Sphinx, so no
``literalinclude`` -- meaning each ```` ```python ```` block is a
literal copy of the matching example file's current contents. This
must stay a copy checked by a test, not just a one-time paste:
nothing else would catch the block silently drifting out of sync the
next time someone edits the example but not the README.
"""
source = (
demos.ROOT / 'docs' / 'examples' / 'readme' / f'{example_name}.py'
).read_text(encoding='utf-8')
return f'```python\n{source}```'
@pytest.mark.parametrize(
'example_name',
['colors', 'hero', 'multibar', 'parallel', 'unknown_length'],
)
def test_readme_code_blocks_match_example_sources(example_name: str) -> None:
readme = (demos.ROOT / 'README.md').read_text(encoding='utf-8')
assert _readme_demo_block(example_name) in readme
def test_readme_omits_obsolete_gpg_release_verification() -> None:
readme = (demos.ROOT / 'README.md').read_text(encoding='utf-8')
assert 'Release verification' not in readme
assert 'GPG' not in readme
assert 'pgp.mit.edu' not in readme
assert '.tar.gz.asc' not in readme
def test_multibar_demo_captures_rendered_progressbar_output() -> None:
demo = demos.DEMOS_BY_NAME['readme/multibar']
frames = demos.capture_demo(demo)
text = '\n'.join(line for frame in frames for line in frame)
assert frames
assert 'build' in text
assert 'test' in text
assert 'Elapsed Time:' in text
assert '(24 of 24)' in text
assert any(
len(frame) == 2
and any('build' in line for line in frame)
and any('test' in line for line in frame)
for frame in frames
)
def test_multibar_demo_shows_both_bars_finishing() -> None:
demo = demos.DEMOS_BY_NAME['readme/multibar']
frames = demos.capture_demo(demo)
last_frame = '\n'.join(frames[-1])
assert 'build' in last_frame
assert 'test' in last_frame
assert last_frame.count('(24 of 24)') == 2
def test_colors_demo_captures_colored_multibar() -> None:
demo = demos.DEMOS_BY_NAME['readme/colors']
frames = demos.capture_demo(demo)
text = '\n'.join(line for frame in frames for line in frame)
assert 'download' in text
assert 'render' in text
assert 'scan' in text
assert '\x1b[38;' in text
assert any(len(frame) == 3 for frame in frames)
def test_parallel_demo_captures_total_and_task_bars() -> None:
demo = demos.DEMOS_BY_NAME['readme/parallel']
frames = demos.capture_demo(demo)
text = '\n'.join(line for frame in frames for line in frame)
assert frames
assert 'Total' in text
assert any(
len(frame) >= 2 and any('Total' in line for line in frame)
for frame in frames
)
assert '(8 of 8)' in '\n'.join(frames[-1])
def test_cli_demo_captures_pv_style_transfer() -> None:
demo = demos.DEMOS_BY_NAME['readme/cli']
frames = demos.capture_demo(demo)
text = '\n'.join(line for frame in frames for line in frame)
assert '%' in text
assert 'B/s' in text
assert 'iB' in text
assert any('100%' in line for line in frames[-1])
def test_capture_demo_reports_a_crashing_example_clearly(
tmp_path: Path,
) -> None:
script = tmp_path / 'broken.py'
script.write_text("raise RuntimeError('boom')\n", encoding='utf-8')
demo = types.SimpleNamespace(
name='broken-test',
path=script,
term_width=80,
log_lines=0,
max_frames=24,
)
with pytest.raises(SystemExit) as error:
demos.capture_demo(demo)
message = demos.ANSI_SGR_RE.sub('', str(error.value))
assert 'example failed: broken-test (exit code 1)' in message
assert 'RuntimeError' in message
assert 'boom' in message
def test_capture_demo_reports_a_hanging_example_clearly(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
script = tmp_path / 'hangs.py'
script.write_text(
'import time\nwhile True:\n time.sleep(3600)\n',
encoding='utf-8',
)
monkeypatch.setattr(demos, 'CAPTURE_TIMEOUT_SECONDS', 1.0)
demo = types.SimpleNamespace(
name='hang-test',
path=script,
term_width=80,
log_lines=0,
max_frames=24,
)
with pytest.raises(SystemExit) as error:
demos.capture_demo(demo)
assert str(error.value) == 'example hung: hang-test'
def test_hero_demo_keeps_recent_logs_visible_above_progress() -> None:
demo = demos.DEMOS_BY_NAME['readme/hero']
frames = demos.capture_demo(demo)
assert max(len(frame) for frame in frames) >= 3
assert any(
any(line.startswith('log: completed step 8') for line in frame)
and any('Build' in line for line in frame)
for frame in frames
)
def test_logging_integration_demo_keeps_recent_logs_visible_above_progress() -> (
None
):
demo = demos.DEMOS_BY_NAME['howto/logging-integration']
frames = demos.capture_demo(demo)
assert max(len(frame) for frame in frames) >= 2
assert any(
any('completed step 8' in line for line in frame)
and any(_has_bar(line) for line in frame)
for frame in frames
)
def test_redirect_stdout_demo_keeps_recent_prints_visible_above_progress() -> (
None
):
demo = demos.DEMOS_BY_NAME['howto/redirect-stdout']
frames = demos.capture_demo(demo)
assert max(len(frame) for frame in frames) >= 2
assert any(
any('Processing errors.log' in line for line in frame)
and any(_has_bar(line) for line in frame)
for frame in frames
)
def test_tutorial_step5_demo_keeps_recent_prints_visible_above_progress() -> (
None
):
demo = demos.DEMOS_BY_NAME['tutorial/step5']
frames = demos.capture_demo(demo)
assert max(len(frame) for frame in frames) >= 2
assert any(
any('Reached step 0' in line for line in frame)
and any(_has_bar(line) for line in frame)
for frame in frames
)
DEMOS_WITH_NON_LOG_PRINTS: frozenset[str] = frozenset(
{
'howto/multibar-line-offset',
}
)
_PRINT_OR_LOGGER_CALL_RE = re.compile(r'\bprint\(|\blogger\.\w+\(')
@pytest.mark.parametrize('demo', demos.DEMOS, ids=lambda demo: demo.name)
def test_demos_that_print_or_log_retain_it_or_are_explicitly_exempt(
demo: demos.Demo,
) -> None:
source = demo.path.read_text(encoding='utf-8')
if not _PRINT_OR_LOGGER_CALL_RE.search(source):
return
assert demo.log_lines > 0 or demo.name in DEMOS_WITH_NON_LOG_PRINTS, (
f'{demo.name} calls print()/logger.*() but has log_lines=0 and is '
'not in DEMOS_WITH_NON_LOG_PRINTS -- its printed output will '
'never appear together with the bar redraw (see '
'keep_recent_logs_with_progress). Set log_lines>0 in '
'docs/examples/_registry.py, or add this demo to '
'DEMOS_WITH_NON_LOG_PRINTS with a comment explaining why its '
'output is not meant to be retained.'
)
def test_hero_demo_shows_wide_determinate_progress_bar() -> None:
demo = demos.DEMOS_BY_NAME['readme/hero']
frames = demos.capture_demo(demo)
first_frame = '\n'.join(frames[0])
last_frame = '\n'.join(frames[-1])
text = '\n'.join(line for frame in frames for line in frame)
assert '0%' in first_frame
assert '100%' in last_frame
assert max(_bar_widths(frames)) >= 32
assert 'Elapsed Time:' in text
def test_readme_demos_capture_real_percentage_color_changes() -> None:
demo = demos.DEMOS_BY_NAME['readme/hero']
text = '\n'.join(
line for frame in demos.capture_demo(demo) for line in frame
)
assert '\x1b[38;2;255;0;0m' in text
assert '\x1b[38;2;0;255;0m' in text
def test_absolute_eta_demo_shows_a_deterministic_projected_finish_time() -> (
None
):
demo = demos.DEMOS_BY_NAME['widgets/absolute-eta']
frames = demos.capture_demo(demo)
text = '\n'.join(line for frame in frames for line in frame)
frozen_date = demos.CAPTURE_CLOCK_INSTANT.split('T')[0]
assert f'Estimated finish time: {frozen_date} ' in text
assert 'Estimated finish time: ----/--/-- --:--:--' in text
def test_current_time_demo_shows_a_frozen_plausible_clock_reading() -> None:
demo = demos.DEMOS_BY_NAME['widgets/current-time']
frames = demos.capture_demo(demo)
text = '\n'.join(line for frame in frames for line in frame)
clock_reading = demos.CAPTURE_CLOCK_INSTANT.split('T')[1]
assert f'Current Time: {clock_reading}' in text
assert '0:00:00' not in text
def test_bouncing_bar_demo_respects_narrow_term_width() -> None:
demo = demos.DEMOS_BY_NAME['widgets/bouncing-bar']
assert demo.term_width == 30
frames = demos.capture_demo(demo)
widths = [len(line) for frame in frames for line in frame]
assert widths
assert max(widths) <= demo.term_width
def test_two_captures_of_the_same_demo_are_byte_identical() -> None:
demo = demos.DEMOS_BY_NAME['readme/hero']
first = demos.svg_document(demo.title, demos.capture_demo(demo))
second = demos.svg_document(demo.title, demos.capture_demo(demo))
assert first == second
def test_main_rejects_unknown_only_name(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(
sys, 'argv', ['render_demos.py', '--only', 'no/such-demo']
)
with pytest.raises(SystemExit) as error:
demos.main()
assert 'unknown demo: no/such-demo' in str(error.value)
def test_check_mode_skips_and_reports_drift_check_false_demos(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
capsys: pytest.CaptureFixture[str],
) -> None:
demo = types.SimpleNamespace(
name='flaky-demo',
title='Flaky',
svg_path=tmp_path / 'never-written.svg',
drift_check=False,
)
def _must_not_be_called(_: object) -> list[list[str]]:
raise AssertionError('drift_check=False demo must not be captured')
monkeypatch.setattr(demos, 'DEMOS', [demo])
monkeypatch.setattr(demos, 'DEMOS_BY_NAME', {demo.name: demo})
monkeypatch.setattr(demos, 'capture_demo', _must_not_be_called)
monkeypatch.setattr(sys, 'argv', ['render_demos.py', '--check'])
demos.main()
assert not demo.svg_path.exists()
stderr = capsys.readouterr().err
assert 'not gating 1 demo' in stderr
assert demo.name in stderr
assert 'drift_check' in stderr
def test_render_mode_still_renders_drift_check_false_demos(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
output = tmp_path / 'flaky.svg'
source = tmp_path / 'flaky_demo.py'
source.write_text('"""A flaky demo, for test purposes only."""\n')
demo = types.SimpleNamespace(
name='flaky-demo',
title='Flaky',
svg_path=output,
drift_check=False,
path=source,
frame_seconds=0.08,
end_hold_seconds=0.0,
)
monkeypatch.setattr(demos, 'DEMOS_BY_NAME', {demo.name: demo})
monkeypatch.setattr(demos, 'capture_demo', lambda _: [['rendered']])
monkeypatch.setattr(sys, 'argv', ['render_demos.py', '--only', demo.name])
demos.main()
assert output.exists()
def test_check_mode_does_not_rewrite_mismatched_asset(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
output = tmp_path / 'stale.svg'
output.write_text('stale asset', encoding='utf-8')
source = tmp_path / 'fake_check_demo.py'
source.write_text('"""A fake check demo, for test purposes only."""\n')
demo = types.SimpleNamespace(
name='fake-check-demo',
title='Fake',
svg_path=output,
drift_check=True,
path=source,
frame_seconds=0.08,
end_hold_seconds=0.0,
)
monkeypatch.setattr(demos, 'DEMOS_BY_NAME', {demo.name: demo})
monkeypatch.setattr(demos, 'capture_demo', lambda _: [['fresh asset']])
monkeypatch.setattr(
sys, 'argv', ['render_demos.py', '--check', '--only', demo.name]
)
with pytest.raises(SystemExit) as error:
demos.main()
assert 'outdated generated asset' in str(error.value)
assert output.read_text(encoding='utf-8') == 'stale asset'