waybetter/packages/runtime/tests/column.test.ts

118 lines
5.7 KiB
TypeScript

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);
});