import { describe, expect, it } from 'vitest'; import { initialNote, 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'); 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): string { const path = `voice/inbox/${record.id}.md`; ctx.notes.files.set(path, initialNote(record, 0)); ctx.recordings.files.set(record.audio, new Uint8Array([1]).buffer); return path; } const fields = (ctx: Ctx, path: string) => readVoiceFields(ctx.notes.files.get(path)!); describe('QueueRunner.retry', () => { it('failed → sent again with attempts from zero', async () => { const ctx = setup([{ ok: false, kind: 'temporary', message: 'offline' }]); const path = seed(ctx, rec('a', { status: 'failed', attempts: 5, error: '401' })); expect(await ctx.runner.retry(path)).toBe(true); expect(ctx.transcriber.calls).toHaveLength(1); expect(fields(ctx, path)).toMatchObject({ 'voice-status': 'retry', 'voice-attempts': '1' }); }); it('retry state: sends now without waiting', async () => { const ctx = setup([{ ok: true, text: 'x' }]); const path = seed(ctx, rec('a', { status: 'retry', attempts: 2, nextAttempt: '2026-09-27T12:00:00.000Z', error: 't' })); expect(await ctx.runner.retry(path)).toBe(true); expect(fields(ctx, path)).toMatchObject({ 'voice-status': 'inserted', 'voice-attempts': '0' }); }); it("claims another device's failed record", async () => { const ctx = setup([{ ok: true, text: 'x' }]); const path = seed(ctx, rec('a', { status: 'failed', attempts: 5, error: 'e', device: 'iphone' })); expect(await ctx.runner.retry(path)).toBe(true); expect(fields(ctx, path)).toMatchObject({ 'voice-status': 'inserted', 'voice-device': 'test-device' }); }); it.each([ ['saved', {}], ['inserted', {}], ['sent', { sentAt: T0.toISOString() }], ] as const)('%s: no change, no request', async (status, over) => { const ctx = setup([]); const path = seed(ctx, rec('a', { status, ...over })); const before = ctx.notes.files.get(path); expect(await ctx.runner.retry(path)).toBe(false); expect(ctx.notes.files.get(path)).toBe(before); expect(ctx.transcriber.calls).toHaveLength(0); }); it('missing note → false', async () => { expect(await setup().runner.retry('voice/inbox/nope.md')).toBe(false); }); it('after dispose(): no change, no request', async () => { const ctx = setup([]); const path = seed(ctx, rec('a', { status: 'failed', attempts: 5, error: 'e' })); const before = ctx.notes.files.get(path); ctx.runner.dispose(); expect(await ctx.runner.retry(path)).toBe(false); expect(ctx.notes.files.get(path)).toBe(before); expect(ctx.transcriber.calls).toHaveLength(0); }); }); describe('QueueRunner.claimAll', () => { it("takes the phone's retry and sends it now, keeping attempts", async () => { const ctx = setup([{ ok: true, text: 'x' }]); const path = seed(ctx, rec('a', { device: 'iphone', status: 'retry', attempts: 2, nextAttempt: '2026-09-27T11:35:03.000Z', error: 't' })); expect(await ctx.runner.claimAll()).toBe(1); expect(ctx.transcriber.calls).toHaveLength(1); expect(fields(ctx, path)).toMatchObject({ 'voice-status': 'inserted', 'voice-device': 'test-device', 'voice-attempts': '2' }); }); it('includes stale sent, own retry and own stale sent; leaves failed, inserted and fresh sent untouched', async () => { const ctx = setup([ { ok: true, text: 'w' }, { ok: true, text: 'x' }, { ok: true, text: 'y' }, { ok: true, text: 'z' }, ]); const o = { device: 'iphone' }; const saved = seed(ctx, rec('a', o)); const stale = seed(ctx, rec('b', { ...o, status: 'sent', sentAt: '2026-09-27T11:04:00.000Z' })); const fresh = seed(ctx, rec('c', { ...o, status: 'sent', sentAt: '2026-09-27T11:13:03.000Z' })); const failed = seed(ctx, rec('d', { ...o, status: 'failed', attempts: 5, error: 'e' })); const done = seed(ctx, rec('e', { ...o, status: 'inserted' })); const ownRetry = seed(ctx, rec('f', { status: 'retry', attempts: 1, nextAttempt: '2026-09-27T11:35:03.000Z', error: 't' })); const ownStale = seed(ctx, rec('g', { status: 'sent', sentAt: '2026-09-27T11:04:00.000Z' })); const ownFresh = seed(ctx, rec('h', { status: 'sent', sentAt: '2026-09-27T11:13:03.000Z' })); const untouched = [fresh, failed, done, ownFresh].map((p) => ctx.notes.files.get(p)); expect(await ctx.runner.claimAll()).toBe(2); expect(fields(ctx, saved)['voice-status']).toBe('inserted'); expect(fields(ctx, stale)['voice-status']).toBe('inserted'); expect(fields(ctx, ownRetry)['voice-status']).toBe('inserted'); expect(fields(ctx, ownStale)['voice-status']).toBe('inserted'); expect([fresh, failed, done, ownFresh].map((p) => ctx.notes.files.get(p))).toEqual(untouched); }); it('nothing to claim → 0', async () => { expect(await setup().runner.claimAll()).toBe(0); }); });