Skip to content

refactor(logic-simplifier): the Channels pane walked the device list twice - #426

Merged
tylerkron merged 3 commits into
mainfrom
logic-simplifier-channels-rebuild-20260920
Sep 21, 2026
Merged

tylerkron merged 3 commits into
mainfrom
logic-simplifier-channels-rebuild-20260920

Conversation

@tylerkron

Copy link
Copy Markdown
Contributor

Not merging — for review.

The simplification, in one line

ChannelsPaneViewModel.Rebuild ran two back-to-back foreach (var device in devices) passes over the same list; one loop now does both jobs.

Daqifi.Avalonia/Daqifi.Desktop/ViewModels/ChannelsPaneViewModel.cs   +0 / −3

That is the whole production diff, and it is the same figure with or without comments — no comment was touched, so there is no second number to report for it. The rest of the PR is the test file this routine required before the file could be touched at all: Daqifi.Avalonia.Tests/ViewModels/ChannelsPaneRebuildTests.cs, +376 raw / +229 excluding comments and blanks (147 lines of XML doc). Whole PR: +376/−3 raw, +229/−3 code-only.

So this diff is net-additive overall and I am not going to pretend otherwise: the logic shrank by three lines, and the reason the PR is nonetheless worth its size is the second half — Rebuild had no behavioural coverage at all before this, and it is the method that builds the entire Channels pane.

Why it is simpler, not just shorter

Before:

foreach (var device in devices)
{
    ConnectedDeviceNames.Add(device.Name);
}

foreach (var device in devices)
{
    foreach (var channel in device.DataChannels.OrderBy(...))
    { /* build and shelve the tile */ }
}

Nothing sat between the two loops, and the second pass read nothing the first produced. The tile constructor takes (channel, this, device.Name, HasMultipleDevices) — the device name comes off the device in hand, and HasMultipleDevices is assigned from devices.Count before either loop starts. So the split was accidental, not load-bearing: a reader had to check for a dependency between the passes to find out there wasn't one, and the list was walked twice to no end.

The merged loop states the actual rule directly — for each connected device: chip it, then shelve its channels — which is one sentence instead of two that have to be related by the reader.

Behaviour preservation

This is a behaviour-preserving change, so "tests that fail unfixed" is zero and says nothing. What backs it instead:

1. The tests landed first and passed against unmodified code. Commit 0b4ec35 (tests) precedes f8966b0 (the refactor) in this branch's history, and the 10 tests were green against production code byte-identical to origin/main.

2. Mutation controls on pristine origin/main — 10 mutations, 10 correct kills. Each mutation's anchor text was asserted present before rewriting, so a mutation that silently did nothing could not be misread as a surviving one; the file was restored from a backup and touched after every round so MSBuild never handed the runner a stale assembly.

mutation on origin/main test killed
HasMultipleDevices assigned after the tile loop — the exact hazard merging the loops creates Every_tile_labels_its_device_when_more_than_one_is_connected
.OrderBy(c => c.Name, ChannelNameComparer.Instance) dropped Tiles_run_device_by_device_and_numerically_within_each
tile loop walks the devices in reverse Tiles_run_device_by_device_and_numerically_within_each
chip loop walks the devices in reverse The_pane_chips_every_connected_device_in_connection_order
_channelOwners[channel] = device deleted The_settings_drawer_opens_on_the_device_that_owns_the_channel
PWM no longer shelves a digital channel as an output Each_channel_shelves_by_type_direction_and_pwm
RecomputeCounts() dropped The_header_counts_the_tiles_and_the_active_ones_separately
HasConnectedDevice = false One_device_names_it… + Two_devices_report_multiple…
HasConnectedDevice = true With_nothing_connected_the_pane_is_empty
HasMultipleDevices = devices.Count > 0 One_device_names_it… + A_single_device_leaves_the_tile_labels_off

