From afb2c2d5b297c5aa28bcced0e3f90e207d799c2a Mon Sep 17 00:00:00 2001 From: historia Date: Fri, 28 Aug 2026 13:51:01 -0400 Subject: fix: remove backtrace from console output when using incorrect/incomplete cli flags --- app/tests/test_audiobook_cli.py | 91 +++++++++++++++++++++++++++++++++++++++++ app/tests/test_converter.py | 29 +++++++++++++ 2 files changed, 120 insertions(+) (limited to 'app/tests') diff --git a/app/tests/test_audiobook_cli.py b/app/tests/test_audiobook_cli.py index e4b558b..b06a715 100644 --- a/app/tests/test_audiobook_cli.py +++ b/app/tests/test_audiobook_cli.py @@ -11,6 +11,7 @@ temporary directories. import contextlib import io +import logging import shutil import sys import tempfile @@ -308,5 +309,95 @@ class PreflightOverrideTests(unittest.TestCase): self.assertEqual((book_files, planned), ([], [])) +class FatalErrorReportingTests(unittest.TestCase): + """convert() reports failures once; tracebacks stay in the log file. + + A RuntimeError/ValueError is an expected, user-facing failure (the + clients raise them with actionable hints): the console gets a single + [FATAL] line and the full traceback only lands in the dated log file. + Any other exception is an actual crash, so its traceback is shown too. + """ + + def setUp(self): + self.tmp = Path(tempfile.mkdtemp(prefix="audiobook_fatal_")) + self.addCleanup(shutil.rmtree, self.tmp, True) + self.book = _make_book(self.tmp) + self.log_dir = self.tmp / "logs" + # setup_logging opens the dated log file from this global. + patcher = patch.object(converter_mod, "LOGS_FOLDER", self.log_dir) + patcher.start() + self.addCleanup(patcher.stop) + self.addCleanup(self._reset_logging) + # convert() repoints the converter module's folder globals; restore + # them so other tests keep seeing the configured folders. + self._old_folders = (converter_mod.BOOKS_FOLDER, + converter_mod.AUDIOBOOKS_FOLDER) + self.addCleanup(self._restore_folders) + + def _reset_logging(self): + root = logging.getLogger() + for handler in list(root.handlers): + root.removeHandler(handler) + handler.close() + logging.getLogger("converter").setLevel(logging.INFO) + + def _restore_folders(self): + converter_mod.BOOKS_FOLDER, converter_mod.AUDIOBOOKS_FOLDER = \ + self._old_folders + + def _convert(self, exc, progress=None): + """Run convert() with the converter class raising EXC in __init__. + + Returns (exit code, stdout, stderr); setup_logging runs for real + with LOGS_FOLDER pointed at the temporary directory, so the dated + log file's contents can be asserted on. + """ + preflight = MagicMock( + return_value=([self.book], [(self.book, "dune")])) + fake_class = MagicMock(side_effect=exc) + fake_class.preflight_overwrites = preflight + out, err = io.StringIO(), io.StringIO() + with patch.object(audiobook, "setup_directories"), \ + contextlib.redirect_stdout(out), \ + contextlib.redirect_stderr(err), \ + patch.object(audiobook, "AudiobookConverter", fake_class): + code = audiobook.convert(backend="audiocpp", progress=progress) + return code, out.getvalue(), err.getvalue() + + def _log_text(self) -> str: + (log_path,) = self.log_dir.glob("audiobook_*.log") + return log_path.read_text(encoding="utf-8") + + def test_expected_failure_shows_one_friendly_line(self): + message = ("The audio.cpp model 'Qwen3-TTS-12Hz-1.7B-Base-GGUF' " + "(family 'qwen3_tts') has no built-in speakers " + "(see README).") + code, out, err = self._convert(RuntimeError(message)) + self.assertEqual(code, 1) + self.assertEqual(out.count("[FATAL]"), 1) + self.assertIn(f"[FATAL] Fatal error: {message}", out) + self.assertNotIn("Traceback (most recent call last)", out) + self.assertNotIn("Traceback (most recent call last)", err) + log_text = self._log_text() + self.assertIn("Traceback (most recent call last):", log_text) + self.assertIn(f"RuntimeError: {message}", log_text) + + def test_expected_failure_reports_error_event(self): + events = [] + code, _, _ = self._convert(ValueError("bad input"), + progress=events.append) + self.assertEqual(code, 1) + self.assertEqual(events, + [{"kind": "error", "message": "bad input"}]) + + def test_unexpected_crash_also_shows_traceback(self): + code, out, err = self._convert(TypeError("boom")) + self.assertEqual(code, 1) + self.assertEqual(out.count("[FATAL]"), 1) + self.assertIn("Traceback (most recent call last)", err) + self.assertIn("TypeError: boom", err) + self.assertIn("Traceback (most recent call last)", self._log_text()) + + if __name__ == "__main__": unittest.main() diff --git a/app/tests/test_converter.py b/app/tests/test_converter.py index e04d3fe..4d7a064 100644 --- a/app/tests/test_converter.py +++ b/app/tests/test_converter.py @@ -2,10 +2,12 @@ import io import logging +import logging_kit import tempfile import time import unittest from contextlib import redirect_stdout +from datetime import datetime from pathlib import Path from unittest.mock import MagicMock, patch @@ -391,6 +393,33 @@ class SetupLoggingTests(unittest.TestCase): "Chunk 1/1 request text", None, None) self.assertTrue(console.filter(chunk_record)) + def test_traceback_logger_filtered_from_console_only(self): + setup_logging(debug=True) + console = self._console_handler() + traceback_record = logging.LogRecord( + logging_kit.TRACEBACK_LOGGER, logging.ERROR, "app", 1, + "Traceback (most recent call last): ...", None, None) + self.assertFalse(console.filter(traceback_record)) + + def test_log_traceback_reaches_file_but_not_console(self): + setup_logging() + console = self._console_handler() + console_stream = io.StringIO() + original = console.setStream(console_stream) + try: + try: + raise RuntimeError("boom") + except RuntimeError: + logging_kit.log_traceback() + finally: + console.setStream(original) + self.assertNotIn("Traceback", console_stream.getvalue()) + self.assertNotIn("boom", console_stream.getvalue()) + log_text = (Path(self._tmp.name) / "audiobook_" + f"{datetime.now():%Y%m%d}.log").read_text(encoding="utf-8") + self.assertIn("Traceback (most recent call last):", log_text) + self.assertIn("RuntimeError: boom", log_text) + class SynthesizeChunkLoggingTests(unittest.TestCase): """Chunk failures surface as a single ERROR record (no print echo).""" -- cgit v1.2.3