import { describe, expect, it } from 'vitest'; import { initialNote, patchFrontmatter, readVoiceFields } from '../src/core/note'; import { QueueRunner } from '../src/core/queue'; import type { VoiceRecord } from '../src/core/record'; import { MemoryNotes, MemoryRecordings, ScriptedTranscriber, TestEnv, type Step } from './fakes'; const T0 = new Date('2026-09-27T11:15:03.000Z'); const ok = (text: string): Step => ({ ok: true, text }); const temporary = (message: string): Step => ({ ok: false, kind: 'temporary', message }); const permanent = (message: string): Step => ({ ok: false, kind: 'permanent', message }); function setup(steps: Step[] = []) { const env = new TestEnv(T0); const notes = new MemoryNotes(); const recordings = new MemoryRecordings(); const transcriber = new ScriptedTranscriber(steps); const runner = new QueueRunner({ recordings, notes, transcriber, env }); return { env, notes, recordings, transcriber, runner }; } type Ctx = ReturnType; function rec(id: string, over: Partial = {}): VoiceRecord { return { id, status: 'saved', device: 'test-device', audio: `voice/source/${id}.m4a`, mime: 'audio/mp4', attempts: 0, ...over }; } function seed(ctx: Ctx, record: VoiceRecord, path = `voice/inbox/${record.id}.md`): string { ctx.notes.files.set(path, initialNote(record, 0)); ctx.recordings.files.set(record.audio, new Uint8Array([1, 2, 3]).buffer); return path; } const fields = (ctx: Ctx, path: string) => readVoiceFields(ctx.notes.files.get(path)!); describe('QueueRunner.tick', () => { it('sends a saved record and inserts the trimmed transcript', async () => { const ctx = setup([ok(' привет ')]); const path = seed(ctx, rec('a')); await ctx.runner.tick(); expect(ctx.transcriber.calls).toHaveLength(1); expect(new Uint8Array(ctx.transcriber.calls[0].audio)).toEqual(new Uint8Array([1, 2, 3])); expect(ctx.transcriber.calls[0].mime).toBe('audio/mp4'); expect(fields(ctx, path)['voice-status']).toBe('inserted'); expect(fields(ctx, path)['voice-sent-at']).toBeUndefined(); expect(ctx.notes.files.get(path)).toContain('\n\nпривет\n'); expect(ctx.notes.files.get(path)).not.toContain('%%voice-note'); }); it('network lost, then back: retry at +30s, then inserted', async () => { const ctx = setup([temporary('offline'), ok('текст')]); const path = seed(ctx, rec('a')); await ctx.runner.tick(); expect(fields(ctx, path)).toMatchObject({ 'voice-status': 'retry', 'voice-attempts': '1', 'voice-next-attempt': '2026-09-27T11:15:33.000Z', 'voice-error': 'offline', }); expect(ctx.notes.files.get(path)).toContain('Не удалось отправить, повтор в 14:15 (попытка 1/5)'); ctx.env.advance(17_000); // 11:15:20 — рано await ctx.runner.tick(); expect(ctx.transcriber.calls).toHaveLength(1); ctx.env.advance(13_000); // 11:15:33 — пора await ctx.runner.tick(); expect(ctx.transcriber.calls).toHaveLength(2); expect(fields(ctx, path)['voice-status']).toBe('inserted'); }); it('five temporary failures → failed with the last message', async () => { const ctx = setup([temporary('e1'), temporary('e2'), temporary('e3'), temporary('e4'), temporary('e5')]); const path = seed(ctx, rec('a')); for (let i = 0; i < 5; i++) { await ctx.runner.tick(); ctx.env.advance(3_600_000); } await ctx.runner.tick(); expect(ctx.transcriber.calls).toHaveLength(5); expect(fields(ctx, path)).toMatchObject({ 'voice-status': 'failed', 'voice-attempts': '5', 'voice-error': 'e5' }); }); it('permanent error → failed at once', async () => { const ctx = setup([permanent('401 Unauthorized')]); const path = seed(ctx, rec('a')); await ctx.runner.tick(); expect(fields(ctx, path)).toMatchObject({ 'voice-status': 'failed', 'voice-attempts': '1' }); expect(ctx.notes.files.get(path)).toContain('Не распознано: 401 Unauthorized'); }); it('whitespace-only transcript → failed no-speech', async () => { const ctx = setup([ok(' \n\t ')]); const path = seed(ctx, rec('a')); await ctx.runner.tick(); expect(fields(ctx, path)).toMatchObject({ 'voice-status': 'failed', 'voice-error': 'no-speech' }); expect(ctx.notes.files.get(path)).toContain('> [!info] В записи не распознано речи'); }); it('exception from transcriber is a temporary failure', async () => { const ctx = setup([new Error('socket hang up')]); const path = seed(ctx, rec('a')); await ctx.runner.tick(); expect(fields(ctx, path)).toMatchObject({ 'voice-status': 'retry', 'voice-error': 'socket hang up' }); }); it('missing audio file is a temporary failure', async () => { const ctx = setup([]); const path = seed(ctx, rec('a')); ctx.recordings.files.clear(); await ctx.runner.tick(); expect(ctx.transcriber.calls).toHaveLength(0); expect(fields(ctx, path)['voice-status']).toBe('retry'); }); it("does not send other devices' records", async () => { const ctx = setup([]); const path = seed(ctx, rec('a', { device: 'iphone' })); await ctx.runner.tick(); expect(ctx.transcriber.calls).toHaveLength(0); expect(fields(ctx, path)['voice-status']).toBe('saved'); }); it('own sent older than 10 min → retry without a request; younger untouched', async () => { const ctx = setup([]); const stale = seed(ctx, rec('a', { status: 'sent', sentAt: '2026-09-27T11:04:00.000Z' })); const fresh = seed(ctx, rec('b', { status: 'sent', sentAt: '2026-09-27T11:13:03.000Z' })); const before = ctx.notes.files.get(fresh); await ctx.runner.tick(); expect(ctx.transcriber.calls).toHaveLength(0); expect(fields(ctx, stale)).toMatchObject({ 'voice-status': 'retry', 'voice-attempts': '1', 'voice-error': 'отправка прервана' }); expect(ctx.notes.files.get(fresh)).toBe(before); }); it('processes records one at a time, oldest first', async () => { let inFlight = 0; let maxInFlight = 0; const order: string[] = []; const slow: Step = async (audio) => { inFlight++; maxInFlight = Math.max(maxInFlight, inFlight); order.push(String(new Uint8Array(audio)[0])); await new Promise((resolve) => setTimeout(resolve, 5)); inFlight--; return { ok: true, text: 't' }; }; const ctx = setup([slow, slow, slow]); for (const [id, byte] of [['c', 3], ['a', 1], ['b', 2]] as const) { seed(ctx, rec(id)); ctx.recordings.files.set(`voice/source/${id}.m4a`, new Uint8Array([byte]).buffer); } await ctx.runner.tick(); expect(maxInFlight).toBe(1); expect(order).toEqual(['1', '2', '3']); }); it('tick during tick runs exactly one extra pass', async () => { const ctx: Ctx = setup(); ctx.transcriber.steps.push(async () => { void ctx.runner.tick(); void ctx.runner.tick(); void ctx.runner.tick(); return { ok: true, text: 't' }; }); seed(ctx, rec('a')); await ctx.runner.tick(); expect(ctx.notes.listCalls).toBe(2); expect(ctx.transcriber.calls).toHaveLength(1); }); it('note deleted during the request: result dropped, next record processed', async () => { const ctx: Ctx = setup(); const first = seed(ctx, rec('a')); const second = seed(ctx, rec('b')); ctx.transcriber.steps.push(async () => { ctx.notes.files.delete(first); return { ok: true, text: 'один' }; }, ok('два')); await ctx.runner.tick(); expect(ctx.notes.files.has(first)).toBe(false); expect(fields(ctx, second)['voice-status']).toBe('inserted'); }); it('note deleted before send: skipped without a request', async () => { const ctx = setup([ok('два')]); const second = seed(ctx, rec('b')); ctx.notes.staleList = [ { path: 'voice/inbox/gone.md', frontmatter: readVoiceFields(initialNote(rec('a'), 0)) }, { path: second, frontmatter: fields(ctx, second) }, ]; await ctx.runner.tick(); expect(ctx.transcriber.calls).toHaveLength(1); expect(fields(ctx, second)['voice-status']).toBe('inserted'); }); it('stale list says saved, file says sent: no second request', async () => { const ctx = setup([]); const path = seed(ctx, rec('a', { status: 'sent', sentAt: '2026-09-27T11:15:00.000Z' })); ctx.notes.staleList = [{ path, frontmatter: readVoiceFields(initialNote(rec('a'), 0)) }]; const before = ctx.notes.files.get(path); await ctx.runner.tick(); expect(ctx.transcriber.calls).toHaveLength(0); expect(ctx.notes.files.get(path)).toBe(before); }); it('stale list says saved, file says inserted: untouched', async () => { const ctx = setup([ok('x')]); const path = seed(ctx, rec('a')); await ctx.runner.tick(); const done = ctx.notes.files.get(path); ctx.notes.staleList = [{ path, frontmatter: readVoiceFields(initialNote(rec('a'), 0)) }]; await ctx.runner.tick(); expect(ctx.transcriber.calls).toHaveLength(1); expect(ctx.notes.files.get(path)).toBe(done); }); it('finds records outside voice/inbox', async () => { const ctx = setup([ok('x')]); const path = seed(ctx, rec('a'), 'projects/перенесённая.md'); await ctx.runner.tick(); expect(fields(ctx, path)['voice-status']).toBe('inserted'); }); it('ignores notes with a broken status', async () => { const ctx = setup([]); const path = seed(ctx, rec('a')); ctx.notes.files.set(path, ctx.notes.files.get(path)!.replace('"saved"', '"foo"')); await expect(ctx.runner.tick()).resolves.toBeUndefined(); expect(ctx.transcriber.calls).toHaveLength(0); }); it('dispose aborts the signal given to the transcriber', async () => { const ctx = setup([ok('x')]); seed(ctx, rec('a')); await ctx.runner.tick(); expect(ctx.transcriber.calls[0].signal.aborted).toBe(false); ctx.runner.dispose(); expect(ctx.transcriber.calls[0].signal.aborted).toBe(true); }); it('dispose during a request stops the pass without touching later due records', async () => { const ctx = setup(); const first = seed(ctx, rec('a')); const second = seed(ctx, rec('b')); const beforeSecond = ctx.notes.files.get(second); ctx.transcriber.steps.push(async () => { ctx.runner.dispose(); throw new Error('aborted'); }); await ctx.runner.tick(); expect(ctx.transcriber.calls).toHaveLength(1); expect(fields(ctx, first)).toMatchObject({ 'voice-status': 'retry', 'voice-attempts': '1' }); expect(fields(ctx, second)['voice-status']).toBe('saved'); expect(ctx.notes.files.get(second)).toBe(beforeSecond); const listCallsBefore = ctx.notes.listCalls; await ctx.runner.tick(); expect(ctx.notes.listCalls).toBe(listCallsBefore); }); it('one record throwing does not block the rest; tick rejects after the pass completes', async () => { const ctx = setup([ok('два')]); const first = seed(ctx, rec('a')); const second = seed(ctx, rec('b')); const boom = new Error('boom'); const originalProcess = ctx.notes.process.bind(ctx.notes); ctx.notes.process = async (path, fn) => { if (path === first) throw boom; return originalProcess(path, fn); }; await expect(ctx.runner.tick()).rejects.toBe(boom); expect(ctx.transcriber.calls).toHaveLength(1); expect(fields(ctx, second)['voice-status']).toBe('inserted'); }); it('post-request guard: result is dropped if another device already re-sent the record', async () => { const ctx = setup([]); const path = seed(ctx, rec('a')); ctx.transcriber.steps.push(async () => { const current = ctx.notes.files.get(path)!; ctx.notes.files.set( path, patchFrontmatter(current, { 'voice-device': '"iphone"', 'voice-sent-at': '"2026-09-27T11:16:00.000Z"' }), ); return { ok: true, text: 'x' }; }); await ctx.runner.tick(); expect(fields(ctx, path)).toMatchObject({ 'voice-status': 'sent', 'voice-device': 'iphone', 'voice-sent-at': '2026-09-27T11:16:00.000Z', }); }); it('sent status and sentAt are on disk while the request is in flight', async () => { const ctx = setup(); const path = seed(ctx, rec('a')); ctx.transcriber.steps.push(async () => { expect(fields(ctx, path)).toMatchObject({ 'voice-status': 'sent', 'voice-sent-at': T0.toISOString() }); return { ok: true, text: 'x' }; }); await ctx.runner.tick(); expect(fields(ctx, path)['voice-status']).toBe('inserted'); }); });