Skip to content

Remove the old chain-based track model - #16

Merged
Ktechen merged 16 commits into
masterfrom
feat/update-hmi-track-editor
Oct 16, 2026
Merged

Ktechen merged 16 commits into
masterfrom
feat/update-hmi-track-editor

Conversation

@Ktechen

@Ktechen Ktechen commented Oct 15, 2026

Copy link
Copy Markdown
Owner

No description provided.

kevin and others added 16 commits October 14, 2026 20:45
The ecc@ecc plugin ships 408 skills, 71 agents and 97 commands for every
stack, and a plugin install is all-or-nothing: plugin.json declares
skills: ["./skills/"], so there is no per-skill enable. The --modules
machinery under manifests/ belongs to the standalone `ecc setup`
installer, and the lean@1 context profile says of itself that it does not
activate a harness. Selection therefore has to happen at use time, which
is what this section records.

Lists the nine skills and agents that fit a .NET 10 / Uno / Clean
Architecture repo, the two that need a caveat, and the gaps:

- csharp-testing is xUnit-based but prescribes FluentAssertions,
  NSubstitute and Testcontainers, none of which this repo uses; tests here
  are plain xUnit with hand-written doubles under Test/Trackify.Tests/Fakes/.
- mcp-server-patterns is Node/TypeScript only with no C# content, so it is
  not a reference for the ModelContextProtocol work in Infrastructure/Mcp/.
- ECC has no C#/.NET build resolver, so /ecc:build-fix has nothing to
  delegate to for a failed dotnet build.
- ECC has no Uno/XAML/MAUI/WinUI skill at all, leaving the whole app head
  uncovered.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The Streckenplaner's geometry was invented: 90-unit straights, radius-70
curves, 90-degree turns, 30-degree branch peel-off. None of those numbers
exist in the LEGO system, so a plan built with them cannot be plugged
together out of real track.

Replace it with the real dimensions, in studs (1 stud = 8 mm):

  straight   16 studs (53401)
  curve      R40, 22.5 degrees, 16 to the full circle (53400)
  point      32 studs main route, 22.5 degree diverging route (53407/53404)
  crossing   16x16 studs, 90 degrees (9V 32087)

The important consequence is that positions cannot be snapped to a grid.
A curve ends at 40*sin(22.5 degrees) = 15.3073... studs, so after a few
parts nothing sits on a round number; rounding would produce a plan that
looks tidy on screen and falls apart in plastic. The only safe invariant
is that every part is plugged into a port of another part, so a plan is a
graph of parts and port connections and world coordinates are derived by
walking it -- never stored, never entered.

Headings, by contrast, are exact: always a multiple of 22.5 degrees, so a
heading is an integer step 0..15 and compares exactly. Positions compare
against a tolerance.

Catalogue port poses are derived from the drawn spans rather than typed
in, so geometry and connection points cannot drift apart. The catalogue
holds only parts LEGO actually makes; the community radii R56..R104 are
each one more entry, not a code change.

The walker reports connections the geometry contradicts instead of
letting the last write win -- that is a loop the user drew which does not
really close, and saying "one curve short" is the most useful thing the
planner can tell them.

Verified by 77 tests, including: 16 R40 curves return to the start pose,
a stadium oval of 8 curves + straight + 8 curves + straight closes, and a
15-curve loop is diagnosed as one 22.5 degree step and one chord short.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two halves of "draw it instead of configuring it", both pure geometry.

TrackSnapper follows a stroke: from an open end it repeatedly picks the
straight or R40 curve whose far end lands nearest where the stroke will
be a part-length further on, with a small penalty for disagreeing with
the stroke's own direction so it does not zig-zag between a left and a
right curve where a straight tracks better. Greedy on purpose -- it runs
on every pointer move, and a preview that follows the finger now beats
one that is a millimetre closer later. The gesture may only lay straights
and curves; points and crossings are placed deliberately, never guessed.

TrackLoopCloser is a search, not a fit, because closing a loop has to
land on the target pose exactly and greedy following never will. Iterative
deepening gives the fewest parts that work, pruned by two admissible
bounds: no part advances further than a straight, and none turns by more
than one 22.5 degree step.

