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(); async write(path: string, data: ArrayBuffer): Promise { this.files.set(path, data.slice(0)); } async read(path: string): Promise { 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(); // Имитация отстающего metadataCache: если задано, list() возвращает это. staleList: ListedNote[] | null = null; listCalls = 0; async exists(path: string): Promise { return this.files.has(path); } async create(path: string, content: string): Promise { if (this.files.has(path)) throw new Error(`already exists: ${path}`); this.files.set(path, content); } async list(): Promise { this.listCalls++; if (this.staleList) return this.staleList; return [...this.files] .map(([path, content]) => ({ path, frontmatter: readVoiceFields(content) as Record })) .filter((note) => 'voice-id' in note.frontmatter); } async process(path: string, fn: (content: string) => string): Promise { 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); 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 { 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 = () => ({ status: 200, text: '{"text":"ok"}', }), ) {} // Не async: если reply бросает синхронно, post тоже бросает синхронно. post(req: HttpRequest): Promise { this.requests.push(req); return Promise.resolve(this.reply(req)); } } // Разбор multipart для проверок: обычное поле → значение, файл → "filename|Content-Type". export function formParts(req: HttpRequest): Record { 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 = {}; 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; }