fix: refuse a layout override whose manifest excludes the live topology - #3413
Conversation
`LayoutManifest.compatibleTopologies` was validated by
`validateLayoutManifest` and documented as a restriction, but no runtime
path read it: `resolveSkinId` returned the preferred skin id straight
from `ctx.layoutPreference`, so `?layout=flagship-probe` mounted the
probe on an IC-7300 (`1/ab`) although its manifest declares only
`2/ab_shared` and `2/main_sub`.
Each forced-preference branch in `resolveSkinId` now also requires
`admitsLiveTopology`, which reads the manifest registered under the skin
id and compares its declared classes against `${structuralCount}/
${scheme}` derived from `ctx.capabilities` — the same string
`radio-view-model-adapter.ts` composes for `topologyId`. A refused
preference falls through to the default, and the refusal is reported
once per (layout, live class) via `console.warn`, the channel
`lib/stores/qa-cockpit-override.ts` already uses to explain its own
no-op override.
An id with no registered manifest, and a manifest that declares every
class in `TOPOLOGY_CLASSES`, are admitted without deriving anything, so
an unrestricted preference still resolves before capabilities arrive.
The manifests that can refuse today are dual-receiver-cockpit,
flagship-probe, peer-split, unified-instrument and panadapter-first.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…be e2e on a two-receiver fixture `resolveSkinId` refused a layout preference whose manifest excludes the live receiver topology and then returned `desktop-v2`, ignoring the manifest's own `fallbackLayoutId`: six shipped manifests declare that field and no runtime path read it. A refusal now walks the chain and takes the first hop that is a built-in skin admitting the live topology — dual-receiver-cockpit and flagship-probe to sdr-test, peer-split to lcd-cockpit, unified-instrument and panadapter-first through the refused peer-split to lcd-cockpit — ending at `desktop-v2` when the chain names an id already visited or names no registered layout. The one `console.warn` per refused (preference, live class) pair now names the skin actually mounted. `tests/e2e/i18n/desktop-geometry.spec.ts`'s two T185 cases booted the probe on the default single-receiver fixture (class 1/ab), which the gate refuses, so `[data-testid="flagship-geometry-probe"]` never appeared and the i18n visual smoke step failed. Both now boot the `topology-2-main-sub` catalog fixture; each already pins the rail column it measures, so the pair measurement is unchanged in shape. Review fixes: the stub caps in `design-language-activation.component.test.ts` name `ab_shared`, the scheme `rigs/ftx1.toml` declares under `[vfo]`; the `reportedRefusals` docstring no longer claims `admitsLiveTopology` never reads it; `qa-cockpit-override.ts` records the topology refusal as a second way `?layout=` no-ops. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
|
Agent Review: PASS 32df3bf Head confirmed: 1. The merge brought only the mergeMerge-base equals 2. The "no rework needed" conclusion holds
The PR's hunk changes Main did touch this spec (the merge added 3. The defect is still live on
|
The resolveSkinId doc comment's T198 bullet claimed that routing the 'standard' branch through resolveWithFallback "could only report a refusal and return the same id". The opposite holds: desktop-v2's manifest declares all four TOPOLOGY_CLASSES, so admitsLiveTopology admits it without deriving anything and no refusal could be reported. Delete the clause; keep "the walk's own endpoint", which the fallback walk's DEFAULT_SKIN_ID landing establishes. Narrow the same drift in resolveWithFallback's doc comment: the deleted list claimed an unregistered candidate "continues down that candidate's own chain", but a built-in unregistered id is admitted outright (no manifest excludes anything) and a non-built-in unregistered id ends the walk at DEFAULT_SKIN_ID — the walk-termination tests pin both. The replacement states only the checked mechanics: a hop requires built-in and admitted, every other candidate's own fallbackLayoutId is followed, and a candidate naming none ends the walk at DEFAULT_SKIN_ID. Comment-only change; no code or test behavior touched. Part of MOR-2425 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Agent Review: PASS 6793ca0 Head confirmed: 1. Delta is comment-onlyOne file, comments only; the only changed hunks are the 2. My finding — remainder "the walk's own endpoint" is establishedThe deleted clause is gone and nothing replaced it: The remainder is true at this head:
So the 3. The new tail sentence — checked as a fresh claimNew text ( It matches the code exactly ( Both sub-cases:
The old wording was false for both: for a no-manifest built-in the id is taken as the mounted hop (it has no "own chain" to continue down), and for a non-built-in the walk ends at the default rather than traversing a chain. The correction is itself correct. 4. Rest of the file's comments at this headRe-read and checked, all true: 5. Check / lintNo test run is needed for a comment-only change — none was performed as acceptance. (The throwaway sub-case-(a) double in item 3 was run and reverted; 6. CI as found (reported, not waited on)
REQUIRED BEFORE MERGEPR body refresh (my earlier item #1 recurs at the new head). The body was updated to |
The defect
LayoutManifest.compatibleTopologieswas validated byvalidateLayoutManifestand documented as a restriction on which radios a layout's skin may mount, but
no runtime path read it.
resolveSkinIdreturned the preferred skin idstraight from
ctx.layoutPreference, so?layout=flagship-probemounted thegeometry probe on an IC-7300 — live class
1/ab— although the probe'smanifest declares only
2/ab_sharedand2/main_sub.LayoutManifest.fallbackLayoutIdwas in the same state: declared by sixshipped manifests, validated, and read by no runtime path.
The gate
admitsLiveTopology(id, capabilities)reads the manifest registered under theskin id and compares its declared classes against
${structuralCount}/${scheme}derived from
ctx.capabilities— the same stringlib/runtime/adapters/radio-view-model-adapter.tscomposes for itstopologyId, over the sameTOPOLOGY_CLASSESvocabulary a manifest declaresagainst.
Two shapes are admitted without deriving anything: an id with no registered
manifest, and a manifest that declares every class in
TOPOLOGY_CLASSES.Neither can exclude a radio, so an unrestricted preference still resolves
before capabilities arrive. A manifest that does exclude a class must see its
own: an underivable topology (no capabilities yet, or a
vfoScheme/receiverspair
derivePresentationCapabilitiesreports asinvalid-topology) isrefused.
The fallback chain
A refused preference no longer drops straight to the default.
resolveWithFallbackwalks
fallbackLayoutIdand takes the first hop that is both a built-in skinand admitted; a hop that is unregistered, registered but not loadable, or
itself refused continues down that hop's own chain. A
visitedset bounds thewalk: a chain naming an id already seen stops there. The endpoint when the walk
finds nothing is
desktop-v2.Where each gated preference lands on a radio its manifest excludes:
dual-receiver-cockpitsdr-testsdr-testflagship-probesdr-testsdr-testpeer-splitlcd-cockpitlcd-cockpitunified-instrumentpeer-split(refused) →lcd-cockpitlcd-cockpitpanadapter-firstpeer-split(refused) →lcd-cockpitlcd-cockpitsdr-testandlcd-cockpiteach declare all four topology classes, which iswhy the same table holds for
1/ab, for no capabilities at all, and for aninvalid topology. A test asserts that property of both endpoints rather than
assuming it.
Gated skins and what the user sees
The gated ids are the five whose registered manifest leaves a class out:
dual-receiver-cockpit,flagship-probe,panadapter-first,peer-split,unified-instrument— read off the shipped manifests by the test, nothand-listed. Three of them (
peer-split,unified-instrument,panadapter-first) are options incomponents-v2/layout/StatusBar.svelte'sskin picker; the other two are reachable only through
?layout=<id>.On a single-receiver radio, picking one of those three now renders the
fallback instead. The persisted preference is not touched —
resolveSkinIdispure and writes nothing — so the picker still shows the chosen value while a
different skin is on screen, and the only signal is one
console.warnnamingthe layout actually mounted. Surfacing the refusal in the picker itself is
T209.
The e2e fixture change
frontend/tests/e2e/i18n/desktop-geometry.spec.ts's two T185 cases booted theprobe on
fixture()'s default caps (IC-7300,receivers: 1,vfoScheme: 'ab'→ class
1/ab), which the gate refuses, so[data-testid="flagship-geometry-probe"]never appeared and the i18n visualsmoke step failed on the previous head (2 failed / 87 passed). Both now pass
'topology-2-main-sub'asboot()'s 7th parameter.No assertion in those two cases carries a geometry literal, and both already
pin the rail column they measure (200px narrow, 400px wide) with their own
addStyleTag, so nothing had to be re-tuned. Measured on the two-receiverdeck, from the cases' own attachments:
key.width131.12,unkey.width130.83,
columnGap8 → pair 269.95 in both cases;actions.width200.00(narrow, buttons stacked) and 400.00 (wide, one line).
Tests and mutations
Gates run locally at the pre-merge head
277b399d, beforeorigin/mainwas merged into the branch (kept as the record of that head; the current head's evidence is under "CI at this head"):npx vitest run src/skins/__tests__/ src/presentation/ src/__tests__/design-language-activation.component.test.ts— 66 files, 1390 tests, 0 failednpm run check—COMPLETED 4844 FILES 0 ERRORS 0 WARNINGS 0 FILES_WITH_PROBLEMSnpm run lint— no output (clean)npm run lint:boundaries— no output (clean)npm run buildthennpx playwright test -c ./playwright.i18n.config.ts tests/e2e/i18n/desktop-geometry.spec.ts— 42 passed (45.2s)Mutations, each applied to the restored tree and reverted afterwards:
reportRefusal(id, capabilities, DEFAULT_SKIN_ID); return DEFAULT_SKIN_ID;immediately after the admit check inresolveWithFallback, so a refusal never walks the chain.src/skins/__tests__/registry.test.ts: 12 failed / 81 passed — every "routes the refused X preference to its declared fallback" case, every "never mounts X while the live topology is underivable" case, "names the layout the walk actually mounted", and the once-per-pair report case.while (candidate !== null && !visited.has(candidate)) { visited.add(candidate);withwhile (candidate !== null) {. The run never produced a result: the cycle registered by "stops at the default when the chain names an id already visited" spins the resolver forever, and the vitest worker had to be killed after >600s with no test output. That is the failure mode the bound exists to prevent.candidatetohop(9 occurrences insideresolveWithFallback), a behaviour-preserving edit: 93 passed / 0 failed, unchanged from the unmutated file.undefinedback asboot()'s topology argument in both T185 cases: both fail atboot'sawait expect(page.locator('[data-testid="flagship-geometry-probe"]').first()).toBeVisible()— the exact CI red this PR fixes.Class sweep for the fixture defect:
qaLayout/?layout=<gated id>appears intests/e2e/i18n/desktop-geometry.spec.tsonly.presentation-switch-resources. component.test.tsandpresentation-switch-tx.component.test.tsmention?layout=flagship-probein comments but select skins through their ownwidthToSkintable, not the resolver; the visual specs mount fixturesdirectly. No other site reaches a gated skin through
resolveSkinId.Review fixes carried in this head
design-language-activation.component.test.ts: the stub caps saidvfoScheme: 'main_sub'under a comment calling2/main_sub"the FTX-1'sMAIN/SUB pair".
rigs/ftx1.tomldeclaresscheme = "ab_shared"under[vfo]; the stub and the sentence now sayab_shared.skins/registry.ts: thereportedRefusalsdocstring claimed it was "neverread by
admitsLiveTopology" while that function read it for the throttle.Reporting now lives in its own
reportRefusal, and the clause is deleted.skins/registry.ts: theresolveSkinIdbullet claiming "the two that do notcarry the term" is replaced — it did not count the
isMobilebranch.lib/stores/qa-cockpit-override.ts: one sentence recording the topologyrefusal as a second way
?layout=no-ops, tied toskins/__tests__/registry.test.ts's "layout-manifest topology gate".Size
Census at
6793ca0940d17da0b3e80e0a838d1104de87ca8e, the head afterorigin/main(6d7e712f) was merged into the branch and two untrue comment claims were deleted: 6 files, 388+/29- = 417 changed lines, re-measured withgit diff --numstat origin/main...HEAD.At the 6-file soft threshold, under the 600-line one. One unit of work: the
gate and the fallback it falls to are the same decision in
resolveSkinId, thetwo test files pin that decision, and the e2e fixture and the
qa-cockpit-override.tssentence are the two places the gate's own effectshows up elsewhere. Splitting would land a gate whose e2e is red.
CI at this head
At the current head
6793ca09:Agent Review Gate— pass, bound to this head;quick— run 35039192901 (the comment-only delta's own run). The pre-delta head32df3bfahadquickpass in 5m45s, run 35038270578. The paragraph below records the earlier run 34362092289 (4m59s) at the pre-merge head, whose selection reasoning still applies. Its own selection step reportsCore checks selected: false/Frontend checks selected: true: this PRtouches only
frontend/, soquick.yml'scorefilter skipped the pytest andruff block and the frontend block ran. Frontend vitest: 470 files, 11651 tests,
all passed. Frontend i18n visual smoke: 89 passed (previous head: 2 failed / 87
passed).
visual— pass.Agent Review Gate— red pending the independentreview directive.
internal-identifier self-check — empty
Linear: MOR-2425
🤖 Generated with Claude Code