aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--app/tests/test_runview.py100
-rw-r--r--app/ui/runview.py25
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: