A click on a feed, All Subscriptions or a filter fetched the list and only then drew it, so nothing answered the click until the server did. The page now keeps the first page of each list (the newest 40, by view, filter, sort and search), draws a kept one at once and refreshes it behind, drawing again only if it changed. It fetches ahead: All Subscriptions and the twenty feeds with the most unread once the page is idle, two at a time, and a feed or filter when the pointer or a finger reaches it. A feed_row or a read forgets that feed's lists and All Subscriptions'; the forgotten are fetched again behind. A list with nothing kept shows a spinner if its answer takes more than 120 ms. list_shown (#246) is reported when the list first shows, so a kept one reports its few ms. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
146 lines
7.0 KiB
JavaScript
146 lines
7.0 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);
|
|
});
|