Files
website/client/test/navOverrides.test.js
wtclaude b517d7b2df feat(theming): dropdown sections and added links in the public header
Phase 10 of docs/website/THEMING_AND_NAV.md, asked for before the edge -> main
cutover. An admin can now create dropdown sections in the public header, organise
the coded entries into them, and add links of their own.

This deliberately amends §7, which said the override layer "cannot introduce a
`to` that is not already in the hardcoded NAV array". That stays true of every
CODED entry; an admin may now also add a link, restricted to a same-origin path —
no scheme, no protocol-relative //host. A link carries no gate of its own and
needs none: the page behind it enforces its own access, so an added link
advertises a route and never grants one.

The invariant is kept structurally rather than by vigilance. Coded entries live
in an `items` map whose keys must be routes the base array declares, so that map
cannot invent a route; everything that CAN name an arbitrary path lives in
`links`, which is the one place the path rule is applied — on both the write and
the read path.

nav_public therefore grew a { items, sections, links } wrapper. A bare map still
reads as the items map, and a nav with no sections still stores one, so this
changed nothing for a nav that does not use it. Free to do now because nothing
has shipped; after the cutover it would have needed a migration.

The Public tab gets its own editor. A public section is an entry in the
top-level order that the admin created and can drag among the pills, unlike the
admin sidebar's four coded sections, where only membership moves — that is a tree
rather than a list of groups. Deleting a section returns its entries to the top
level rather than removing them, which is the one destructive act this screen
could otherwise commit.

The dropdown opens on click and never on hover, and its trigger is not a link: a
hover menu is unusable on touch, and a trigger that navigates means tapping to
open takes you somewhere instead. Escape closes and returns focus, an outside
press closes, navigating closes, and Arrow Up/Down walk the items.

pruneNav applies the shard-feature gate inside a section and drops one it leaves
empty, so a dropdown never opens onto nothing.

Also fixes a bug this surfaced in the phase 6-8 code: the save path judged "does
this route still exist?" against the palette — the base array already filtered to
what the editing admin can see — so on the public header a feature-gated row's
override could never be carried through and would have been silently reset.
Membership is now judged against the full coded nav while the rows still come
from the palette.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-08 00:42:19 -05:00

