From 2b17ad3938c65acf962bf5c1ce560a3a049e2e3b Mon Sep 17 00:00:00 2001 From: DonislawDev Date: Sun, 2 Aug 2026 14:11:38 +0200 Subject: [PATCH] fix(gui): lock start-only fields on every surface, not just the page A field marked start_only in the registry is meant to grey out for as long as a session runs (conventions 7 and 21). That held for the Control page, whose form App._sync_running_ui refreshes, and silently did not hold for a field rendered by a WINDOW: the Settings window owns a separate ControlForm, and nothing re-read the running state for it. So "Capture only the targeted traffic" stayed clickable for the whole session whenever that window was open before START. Opened after a start it came up correctly disabled, because the build path reads is_locked - which is why this never looked broken. Measured on the fake Tk both ways, before and after. - SettingsWindow.refresh() now refreshes its form's field states, so the lock heals on the tick whatever moved the state, not only on start/stop. - App._sync_running_ui() ticks the open windows, so the lock is immediate rather than up to one 700 ms tick late. - The guard now DERIVES its subjects from fields.FIELD_DEFS and resolves each one to the surface that renders it, instead of naming "duration" and the filter combobox by hand. The old shape tested two examples, which is how narrow_filter was added to the registry, shipped unlocked, and kept the suite green. Verified by mutation: removing either half turns it red. Co-Authored-By: Claude Opus 5 --- CHANGELOG-INTERNAL.md | 26 +++++++++++++++++ CHANGELOG.md | 9 ++++++ beantester/gui/app.py | 7 +++++ beantester/gui/panels/settings.py | 13 ++++++++- tests/test_gui_release_fixes.py | 47 +++++++++++++++++++++++++------ 5 files changed, 93 insertions(+), 9 deletions(-) diff --git a/CHANGELOG-INTERNAL.md b/CHANGELOG-INTERNAL.md index 87cf8c1..369d08c 100644 --- a/CHANGELOG-INTERNAL.md +++ b/CHANGELOG-INTERNAL.md @@ -15,6 +15,32 @@ flags, exit codes, the NDJSON schema, on-disk file formats, or the facade's publ a `### BREAKING` section placed FIRST in that version, and each such line is prefixed with `**BREAKING:**`. A breaking change also requires a version bump by the owner (convention 34). +## [Unreleased] + +### Fixed + +- **A START-only field rendered by a WINDOW never locked.** `App._sync_running_ui` refreshed + `self.form` only, so `narrow_filter` - which lives on `SettingsWindow`'s own `ControlForm` + (`surface="settings"`, convention 42) - stayed `state="normal"` for the whole session whenever + the window was open BEFORE START. Opened AFTER a start it came up correctly disabled, because + the build path reads `is_locked` and nothing re-read it afterwards. Measured on the fake Tk both + ways before and after the fix. Two halves, both guarded: `SettingsWindow.refresh()` now calls + `form.refresh_field_states()` (self-healing on the 700 ms tick, whatever moved the state), and + `_sync_running_ui` ticks the open windows so the lock is immediate rather than a tick late. + Conventions 7, 21 and 42. + +### Tests + +- `tests/test_gui_release_fixes.py::test_start_only_fields_are_locked_while_a_session_runs` + rewritten to DERIVE its subjects from `fields.FIELD_DEFS` instead of naming `duration` and the + filter combobox by hand, and to resolve each field to the surface that renders it + (`SECTIONS[].surface`, with `CHOICE` belonging to `App`, not to a form). The old shape was a test + of two examples, so `narrow_filter` was added to the registry, shipped unlocked and left the + suite green - PROJECT_NOTES rule 2.6 (one value, several consumers, a guard on one of them) and + the same mistake convention 16 records. It now opens the Settings window BEFORE the session + starts, which is the order that was broken. Verified by mutation: removing either half of the + fix turns it red. + ## [0.4.0] - 2026-08-01 ### BREAKING diff --git a/CHANGELOG.md b/CHANGELOG.md index 249557a..0f975d5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,15 @@ All notable changes to Bean Network Tester. The format follows [Keep a Changelog](https://keepachangelog.com/); versions follow SemVer. +## [Unreleased] + +### Fixed + +- In the Settings window, "Capture only the targeted traffic" stayed clickable after you pressed + START, if the window was already open at the time. Ticking it did nothing until the next + session. It now greys out for as long as the session runs, with the same "locked while running" + note as every other option that is only read at start. + ## [0.4.0] - 2026-08-01 **The short version.** This release is about numbers you can trust and targeting that catches what diff --git a/beantester/gui/app.py b/beantester/gui/app.py index dfe5040..439e8c4 100644 --- a/beantester/gui/app.py +++ b/beantester/gui/app.py @@ -1708,6 +1708,13 @@ def _sync_running_ui(self): if self.form is not None: # every other START-only field (today: "Run time") locks with it self.form.refresh_field_states() + # ...and so do the ones rendered by a WINDOW. The Settings window has its + # own ControlForm carrying "narrow_filter", so refreshing only self.form + # left that checkbox live for the whole session whenever the window + # happened to be open before START. The tick would heal it within 700 ms; + # doing it here makes the lock immediate, like every other surface. + with crashlog.quiet("gui.app"): + self.windows.refresh() self._form_changed = True self._refresh_dirty() self._refresh_start_enabled() diff --git a/beantester/gui/panels/settings.py b/beantester/gui/panels/settings.py index cfa33ca..285ec75 100644 --- a/beantester/gui/panels/settings.py +++ b/beantester/gui/panels/settings.py @@ -198,8 +198,19 @@ def _sync_scope_status(self): label.pack_forget() def refresh(self): - """Ticked by the App while this window is open (PanelWindow.refresh).""" + """Ticked by the App while this window is open (PanelWindow.refresh). + + ``refresh_field_states`` is here because this window renders START-only + fields (``narrow_filter``) on its OWN ``ControlForm``, and + ``App._sync_running_ui`` only ever refreshed the Control page's form. A + window opened BEFORE the session started therefore kept the checkbox + clickable for the whole run, while the same field opened AFTER the start + came up correctly disabled - the build path reads the registry, nothing + re-read it afterwards. Ticking it here heals the state whatever moved it, + not just start/stop. + """ with crashlog.quiet("gui.panels.settings"): + self.form.refresh_field_states() self._sync_scope_status() # -- preference rows ------------------------------------------------------- # diff --git a/tests/test_gui_release_fixes.py b/tests/test_gui_release_fixes.py index 5f77814..673de0e 100644 --- a/tests/test_gui_release_fixes.py +++ b/tests/test_gui_release_fixes.py @@ -231,21 +231,52 @@ def test_a_target_that_matches_nothing_says_so_on_the_page(): def test_start_only_fields_are_locked_while_a_session_runs(): - """"Run time" is consumed by BeanEngine.start(), exactly like the traffic - filter - so, exactly like the filter, it must not look editable mid-session.""" + """EVERY field the registry marks start_only greys out mid-session, on + whichever surface renders it. + + This used to name ``duration`` and the filter combobox by hand, which made it + a test of two EXAMPLES rather than of the rule (convention 16's mistake, and + PROJECT_NOTES rule 2.6: one value, several consumers, a guard on one of them). + ``narrow_filter`` was added to the registry later, landed on the Settings + window's own ControlForm, and stayed clickable for an entire session whenever + that window was open before START - with this test green throughout. Deriving + the list from FIELD_DEFS means the fourth start_only field cannot repeat it. + + The window is opened BEFORE the session starts on purpose: that is the order + that was broken. Opened after, the build path already read the registry. + """ run_gui(""" - assert app.form.entries["duration"].kw.get("state") in (None, "normal") + from beantester.fields import CHOICE, FIELD_DEFS, FIELDS, SECTIONS + + surface_of = {key: sec.surface for sec in SECTIONS for key in sec.fields} + start_only = [f.key for f in FIELD_DEFS if f.start_only] + assert start_only, "no start_only field in the registry - the rule lost its subject" + + app.open_window("settings") + settings_form = app.windows._open["settings"].form + + def widget(key): + # the traffic filter is a CHOICE and belongs to App, not to a form + if FIELDS[key].kind == CHOICE: + return app.filter_cb + form = settings_form if surface_of[key] == "settings" else app.form + return form.entries[key] + + for key in start_only: + assert widget(key).kw.get("state") in (None, "normal", "readonly"), key app.running = True app._sync_running_ui() - assert app.form.entries["duration"].kw.get("state") == "disabled" - assert app.form.labels["duration"].kw.get("style") == "CardOff.TLabel" - assert app.filter_cb.kw.get("state") == "disabled" + for key in start_only: + assert widget(key).kw.get("state") == "disabled", ( + key + " stayed editable while a session was running") app.running = False app._sync_running_ui() - assert app.form.entries["duration"].kw.get("state") == "normal" - assert app.filter_cb.kw.get("state") == "readonly" + for key in start_only: + expected = ("readonly" if FIELDS[key].kind == CHOICE + else "normal") + assert widget(key).kw.get("state") == expected, key """)