Files
ipx/tests/page-unit.js
rays 9a41641eaa Signals: an admin chooses whether the server's TypeSafe key pays for everyone (#271)
With signals_on_server set and TYPESAFE_KEY present, the judge asks Jev
about everyone's Signals on the server's key; otherwise each person turns
Signals on in Settings with a key of their own, which is now checked with
TypeSafe before it is saved. The Signals folder shows only when they are on.

The browser suite's daemon inherited the shell's TYPESAFE_KEY, so fixture
feeds were sent to the real TypeSafe to be categorised; it is blanked now,
and an empty key counts as none.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 13:18:03 +00:00

211 lines
11 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>')`), '&lt;a href=&quot;x&quot;&gt;Tom &amp; Jerry&#39;s&lt;/a&gt;');
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/);
});
test('Jev is offered the icons found for each word of a Signal', () => {
const all = JSON.stringify([
['solid/robot', 'Robot', 'ai bot machine'], ['solid/code', 'Code', 'programming brackets'],
['solid/car', 'Car', 'auto vehicle'], ['solid/the-red-yeti', 'The Red Yeti', ''],
]);
const ids = JSON.parse(run(`JSON.stringify(iconCandidates(${all}, 'AI Development', ['programming'], 'The tools', 40).map(c => c[0]))`));
assert.deepEqual(ids, ['solid/robot', 'solid/code'], 'each word its icons, "the" none');
const brands = JSON.stringify([
['brands/mandalorian', 'Mandalorian', 'disney plus helmet star wars'], ['brands/apple', 'Apple', 'fruit ios mac'],
['solid/fort-awesome', 'Fort', 'castle'], ['solid/apple-whole', 'Apple Whole', 'fruit'],
]);
const offered = q => JSON.parse(run(`JSON.stringify(iconCandidates(${brands}, ${JSON.stringify(q)}, [], null, 40).map(c => c[0]))`));
assert.deepEqual(offered('Disney castle'), ['solid/fort-awesome'], 'a brand only by its own name, never its search terms (#264)');
assert.deepEqual(offered('Apple'), ['brands/apple', 'solid/apple-whole']);
assert.equal(run(`signalCounts({items: 12, unread: 1})`), '12 items · 1 unread');
});
test('Signals are everyone\'s on the server\'s key, and yours on your own once a key is saved', () => {
const on = me => { run(`S.me = ${JSON.stringify(me)}`); return run('signalsOn()'); };
assert.equal(on({ signals: 'server', typesafe_key_set: false }), true);
assert.equal(on({ signals: 'own', typesafe_key_set: true }), true);
assert.equal(on({ signals: 'own', typesafe_key_set: false }), false);
assert.equal(on(null), false);
run('S.me = null');
});