import assert from 'node:assert/strict'; import test from 'node:test'; import { buildColumnCss, compileFilters, createUnreadTracker, isSourcePage, normalizeColumnConfig, parseTerm, type ColumnSite, } from '../src/column.ts'; const SITE: ColumnSite = { prefix: 'demo', keep: '#content', hide: ['nav.rail', '.fab'], sourceHide: ['.page-title'], itemSelector: 'article', itemId: item => item.getAttribute('data-id'), css: density => `html[data-demo-column][data-demo-density="${density}"] article { padding: 0 }`, }; const item = (text: string, kind: Partial<{ reply: boolean; repost: boolean; media: boolean }> = {}) => ({ text, reply: false, repost: false, media: false, ...kind }); test('column: config is normalized and bounded', () => { assert.equal(normalizeColumnConfig(null), null); assert.equal(normalizeColumnConfig({ url: 5 }), null); const config = normalizeColumnConfig({ url: 'https://site.test/home', density: 'weird', filters: { terms: ['ok', '', 7, 'x'.repeat(201), ...Array(40).fill('t')], mediaOnly: 'yes' }, readSeq: -3, jumpSeq: 2.7, kind: 'home', })!; assert.equal(config.density, 'compact'); assert.equal(config.filters.terms.length, 32); assert.equal(config.filters.terms[0], 'ok'); assert.equal(config.filters.mediaOnly, false); assert.deepEqual([config.readSeq, config.jumpSeq, config.unread], [0, 2, true]); }); test('column: terms are keywords or /regex/flags; bad regexes are skipped', () => { assert.equal(parseTerm(' Crypto '), 'crypto'); const re = parseTerm('/gm+\\b/'); assert.ok(re instanceof RegExp && re.flags.includes('i'), 'regexes are case-insensitive'); assert.equal(parseTerm('/(unclosed/'), null); assert.equal(parseTerm('/a/g'), '/a/g', 'only i, m, s, u flags make a regex'); assert.equal(parseTerm(' '), null); }); test('column: filters hide replies, reposts, non-media and matching text', () => { const none = compileFilters({ terms: [], hideReplies: false, hideReposts: false, mediaOnly: false }); assert.equal(none(item('anything', { reply: true })), false); const f = compileFilters({ terms: ['giveaway', '/^gm\\b/', '/(bad/'], hideReplies: true, hideReposts: true, mediaOnly: false }); assert.equal(f(item('Huge GIVEAWAY today')), true); assert.equal(f(item('GM frens')), true); assert.equal(f(item('a normal post')), false); assert.equal(f(item('normal', { reply: true })), true); assert.equal(f(item('normal', { repost: true })), true); const media = compileFilters({ terms: [], hideReplies: false, hideReposts: false, mediaOnly: true }); assert.equal(media(item('text only')), true); assert.equal(media(item('photo', { media: true })), false); }); test('column: the source page is compared by origin, path and query', () => { assert.equal(isSourcePage('https://site.test/home/', 'https://site.test/home'), true); assert.equal(isSourcePage('https://site.test/search?q=a&f=live', 'https://site.test/search?q=a&f=live'), true); assert.equal(isSourcePage('https://site.test/search?q=b&f=live', 'https://site.test/search?q=a&f=live'), false); assert.equal(isSourcePage('https://site.test/home/status/1', 'https://site.test/home'), false); assert.equal(isSourcePage('https://other.test/home', 'https://site.test/home'), false); assert.equal(isSourcePage('nonsense', 'https://site.test/home'), false); }); test('column: CSS is scoped to the column attribute and hides chrome', () => { const css = buildColumnCss(SITE, 'comfortable'); for (const line of css.split('\n').filter(Boolean)) assert.match(line, /^html\[data-demo-column\]/, line); assert.match(css, /:is\(nav\.rail, \.fab\) \{ display: none !important; \}/); assert.match(css, /html\[data-demo-column\]\[data-demo-column-home\] :is\(\.page-title\)/); assert.match(css, /#content \{ width: 100% !important; max-width: none !important/); assert.match(css, /\[data-demo-filtered\] \{ display: none !important; \}/); assert.match(css, /data-demo-density="comfortable"/); }); test('unread: first render is the baseline; new ids above the read ones count until seen', () => { const t = createUnreadTracker(); t.observe([], new Set()); t.observe(['c', 'b', 'a'], new Set(['c', 'b', 'a'])); assert.equal(t.count(), 0, 'baseline'); // Two new posts rendered above while the user is scrolled down (not on screen). t.observe(['e', 'd', 'c', 'b', 'a'], new Set(['a'])); assert.equal(t.count(), 2); assert.equal(t.nextUnread(['e', 'd', 'c', 'b', 'a']), 'd', 'the one closest to what was read'); // Older posts loaded below are read. t.observe(['e', 'd', 'c', 'b', 'a', 'z', 'y'], new Set()); assert.equal(t.count(), 2); t.seen('d'); assert.equal(t.count(), 1); // A new post rendered while on screen is read at once. t.observe(['f', 'e', 'd', 'c'], new Set(['f'])); assert.equal(t.count(), 1); assert.equal(t.isUnread('e'), true); t.markRead(); assert.equal(t.count(), 0); assert.equal(t.nextUnread(['f', 'e']), null); }); test('unread: a new id below what is on screen is an older item, never unread', () => { const t = createUnreadTracker(); t.observe(['a', 'b', 'c'], new Set(['a'])); // X re-keys or lazily renders an item further down while the top is on screen. t.observe(['a', 'x', 'b', 'c'], new Set(['a'])); assert.equal(t.count(), 0); // Scrolled down (c on screen), a new one on top is unread; one between the top and c is not. t.observe(['n', 'a', 'y', 'b', 'c'], new Set(['c'])); assert.equal(t.isUnread('n'), true); assert.equal(t.isUnread('y'), false); }); test('unread: items rendered on screen again are marked read by observe', () => { const t = createUnreadTracker(); t.observe(['a'], new Set()); t.observe(['b', 'a'], new Set()); assert.equal(t.count(), 1); t.observe(['b', 'a'], new Set(['b'])); assert.equal(t.count(), 0); });