377 lines
16 KiB
TypeScript
377 lines
16 KiB
TypeScript
// 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,
|
||
}));
|
||
}
|