The property that makes the whole approach honest is tested directly: a
free-form stroke -- straight, quarter circle either way, or a wiggle --
always yields a plan the walker finds buildable, because nothing
free-form is ever stored, only whole catalogue parts plugged port to
port.

92 tests green, including: a quarter-circle stroke becomes exactly four
R40 curves, a circle one curve short is closed with exactly one curve,
two ends already nose to nose get a connection and no new track, and a
gap beyond the budget is reported as no answer rather than a bad fit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
"I generate it in the UI and import it into the CLI, and the rest no
longer interests me" -- so export/import is the feature, not a trailer.

The format (trackplan.v1.json) stores the GRAPH, not coordinates: parts
by catalogue key plus connections written as "p1:end" -> "p2:start". A
stored coordinate could describe a layout that cannot be plugged
together; a connection cannot. Document-local ids keep the file readable
by a person, which is the point of having one.

Drive behaviour sits in its own block per part, separate from topology,
because they are different kinds of fact -- and because the camera import
that comes later can only ever supply topology. The same reasoning puts an
optional measured "pose" on each part: a reconstruction from a photograph
hands over poses, and ports landing on each other within tolerance can be
paired into connections from there. Nothing reads it yet; it is the hook
that keeps that path open without changing the format.

Points now carry what they need to be driven: which hub and port throws
them, where the sensor watching the approach is plugged in, and entry
rules saying "a train arriving here, bound for there, needs the point set
this way". Destination-specific rules beat catch-all ones regardless of
list order. Acting on this is later work; the data exists now.

Persistence moves to EF Core migrations (the arc42 section 11 debt), with
dotnet-ef pinned as a local tool so CI and every machine generate the same
thing. A plan is read and written whole, in one transaction -- half a
graph would render as track that falls apart. Generated migrations are
marked as generated code in .editorconfig, since dotnet ef emits
block-scoped namespaces that IDE0161 rejects as an error here.

The DTO boundary grows to match: the architecture test now also forbids
the CLI from touching Trackify.Domain.Tracks, which caught a real slip --
`track show` was reading TrackMetrics directly to print metres. The
summary converts it instead.

trackify track import <file> [--replace] [--dry-run] | export | show

Verified by 109 tests and a real CLI run: importing a straight, two R40
curves and a wired left point reports 4 parts, 3 connections, 3 open ends,
and `track show` lists the parts needed, 79 studs of rail and confirms
every joint lines up.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
You pull from a glowing dot and real LEGO track follows your finger. That
is the whole mechanic, and building needs no properties at all: speed,
direction and sensor are edited afterwards by tapping a piece.

Nothing free-form is ever stored. A gesture becomes a stroke in studs,
TrackSnapper turns the stroke into whole catalogue parts, and the drawing
comes back out of walking the graph -- so everything on screen is
buildable out of real track by construction. Drag near another open end
and TrackLoopCloser searches for the parts that genuinely reach it rather
than bending anything to fit.

The editor is no longer a chain of segments with a properties table beside
it. Strands are gone: a plan is a graph, so a part has ports, not a
position in a list, which is why reorder and duplicate went with them.
Points and crossings are placed deliberately at the aimed-at open end,
because the snapper may only ever lay straights and curves -- a gesture
must not guess a turnout. Undo/redo covers building; property edits stay
off the stack, since one tap on "Zurück" undoing a slider nobody thought
of as a step is worse than no undo.

Within the house rules: a new DrawGestureBehavior turns pointer press/
drag/release into commands with a point, so drawing adds no code-behind;
it sits on the Canvas itself, so points arrive in canvas coordinates and
the zoom (still a bound Viewbox size) never has to be undone by hand.
TrackPlanFitter computes the view from the committed parts only, so the
plan does not rescale under the finger while a preview grows past its
edge.

Found while verifying, and fixed here:

- Uno does not implement Shape.StrokeLineJoin, so it is gone from the XAML.
- A database created by the old EnsureCreated() has no
  __EFMigrationsHistory, so Migrate() died with "table already exists" --
  which says nothing about what to do, against a file holding the user's
  saved trains. TrackifySchema now detects it and says to move the file
  aside; deleting someone's data is not ours to decide.
