import { test } from 'node:test' import assert from 'node:assert/strict' import { applyNavOverrides, buildNavRows, buildNavOverrides } 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 admin’s 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), {}) })