Refs #5 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QKHeGuAYh4W1jNdjMConZa
132 lines
4.1 KiB
TypeScript
132 lines
4.1 KiB
TypeScript
import { readVoiceFields } from '../src/core/note';
|
||
import type {
|
||
Env,
|
||
HttpClient,
|
||
HttpRequest,
|
||
HttpResponse,
|
||
ListedNote,
|
||
NoteStore,
|
||
RecordingStore,
|
||
TranscribeResult,
|
||
Transcriber,
|
||
} from '../src/core/ports';
|
||
|
||
export class MemoryRecordings implements RecordingStore {
|
||
files = new Map<string, ArrayBuffer>();
|
||
|
||
async write(path: string, data: ArrayBuffer): Promise<void> {
|
||
this.files.set(path, data.slice(0));
|
||
}
|
||
|
||
async read(path: string): Promise<ArrayBuffer> {
|
||
const data = this.files.get(path);
|
||
if (!data) throw new Error(`no such file: ${path}`);
|
||
return data.slice(0);
|
||
}
|
||
}
|
||
|
||
export class MemoryNotes implements NoteStore {
|
||
files = new Map<string, string>();
|
||
// Имитация отстающего metadataCache: если задано, list() возвращает это.
|
||
staleList: ListedNote[] | null = null;
|
||
listCalls = 0;
|
||
|
||
async exists(path: string): Promise<boolean> {
|
||
return this.files.has(path);
|
||
}
|
||
|
||
async create(path: string, content: string): Promise<void> {
|
||
if (this.files.has(path)) throw new Error(`already exists: ${path}`);
|
||
this.files.set(path, content);
|
||
}
|
||
|
||
async list(): Promise<ListedNote[]> {
|
||
this.listCalls++;
|
||
if (this.staleList) return this.staleList;
|
||
return [...this.files]
|
||
.map(([path, content]) => ({ path, frontmatter: readVoiceFields(content) as Record<string, unknown> }))
|
||
.filter((note) => 'voice-id' in note.frontmatter);
|
||
}
|
||
|
||
async process(path: string, fn: (content: string) => string): Promise<boolean> {
|
||
const content = this.files.get(path);
|
||
if (content === undefined) return false;
|
||
this.files.set(path, fn(content));
|
||
return true;
|
||
}
|
||
}
|
||
|
||
export type Step =
|
||
| TranscribeResult
|
||
| Error
|
||
| ((audio: ArrayBuffer, mime: string) => Promise<TranscribeResult>);
|
||
|
||
export class ScriptedTranscriber implements Transcriber {
|
||
calls: { audio: ArrayBuffer; mime: string; signal: AbortSignal }[] = [];
|
||
|
||
constructor(public steps: Step[] = []) {}
|
||
|
||
async transcribe(audio: ArrayBuffer, mime: string, signal: AbortSignal): Promise<TranscribeResult> {
|
||
this.calls.push({ audio, mime, signal });
|
||
const step = this.steps.shift();
|
||
if (step === undefined) throw new Error('ScriptedTranscriber: no more steps');
|
||
if (step instanceof Error) throw step;
|
||
if (typeof step === 'function') return step(audio, mime);
|
||
return step;
|
||
}
|
||
}
|
||
|
||
export class TestEnv implements Env {
|
||
constructor(
|
||
public time = new Date('2026-09-27T11:15:03.000Z'),
|
||
public deviceId = 'test-device',
|
||
public random: () => number = () => 0,
|
||
) {}
|
||
|
||
now(): Date {
|
||
return new Date(this.time);
|
||
}
|
||
|
||
advance(ms: number): void {
|
||
this.time = new Date(this.time.getTime() + ms);
|
||
}
|
||
}
|
||
|
||
export class FakeHttp implements HttpClient {
|
||
requests: HttpRequest[] = [];
|
||
|
||
constructor(
|
||
public reply: (req: HttpRequest) => HttpResponse | Promise<HttpResponse> = () => ({
|
||
status: 200,
|
||
text: '{"text":"ok"}',
|
||
}),
|
||
) {}
|
||
|
||
// Не async: если reply бросает синхронно, post тоже бросает синхронно.
|
||
post(req: HttpRequest): Promise<HttpResponse> {
|
||
this.requests.push(req);
|
||
return Promise.resolve(this.reply(req));
|
||
}
|
||
}
|
||
|
||
// Разбор multipart для проверок: обычное поле → значение, файл → "filename|Content-Type".
|
||
export function formParts(req: HttpRequest): Record<string, string> {
|
||
const match = /boundary=(.+)$/.exec(req.contentType);
|
||
if (!match) throw new Error(`no boundary in ${req.contentType}`);
|
||
const text = new TextDecoder().decode(req.body);
|
||
const parts: Record<string, string> = {};
|
||
for (const part of text.split(`--${match[1]}`)) {
|
||
const m = /name="([^"]+)"(?:; filename="([^"]+)")?\r\n(?:Content-Type: ([^\r]+)\r\n)?\r\n([\s\S]*)\r\n$/.exec(part);
|
||
if (m) parts[m[1]] = m[2] ? `${m[2]}|${m[3]}` : m[4];
|
||
}
|
||
return parts;
|
||
}
|
||
|
||
export function bytesIndexOf(hay: Uint8Array, needle: Uint8Array): number {
|
||
outer: for (let i = 0; i + needle.length <= hay.length; i++) {
|
||
for (let j = 0; j < needle.length; j++) if (hay[i + j] !== needle[j]) continue outer;
|
||
return i;
|
||
}
|
||
return -1;
|
||
}
|