- That message arrived wrapped in "Could not resolve type ..." because
  commands are built by DI, so the CLI's handler now reports the innermost
  cause.
- SecondViewModel is started with a fire-and-forget load, where a store
  failure would vanish as an unobserved task exception and leave an editor
  that silently has no plan. Load and save now surface it on the canvas.

Verified: all four buildable heads green, 109 tests green, the desktop
head launches (exit 0), and `track import` still round-trips against a
fresh store. The legacy-database path was exercised directly and reports
the intended message. arc42 §05 updated, ADR-17 (graph, not coordinates)
and ADR-18 (migrations) recorded.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The graph editor (ADR-17) replaced the strand chain, but the shared core still
carried the model it replaced. Nothing read it any more, so it goes:

- Domain: `Trains/TrackSegment` and the `SegmentType` enum. `CurveDirection`,
  `SwitchRoute` and `TrackDirection` stay — the graph model uses all three.
- Application: `TrackSegmentDto`/`TrackSegmentMapping`, `ITrackSegmentRepository`,
  `ITrackPlanService`/`TrackPlanService`, `TemplateType`, and the two bits that
  existed only to feed the old "Teil anhängen" toolbar (`TrackPartOption` and
  `LegoinoCatalog.TrackParts`, neither of which had a caller left).
- Infrastructure: `SqliteTrackSegmentRepository`, the `TrackSegments` DbSet and
  its key mapping.

The table is dropped by its own `DropTrackSegments` migration rather than by
editing `InitialCreate`, so a database that already ran the initial migration
drops the table instead of failing to match it.

Docs pulled in step: the Domain/Application/Infrastructure tables in arc42 §5
now name the graph model, §8's class diagram shows `TrackPart`/`TrackLink`, R6
in §1 and debt D-6 in §11 point at the surviving files, and ADR-17 records that
the chain model is fully replaced. CLAUDE.md and README.md said `TrackSegment`
was a Domain entity; CLAUDE.md also still claimed the schema is created with
`EnsureCreated()` and no migrations, which this commit's migration contradicts.

Verified: 106 tests green (109 minus the three deleted `TrackPlanServiceTests`),
all four buildable heads green, and `trackify track show` against a fresh store
creates only Trains/TrackParts/TrackLinks with both migrations applied.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
All 106 existing tests stop at Domain and Application. SecondViewModel and
TrackPlanFitter live in the app head, so nothing exercised the half of the
editor the user actually touches.

Why a second test project: Source/Trackify declares only head TFMs, with
no plain net10.0, so the existing net10.0 Trackify.Tests cannot reference
it. This one uses Uno.Sdk and targets net10.0-windows10.0.19041.0 rather
than net10.0-desktop, because the desktop head pulls in WPF transitively
and NETSDK1136 then demands a Windows-targeted consumer. The consequence
is recorded in ADR-13: Windows only, deliberately not in ci.yml, where the
plain net10.0 project stays the gate.

The tests drive the ViewModel the way the canvas does -- press, drag,
release -- and check what the plan became:

- a drag from an open end lays track the walker finds buildable
- a drag that curves answers with R40 curves, not only straights
- a press away from every open end lays nothing
- mid-drag the preview is drawn but does NOT count as laid, which is the
  distinction a ghost preview lives or dies by
- undo takes a run back and redo restores it
- the oval template is a closed loop of 18 real parts
- a point lands with a SwitchConfig, and deleting a piece takes its
  connections with it
- a store that cannot be read surfaces on the canvas instead of vanishing
  as an unobserved task exception

On the fitter, the test that matters most is that a growing preview never
moves the committed plan: the view is computed from committed parts alone,
or the whole layout would shift and rescale under the user's finger. Also
pinned: ToStuds is the exact inverse of the fit, a small plan is not blown
up past the default scale, an oversized one is shrunk to the canvas, and a
point draws as two runs of rail.

Renamed from Trackify.App.Tests: that namespace collided with the app's own
App type (CS0435).

131 tests green: 106 + 25.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Finishes the app head's test coverage. The editor screen was done; this
adds the train screen and the per-part presentation logic.

