- I-1 (F-1): claimAll() now also moves this device's own `retry` records and own stale `sent` records to `saved` via the `claim` transition (device = own), same as it already does for other devices' claimable records, then runs a normal tick() so everything sends without waiting for the next retry time. The returned count still only reflects records reassigned from other devices. Adds `ownReclaimable()` to schedule.ts. - M-1 (F-2): pass() wraps each per-record operation (staleOwn update and each send) in try/catch, remembers the first error, keeps processing the rest of the due queue, and rethrows the first error after both loops complete — so one permanently failing record no longer blocks newer records behind it. list()/load() failures still reject tick() immediately, as before. - F-4a: send()'s post-request update now also requires `r.device === env.deviceId && r.sentAt === sent.sentAt`, so a late transcription result can never overwrite a record another device (or a newer send) has since taken over. - F-4b: pass()'s staleOwn loop now also stops when the abort signal fires; retry() and claimAll() return early (false / 0) without writing once disposed. - F-4d: retry() no-op it.each now also covers `sent` (own, fresh). Refs #1 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
122 lines
5.6 KiB
TypeScript
122 lines
5.6 KiB
TypeScript
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<typeof setup>;
|
|
|
|
function rec(id: string, over: Partial<VoiceRecord> = {}): 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);
|
|
});
|
|
});
|