122 lines
7.1 KiB
TypeScript
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]);
|
|
});
|