Fix: Separate normal renderer output from diagnostics
This commit is contained in:
1 parent
69a8cb5647
commit
af8b83007f
11 files changed
+249
-90
No files matched your search
@@ -130,9 +130,9 @@ def endpoint_deviation(a, b):
|
||||
return mismatches, worst
|
||||
|
||||
|
||||
def trace_cost(stderr, label):
|
||||
def trace_cost(text, label):
|
||||
match = re.search(label + r' trace cost: accepted=(\d+) rejected=(\d+) '
|
||||
r'rhs=(\d+)', stderr)
|
||||
r'rhs=(\d+)', text)
|
||||
return None if match is None else match.groups()
|
||||
|
||||
|
||||
@@ -263,8 +263,8 @@ with tempfile.TemporaryDirectory(prefix='gr-adaptive-cli-',
|
||||
replay_hdr = replay_out.with_name(replay_out.stem + '_HDR.fits')
|
||||
assert dflt_out.with_name(dflt_out.stem + '_HDR.fits').read_bytes() \
|
||||
== replay_hdr.read_bytes()
|
||||
live_cost = trace_cost(dflt_run.stderr, 'Frame 0')
|
||||
replay_cost = trace_cost(replay_run.stderr, 'Imported map')
|
||||
live_cost = trace_cost(dflt_run.stdout, 'Frame 0')
|
||||
replay_cost = trace_cost(replay_run.stdout, 'Imported map')
|
||||
assert live_cost is not None and replay_cost is not None
|
||||
assert live_cost == replay_cost, (live_cost, replay_cost)
|
||||
|
||||
|
||||
@@ -150,7 +150,7 @@ with tempfile.TemporaryDirectory(prefix='gr-camera-cli-', dir='/tmp/opencode') a
|
||||
fast_path = tmp / f'minkowski_fast.{ext}'
|
||||
fast = run(binary, *common, '--fast-mode', '--fast-supersample', 2,
|
||||
'--output', fast_path)
|
||||
assert 'Fast FFTW:' in fast.stderr, fast.stderr
|
||||
assert 'Fast FFTW:' in fast.stdout, fast.stdout
|
||||
assert image_payload(fast_path)
|
||||
|
||||
# Equivalent independently specified and inferred camera geometry.
|
||||
@@ -227,8 +227,8 @@ with tempfile.TemporaryDirectory(prefix='gr-camera-cli-', dir='/tmp/opencode') a
|
||||
tmp / 'missing_catalog.csv', '--output', long_path,
|
||||
ok=False)
|
||||
assert 'Mesh overlay output path is too long' in too_long.stderr, too_long.stderr
|
||||
assert 'Blackbody backend' not in too_long.stderr, too_long.stderr
|
||||
assert 'PSF cache ready' not in too_long.stderr
|
||||
assert 'Blackbody backend' not in (too_long.stdout + too_long.stderr), too_long.stderr
|
||||
assert 'PSF cache ready' not in (too_long.stdout + too_long.stderr)
|
||||
|
||||
errors = [
|
||||
(['--observer-time'], None),
|
||||
@@ -294,7 +294,7 @@ with tempfile.TemporaryDirectory(prefix='gr-camera-cli-', dir='/tmp/opencode') a
|
||||
if message:
|
||||
assert message in result.stderr, result.stderr
|
||||
assert not missing_catalog.exists(), result.stderr
|
||||
assert 'PSF cache ready' not in result.stderr
|
||||
assert 'PSF cache ready' not in (result.stdout + result.stderr)
|
||||
|
||||
track = tmp / f'{backend}.csv'
|
||||
run(TESTDIR / f'test_observer_{backend}', track)
|
||||
@@ -397,8 +397,8 @@ with tempfile.TemporaryDirectory(prefix='gr-camera-cli-', dir='/tmp/opencode') a
|
||||
'--sensor-bloom-transfer', 0.5,
|
||||
'--output', bloom_output)
|
||||
assert image_payload(bloom_output) != baseline
|
||||
assert 'Sensor bloom:' in bloom_run.stderr, bloom_run.stderr
|
||||
report = bloom_run.stderr.split('Sensor bloom:', 1)[1].splitlines()[0]
|
||||
assert 'Sensor bloom:' in bloom_run.stdout, bloom_run.stdout
|
||||
report = bloom_run.stdout.split('Sensor bloom:', 1)[1].splitlines()[0]
|
||||
fields = dict(token.split('=', 1) for token in report.split() if '=' in token)
|
||||
assert int(fields['saturated']) > 0, report
|
||||
assert int(fields['iterations'].split('/')[0]) >= 1, report
|
||||
@@ -443,8 +443,8 @@ with tempfile.TemporaryDirectory(prefix='gr-camera-cli-', dir='/tmp/opencode') a
|
||||
'--movie-track-samples', '--frames-dir', tmp,
|
||||
'--frames-prefix', 'mixed', '--verbose',
|
||||
'--lens-map-output', parallel_map)
|
||||
assert 'Ray trace generation 1: frame 0 added' in result.stderr
|
||||
assert 'Ray trace generation 0: frame 1 added' not in result.stderr
|
||||
assert 'Ray trace generation 1: frame 0 added' in result.stdout
|
||||
assert 'Ray trace generation 0: frame 1 added' not in result.stdout
|
||||
for frame in range(2):
|
||||
image_payload(tmp / f'mixed_{frame:06d}.{ext}',
|
||||
dimensions=(64, 36), allow_black=True)
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
#!/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)
|
||||
Reference in new issue
Block a user