feat(core): due, stale-sent and claimable selection
Refs #1 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
d579ffd4a1
commit
0cd683d5df
38
src/core/schedule.ts
Normal file
38
src/core/schedule.ts
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
import { SENT_STALE_MS, type VoiceRecord } from './record';
|
||||||
|
|
||||||
|
export interface Located {
|
||||||
|
path: string;
|
||||||
|
record: VoiceRecord;
|
||||||
|
}
|
||||||
|
|
||||||
|
const time = (iso?: string) => (iso ? Date.parse(iso) : NaN);
|
||||||
|
|
||||||
|
// Отсутствующее или испорченное время — не повод зависнуть навсегда.
|
||||||
|
export function isStaleSent(r: VoiceRecord, now: Date): boolean {
|
||||||
|
if (r.status !== 'sent') return false;
|
||||||
|
const t = time(r.sentAt);
|
||||||
|
return Number.isNaN(t) || now.getTime() - t > SENT_STALE_MS;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function due(items: readonly Located[], now: Date, deviceId: string): Located[] {
|
||||||
|
return items
|
||||||
|
.filter(({ record: r }) => {
|
||||||
|
if (r.device !== deviceId) return false;
|
||||||
|
if (r.status === 'saved') return true;
|
||||||
|
if (r.status !== 'retry') return false;
|
||||||
|
const t = time(r.nextAttempt);
|
||||||
|
return Number.isNaN(t) || t <= now.getTime();
|
||||||
|
})
|
||||||
|
.sort((a, b) => (a.record.id < b.record.id ? -1 : a.record.id > b.record.id ? 1 : 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function staleOwn(items: readonly Located[], now: Date, deviceId: string): Located[] {
|
||||||
|
return items.filter(({ record: r }) => r.device === deviceId && isStaleSent(r, now));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function claimable(items: readonly Located[], now: Date, deviceId: string): Located[] {
|
||||||
|
return items.filter(
|
||||||
|
({ record: r }) =>
|
||||||
|
r.device !== deviceId && (r.status === 'saved' || r.status === 'retry' || isStaleSent(r, now)),
|
||||||
|
);
|
||||||
|
}
|
||||||
74
tests/schedule.test.ts
Normal file
74
tests/schedule.test.ts
Normal file
@ -0,0 +1,74 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import type { VoiceRecord } from '../src/core/record';
|
||||||
|
import { claimable, due, isStaleSent, staleOwn, type Located } from '../src/core/schedule';
|
||||||
|
|
||||||
|
const NOW = new Date('2026-09-27T11:15:03.000Z');
|
||||||
|
const ago = (ms: number) => new Date(NOW.getTime() - ms).toISOString();
|
||||||
|
const later = (ms: number) => new Date(NOW.getTime() + ms).toISOString();
|
||||||
|
|
||||||
|
function item(id: string, over: Partial<VoiceRecord> = {}): Located {
|
||||||
|
return {
|
||||||
|
path: `voice/inbox/${id}.md`,
|
||||||
|
record: { id, status: 'saved', device: 'me', audio: `voice/source/${id}.m4a`, mime: 'audio/mp4', attempts: 0, ...over },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const ids = (xs: Located[]) => xs.map((x) => x.record.id);
|
||||||
|
|
||||||
|
describe('due', () => {
|
||||||
|
it('takes own saved and retry whose time has come, oldest id first', () => {
|
||||||
|
const items = [
|
||||||
|
item('c', { status: 'retry', nextAttempt: NOW.toISOString() }),
|
||||||
|
item('a'),
|
||||||
|
item('b', { status: 'retry', nextAttempt: later(1) }),
|
||||||
|
item('d', { device: 'other' }),
|
||||||
|
item('e', { status: 'sent', sentAt: ago(0) }),
|
||||||
|
item('f', { status: 'failed' }),
|
||||||
|
item('g', { status: 'inserted' }),
|
||||||
|
];
|
||||||
|
expect(ids(due(items, NOW, 'me'))).toEqual(['a', 'c']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('unparsable nextAttempt is due', () => {
|
||||||
|
expect(ids(due([item('a', { status: 'retry', nextAttempt: 'завтра' })], NOW, 'me'))).toEqual(['a']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isStaleSent', () => {
|
||||||
|
it.each([
|
||||||
|
['exactly 10 min', { status: 'sent' as const, sentAt: ago(600_000) }, false],
|
||||||
|
['10 min + 1 ms', { status: 'sent' as const, sentAt: ago(600_001) }, true],
|
||||||
|
['2 min', { status: 'sent' as const, sentAt: ago(120_000) }, false],
|
||||||
|
['no sentAt', { status: 'sent' as const }, true],
|
||||||
|
['garbage sentAt', { status: 'sent' as const, sentAt: 'x' }, true],
|
||||||
|
['not sent', { status: 'retry' as const, sentAt: ago(3_600_000) }, false],
|
||||||
|
])('%s → %s', (_name, over, expected) => {
|
||||||
|
expect(isStaleSent(item('a', over).record, NOW)).toBe(expected);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('staleOwn', () => {
|
||||||
|
it('only own stale sent', () => {
|
||||||
|
const items = [
|
||||||
|
item('a', { status: 'sent', sentAt: ago(700_000) }),
|
||||||
|
item('b', { status: 'sent', sentAt: ago(700_000), device: 'other' }),
|
||||||
|
item('c', { status: 'sent', sentAt: ago(1_000) }),
|
||||||
|
];
|
||||||
|
expect(ids(staleOwn(items, NOW, 'me'))).toEqual(['a']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('claimable', () => {
|
||||||
|
it("others' saved, retry and stale sent only", () => {
|
||||||
|
const o = { device: 'other' };
|
||||||
|
const items = [
|
||||||
|
item('a', o),
|
||||||
|
item('b', { ...o, status: 'retry', nextAttempt: later(1_200_000) }),
|
||||||
|
item('c', { ...o, status: 'sent', sentAt: ago(700_000) }),
|
||||||
|
item('d', { ...o, status: 'sent', sentAt: ago(1_000) }),
|
||||||
|
item('e', { ...o, status: 'failed' }),
|
||||||
|
item('f', { ...o, status: 'inserted' }),
|
||||||
|
item('g'),
|
||||||
|
];
|
||||||
|
expect(ids(claimable(items, NOW, 'me'))).toEqual(['a', 'b', 'c']);
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
x
Reference in New Issue
Block a user