Andrey Epifancev 3f6debc18b feat(core): HttpClient port and byte-exact multipart builder
Refs #5

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QKHeGuAYh4W1jNdjMConZa
2026-09-28 00:00:08 +04:00

132 lines
4.1 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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;
}