545 lines
23 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { test } from 'node:test'
import assert from 'node:assert/strict'
import {
applyNavOverrides,
buildNavRows,
buildNavOverrides,
buildPublicNav,
pruneNav,
buildPublicNavOverrides,
} from '../src/lib/navOverrides.js'
// The nav-override merge (docs/website/THEMING_AND_NAV.md §7.1) — the one piece
// of this feature with real correctness risk, so it is tested in isolation from
// React. Two properties matter above all others:
//
// 1. No override, or a useless one, renders the coded nav untouched.
// 2. The override cannot add a route, cannot touch a role/feature gate, and
// cannot un-hide anything. It is presentation only.
const FLAT = [
{ label: 'Home', to: '/', end: true },
{ label: 'News', to: '/site/news' },
{ label: 'Wiki', to: '/wiki' },
{ label: 'Shard', to: '/site/shard', feature: 'status' },
]
const GROUPED = [
{ items: [{ to: '/admin', label: 'Dashboard', end: true, roles: ['admin', 'editor', 'moderator'] }] },
{
title: 'Content',
items: [
{ to: '/admin/posts', label: 'Posts', roles: ['admin', 'editor'] },
{ to: '/admin/wiki', label: 'Wiki', roles: ['admin', 'editor'] },
],
},
{
title: 'System',
items: [
{ to: '/admin/settings', label: 'Settings', roles: ['admin'] },
{ to: '/admin/users', label: 'Users', roles: ['admin'] },
],
},
]
const labels = (nav) => nav.map((i) => i.label)
const groupLabels = (nav) => nav.map((g) => [g.title ?? null, g.items.map((i) => i.label)])
// ── The untouched path ────────────────────────────────────────────────────
// Most instances will never set these keys. Absence must be a true no-op, and
// cheap: the same array reference back means no needless re-render either.
test('no override returns the base nav unchanged', () => {
for (const overrides of [null, undefined, '', 0, [], 'not an object']) {
assert.equal(applyNavOverrides(FLAT, overrides), FLAT)
}
})
test('an override with nothing usable in it returns the base nav unchanged', () => {
assert.equal(applyNavOverrides(FLAT, {}), FLAT)
// Every field here is unusable: unknown route, blank label, non-numeric order,
// hidden as a string rather than the boolean true.
assert.equal(
applyNavOverrides(FLAT, {
'/does/not/exist': { label: 'Ghost', hidden: true },
'/wiki': { label: ' ', order: 'first', hidden: 'yes' },
}),
FLAT,
)
})
// ── The security boundary ─────────────────────────────────────────────────
// The single most important negative case: the override layer must never be a
// way to introduce a route into a nav.
test('an unknown `to` is ignored, never added', () => {
const out = applyNavOverrides(FLAT, { '/admin/secret': { label: 'Secret', order: 0 } })
assert.equal(out.length, FLAT.length)
assert.ok(!out.some((i) => i.to === '/admin/secret'))
})
test('roles, feature, icon, end and to survive the merge verbatim', () => {
const out = applyNavOverrides(FLAT, {
'/site/shard': { label: 'Server Status', roles: ['player'], feature: null, to: '/evil' },
})
const shard = out.find((i) => i.to === '/site/shard')
assert.equal(shard.label, 'Server Status') // the one thing an override may set
assert.equal(shard.feature, 'status') // gate untouched
assert.equal(shard.roles, undefined) // and not invented
assert.ok(!out.some((i) => i.to === '/evil'))
})
test('hidden:false cannot un-hide anything — hiding is subtractive only', () => {
// The item is still present after the merge; whether it renders is decided by
// the caller's own role/feature filter, which this layer cannot reach.
const out = applyNavOverrides(GROUPED, { '/admin/settings': { hidden: false } })
assert.equal(out, GROUPED, 'a no-op override leaves the base nav alone')
})
// ── Flat navs: label, order, hidden ───────────────────────────────────────
test('label overrides only the labelled item', () => {
const out = applyNavOverrides(FLAT, { '/site/news': { label: 'Announcements' } })
assert.deepEqual(labels(out), ['Home', 'Announcements', 'Wiki', 'Shard'])
})
test('hidden drops the item', () => {
const out = applyNavOverrides(FLAT, { '/wiki': { hidden: true } })
assert.deepEqual(labels(out), ['Home', 'News', 'Shard'])
})
// An item the admin never reordered keeps its position in the coded array, so
// setting one order does not scramble the rest.
test('order moves one item and leaves the others in code order', () => {
const out = applyNavOverrides(FLAT, { '/wiki': { order: -1 } })
assert.deepEqual(labels(out), ['Wiki', 'Home', 'News', 'Shard'])
})
test('two items given the same order keep their code order (stable sort)', () => {
const out = applyNavOverrides(FLAT, { '/site/news': { order: 0 }, '/wiki': { order: 0 } })
// News before Wiki — the tie resolves to the coded order, not to insertion
// order in the settings JSON. Both precede Home, whose 0 is only its index.
assert.deepEqual(labels(out), ['News', 'Wiki', 'Home', 'Shard'])
})
// An explicit order and an untouched item's index share one number line, so
// they can collide. "Put this first" has to actually mean first.
test('an explicit order beats an untouched item that merely sits at that index', () => {
const out = applyNavOverrides(FLAT, { '/wiki': { order: 0 } })
assert.deepEqual(labels(out), ['Wiki', 'Home', 'News', 'Shard'])
})
test('the merge does not mutate the base nav', () => {
const before = JSON.stringify(FLAT)
applyNavOverrides(FLAT, { '/wiki': { label: 'Library', order: 0, hidden: false } })
assert.equal(JSON.stringify(FLAT), before)
})
test('no internal sort key leaks into the returned items', () => {
const out = applyNavOverrides(FLAT, { '/wiki': { order: 1 } })
for (const item of out) assert.ok(!('__order' in item), 'sort key must not be rendered')
})
// ── Grouped (admin) navs ──────────────────────────────────────────────────
test('label and order apply within a group', () => {
const out = applyNavOverrides(GROUPED, {
'/admin/wiki': { label: 'Knowledge Base', order: 0 },
})
assert.deepEqual(groupLabels(out), [
[null, ['Dashboard']],
['Content', ['Knowledge Base', 'Posts']],
['System', ['Settings', 'Users']],
])
})
test('group moves an item into another existing section', () => {
const out = applyNavOverrides(GROUPED, { '/admin/users': { group: 'Content' } })
assert.deepEqual(groupLabels(out), [
[null, ['Dashboard']],
['Content', ['Posts', 'Wiki', 'Users']],
['System', ['Settings']],
])
})
// A group that does not exist must not conjure a header. Groups are chosen from
// a dropdown of existing titles in the editor; this is the stale-row guard.
test('a group that is not an existing title is ignored', () => {
const out = applyNavOverrides(GROUPED, { '/admin/users': { group: 'Danger Zone' } })
assert.deepEqual(groupLabels(out), [
[null, ['Dashboard']],
['Content', ['Posts', 'Wiki']],
['System', ['Settings', 'Users']],
])
})
test('a moved item can be ordered in its new group', () => {
const out = applyNavOverrides(GROUPED, { '/admin/users': { group: 'Content', order: -1 } })
assert.deepEqual(groupLabels(out)[1], ['Content', ['Users', 'Posts', 'Wiki']])
})
test('hiding every item in a group leaves no orphaned header', () => {
const out = applyNavOverrides(GROUPED, {
'/admin/settings': { hidden: true },
'/admin/users': { hidden: true },
})
assert.deepEqual(groupLabels(out), [
[null, ['Dashboard']],
['Content', ['Posts', 'Wiki']],
])
})
test('group ordering itself is not overridable — sections stay in code order', () => {
const out = applyNavOverrides(GROUPED, { '/admin/settings': { order: -99 } })
assert.deepEqual(
out.map((g) => g.title ?? null),
[null, 'Content', 'System'],
)
})
// ── Degenerate input ──────────────────────────────────────────────────────
test('a non-array base nav yields an empty nav rather than throwing', () => {
assert.deepEqual(applyNavOverrides(null, { '/': { hidden: true } }), [])
assert.deepEqual(applyNavOverrides(undefined, null), [])
})
test('an empty base nav stays empty', () => {
assert.deepEqual(applyNavOverrides([], { '/': { label: 'Home' } }), [])
})
// ── The editor's round trip (phase 7) ─────────────────────────────────────
//
// buildNavRows and buildNavOverrides are inverse, and the property that matters
// is that the editor and the site agree: the rows an admin drags come out of the
// same merge the layouts render, hidden ones included.
const rowLabels = (groups) => groups.map((g) => [g.title, g.items.map((i) => i.label)])
test('rows with no override are the coded nav, in code order', () => {
const rows = buildNavRows(FLAT, null)
assert.deepEqual(rowLabels(rows), [[null, ['Home', 'News', 'Wiki', 'Shard']]])
assert.equal(rows[0].items.every((i) => i.hidden === false), true)
})
test('a flat nav becomes one untitled group, so one editor handles both shapes', () => {
assert.equal(buildNavRows(FLAT, null).length, 1)
assert.equal(buildNavRows(GROUPED, null).length, 3)
})
test('rows keep hidden items, in place and marked — the site drops them', () => {
const overrides = { '/site/news': { hidden: true } }
// The layout must not render it...
assert.deepEqual(labels(applyNavOverrides(FLAT, overrides)), ['Home', 'Wiki', 'Shard'])
// ...while the editor must, or there is no way to un-hide it.
const rows = buildNavRows(FLAT, overrides)[0].items
assert.deepEqual(rows.map((i) => i.label), ['Home', 'News', 'Wiki', 'Shard'])
assert.equal(rows[1].hidden, true)
assert.equal(rows[0].hidden, false)
})
test('rows carry the coded label alongside the overridden one', () => {
const rows = buildNavRows(FLAT, { '/site/news': { label: 'Announcements' } })[0].items
assert.equal(rows[1].label, 'Announcements')
assert.equal(rows[1].defaultLabel, 'News')
})
test('rows show the same order the site renders', () => {
const overrides = { '/wiki': { order: 0 }, '/': { order: 1 } }
assert.deepEqual(labels(applyNavOverrides(FLAT, overrides)), ['Wiki', 'Home', 'News', 'Shard'])
assert.deepEqual(rowLabels(buildNavRows(FLAT, overrides)), [[null, ['Wiki', 'Home', 'News', 'Shard']]])
})
test('rows keep an emptied group so something can be moved back into it', () => {
// applyNavOverrides drops a group whose every item is hidden; the editor must
// still show the header, or the section is unreachable forever.
const overrides = { '/admin/posts': { hidden: true }, '/admin/wiki': { hidden: true } }
assert.equal(applyNavOverrides(GROUPED, overrides).some((g) => g.title === 'Content'), false)
assert.equal(buildNavRows(GROUPED, overrides).some((g) => g.title === 'Content'), true)
})
test('an untouched editor saves nothing at all', () => {
// Opening the screen and pressing Save must not pin the position of every
// item — the caller deletes the row when this comes back empty.
assert.deepEqual(buildNavOverrides(buildNavRows(FLAT, null), FLAT), {})
assert.deepEqual(buildNavOverrides(buildNavRows(GROUPED, null), GROUPED), {})
})
test('a rename alone writes a label and no orders', () => {
const groups = buildNavRows(FLAT, null)
groups[0].items[1].label = 'Announcements'
assert.deepEqual(buildNavOverrides(groups, FLAT), { '/site/news': { label: 'Announcements' } })
})
test('a label typed back to the coded one is not stored as an override', () => {
const groups = buildNavRows(FLAT, { '/site/news': { label: 'Announcements' } })
groups[0].items[1].label = 'News'
assert.deepEqual(buildNavOverrides(groups, FLAT), {})
// Whitespace-only reads as "use the default" too.
groups[0].items[1].label = ' '
assert.deepEqual(buildNavOverrides(groups, FLAT), {})
})
test('hiding alone writes hidden and no orders', () => {
const groups = buildNavRows(FLAT, null)
groups[0].items[3].hidden = true
assert.deepEqual(buildNavOverrides(groups, FLAT), { '/site/shard': { hidden: true } })
})
test('reordering writes an order for every row in the list', () => {
// §7.1: explicit and implicit sort keys share one number line, so a partial
// set of orders is the stale-row case rather than something the editor makes.
const groups = buildNavRows(FLAT, null)
const [home] = groups[0].items.splice(0, 1)
groups[0].items.push(home)
assert.deepEqual(buildNavOverrides(groups, FLAT), {
'/site/news': { order: 0 },
'/wiki': { order: 1 },
'/site/shard': { order: 2 },
'/': { order: 3 },
})
})
test('the round trip is stable: save, reload, save again yields the same thing', () => {
const groups = buildNavRows(FLAT, null)
groups[0].items.reverse()
groups[0].items[0].label = 'The Shard'
const first = buildNavOverrides(groups, FLAT)
const second = buildNavOverrides(buildNavRows(FLAT, first), FLAT)
assert.deepEqual(second, first)
// And it renders what the editor showed.
assert.deepEqual(labels(applyNavOverrides(FLAT, first)), ['The Shard', 'Wiki', 'News', 'Home'])
})
test('moving an item to another section writes group, and moving it back clears it', () => {
const groups = buildNavRows(GROUPED, null)
const [posts] = groups[1].items.splice(0, 1)
groups[2].items.push(posts)
const saved = buildNavOverrides(groups, GROUPED)
assert.equal(saved['/admin/posts'].group, 'System')
assert.deepEqual(groupLabels(applyNavOverrides(GROUPED, saved)), [
[null, ['Dashboard']],
['Content', ['Wiki']],
['System', ['Settings', 'Users', 'Posts']],
])
const back = buildNavRows(GROUPED, saved)
const [moved] = back[2].items.splice(2, 1)
back[1].items.unshift(moved)
assert.equal(buildNavOverrides(back, GROUPED)['/admin/posts'], undefined)
})
test('an override for an item outside this admins palette survives a save', () => {
// §8.1 filters the editor to what the editing admin can themselves see. An
// item filtered out has no row, and must not be quietly reset by their save.
const visible = buildNavRows(FLAT, { '/site/shard': { hidden: true } }).map((g) => ({
...g,
items: g.items.filter((i) => !i.feature),
}))
const stored = { '/site/shard': { hidden: true }, '/site/news': { label: 'Old' } }
const out = buildNavOverrides(visible, FLAT, stored)
assert.deepEqual(out['/site/shard'], { hidden: true })
// The rows they *could* see still win over what was stored.
assert.equal(out['/site/news'], undefined)
})
test('a stored entry for a route the code no longer declares is dropped on save', () => {
const groups = buildNavRows(FLAT, null)
const out = buildNavOverrides(groups, FLAT, { '/site/gone': { label: 'Ghost' } })
assert.deepEqual(out, {})
})
test('degenerate input yields an empty result rather than throwing', () => {
assert.deepEqual(buildNavRows(null, {}), [])
assert.deepEqual(buildNavRows([], {}), [])
assert.deepEqual(buildNavOverrides(null, FLAT), {})
assert.deepEqual(buildNavOverrides([], null), {})
})
// ── The public header: sections and added links (phase 10) ────────────────
//
// The one nav an admin can restructure rather than only reorder. The invariant
// that has to survive is §7's, in its narrower form: a CODED entry still cannot
// have its `to` or `feature` touched, and everything that can name an arbitrary
// path lives in `links`, where the path rule applies.
const PUB = [
{ label: 'Home', to: '/', end: true },
{ label: 'News', to: '/site/news' },
{ label: 'Champions', to: '/site/champs', feature: 'champs' },
{ label: 'Guilds', to: '/site/guilds', feature: 'guilds' },
{ label: 'About', to: '/site/about' },
]
const shape = (tree) =>
tree.map((n) => (n.kind === 'section' ? { [n.label]: n.items.map((i) => i.label) } : n.label))
test('no override yields the coded header, in code order', () => {
assert.deepEqual(shape(buildPublicNav(PUB, null)), ['Home', 'News', 'Champions', 'Guilds', 'About'])
assert.deepEqual(shape(buildPublicNav(PUB, {})), ['Home', 'News', 'Champions', 'Guilds', 'About'])
})
test('a phase 6-8 bare map still reads as the items map', () => {
// Nothing has shipped, but a row written during review must not become
// unreadable just because the wrapper arrived.
assert.deepEqual(shape(buildPublicNav(PUB, { '/site/news': { label: 'Announcements' } })), [
'Home',
'Announcements',
'Champions',
'Guilds',
'About',
])
})
const SECTIONED = {
items: { '/site/champs': { section: 'sec_aaaa', order: 0 }, '/site/guilds': { section: 'sec_aaaa', order: 1 } },
sections: [{ id: 'sec_aaaa', label: 'The World', order: 2 }],
links: [{ id: 'lnk_bbbb', label: 'Guide', to: '/wiki/new-player-guide', section: 'sec_aaaa', order: 2 }],
}
test('a section collects its members and sits in the top-level order', () => {
assert.deepEqual(shape(buildPublicNav(PUB, SECTIONED)), [
'Home',
'News',
{ 'The World': ['Champions', 'Guilds', 'Guide'] },
'About',
])
})
test('an added link is kept apart from the coded items', () => {
const tree = buildPublicNav(PUB, SECTIONED)
const link = tree.find((n) => n.kind === 'section').items.find((i) => i.kind === 'link')
assert.equal(link.to, '/wiki/new-player-guide')
assert.equal(link.id, 'lnk_bbbb')
// It carries no gate of its own — that is the documented contract, and the
// page behind it is what actually enforces access.
assert.equal(link.feature, undefined)
assert.equal(link.roles, undefined)
})
test('an off-origin link is dropped rather than rendered', () => {
for (const to of ['https://evil.example', '//evil.example/x', 'javascript:alert(1)', '/x y', '/a"b']) {
const tree = buildPublicNav(PUB, { items: {}, links: [{ id: 'lnk_bbbb', label: 'Bad', to }] })
assert.equal(
tree.some((n) => n.kind === 'link'),
false,
`${to} should be dropped`,
)
}
})
test('an item naming a section that does not exist stays at the top level', () => {
const tree = buildPublicNav(PUB, { items: { '/site/champs': { section: 'sec_gone' } } })
assert.deepEqual(shape(tree), ['Home', 'News', 'Champions', 'Guilds', 'About'])
})
test('an override still cannot introduce a coded route', () => {
const tree = buildPublicNav(PUB, { items: { '/site/secret': { label: 'Secret' } } })
assert.equal(
tree.some((n) => n.to === '/site/secret'),
false,
)
})
test('hidden entries are dropped for the site and kept for the editor', () => {
const overrides = { items: { '/site/news': { hidden: true } } }
assert.equal(shape(buildPublicNav(PUB, overrides)).includes('News'), false)
const rows = buildPublicNav(PUB, overrides, { keepHidden: true })
assert.equal(rows.find((n) => n.to === '/site/news').hidden, true)
})
// ── pruneNav: the empty dropdown ──────────────────────────────────────────
test('a section keeps the entries the viewer may see', () => {
const tree = buildPublicNav(PUB, SECTIONED)
const out = pruneNav(tree, (i) => i.feature !== 'guilds')
assert.deepEqual(shape(out), ['Home', 'News', { 'The World': ['Champions', 'Guide'] }, 'About'])
})
test('a section whose every entry is gated out does not render at all', () => {
// The case that matters: a dropdown that opens onto nothing is worse than no
// dropdown, and shard visibility can empty one at any time.
const overrides = {
items: { '/site/champs': { section: 'sec_aaaa' }, '/site/guilds': { section: 'sec_aaaa' } },
sections: [{ id: 'sec_aaaa', label: 'The World' }],
}
const tree = buildPublicNav(PUB, overrides)
assert.deepEqual(shape(pruneNav(tree, () => true)), [
'Home',
'News',
'About',
{ 'The World': ['Champions', 'Guilds'] },
])
assert.deepEqual(shape(pruneNav(tree, (i) => !i.feature)), ['Home', 'News', 'About'])
})
test('an added link is never pruned — it carries no gate', () => {
const tree = buildPublicNav(PUB, { items: {}, links: [{ id: 'lnk_bbbb', label: 'Guide', to: '/wiki/g' }] })
assert.equal(
pruneNav(tree, () => false).some((n) => n.kind === 'link'),
true,
)
})
// ── The editor round trip ─────────────────────────────────────────────────
test('an untouched public editor saves nothing', () => {
assert.deepEqual(buildPublicNavOverrides(buildPublicNav(PUB, null, { keepHidden: true }), PUB), {})
})
test('a nav with no sections still stores the plain items map', () => {
// Adding this feature changed nothing for a nav that does not use it.
const tree = buildPublicNav(PUB, null, { keepHidden: true })
tree[1].label = 'Announcements'
const out = buildPublicNavOverrides(tree, PUB)
assert.deepEqual(out, { '/site/news': { label: 'Announcements' } })
assert.equal(out.items, undefined)
})
test('the sectioned round trip is stable and renders what the editor showed', () => {
const tree = buildPublicNav(PUB, SECTIONED, { keepHidden: true })
const first = buildPublicNavOverrides(tree, PUB)
const second = buildPublicNavOverrides(buildPublicNav(PUB, first, { keepHidden: true }), PUB)
assert.deepEqual(second, first)
assert.deepEqual(shape(buildPublicNav(PUB, first)), [
'Home',
'News',
{ 'The World': ['Champions', 'Guilds', 'Guide'] },
'About',
])
})
test('deleting a section returns its entries to the top level, never deletes them', () => {
// The one destructive act this screen could commit, so it is locked here.
const tree = buildPublicNav(PUB, SECTIONED, { keepHidden: true })
const section = tree.find((n) => n.kind === 'section')
const flattened = [...tree.filter((n) => n.kind !== 'section'), ...section.items]
const out = buildPublicNavOverrides(flattened, PUB)
const rendered = buildPublicNav(PUB, out)
assert.equal(
rendered.some((n) => n.kind === 'section'),
false,
)
assert.deepEqual(shape(rendered), ['Home', 'News', 'About', 'Champions', 'Guilds', 'Guide'])
})
test('an override for a feature-gated item outside the palette survives a save', () => {
// §8.1 filters the editor to what this admin can see. The rows come from their
// palette, but membership is judged against the FULL coded nav — otherwise a
// row a shard feature hid from them is indistinguishable from a deleted route,
// and their save would silently reset it.
const palette = PUB.filter((i) => i.feature !== 'champs')
// The editor was opened on a nav that only hides champs — which their palette
// does not show them. `stored` additionally carries a label for a row they CAN
// see, and which they have since reset.
const tree = buildPublicNav(palette, { items: { '/site/champs': { hidden: true } } }, { keepHidden: true })
const stored = { items: { '/site/champs': { hidden: true }, '/site/news': { label: 'Old' } } }
const out = buildPublicNavOverrides(tree, PUB, stored)
assert.deepEqual(out['/site/champs'], { hidden: true }, 'carried: they could not see it')
assert.equal(out['/site/news'], undefined, 'not carried: their row is the authority for what they can see')
})
test('a stored entry for a route the code no longer declares is dropped on save', () => {
const tree = buildPublicNav(PUB, null, { keepHidden: true })
assert.deepEqual(buildPublicNavOverrides(tree, PUB, { items: { '/site/gone': { label: 'Ghost' } } }), {})
})