Files
2026-07-13 12:59:42 +08:00

154 lines
6.2 KiB
Python

"""Unit tests for runner.py error-handling behaviour.
Regression: when the JAR fails, the streaming branch already wrote the
JAR's stdout to the console live, so the except handler must not re-emit
the captured copy. The quiet branch, conversely, has not surfaced anything
yet and is allowed to print the captured streams — but only once
(``CalledProcessError.output`` and ``.stdout`` are the same attribute).
"""
import io
import subprocess
from unittest.mock import MagicMock
import pytest
from opendataloader_pdf import runner
class _FakeAsFile:
def __init__(self, path):
self._path = path
def __enter__(self):
return self._path
def __exit__(self, *_args):
return False
@pytest.fixture
def patched_jar(monkeypatch, tmp_path):
"""Bypass the real resources lookup so run_jar reaches subprocess."""
fake_jar = tmp_path / "opendataloader-pdf-cli.jar"
fake_jar.write_bytes(b"")
fake_traversable = MagicMock()
fake_traversable.joinpath = lambda *_a, **_kw: fake_jar
monkeypatch.setattr(runner.resources, "files", lambda _pkg: fake_traversable)
monkeypatch.setattr(runner.resources, "as_file", lambda p: _FakeAsFile(p))
def test_streaming_failure_does_not_duplicate_output(monkeypatch, capsys, patched_jar):
"""Streaming mode prints JAR output live; the except handler must not
re-emit the captured copy on stderr."""
jar_output = "Invalid page range format: '-10'\nusage: [options] ...\n"
fake_process = MagicMock()
fake_process.stdout = iter([jar_output])
fake_process.wait.return_value = 2
fake_process.__enter__ = lambda self: self
fake_process.__exit__ = lambda self, *_a: False
monkeypatch.setattr(runner.subprocess, "Popen", lambda *_a, **_kw: fake_process)
with pytest.raises(subprocess.CalledProcessError):
runner.run_jar(["--bogus"], quiet=False)
captured = capsys.readouterr()
# JAR text appears exactly once: the live streaming write.
assert "Invalid page range format" in captured.out
assert captured.out.count("usage: [options]") == 1
# The except handler did NOT re-emit the captured copy on stderr.
assert "Invalid page range format" not in captured.err
assert "usage: [options]" not in captured.err
# Meta info is still surfaced.
assert "Error running opendataloader-pdf CLI." in captured.err
assert "Return code: 2" in captured.err
def test_quiet_success_relays_stdout_but_not_stderr(monkeypatch, capsys, patched_jar):
"""Quiet mode must relay the JAR's stdout (--to-stdout payload, folder
summary line) to the caller while still suppressing the JAR's log
stream (stderr). Regression: --quiet + --to-stdout produced no output,
breaking pipe consumers."""
result = subprocess.CompletedProcess(
args=["java", "-jar", "fake.jar"],
returncode=0,
stdout="extracted text payload\n",
stderr="[INFO] java log noise\n",
)
monkeypatch.setattr(runner.subprocess, "run", MagicMock(return_value=result))
returned = runner.run_jar(["doc.pdf", "--quiet", "--to-stdout"], quiet=True)
captured = capsys.readouterr()
# Payload reaches the caller's stdout exactly once.
assert captured.out.count("extracted text payload") == 1
# The JAR's log stream stays suppressed (that is what quiet means).
assert "java log noise" not in captured.out
assert "java log noise" not in captured.err
# Return value is unchanged for library callers.
assert returned == "extracted text payload\n"
def test_quiet_relays_through_stdout_buffer_byte_path(monkeypatch, patched_jar):
"""The production CLI writes the relayed payload through
``sys.stdout.buffer`` (bytes), not the ``sys.stdout.write`` (str)
fallback. ``capsys`` replaces ``sys.stdout`` with an object whose
``.buffer`` semantics differ, so the sibling test only exercises the
str branch. This drives the real byte-relay path and asserts the
payload is encoded to UTF-8 and written exactly once, intact — covering
the ``encode("utf-8", "replace")`` step that the str branch skips.
"""
payload = "héllo 한글 payload\n" # non-ASCII: exercises the utf-8 encode
result = subprocess.CompletedProcess(
args=["java", "-jar", "fake.jar"],
returncode=0,
stdout=payload,
stderr="[INFO] java log noise\n",
)
monkeypatch.setattr(runner.subprocess, "run", MagicMock(return_value=result))
# Fake stdout with a real binary buffer, mirroring a genuine TextIOWrapper
# (hasattr(sys.stdout, "buffer") is True), so run_jar takes the byte path.
fake_stdout = MagicMock()
fake_stdout.buffer = io.BytesIO()
monkeypatch.setattr(runner.sys, "stdout", fake_stdout)
returned = runner.run_jar(["doc.pdf", "--quiet", "--to-stdout"], quiet=True)
written = fake_stdout.buffer.getvalue()
# Byte path was taken: payload written as UTF-8 exactly once, intact.
assert written == payload.encode("utf-8")
assert written.decode("utf-8") == payload
# The str fallback branch was NOT used.
fake_stdout.write.assert_not_called()
fake_stdout.buffer.flush() # buffer is flushed by run_jar; no error
# Return value is unchanged for library callers.
assert returned == payload
def test_quiet_failure_prints_captured_streams_once(monkeypatch, capsys, patched_jar):
"""Quiet mode captures output, so the except handler surfaces it — but
must avoid the old bug where Output and Stdout (aliases) both printed."""
error = subprocess.CalledProcessError(
returncode=2,
cmd=["java", "-jar", "fake.jar"],
output="captured stdout text",
stderr="captured stderr text",
)
monkeypatch.setattr(runner.subprocess, "run", MagicMock(side_effect=error))
with pytest.raises(subprocess.CalledProcessError):
runner.run_jar(["--bogus"], quiet=True)
err = capsys.readouterr().err
assert err.count("captured stdout text") == 1
assert err.count("captured stderr text") == 1
# The pre-fix code printed both "Output:" and "Stdout:" with the same text.
assert "Output:" not in err
assert "Stdout: captured stdout text" in err
assert "Stderr: captured stderr text" in err
assert "Error running opendataloader-pdf CLI." in err
assert "Return code: 2" in err