textmachine/frontend/src/mock/intake.ts

377 lines
16 KiB
TypeScript
Raw 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.

// 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,
}));
}