diff options
| -rw-r--r-- | app/tests/test_runview.py | 100 | ||||
| -rw-r--r-- | app/ui/runview.py | 25 |
2 files changed, 118 insertions, 7 deletions
diff --git a/app/tests/test_runview.py b/app/tests/test_runview.py index 6add83f..0d99a6a 100644 --- a/app/tests/test_runview.py +++ b/app/tests/test_runview.py @@ -90,6 +90,25 @@ class StateTransitionTests(_FakeTui, unittest.TestCase): "url": "http://x"}) self.assertEqual(view.server, "ready") + def test_running_event_escapes_starting_without_a_boot(self): + # Regression for the stuck "Status: starting" run view: when this + # run did not boot the server (already running locally, or remote) + # the worker's probe reports "running" — and the book events that + # follow must still move the panel to "processing". + view, _ = self.make_view() + view.handle_event({"kind": "running", "name": "audiocpp", + "url": "http://x"}) + self.assertEqual(view.server, "ready") + self.assertFalse(view.started_server) + view.handle_event({"kind": "book", "index": 1, "total": 1, + "name": "b"}) + self.assertEqual(view.phase, "convert") + self.assertEqual(view.server, "processing") + view.handle_event({"kind": "book_done", "name": "b", "ok": True}) + view.handle_event({"kind": "done", "ok": 1, "total": 1}) + self.assertEqual(view.phase, "done") + self.assertNotEqual(view.server, "starting") + def test_chunk_progress_updates(self): view, _ = self.make_view() view.handle_event({"kind": "book", "index": 1, "total": 2, @@ -225,6 +244,16 @@ class StateTransitionTests(_FakeTui, unittest.TestCase): view.handle_event({"kind": "server_down"}) self.assertEqual(view.server, "down") + def test_server_down_during_boot_marks_not_responding(self): + # The worker's boot-time probe of a server this run did not start: + # a target that never answers is reported "down" (not the eternal + # "starting"), and never offered for a stop (not started here). + view, _ = self.make_view() + view.handle_event({"kind": "server_down"}) + self.assertEqual(view.server, "down") + self.assertEqual(view.phase, "boot") + self.assertFalse(view.started_server) + def test_server_stopped_event_marks_server_stopped(self): view, _ = self.make_view() view.server = "stopping" @@ -308,7 +337,9 @@ class LogAppenderTests(_FakeTui, unittest.TestCase): tmp = tempfile.TemporaryDirectory() self.addCleanup(tmp.cleanup) log_path = os.path.join(tmp.name, "audiobook_20260828.log") - view, _ = self.make_view(log_path=log_path) + # server_url=None: no boot and no probe, so the run log test sees + # only the worker's own events. + view, _ = self.make_view(log_path=log_path, server_url=None) fake = types.ModuleType("audiobook") @@ -348,6 +379,24 @@ class RenderTests(_FakeTui, unittest.TestCase): self.assertIn("starting", text) self.assertIn("Esc or q: cancel", text) + def test_ready_without_autostart_shows_the_not_started_tag(self): + # A run that talks to a server it did not start (already running + # locally, or remote) tags the ready state so the panel does not + # imply this run booted it. + view, screen = self.make_view() + view.handle_event({"kind": "running", "name": "audiocpp", + "url": "http://x"}) + view.render() + self.assertIn("ready (not started by this run)", self._strings(screen)) + + def test_not_responding_message_shown_while_down(self): + view, screen = self.make_view() + view.handle_event({"kind": "server_down"}) + view.render() + text = self._strings(screen) + self.assertIn("not responding", text) + self.assertIn("the server is not responding", text) + def test_summary_screen_after_done(self): view, screen = self.make_view() view.handle_event({"kind": "book", "index": 1, "total": 1, @@ -620,7 +669,9 @@ class WorkerTests(_FakeTui, unittest.TestCase): kwargs={"voice": "alloy", "book_files": ["b.txt"], "planned": ["b.txt"]}, book_files=["b.txt"], planned=["b.txt"]) - with patch("audiobook.convert") as mk_convert: + with patch("audiobook.convert") as mk_convert, \ + patch.object(runview.common, "server_running", + return_value=True): view._worker_main() _, kw = mk_convert.call_args self.assertEqual(kw["book_files"], ["b.txt"]) @@ -635,7 +686,9 @@ class WorkerTests(_FakeTui, unittest.TestCase): log_path = os.path.join(tmp, "audiobook_20260825.log") view = self.make_view(log_path=log_path) with patch("audiobook.convert", - side_effect=TypeError("boom")): + side_effect=TypeError("boom")), \ + patch.object(runview.common, "server_running", + return_value=False): view._worker_main() with open(log_path, encoding="utf-8") as handle: text = handle.read() @@ -681,6 +734,47 @@ class WorkerTests(_FakeTui, unittest.TestCase): view._worker_main() mk_stop.assert_not_called() + def test_worker_probes_the_server_it_does_not_boot(self): + # No autostart (the server is already running locally, or remote): + # the worker still reports the target as "running" so the status + # leaves "starting", then converts against it. + with patch("audiobook.convert") as mk_convert, \ + patch.object(runview.common, "server_running", + return_value=True) as mk_running: + view = self.make_view() + view._worker_main() + mk_running.assert_called_once_with("http://127.0.0.1:8080") + mk_convert.assert_called_once() + events = self._drain(view) + self.assertEqual(events[0], + {"kind": "running", "name": "audiocpp", + "url": "http://127.0.0.1:8080"}) + self.assertEqual(events[-1]["kind"], "worker_exit") + + def test_worker_reports_down_when_the_probe_fails(self): + # A target that never answers (dead remote, server stopped between + # the form and the run) is reported "down"; the conversion still + # runs and fails through the normal chunk/error path. + with patch("audiobook.convert") as mk_convert, \ + patch.object(runview.common, "server_running", + return_value=False): + view = self.make_view() + view._worker_main() + mk_convert.assert_called_once() + self.assertEqual(self._drain(view)[0]["kind"], "server_down") + + def test_worker_skips_the_probe_without_a_url(self): + # No server URL at all (backend vanished between menu and run): + # there is nothing to probe, so no server events are queued. + with patch("audiobook.convert"), \ + patch.object(runview.common, "server_running") as mk_running: + view = self.make_view(server_url=None, server_name=None, + server_identity=None) + view._worker_main() + mk_running.assert_not_called() + self.assertEqual([e["kind"] for e in self._drain(view)], + ["worker_exit"]) + if __name__ == "__main__": unittest.main() diff --git a/app/ui/runview.py b/app/ui/runview.py index 005db59..ff7cfb2 100644 --- a/app/ui/runview.py +++ b/app/ui/runview.py @@ -12,7 +12,10 @@ The screen is fed by two threads the widget spawns: ``backends.servers.start`` (when the conversion needs to boot a managed server; its progress events stream in as they happen) followed by ``audiobook.convert`` with a ``progress`` callback — so behavior is - identical to the CLI, only the presentation differs; + identical to the CLI, only the presentation differs. When the run does + not boot a server itself (one is already running locally, or a remote + ``api_url``), it probes the target first and reports it "running" or + "down" through the same event stream; * a monitor polls the server URL while the conversion runs and reports when it stops answering. @@ -187,7 +190,10 @@ class RunView(ScreenView): self.server = "stopped" self._finish("cancelled") elif kind == "server_down": - if self.phase == "convert": + # The monitor fires this mid-conversion; the worker's boot-time + # probe of a server this run did not start fires it too. Both + # mean the same thing: the target is not answering. + if self.phase not in _TERMINAL: self.server = "down" elif kind == "server_stopped": self.server = "stopped" @@ -302,6 +308,17 @@ class RunView(ScreenView): if self._cancel.is_set(): self._queue.put({"kind": "cancelled"}) return + # When this run does not boot the server itself, the boot + # events never fire — report the target's state so the + # server panel moves past "starting" (or shows "not + # responding" for a target that never answers). + if config.autostart_spec is None and config.server_url: + if common.server_running(config.server_url): + self._queue.put({"kind": "running", + "name": config.server_name or "", + "url": config.server_url}) + else: + self._queue.put({"kind": "server_down"}) # book_files/planned travel on the config fields; dropping # any stray duplicates from kwargs keeps convert()'s call # binding unambiguous. @@ -588,7 +605,7 @@ class RunView(ScreenView): elif self.server == "stopping" and self.stop_started is not None: state_text += f" ({int(self._now() - self.stop_started)}s)" if self.config.autostart_spec is None and self.server == "ready": - state_text += " (external)" + state_text += " (not started by this run)" _text(scr, theme, y, inner_x, "Status".ljust(label_w), theme["dim"]) _text(scr, theme, y, value_x, _fit(state_text, value_w), theme.get(state_kind, theme["body"])) @@ -673,7 +690,7 @@ class RunView(ScreenView): y += 1 elif self.server == "down": _text(scr, theme, y, inner_x, - _fit("the server stopped responding; the conversion " + _fit("the server is not responding; the conversion " "will fail", width - inner_x - 3), theme["err"]) y += 1 elif self.config.notice: |
