// 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: '

Notes & mehr

', 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.'), '

Eins kursiv und fett. Weiter.

\n

Zwei.

'); assert.equal(pa.markdownToHtml('- a\n- b'), ''); assert.equal(pa.markdownToHtml('1. a\n2. b'), '
  1. a
  2. b
'); assert.equal(pa.markdownToHtml('> „Zitat“\n> mehr'), '

„Zitat“ mehr

'); const evil = pa.markdownToHtml(' **** [l](javascript:x) "q"'); assert.ok(!/<img')); assert.equal(pa.markdownToHtml('2 * 3 * 4'), '

2 * 3 * 4

'); }); test('articleHtml: summary, heading, time link, body', () => { const h = pa.articleHtml({summary: 'Kurz & gut.', sections: [ {heading: 'Erste Idee', start: 754.9, body_markdown: 'Text.'}]}); assert.equal(h, '

Kurz & gut.

\n

Erste <b>Idee</b>

\n

▶ ab 12:34

\n

Text.

'); }); // ---- 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, /▶ 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); });