Skip to content

feat(core): "Internet only", the mirror of LAN mode, plus GUI fixes - #142

Merged
donislawdev merged 5 commits into
masterfrom
feat/lan-split-and-gui-options
Aug 19, 2026
Merged

feat(core): "Internet only", the mirror of LAN mode, plus GUI fixes#142
donislawdev merged 5 commits into
masterfrom
feat/lan-split-and-gui-options

Conversation

@donislawdev

Copy link
Copy Markdown
Owner

Three things that were asked for, plus two defects the work uncovered on the way.

"Internet only" - the mirror of LAN mode

A second checkbox under "Traffic to modify", and --internet-only on the command line: it
cuts the local network and leaves the internet up, for testing an application whose intranet
server, NAS or printer has gone away. Pipeline step 2b, reason internet_only, counter
drop_internet_only. The numbered pipeline 1..12 is unchanged.

Loopback survives it. utils.is_lan_ip is deliberately not the mirror of is_local_ip:
a machine talking to itself is not "the local network", and cutting 127.x would take down a
local development server on the very machine running the tool. Both the tooltip and the
READMEs say so, together with the half nobody expects - the router is on the local network,
so DNS asked of it stops with everything else and the internet then looks broken.

Measured while writing the predicate, because it reads the other way round: IPv4 multicast is
globally routable as far as ipaddress is concerned, so mDNS and SSDP sit on the internet
side of both switches.

Both switches on at once is allowed and said out loud in the log. They judge the same packet
from opposite sides and can never both fire on it, so each counter reports its own half and
only loopback gets through. Refusing the combination was rejected: it is the union of two
impairments, like --loss 100.

Two constraints that shaped the change:

  • The flag may not start with lan-. Measured: a second --lan-* option makes the --lan
    abbreviation ambiguous and argparse then refuses it outright, silently breaking a documented
    shortcut of --lan-mode.
  • decide() sat exactly on the complexity ceiling. The two gates went behind one statement
    calling _address_class_cut, guarded so an ordinary session still pays two attribute reads
    and no call. Measured: the metric counts branch statements and not the boolean operators
    inside them, so the obvious nested pair reads as 30 while this reads as 29. The ceiling was
    not raised.

A switch to hide the Control page's search box

Settings -> Display, on by default. It applies immediately rather than on the next tick: a
widget that appears or disappears up to 0.7 s after the click reads as a broken checkbox.

Hiding clears. The marks are painted on the form, so a standing query would leave fields
highlighted with no box left to clear them from, the sections the search unfolded would stay
unfolded, and a debounce in flight would repaint both after the bar was gone. Bringing the bar
back names what to sit before, because pack hands out space in call order. Ctrl+F declines
when the box is hidden and falls back to the connection table, instead of focusing an unmapped
widget and swallowing the keystrokes that follow the shortcut.

The Statistics copy menu was white

ttk styles do not reach a classic menu - on Windows it is a native popup - and the connection
table wrapped its menu in the dark theme while this one did not, two files apart. Nothing in
the suite could see it: the fake tkinter records colours and never renders them. Two guards,
because "the wrapper is called" and "the menu is dark" are two claims, and the first one walks
the AST so the next menu somebody adds cannot repeat this.

Two defects found on the way, not asked for

  • A reproduction command left out --narrow-filter. Found by running the CLI and reading
    its own Reproduce: line, not by a test - the suite was green. settings_to_cli is a
    hand-written list that had fallen behind the registry by two flags, so a session started
    with a narrowed capture produced a command that re-runs a wider one: the packet counts of
    the re-run cannot match the report they came from. The reproduction report carried the fact
    faithfully all along, which is why nobody noticed. New guard walks the field registry and
    demands each flag appear in the command.
  • Pref.hint was dead text for checkbox rows. The Settings window returns before the hint
    is drawn for anything but a numeric row, so a 250-character explanation on "Show only the
    targeted traffic" was written, translated into both languages and shown to nobody. The field
    registry has had a guard for exactly this since a field did the same thing; the preference
    registry now has its mirror. Nothing was lost by deleting the key - checked line by line, the
    tooltip already carried everything it said.

Contracts

NDJSON, the CSV export and the reproduction report all gained a key. Additive, so this is not
a breaking change and the version is untouched.

Verification

The full suite runs here, on both platforms. Locally: the guards of everything touched (501
of them), ruff, mypy and the GUI smoke, plus nine entries added to the mutation registry,
all caught.

Beyond the suite, because it is the part the suite cannot reach:

  • the live GUI at 150% DPI in Polish - the new checkbox beside its sibling, the summary line,
    the new counter cell, the search box disappearing and coming back in its own place, and
    Ctrl+F falling through to the connections table;
  • the CLI end to end - the counter moving, the both-switches line in the log, the reproduction
    command carrying both flags, and the same command run twice;
  • a config file written by an older version, which has no such setting, still loading;
  • a rig against the real driver: three channels - a LAN peer, a public resolver and a
    loopback echo it owns - through four phases, twice, with the drop counts repeating exactly.
    It was then shown able to fail: with the loopback carve-out mutated out, it exits non-zero
    and names exactly the two phases whose loopback column broke.

donislawdev and others added 5 commits August 19, 2026 20:51
The right-click menu on Statistics came up in the SYSTEM colours - a white box
in the middle of a dark program - while the connection table's menu was dark.
ttk styles do not reach a classic tk.Menu (on Windows it is a native Win32
popup), so the only difference was one missing call: the table wraps its menu in
theme.style_menu, this one did not.

Nothing in the suite could see it: the fake tkinter records colours and never
renders them. So two guards, because "the wrapper is called" and "the menu is
dark" are two separate claims:

