172 lines
8.6 KiB
JavaScript
172 lines
8.6 KiB
JavaScript
// Tests of static/js/podcast-article.js under Node: node --test tools/tests/
|
|
// No network, no LLM: fetch and DioraAI are stubbed.
|
|
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import * as pa from '../../static/js/podcast-article.js';
|
|
|
|
test('transcriptToText groups 30 seconds and splits on speaker change', () => {
|
|
const segs = [
|
|
{start: 0, end: 5, speaker: '', text: 'Hallo zusammen.'},
|
|
{start: 20, end: 29, speaker: '', text: 'Heute geht es um X.'},
|
|
{start: 31, end: 40, speaker: '', text: 'Zweite Gruppe.'},
|
|
{start: 3725, end: 3730, speaker: '', text: 'Spät.'},
|
|
{start: 3726, end: 3730, speaker: '', text: ' '},
|
|
];
|
|
assert.equal(pa.transcriptToText(segs),
|
|
'[00:00] Hallo zusammen. Heute geht es um X.\n[00:31] Zweite Gruppe.\n[62:05] Spät.');
|
|
const sp = [
|
|
{start: 0, speaker: 'Anna', text: 'Eins.'},
|
|
{start: 4, speaker: 'Anna', text: 'Zwei.'},
|
|
{start: 8, speaker: 'Bernd', text: 'Drei.'},
|
|
];
|
|
assert.equal(pa.transcriptToText(sp), '[00:00] Anna: Eins. Zwei.\n[00:08] Bernd: Drei.');
|
|
assert.equal(pa.transcriptToText([]), '');
|
|
});
|
|
|
|
test('clock and mmss', () => {
|
|
assert.equal(pa.mmss(754), '12:34');
|
|
assert.equal(pa.clock(754), '12:34');
|
|
assert.equal(pa.clock(3725), '1:02:05');
|
|
});
|
|
|
|
test('token estimate and the limit message', () => {
|
|
assert.equal(pa.estimateTokens('a'.repeat(400)), 100);
|
|
const big = {segments: Array.from({length: 20000}, (_, i) => ({start: i * 31, speaker: '', text: 'x'.repeat(30)}))};
|
|
assert.throws(() => pa.buildPrompt({title: 'T'}, big), e => e.diora && /zu lang/.test(e.message));
|
|
const ok = pa.buildPrompt({title: 'T', feed: {title: 'P'}, description: '<p>Notes & mehr</p>', duration_seconds: 3725,
|
|
chapters: [{start: 60, title: 'Kap 1'}]}, {segments: [{start: 0, text: 'Hallo'}]});
|
|
assert.match(ok, /Podcast: P\nFolge: T\nDauer: 1:02:05\nShownotes:\nNotes & mehr\nKapitel:\n\[01:00\] Kap 1\n\nTranskript:\n\[00:00\] Hallo$/);
|
|
assert.throws(() => pa.buildPrompt({title: 'T'}, {segments: []}), /leer/);
|
|
});
|
|
|
|
test('system prompt is the one of the contract', async () => {
|
|
const fs = await import('node:fs');
|
|
const doc = fs.readFileSync(new URL('../../docs/transcription.md', import.meta.url), 'utf8').split('\r\n').join('\n');
|
|
assert.ok(doc.includes(pa.SYSTEM));
|
|
assert.deepEqual(pa.SCHEMA.required, ['title', 'summary', 'kind', 'speakers', 'sections']);
|
|
});
|
|
|
|
test('markdownToHtml: paragraphs, emphasis, lists, quotes, escaping', () => {
|
|
assert.equal(pa.markdownToHtml('Eins *kursiv* und **fett**.\nWeiter.\n\nZwei.'),
|
|
'<p>Eins <em>kursiv</em> und <strong>fett</strong>. Weiter.</p>\n<p>Zwei.</p>');
|
|
assert.equal(pa.markdownToHtml('- a\n- b'), '<ul><li>a</li><li>b</li></ul>');
|
|
assert.equal(pa.markdownToHtml('1. a\n2. b'), '<ol><li>a</li><li>b</li></ol>');
|
|
assert.equal(pa.markdownToHtml('> „Zitat“\n> mehr'), '<blockquote><p>„Zitat“ mehr</p></blockquote>');
|
|
const evil = pa.markdownToHtml('<script>alert(1)</script> **<img src=x onerror=y>** [l](javascript:x) "q"');
|
|
assert.ok(!/<script|<img/.test(evil));
|
|
assert.ok(evil.includes('<script>') && evil.includes('<strong><img'));
|
|
assert.equal(pa.markdownToHtml('2 * 3 * 4'), '<p>2 * 3 * 4</p>');
|
|
});
|
|
|
|
test('articleHtml: summary, heading, time link, body', () => {
|
|
const h = pa.articleHtml({summary: 'Kurz & gut.', sections: [
|
|
{heading: 'Erste <b>Idee</b>', start: 754.9, body_markdown: 'Text.'}]});
|
|
assert.equal(h, '<p>Kurz & gut.</p>\n<h2>Erste <b>Idee</b></h2>\n<p><a href="#t=754">▶ ab 12:34</a></p>\n<p>Text.</p>');
|
|
});
|
|
|
|
// ---- stubs ---------------------------------------------------------------------------------
|
|
|
|
function stubWorld({pending, claims = {}, hasKey = true, askImpl, saveStatus = 200}) {
|
|
const calls = [];
|
|
const notices = [];
|
|
globalThis.DioraNative = undefined;
|
|
globalThis.DioraAI = {
|
|
available: () => true,
|
|
hasKey: async () => hasKey,
|
|
withKey: fn => fn(),
|
|
modelName: () => 'test-model',
|
|
toast: t => notices.push(t),
|
|
ask: askImpl || (async opts => {
|
|
opts.onProgress && opts.onProgress(1200);
|
|
return {title: 'Titel', summary: 'Sum.', kind: 'Gespräch', speakers: [], sections: [{heading: 'H', start: 61, body_markdown: 'B'}]};
|
|
}),
|
|
};
|
|
globalThis.fetch = async (url, init = {}) => {
|
|
const method = init.method || 'GET';
|
|
calls.push(method + ' ' + url + (init.body ? ' ' + init.body : ''));
|
|
const json = (status, data) => ({status, ok: status < 400, json: async () => data});
|
|
if (url === '/podcasts/transcription/pending/') return json(200, pending);
|
|
let m = /^\/podcasts\/episodes\/(\d+)\/article\/claim\/$/.exec(url);
|
|
if (m) return method === 'POST' ? json(claims[m[1]] || 200, {}) : json(200, {});
|
|
m = /^\/podcasts\/episodes\/(\d+)\/transcript\/$/.exec(url);
|
|
if (m) return m[1] === '3' ? json(404, {error: 'no transcript'}) : json(200, {segments: [{start: 0, speaker: '', text: 'Hallo'}]});
|
|
m = /^\/podcasts\/episodes\/(\d+)\/article\/$/.exec(url);
|
|
if (m) return json(saveStatus, {item_id: 900 + Number(m[1])});
|
|
m = /^\/podcasts\/episodes\/(\d+)\/$/.exec(url);
|
|
if (m) return json(200, {id: Number(m[1]), title: 'Folge ' + m[1], description: '', chapters: [], feed: {title: 'P'}});
|
|
return json(404, {});
|
|
};
|
|
return {calls, notices};
|
|
}
|
|
|
|
test('write: loads, asks with the contract options, saves the HTML', async () => {
|
|
const w = stubWorld({pending: []});
|
|
let opts;
|
|
globalThis.DioraAI.ask = async o => { opts = o; o.onProgress(5); return {title: 'T', summary: 'S', kind: 'Monolog', speakers: [], sections: [{heading: 'H', start: 61, body_markdown: 'B'}]}; };
|
|
const states = [];
|
|
const r = await pa.write(7, {onState: s => states.push(s.phase)});
|
|
assert.deepEqual(r, {item_id: 907, title: 'T'});
|
|
assert.equal(opts.effort, 'medium');
|
|
assert.equal(opts.maxTokens, 32000);
|
|
assert.equal(opts.system, pa.SYSTEM);
|
|
assert.match(opts.prompt, /Transkript:\n\[00:00\] Hallo$/);
|
|
assert.deepEqual(states, ['loading', 'writing', 'saving']);
|
|
const post = w.calls.find(c => c.startsWith('POST /podcasts/episodes/7/article/ '));
|
|
const body = JSON.parse(post.slice(post.indexOf('{')));
|
|
assert.equal(body.model, 'test-model');
|
|
assert.equal(body.kind, 'Monolog');
|
|
assert.match(body.body_html, /<a href="#t=61">▶ ab 01:01<\/a>/);
|
|
});
|
|
|
|
test('write: no transcript gives a German message', async () => {
|
|
stubWorld({pending: []});
|
|
await assert.rejects(pa.write(3), /noch kein Transkript/);
|
|
});
|
|
|
|
test('pickUp: claim, 409 skipped, failure releases the claim, notifier per success', async () => {
|
|
pa._reset();
|
|
const w = stubWorld({
|
|
pending: [{episode_id: 1, title: 'Eins'}, {episode_id: 2, title: 'Zwei'}, {episode_id: 4, title: 'Vier'}],
|
|
claims: {2: 409},
|
|
askImpl: async o => {
|
|
if (o.prompt.includes('Folge 4')) throw new Error('kaputt');
|
|
return {title: 'A', summary: 'S', kind: 'Gespräch', speakers: [], sections: [{heading: 'H', start: 0, body_markdown: 'B'}]};
|
|
},
|
|
});
|
|
const r = await pa.pickUp();
|
|
assert.deepEqual(r, {done: 1, failed: 1, skipped: 1});
|
|
assert.ok(w.calls.some(c => /^POST \/podcasts\/episodes\/2\/article\/claim\/ \{"device":"d-/.test(c)));
|
|
assert.ok(!w.calls.some(c => c.startsWith('GET /podcasts/episodes/2/'))); // 409: not written
|
|
assert.ok(w.calls.some(c => c.startsWith('DELETE /podcasts/episodes/4/article/claim/'))); // failed: released
|
|
assert.ok(!w.calls.some(c => c.startsWith('DELETE /podcasts/episodes/1/article/claim/')));
|
|
assert.deepEqual(w.notices, ['Artikel fertig: A']);
|
|
// The failed one is not tried again in this visit.
|
|
const w2 = stubWorld({pending: [{episode_id: 4, title: 'Vier'}]});
|
|
assert.deepEqual(await pa.pickUp(), {done: 0, failed: 0, skipped: 1});
|
|
assert.ok(!w2.calls.some(c => c.includes('claim')));
|
|
});
|
|
|
|
test('pickUp: android bridge notifies instead of the toast', async () => {
|
|
pa._reset();
|
|
const w = stubWorld({pending: [{episode_id: 1, title: 'Eins'}]});
|
|
const got = [];
|
|
globalThis.DioraNative = {notify: (...a) => got.push(a)};
|
|
await pa.pickUp();
|
|
assert.deepEqual(got, [['Artikel fertig', 'Titel', 'episode:1']]);
|
|
assert.deepEqual(w.notices, []);
|
|
globalThis.DioraNative = undefined;
|
|
});
|
|
|
|
test('pickUp: no key does nothing, not even a request; no second run at the same time', async () => {
|
|
pa._reset();
|
|
const w = stubWorld({pending: [{episode_id: 1, title: 'x'}], hasKey: false});
|
|
assert.deepEqual(await pa.pickUp(), {done: 0, failed: 0, skipped: 0});
|
|
assert.deepEqual(w.calls, []);
|
|
|
|
const w2 = stubWorld({pending: [{episode_id: 1, title: 'x'}]});
|
|
const a = pa.pickUp();
|
|
const b = await pa.pickUp();
|
|
assert.deepEqual(b, {busy: true});
|
|
assert.equal((await a).done, 1);
|
|
assert.equal(w2.calls.filter(c => c.startsWith('POST') && c.includes('/claim/ {"device":')).length, 1);
|
|
});
|