MainViewModel (both partials), 27 tests: adding a train selects it and
clears the search so it is actually visible; duplicating puts the copy
right after its original; deleting falls back to the first train left;
duplicate and delete stay disabled with nothing selected. The search
filters by name and by hub type, case-insensitively and trimmed. The
active count follows a train being switched off. Choosing a colour marks
exactly one swatch.

Hub control: a platform without Bluetooth says so instead of scanning; a
scan that finds nothing offers no hub; a found hub is held for
confirmation and becomes a train, or binds to the train that already
knows its MAC rather than adding a second. Connecting addresses the hub
by its discovered device id and falls back to the typed-in BLE address --
the HubKey rule, which is easy to get backwards. Disconnecting clears the
flag and re-enables Connect.

Only the two offline branches of ApplyConnection are exercised. With a
server URL set, the sync genuinely dials out, and a test must not depend
on that.

TrackPiece, 15 tests: the speed-colour thresholds, a German label for
every catalogue part, the sensor marker's kind, and that the speed label
and sensor marker land on opposite sides of the rail -- the label offset
against the outward normal, the marker along it. That last one caught my
own wrong assumption about the sign before it caught anything else.

ShellViewModel has no tests on purpose: it is an empty class that exists
only so Navigation's root RouteMap has something to bind.

175 tests green: 106 in the net10.0 project, 69 here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Towards 100% line / >=90% branch coverage. Two parts: saying what the
target covers, and starting on the biggest gap.

