// nav-config-model.test.js — pure config helpers + static HTML gates for // the left-nav hiddenPages filter (lane-nav-optional). // // Three fixture cases (task spec): // • config.json missing `hiddenPages` → default hidden set (Playground + Missions) // • config.json has empty array → nothing hidden (all pages show) // • config.json has custom list → honored as-is (e.g. ["prs","growth"]) // // Plus DOM-shape assertions on index.html so a future rewrite of the // sidebar can't silently strip the Optional pages section. 'use strict' const test = require('node:test') const assert = require('node:assert') const fs = require('node:fs') const path = require('node:path') const M = require('../src/renderer/nav-config-model.js') // -- resolveHiddenPages ---------------------------------------------------- test('resolveHiddenPages: no config → default hidden set', () => { assert.deepStrictEqual(M.resolveHiddenPages(null), ['playground-shim', 'mission']) assert.deepStrictEqual(M.resolveHiddenPages(undefined), ['playground-shim', 'mission']) assert.deepStrictEqual(M.resolveHiddenPages({}), ['playground-shim', 'mission']) }) test('resolveHiddenPages: hiddenPages missing on partial config → default set', () => { const cfg = { role: 'engineer', approvalMode: 'ask', createdAt: 1234 } assert.deepStrictEqual(M.resolveHiddenPages(cfg), ['playground-shim', 'mission']) }) test('resolveHiddenPages: empty array → show everything', () => { assert.deepStrictEqual(M.resolveHiddenPages({ hiddenPages: [] }), []) }) test('resolveHiddenPages: custom list → honored as-is', () => { assert.deepStrictEqual( M.resolveHiddenPages({ hiddenPages: ['prs', 'bench'] }), ['prs', 'bench'] ) }) test('resolveHiddenPages: legacy rubrics/growth/runtimes ids remap to evals (lane-evals-merge)', () => { // 2026-07-19: three separate nav items were merged into one Evals // door. Old config values must keep hiding what the user asked for — // if they'd hidden Rubrics before, they should still see no Rubrics // surface after upgrade (which means Evals stays hidden). assert.deepStrictEqual( M.resolveHiddenPages({ hiddenPages: ['rubrics'] }), ['evals'] ) assert.deepStrictEqual( M.resolveHiddenPages({ hiddenPages: ['growth'] }), ['evals'] ) assert.deepStrictEqual( M.resolveHiddenPages({ hiddenPages: ['runtimes'] }), ['evals'] ) // Multiple legacy ids collapse to a single 'evals' entry. assert.deepStrictEqual( M.resolveHiddenPages({ hiddenPages: ['rubrics', 'growth', 'runtimes'] }), ['evals'] ) // Legacy ids mixed with unrelated ids preserve the unrelated ids. assert.deepStrictEqual( M.resolveHiddenPages({ hiddenPages: ['prs', 'growth', 'bench'] }), ['prs', 'evals', 'bench'] ) }) test('resolveHiddenPages: non-array garbage → falls back to defaults (safe)', () => { assert.deepStrictEqual(M.resolveHiddenPages({ hiddenPages: 'playground' }), ['playground-shim', 'mission']) assert.deepStrictEqual(M.resolveHiddenPages({ hiddenPages: 42 }), ['playground-shim', 'mission']) assert.deepStrictEqual(M.resolveHiddenPages({ hiddenPages: { pages: [] } }), ['playground-shim', 'mission']) }) test('resolveHiddenPages: filters out non-string / blank entries', () => { assert.deepStrictEqual( M.resolveHiddenPages({ hiddenPages: ['prs', '', null, 42, 'bench'] }), ['prs', 'bench'] ) }) test('resolveHiddenPages: returns a fresh array (mutation safety)', () => { const a = M.resolveHiddenPages({}) a.push('sneaky') const b = M.resolveHiddenPages({}) assert.deepStrictEqual(b, ['playground-shim', 'mission'], 'a subsequent call must not see the mutation of a prior return') }) // -- toggleOptionalPage ----------------------------------------------------- test('toggleOptionalPage: enable removes the id from hidden', () => { assert.deepStrictEqual( M.toggleOptionalPage(['playground-shim', 'mission'], 'playground-shim', true).sort(), ['mission'] ) }) test('toggleOptionalPage: disable adds the id to hidden', () => { assert.deepStrictEqual( M.toggleOptionalPage(['mission'], 'playground-shim', false).sort(), ['mission', 'playground-shim'] ) }) test('toggleOptionalPage: idempotent — disable an already-hidden page = no change', () => { assert.deepStrictEqual( M.toggleOptionalPage(['mission'], 'mission', false).sort(), ['mission'] ) }) test('toggleOptionalPage: idempotent — enable an already-visible page = no change', () => { assert.deepStrictEqual( M.toggleOptionalPage(['mission'], 'playground-shim', true).sort(), ['mission'] ) }) test('toggleOptionalPage: non-array current is tolerated (start fresh)', () => { assert.deepStrictEqual( M.toggleOptionalPage(undefined, 'mission', false).sort(), ['mission'] ) assert.deepStrictEqual( M.toggleOptionalPage(null, 'mission', true).sort(), [] ) }) // -- OPTIONAL_PAGES shape --------------------------------------------------- test('OPTIONAL_PAGES: matches DEFAULT_HIDDEN 1:1 by id', () => { const optIds = M.OPTIONAL_PAGES.map((p) => p.id).sort() const defIds = M.DEFAULT_HIDDEN.slice().sort() assert.deepStrictEqual(optIds, defIds, 'every default-hidden page should have a Settings checkbox and vice versa') }) test('OPTIONAL_PAGES: every entry has id/label/hint strings', () => { for (const page of M.OPTIONAL_PAGES) { assert.ok(typeof page.id === 'string' && page.id.length > 0, `id: ${JSON.stringify(page)}`) assert.ok(typeof page.label === 'string' && page.label.length > 0, `label: ${JSON.stringify(page)}`) assert.ok(typeof page.hint === 'string' && page.hint.length > 0, `hint: ${JSON.stringify(page)}`) } }) // -- static HTML gate: Optional pages section -------------------------------- const HTML = fs.readFileSync( path.resolve(__dirname, '..', 'src/renderer/index.html'), 'utf8' ) test('Optional pages section is declared in the Settings pane', () => { assert.match(HTML, /data-settings-optional-pages/, 'Optional pages section marker present') assert.match(HTML, /data-settings-optional-list/, 'Optional pages list hook present') assert.match(HTML, /Optional pages/, 'Section title copy present') }) test('Optional pages section lives inside the Settings pane (not orphaned)', () => { // Grab settings-pane block through its closing tag and assert the // Optional marker sits inside. Guards against a merge that moves the // block out of the pane by accident. const paneMatch = HTML.match(/id="settings-pane"[\s\S]*?<\/section>\s*<\/section>/) assert.ok(paneMatch, 'settings-pane block found') assert.match(paneMatch[0], /data-settings-optional-pages/, 'Optional pages section must live inside #settings-pane') }) test('nav-config-model.js is loaded before settings-page.js', () => { const navMatch = HTML.match(/]*src="\.\/nav-config-model\.js"/) const settingsMatch = HTML.match(/]*src="\.\/settings-page\.js"/) assert.ok(navMatch, 'nav-config-model.js script tag present') assert.ok(settingsMatch, 'settings-page.js script tag present') const navIdx = HTML.indexOf(navMatch[0]) const settingsIdx = HTML.indexOf(settingsMatch[0]) assert.ok(settingsIdx > navIdx, 'nav-config-model must load before settings-page so OPTIONAL_PAGES is defined at render time') }) test('Playground-shim + mission buttons expose the data-tab ids the filter matches', () => { // These two ids are the default-hidden set — if a lane renames either // without also updating nav-config-model.DEFAULT_HIDDEN, the filter // would silently no-op and both buttons would show up on fresh installs. assert.match(HTML, /data-tab="playground-shim"/, 'playground-shim button id must match DEFAULT_HIDDEN entry') assert.match(HTML, /data-tab="mission"/, 'mission button id must match DEFAULT_HIDDEN entry') }) // -- DOM-level filter proof (three fixture cases) -------------------------- // Mirror the loop inside renderer.js:applyNavHiddenPages() against a tiny // shim so each fixture case is proven end-to-end: config in → // hiddenPages resolved → class toggled on matching button. Using a // hand-rolled shim rather than jsdom (not a dev-dep) or the full // renderer-harness (evals 8k lines of renderer.js for a one-loop check). function makeSidebar() { const buttons = [] const groups = [] function makeBtn(dataTab) { return { dataset: { tab: dataTab }, classList: { _s: new Set(), toggle(n, force) { if (force) this._s.add(n); else this._s.delete(n) return force }, contains(n) { return this._s.has(n) }, }, } } function makeGroup(btns) { const g = { classList: { _s: new Set(), toggle(n, force) { if (force) this._s.add(n); else this._s.delete(n) return force }, contains(n) { return this._s.has(n) }, }, _btns: btns, } for (const b of btns) buttons.push(b) groups.push(g) return g } makeGroup([ makeBtn('chat'), makeBtn('tree'), makeBtn('context'), makeBtn('tracing'), ]) // "iteration" group — playground-shim (default hidden) sits here. makeGroup([ makeBtn('playground-shim'), makeBtn('hub'), makeBtn('bench'), ]) // "runtime" group — mission (default hidden) sits here. Post // lane-evals-merge (2026-07-19) rubrics/growth/runtimes fold into // a single 'evals' button; the sidebar shim mirrors that new shape. makeGroup([ makeBtn('evals'), makeBtn('plugins'), makeBtn('mission'), makeBtn('prs'), ]) return { buttons, groups } } function applyFilter(sidebar, cfg) { const hidden = new Set(M.resolveHiddenPages(cfg)) for (const b of sidebar.buttons) { b.classList.toggle('nav-item--hidden', hidden.has(b.dataset.tab)) } for (const g of sidebar.groups) { const allHidden = g._btns.every((b) => b.classList.contains('nav-item--hidden')) g.classList.toggle('nav-group--hidden', allHidden) } } test('DOM filter (case 1): missing hiddenPages → playground-shim + mission hidden, others visible', () => { const sb = makeSidebar() applyFilter(sb, {}) const hidden = sb.buttons.filter((b) => b.classList.contains('nav-item--hidden')) assert.deepStrictEqual( hidden.map((b) => b.dataset.tab).sort(), ['mission', 'playground-shim'] ) }) test('DOM filter (case 2): empty array → nothing hidden (all pages show)', () => { const sb = makeSidebar() applyFilter(sb, { hiddenPages: [] }) const hidden = sb.buttons.filter((b) => b.classList.contains('nav-item--hidden')) assert.strictEqual(hidden.length, 0, 'no buttons should be hidden with []') const groupHidden = sb.groups.filter((g) => g.classList.contains('nav-group--hidden')) assert.strictEqual(groupHidden.length, 0, 'no groups should be collapsed with []') }) test('DOM filter (case 3): custom list → matching buttons hidden, others untouched', () => { const sb = makeSidebar() // 'growth' remaps to 'evals' via LEGACY_ID_ALIAS (lane-evals-merge); // 'prs' passes through unchanged. Both target actual sidebar buttons. applyFilter(sb, { hiddenPages: ['prs', 'growth'] }) const hidden = sb.buttons.filter((b) => b.classList.contains('nav-item--hidden')) assert.deepStrictEqual( hidden.map((b) => b.dataset.tab).sort(), ['evals', 'prs'] ) // Playground + mission are NOT hidden because the researcher opted them in // via an explicit list; only the ids in the list are hidden. const play = sb.buttons.find((b) => b.dataset.tab === 'playground-shim') const mission = sb.buttons.find((b) => b.dataset.tab === 'mission') assert.ok(!play.classList.contains('nav-item--hidden'), 'playground-shim visible on custom list') assert.ok(!mission.classList.contains('nav-item--hidden'), 'mission visible on custom list') }) test('DOM filter: group collapses when every button inside is hidden', () => { const sb = makeSidebar() // Hide every button in the iteration group. applyFilter(sb, { hiddenPages: ['playground-shim', 'hub', 'bench'] }) const iterGroup = sb.groups[1] // second group is "iteration" assert.ok(iterGroup.classList.contains('nav-group--hidden'), 'iteration group should be collapsed when all its buttons are hidden') // The other groups still have visible members. assert.ok(!sb.groups[0].classList.contains('nav-group--hidden')) assert.ok(!sb.groups[2].classList.contains('nav-group--hidden')) })