208 lines
8 KiB
TypeScript
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 };
|