The scope is declared in both test projects rather than left implicit,
because a percentage over code that cannot run is a number without a
meaning. Excluded, deliberately:

  Infrastructure/Ble      BlueZ over D-Bus; needs a Pi with a real radio
  Application/Services    Plugin.BLE and WinRT; compile only on their heads
  app head                Platforms/**, App.xaml.cs, XAML partials, and the
                          two layout code-behinds, which need a visual tree

Writing tests for those would mean mocking the hardware away and then
asserting the mocks. The Threshold switch is left off until the scope is
actually covered; turning it on now would fail every build.

Measured in scope, the picture changes a lot: Infrastructure 68% -> 89%
line and 13% -> 45% branch, Application 67% -> 72%. Total 62% -> 72.7%
line, 62.7% branch.

Then the CLI, which was the real hole at 0.5%. The track commands are now
covered through their exit codes, which is the CLI's actual contract --
what a script or a systemd unit reacts to. `show` returns 1 for a plan
whose joints do not line up; `import` returns 1 for a missing file without
touching the store, passes --replace through, and a --dry-run validates
without writing; `export` writes the file and passes the plan name on.

Finding the way in took a TDD loop of its own: ExecuteAsync is protected
on Spectre's AsyncCommand, and ICommand exposes ExecuteAsync rather than
Execute, so tests go in the way Spectre's own executor does. That harness
is the reusable part -- the nine remaining commands can follow it.

CLI 0.5% -> 14.2% line. 117 + 69 = 186 tests green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Focused on Domain, Application and Infrastructure. Three gaps the coverage
report pointed at, largest first.

SqliteTrackPlanRepository was at 0% -- the whole class, including code
added on this branch. Now tested against a real SQLite file, because the
things worth proving only exist there: that parts and connections survive
together, that a point's SwitchConfig round-trips through its nested JSON
column (motor, sensor, trigger colour and entry rules), that Replace
throws the previous plan away, that Append continues past the existing
positions instead of reusing them, and that enum fields come back as the
values they went in as.

LegoinoCatalog and its seven option records were at 0%. The useful
assertion there is not that the data exists but that every enum value a
picker can hold is in it -- a missing entry makes the lookups throw in
front of the user. Each of SpeedFunctionType, TrackDirection, SensorType
and SensorActionType is now checked against its list.

TrainMapping was at 50%: only one direction was exercised. A round trip
now pins every field, which is the only thing that catches a field
forgotten in the mapping.

Two of my own assumptions were wrong and the tests caught them, not the
code: SpeedFunctionType.Custom carries no formula on purpose (the user
types their own), and a fresh Train is active by default. Both now assert
what is true rather than what I assumed. The repository's append offset
was right too -- my "four distinct positions" assertion was wrong, because
the fixture left both parts at order 0.

In scope: Application 71.8% -> 86.7% line, Infrastructure 89.0% -> 92.8%
line and 45% -> 75% branch. Total 72.7% -> 77.0% line, 64.7% branch.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
TrackPlanTopology sat at 57%: the four existing track tests all use it as a
builder via Add/Connect, so Disconnect, Remove, TryGetPart and IsPortUsed
were never exercised — and those are exactly the edits the editor makes
when a user deletes or unplugs something.

Now covered, including the two properties that matter beyond the happy
path: that every edit returns a new topology and leaves the original
alone, which is what lets the editor's undo stack be a plain stack of
these values; and that removing a part takes every connection touching it
with it, rather than leaving a link pointing at nothing.

Written in AAA form with the phases marked, which is the style the rest of
the suite is being moved to.

Domain 92.5% -> 94.3% line, 74.8% -> 76.0% branch. Total 77.0% -> 77.5%.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
It was the largest single gap left at 28.6% line / 38.9% branch: the
service that every drive command in both front-ends goes through was
barely exercised.

Now covered: that connecting prefers the discovered device id over the
typed-in BLE address and falls back the other way when there is none --
the HubKey rule, which is easy to get backwards and which both front-ends
depend on; that a train with no hub at all is refused loudly on connect
(a silent no-op there would look like a connection that worked) but is a
quiet no-op on disconnect, speed and LED, where there is nothing to undo;
that the speed is clamped to the +/-100 a motor accepts; and that the LED
is sent as the three bytes of the catalogue's hex.

The debounce gets two tests, because its value is entirely in what it does
NOT send: a single change arrives after the pause, and three changes in
quick succession -- a dragged slider -- leave only the last one reaching
the hub.

Written as a sister file rather than extending TrainControlServiceTests, so
the pending AAA refactor and this do not collide in one file. AAA form with
the phases marked.

Application 86.7% -> 94.0% line, 74.6% -> 81.5% branch. 167 tests green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
My own slip: a `git add -A` while measuring coverage checked in
coverage.cobertura.xml for both test projects. Coverlet rewrites them on
every -p:CollectCoverage run, so they would have produced a noisy diff
forever and tempted someone to resolve conflicts in generated XML.

Untracked (the files stay on disk) and added to .gitignore. The existing
/coverage/ entry did not cover them, because Coverlet writes its report
next to the test project rather than into a folder of its own.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
master's 62a5dce ("Keep the planner and editor panes from clipping their
content") landed on the editor files this branch rewrote, so the merge
needed deciding rather than resolving mechanically. Its substance is kept,
not discarded:

- The pane widths (260/340) and splitter minimums auto-merged into
  SecondPage and its code-behind, and are verified present.
- SegmentInspector's header conflicted. Taken from master: the Grid with a
  `*` column instead of a horizontal StackPanel, which is the actual fix —
  a StackPanel measures its children with infinite width, so the id
  overflowed the pane instead of trimming. Taken from this branch: the
  binding, since SelectedSegment became SelectedPiece when the chain model
  gave way to the graph.
- StrandList was a modify/delete: deleted here in e3df738 because there are
  no strands any more. It stays deleted, but its fix did not die with it —
  the NoWrap + CharacterEllipsis it gained is now on PieceList's name row,
  which has the same no-horizontal-scroll pane around it.

Verified after merging: desktop head builds clean, 167 + 69 tests green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Operator-facing, page by page: what the planner's canvas, toolbar, parts list
and inspector each do, and what every setting means. Written from the XAML's
own labels so it matches what is on screen rather than describing an
invented layout.

The five screenshot slots under docs/images/ are left empty on purpose. The
Skia surface comes back blank here and the WASM canvas times out the browser
tool, so the captures have to come from a real device; the file names and
what each should show are listed at the end of the document.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
docs/editor-view.md is now the English version and docs/editor-view.de.md the
German one, cross-linked at the top. Both quote the app's German labels
verbatim, because that is what is on screen; the English one glosses each
label so it reads without German.

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

Copy link
Copy Markdown

@Ktechen
Ktechen merged commit 7359ece into master Oct 16, 2026
6 checks passed
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