The last two exist specifically because two tests assert a bool at its default (HasConnectedDevice == false on an empty pane, ShowDeviceLabel == false on a single device). An assertion on a default value passes when the assignment that should produce it is deleted, so each of those needed a mutation forcing the value the other way before it could be called a guard. All 10 tests are now demonstrably killable.

3. A double loop is a reordering risk, so the tests pin the sequence, not the contents. ConnectedDeviceNames, AnalogInputs, DigitalInputs and DigitalOutputs are all bound as ItemsSource (ChannelsPanePrototype.axaml, ChannelsMobileView.axaml), so their order is what the user reads. Tiles_run_device_by_device_and_numerically_within_each asserts the full device/channel sequence across two devices, and feeds both devices their channels in the byte-wise order (AI0, AI10, AI2) so a pane that stopped sorting would produce exactly the wrong sequence rather than accidentally the right one.

4. The rendered result is byte-identical. tools/parity-audit/run.sh --check-baseline → 39/39 screens matching the committed baselines/macos-arm64.sha256, which itself holds 39 lines. That includes all 9 desktop-min-* screens and all four that render this view-model directly (desktop-3-channels, desktop-min-3-channels, mobile-landscape-2-channels, mobile-portrait-2-channels).

5. Build + suite. Desktop build, iOS Release/iossimulator-arm64 build, and dotnet test all exit 0; suite 1126/1126 (1116 on main + the 10 added here). packages.lock.json untouched. .github/scripts/check_test_sqlite_pooling.py --glob passes over the new file.

Scope notes

Handed forward, deliberately not folded in

