185 lines
9.4 KiB
JavaScript
185 lines
9.4 KiB
JavaScript
// Unit tests for the page's own functions (#234): the script as shipped, loaded into the stub DOM
|
|
// page-smoke.js uses, and its helpers called directly. The browser suite tests what people do;
|
|
// this tests the small pieces it is made of, in a second, with nothing running.
|
|
//
|
|
// node --test tests/page-unit.js
|
|
//
|
|
// The files in web/src are one script, not modules. Its top-level `function`s are properties of
|
|
// the context; its top-level `const`s are not, but a script run in the same context sees them, as
|
|
// a second <script> on a page would, so `run` evaluates an expression there.
|
|
const { test } = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const { makeContext, vm } = require('./dom-stub.js');
|
|
const { buildPage } = require('../web/build.mjs');
|
|
|
|
const { html, js } = buildPage('index.html');
|
|
const { ctx } = makeContext(html, js);
|
|
vm.createContext(ctx);
|
|
vm.runInContext(js, ctx, { filename: 'index.html<script>', timeout: 5000 });
|
|
const run = expr => vm.runInContext(expr, ctx);
|
|
// Time as the page sees it, for the helpers that count from now.
|
|
const NOW = 1_800_000_000;
|
|
run(`Date.now = () => ${NOW * 1000}`);
|
|
|
|
test('esc makes text safe to put in markup and attributes', () => {
|
|
assert.equal(run(`esc('<a href="x">Tom & Jerry\\'s</a>')`), '<a href="x">Tom & Jerry's</a>');
|
|
assert.equal(run('esc(null)'), '');
|
|
assert.equal(run('esc(undefined)'), '');
|
|
});
|
|
|
|
test('clock reads seconds as a player does', () => {
|
|
assert.equal(run('clock(0)'), '0:00');
|
|
assert.equal(run('clock(65)'), '1:05');
|
|
assert.equal(run('clock(3661)'), '1:01:01');
|
|
assert.equal(run('clock(59.9)'), '0:59');
|
|
assert.equal(run('clock(-5)'), '0:00');
|
|
assert.equal(run('clock(undefined)'), '0:00');
|
|
});
|
|
|
|
test('mb sizes a file in the unit that reads best', () => {
|
|
assert.equal(run('mb(0)'), '');
|
|
// An article's small image is not "0 KB" or "0 MB".
|
|
assert.equal(run('mb(100)'), '1 KB');
|
|
assert.equal(run('mb(2048)'), '2 KB');
|
|
assert.equal(run('mb(5 * 1048576)'), '5 MB');
|
|
assert.equal(run('mb(1.5 * 1073741824)'), '1.5 GB');
|
|
});
|
|
|
|
test('ago says how long since, and never for nothing', () => {
|
|
assert.equal(run('ago(0)'), 'never');
|
|
assert.equal(run(`ago(${NOW - 10})`), '1m ago');
|
|
assert.equal(run(`ago(${NOW - 45 * 60})`), '45m ago');
|
|
assert.equal(run(`ago(${NOW - 5 * 3600})`), '5h ago');
|
|
assert.equal(run(`ago(${NOW - 3 * 86400})`), '3d ago');
|
|
// Past a month, a date: whatever the locale, it names the year.
|
|
assert.match(run(`ago(${NOW - 90 * 86400})`), /2026/);
|
|
});
|
|
|
|
test('due says how long until a feed is checked', () => {
|
|
assert.equal(run(`due(${NOW - 1})`), 'due now');
|
|
assert.equal(run(`due(${NOW + 10})`), 'in 1m');
|
|
assert.equal(run(`due(${NOW + 90 * 60})`), 'in 2h');
|
|
assert.equal(run(`due(${NOW + 3 * 86400})`), 'in 3d');
|
|
});
|
|
|
|
test('DeadAir savings read as time', () => {
|
|
assert.equal(run('spanText(40)'), '40 s');
|
|
assert.equal(run('spanText(12 * 60 + 5)'), '12 min');
|
|
assert.equal(run('spanText(3 * 3600 + 12 * 60)'), '3 h 12 min');
|
|
assert.equal(run('shorter(45)'), '45 s shorter');
|
|
assert.equal(run('shorter(150)'), '3 min shorter');
|
|
});
|
|
|
|
test('initials and plural', () => {
|
|
assert.equal(run(`initials('The Verge')`), 'TV');
|
|
assert.equal(run(`initials('accidental tech podcast')`), 'AT');
|
|
assert.equal(run(`initials('')`), '?');
|
|
assert.equal(run(`initials('!!!')`), '?');
|
|
assert.equal(run(`plural(1, 'feed')`), '1 feed');
|
|
assert.equal(run(`plural(0, 'feed')`), '0 feeds');
|
|
assert.equal(run(`plural(2, 'feed')`), '2 feeds');
|
|
});
|
|
|
|
test("kindOf names a file by its type, or else its address's extension", () => {
|
|
const kind = enc => run(`kindOf(${JSON.stringify(enc)})`);
|
|
assert.equal(kind({ mime: 'image/png' }), 'image');
|
|
assert.equal(kind({ mime: 'Video/MP4' }), 'video');
|
|
assert.equal(kind({ mime: 'audio/mpeg' }), 'audio');
|
|
assert.equal(kind({ mime: 'application/pdf' }), 'pdf');
|
|
assert.equal(kind({ mime: 'application/x-bittorrent' }), 'torrent');
|
|
assert.equal(kind({ url: 'https://x/ep.MP3?token=1' }), 'mp3');
|
|
assert.equal(kind({ url: 'https://x/download' }), 'file');
|
|
assert.equal(kind({ url: 'https://x/a.toolongext' }), 'file');
|
|
assert.equal(kind({}), 'file');
|
|
});
|
|
|
|
test('an item without a title is named by its file', () => {
|
|
const name = e => run(`entryName(${JSON.stringify(e)})`);
|
|
assert.deepEqual({ ...name({ title: 'Episode 1' }) }, { text: 'Episode 1', derived: false });
|
|
assert.deepEqual({ ...name({ title: ' ', enclosures: [{ url: 'https://x/My%20Show%20101.mp3?x=1' }] }) },
|
|
{ text: 'My Show 101', derived: true });
|
|
// A name that does not decode is shown as it is, not dropped.
|
|
assert.deepEqual({ ...name({ enclosures: [{ url: 'https://x/100%.mp3' }] }) }, { text: '100%', derived: true });
|
|
});
|
|
|
|
test('the directory sorts by name as people read, and by subscribers', () => {
|
|
const sorted = (list, by) => run(`${JSON.stringify(list)}.sort(${by}).map(p => p.title)`);
|
|
assert.deepEqual([...sorted([{ title: 'ep 10' }, { title: 'Ep 2' }, { title: 'ep 1' }], 'byName')], ['ep 1', 'Ep 2', 'ep 10']);
|
|
assert.deepEqual([...sorted([{ title: 'B', subscribers: 1 }, { title: 'C', subscribers: 5 }, { title: 'A', subscribers: 1 }], 'bySubs')],
|
|
['C', 'A', 'B']);
|
|
});
|
|
|
|
test('feeds with something unread go first, in the order they came', () => {
|
|
const ids = run(`[{id:'a',unread:0},{id:'b',unread:3},{id:'c',unread:0},{id:'d',unread:1}].sort(unreadFirst).map(f => f.id)`);
|
|
assert.deepEqual([...ids], ['b', 'd', 'a', 'c']);
|
|
});
|
|
|
|
test('words for filters split on commas, blanks dropped', () => {
|
|
assert.deepEqual([...run(`splitWords(' one, two words ,, three ')`)], ['one', 'two words', 'three']);
|
|
assert.deepEqual([...run(`splitWords('')`)], []);
|
|
});
|
|
|
|
test("a file plays from DeadAir's playlist when it has one", () => {
|
|
assert.equal(run(`mediaSrc({ id: 7, deadair: null })`), '/media/7');
|
|
assert.equal(run(`mediaSrc({ id: 7 })`), '/media/7');
|
|
assert.equal(run(`mediaSrc({ id: 7, deadair: 0 })`), '/media/7/deadair.m3u8');
|
|
});
|
|
|
|
test('kept lists: the newest few dozen, and a changed feed forgets its own and All Subscriptions\'', () => {
|
|
run('lists.clear()');
|
|
for (let i = 0; i < 45; i++) run(`keepList(listKey('f${i}'), {total: 0, entries: []})`);
|
|
assert.equal(run('lists.size'), 40, 'never more than LISTS_KEPT');
|
|
assert.equal(run(`lists.has(listKey('f0'))`), false, 'the oldest go first');
|
|
run(`keepList(listKey('f10'), {total: 1, entries: []})`);
|
|
run(`keepList(listKey(':all'), {total: 0, entries: []}); keepList(listKey(':all', 'unread'), {total: 0, entries: []})`);
|
|
run(`forgetLists('f10')`);
|
|
assert.equal(run(`lists.has(listKey('f10'))`), false);
|
|
assert.equal(run(`lists.has(listKey(':all')) || lists.has(listKey(':all', 'unread'))`), false, 'All Subscriptions holds its items too');
|
|
assert.equal(run(`lists.has(listKey('f11'))`), true, 'the rest stay');
|
|
});
|
|
|
|
test('a Patreon feed is told by its address', () => {
|
|
assert.equal(run(`isPatreon({ url: 'https://www.patreon.com/rss/show?auth=x' })`), true);
|
|
assert.equal(run(`isPatreon({ url: 'https://example.com/feed' })`), false);
|
|
assert.equal(run('isPatreon(null)'), false);
|
|
});
|
|
|
|
test('a Signal is a place of its own, found by its id', () => {
|
|
run(`S.signals = [{id: 7, name: 'F1', keywords: ['f1'], unread: 2}]`);
|
|
assert.equal(run(`signalId(':signal:7')`), 7);
|
|
assert.equal(run(`signalId(':all')`), null);
|
|
assert.equal(run(`signalId('signal-7')`), null, 'a feed id is never a Signal');
|
|
assert.equal(run(`signalOf(':signal:7').name`), 'F1');
|
|
assert.equal(run(`signalOf(':signal:8')`), undefined);
|
|
assert.match(run(`listUrl(':signal:7')`), /^\/api\/signals\/7\/entries\?/);
|
|
run(`S.signals = []`);
|
|
});
|
|
|
|
test('the icon picker finds icons by the start of their words, the one named it first', () => {
|
|
const all = JSON.stringify([
|
|
['solid/caret-down', 'Caret Down', 'arrow'], ['solid/scarf', 'Scarf', 'clothing'], ['solid/taxi', 'Taxi', 'cab car'],
|
|
['solid/car-side', 'Car Side', 'auto vehicle'], ['solid/car', 'Car', 'auto vehicle'], ['solid/robot', 'Robot', 'ai bot machine'],
|
|
['brands/apple', 'Apple', 'fruit ios mac'],
|
|
]);
|
|
const ids = q => JSON.parse(run(`JSON.stringify(iconMatches(${all}, ${JSON.stringify(q)}, 10).map(i => i[0]))`));
|
|
// Named car, then car as a word of the name, then as a search term, then caret; never scarf.
|
|
assert.deepEqual(ids('car'), ['solid/car', 'solid/car-side', 'solid/taxi', 'solid/caret-down']);
|
|
assert.deepEqual(ids('ai'), ['solid/robot'], 'search terms count');
|
|
assert.deepEqual(ids('mac fruit'), ['brands/apple'], 'every word');
|
|
assert.equal(run(`iconMatches(${all}, '', 2).length`), 2, 'nothing typed is the first few');
|
|
});
|
|
|
|
test('editing a Signal sends only what changed, so a new icon asks Jev nothing again', () => {
|
|
const sg = `{id: 1, name: 'F1', keywords: ['f1'], description: null, icon: null, unread: 0}`;
|
|
assert.deepEqual(JSON.parse(run(`JSON.stringify(signalChanges(${sg}, {name: 'F1', keywords: ['f1'], description: null, icon: 'solid/car'}))`)),
|
|
{ icon: 'solid/car' });
|
|
assert.deepEqual(JSON.parse(run(`JSON.stringify(signalChanges(${sg}, {name: 'F1', keywords: ['f1', 'gp'], description: null, icon: null}))`)),
|
|
{ keywords: ['f1', 'gp'] });
|
|
});
|
|
|
|
test('a Signal draws its icon only when it is a style and a plain name', () => {
|
|
assert.match(run(`signalTile({name: 'F1', icon: 'solid/flag-checkered'})`), /url\('\/icons\/solid\/flag-checkered\.svg'\)/);
|
|
assert.doesNotMatch(run(`signalTile({name: 'F1', icon: "solid/x');background:url(evil"})`), /evil/);
|
|
assert.doesNotMatch(run(`signalTile({name: 'F1', icon: null})`), /ficon/);
|
|
});
|