- test_repo_conventions.py::test_every_context_menu_is_handed_to_the_dark_theme
  walks the AST of beantester/gui/** and requires every Menu(...) call to sit
  inside style_menu(...). The rule, not the example - a test naming today's two
  menus would pass on the day a third arrives bare, which is how this one got in.
- test_gui_release_fixes.py::test_the_statistics_copy_menu_is_dark_like_every_other_context_menu
  checks the built menu carries the theme's background, foreground and accent,
  and that the cached instance is reused.

Both proven by mutation (two entries in MUTATIONS, both caught), and confirmed on
a real Tk window: the menu now renders dark on both Live and Session.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Settings -> Display gains "Show the search box on the Control page", on by
default. Turning it off takes the bar away immediately, not on the next tick:
a widget that appears or disappears up to 0.7 s after the click reads as a
broken checkbox rather than a slow one.

- gui/prefs.py: one Pref, worded positively - an unticked "Hide ..." box is a
  double negative, and it would be the only switch in that window where a tick
  means less than an untick.
- gui/pages/__init__.py: pref_changed(app, key) broadcasts to any page that
  exposes on_pref_changed. It sits beside focus_search because addressing a
  page belongs to the page registry - and because gui/app.py is on the size
  ratchet with zero headroom, so a Pref naming an App method had nowhere to go.
- gui/panels/settings.py: both row kinds now write through one _store(), so
  "a preference changed" has a single meaning.
- gui/pages/control.py: hiding CLEARS. The marks are painted on the form, so a
  standing query would leave fields highlighted with no box left to clear them
  from, the sections the search unfolded would stay unfolded, and a debounce in
  flight would repaint both after the bar was gone. Bringing it back passes
  before=, because pack hands out space in call order and the bar would
  otherwise return under the whole page body.
- Ctrl+F declines when the box is hidden and falls back to the connection
  table, instead of focusing an unmapped widget and swallowing the keystrokes
  that follow the shortcut.

Guards: four tests in test_prefs.py, the hidden-box case added to the existing
Ctrl+F guard, three entries in MUTATIONS (all caught). The pack order is the
one claim the fake tkinter cannot make - it keeps children in creation order -
so that half was verified on live Tk, together with the layout after the bar
is gone and the Ctrl+F fallback.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
SettingsWindow._build_pref_row handles BOOL and ACTION first and returns, so
only a NUMBER row ever reaches the line that draws Pref.hint. scope_view_to_target
declared one anyway: 250 characters, written, translated into both languages and
shown to nobody. Nothing raises when that happens, which is how it survived.

The field registry has had a guard for exactly this since narrow_filter did the
same thing, and its docstring records the consequence: an invisible hint makes
the TOOLTIP swell into a wall trying to carry the same explanation. That is what
happened here too - tips.scope_view is the longest string in the language files.

Nothing is lost by deleting the key. Checked line by line: the tooltip already
says what the hint said - which surfaces the preference narrows, and that it
changes the VIEW and never the capture or the impairment - plus three things the
hint did not. Rendering hints for BOOL rows was rejected: it would leave the two
registries with opposite rules about the same widget kind, and the Scope card's
two switches would stop reading as a pair.

Guard: test_prefs.py::test_only_prefs_that_can_show_a_hint_declare_one, the
mirror of the field one, with a MUTATIONS entry that reddens it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A second checkbox under "Traffic to modify": it cuts the local network and
leaves the internet up, for testing an app whose intranet server, NAS or
printer has gone away. CLI: --internet-only. Pipeline step 2b, reason
"internet_only", counter drop_internet_only.

LOOPBACK SURVIVES IT. utils.is_lan_ip is deliberately not the mirror of
is_local_ip: a machine talking to itself is not "the local network", and
cutting 127.x would take down a local development server on the very machine
running the tool. Said in the tooltip and in both READMEs, together with the
half nobody expects - the router is on the local network, so DNS asked of it
stops with everything else, and the internet then LOOKS broken.

Measured while writing the predicate, because it reads the other way round:
IPv4 multicast is globally routable as far as ipaddress is concerned, so mDNS
and SSDP sit on the internet side of both switches.

Both switches on is allowed and said out loud in the log. They judge the same
packet from opposite sides and can never both fire on it, so each counter
reports its own half and only loopback gets through. Refusing the combination
was rejected: it is the union of two impairments, like --loss 100.

The flag may not start with "lan-". Measured: a second --lan-* option makes the
--lan abbreviation ambiguous and argparse then refuses it outright, silently
breaking a documented shortcut of --lan-mode.

decide() sat exactly on the complexity ceiling, so the two gates went behind one
statement calling _address_class_cut, guarded so an ordinary session still pays
two attribute reads and no call. Measured: ruff counts branch statements and not
the boolean operators inside them, so the obvious nested pair reads as 30 and
this reads as 29. The ceiling was not touched.

Guards: the gate, the predicate (loopback, multicast, unclassifiable input), the
both-on split, GATES in the property test - which had to learn a per-gate remote
address, since the two 2b gates want opposite address classes - the sixth row in
the remote-endpoint direction table, and a new test asking both address-class
gates to leave the opposite class AND loopback alone in both directions. Five
MUTATIONS entries, all caught.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
One of the pair public and the other not is a trap for anyone reading the
facade: the obvious question about these two is which of them counts loopback,
and an asymmetric export invites guessing at it. Additive, and the launcher
facade re-exports it with the rest.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@donislawdev
donislawdev merged commit d0e99df into master Aug 19, 2026
13 of 14 checks passed
@donislawdev
donislawdev deleted the feat/lan-split-and-gui-options branch August 19, 2026 21:10
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant