diff --git a/docs/STATUS_LANGUAGE.md b/docs/STATUS_LANGUAGE.md index 7850f68c..d25b8e9f 100644 --- a/docs/STATUS_LANGUAGE.md +++ b/docs/STATUS_LANGUAGE.md @@ -312,6 +312,10 @@ attributes, so it spells the token's value. its marker-coloured border. - Error status rows and the ERROR box lead with the off-white warning sign (`draw_warning_sign`). +- The Settings ERROR box says what was observed on its main line (off-white, + bold) and one next step under it (muted), as the web's box. A banner the + menu opened with brings its own step (`action=` beside `banner=`) or none; + the video failure's step stands only under the video failure. - A fault's sentence is a top-right status row, whatever raised it (a video failure, a missing controller, an output whose interface has no address, a marker another station controls too). diff --git a/openfollow/app.py b/openfollow/app.py index e85dc060..a2343d20 100644 --- a/openfollow/app.py +++ b/openfollow/app.py @@ -334,6 +334,7 @@ def __init__( self._settings_menu_active: bool = False self._settings_menu_index: int = 0 self._settings_menu_banner: str = "" + self._settings_menu_banner_action: str = "" # Newest pushed-settings warning pending when the Settings menu opened. self._settings_pushed_upto: int | None = None self._settings_key_pressed: bool = False @@ -536,7 +537,7 @@ def _refresh_psn_source_advisory(self) -> str: if resolved: banner += f"Using auto-detected {resolved}." else: - banner += "No usable interface – pick one to continue." + banner += "No usable interface." self._psn_source_banner = banner return banner @@ -739,8 +740,8 @@ def _confirm_field_choice_picker(self) -> None: def _process_field_choice_picker_input(self) -> None: runtime_process_field_choice_picker_input(self) - def _enter_settings_menu(self, *, banner: str = "") -> None: - runtime_enter_settings_menu(self, banner=banner) + def _enter_settings_menu(self, *, banner: str = "", action: str = "") -> None: + runtime_enter_settings_menu(self, banner=banner, action=action) def _exit_settings_menu(self) -> None: runtime_exit_settings_menu(self) diff --git a/openfollow/runtime/app_modes.py b/openfollow/runtime/app_modes.py index 57f7b412..d2744aa5 100644 --- a/openfollow/runtime/app_modes.py +++ b/openfollow/runtime/app_modes.py @@ -19,7 +19,7 @@ from openfollow.configuration import config_file_problem, config_problem_words, is_simple_mode, save_config_fields from openfollow.input.touch_remote import holds from openfollow.runtime import paths_runtime -from openfollow.video.failure import SourceKind, VideoFailure, failure_sentence +from openfollow.video.failure import SourceKind, VideoFailure, failure_action, failure_sentence if TYPE_CHECKING: from openfollow.app import OpenFollowApp @@ -461,14 +461,16 @@ def _settings_menu_action(app: OpenFollowApp, index: int) -> str | None: return action -def enter_settings_menu(app: OpenFollowApp, *, banner: str = "") -> None: +def enter_settings_menu(app: OpenFollowApp, *, banner: str = "", action: str = "") -> None: """Open the Settings menu overlay. ``banner`` is stored on the app and rendered inside the menu's red-bordered error box (word-wrapped, bold body) – used by the startup auto-open and the runtime ``check_video_disconnect_banner`` - so the operator sees *why* the menu auto-opened. The subtitle row - keeps its static "Open a sub-screen." text regardless. + so the operator sees *why* the menu auto-opened. ``action`` is the + banner's one next step, drawn under it (lighter); a banner never + carries the step in its own sentence. The subtitle row keeps its + static "Open a sub-screen." text regardless. """ if app._settings_menu_active: return @@ -476,6 +478,7 @@ def enter_settings_menu(app: OpenFollowApp, *, banner: str = "") -> None: app._settings_menu_active = True app._settings_menu_banner = banner + app._settings_menu_banner_action = action app._settings_pushed_upto = newest_pending(app) # Below the removal, where the highlight always starts: a reflexive press # (the menu also opens itself on a video failure) must not acknowledge a push. @@ -485,6 +488,7 @@ def enter_settings_menu(app: OpenFollowApp, *, banner: str = "") -> None: def exit_settings_menu(app: OpenFollowApp) -> None: app._settings_menu_active = False app._settings_menu_banner = "" + app._settings_menu_banner_action = "" app._video_disconnect_menu_open = False @@ -1179,7 +1183,8 @@ def confirm_source_type_selection(app: OpenFollowApp) -> None: ) exit_source_type_selection(app) app._enter_settings_menu( - banner=(f"Couldn't switch to {display}: {error}. Configure it in the web UI, then try again."), + banner=f"Couldn't switch to {display}: {error}.", + action="Configure it in the web UI, then try again.", ) return app._config.video_source_type = new_type @@ -1187,25 +1192,36 @@ def confirm_source_type_selection(app: OpenFollowApp) -> None: logger.info("Video source type set to: %s (live)", new_type) if not saved: exit_source_type_selection(app) - app._enter_settings_menu(banner=_unsaved_banner(app, display, "switch again")) + _report_unsaved(app, display, "switch again") return exit_source_type_selection(app) _route_after_video_source_change(app, new_type) -def _why_unsaved(app: OpenFollowApp, again: str) -> str: - """Why a save did not reach the settings file, and the one step before the operator tries *again*.""" +def _unsaved_reason(app: OpenFollowApp, again: str) -> tuple[str, str]: + """What is wrong with the settings file (``""`` if it reads), and the step before the operator tries *again*.""" problem = config_file_problem(app._config_path) if problem is None: - return f"Check the station's storage, then {again}." + return "", f"Check the station's storage, then {again}." said, step = config_problem_words(problem) - return f"The settings file {said}. {step}, then {again}." + return f"The settings file {said}.", f"{step}, then {again}." + + +def _why_unsaved(app: OpenFollowApp, again: str) -> str: + """Why a save did not reach the settings file, and the one step before the operator tries *again*.""" + return " ".join(part for part in _unsaved_reason(app, again) if part) -def _unsaved_banner(app: OpenFollowApp, what: str, again: str) -> str: +def _enter_unsaved_menu(app: OpenFollowApp, lead: str, again: str) -> None: + """Open Settings on a save that did not reach the settings file, *lead* naming what.""" + seen, step = _unsaved_reason(app, again) + app._enter_settings_menu(banner=f"{lead} {seen}".rstrip(), action=step) + + +def _report_unsaved(app: OpenFollowApp, what: str, again: str) -> None: # A repaired file is reloaded at once and brings its own value back. - return f"{what} is live but could not be saved. {_why_unsaved(app, again)}" + _enter_unsaved_menu(app, f"{what} is live but could not be saved.", again) def save_source_pick(app: OpenFollowApp, field: str, value: str) -> None: @@ -1213,7 +1229,7 @@ def save_source_pick(app: OpenFollowApp, field: str, value: str) -> None: setattr(app._config, field, value) if not _persist_config(app, field): what = f"The {_input_display_name(app._config.video_source_type)} source" - app._enter_settings_menu(banner=_unsaved_banner(app, what, "pick it again")) + _report_unsaved(app, what, "pick it again") def _input_display_name(source_type: str) -> str: @@ -1236,7 +1252,7 @@ def _save_for_input_not_running(app: OpenFollowApp, field: str, value: str, labe if _persist_config(app, field): logger.info("Set %s to %r; its input is not running.", field, value) return - app._enter_settings_menu(banner=f"{label} could not be saved. {_why_unsaved(app, again)}") + _enter_unsaved_menu(app, f"{label} could not be saved.", again) # --------------------------------------------------------------------------- @@ -1333,7 +1349,8 @@ def confirm_url_editor(app: OpenFollowApp) -> None: target = _input_display_name(source_type) exit_url_editor(app) app._enter_settings_menu( - banner=(f"Couldn't switch to {target}: {error}. Configure {label} in the web UI, then try again."), + banner=f"Couldn't switch to {target}: {error}.", + action=f"Configure {label} in the web UI, then try again.", ) return setattr(app._config, field, new_value) @@ -1343,7 +1360,7 @@ def confirm_url_editor(app: OpenFollowApp) -> None: exit_url_editor(app) if not saved: what, again = (_input_display_name(source_type), "switch again") if switching else (label, "enter it again") - app._enter_settings_menu(banner=_unsaved_banner(app, what, again)) + _report_unsaved(app, what, again) def process_url_editor_input(app: OpenFollowApp) -> None: @@ -1476,7 +1493,8 @@ def confirm_field_choice_picker(app: OpenFollowApp) -> None: target = _input_display_name(source_type) exit_field_choice_picker(app) app._enter_settings_menu( - banner=(f"Couldn't switch to {target}: {error}. Configure {label} in the web UI, then try again."), + banner=f"Couldn't switch to {target}: {error}.", + action=f"Configure {label} in the web UI, then try again.", ) return logger.warning( @@ -1488,7 +1506,8 @@ def confirm_field_choice_picker(app: OpenFollowApp) -> None: ) exit_field_choice_picker(app) app._enter_settings_menu( - banner=(f"Couldn't apply {label} = {value!r}: {error}. Try a different value, or edit in the web UI."), + banner=f"Couldn't apply {label} = {value!r}: {error}.", + action="Try a different value, or edit in the web UI.", ) return setattr(app._config, field_name, value) @@ -1498,7 +1517,7 @@ def confirm_field_choice_picker(app: OpenFollowApp) -> None: exit_field_choice_picker(app) if not saved: what, again = (_input_display_name(source_type), "switch again") if switching else (label, "pick it again") - app._enter_settings_menu(banner=_unsaved_banner(app, what, again)) + _report_unsaved(app, what, again) def process_field_choice_picker_input(app: OpenFollowApp) -> None: @@ -1569,6 +1588,14 @@ def _video_disconnect_banner_text(app: OpenFollowApp) -> str: return f"Video source ({source_type}) is not available{label}." +def _video_disconnect_action(app: OpenFollowApp) -> str: + """The video failure's one next step, drawn under its banner.""" + receiver = app._video_receiver + failure = getattr(getattr(receiver, "status_marker", None), "failure", VideoFailure.NONE) + kind = getattr(receiver, "source_kind", SourceKind.REMOTE) + return failure_action(failure, kind=kind, simple=is_simple_mode(app._config)) + + def check_video_disconnect_banner(app: OpenFollowApp) -> None: """Startup-only Settings-menu auto-open for a video source that never comes up. Replaces the standalone "No Signal" overlay; @@ -1614,6 +1641,7 @@ def check_video_disconnect_banner(app: OpenFollowApp) -> None: # banner from current state so a stale source name can't linger. if app._video_disconnect_menu_open and app._settings_menu_active: app._settings_menu_banner = _video_disconnect_banner_text(app) + app._settings_menu_banner_action = _video_disconnect_action(app) return # Disconnected. Only the startup case (never connected) auto-opens; @@ -1643,7 +1671,7 @@ def check_video_disconnect_banner(app: OpenFollowApp) -> None: "Video source %r never connected after startup – opening Settings.", app._config.video_source_type, ) - app._enter_settings_menu(banner=_video_disconnect_banner_text(app)) + app._enter_settings_menu(banner=_video_disconnect_banner_text(app), action=_video_disconnect_action(app)) # Grace period between startup and the Settings-menu auto-open when diff --git a/openfollow/runtime/overlay_draw_hud.py b/openfollow/runtime/overlay_draw_hud.py index c1e967f4..4cdb172e 100644 --- a/openfollow/runtime/overlay_draw_hud.py +++ b/openfollow/runtime/overlay_draw_hud.py @@ -715,14 +715,15 @@ def draw_settings_menu(renderer: Any, cr: Any, state: OverlayState, w: int, h: i # structured message including source + GStreamer error). Opening Settings # manually falls back to the same pair the web UI shows - the sentence # naming what failed, then the element's own wording - because the raw - # message alone was half of what the browser was reporting. + # message alone was half of what the browser was reporting. A banner + # brings its own next step; the video's goes only under the video failure. error_text = state.settings_menu_banner or state.video_failure_text or state.error_message if error_text: cursor_y = _draw_settings_error_box( renderer, cr, error_text, - state.video_failure_action, + state.settings_menu_banner_action if state.settings_menu_banner else state.video_failure_action, content_x, cursor_y, content_w, diff --git a/openfollow/runtime/overlay_state.py b/openfollow/runtime/overlay_state.py index 8a9c05af..0b343028 100644 --- a/openfollow/runtime/overlay_state.py +++ b/openfollow/runtime/overlay_state.py @@ -250,6 +250,7 @@ class OverlayState: settings_items_submenu: list[bool] = field(default_factory=list) settings_selected_index: int = 0 settings_menu_banner: str = "" + settings_menu_banner_action: str = "" # About / license screen – read-only, no extra payload. about_active: bool = False # Drive picker + diagnostics export screen. diff --git a/openfollow/runtime/services_marker_visuals.py b/openfollow/runtime/services_marker_visuals.py index 1e7ef992..39716cc2 100644 --- a/openfollow/runtime/services_marker_visuals.py +++ b/openfollow/runtime/services_marker_visuals.py @@ -646,6 +646,7 @@ def build_marker_visual_state( state.settings_items_submenu = submenu state.settings_selected_index = app._settings_menu_index state.settings_menu_banner = app._settings_menu_banner + state.settings_menu_banner_action = app._settings_menu_banner_action else: state.settings_items = [] state.settings_items_enabled = [] @@ -653,6 +654,7 @@ def build_marker_visual_state( state.settings_items_submenu = [] state.settings_selected_index = 0 state.settings_menu_banner = "" + state.settings_menu_banner_action = "" state.about_active = getattr(app, "_about_active", False) _populate_media_overlay(app, state) diff --git a/tests/test_app_lifecycle.py b/tests/test_app_lifecycle.py index f1c20d20..9feeb588 100644 --- a/tests/test_app_lifecycle.py +++ b/tests/test_app_lifecycle.py @@ -477,7 +477,7 @@ def test_stale_pin_with_multi_nic_opens_settings_with_banner( monkeypatch.setattr( app_module, "runtime_enter_settings_menu", - lambda a, *, banner="": banners.append(banner), + lambda a, *, banner="", action="": banners.append(banner), ) app.run() assert len(banners) == 1 @@ -515,7 +515,7 @@ def test_stale_pin_with_single_nic_proceeds_silently( monkeypatch.setattr( app_module, "runtime_enter_settings_menu", - lambda a, *, banner="": banners.append(banner), + lambda a, *, banner="", action="": banners.append(banner), ) app.run() assert banners == [] @@ -536,7 +536,7 @@ def test_empty_iface_skips_check( monkeypatch.setattr( app_module, "runtime_enter_settings_menu", - lambda a, *, banner="": banners.append(banner), + lambda a, *, banner="", action="": banners.append(banner), ) app.run() assert banners == [] @@ -568,12 +568,12 @@ def test_offline_host_with_pin_logs_no_usable_ip_banner( monkeypatch.setattr( app_module, "runtime_enter_settings_menu", - lambda a, *, banner="": banners.append(banner), + lambda a, *, banner="", action="": banners.append(banner), ) app.run() assert banners == [] # zero candidates → no Settings prompt assert app._psn_source_status == "none" - assert "No usable interface" in app._psn_source_banner + assert app._psn_source_banner == "Pinned network interface 'eth0' is not available. No usable interface." def test_iface_pin_resolves_and_skips_settings( self, @@ -608,7 +608,7 @@ def test_iface_pin_resolves_and_skips_settings( monkeypatch.setattr( app_module, "runtime_enter_settings_menu", - lambda a, *, banner="": banners.append(banner), + lambda a, *, banner="", action="": banners.append(banner), ) app.run() assert banners == [] @@ -792,6 +792,18 @@ def test_no_arg_delegators( # All helpers take ``app`` as first arg. assert hits[0][0] is app + def test_enter_settings_menu_forwards_the_banner_and_its_step( + self, + patched_ctor, + monkeypatch: pytest.MonkeyPatch, # noqa: ANN001 + ) -> None: + hits: list[dict[str, str]] = [] + monkeypatch.setattr(app_module, "runtime_enter_settings_menu", lambda _app, **kw: hits.append(kw)) + + app = OpenFollowApp(config_path=patched_ctor.cfg_path) + app._enter_settings_menu(banner="Seen.", action="Do this.") + assert hits == [{"banner": "Seen.", "action": "Do this."}] + @pytest.mark.parametrize("verdict", [True, False]) def test_run_frame_forwards_the_helpers_verdict( self, diff --git a/tests/test_app_modes.py b/tests/test_app_modes.py index 0601bfc0..6d812c41 100644 --- a/tests/test_app_modes.py +++ b/tests/test_app_modes.py @@ -312,10 +312,10 @@ def _enter_button_detection(self) -> None: def _restart_app(self) -> None: self._restart_called = True - def _enter_settings_menu(self, *, banner: str = "") -> None: + def _enter_settings_menu(self, *, banner: str = "", action: str = "") -> None: from openfollow.runtime.app_modes import enter_settings_menu - enter_settings_menu(self, banner=banner) + enter_settings_menu(self, banner=banner, action=action) return FakeApp() @@ -339,15 +339,24 @@ def test_enter_with_banner_stores_banner(self) -> None: the menu opened (e.g. unreachable network interface). The banner is stored on the app for the draw pass to surface.""" app = self._make_app() - enter_settings_menu(app, banner="Configured interface unavailable.") + enter_settings_menu(app, banner="Configured interface unavailable.", action="Pick another interface.") assert app._settings_menu_active is True assert app._settings_menu_banner == "Configured interface unavailable." + assert app._settings_menu_banner_action == "Pick another interface." + + def test_a_banner_without_a_step_replaces_the_last_one(self) -> None: + app = self._make_app() + enter_settings_menu(app, banner="X", action="Do Y.") + exit_settings_menu(app) + enter_settings_menu(app, banner="Z") + assert app._settings_menu_banner_action == "" def test_exit_clears_banner(self) -> None: app = self._make_app() - enter_settings_menu(app, banner="X") + enter_settings_menu(app, banner="X", action="Do Y.") exit_settings_menu(app) assert app._settings_menu_banner == "" + assert app._settings_menu_banner_action == "" def test_exit_clears_video_disconnect_menu_flag(self) -> None: """Exiting resets the auto-opened bookkeeping so the disconnect @@ -671,9 +680,10 @@ def __init__(self) -> None: def _get_config_mtime(self) -> float: return 1234.5 - def _enter_settings_menu(self, *, banner: str = "") -> None: + def _enter_settings_menu(self, *, banner: str = "", action: str = "") -> None: self._settings_menu_active = True self._settings_menu_banner = banner + self._settings_menu_banner_action = action def _enter_url_editor( self, @@ -875,10 +885,10 @@ def _boom(*_a: object, **_k: object) -> None: assert app._swap_calls == ["rtsp"] assert app._config.video_source_type == "rtsp" # it runs assert app._settings_menu_active is True - assert app._settings_menu_banner == ( - "RTSP is live but could not be saved. " - "The settings file is empty. " - "Restart OpenFollow to start again with default settings, then switch again." + assert app._settings_menu_banner == "RTSP is live but could not be saved. The settings file is empty." + assert ( + app._settings_menu_banner_action + == "Restart OpenFollow to start again with default settings, then switch again." ) assert app._url_editor_calls == [] @@ -938,8 +948,8 @@ def test_confirm_rollback_on_swap_failure_reopens_settings_with_banner( assert app._config.video_source_type == "ndi" # rolled back assert app._source_type_selection_active is False assert app._settings_menu_active is True - assert "RTSP" in app._settings_menu_banner - assert "server unreachable" in app._settings_menu_banner + assert app._settings_menu_banner == "Couldn't switch to RTSP: server unreachable." + assert app._settings_menu_banner_action == "Configure it in the web UI, then try again." def test_keyboard_arrow_down_advances_cursor(self) -> None: from openfollow.runtime import app_modes @@ -1252,9 +1262,10 @@ def _exit_url_editor(self) -> None: exit_url_editor(self) - def _enter_settings_menu(self, *, banner: str = "") -> None: + def _enter_settings_menu(self, *, banner: str = "", action: str = "") -> None: self._settings_menu_active = True self._settings_menu_banner = banner + self._settings_menu_banner_action = action return FakeApp() @@ -1394,10 +1405,10 @@ def _boom(*_a: object, **_k: object) -> None: assert app._config.rtsp_url == "rtsp://192.0.2.20/stream" assert app._url_editor_active is False assert app._settings_menu_active is True - assert app._settings_menu_banner == ( - "RTSP URL is live but could not be saved. " - "The settings file is empty. " - "Restart OpenFollow to start again with default settings, then enter it again." + assert app._settings_menu_banner == "RTSP URL is live but could not be saved. The settings file is empty." + assert ( + app._settings_menu_banner_action + == "Restart OpenFollow to start again with default settings, then enter it again." ) def test_an_auto_chained_switch_that_runs_but_cannot_be_saved_says_so_in_settings( @@ -1417,10 +1428,10 @@ def _boom(*_a: object, **_k: object) -> None: app_modes.handle_url_editor_key(app, "Enter") assert app._swap_calls == ["rtsp"] assert app._config.video_source_type == "rtsp" - assert app._settings_menu_banner == ( - "RTSP is live but could not be saved. " - "The settings file is empty. " - "Restart OpenFollow to start again with default settings, then switch again." + assert app._settings_menu_banner == "RTSP is live but could not be saved. The settings file is empty." + assert ( + app._settings_menu_banner_action + == "Restart OpenFollow to start again with default settings, then switch again." ) def test_a_plain_edit_for_an_input_no_longer_running_is_saved_without_a_swap( @@ -1463,10 +1474,10 @@ def _boom(*_a: object, **_k: object) -> None: app_modes.handle_url_editor_key(app, "x") app_modes.handle_url_editor_key(app, "Enter") assert app._swap_calls == [] - assert app._settings_menu_banner == ( - "RTSP URL could not be saved. " - "The settings file is empty. " - "Restart OpenFollow to start again with default settings, then enter it again." + assert app._settings_menu_banner == "RTSP URL could not be saved. The settings file is empty." + assert ( + app._settings_menu_banner_action + == "Restart OpenFollow to start again with default settings, then enter it again." ) def test_enter_confirms_and_persists( @@ -1557,8 +1568,8 @@ def test_confirm_auto_chained_swap_failure_keeps_the_running_type_and_opens_sett assert saves == [{"rtsp_url": "garbage"}] assert app._url_editor_active is False assert app._settings_menu_active is True - assert "RTSP URL" in app._settings_menu_banner - assert "malformed URL" in app._settings_menu_banner + assert app._settings_menu_banner == "Couldn't switch to RTSP: malformed URL." + assert app._settings_menu_banner_action == "Configure RTSP URL in the web UI, then try again." def test_confirm_plain_edit_rebuilds_pipeline( self, @@ -1613,8 +1624,8 @@ def test_confirm_plain_edit_swap_failure_keeps_type_and_opens_settings( assert saves == [] assert app._url_editor_active is False assert app._settings_menu_active is True - assert "RTSP URL" in app._settings_menu_banner - assert "malformed URL" in app._settings_menu_banner + assert app._settings_menu_banner == "Couldn't switch to RTSP: malformed URL." + assert app._settings_menu_banner_action == "Configure RTSP URL in the web UI, then try again." def test_cancel_auto_chained_leaves_the_running_type(self) -> None: """Esc on the auto-chain path: the picked type never reached the @@ -1707,7 +1718,7 @@ def update(self, _dt): # noqa: ANN001 _browser_active=False, _config=AppConfig(), ) - app._enter_settings_menu = lambda *, banner="": setattr( # noqa: ARG005 + app._enter_settings_menu = lambda *, banner="", action="": setattr( # noqa: ARG005 app, "_settings_menu_active", True, @@ -1840,8 +1851,8 @@ def _enter_url_editor( {"banner": banner, "source_type": source_type}, ) - def _enter_settings_menu(self, *, banner: str = "") -> None: - self._settings_calls.append({"banner": banner}) + def _enter_settings_menu(self, *, banner: str = "", action: str = "") -> None: + self._settings_calls.append({"banner": banner, "action": action}) return FakeApp() @@ -1962,9 +1973,10 @@ def __init__(self) -> None: def _get_config_mtime(self) -> float: return 1234.5 - def _enter_settings_menu(self, *, banner: str = "") -> None: + def _enter_settings_menu(self, *, banner: str = "", action: str = "") -> None: self._settings_menu_active = True self._settings_menu_banner = banner + self._settings_menu_banner_action = action def _enter_url_editor( self, @@ -2068,10 +2080,12 @@ def _boom(*_a: object, **_k: object) -> None: assert app._config.v4l2_render_resolution == "2160p" assert app._field_choice_active is False assert app._settings_menu_active is True - assert app._settings_menu_banner == ( - "Render resolution is live but could not be saved. " - "The settings file is empty. " - "Restart OpenFollow to start again with default settings, then pick it again." + assert ( + app._settings_menu_banner == "Render resolution is live but could not be saved. The settings file is empty." + ) + assert ( + app._settings_menu_banner_action + == "Restart OpenFollow to start again with default settings, then pick it again." ) def test_an_auto_chained_switch_that_runs_but_cannot_be_saved_says_so_in_settings( @@ -2090,10 +2104,10 @@ def _boom(*_a: object, **_k: object) -> None: app_modes.confirm_field_choice_picker(app) assert app._swap_calls == ["v4l2"] assert app._config.video_source_type == "v4l2" - assert app._settings_menu_banner == ( - "USB Camera is live but could not be saved. " - "The settings file is empty. " - "Restart OpenFollow to start again with default settings, then switch again." + assert app._settings_menu_banner == "USB Camera is live but could not be saved. The settings file is empty." + assert ( + app._settings_menu_banner_action + == "Restart OpenFollow to start again with default settings, then switch again." ) def test_a_plain_pick_for_an_input_no_longer_running_that_cannot_be_saved_says_so( @@ -2113,10 +2127,10 @@ def _boom(*_a: object, **_k: object) -> None: app_modes.confirm_field_choice_picker(app) assert app._swap_calls == [] assert (app._config.video_source_type, app._config.v4l2_render_resolution) == ("rtsp", "2160p") - assert app._settings_menu_banner == ( - "Render resolution could not be saved. " - "The settings file is empty. " - "Restart OpenFollow to start again with default settings, then pick it again." + assert app._settings_menu_banner == "Render resolution could not be saved. The settings file is empty." + assert ( + app._settings_menu_banner_action + == "Restart OpenFollow to start again with default settings, then pick it again." ) def test_confirm_writes_selected_value_to_config( @@ -2207,7 +2221,8 @@ def test_confirm_same_type_swap_failure_keeps_the_running_value( assert app._field_choice_active is False assert app._settings_menu_active is True assert "Couldn't apply" in app._settings_menu_banner - assert "pattern rejected" in app._settings_menu_banner + assert app._settings_menu_banner.endswith(": pattern rejected.") + assert app._settings_menu_banner_action == "Try a different value, or edit in the web UI." def test_cancel_without_revert_just_closes( self, @@ -2403,7 +2418,9 @@ def _swap_fail(_cfg): assert app._field_choice_active is False assert app._settings_menu_active is True assert "Couldn't switch" in app._settings_menu_banner - assert "ndi library missing" in app._settings_menu_banner + assert app._settings_menu_banner.endswith(": ndi library missing.") + assert app._settings_menu_banner_action.startswith("Configure ") + assert app._settings_menu_banner_action.endswith(" in the web UI, then try again.") def test_process_input_noop_when_input_manager_missing( self, @@ -3141,11 +3158,11 @@ def _station(self, tmp_path): # noqa: ANN001, ANN202 path = tmp_path / "config.toml" for _ in range(2): # the second save leaves the first as the backup save_config(AppConfig(video_source_type="ndi", ndi_source_name="OLD (A)"), str(path)) - banners: list[str] = [] + banners: list[tuple[str, str]] = [] app = SimpleNamespace( _config=load_config(str(path), strict=True), _config_path=str(path), - _enter_settings_menu=lambda banner="": banners.append(banner), + _enter_settings_menu=lambda banner="", action="": banners.append((banner, action)), ) return app, path, banners @@ -3170,15 +3187,17 @@ def test_the_pick_is_saved_alone_and_the_reload_has_nothing_to_reconnect(self, t assert banners == [] @pytest.mark.parametrize( - ("content", "why"), + ("content", "said", "step"), [ - ("", "is empty. Restart OpenFollow to restore the settings from the latest backup copy"), - (" \n\t\n", "is empty. Restart OpenFollow to restore the settings from the latest backup copy"), - ("not [[ toml", "could not be read. Import a settings file to replace it"), + ("", "is empty", "Restart OpenFollow to restore the settings from the latest backup copy"), + (" \n\t\n", "is empty", "Restart OpenFollow to restore the settings from the latest backup copy"), + ("not [[ toml", "could not be read", "Import a settings file to replace it"), ], ids=["empty", "whitespace", "unparseable"], ) - def test_a_broken_file_is_left_alone_and_the_operator_told(self, tmp_path, content: str, why: str) -> None: # noqa: ANN001 + def test_a_broken_file_is_left_alone_and_the_operator_told( + self, tmp_path, content: str, said: str, step: str + ) -> None: # noqa: ANN001 from openfollow.runtime.app_modes import save_source_pick app, path, banners = self._station(tmp_path) @@ -3189,7 +3208,10 @@ def test_a_broken_file_is_left_alone_and_the_operator_told(self, tmp_path, conte assert path.read_text(encoding="utf-8") == content assert app._config.ndi_source_name == "NEW (B)" assert banners == [ - f"The NDI® source is live but could not be saved. The settings file {why}, then pick it again." + ( + f"The NDI® source is live but could not be saved. The settings file {said}.", + f"{step}, then pick it again.", + ) ] def test_a_save_that_failed_for_another_reason_points_at_the_storage(self, tmp_path) -> None: # noqa: ANN001 @@ -3201,5 +3223,5 @@ def test_a_save_that_failed_for_another_reason_points_at_the_storage(self, tmp_p save_source_pick(app, "ndi_source_name", "NEW (B)") assert banners == [ - "The NDI® source is live but could not be saved. Check the station's storage, then pick it again." + ("The NDI® source is live but could not be saved.", "Check the station's storage, then pick it again.") ] diff --git a/tests/test_configuration.py b/tests/test_configuration.py index 924b1699..879d7682 100644 --- a/tests/test_configuration.py +++ b/tests/test_configuration.py @@ -3078,8 +3078,8 @@ def test_a_switch_that_could_not_be_saved_is_saved_by_switching_again_once_the_f good = AppConfig(video_source_type="ndi", ndi_source_name="CAM-HOST (Camera 1)", rtsp_url="rtsp://192.0.2.20/a") save_config(good, path) app = _app_on_file(path) - banners: list[str] = [] - app._enter_settings_menu = lambda **kw: banners.append(kw.get("banner", "")) + banners: list[tuple[str, str]] = [] + app._enter_settings_menu = lambda **kw: banners.append((kw.get("banner", ""), kw.get("action", ""))) app._enter_url_editor = lambda **_kw: None app._available_source_types = [("rtsp", "RTSP")] app._selected_source_type_index = 0 @@ -3088,8 +3088,10 @@ def test_a_switch_that_could_not_be_saved_is_saved_by_switching_again_once_the_f app_modes.confirm_source_type_selection(app) assert app._config.video_source_type == "rtsp" assert banners == [ - "RTSP is live but could not be saved. The settings file could not be read. " - "Import a settings file to replace it, then switch again." + ( + "RTSP is live but could not be saved. The settings file could not be read.", + "Import a settings file to replace it, then switch again.", + ) ] save_config(good, path, replace_unreadable=True) # the operator repairs it diff --git a/tests/test_overlay_draw_hud.py b/tests/test_overlay_draw_hud.py index cb1a6408..cc9ae8a4 100644 --- a/tests/test_overlay_draw_hud.py +++ b/tests/test_overlay_draw_hud.py @@ -3056,6 +3056,28 @@ def test_an_auto_opened_banner_still_wins(self) -> None: assert "Video source (rtsp) is not available." in drawn assert "Nothing answered at X." not in drawn + def test_a_banner_carries_its_own_next_step_and_not_the_videos(self) -> None: + state = self._state(sentence="Nothing answered at X.", action="Check the camera is powered.") + state.settings_menu_banner = "RTSP is live but could not be saved." + state.settings_menu_banner_action = "Check the station's storage, then switch again." + cr = FakeCairo() + draw_settings_menu(FakeRenderer(), cr, state, 1920, 1080) + drawn = " ".join(cr.show_text_strings()) + + assert "Check the camera is powered." not in drawn + step = cr.find_texts("Check the station's storage")[0] + assert step.bold is False + assert step.rgba == COLOR_TEXT_MUTED + assert step.y > cr.find_texts("RTSP is live")[0].y + + def test_a_banner_without_a_step_does_not_borrow_the_videos(self) -> None: + state = self._state(sentence="Nothing answered at X.", action="Check the camera is powered.") + state.settings_menu_banner = "Pinned network interface 'eth1' is not available." + drawn = self._drawn(state) + + assert "Pinned network interface 'eth1' is not available." in drawn + assert "Check the camera is powered." not in drawn + def test_a_healthy_feed_draws_no_error_box(self) -> None: assert "ERROR" not in self._drawn(self._state(sentence="", error="")) diff --git a/tests/test_services_marker_visuals_remaining.py b/tests/test_services_marker_visuals_remaining.py index e1226531..e36dd4e2 100644 --- a/tests/test_services_marker_visuals_remaining.py +++ b/tests/test_services_marker_visuals_remaining.py @@ -201,6 +201,7 @@ def _build_app( _settings_menu_active=settings_menu_active, _settings_menu_index=0, _settings_menu_banner="", + _settings_menu_banner_action="", _source_type_selection_active=False, _available_source_types=[], _selected_source_type_index=0, @@ -574,6 +575,7 @@ def test_settings_menu_active_builds_items(self, monkeypatch: pytest.MonkeyPatch def test_settings_menu_banner_passes_through_to_state(self, monkeypatch: pytest.MonkeyPatch) -> None: app = _build_app(settings_menu_active=True) app._settings_menu_banner = "Configured IP unavailable." + app._settings_menu_banner_action = "Pick another interface." from openfollow.runtime import app_modes @@ -584,12 +586,15 @@ def test_settings_menu_banner_passes_through_to_state(self, monkeypatch: pytest. ) state = _build(app) assert state.settings_menu_banner == "Configured IP unavailable." + assert state.settings_menu_banner_action == "Pick another interface." def test_settings_menu_inactive_clears_banner(self) -> None: app = _build_app(settings_menu_active=False) app._settings_menu_banner = "stale" + app._settings_menu_banner_action = "stale step" state = _build(app) assert state.settings_menu_banner == "" + assert state.settings_menu_banner_action == "" def test_url_editor_passes_through_to_state( self, diff --git a/tests/test_webkit_browser.py b/tests/test_webkit_browser.py index aba5912d..02e5bfe8 100644 --- a/tests/test_webkit_browser.py +++ b/tests/test_webkit_browser.py @@ -796,8 +796,8 @@ def _make_app( ) app._opened = opened app._exited = exited - app._enter_settings_menu = lambda *, banner="": opened.append( - {"banner": banner}, + app._enter_settings_menu = lambda *, banner="", action="": opened.append( + {"banner": banner, "action": action}, ) def _exit() -> None: @@ -931,9 +931,11 @@ def test_fires_when_disconnected_past_deadline(self) -> None: banner = app._opened[0]["banner"] # The banner is the lead line only, as in the web box: the classified # sentence already names the source, so a headline repeating the URL - # above it was printing the address twice. The next step is drawn - # separately, from ``OverlayState.video_failure_action``. + # above it was printing the address twice. The next step is its own line. assert banner == "Nothing answered at rtsp://10.0.0.5/stream." + assert app._opened[0]["action"] == ( + "Check the camera is powered and on this network, or correct the address under Video Source." + ) assert "Reconnect attempt" not in banner assert app._video_disconnect_banner_shown is True assert app._video_disconnect_menu_open is True @@ -948,6 +950,24 @@ def test_banner_omits_optional_fields_when_empty(self) -> None: banner = app._opened[0]["banner"] assert "Error:" not in banner assert "Reconnect attempt" not in banner + assert app._opened[0]["action"] == "" + + def test_the_next_step_speaks_the_source_kind(self) -> None: + from openfollow.runtime import app_modes + from openfollow.video.failure import SourceKind + + app = self._make_app( + connected=False, + status_marker=SimpleNamespace( + failure=VideoFailure.UNREACHABLE, + source_name="CAM-HOST (Camera 1)", + error_message="", + reconnect_attempt=0, + ), + ) + app._video_receiver.source_kind = SourceKind.NAMED + app_modes.check_video_disconnect_banner(app) + assert app._opened[0]["action"] == "Check the source is running and visible to this station on the network." def test_latch_blocks_repeat_fire_after_dismiss(self) -> None: from openfollow.runtime import app_modes @@ -1049,9 +1069,13 @@ def test_auto_opened_menu_refreshes_banner_while_still_down(self) -> None: reconnect_attempt=0, ), ) + app._settings_menu_banner_action = "" app_modes.check_video_disconnect_banner(app) assert app._exited == [] # menu stays open assert app._settings_menu_banner == "Nothing answered at rtsp://10.0.0.5/stream." + assert app._settings_menu_banner_action == ( + "Check the camera is powered and on this network, or correct the address under Video Source." + ) assert "(ndi)" not in app._settings_menu_banner def test_no_close_when_menu_not_auto_opened(self) -> None: