waybetter/packages/shell/tests/deckModel.test.ts

122 lines
7.1 KiB
TypeScript

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<string, boolean>();
const items = withNavRedundant([box('col', 0, 'column'), box('col2', 400, 'column')], onSite, memory);
assert.deepEqual(items.map(item => item.navRedundant), [false, false]);
});