Getting an episode onto a phone took two buttons in two states: download it to the server, wait, then keep it on the device, beside Save to this computer, Share and Delete, five buttons that did not say where each copy went. Now a file has one button that brings it to where you are. On a phone or in the iOS app it ends on the device; when iPX has not got the file yet, iPX fetches it and the device's copy follows once iPX says it is done (remembered in localStorage, so a page closed meanwhile catches up when a list next shows the file). On a computer it has iPX fetch the file, and with iPX's copy there saves one. A line beside the size says where the file is, and the rarer actions, two of them destructive, are under a menu. isPlayable is false for any file not on iPX, so the phone's button is decided by playsKind, the same test without the path. download_done now marks the file as on iPX in what the page holds, so the open file's box changes at once rather than after the list is fetched again. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RqkrVA8TdTxe556JB3sR1n
371 lines
20 KiB
TypeScript
371 lines
20 KiB
TypeScript
// Unit tests for the page's own functions (#234): the script as shipped, loaded into the stub DOM
|
|
// page-smoke.ts 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.ts
|
|
//
|
|
// 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.
|
|
import { test } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { makeContext, vm, scriptName } from './dom-stub.ts';
|
|
import { buildPage } from '../web/build.mts';
|
|
|
|
const { html, js } = buildPage('index.html');
|
|
const { ctx } = makeContext(html, js);
|
|
vm.createContext(ctx);
|
|
vm.runInContext(js, ctx, { filename: scriptName(js, 'index.html<script>'), timeout: 5000 });
|
|
// What the page's script answers, from inside the VM: its types are the script's, not this file's.
|
|
const run = (expr: string): any => 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('webUrl keeps only web addresses from a feed', () => {
|
|
assert.equal(run(`webUrl('https://example.com/a')`), 'https://example.com/a');
|
|
assert.equal(run(`webUrl('HTTP://example.com/')`), 'HTTP://example.com/');
|
|
for (const u of ['javascript:alert(1)', ' javascript:alert(1)', 'data:text/html,x', 'vbscript:x', '//example.com', ''])
|
|
assert.equal(run(`webUrl(${JSON.stringify(u)})`), null, u);
|
|
assert.equal(run('webUrl(null)'), null);
|
|
});
|
|
|
|
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('the sleep timer reads as minutes left, rounded up', () => {
|
|
assert.equal(run('sleepLabel(0)'), 'Sleep');
|
|
assert.equal(run('sleepLabel(-5)'), 'Sleep');
|
|
assert.equal(run('sleepLabel(1000)'), '1 min left');
|
|
assert.equal(run('sleepLabel(15 * 60000)'), '15 min left');
|
|
assert.equal(run('sleepLabel(15 * 60000 + 1)'), '16 min left');
|
|
});
|
|
|
|
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: object) => 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: object) => 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: object[], by: string) => 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("All Subscriptions' unread count leaves out feeds hidden from it, and an OPML's shows with it", () => {
|
|
run(`S.feeds = [
|
|
{id: 'o', group: null, unread: 0, hide_from_all: true},
|
|
{id: 'a', group: 'o', unread: 2, hide_from_all: false},
|
|
{id: 'c', group: null, unread: 3, hide_from_all: true},
|
|
{id: 'd', group: null, unread: 5, hide_from_all: false}]`);
|
|
assert.equal(run('unreadInAll()'), 5);
|
|
run(`S.feeds[0].hide_from_all = false`);
|
|
assert.equal(run('unreadInAll()'), 7);
|
|
run(`S.feeds = []`);
|
|
});
|
|
|
|
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: string) => 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: string) => 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: object | null) => { 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');
|
|
});
|
|
|
|
test('a tap is its trace\'s root span, reported with its ids once it has an answer (#376)', () => {
|
|
run(`globalThis.sent = []; fetch = (url, o) => { sent.push([url, o]); return Promise.resolve({ ok: true, status: 204 }); }`);
|
|
run(`newTrace('pointerdown'); traceAt = Date.now()`);
|
|
const [, trace, parent] = run('traceparent()').split('-');
|
|
assert.equal(parent, run('rootId'), 'requests hang from the tap');
|
|
assert.equal(trace, run('traceId'));
|
|
run('endTap()');
|
|
assert.equal(run('sent.length'), 0, 'nothing answered yet, nothing to report');
|
|
run(`newTrace('keydown'); answeredAt = rootAt + 250; newTrace('pointerdown')`);
|
|
const body = JSON.parse(run(`sent.at(-1)[1].body`));
|
|
assert.equal(run(`sent.at(-1)[0]`), '/api/client');
|
|
assert.deepEqual([body.ev, body.ms, body.msg, body.span.length, body.trace.length], ['tap', 250, 'keydown', 16, 32]);
|
|
});
|
|
|
|
test('a request begun long after a tap\'s last answer does not stretch the tap (#379)', async () => {
|
|
run(`globalThis.sent = []; fetch = (url, o) => { sent.push([url, o]); return Promise.resolve({ ok: true, status: 204 }); }`);
|
|
run(`newTrace('pointerdown'); traceAt = Date.now()`);
|
|
await run(`api('/api/a')`);
|
|
const answered = run('answeredAt');
|
|
assert.ok(answered >= run('rootAt'), 'its own request ends it');
|
|
run('answeredAt -= 5000; rootAt -= 5000');
|
|
await run(`api('/api/later')`);
|
|
assert.equal(run('answeredAt'), answered - 5000, 'a refresh five seconds on is not the tap\'s');
|
|
assert.equal(run('tapBusy'), 0);
|
|
run(`rootKind = ''`);
|
|
});
|
|
|
|
test('a tap ended over a minute after its last request reports once, without recursing (#378)', () => {
|
|
run(`globalThis.sent = []; fetch = (url, o) => { sent.push([url, o]); return Promise.resolve({ ok: true, status: 204 }); }`);
|
|
run(`newTrace('pointerdown'); answeredAt = rootAt + 100; traceAt = Date.now() - 120_000`);
|
|
run('endTap()');
|
|
assert.equal(run(`sent.filter(([u, o]) => JSON.parse(o.body).ev === 'tap').length`), 1);
|
|
});
|
|
|
|
test('the next page starts after the unread ones on the Unread tab, after all of them elsewhere', () => {
|
|
run(`S.entries = [{guid:'a',read:true},{guid:'b',read:false},{guid:'c',read:true}]; S.total = 3`);
|
|
run(`S.filter = 'unread'`);
|
|
// Two read here are off the server's unread list: starting at 3 would skip two unread ones (#383).
|
|
assert.equal(run('nextOffset()'), 1);
|
|
run(`S.filter = 'all'`);
|
|
assert.equal(run('nextOffset()'), 3);
|
|
});
|
|
|
|
test('a short Unread list read through says nothing more to load (#385)', () => {
|
|
run(`S.filter = 'unread'; S.offset = 0`);
|
|
run(`showList({total: 2, entries: [{guid:'a',read:false,enclosures:[]},{guid:'b',read:false,enclosures:[]}]}, false, 0)`);
|
|
run(`S.entries.forEach(e => e.read = true)`);
|
|
// nextOffset() is 0 now, under the total of 2, which once read as more to load.
|
|
assert.equal(run('hasMore()'), false);
|
|
run(`S.offset = 50; showList({total: 120, entries: Array.from({length: 50}, (_, i) => ({guid: 'p' + i, read: false, enclosures: []}))}, true, 0)`);
|
|
assert.equal(run('hasMore()'), true);
|
|
});
|
|
|
|
test('a reader is stuck only when a drag up or down had room to move it and did not (#384)', () => {
|
|
assert.equal(run('stuckDrag(-200, 5, 0, 0, 1000)'), true); // up from the top, more below, did not move
|
|
assert.equal(run('stuckDrag(-200, 5, 0, 180, 1000)'), false); // it moved
|
|
assert.equal(run('stuckDrag(200, 5, 0, 0, 1000)'), false); // down at the top: nowhere to go
|
|
assert.equal(run('stuckDrag(-200, 5, 1000, 1000, 1000)'), false);// up at the bottom: nowhere to go
|
|
assert.equal(run('stuckDrag(-200, 5, 0, 0, 0)'), false); // nothing to scroll
|
|
assert.equal(run('stuckDrag(-20, 0, 0, 0, 1000)'), false); // too short to mean it
|
|
assert.equal(run('stuckDrag(-100, 150, 0, 0, 1000)'), false); // a swipe, not a scroll
|
|
});
|
|
|
|
test('the pictures saved for offline are those in unread items\' text, as the browser will ask for them (#389)', () => {
|
|
const pics = run(`textPictures([
|
|
{read:false, description:'<p>x</p><img src="https://a.example/1.jpg?w=1&h=2" alt="a"><img alt="none">', markdown_html:'<img src="https://a.example/1.jpg?w=1&h=2">'},
|
|
{read:false, description:'<img src="data:image/png;base64,xx"><img src="/relative.png">', markdown_html:null},
|
|
{read:true, description:'<img src="https://a.example/read.jpg">', markdown_html:null},
|
|
])`);
|
|
assert.deepEqual([...pics], ['https://a.example/1.jpg?w=1&h=2']);
|
|
});
|
|
|
|
test('an episode kept on the device is the copy it plays, and plays from there (#412)', () => {
|
|
// DeadAir's copy for someone it cuts something for, the file itself otherwise: in the clock
|
|
// their places are saved in.
|
|
assert.equal(run('keepUrl({id: 3, deadair: 40})'), '/media/3/deadair');
|
|
assert.equal(run('keepUrl({id: 3, deadair: 0})'), '/media/3');
|
|
assert.equal(run('keepUrl({id: 3})'), '/media/3');
|
|
assert.equal(run('mediaSrc({id: 3, deadair: 40})'), '/media/3/deadair.m3u8');
|
|
run(`kept.add('/media/3/deadair')`);
|
|
assert.equal(run('mediaSrc({id: 3, deadair: 40})'), '/media/3/deadair?offline');
|
|
// Kept as the file, now cut by DeadAir: that copy's clock is not theirs any more.
|
|
run(`kept.clear(); kept.add('/media/3')`);
|
|
assert.equal(run('mediaSrc({id: 3, deadair: 40})'), '/media/3/deadair.m3u8');
|
|
assert.equal(run('mediaSrc({id: 3, deadair: null})'), '/media/3?offline');
|
|
run('kept.clear()');
|
|
});
|
|
|
|
test('a shared link starts where you are, from five seconds in (#447)', () => {
|
|
const url = 'https://play.ipxreader.app/e/abcdefghij';
|
|
assert.equal(run(`shareAt(${JSON.stringify(url)}, 754.6)`), `${url}?t=754`);
|
|
assert.equal(run(`shareAt(${JSON.stringify(url)}, 3)`), url);
|
|
});
|
|
|
|
test('Share says why an episode gets no page anyone can play, and warns of a key in its address (#454)', () => {
|
|
const note = (why: string | null, url: string) => run(`noLinkNote(${JSON.stringify(why)}, ${JSON.stringify(url)})`) as string;
|
|
const paid = 'https://c10.patreonusercdn.com/ep.mp3?token-hash=abc';
|
|
assert.match(note('private', paid), /private to you.*includes your own access/s);
|
|
assert.match(note('failed', 'https://example.com/ep.mp3'), /could not be made just now/);
|
|
assert.doesNotMatch(note('failed', 'https://example.com/ep.mp3'), /own access/);
|
|
// A server with no ipx-play shares as it always has, with no reason to give.
|
|
assert.equal(note('not_set_up', 'https://example.com/ep.mp3'), '');
|
|
// The request failed outright: no reason known, but the key is still warned of.
|
|
assert.match(note(null, paid), /own access/);
|
|
});
|
|
|
|
test('a Patreon feed or file carries your access, a post page does not (#455)', () => {
|
|
const carries = (url: string) => run(`carriesAccess(${JSON.stringify(url)})`) as boolean;
|
|
assert.equal(carries('https://www.patreon.com/rss/glasscannon?auth=abc&show=1'), true);
|
|
assert.equal(carries('https://www.patreon.com/api/rss/u/H0Pm/e/174604106.mp3?sig=abc'), true);
|
|
assert.equal(carries('https://c10.patreonusercdn.com/ep.mp3?token-hash=abc'), true);
|
|
assert.equal(carries('https://www.patreon.com/glasscannon/posts/music-from-room-174604106'), false);
|
|
assert.equal(carries('https://example.com/ep.mp3'), false);
|
|
});
|
|
|
|
test('a file\'s box says where it is: the publisher, on its way, iPX, this device (#457)', () => {
|
|
const where = (x: object) => run(`whereText(${JSON.stringify({ id: 7, deadair: 0, path: null, state: 'held', ...x })})`) as string;
|
|
assert.equal(where({}), 'Publisher only');
|
|
assert.equal(where({ state: 'pending' }), 'Queued for iPX');
|
|
assert.equal(where({ state: 'error' }), 'iPX could not download it');
|
|
run(`want(7, 'Ep')`);
|
|
assert.equal(where({ state: 'pending' }), 'Downloading to iPX, then this device');
|
|
run('unwant(7)');
|
|
assert.equal(where({ path: '/d/ep.mp3', state: 'done' }), 'On iPX');
|
|
run(`kept.add('/media/7')`);
|
|
assert.equal(where({ path: '/d/ep.mp3', state: 'done' }), 'On iPX and this device');
|
|
// iPX let its copy go; the one here stays.
|
|
assert.equal(where({ state: 'reaped' }), 'On this device');
|
|
run('kept.clear()');
|
|
});
|