import assert from 'node:assert/strict'; import test from 'node:test'; import { COLUMN_GAP, MAX_COLUMNS, MAX_TERMS, columnUrl, layoutColumns, moveColumn, moveFocus, normalizeDeck, normalizeDecks, pageOf, paginate, unreadTotal, type ColumnSource, } from '../src/deckModel.ts'; import { redundantNavPanes, withNavRedundant, type PaneBox } from '../src/paneModel.ts'; const col = (id: string, width = 360) => ({ id, width }); test('deck: normalizeDeck bounds everything it reads', () => { const columns = Array.from({ length: MAX_COLUMNS + 5 }, (_, i) => ({ id: `c${i}`, tabId: `t${i}`, profile: 'default', source: { kind: 'home' }, width: 9999, flags: { disableVideoAutoplay: true, evil: 'yes', notAllowed: true }, filters: { terms: ['a', 'a', '', 'x'.repeat(201), ...Array.from({ length: 40 }, (_, j) => `t${j}`)], hideReplies: 1 }, })); const deck = normalizeDeck({ id: 'd1', name: ' Monitor ', columns }, { kinds: ['home'], flags: ['disableVideoAutoplay'] })!; assert.equal(deck.name, 'Monitor'); assert.equal(deck.columns.length, MAX_COLUMNS); const first = deck.columns[0]!; assert.equal(first.width, 640, 'width clamped'); assert.deepEqual(first.flags, { disableVideoAutoplay: true }, 'only allow-listed boolean flags'); assert.equal(first.filters.terms.length, MAX_TERMS); assert.equal(first.filters.terms[0], 'a', 'duplicates and empty/oversized terms dropped'); assert.ok(!first.filters.terms.includes('x'.repeat(201))); assert.equal(first.filters.hideReplies, false, 'non-boolean filter flags are off'); assert.equal(normalizeDeck({ id: 'bad id!', columns: [] }), null); }); test('deck: unknown kinds, duplicate ids and removed profiles', () => { const deck = normalizeDeck({ id: 'd', columns: [ { id: 'a', tabId: 'ta', profile: 'pgone', source: { kind: 'home' } }, { id: 'a', tabId: 'tb', source: { kind: 'home' } }, { id: 'b', tabId: 'ta', source: { kind: 'home' } }, { id: 'c', tabId: 'tc', source: { kind: 'rocket' } }, { id: 'd', tabId: 'td', source: { kind: 'search', value: 'from:me' }, title: 'Mine' }, ], }, { kinds: ['home', 'search'], profiles: ['default', 'p1'], fallbackProfile: 'default' })!; assert.deepEqual(deck.columns.map(c => c.id), ['a', 'd']); assert.equal(deck.columns[0]!.profile, 'default'); assert.deepEqual(deck.columns[1]!.source, { kind: 'search', value: 'from:me' }); assert.equal(deck.columns[1]!.title, 'Mine'); const decks = normalizeDecks([deck, { ...deck, id: 'other' }, { ...deck }]); assert.deepEqual(decks.map(d => d.id), ['d', 'other']); assert.equal(decks[1]!.columns.length, 0, 'a tab webview belongs to one deck only'); }); test('deck: paging fills whole columns per page and respects maxVisible', () => { const columns = ['a', 'b', 'c', 'd', 'e'].map(id => col(id)); assert.deepEqual(paginate(1500, columns), [['a', 'b', 'c', 'd'], ['b', 'c', 'd', 'e']], 'the last page is filled from the end'); assert.deepEqual(paginate(1500, columns, { maxVisible: 2 }), [['a', 'b'], ['c', 'd'], ['d', 'e']]); assert.deepEqual(paginate(1500, columns.slice(0, 4)), [['a', 'b', 'c', 'd']]); assert.deepEqual(paginate(200, columns.slice(0, 2)), [['a'], ['b']], 'a column wider than the viewport still gets a page'); assert.deepEqual(paginate(1000, []), []); }); test('deck: layout stretches a page to the viewport edge and parks the rest', () => { const columns = ['a', 'b', 'c', 'd', 'e'].map(id => col(id)); const layout = layoutColumns(1500, columns, 0); assert.equal(layout.pages, 2); assert.deepEqual(layout.parked, ['e']); assert.equal(layout.visible.length, 4); const last = layout.visible.at(-1)!; assert.equal(last.x + last.width, 1500, 'the row ends at the edge'); for (let i = 1; i < layout.visible.length; i++) { const prev = layout.visible[i - 1]!; assert.equal(layout.visible[i]!.x, prev.x + prev.width + COLUMN_GAP); assert.ok(Math.abs(layout.visible[i]!.width - prev.width) <= 3, 'even stretch'); } const second = layoutColumns(1500, columns, 7); assert.equal(second.page, 1, 'page clamped'); assert.deepEqual(second.visible.map(c => c.id), ['b', 'c', 'd', 'e']); assert.deepEqual(second.parked, ['a']); const squeezed = layoutColumns(300, [col('w', 480)], 0); assert.deepEqual(squeezed.visible, [{ id: 'w', x: 0, width: 300 }]); const lone = layoutColumns(1600, [col('x')], 0); assert.deepEqual(lone.visible, [{ id: 'x', x: 0, width: 720 }], 'a lone column stretches at most 2x'); assert.deepEqual(layoutColumns(1000, [], 3), { page: 0, pages: 0, visible: [], parked: [] }); }); test('deck: focus moves are clamped, pageOf finds a column', () => { const columns = ['a', 'b', 'c'].map(id => col(id)); assert.equal(moveFocus(columns, 'a', -1), 'a'); assert.equal(moveFocus(columns, 'a', 1), 'b'); assert.equal(moveFocus(columns, 'c', 5), 'c'); assert.equal(moveFocus(columns, null, 1), 'a'); assert.equal(moveFocus(columns, 'gone', -1), 'c'); assert.equal(moveFocus([], 'a', 1), null); assert.equal(pageOf(740, columns, 'c'), 1); assert.equal(pageOf(740, columns, 'b', {}, 1), 1, 'stays on the current page when it shows the column'); assert.equal(pageOf(740, columns, 'a', {}, 1), 0); assert.equal(pageOf(740, columns, 'nope'), 0); assert.deepEqual(moveColumn(columns, 'c', -5).map(c => c.id), ['c', 'a', 'b']); assert.deepEqual(moveColumn(columns, 'a', 1).map(c => c.id), ['b', 'a', 'c']); }); test('deck: column URLs come from the adapter and must be plain https', () => { const urlFor = (source: ColumnSource) => source.kind === 'home' ? 'https://site.test/home' : source.kind === 'bad' ? 'javascript:alert(1)' : source.kind === 'cred' ? 'https://u:p@site.test/' : null; assert.equal(columnUrl({ kind: 'home' }, urlFor), 'https://site.test/home'); for (const kind of ['bad', 'cred', 'nope']) assert.equal(columnUrl({ kind }, urlFor), null, kind); assert.equal(columnUrl({ kind: 'x' }, () => { throw new Error('boom'); }), null); }); test('deck: unread total sums columns and caps at 999', () => { const columns = [{ tabId: 'a' }, { tabId: 'b' }, { tabId: 'c' }]; assert.equal(unreadTotal(new Map([['a', 3], ['b', 4]]), columns), 7); assert.equal(unreadTotal({ a: 900, b: 900, c: -5 }, columns), 999); assert.equal(unreadTotal({ other: 5 }, columns), 0); }); test('panes: deck columns never take part in the rail rules', () => { const onSite = (url: string) => url.startsWith('https://site.test/'); const box = (id: string, x: number, role?: 'pane' | 'column'): PaneBox => ({ id, url: 'https://site.test/feed', profile: 'default', x, y: 0, visible: true, ...(role ? { role } : {}) }); // A column left of a pane does not make that pane redundant, and is never redundant itself. assert.deepEqual([...redundantNavPanes([box('col', 0, 'column'), box('pane', 400)], onSite)], []); assert.deepEqual([...redundantNavPanes([box('col1', 0, 'column'), box('col2', 400, 'column')], onSite)], []); assert.deepEqual([...redundantNavPanes([box('p1', 0, 'pane'), box('col', 400, 'column'), box('p2', 800)], onSite)], ['p2']); const memory = new Map(); const items = withNavRedundant([box('col', 0, 'column'), box('col2', 400, 'column')], onSite, memory); assert.deepEqual(items.map(item => item.navRedundant), [false, false]); });