textmachine/frontend/src/mock/worlds.ts

208 lines
8 KiB
TypeScript

// One fixture world per scenario. The scenarios are not decoration: the prompt for this stage asks
// for fixtures of ALL states rather than one happy one, and each of them is a route, so each gets
// its own screenshot and its own accessibility pass.
//
// showcase — the hard case: mid draft wave, a half-decided bank, chapters without labels
// scale — the long tail: 2284 chapters and 1200 terms, served in pages
// empty — a user with no books at all
// intake — the first path of the product: upload → parsing → a book that can be translated
// loading — reads that never answer, so the waiting branch is the final picture
// error — the platform refuses, so the error branch is the final picture
// offline — reads work, the live stream does not
import type { Scenario } from '../api';
import type { components } from '../api/schema';
import { bank } from './bank';
import { bookId, books, chapters, notes, run, unitsOf } from './book';
import {
advanceIntakeChapter,
haltIntakeRun,
intakeBooks,
intakeBounds,
intakeCeilingFits,
intakeChapters,
intakeRun,
intakeUnits,
startIntakeRun,
} from './intake';
import { live, liveBook } from './live';
import { scaleBook, scaleChapters, scaleNotes, scaleTerms, scaleUnitsOf } from './scale';
type Schemas = components['schemas'];
export interface World {
readonly revision: number;
readonly books: Schemas['Book'][];
runOf: (id: string) => Schemas['Run'] | null;
/**
* Starting a run. A world where the book comes from the FORM has no run written in advance, so
* what "start" means belongs to the world; the hand-written ones hand back the run they always
* had, with the ceiling and the stop the request asked for.
*/
startRun: (id: string, request: Schemas['RunRequest']) => Schemas['Run'] | null;
chaptersOf: (id: string) => Schemas['Chapter'][];
unitsOf: (bookId: string, chapterId: string) => Schemas['Unit'][];
notesOf: (id: string) => Schemas['Note'][];
bankOf: (id: string) => { total: number; signed: number; terms: Schemas['BankTerm'][] };
usage: Schemas['Usage'];
ceiling: Schemas['CeilingBounds'];
/** Rows per page. The scale world pages for real, so following the cursor is exercised live. */
pageSize: number;
/**
* Whether a ceiling asked for still fits. Its own answer per world, because the bounds a world
* answers a READ with may move before the START — which is the contract's 409 and the one refusal
* a correct client meets on a correct form.
*/
fits?: (chapters: number) => boolean;
/** The world accepts uploads. Only the intake one does; elsewhere `POST /books` is not mounted. */
intake?: boolean;
/**
* The run halts on its ceiling at the end of the script. Present only where the whole path is
* played out — the reads have to agree with the stream, so the world is told before the frames
* announcing the halt are sent.
*/
halt?: () => void;
/**
* One chapter of the running book finishes, and the world moves with it. Present in the same
* world and for the same reason: the tree's per-chapter indicators have to be fed by something,
* and what feeds them must also be what a re-read answers.
*/
advanceChapter?: () => Schemas['EventChapter'] | null;
}
/**
* Starting a run in a hand-written world.
*
* ⚠ A run is MINTED for the book that was asked about, and not taken out of the fixture: on the
* platform a start creates a run, so a fixture that answered only for the one book it had written
* down refused every other with "book not found" — which is what the new action on the card ran
* into on four of the five startable books (found by the adversarial review of this pack). The
* book's own run, where the fixture has one, gives the id and the revision; everything else comes
* from the request, because that is what a start is.
*/
const startExisting =
(world: Pick<World, 'runOf'>) => (id: string, request: Schemas['RunRequest']) => {
const existing = world.runOf(id);
return {
...(existing ?? run),
id: existing?.id ?? `run_${id}`,
status: 'translating' as const,
paused_reason: null,
finished_at: null,
ceiling_chapters: request.ceiling_chapters,
verify_bank: request.verify_bank,
};
};
const pausedRun: Schemas['Run'] = {
...run,
id: 'run_38',
status: 'paused',
paused_reason: 'credit_exhausted',
ceiling_chapters: 120,
};
const defaultWorld: World = {
// Read from the live state, not frozen: the stream advances both, so a read can never answer with
// a revision older than a frame already emitted — which is what the contract forbids and what the
// client's guard is entitled to rely on.
get revision() {
return live.revision;
},
get books() {
return [liveBook(), ...books.slice(1)];
},
runOf: (id) =>
id === bookId ? { ...run, revision: live.revision } : id === 'bk_7' ? pausedRun : null,
startRun: (id, request) => startExisting(defaultWorld)(id, request),
chaptersOf: (id) => (id === bookId ? chapters : []),
unitsOf: (id, chapterId) => (id === bookId ? unitsOf(chapterId) : []),
notesOf: (id) => (id === bookId ? notes : []),
bankOf: (id) => (id === bookId ? bank : { total: 0, signed: 0, terms: [] }),
usage: { state: 'ok', remaining_percent: 62, paused_reason: null },
// A ceiling smaller than the book: the scale is bounded by what is left of the credit, and the
// whole point of the widget is that the user meets that bound before the run, not after it.
ceiling: { min_chapters: 1, max_chapters: 180, default_chapters: 60 },
pageSize: 5000,
};
const scaleBank = {
total: scaleTerms.length,
signed: scaleTerms.filter((term) => term.status === 'approved').length,
terms: scaleTerms,
};
const scaleWorld: World = {
revision: 4102,
books: [scaleBook],
runOf: () => ({ ...run, id: 'run_scale' }),
startRun: (id, request) => startExisting(scaleWorld)(id, request),
chaptersOf: () => scaleChapters,
unitsOf: (_, chapterId) => scaleUnitsOf(chapterId),
notesOf: () => scaleNotes,
bankOf: () => scaleBank,
usage: { state: 'low', remaining_percent: 8, paused_reason: null },
ceiling: { min_chapters: 1, max_chapters: 240, default_chapters: 240 },
// Small on purpose: the contract's own default would fit this book in one page, and then the
// cursor rule would never be exercised anywhere outside a test.
pageSize: 500,
};
const emptyWorld: World = {
revision: 1,
books: [],
runOf: () => null,
startRun: () => null,
chaptersOf: () => [],
unitsOf: () => [],
notesOf: () => [],
bankOf: () => ({ total: 0, signed: 0, terms: [] }),
usage: { state: 'ok', remaining_percent: 100, paused_reason: null },
ceiling: { min_chapters: 1, max_chapters: 0, default_chapters: 0 },
pageSize: 5000,
};
// The path the product begins with: an empty library, a book that arrives through the form, gets
// cut into chapters and is translated. The only world that accepts an upload.
const intakeWorld: World = {
get revision() {
return live.revision;
},
get books() {
return intakeBooks();
},
runOf: (id) => intakeRun(id),
startRun: (id, request) => startIntakeRun(id, request),
chaptersOf: (id) => intakeChapters(id),
unitsOf: (id, chapterId) => intakeUnits(id, chapterId),
notesOf: () => [],
bankOf: () => ({ total: 0, signed: 0, terms: [] }),
usage: { state: 'ok', remaining_percent: 100, paused_reason: null },
// A getter: these bounds MOVE, once, when the first start meets them — the contract's own 409
// (a hold taken for another book lowers what is left between the read and the call).
get ceiling() {
return intakeBounds();
},
fits: intakeCeilingFits,
pageSize: 5000,
intake: true,
halt: haltIntakeRun,
advanceChapter: advanceIntakeChapter,
};
const worlds: Record<Scenario, World> = {
showcase: defaultWorld,
scale: scaleWorld,
empty: emptyWorld,
intake: intakeWorld,
loading: defaultWorld,
error: defaultWorld,
offline: defaultWorld,
partial: defaultWorld,
};
export function worldOf(scenario: Scenario): World {
return worlds[scenario];
}
export { bookId };