// The intake world: a library that starts EMPTY and fills up from the form. // // It is the one fixture that models a WRITE, and therefore the one that has to model the rules of // the wire rather than only the shape of the answer: the order of the parts, their number, the // length of a text field, the cap on the body. A client that broke one of them would otherwise be // found out by the platform and not here (contract 0.2.3, PD-172/PD-180). // // ⚠ HOW A REFUSAL IS ASKED FOR. Everything the platform refuses for a reason of its own — no intake // on this deployment, a body that missed the deadline, a source it could not read — is chosen by a // MARKER IN THE FILE NAME (the table below). It is the fixture's own switch, the same job the route // does for the choice of the world, and it exists because those branches have no other trigger: a // correct client cannot produce them, so without it the screens for them would be checked by // nothing. Product code knows nothing about any of this. import { HttpResponse, delay, http } from 'msw'; import type { components } from '../api/schema'; import { begin, live } from './live'; type Schemas = components['schemas']; /** The wire rules of the intake, in the platform's own numbers (httpapi/v0.go). */ export const intakeLimits = { maxParts: 16, maxFieldBytes: 1024, /** Small on purpose: a scene has to be able to exceed it without carrying tens of megabytes. */ maxBodyBytes: 2_000_000, }; /** How long a book stays `parsing` before the fixture answers what came of it. */ export const parseMs = 1200; /** * How long the answer to the intake is held back. * * A book is minutes of upload on a domestic connection, and the state that waits for the answer is * a state the screen has to have — held for a moment here so that it can be photographed and * checked at all. Everything before it is the browser's own doing: the mock network answers the * request only after the body has been read. */ const answerMs = 600; /** * What a marker in the file name asks the fixture to answer. `null` means "accept and parse". * * The refusals are the classes the contract names for this call; the reasons are the platform's * closed vocabulary for a book that was accepted and then could not be turned into chapters. */ const outcomes = [ // ⚠ The phrases are deliberately NOT the client's own fallbacks word for word: the client shows // the server's phrase and keeps its own only for a refusal that carried no body, and two equal // strings would make that impossible to tell apart — the scene checking it went blind on exactly // the one that matched (found by the adversarial review). { marker: 'refuse-400', status: 400, title: 'Загрузка не дошла целиком' }, { marker: 'refuse-404', status: 404, title: 'Этот сервис книги не принимает' }, { marker: 'refuse-408', status: 408, title: 'Файл не дошёл за отведённое время' }, { marker: 'reject-source', reject: 'source_unreadable' }, { marker: 'reject-config', reject: 'not_configured' }, { marker: 'reject-parser', reject: 'parser_unavailable' }, ] as const; interface Accepted { book: Schemas['Book']; chapters: Schemas['Chapter'][]; /** When the parse ends, in milliseconds of the clock. */ parsedAt: number; reject: Schemas['RejectReason'] | null; run: Schemas['Run'] | null; } const accepted: Accepted[] = []; /** Tests and scenes share one module instance; a world left half-filled would leak into the next. */ export function clearIntake(): void { accepted.length = 0; ceilingMoved = false; } /** Moves every unfinished parse into the past — for tests, which must not wait out a delay. */ export function settleIntake(): void { for (const row of accepted) row.parsedAt = 0; } export function intakeBooks(): Schemas['Book'][] { // NEWEST FIRST, as the platform answers (`order by b.added_at desc, b.id desc` — pgstore/books.go): // a fixture that listed them the other way round would put the book just uploaded at the bottom of // a library the screen reads from the top. return accepted.map((row) => shown(row)).reverse(); } export function intakeChapters(bookId: string): Schemas['Chapter'][] { const row = accepted.find((item) => item.book.id === bookId); // A book still being parsed has no chapters yet — and that is the honest answer, not an error: // the tree shows the book with none until the parse ends. return row && parsed(row) && row.reject === null ? row.chapters : []; } export function intakeUnits(bookId: string, chapterId: string): Schemas['Unit'][] { const chapter = intakeChapters(bookId).find((row) => row.id === chapterId); if (!chapter) return []; return Array.from({ length: chapter.units_total }, (_, index) => ({ id: `${chapterId}_u${String(index)}`, source: sources[index % sources.length] ?? '', target: '', state: 'pending' as const, note: null, })); } export function intakeRun(bookId: string): Schemas['Run'] | null { const run = accepted.find((row) => row.book.id === bookId)?.run; // The revision is taken at READ time: frozen at the start it would fall behind the frames the // stream is already emitting, which is the one thing the fixture must never do. return run ? { ...run, revision: live.revision } : null; } /** * One chapter of the running book finishes. * * The tree draws progress PER CHAPTER, and without this the indicators stood at zero while the * book's own counter ran to the end — the tree and the card contradicting each other on the same * screen. Mutates the stored chapter so that a re-read agrees with the frame: a fixture whose read * takes back what its stream just said is a server the contract forbids. */ export function advanceIntakeChapter(): Schemas['EventChapter'] | null { const row = accepted.find((item) => item.run !== null); const chapter = row?.chapters.find((item) => item.units_done < item.units_total); if (!chapter) return null; chapter.units_done = chapter.units_total; live.revision += 1; return { chapter_id: chapter.id, units_done: chapter.units_done, note_count: chapter.note_count, }; } /** * The bounds of the ceiling scale, and they MOVE — once, on the first start. * * The contract says exactly this happens (§startRun): the maximum belongs to the account, a hold * taken for another book lowers it between the read and the call, and the answer is 409. It is the * one refusal a correct client can meet on a correct form, so the fixture produces it the way the * platform does rather than by a marker — and it produces it ONCE, so the second press goes through * and the path stays walkable. */ export const intakeCeiling = { min_chapters: 1, max_chapters: 8, default_chapters: 4 }; let ceilingMoved = false; /** The bounds a read answers with. Narrower after the first start attempt was refused. */ export function intakeBounds(): Schemas['CeilingBounds'] { return ceilingMoved ? { ...intakeCeiling, max_chapters: 2, default_chapters: 2 } : intakeCeiling; } /** The refusal a start meets when the ceiling asked for no longer fits. */ export function intakeCeilingFits(chapters: number): boolean { if (!ceilingMoved) { ceilingMoved = true; return false; } return chapters <= intakeBounds().max_chapters; } /** * Starting a run over an uploaded book. The world's own, because here the run does not exist until * it is started — in the hand-written worlds it is a fixture that was always there. */ export function startIntakeRun( bookId: string, request: Schemas['RunRequest'], ): Schemas['Run'] | null { const row = accepted.find((item) => item.book.id === bookId); if (!row || !parsed(row) || row.reject !== null) return null; begin(row.book.progress.draft.total); row.run = { id: `run_${row.book.id}`, revision: live.revision, status: 'translating', verify_bank: request.verify_bank, ceiling_chapters: request.ceiling_chapters, paused_reason: null, started_at: new Date().toISOString(), finished_at: null, }; return row.run; } /** * The run walks into its ceiling and halts. * * ⚠ Built to the CONTRACT and not to the live platform: a ceiling stop is `paused` with a machine * reason, never `failed` (§BookStatus). On the stand today it arrives as `failed` — the consumer * half of that seam is not built (platform PD-113) — and a fixture bent to match it would teach the * screens the wrong shape. Which ceiling was hit, the run's own or the account's, this frame does * not say: whether the two need separate reasons is the owner's question (K-13), so the fixture * uses the single value the contract has. */ export function haltIntakeRun(): void { const row = accepted.find((item) => item.run !== null); if (!row?.run) return; live.revision += 3; row.run = { ...row.run, status: 'paused', paused_reason: 'credit_exhausted' }; } /** `POST /books` — the whole of it, rules included. */ export const intakeHandler = http.post('*/v0/books', async ({ request }) => { if (request.headers.get('X-TM-Client') === null) { return problem(403, 'Запрос без заголовка клиента'); } const form = await request.formData(); const parts = [...form.entries()]; if (parts.length > intakeLimits.maxParts) return problem(400, 'В форме слишком много частей'); // ⚠ The platform reads the form as a STREAM and STOPS at the file (httpapi/v0.go): everything // after it is never read. So the rule is modelled as the platform's own — parts before the file // are taken, parts after it are dropped on the floor — and not as "the file must be last, else // 400". The difference is not academic: a form with a trailing `genre` is ACCEPTED by the // platform, minus the genre, and a fixture that refused it would refuse a legal request (found by // the adversarial review of the assignment itself). const fileAt = parts.findIndex(([name, value]) => name === 'file' && value instanceof File); if (fileAt < 0) return problem(400, 'Форма пришла без файла'); const read = parts.slice(0, fileAt); const last = parts[fileAt]; if (!last || !(last[1] instanceof File)) return problem(400, 'Форма пришла без файла'); // In BYTES, which is what the platform measures (`io.LimitReader` over the part): a Cyrillic // character is two bytes and a hanzi three, so counting string units would let through a field // three times over the limit. const bytesOf = new TextEncoder(); for (const [name, value] of read) { if (typeof value === 'string' && bytesOf.encode(value).length > intakeLimits.maxFieldBytes) { return problem(400, `Поле «${name}» длиннее килобайта`); } } const file = last[1]; if (file.size > intakeLimits.maxBodyBytes) { return problem(413, 'Файл больше, чем принимает сервис'); } const outcome = outcomes.find((item) => file.name.includes(item.marker)); if (outcome && 'status' in outcome) return problem(outcome.status, outcome.title); await delay(answerMs); // Only what was READ counts — a language sent after the file is a language the platform never // saw, and it answers that with the same 400 as one never sent. const fields = new Map( read.filter(([, value]) => typeof value === 'string') as [string, string][], ); const source = fields.get('source_lang') ?? ''; const target = fields.get('target_lang') ?? ''; if (source === '' || target === '') return problem(400, 'Языки не заданы'); // CHARACTERS and not bytes: the platform counts UTF-8 lead bytes as it streams // (books.go `counter.Write`), which is a count of code points — and on CJK that is three times // fewer than bytes. The cap above is the other quantity and stays in bytes, as it is on the wire. const characters = [...(await file.text())].length; const book = create({ // The title of the intake, or the name of the file when the form left it empty — which is what // the platform does today (contract 0.2.3, `BookIntake.title`). title: (fields.get('title') ?? '') || file.name.replace(/\.[^.]+$/, ''), source, target, genre: fields.get('genre') ?? '', characters, reject: outcome?.reject ?? null, }); // The `201` carries `parsing` and never `uploading` (PD-180): by the time an answer can be read, // the last byte is in. `uploading` is real and is seen from a SECOND read of the library while // the body is still on the wire — which is the mock network's own doing, not this handler's. return HttpResponse.json(shown(book), { status: 201 }); }); // Deliberately Russian: the platform words its own `problem+json`, and the client shows those // phrases as they came (contract §Problem). A fixture that answered in English would be modelling // a platform we do not have. function problem(status: number, title: string): Response { return HttpResponse.json({ type: 'about:blank', title, status } satisfies Schemas['Problem'], { status, headers: { 'Content-Type': 'application/problem+json' }, }); } function create(intake: { title: string; source: string; target: string; genre: string; characters: number; reject: Schemas['RejectReason'] | null; }): Accepted { const chapters = chaptersOf(accepted.length); const row: Accepted = { book: { id: `bk_up${String(accepted.length + 1)}`, title: intake.title, source_lang: intake.source, target_lang: intake.target, genre: intake.genre, chapter_count: chapters.length, character_count: intake.characters, added_at: new Date().toISOString(), status: 'parsing', // ZERO until a run starts, and it is the contract's shape: `Progress` counts the units of a // RUN, so a book that has never been run has nothing to count. A fixture that filled the // totals at intake made the card print "0 sections, 12 blocks" while the parse was still on. progress: { draft: { done: 0, total: 0 }, edit: { done: 0, total: 0 } }, note_count: 0, }, chapters, parsedAt: Date.now() + parseMs, reject: intake.reject, run: null, }; accepted.push(row); live.revision += 1; return row; } const parsed = (row: Accepted) => Date.now() >= row.parsedAt; /** * The book as a read must answer it right now: parsing until the deadline, then either the tree it * was cut into or the reason it could not be. * * ⚠ Nothing PUSHES this change: the event stream belongs to a run, and a book being parsed has no * run at all. The client learns of it by asking again — which is why the reads of the library and * of the card poll while a book is in intake (src/api/queries.ts, BACKLOG Ф-56). */ function shown(row: Accepted): Schemas['Book'] { // ⚠ NO chapter count until the parse ends, and it is the platform's own shape: the number is // written when the engine has cut the book, so a fixture that answered it earlier would teach the // screen that a book being parsed already knows how many sections it has. if (!parsed(row)) return { ...row.book, chapter_count: 0 }; if (row.reject !== null) { return { ...row.book, status: 'rejected', reject_reason: row.reject, chapter_count: 0 }; } if (row.run) { return { ...row.book, status: row.run.status, progress: { draft: { done: live.draftDone, total: live.draftTotal }, edit: { done: 0, total: live.draftTotal }, eta_seconds: live.etaSeconds, }, }; } return { ...row.book, status: 'not_started' }; } // The book a parse produces: labels of the same shape as the long-tail fixture, and a book without // headings among them — a book legally has none (K-3), and the intake is where one arrives. const headings = [ 'Нет раскаяния', 'Прозрение пятисот лет', 'Церемония открытия', 'Класс А', 'Деревня Гуюэ', 'Первый гу', 'Аптека', 'Кровь на снегу', ]; const sources = [ '青茅山下,古月家的少年们排成一列,等待开窍。', '风从北面吹来,带着雪的气味。', '祠堂前的石阶上落满了霜。', ]; function chaptersOf(book: number): Schemas['Chapter'][] { return Array.from({ length: headings.length }, (_, index) => ({ id: `up${String(book)}_ch${String(index + 1)}`, number: index + 1, heading: index === headings.length - 1 ? null : (headings[index] ?? null), units_total: 1 + (index % 2), units_done: 0, note_count: 0, })); }