151 lines
7.0 KiB
Python
151 lines
7.0 KiB
Python
#!/usr/bin/env python3
|
|
"""Regression test for the stdout/stderr split of the production CLI.
|
|
|
|
Normal progress, startup/backend configuration, successful file outputs,
|
|
timings and cache summaries are normal informational output and must go to
|
|
stdout. Genuine warnings, errors and the Debug per-event direct-fallback
|
|
diagnostic go to stderr. A mixed success/error line such as
|
|
``Rendered ... (ok|write failed)`` must follow its outcome.
|
|
|
|
The checks capture the two streams separately so that a future change which
|
|
silently moves a normal message to stderr (or an error to stdout) is caught.
|
|
Small deterministic Minkowski scenes keep the runtime short.
|
|
"""
|
|
import os
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
from pathlib import Path
|
|
|
|
# Keep scratch data inside the pre-approved OpenCode scratch directory.
|
|
TMP_ROOT = Path('/tmp/opencode')
|
|
TMP_ROOT.mkdir(parents=True, exist_ok=True)
|
|
|
|
BUILD = Path(sys.argv[1] if len(sys.argv) > 1 else 'build/Release').resolve()
|
|
ENV = dict(os.environ, OMP_NUM_THREADS='4')
|
|
|
|
COMMON = ['--catalog', 'assets/sky_grid_5deg.csv', '--width', 16, '--height', 8,
|
|
'--fov-deg', 80, '--exposure', 1e-3, '--coarse-cell-pixels', 8,
|
|
'--refine-max-level', 0, '--psf-relative-tail', 1e-4]
|
|
|
|
|
|
def run(binary, *args, env=ENV):
|
|
return subprocess.run([str(binary), *map(str, args)], env=env,
|
|
capture_output=True, text=True)
|
|
|
|
|
|
def stderr_errors(result):
|
|
"""Non-advisory stderr lines.
|
|
|
|
A Debug build deliberately logs each direct PSF fallback from the active
|
|
splat worker; those ``Debug:`` lines are real diagnostics, not a misplaced
|
|
normal message, so they are excluded when the Debug startup banner is
|
|
present on stdout.
|
|
"""
|
|
lines = [line for line in result.stderr.splitlines() if line.strip()]
|
|
if 'Debug build:' in result.stdout:
|
|
lines = [line for line in lines if not line.startswith('Debug:')]
|
|
return lines
|
|
|
|
|
|
binary = BUILD / 'minkowski_sky'
|
|
assert binary.exists(), f'missing {binary}'
|
|
|
|
# The image extension follows the build's compiled writer, exactly as
|
|
# tests/test_camera_cli.py detects it from --help (a libpng build advertises
|
|
# `.png`, a PNG-less build advertises `.ppm`).
|
|
help_text = run(binary, '--help').stdout
|
|
ext = 'png' if '.png' in help_text else 'ppm'
|
|
|
|
with tempfile.TemporaryDirectory(prefix='gr-output-streams-',
|
|
dir=str(TMP_ROOT)) as directory:
|
|
tmp = Path(directory)
|
|
|
|
# 1) --help is informational: complete usage on stdout, nothing on stderr.
|
|
help_result = run(binary, '--help')
|
|
assert help_result.returncode == 0, help_result.stderr
|
|
assert 'Usage:' in help_result.stdout, help_result.stdout
|
|
assert help_result.stderr == '', help_result.stderr
|
|
|
|
# 2) An unknown option is an error: usage diagnostic on stderr only.
|
|
bad_result = run(binary, '--not-an-option')
|
|
assert bad_result.returncode != 0, bad_result.stdout
|
|
assert bad_result.stdout == '', bad_result.stdout
|
|
assert bad_result.stderr != '', 'missing error diagnostic on stderr'
|
|
|
|
# 3) A successful non-verbose single-frame render puts every startup,
|
|
# statistics and success line on stdout with an empty stderr.
|
|
single_out = tmp / f'single.{ext}'
|
|
single = run(binary, *COMMON, '--output', single_out)
|
|
assert single.returncode == 0, single.stderr
|
|
assert single_out.exists(), single.stderr
|
|
assert 'Blackbody backend:' in single.stdout, single.stdout
|
|
assert 'PSF cache ready:' in single.stdout, single.stdout
|
|
assert 'Rendered' in single.stdout and '(ok)' in single.stdout, single.stdout
|
|
assert '(write failed)' not in single.stdout, single.stdout
|
|
assert not stderr_errors(single), single.stderr
|
|
|
|
# 4) Verbose progress (including the trace-cost line) is still stdout.
|
|
verbose_out = tmp / f'verbose.{ext}'
|
|
verbose = run(binary, *COMMON, '--verbose', '--output', verbose_out)
|
|
assert verbose.returncode == 0, verbose.stderr
|
|
assert 'Frame 0: tracing' in verbose.stdout, verbose.stdout
|
|
assert 'Frame 0 trace cost:' in verbose.stdout, verbose.stdout
|
|
assert not stderr_errors(verbose), verbose.stderr
|
|
|
|
# 5) A short movie exercises the asynchronous writer; its per-frame logs,
|
|
# timing summary and writer summary are stdout, stderr stays empty. The
|
|
# 2 s fixture track plus --duration 1 --fps 1 yields two frames, so the
|
|
# async producer/writer overlap is actually exercised.
|
|
track = tmp / 'track.csv'
|
|
track_run = run(binary, '--write-minkowski-accel-track', track,
|
|
'--duration', 2, '--fps', 30, '--proper-acceleration', 1.52)
|
|
assert track_run.returncode == 0, track_run.stderr
|
|
assert track.exists(), track_run.stderr
|
|
frames_dir = tmp / 'frames'
|
|
frames_dir.mkdir()
|
|
movie = run(binary, *COMMON, '--observer-track', track, '--frames-dir',
|
|
frames_dir, '--frames-prefix', 'frame', '--duration', 1,
|
|
'--fps', 1, '--verbose', '--output', tmp / f'movie.{ext}')
|
|
assert movie.returncode == 0, movie.stderr
|
|
assert (frames_dir / f'frame_000000.{ext}').exists(), movie.stderr
|
|
assert (frames_dir / f'frame_000001.{ext}').exists(), movie.stderr
|
|
assert 'Ray trace generation' in movie.stdout, movie.stdout
|
|
assert movie.stdout.count('Rendered') == 2, movie.stdout
|
|
assert movie.stdout.count('(ok)') == 2, movie.stdout
|
|
assert 'Movie timing total' in movie.stdout, movie.stdout
|
|
assert 'Movie writer summary:' in movie.stdout, movie.stdout
|
|
assert 'Movie end-to-end wall:' in movie.stdout, movie.stdout
|
|
assert not stderr_errors(movie), movie.stderr
|
|
|
|
# 6) A genuine warning goes to stderr and does not disturb the success line
|
|
# on stdout. The fast-mode preview advisory is deterministic in the CPU
|
|
# build.
|
|
fast_out = tmp / f'fast.{ext}'
|
|
fast = run(binary, *COMMON, '--fast-mode', '--output', fast_out)
|
|
assert fast.returncode == 0, fast.stderr
|
|
assert 'Fast mode is a preview approximation' in fast.stderr, fast.stderr
|
|
assert fast.stdout.count('Rendered') == 1, fast.stdout
|
|
assert '(ok)' in fast.stdout, fast.stdout
|
|
# The only stderr content is the advisory: no normal line leaked across.
|
|
assert len(stderr_errors(fast)) == 1, fast.stderr
|
|
|
|
# 7) A failed write must route the mixed success/error line to stderr and
|
|
# leave stdout free of the success wording.
|
|
missing_dir = tmp / 'missing_subdir' / f'out.{ext}'
|
|
failed = run(binary, *COMMON, '--output', missing_dir)
|
|
assert failed.returncode != 0, failed.stdout
|
|
assert '(write failed)' in failed.stderr, failed.stderr
|
|
assert '(write failed)' not in failed.stdout, failed.stdout
|
|
|
|
# 8) A rejected camera velocity is an error on stderr, not stdout.
|
|
velocity = run(binary, *COMMON, '--observer-velocity', 10, 0, 0,
|
|
'--output', tmp / f'velocity.{ext}')
|
|
assert velocity.returncode != 0, velocity.stdout
|
|
assert 'not timelike' in velocity.stderr, velocity.stderr
|
|
# stdout may hold only the Debug startup banner; no render ran.
|
|
assert 'Rendered' not in velocity.stdout, velocity.stdout
|
|
|
|
print('output-stream checks passed: normal success stdout / diagnostics '
|
|
'stderr', flush=True)
|