Files
GR-raytracing/tests/test_output_streams.py

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)