The orphaned doc block in DeviceLogsViewModel (lines 342–361) is still there and I left it alone: it belongs to a different view-model and a different feature, and pairing it with this would make a PR whose title cannot state one thing. One correction for whoever picks it up — it is a MOVE, not a deletion. The block accurately documents RaiseSdGateChanged (declared at line 437 with no docs of its own) and records a real upstream limitation (daqifi-core#394: a silent TCP death leaves IsConnected stale). It has merely drifted to sit above WatchSelectedDeviceConnectivity's own doc block. Deleting it would destroy accurate information.

🤖 Generated with Claude Code

tylerkron and others added 3 commits September 20, 2026 09:40
ChannelsPaneViewModel.Rebuild builds the entire Channels pane — the device
chips, the three tile shelves, the channel -> owning-device map the settings
drawer resolves through, and the header counts — and had no behavioural
coverage at all. Every collection it fills is bound as an ItemsSource, so a
change that reorders or mis-shelves a tile builds green and is visible only on
screen.

These 11 tests pass against unmodified production code. They assert the
resulting SEQUENCE, not just membership.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Draining Dispatcher.UIThread from an arbitrary xUnit worker throws VerifyAccess
once another class has claimed the dispatcher thread first: the test passed in
isolation and failed in the full suite. The remaining ten reach the same
Rebuild through the synchronous constructor path.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…twice

Rebuild ran two back-to-back 'foreach (var device in devices)' passes over the
same list: the first added each device's name to ConnectedDeviceNames, the
second built its tiles. Nothing sat between them and the second pass read
nothing the first produced -- ChannelTileViewModel's constructor takes the
device name and HasMultipleDevices, both already in hand before either loop
starts -- so the split was accidental, not load-bearing.

One loop now does both. Order is unchanged in both directions: names still
follow connection order, tiles still run device by device with each device's
channels sorted, because the merged loop walks the same list in the same order.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@tylerkron

Copy link
Copy Markdown
Contributor Author

Round 1. Head f8966b0. Production diff is +0/−3 in ChannelsPaneViewModel.Rebuild (two consecutive passes over the same device list merged into one); the rest is the guarding test file this routine required, with the mutation controls listed in the PR body.

@tylerkron

Copy link
Copy Markdown
Contributor Author

/agentic_review

@qodo-code-review

Copy link
Copy Markdown

PR Summary by Qodo

Simplify Channels pane rebuild into a single device pass

✨ Enhancement 🧪 Tests 🕐 20-40 Minutes

Grey Divider

AI Description

• Consolidates device chips and channel tile rebuilding into one device-list pass.
• Adds ten behavioral tests covering ordering, shelving, counts, labels, and ownership.
Diagram

graph TD
  CM["Connection Manager"] --> RB["Pane Rebuild"] --> DP["Single Device Pass"] --> CS["Sorted Channels"] --> PC["Pane Collections"] --> FS["Counts and Drawer"]
Loading
High-Level Assessment

The PR's single-pass approach is proportionate and preferable: the two former loops had no dependency requiring separation, so combining them removes redundant traversal while preserving ordering. Retaining separate passes or extracting a new projection abstraction would add indirection or scope without improving behavior, and the focused regression tests adequately protect the refactor.

Files changed (2) +376 / -3

Refactor (1) +0 / -3
ChannelsPaneViewModel.csRebuild device chips and channel tiles in one pass +0/-3

Rebuild device chips and channel tiles in one pass

• Merges two consecutive iterations over connected devices so each device name is added immediately before its sorted channels are processed. Existing pane ordering, ownership mapping, shelving, counts, and settings synchronization remain unchanged.

Daqifi.Avalonia/Daqifi.Desktop/ViewModels/ChannelsPaneViewModel.cs

Other (1) +376 / -0
ChannelsPaneRebuildTests.csAdd behavioral coverage for Channels pane rebuilding +376/-0

Add behavioral coverage for Channels pane rebuilding

• Adds ten tests that pin empty, single-device, and multi-device pane output; channel shelving and ordering; active and total counts; tile labels; and channel ownership resolution. Introduces lifecycle-safe helpers and a configurable channel test double while serializing access to the process-wide connection manager.

Daqifi.Avalonia.Tests/ViewModels/ChannelsPaneRebuildTests.cs

@qodo-code-review

qodo-code-review Bot commented Sep 20, 2026 •

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (0) 📘 Rule violations (0) 📎 Requirement gaps (0)

Grey Divider

Great, no issues found!

Qodo reviewed your code and found no material issues that require review

Grey Divider

Tip of the day
💡 Did you know, you can turn on the rule miner and Qodo learns your standards from review history

More tips ↗ | Customize Qodo ↗ | Qodo docs ↗

Grey Divider

Previous reviews

Review updated until commit f8966b0 🚀 Fast

Results up to commit f8966b0 🚀 Fast


No changes from previous review

Grey Divider

Qodo Logo

@tylerkron

Copy link
Copy Markdown
Contributor Author

/agentic_review

@qodo-code-review

Copy link
Copy Markdown

Code review by qodo was updated up to the latest commit f8966b0

@tylerkron

Copy link
Copy Markdown
Contributor Author

Shepherd round: firing an independent Qodo round on the unchanged head f8966b0. The two clean rounds on record are the builder's own; on this loop an independent unchanged-head round has raised a real finding on three of the six PRs it has been run on, so it is worth the round even with nothing changed. Both existing rounds are graded 🚀 Fast (read off the folded "Previous reviews" section, not the visible summary).

Also re-deriving the claims independently: the mutation controls, the reordering risk in collapsing the double foreach, and the seven-way merge build with #418/#419/#420/#422/#423/#425.

@tylerkron

Copy link
Copy Markdown
Contributor Author

/agentic_review

@qodo-code-review

Copy link
Copy Markdown

Code review by qodo was updated up to the latest commit f8966b0

@tylerkron

Copy link
Copy Markdown
Contributor Author

Qodo-clean, CI green — ready for review.

Head f8966b0073595079ac44b89d872f364c32ebe9ea — this is the current head and the exact head_sha of CI run 35533903659, conclusion success. I pushed nothing, so the verification behind this note is CI's on f8966b0, read from the API — not a local re-build of this PR.

Independent Qodo round (round 3, on the unchanged head). Trigger timestamp recorded before posting (20:10:55Z); summary updated_at advanced 20:01:14Z → 20:11:19Z, past it. I read the content, not just the timestamp: Bugs (0) / Rule violations (0) / Requirement gaps (0), no placeholder, and no review skipped string. Grade 🚀 Fast, read off the folded "Previous reviews" section. Settled 13.6 min and re-checked both surfaces: summary unchanged, and GraphQL reviewThreads gives 0 qodo threads / 0 unresolved (author leg validated against control PR #196 → 3 threads ignoring isResolved). One honest caveat: on an unchanged head the summary body is byte-identical to its pre-trigger state, so "it fired" rests on the updated_at advance plus Qodo's fresh commit notice, not on new text.

Re-proof of the claims. Every figure in the body re-derives: +0/−3 production; +376 raw / 229 code-only with 376−229 = 147 doc lines; suite 1126 on main+this (delta +10, not 1126 — main measures 1116 here); --check-baseline 39/39 where I read the manifest's own line count (39, 9 of them desktop-min-*) and counted 39 rendered PNGs with all 9 desktop-min-* present, so the denominator is the real one.

  • Ordering. The two loops wrote to disjoint collections and HasMultipleDevices/HasConnectedDevice/DeviceName are all assigned before either one, so per-collection sequence is unchanged — only notification interleaving differs. Downstream is safe: both views bind these as plain ItemsSource with no comparer, no SortDescription, no index dependency, and there is no CollectionChanged subscriber on any of them anywhere in the repo.
  • Mutations. Re-ran 3 of the 10 myself (backup-restore + touch after each, so no stale assembly): reversing device iteration killed 2 tests; dropping the OrderBy killed 1; forcing the tile ctor's HasMultipleDevices argument to false killed exactly Every_tile_labels_its_device_when_more_than_one_is_connected.
  • The bool-at-default fix is real, and that third mutation is the proof. ShowDeviceLabel is get-only and set only in the ctor, so false is its default; the single-device test asserting False is half a test on its own, but the two-device test asserting True forces it away from the default — and that is the one the mutation kills.

Seven-way merge (main + #418 #419 #420 #422 #423 #425 #426): tree 1369c47cadab7bd1b63035a108f8ad85fb0b99b2, reproduced three ways (octopus, ascending, descending) — all agree, no conflicts in any order. Intermediates match the earlier sweep exactly: five-way 8599ae34, six-way fd7c5e1b. Desktop + iOS Release/iossimulator-arm64 build clean, packages.lock.json untouched, suite 1155 / 0 failed / 0 skipped, and --check-baseline 39/39 on the merged tree.

Counts reconcile against raw per-PR deltas I measured individually (main + each PR alone), signs preserved:

suite delta
main 4716ce1 1116 —
+#418 1121 +5
+#419 1113 −3
+#420 1140 +24
+#422 1116 +0
+#423 1116 +0
+#425 1119 +3
+#426 1126 +10

1116 +5 −3 +24 +0 +0 +3 +10 = 1155, matching the measured seven-way total. Quoting Passed; on macOS Skipped is 0 at every step, so Passed == Total and #423's Passed→Skipped conversion is a Windows-side effect invisible here — which is why its delta is +0 rather than negative.

Removal hazards (the #189 CS0115 shape) cleared: greped the merged tree across .cs and .axaml for every member #419/#422/#425 delete — PauseForFlashAsync, SendScpiMessage, ImportFromStreamAsync, EnsureAnyDeviceConnected, SwitchToNewInterface, IsDuplicate — and the only hit is a prose mention of PauseForFlashAsync inside an XML doc comment documenting its own removal. Searched the type name DuplicateDeviceCheckResult too, since target-typed new(...) hides a constructor. Duplicate added types are ruled out by the merged tree compiling clean.

Only #418 branched before current main (base ef7d18c); the other six share base 4716ce1.

Not merging.

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