Files
radar_system/python_app/tests/test_gui_action_lifecycle.py
T
2026-10-05 17:39:40 +03:00

495 lines
27 KiB
Python

"""Regression coverage for saves, full history drains and interruptible GUI actions."""
from __future__ import annotations
from collections import deque
import itertools
import os
from pathlib import Path
import tempfile
from types import SimpleNamespace
import unittest
from unittest import mock
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
from PyQt6.QtWidgets import QApplication # noqa: E402
from PyQt6.QtCore import QTimer # noqa: E402
from python_app.gui.app_window import AppWindow # noqa: E402
from python_app.gui.controllers.app_window_config.profile_io_mixin import AppWindowConfigProfileIOMixin # noqa: E402
from python_app.gui.controllers.app_window_config.live_processing_mixin import AppWindowLiveProcessingMixin # noqa: E402
from python_app.gui.controllers.app_window_pipeline_mixin import AppWindowPipelineMixin # noqa: E402
from python_app.gui.controllers.app_window_preprocess_mixin import AppWindowPreprocessMixin # noqa: E402
from python_app.gui.controllers.app_window_snapshot_mixin import AppWindowSnapshotMixin # noqa: E402
from python_app.gui.controllers.app_window_recording_mixin import AppWindowRecordingMixin # noqa: E402
from python_app.models.dataset_model import ResultCollection, SweepCollection # noqa: E402
from python_app.models.gui_profile_model import GuiProfileModel, GuiStateModel # noqa: E402
from python_app.models.run_config_model import ComboModel, GprRxGeometryModel, GprTxGeometryModel, RunConfigModel # noqa: E402
from python_app.storage.npz_store import NpzStore # noqa: E402
from python_app.workflows.kamil_adc_neutral_preprocess import build_neutral_s21_sets # noqa: E402
def setUpModule() -> None:
global _app
_app = QApplication.instance() or QApplication([])
def _reader(method: str, collections: list):
pending = deque(collections)
return SimpleNamespace(**{method: lambda: pending.popleft() if pending else None, "close": mock.Mock()})
class _Runtime(AppWindowPipelineMixin, AppWindowSnapshotMixin, AppWindowPreprocessMixin):
def __init__(self) -> None:
self._raw_reader = self._pre_reader = self._result_reader = None
self._raw_history = deque(maxlen=2)
self._pre_history = deque(maxlen=2)
self._result_history = deque(maxlen=2)
self._max_pop_per_poll = self._max_pop_per_snapshot_drain = 1
self._single_capture_active = True
self._single_capture_start_ns = 10
self._single_capture_seen_raw = False
self._single_capture_target_collection_id = None
self._capture_session = None
self._preprocess_capture_busy = self._closing = False
self._resume_pipeline_after_capture = True
self._stop_request_seq = 0
self._pipeline_metrics = mock.Mock()
self._record_collection = mock.Mock()
self._supervisor = mock.Mock()
self._supervisor.is_running.return_value = False
self._supervisor.is_processor_running.return_value = True
self._status_label = mock.Mock()
for name in ("_log", "_log_debug", "_log_exception", "_show_error", "_show_exception",
"_update_history_indicator", "_finalize_recording_on_stop", "_web_update_snapshot",
"_build_config", "_update_capture_dialog_state", "close"):
setattr(self, name, mock.Mock())
self._pump_events_during_drain = mock.Mock()
class NeutralSetCreationTest(unittest.TestCase):
def test_failed_second_set_preserves_gui_selection_and_previous_data(self) -> None:
w = _Runtime()
config = RunConfigModel.from_dict({"radar": {
"model": "librevna", "driver_mode": "mock", "sweep": {"points": 7},
}})
w._build_config.return_value = config
w._ensure_preprocess_dialog = mock.Mock(return_value=SimpleNamespace(set_name=lambda: "new_neutral"))
w._radar_key = mock.Mock(return_value="radar")
w._selected_preprocess_sets = {"s21_calibration": "old_cal", "s21_reference": "old_ref"}
w._selected_preprocess_radar_key = "previous_radar"
w._processor_run_signature = "processor_signature"
w._history_run_signature = "history_signature"
w._refresh_sets = mock.Mock()
w._reset_runtime_history = mock.Mock()
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
w._store = NpzStore(root)
calibration, reference = build_neutral_s21_sets(config)
w._store.save_set("s21_calibration", "radar", "old_cal", calibration)
w._store.save_set("s21_reference", "radar", "old_ref", reference)
before = {path: path.read_bytes() for path in root.rglob("*") if path.is_file()}
save = w._store.save_set
def save_set(kind, *args):
if kind == "s21_reference":
raise OSError("reference write failed")
return save(kind, *args)
with mock.patch.object(w._store, "save_set", side_effect=save_set):
w._create_neutral_sets()
self.assertEqual({path: path.read_bytes() for path in root.rglob("*") if path.is_file()}, before)
self.assertEqual(w._selected_preprocess_sets, {"s21_calibration": "old_cal", "s21_reference": "old_ref"})
self.assertEqual(w._selected_preprocess_radar_key, "previous_radar")
self.assertEqual((w._processor_run_signature, w._history_run_signature), ("processor_signature", "history_signature"))
w._show_exception.assert_called_once()
w._refresh_sets.assert_not_called()
w._reset_runtime_history.assert_not_called()
class SnapshotDrainTest(unittest.TestCase):
def test_save_drain_preserves_recording_and_single_capture_tracking(self) -> None:
w = _Runtime()
raw = SweepCollection(collection_id=5, monotonic_ns=20, traces=[], capture_start_ns=1, capture_end_ns=8)
preprocessed = SweepCollection(collection_id=5, monotonic_ns=20, traces=[])
result = ResultCollection(collection_id=5, monotonic_ns=20, blocks=[])
w._raw_reader = _reader("pop_raw_collection", [raw])
w._pre_reader = _reader("pop_preprocessed_collection", [preprocessed])
w._result_reader = _reader("pop_result_collection", [result, result])
w._drain_runtime_rings_for_snapshot()
self.assertEqual(w._record_collection.call_args_list, [
mock.call("raw", raw), mock.call("preprocessed", preprocessed), mock.call("results", result),
])
self.assertTrue(w._single_capture_seen_raw)
self.assertEqual(w._single_capture_target_collection_id, 5)
w._pipeline_metrics.record.assert_any_call("acquisition", 7)
self.assertEqual(list(w._result_history), [result])
w._log_exception.assert_not_called()
def test_continuous_producer_cannot_prevent_snapshot_timeout(self) -> None:
w = _Runtime()
collection = SweepCollection(collection_id=1, monotonic_ns=20, traces=[])
pop = mock.Mock(return_value=collection)
w._raw_reader = SimpleNamespace(pop_raw_collection=pop)
ticks = itertools.count(0.0, 0.1)
with mock.patch("python_app.gui.controllers.app_window_snapshot_mixin.time.monotonic", side_effect=lambda: next(ticks)):
w._drain_runtime_rings_for_snapshot()
self.assertGreater(pop.call_count, 1)
self.assertLess(pop.call_count, 15)
w._log_exception.assert_not_called()
def test_stop_drain_reads_the_whole_tail_when_history_is_full(self) -> None:
w = _Runtime()
w._raw_history = deque([SweepCollection(collection_id=0, monotonic_ns=0, traces=[])], maxlen=1)
tail = [SweepCollection(collection_id=i, monotonic_ns=i, traces=[]) for i in range(1, 11)]
w._raw_reader = _reader("pop_raw_collection", tail)
clock = [0.0]
w._pump_events_during_drain.side_effect = lambda pause: clock.__setitem__(0, clock[0] + pause)
with mock.patch("python_app.gui.controllers.app_window_pipeline_mixin.time.monotonic", side_effect=lambda: clock[0]):
w._drain_rings_until_quiet(timeout_s=1.0, poll_s=0.01)
self.assertEqual([call.args[1].collection_id for call in w._record_collection.call_args_list], list(range(1, 11)))
self.assertEqual(w._raw_history[-1].collection_id, 10)
class ShutdownReentryTest(unittest.TestCase):
def test_reader_failure_on_stop_is_reported_and_cancels_automatic_resume(self) -> None:
w = _Runtime()
w._supervisor.is_running.return_value = True
w._drain_rings_until_quiet = mock.Mock(side_effect=ValueError("corrupt ring payload"))
raw = w._raw_reader = SimpleNamespace(close=mock.Mock())
preprocessed = w._pre_reader = SimpleNamespace(close=mock.Mock())
result = w._result_reader = SimpleNamespace(close=mock.Mock(), _ring_name="/results")
expected_stop_seq = w._stop_request_seq + 1
with mock.patch("python_app.gui.controllers.app_window_pipeline_mixin.ShmRingReader") as reopen:
w._stop_run()
w._supervisor.stop.assert_called_once()
w._supervisor.stop_all.assert_not_called()
w._finalize_recording_on_stop.assert_called_once()
for reader in (raw, preprocessed, result):
reader.close.assert_called_once()
self.assertIsNone(w._raw_reader)
self.assertIsNone(w._pre_reader)
self.assertIs(w._result_reader, reopen.return_value)
reopen.assert_called_once_with("/results")
w._show_exception.assert_called_once()
self.assertIn("corrupt ring", str(w._show_exception.call_args.args[1]))
self.assertFalse(w._stopping_run)
self.assertFalse(w._single_capture_active)
self.assertEqual(w._status_label.setText.call_args.args[0], "Status: error")
self.assertFalse(w._pipeline_resume_allowed(expected_stop_seq))
def test_unsupported_kamil_adc_mock_fails_before_starting_any_process(self) -> None:
w = _Runtime()
config = RunConfigModel()
config.radar.model = "kamil_adc"
config.apply_device_model_constraints()
w._build_config.return_value = config
w._stop_all_processes = mock.Mock()
w._start_run(single_capture=True)
w._supervisor.start.assert_not_called()
w._show_exception.assert_called_once()
self.assertIn("native", str(w._show_exception.call_args.args[1]))
def test_stop_blocks_nested_start_poll_and_duplicate_stop(self) -> None:
w = _Runtime()
w._supervisor.is_running.return_value = True
def reenter(**_kwargs):
w._start_run()
w._poll_rings()
w._stop_run()
w._drain_rings_until_quiet = mock.Mock(side_effect=reenter)
w._stop_run()
w._supervisor.stop_orchestrator.assert_called_once()
w._supervisor.stop_preprocessor.assert_called_once()
w._finalize_recording_on_stop.assert_called_once()
w._build_config.assert_not_called()
w._web_update_snapshot.assert_not_called()
self.assertFalse(w._stopping_run)
self.assertFalse(w._resume_pipeline_after_capture)
def test_close_during_stop_waits_until_tail_is_finalized(self) -> None:
w = _Runtime()
w._supervisor.is_running.return_value = True
event = mock.Mock()
ordering = []
def close_during_drain(**_kwargs):
AppWindow.closeEvent(w, event)
w._drain_rings_until_quiet = mock.Mock(side_effect=close_during_drain)
w._finalize_recording_on_stop.side_effect = lambda: ordering.append("finalize")
w.close.side_effect = lambda: ordering.append("close")
w._stop_run()
event.ignore.assert_called()
w._supervisor.stop_all.assert_called_once()
self.assertEqual(ordering, ["finalize", "close"])
self.assertFalse(w._close_after_stop)
def test_explicit_stop_during_apply_pause_cancels_apply_and_restart(self) -> None:
w = _Runtime()
w._supervisor.is_running.return_value = True
w._start_run = mock.Mock()
w._drain_rings_until_quiet = mock.Mock(side_effect=lambda **_kwargs: w._stop_run())
w._apply_radar_settings()
w._build_config.assert_not_called()
w._start_run.assert_not_called()
class CaptureBatchControlTest(unittest.TestCase):
def test_stop_between_combos_keeps_captures_and_allows_later_resume(self) -> None:
w = _Runtime()
captured = []
session = SimpleNamespace(
kind="s21_reference", is_complete=lambda: len(captured) == 3,
state=lambda: SimpleNamespace(total_count=3, captured_count=len(captured)),
capture_current_combo=lambda: captured.append(len(captured)),
)
w._capture_session = session
dialog = mock.Mock()
w._ensure_preprocess_dialog = lambda: dialog
w._record_preprocess_capture = mock.Mock()
w._pump_events_during_drain = lambda pause: AppWindowPipelineMixin._pump_events_during_drain(w, pause)
QTimer.singleShot(0, w._stop_run)
with mock.patch("python_app.gui.controllers.app_window_preprocess_mixin.QTimer.singleShot", side_effect=lambda _delay, fn: fn()):
w._capture_all_remaining()
self.assertEqual(captured, [0])
self.assertIs(w._capture_session, session)
self.assertFalse(w._preprocess_capture_busy)
self.assertIn("paused", dialog.set_status.call_args.args[0])
w._capture_all_remaining()
self.assertEqual(captured, [0, 1, 2])
self.assertEqual(w._record_preprocess_capture.call_count, 3)
class GuiProfileWriteTest(unittest.TestCase):
def test_dataset_profile_uses_actual_preprocessing_and_preserves_current_gui(self) -> None:
for applied, selected in (("", "next_run"), ("applied", "")):
with self.subTest(applied=applied), tempfile.TemporaryDirectory() as folder:
pending = GuiProfileModel(gui=GuiStateModel())
pending.gui.processing.bscan.max_depth_m = 7.5
pending.run_config.gpr.tx_geometry = [GprTxGeometryModel(output_pos=0, x_m=.4, y_m=.2, z_m=.1)]
pending.run_config.preprocess.s21.object_calibration.set_name = selected
active = pending.run_config.clone()
active.gpr.tx_geometry[0].x_m = -.4
active.radar.serial = "acquired_A"
active.radar.sweep.points = 129
active.input_switch.positions = 2
active.output_switch.positions = 2
active.combos = [ComboModel(input=1, output=1)]
pending.run_config.radar.serial = "pending_B"
pending.run_config.radar.sweep.points = 257
active.preprocess.s21.object_calibration.set_name = applied
active.preprocess.s21.object_calibration.bundle_path = "/bundles/object" if applied else ""
active.preprocess.s21.calibration.set_name = "actual_through"
active.preprocess.s21.calibration.bundle_path = "/bundles/through"
active.preprocess.s21.reference.set_name = "actual_reference"
active.preprocess.s21.reference.bundle_path = "/bundles/reference"
active.preprocess.s11.calibration.open.set_name = "actual_open"
active.preprocess.s11.calibration.open.bundle_path = "/bundles/open"
active.preprocess.s11.reference.set_name = "actual_s11_reference"
active.preprocess.notch.enabled = True
active.preprocess.notch.bands_hz = [(2e9, 2.1e9)]
writer = AppWindowConfigProfileIOMixin()
writer._build_gui_profile = pending.clone
writer._active_run_config = active
dataset_path, settings_path = Path(folder) / "dataset.json", Path(folder) / "settings.json"
saved = writer._write_gui_profile_to_path(dataset_path, dataset=True)
writer._write_gui_profile_to_path(settings_path)
dataset = GuiProfileModel.load_from_path(dataset_path).to_dict()
expected = pending.to_dict()
acquired = active.to_dict()
for key in ("radar", "switches", "run", "preprocess"):
expected[key] = acquired[key]
expected["gpr"]["tx_geometry"] = acquired["gpr"]["tx_geometry"]
expected["gpr"]["rx_geometry"] = acquired["gpr"]["rx_geometry"]
self.assertEqual(dataset, expected)
self.assertEqual(GuiProfileModel.load_from_path(settings_path).to_dict(), pending.to_dict())
saved.run_config.preprocess.s21.calibration.set_name = "mutated_return_value"
self.assertEqual(active.preprocess.s21.calibration.set_name, "actual_through")
def test_dataset_profile_retains_original_device_and_geometry_after_loading_another_profile(self) -> None:
acquired = GuiProfileModel()
acquired.run_config.ensure_combos()
acquired.run_config.radar.serial = "device_A"
acquired.run_config.gpr.tx_geometry = [GprTxGeometryModel(output_pos=0, x_m=-.4)]
acquired.run_config.gpr.rx_geometry = [GprRxGeometryModel(input_pos=0, x_m=.4)]
pending = acquired.clone()
pending.run_config.radar.model = "librevna_multi"
pending.run_config.radar.serial = "device_B"
pending.run_config.gpr.tx_geometry[0].x_m = 2.
pending.run_config.gpr.rx_geometry[0].x_m = -2.
writer = AppWindowConfigProfileIOMixin()
writer._build_gui_profile = pending.clone
writer._active_run_config = acquired.run_config
with tempfile.TemporaryDirectory() as folder:
path = Path(folder) / "dataset.json"
writer._write_gui_profile_to_path(path, dataset=True)
saved = GuiProfileModel.load_from_path(path)
self.assertEqual(saved.run_config.radar, acquired.run_config.radar)
self.assertEqual(saved.run_config.gpr, acquired.run_config.gpr)
self.assertEqual(saved.run_config.combos, acquired.run_config.combos)
def test_applied_geometry_tracks_successful_live_writes_only_while_acquiring(self) -> None:
for running, failed_write in ((True, False), (False, False), (True, True)):
with self.subTest(running=running, failed_write=failed_write):
active = RunConfigModel()
active.gpr.tx_geometry = [GprTxGeometryModel(output_pos=0, x_m=-.4)]
active.gpr.rx_geometry = [GprRxGeometryModel(input_pos=0, x_m=.4)]
original = active.clone()
writer = SimpleNamespace(
_active_run_config=active,
_supervisor=SimpleNamespace(is_running=lambda: running),
_live_config_writer=SimpleNamespace(write=mock.Mock(side_effect=OSError("disk full") if failed_write else None)),
_live_processing_config=mock.Mock(return_value=object()),
_can_reprocess_history=lambda: False,
_gpr_tx_geometry_input=SimpleNamespace(records=lambda: [dict(output_pos=0, x_m=1., y_m=2., z_m=3.)]),
_gpr_rx_geometry_input=SimpleNamespace(records=lambda: [dict(input_pos=0, x_m=-1., y_m=-2., z_m=-3.)]),
)
if failed_write:
with self.assertRaises(OSError):
AppWindowLiveProcessingMixin._write_live_processing_config(writer)
else:
AppWindowLiveProcessingMixin._write_live_processing_config(writer)
if running and not failed_write:
self.assertEqual(active.gpr.tx_geometry, [GprTxGeometryModel(0, 1., 2., 3.)])
self.assertEqual(active.gpr.rx_geometry, [GprRxGeometryModel(0, -1., -2., -3.)])
else:
self.assertEqual(active.gpr, original.gpr)
def test_dataset_profile_without_active_run_preserves_ui_selection(self) -> None:
writer = AppWindowConfigProfileIOMixin()
pending = GuiProfileModel()
pending.run_config.preprocess.s21.object_calibration.set_name = "selected"
writer._build_gui_profile = pending.clone
writer._active_run_config = None
with tempfile.TemporaryDirectory() as folder:
path = Path(folder) / "dataset.json"
writer._write_gui_profile_to_path(path, dataset=True)
self.assertEqual(GuiProfileModel.load_from_path(path).to_dict(), pending.to_dict())
def test_failed_profile_replace_preserves_existing_profile_and_cleans_temporary(self) -> None:
writer = AppWindowConfigProfileIOMixin()
writer._build_gui_profile = lambda: GuiProfileModel()
with tempfile.TemporaryDirectory() as folder:
path = Path(folder) / "profile.json"
previous = b'{"previous": "profile"}'
path.write_bytes(previous)
with mock.patch("python_app.file_io.os.replace", side_effect=OSError("disk failure")):
with self.assertRaisesRegex(OSError, "disk failure"):
writer._write_gui_profile_to_path(path)
self.assertEqual(path.read_bytes(), previous)
self.assertEqual(list(path.parent.iterdir()), [path])
class HistoryCommandRestoreTest(unittest.TestCase):
def test_malformed_or_exhausted_counter_cannot_prevent_gui_start(self) -> None:
with tempfile.TemporaryDirectory() as folder:
path = Path(folder) / "live.json"
for content in ('[]', 'null', 'invalid', '{"history_command_seq": NaN}',
'{"history_command_seq": Infinity}', '{"history_command_seq": true}',
'{"history_command_seq": 1.5}', '{"history_command_seq": -1}',
'{"history_command_seq": 18446744073709551615}'):
path.write_text(content)
with self.subTest(content=content):
self.assertEqual(AppWindow._load_history_command_seq(path), 0)
path.write_text('{"history_command_seq": 9876543210}')
self.assertEqual(AppWindow._load_history_command_seq(path), 9876543210)
class PreprocessHistoryRestartTest(unittest.TestCase):
def _runtime(self):
w = _Runtime()
config = RunConfigModel.from_dict({"radar": {"model": "librevna", "driver_mode": "mock"}})
config.input_switch.driver = config.output_switch.driver = "h7992"
config.input_switch.radar_port, config.output_switch.radar_port = 1, 2
config.preprocess.s21.calibration.set_name = "through"
config.preprocess.s21.reference.set_name = "empty"
w._build_config.return_value = config
w._processor_run_signature = w._build_processor_run_signature(config)
w._history_run_signature = w._build_run_history_signature(config)
w._project_root = Path("/tmp/object-calibration-test")
w._store = mock.Mock()
w._store.has_combo_coverage.return_value = True
w._config_writer = mock.Mock()
w._config_writer.write.return_value = w._project_root / "runtime.json"
w._radar_key = mock.Mock(return_value="radar")
w._supervisor.pids.return_value = {}
w._bscan_reprocess_timer = mock.Mock()
for name in ("_validate_processing_mode_constraints", "_prepare_radar_for_native_acquisition",
"_close_replay_ring_writer", "_discard_bscan_replay_results", "_close_readers",
"_drop_pending_ring_payloads", "_reset_runtime_history", "_stop_all_processes",
"_write_live_processing_config"):
setattr(w, name, mock.Mock())
return w, config
def test_changed_preprocess_clears_native_history_before_both_start_modes(self) -> None:
for single_capture in (False, True):
with self.subTest(single_capture=single_capture):
w, config = self._runtime()
config.preprocess.s21.object_calibration.set_name = "sphere"
events = []
w._write_live_processing_config.side_effect = lambda **_kwargs: events.append("clear")
w._supervisor.start.side_effect = lambda *_args, **_kwargs: events.append("start")
with mock.patch("python_app.gui.controllers.app_window_pipeline_mixin.ShmRingReader"):
w._start_run(single_capture=single_capture)
w._show_exception.assert_not_called()
w._write_live_processing_config.assert_called_once_with(
history_command="clear_all", bump_history_seq=True)
self.assertEqual(events, ["clear", "start"])
w._reset_runtime_history.assert_called_once()
self.assertEqual(w._history_run_signature, w._build_run_history_signature(config))
def test_unchanged_preprocess_retains_history_and_existing_processor(self) -> None:
w, config = self._runtime()
with mock.patch("python_app.gui.controllers.app_window_pipeline_mixin.ShmRingReader"):
w._start_run(single_capture=True)
w._show_exception.assert_not_called()
w._write_live_processing_config.assert_not_called()
w._reset_runtime_history.assert_not_called()
w._stop_all_processes.assert_not_called()
def test_capture_override_is_cloned_and_skips_display_mode_constraints(self) -> None:
w, config = self._runtime()
w._object_capture_job = object()
with mock.patch("python_app.gui.controllers.app_window_pipeline_mixin.ShmRingReader"):
w._start_run(config_override=config)
w._show_exception.assert_not_called()
w._build_config.assert_not_called()
w._validate_processing_mode_constraints.assert_not_called()
exported = w._config_writer.prepare_preprocess_bundles.call_args.args[2]
self.assertIsNot(exported, config)
self.assertFalse(config.runtime.continuous)
self.assertTrue(exported.runtime.continuous)
def test_regular_start_and_other_hardware_actions_wait_for_object_capture(self) -> None:
for action, args in (("_start_run", ()), ("_capture_tmp_reference", ()),
("_create_neutral_sets", ()), ("_start_capture_sequence", ("s21_reference",)),
("_apply_radar_settings", ()), ("_save_snapshot", ()),
("_save_vna_history_json", ()), ("_remove_last_runtime_history", ()),
("_clear_all_runtime_history", ())):
with self.subTest(action=action):
w, _ = self._runtime()
w._object_capture_job = object()
getattr(w, action)(*args)
w._build_config.assert_not_called()
w._show_error.assert_called_once()
def test_profile_load_and_recording_wait_for_object_capture_even_before_pipeline_start(self) -> None:
for action in (AppWindowConfigProfileIOMixin._ensure_ready_to_load_config,
AppWindowRecordingMixin._start_run_with_recording):
with self.subTest(action=action.__name__):
w, _ = self._runtime()
w._object_capture_job = object()
self.assertFalse(action(w))
w._build_config.assert_not_called()
w._supervisor.is_running.assert_not_called()
w._show_error.assert_called_once()
if __name__ == "__main__":
unittest.main()