From 63c8c980b32878a7c30aaf1f5557c69e1c4111c5 Mon Sep 17 00:00:00 2001 From: Onur Solmaz <2453968+osolmaz@users.noreply.github.com> Date: Thu, 29 Oct 2026 12:40:12 +0800 Subject: [PATCH] feat: draw WikiRace races in the viewer like Wikipedia The WikiRace part of the viewer now draws the route of the articles so far, with the target at its end, and each step's likeliest branches from the article the race is at: links, sections and giving up, with the model's probability as a bar and as the width of a curve, and the picked branch marked. The list of steps says where each step went. The page uses Wikipedia's light colors, or its night colors when the system asks for dark, and fits a phone's width. - The viewer gives each choice of a pick its type, from the pick's facts. - A section's choice keeps its name and its number of links as its data. --- examples/wikirace/README.md | 10 +- examples/wikirace/src/race.ts | 6 +- examples/wikirace/src/viewer.ts | 7 +- examples/wikirace/src/web/data.ts | 132 +++++++ examples/wikirace/src/web/part.ts | 108 +++--- examples/wikirace/src/web/route.ts | 177 +++++++++ examples/wikirace/src/web/wikirace.css | 410 ++++++++++++++++++-- examples/wikirace/tests/harness.test.ts | 59 --- examples/wikirace/tests/helpers/sessions.ts | 55 +++ examples/wikirace/tests/page.test.ts | 224 +++++++++++ viewer/README.md | 5 +- viewer/src/frames.ts | 11 +- viewer/tests/model.test.ts | 1 + viewer/tests/page.test.ts | 23 +- 14 files changed, 1075 insertions(+), 153 deletions(-) create mode 100644 examples/wikirace/src/web/data.ts create mode 100644 examples/wikirace/src/web/route.ts create mode 100644 examples/wikirace/tests/helpers/sessions.ts create mode 100644 examples/wikirace/tests/page.test.ts diff --git a/examples/wikirace/README.md b/examples/wikirace/README.md index a4a2a24..840bafc 100644 --- a/examples/wikirace/README.md +++ b/examples/wikirace/README.md @@ -77,9 +77,13 @@ The harness has the `view` command of switch's [viewer](../../viewer/README.md): examples/wikirace/src/main.ts view runs/wikirace ``` -The wikirace part of the page, in [`src/web/`](src/web), draws each step's trail of articles from the start, the -current one lit, toward the target, and says which section the step showed. The viewer shows the step's choices with -the model's probability for each link. +The wikirace part of the page, in [`src/web/`](src/web), looks like Wikipedia, in its light or night colors as the +system asks. At the left it draws the route of the articles so far, like the stops of a train line, with the target +at its end. At the right it draws the step's branches from the article the race is at: the eight likeliest links, +sections and giving up, each with a bar for the model's probability, and a curve to each that is thicker for a likelier +branch. The picked branch is marked, and it is always among the eight. Beside the drawing, the list of steps says +where each step went and how likely the model found it. Each article is a link to Wikipedia. A section's choice keeps +its name and its number of links in its data, so the page can name it. ## Check diff --git a/examples/wikirace/src/race.ts b/examples/wikirace/src/race.ts index 4f710fc..f844ab1 100644 --- a/examples/wikirace/src/race.ts +++ b/examples/wikirace/src/race.ts @@ -126,7 +126,10 @@ const link = (wiki: Wiki) => const sampleOf = (links: readonly string[]): string => links.length <= 3 ? links.join(", ") : `${links.slice(0, 3).join(", ")} and ${String(links.length - 3)} more`; -/** Open a section of an article with more links than one step shows, to see its links; each section at most once. */ +/** + * Open a section of an article with more links than one step shows, to see its links; each section at most once. Its + * data names the section and counts its links, for the viewer. + */ const section = choiceType({ type: "section", choices: (state) => @@ -134,6 +137,7 @@ const section = choiceType({ key: `${String(state.path.length)}:${String(index)}`, text: `Open section ${String(index)}, "${name}": ${sampleOf(links)}`, arg: index, + data: { section: name, links: links.length }, })), transition: (state, index) => ({ ...state, diff --git a/examples/wikirace/src/viewer.ts b/examples/wikirace/src/viewer.ts index 46ffa73..9a806a0 100644 --- a/examples/wikirace/src/viewer.ts +++ b/examples/wikirace/src/viewer.ts @@ -1,8 +1,11 @@ import { type DomainViewer, modulesNear } from "@osolmaz/switch-viewer"; -/** The wikirace part of the viewer: the trail of articles toward the target, with its styles. */ +/** + * The wikirace part of the viewer: the route of the articles toward the target, the branches of each step with the + * model's probabilities, and the list of the steps, with styles that look like Wikipedia. + */ export const wikiraceViewer: DomainViewer = { - modules: modulesNear(import.meta.url, "./web", ["part"]), + modules: modulesNear(import.meta.url, "./web", ["data", "route", "part"]), styles: [new URL("./web/wikirace.css", import.meta.url)], summary: ({ result, hops }) => [result, typeof hops === "number" ? `${String(hops)} hops` : null] diff --git a/examples/wikirace/src/web/data.ts b/examples/wikirace/src/web/data.ts new file mode 100644 index 0000000..b7ade21 --- /dev/null +++ b/examples/wikirace/src/web/data.ts @@ -0,0 +1,132 @@ +/** + * What the wikirace part reads of a case: a race in each state, and the branches of each step's pick, which are links, + * sections to open and giving up. + */ +import { type FrameChoice, type Moment, pickOf, ranked, type Session } from "@osolmaz/switch-viewer/page"; + +/** One article of the path, and the text of the link that led to it. */ +export interface RaceVisit { + readonly title: string; + readonly via: string; +} + +/** What the part reads of a race's state; anything else in it stays in the state's JSON. */ +export interface RaceView { + readonly target: string; + readonly path: readonly RaceVisit[]; + /** The section whose links the state shows; null when it shows every link of the article. */ + readonly section: string | null; + readonly gaveUp: boolean; + /** What the last step did, in words. */ + readonly last: string; +} + +const isRecord = (value: unknown): value is Readonly> => + typeof value === "object" && value !== null && !Array.isArray(value); + +const visitsOf = (path: readonly unknown[]): RaceVisit[] => + path.flatMap((visit) => + isRecord(visit) && typeof visit["title"] === "string" + ? [{ title: visit["title"], via: typeof visit["via"] === "string" ? visit["via"] : visit["title"] }] + : [], + ); + +/** The name of the section that a state shows; null when it shows every link. */ +function openSection(state: Readonly>): string | null { + const sections = Array.isArray(state["sections"]) ? state["sections"] : []; + const open: unknown = typeof state["open"] === "number" ? sections[state["open"]] : null; + return isRecord(open) && typeof open["name"] === "string" ? open["name"] : null; +} + +/** The race of a moment's state; null when the state is not a race's. */ +export function raceOf(state: unknown): RaceView | null { + if (!isRecord(state) || typeof state["target"] !== "string" || !Array.isArray(state["path"])) return null; + return { + target: state["target"], + path: visitsOf(state["path"]), + section: openSection(state), + gaveUp: state["gaveUp"] === true, + last: typeof state["last"] === "string" ? state["last"] : "", + }; +} + +/** Wikipedia titles match whatever the case of their first letter. */ +export const sameArticle = (a: string, b: string): boolean => + a.charAt(0).toUpperCase() + a.slice(1) === b.charAt(0).toUpperCase() + b.slice(1); + +/** The article the race is at. */ +export const raceHere = (race: RaceView): string => race.path.at(-1)?.title ?? ""; + +export const raceReached = (race: RaceView): boolean => sameArticle(raceHere(race), race.target); + +/** The hops between articles so far. */ +export const raceHops = (race: RaceView): number => Math.max(0, race.path.length - 1); + +/** One branch of a step's pick: a link to follow, a section to open, or giving up. */ +export interface RaceBranch { + /** The choice's type: "link", "section" or "give-up". */ + readonly type: string; + /** The link's title, the section's name, or the text of giving up. */ + readonly name: string; + /** For a section, how many links it has; null for the others. */ + readonly links: number | null; + readonly probability: number | null; + readonly picked: boolean; +} + +/** What the part reads of one choice of a pick. */ +export function raceBranch(choice: FrameChoice): RaceBranch { + const { data } = choice; + const section = choice.type === "section" && isRecord(data) && typeof data["section"] === "string"; + return { + type: choice.type ?? "link", + name: section ? (data["section"] as string) : choice.text, + links: section && typeof data["links"] === "number" ? data["links"] : null, + probability: choice.probability, + picked: choice.picked, + }; +} + +/** The branches of a step that the drawing shows, and how many choices of its pick it leaves out or the limit cut. */ +export interface RaceFork { + readonly shown: readonly RaceBranch[]; + readonly total: number; + readonly cut: number; + readonly answered: boolean; +} + +/** + * A step's likeliest branches, at most `count`, with the picked one always among them, or the first `count` in the + * article's order before the model answered; null for a moment without a pick. + */ +export function raceFork(moment: Moment, count: number): RaceFork | null { + const pick = pickOf(moment.frame); + if (pick === null) return null; + const all = ranked(pick.choices); + const shown = all.slice(0, count); + const picked = all.find((choice) => choice.picked); + if (picked !== undefined && !shown.includes(picked)) shown[shown.length - 1] = picked; + return { shown: shown.map(raceBranch), total: all.length, cut: pick.cut, answered: pick.answer !== null }; +} + +/** One step of a race, for the list of steps: the article it started at and the branch the model picked. */ +export interface RaceStep { + /** The moment that shows the step. */ + readonly moment: number; + readonly from: string; + /** The picked branch; null before the model answered, or when the step failed. */ + readonly picked: RaceBranch | null; +} + +/** Every step of a race, in order. */ +export function raceSteps(session: Session): RaceStep[] { + return session.story.frames.map((frame, index) => { + const choice = pickOf(frame)?.choices.find((offered) => offered.picked); + const before = raceOf(frame.before); + return { + moment: index + 1, + from: before === null ? "" : raceHere(before), + picked: choice === undefined ? null : raceBranch(choice), + }; + }); +} diff --git a/examples/wikirace/src/web/part.ts b/examples/wikirace/src/web/part.ts index 81a5042..c304f24 100644 --- a/examples/wikirace/src/web/part.ts +++ b/examples/wikirace/src/web/part.ts @@ -1,58 +1,78 @@ /// -/** The wikirace part of the page: the trail of articles from the start toward the target, and what each step showed. */ -import { type DomainPart, h, type Moment, registerPart } from "@osolmaz/switch-viewer/page"; +/** + * The wikirace part of the page: the race toward its target, with the route so far and the branches of each step, and + * the list of the race's steps. + */ +import { + button, + type DomainPart, + h, + type Moment, + type PartContext, + percent, + registerPart, + type Session, +} from "@osolmaz/switch-viewer/page"; +import { type RaceBranch, raceHere, raceOf, type RaceStep, raceSteps } from "./data.ts"; +import { raceDrawing } from "./route.ts"; -/** What the part reads of a race's state; anything else in it stays in the state's JSON. */ -interface RaceTrail { - readonly target: string; - readonly titles: readonly string[]; - readonly section: string | null; +/** Where a step went: the article, the section it opened, or giving up. */ +function whereTo(picked: RaceBranch | null): string { + if (picked === null) return "…"; + if (picked.type === "section") return `§ ${picked.name}`; + return picked.type === "give-up" ? "gave up" : picked.name; } -const isRecord = (value: unknown): value is Readonly> => - typeof value === "object" && value !== null; - -/** The trail of a moment's state; null when the state is not a race's. */ -export function raceTrailOf(state: unknown): RaceTrail | null { - if (!isRecord(state) || typeof state["target"] !== "string" || !Array.isArray(state["path"])) return null; - const titles = state["path"].flatMap((visit) => - isRecord(visit) && typeof visit["title"] === "string" ? [visit["title"]] : [], +function stepButton(step: RaceStep, current: boolean, open: (moment: number) => void): HTMLElement { + const to = whereTo(step.picked); + const odds = step.picked === null ? "" : percent(step.picked.probability); + const node = button( + `Step ${String(step.moment)}: from ${step.from} to ${to}`, + `race-step${current ? " current" : ""}`, + "", + ); + // The article a step left is the one the step before went to, so the list shows only where each step went; the + // label, which is also the title, names both. + node.append( + h("span", { class: "race-n" }, String(step.moment)), + h("span", { class: "race-dest" }, to), + h("small", {}, odds), ); - const sections = Array.isArray(state["sections"]) ? state["sections"] : []; - const open = typeof state["open"] === "number" ? (sections[state["open"]] as unknown) : null; - const section = isRecord(open) && typeof open["name"] === "string" ? open["name"] : null; - return { target: state["target"], titles, section }; + if (current) node.setAttribute("aria-current", "step"); + node.addEventListener("click", () => { + open(step.moment); + }); + return node; } -/** The trail as a row of articles, the current one marked, and the target at its end. */ -function raceTrail(trail: RaceTrail): HTMLElement { - const last = trail.titles.length - 1; - const reached = trail.titles[last] === trail.target; - const crumbs = trail.titles.map((title, index) => - h("li", { class: index === last ? "race-here" : "race-been" }, title), - ); - const goal = reached ? [] : [h("li", { class: "race-gap" }, "…"), h("li", { class: "race-goal" }, trail.target)]; - const where = trail.section === null ? "" : `Showing the section "${trail.section}"`; - return h( - "div", - { class: "race-area" }, - h("p", { class: "race-label" }, reached ? `Reached ${trail.target}` : `Racing to ${trail.target}`), - h("ol", { class: "race-trail" }, ...crumbs, ...goal), - h("p", { class: "race-where" }, where), - ); +/** The race's steps, each with the article it left, where it went and the model's probability; each opens its step. */ +export function raceStepList(session: Session, index: number, open: (moment: number) => void): HTMLElement[] { + const steps = raceSteps(session); + if (steps.length === 0) return []; + const items = steps.map((step) => h("li", {}, stepButton(step, step.moment === index, open))); + return [h("ol", { class: "race-steps" }, ...items)]; } -/** The wikirace part: it draws the trail of each moment, and notes the article each step was at. */ -export function racePart(): DomainPart { +/** The article a step was at, after the step's label. */ +export function raceNote(moment: Moment): string { + const race = raceOf(moment.state); + return race === null ? "" : ` · at ${raceHere(race)}`; +} + +/** + * The wikirace part: it draws each moment of the race. When the page steps forward, the article the step arrived at + * and the branches move in, for as long as one motion of the page takes. + */ +export function racePart(context: PartContext): DomainPart { return { - draw(moment: Moment) { - const trail = raceTrailOf(moment.state); - return Promise.resolve(trail === null ? null : raceTrail(trail)); - }, - stepNote(moment: Moment) { - const here = raceTrailOf(moment.state)?.titles.at(-1); - return here === undefined ? "" : ` · at ${here}`; + draw(moment: Moment, from: Moment | null, session: Session) { + const race = raceOf(moment.state); + if (race === null) return Promise.resolve(null); + const motion = from === null ? 0 : context.motion(); + return Promise.resolve(raceDrawing(moment, session, race, motion)); }, + stepNote: raceNote, + steps: { title: "Steps", list: raceStepList }, hidden: ["path"], }; } diff --git a/examples/wikirace/src/web/route.ts b/examples/wikirace/src/web/route.ts new file mode 100644 index 0000000..29a4374 --- /dev/null +++ b/examples/wikirace/src/web/route.ts @@ -0,0 +1,177 @@ +/// +/** + * The wikirace part's drawing: the race's head with its target and status, the route of the articles so far, and the + * branches of the current step, each with the model's probability, from the article the race is at. + */ +import { type Child, h, type Moment, percent, type Session } from "@osolmaz/switch-viewer/page"; +import { type RaceBranch, type RaceFork, raceFork, raceHere, raceHops, raceReached, type RaceView } from "./data.ts"; + +/** How many branches of a step the drawing shows: the likeliest ones, with the picked one among them. */ +export const RACE_BRANCHES = 8; +/** The height of one branch's row, which the curves to the branches follow. */ +const ROW = 36; +/** The width of the curves between the article and its branches. */ +const FAN = 56; +const SVG = "http://www.w3.org/2000/svg"; + +/** The address of an article on the English Wikipedia, which the race reads. */ +export const articleUrl = (title: string): string => + `https://en.wikipedia.org/wiki/${encodeURIComponent(title.replaceAll(" ", "_"))}`; + +/** A link to an article, which opens in a new tab. */ +export const articleLink = (title: string, attributes: Readonly> = {}): HTMLElement => + h("a", { ...attributes, href: articleUrl(title), target: "_blank", rel: "noopener noreferrer" }, title); + +/** How the race stands at a moment: racing, or how it ended once the case has its outcome. */ +export function raceStatus(moment: Moment, session: Session, race: RaceView): string { + if (raceReached(race)) return "reached"; + if (race.gaveUp) return "gave up"; + const { outcome } = session; + const reason = outcome?.["reason"]; + if (moment.type === "end" && typeof reason === "string") return reason; + return "racing"; +} + +/** The race's head: from where to which article, the hops so far, and how it stands. */ +export function raceHead(race: RaceView, status: string): HTMLElement { + const start = race.path[0]?.title ?? ""; + const hops = raceHops(race); + return h( + "header", + { class: "race-head" }, + h( + "h2", + { class: "race-title" }, + start, + h("span", { class: "race-to", "aria-label": "to" }, " → "), + articleLink(race.target), + ), + h( + "p", + { class: "race-score" }, + h("strong", {}, String(hops)), + hops === 1 ? " hop" : " hops", + h("span", { class: `race-status race-${status.replaceAll(" ", "-")}` }, status), + ), + ); +} + +/** One article of the route, with the link text that led to it when the article's title is another. */ +function stop(title: string, via: string, classes: string, arrived: boolean): HTMLElement { + const parts: Child[] = [h("span", { class: "race-dot", "aria-hidden": "true" }), articleLink(title)]; + if (via !== "" && via !== title) parts.push(h("small", { class: "race-via" }, `via “${via}”`)); + return h("li", { class: `race-stop ${classes}${arrived ? " race-arrived" : ""}` }, ...parts); +} + +/** + * The route: every article so far, the current one marked, and the target at the end, apart when the race has not + * reached it. The article the step arrived at is marked, so that it can move in. + */ +export function raceRoute(race: RaceView, arrived: boolean): HTMLElement { + const last = race.path.length - 1; + const reached = raceReached(race); + const stops = race.path.map((visit, index) => { + const classes = index === last ? `race-here${reached ? " race-goal" : ""}` : "race-been"; + return stop(visit.title, index === 0 ? "" : visit.via, classes, arrived && index === last && last > 0); + }); + if (!reached) { + stops.push( + h("li", { class: "race-stop race-gap", "aria-hidden": "true" }, "⋮"), + stop(race.target, "", "race-target", false), + ); + } + return h("ol", { class: "race-route", "aria-label": "The route" }, ...stops); +} + +/** A branch's name: a link to its article, a section with its count of links, or giving up. */ +function branchName(branch: RaceBranch): HTMLElement { + if (branch.type === "section") { + const count = branch.links === null ? "" : ` · ${String(branch.links)} links`; + return h("span", { class: "race-name race-section" }, `§ ${branch.name}${count}`); + } + if (branch.type === "give-up") return h("span", { class: "race-name race-give-up" }, branch.name); + return articleLink(branch.name, { class: "race-name" }); +} + +function branchRow(branch: RaceBranch): HTMLElement { + const width = branch.probability === null ? 0 : Math.round(branch.probability * 1000) / 10; + return h( + "li", + { class: `race-branch${branch.picked ? " race-picked" : ""}` }, + branchName(branch), + h("span", { class: "race-bar", "aria-hidden": "true" }, h("i", { style: `width:${String(width)}%` })), + h("span", { class: "race-p" }, percent(branch.probability)), + ); +} + +/** The curves from the article to each branch's row: thicker for a likelier branch, and the picked one marked. */ +function curves(branches: readonly RaceBranch[]): SVGSVGElement { + const height = branches.length * ROW; + const svg = document.createElementNS(SVG, "svg"); + svg.setAttribute("class", "race-curves"); + svg.setAttribute("width", String(FAN)); + svg.setAttribute("height", String(height)); + svg.setAttribute("viewBox", `0 0 ${String(FAN)} ${String(height)}`); + svg.setAttribute("aria-hidden", "true"); + branches.forEach((branch, index) => { + const y = index * ROW + ROW / 2; + const path = document.createElementNS(SVG, "path"); + path.setAttribute( + "d", + `M0 ${String(height / 2)}C${String(FAN / 2)} ${String(height / 2)} ${String(FAN / 2)} ${String(y)} ${String(FAN)} ${String(y)}`, + ); + const p = branch.probability; + path.setAttribute("stroke-width", String(p === null ? 1.5 : 1.5 + 6 * p)); + path.setAttribute( + "class", + branch.picked ? "race-curve race-picked" : `race-curve${p === null ? " race-unknown" : ""}`, + ); + svg.append(path); + }); + return svg; +} + +/** What the branches leave out: the rest of the pick's choices, and the ones its limit cut. */ +function forkNote(fork: RaceFork): string { + const rest = fork.total - fork.shown.length; + const parts = [ + rest > 0 ? `${String(rest)} more choices` : "", + fork.cut > 0 ? `the limit cut ${String(fork.cut)}` : "", + ]; + return parts.filter((part) => part !== "").join("; "); +} + +/** The branches of a step: the article the race is at, the curves, and a row for each branch. */ +export function raceBranches(race: RaceView, fork: RaceFork): HTMLElement { + const order = fork.answered ? "by the model's probability" : "in the article's order, before the model answers"; + const where = race.section === null ? raceHere(race) : `${raceHere(race)}, section “${race.section}”`; + return h( + "section", + { class: "race-fork", "aria-label": "The choices of the step" }, + h("p", { class: "race-fork-title" }, `${String(fork.total)} choices at ${where}, ${order}`), + h( + "div", + { class: "race-fan" }, + h("span", { class: "race-node" }, raceHere(race)), + curves(fork.shown), + h("ol", { class: "race-branches" }, ...fork.shown.map(branchRow)), + ), + h("p", { class: "race-more" }, forkNote(fork)), + ); +} + +/** The whole drawing of a moment; `motion` is how many milliseconds the article it arrived at takes to move in. */ +export function raceDrawing(moment: Moment, session: Session, race: RaceView, motion: number): HTMLElement { + const fork = moment.type === "step" ? raceFork(moment, RACE_BRANCHES) : null; + const body = h("div", { class: "race-body" }, raceRoute(race, motion > 0)); + if (fork !== null) body.append(raceBranches(race, fork)); + const drawing = h( + "div", + { class: motion > 0 ? "race race-moving" : "race" }, + raceHead(race, raceStatus(moment, session, race)), + body, + h("p", { class: "race-last" }, race.last), + ); + drawing.style.setProperty("--race-motion", `${String(motion)}ms`); + return drawing; +} diff --git a/examples/wikirace/src/web/wikirace.css b/examples/wikirace/src/web/wikirace.css index 22102cc..ae657b0 100644 --- a/examples/wikirace/src/web/wikirace.css +++ b/examples/wikirace/src/web/wikirace.css @@ -1,56 +1,402 @@ -/* A race's page: the trail of articles as a row of pills, the current one lit and the target at the end. */ -.race-area { +/* + * A race's page looks like Wikipedia: its light colors, or its night colors when the system asks for dark, its serif + * titles and its blue links. The drawing shows the route of the articles so far at the left, and the branches of the + * current step at the right, each with the model's probability. + */ +:root[data-domain="wikirace"] { + --bg: #f8f9fa; + --panel: #ffffff; + --raise: #eaecf0; + --raise-hover: #dadde3; + --ink: #202122; + --text: #202122; + --muted: #54595d; + --faint: #72777d; + --accent: #3366cc; + --accent-edge: #233566; + --accent-hover: #2a4b8d; + --live: #d73333; + --track: #eaecf0; + --bar: #a2a9b1; + --chart: #3366cc; + --race-link: #3366cc; + --race-line: #c8ccd1; + --race-picked: #3366cc; + --race-good: #14866d; + --race-bad: #d73333; + --race-serif: "Linux Libertine", "Georgia", "Times", serif; + color-scheme: light; +} +@media (prefers-color-scheme: dark) { + :root[data-domain="wikirace"] { + --bg: #101418; + --panel: #1b1f23; + --raise: #27292d; + --raise-hover: #34373b; + --ink: #f8f9fa; + --text: #eaecf0; + --muted: #a2a9b1; + --faint: #8a9096; + --accent: #3366cc; + --accent-edge: #233566; + --accent-hover: #2a4b8d; + --live: #fd7865; + --track: #34373b; + --bar: #72777d; + --chart: #88a3e8; + --race-link: #88a3e8; + --race-line: #484c52; + --race-picked: #88a3e8; + --race-good: #4eb39a; + --race-bad: #fd7865; + color-scheme: dark; + } +} +:root[data-domain="wikirace"] button { + box-shadow: inset 0 -2px 0 rgba(0, 0, 0, 0.12); +} +.race { display: grid; - gap: 12px; - padding: 16px; - border-radius: 12px; + gap: 18px; + min-width: 0; + padding: clamp(14px, 3vw, 24px); + border-radius: 8px; background: var(--panel); + box-shadow: 0 0 0 1px var(--race-line); +} +.race a { + color: var(--race-link); + text-decoration: none; +} +.race a:hover { + text-decoration: underline; +} +.race-head { + display: flex; + flex-wrap: wrap; + align-items: baseline; + justify-content: space-between; + gap: 6px 20px; + padding-bottom: 12px; + border-bottom: 1px solid var(--race-line); } -.race-label { +.race-title { margin: 0; - font-size: 1.1rem; - font-weight: 600; + font: 400 28px/1.25 var(--race-serif); color: var(--ink); + overflow-wrap: anywhere; } -.race-trail { +.race-to { + color: var(--muted); +} +.race-score { display: flex; - flex-wrap: wrap; - align-items: center; - gap: 8px; + align-items: baseline; + gap: 6px; margin: 0; - padding: 0; - list-style: none; + color: var(--muted); +} +.race-score strong { + font-size: 28px; + line-height: 1; + color: var(--ink); + font-variant-numeric: tabular-nums; } -.race-trail li { - padding: 4px 10px; +.race-status { + margin-left: 8px; + padding: 2px 10px; border-radius: 999px; + font-size: 13px; + font-weight: 600; + color: var(--ink); background: var(--raise); - color: var(--text); } -.race-trail li + li { +.race-status.race-reached { + color: #ffffff; + background: var(--race-good); +} +.race-status.race-gave-up, +.race-status.race-failed { + color: #ffffff; + background: var(--race-bad); +} +.race-body { + display: grid; + grid-template-columns: minmax(0, 1fr); + gap: 24px; + align-items: start; +} +@media (min-width: 720px) { + .race-body { + grid-template-columns: minmax(160px, 240px) minmax(0, 1fr); + } +} +/* The route: the articles so far on one line, like the stops of a train line. */ +.race-route { + margin: 0; + padding: 0; + list-style: none; +} +.race-stop { position: relative; - margin-left: 14px; + display: grid; + gap: 2px; + min-height: 40px; + padding: 0 0 12px 26px; + font: 400 18px/1.3 var(--race-serif); } -.race-trail li + li::before { - content: "›"; +.race-stop::before { + content: ""; position: absolute; - left: -15px; + left: 6px; + top: 14px; + bottom: -2px; + width: 2px; + background: var(--race-line); +} +.race-stop:last-child::before { + display: none; +} +.race-dot { + position: absolute; + left: 0; + top: 6px; + width: 14px; + height: 14px; + border-radius: 50%; + background: var(--panel); + box-shadow: inset 0 0 0 2px var(--race-line); +} +.race-been .race-dot { + box-shadow: inset 0 0 0 7px var(--race-line); +} +.race-here .race-dot { + box-shadow: inset 0 0 0 7px var(--race-picked); +} +.race-here a { + font-weight: 700; +} +.race-goal .race-dot { + box-shadow: inset 0 0 0 7px var(--race-good); +} +.race-target .race-dot { + box-shadow: inset 0 0 0 2px var(--race-good); + border: 2px dashed var(--panel); +} +.race-gap { + min-height: 28px; + padding-left: 3px; + color: var(--faint); +} +.race-gap::before { + background: repeating-linear-gradient(var(--race-line) 0 4px, transparent 4px 8px); +} +.race-via { + font: + 13px/1.3 system-ui, + sans-serif; + color: var(--faint); +} +/* The branches of the step: from the article, a curve to each branch, thicker for a likelier one. */ +.race-fork { + display: grid; + gap: 10px; + min-width: 0; + container-type: inline-size; +} +.race-fork-title, +.race-more, +.race-last { + margin: 0; + font-size: 14px; color: var(--muted); } -.race-trail .race-here { - background: var(--accent); +.race-fan { + display: grid; + grid-template-columns: auto 56px minmax(0, 1fr); + align-items: center; +} +.race-node { + padding: 6px 12px; + border-radius: 6px; + font: 700 16px/1.3 var(--race-serif); color: var(--ink); + background: var(--raise); + max-width: 160px; + overflow-wrap: anywhere; } -.race-trail .race-gap { - background: none; - color: var(--muted); +.race-curves { + display: block; + overflow: visible; } -.race-trail .race-goal { - outline: 2px dashed var(--accent); - background: none; +.race-curve { + fill: none; + stroke: var(--race-line); + stroke-linecap: round; +} +.race-curve.race-picked { + stroke: var(--race-picked); } -.race-where { +.race-curve.race-unknown { + stroke-dasharray: 3 5; +} +.race-branches { margin: 0; - min-height: 1.2em; + padding: 0; + list-style: none; +} +.race-branch { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(48px, 120px) 4.2em; + align-items: center; + gap: 10px; + height: 36px; + padding: 0 8px; + border-radius: 6px; + font-size: 15px; +} +.race-branch.race-picked { + background: var(--raise); +} +.race-name { + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + font-family: var(--race-serif); + font-size: 17px; +} +.race-picked .race-name { + font-weight: 700; +} +.race-section, +.race-give-up { + color: var(--muted); + font-family: inherit; + font-size: 15px; +} +.race-give-up { + color: var(--race-bad); +} +.race-bar { + height: 8px; + border-radius: 4px; + background: var(--track); + overflow: hidden; +} +.race-bar i { + display: block; + height: 100%; + border-radius: 4px; + background: var(--bar); +} +.race-picked .race-bar i { + background: var(--race-picked); +} +.race-p { + text-align: right; + font-size: 13px; color: var(--muted); + font-variant-numeric: tabular-nums; +} +/* The steps beside the drawing: from which article to which, with the model's probability. */ +.race-steps { + margin: 0 -16px; + padding: 0; + list-style: none; + max-height: 360px; + overflow: auto; +} +.race-step { + display: grid; + grid-template-columns: 2em minmax(0, 1fr) 4em; + align-items: center; + gap: 6px; + width: 100%; + min-height: 32px; + padding: 2px 16px; + border-radius: 0; + background: none; + box-shadow: none; + font-weight: 400; + text-align: left; +} +:root[data-domain="wikirace"] .race-step { + box-shadow: none; +} +.race-step:hover { + background: var(--raise); +} +.race-step.current { + background: var(--raise-hover); +} +.race-step span { + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; +} +.race-n, +.race-step small { + color: var(--faint); + font-variant-numeric: tabular-nums; +} +.race-dest { + font-weight: 600; + color: var(--ink); +} +.race-step small { + text-align: right; +} +/* A step forward moves the new article and the branches in. */ +.race-moving .race-arrived { + animation: race-arrive var(--race-motion) ease-out both; +} +.race-moving .race-branch { + animation: race-arrive var(--race-motion) ease-out both; +} +.race-moving .race-bar i { + animation: race-grow var(--race-motion) ease-out both; +} +@keyframes race-arrive { + from { + opacity: 0; + transform: translateY(-8px); + } +} +@keyframes race-grow { + from { + transform: scaleX(0); + transform-origin: 0 50%; + } +} +@media (prefers-reduced-motion: reduce) { + .race-moving .race-arrived, + .race-moving .race-branch, + .race-moving .race-bar i { + animation: none; + } +} +@media (min-width: 981px) { + .race-steps { + flex: 1 1 0; + min-height: 0; + max-height: none; + } +} +/* On a narrow drawing, the article heads the list of branches, without the curves. */ +@container (max-width: 520px) { + .race-fan { + grid-template-columns: minmax(0, 1fr); + gap: 8px; + } + .race-node { + justify-self: start; + max-width: 100%; + } + .race-curves { + display: none; + } + .race-branch { + grid-template-columns: minmax(0, 1fr) 56px 4.2em; + padding: 0 4px; + } } diff --git a/examples/wikirace/tests/harness.test.ts b/examples/wikirace/tests/harness.test.ts index ad54e66..b7e8d60 100644 --- a/examples/wikirace/tests/harness.test.ts +++ b/examples/wikirace/tests/harness.test.ts @@ -9,8 +9,6 @@ import { answered, fakeDecider, type Policy } from "@osolmaz/switch/testing"; import { loadRace } from "../src/case.ts"; import { wikiraceSetup } from "../src/setup.ts"; import { wikiraceViewer } from "../src/viewer.ts"; -import type { Moment } from "@osolmaz/switch-viewer/page"; -import { racePart, raceTrailOf } from "../src/web/part.ts"; import { fakeWiki, PAGES } from "./helpers/wiki.ts"; let folder = ""; @@ -65,60 +63,3 @@ describe("the wikirace harness", () => { await expect(loadRace(folder)).rejects.toThrow(); }); }); - -describe("the wikirace part of the page", () => { - const state = { - target: "Moon", - path: [ - { title: "Duck", via: "Duck" }, - { title: "Bird", via: "Bird" }, - ], - sections: [ - { name: "Lead", links: ["Sky"] }, - { name: "Flight", links: ["Air"] }, - ], - open: 1, - opened: [1], - last: "", - }; - const moment = { type: "step" as const, label: "Step 2", state, frame: null }; - - it("reads the trail of a race's state, and nothing of another state", () => { - expect(raceTrailOf(state)).toEqual({ target: "Moon", titles: ["Duck", "Bird"], section: "Flight" }); - expect(raceTrailOf({ ...state, open: null })?.section).toBeNull(); - expect(raceTrailOf({ moves: [] })).toBeNull(); - expect(raceTrailOf(null)).toBeNull(); - }); - - /** The part's drawing of `shown`, which must be one. */ - async function drawingOf(shown: Moment): Promise { - const drawn = await racePart().draw?.(shown, null, {} as never); - if (drawn === undefined || drawn === null) throw new Error("no drawing"); - return drawn; - } - const texts = (root: HTMLElement, selector: string): (string | null)[] => - [...root.querySelectorAll(selector)].map((item) => item.textContent); - - it("draws the trail toward the target, and marks the article it is at", async () => { - const drawn = await drawingOf(moment); - expect(texts(drawn, ".race-label")).toEqual(["Racing to Moon"]); - expect(texts(drawn, ".race-trail li")).toEqual(["Duck", "Bird", "…", "Moon"]); - expect(texts(drawn, ".race-here")).toEqual(["Bird"]); - expect(texts(drawn, ".race-where")).toEqual(['Showing the section "Flight"']); - }); - - it("draws a reached target without the gap, and nothing for a moment without a state", async () => { - const path = [...state.path, { title: "Moon", via: "Moon" }]; - const done = await drawingOf({ ...moment, state: { ...state, path, open: null } }); - expect(texts(done, ".race-label")).toEqual(["Reached Moon"]); - expect(texts(done, ".race-goal")).toEqual([]); - await expect(racePart().draw?.({ ...moment, state: null }, null, {} as never)).resolves.toBeNull(); - }); - - it("notes the article of each step, and hides the path in the result", () => { - const part = racePart(); - expect(part.stepNote?.(moment)).toBe(" · at Bird"); - expect(part.stepNote?.({ ...moment, state: null })).toBe(""); - expect(part.hidden).toEqual(["path"]); - }); -}); diff --git a/examples/wikirace/tests/helpers/sessions.ts b/examples/wikirace/tests/helpers/sessions.ts new file mode 100644 index 0000000..423912f --- /dev/null +++ b/examples/wikirace/tests/helpers/sessions.ts @@ -0,0 +1,55 @@ +import { PICK_ID, run } from "@osolmaz/switch"; +import { answered, fakeDecider, type Policy } from "@osolmaz/switch/testing"; +import { storyOf } from "@osolmaz/switch-viewer"; +import type { Session } from "@osolmaz/switch-viewer/page"; +import { openRace, outcomeOf, raceMachine, WIKIRACE } from "../../src/race.ts"; +import { wikiraceViewer } from "../../src/viewer.ts"; +import { fakeWiki, type Pages, PAGES } from "./wiki.ts"; + +/** + * Picks the first choice whose text is in `route`, else the first choice, at 0.6; the other choices share the rest, the + * earlier ones more, so that every choice has a probability. + */ +export const following = + (route: readonly string[]): Policy => + (request) => { + const question = request.decisionQuestions[PICK_ID]; + const texts = question?.type === "choice" ? Object.entries(question.criteria) : []; + const label = + route.flatMap((title) => texts.filter(([, text]) => text === title))[0]?.[0] ?? texts[0]?.[0] ?? "o1"; + const others = texts.filter(([key]) => key !== label); + const probabilities: Record = { [label]: 0.6 }; + others.forEach(([key], index) => { + probabilities[key] = 0.4 * 2 ** -(index + 1); + }); + return answered({ [PICK_ID]: { type: "choice", choice: label, probabilities, confidence: 0.5 } }); + }; + +interface RaceOptions { + readonly pages: Pages; + readonly policy: Policy; + readonly steps: number; + readonly finished: boolean; +} + +const RACE: RaceOptions = { pages: PAGES, policy: following(["Bird", "Sky", "Moon"]), steps: 40, finished: true }; + +/** A race from Duck to Moon on `pages`, run by `policy`, as the viewer receives it. */ +export async function raceSession(options: Partial = {}): Promise { + const { pages, policy, steps, finished } = { ...RACE, ...options }; + const wiki = fakeWiki(pages); + const race = await openRace({ id: "duck-to-moon", start: "Duck", target: "Moon" }, wiki); + const result = await run( + { ...WIKIRACE, limits: { ...WIKIRACE.limits, steps } }, + raceMachine(race, wiki), + fakeDecider(policy), + ); + const outcome = finished ? outcomeOf(result) : null; + return { + story: storyOf(result.trace.entries, "duck-to-moon"), + outcome, + summary: outcome === null ? null : (wikiraceViewer.summary?.(outcome) ?? null), + finished, + version: `${String(result.trace.entries.length)}:${String(finished)}`, + }; +} diff --git a/examples/wikirace/tests/page.test.ts b/examples/wikirace/tests/page.test.ts new file mode 100644 index 0000000..1ee5c6b --- /dev/null +++ b/examples/wikirace/tests/page.test.ts @@ -0,0 +1,224 @@ +// @vitest-environment happy-dom +import { afterEach, describe, expect, it, vi } from "vitest"; +import { PICK_ID } from "@osolmaz/switch"; +import { answered } from "@osolmaz/switch/testing"; +import { type AppConfig, type Moment, momentsOf, type Session, start } from "@osolmaz/switch-viewer/page"; +import { TEXTS } from "../src/race.ts"; +import { raceFork, raceOf, raceSteps } from "../src/web/data.ts"; +import { racePart } from "../src/web/part.ts"; +import { articleUrl } from "../src/web/route.ts"; +import { following, raceSession } from "./helpers/sessions.ts"; +import { manyLinks } from "./helpers/wiki.ts"; + +const QUICK = { motion: 0, dwell: 0, poll: 5, parts: { wikirace: {} } } as const; +const mounted: ReturnType[] = []; + +function mount(config: AppConfig): HTMLElement { + const root = document.createElement("div"); + document.body.replaceChildren(root); + mounted.push(start(root, config)); + return root; +} + +const texts = (root: ParentNode, selector: string): (string | null)[] => + [...root.querySelectorAll(selector)].map((item) => item.textContent); +const text = (root: ParentNode, selector: string): string => root.querySelector(selector)?.textContent ?? ""; +const click = (root: HTMLElement, selector: string): void => { + root.querySelector(selector)?.click(); +}; +const label = (root: HTMLElement): string => text(root, "output"); +/** The moments of a session, as the page has them. */ +const momentAt = (session: Session, index: number): Moment => { + const moment = momentsOf(session.story, session.finished)[index]; + if (moment === undefined) throw new Error(`no moment ${String(index)}`); + return moment; +}; +/** A part that moves things for `motion` milliseconds. */ +const partWith = (motion: number) => racePart({ config: {}, motion: () => motion, gap: () => 0 }); + +afterEach(() => { + for (const app of mounted.splice(0)) app.close(); + vi.restoreAllMocks(); + localStorage.clear(); +}); + +describe("what the wikirace part reads", () => { + it("reads a race from a state, and nothing from another state", async () => { + const session = await raceSession(); + expect(raceOf(session.story.initial)).toEqual({ + target: "Moon", + path: [{ title: "Duck", via: "Duck" }], + section: null, + gaveUp: false, + last: "The race starts at Duck.", + }); + expect(raceOf({ moves: [] })).toBeNull(); + expect(raceOf(null)).toBeNull(); + }); + + it("reads each step's article and picked branch, and a step's likeliest branches", async () => { + const session = await raceSession(); + expect( + raceSteps(session).map((step) => [step.moment, step.from, step.picked?.name, step.picked?.probability]), + ).toEqual([ + [1, "Duck", "Bird", 0.6], + [2, "Bird", "Sky", 0.6], + [3, "Sky", "Moon", 0.6], + ]); + const fork = raceFork(momentAt(session, 1), 3); + expect(fork).toMatchObject({ total: 5, cut: 0, answered: true }); + expect(fork?.shown.map((branch) => [branch.name, branch.type, branch.picked])).toEqual([ + ["Bird", "link", true], + ["Water", "link", false], + ["Pond", "link", false], + ]); + expect(raceFork(momentAt(session, 0), 3)).toBeNull(); + }); + + it("keeps the picked branch among the shown ones, and shows the article's order before the model answers", async () => { + // The model picks Pond, its third likeliest link. + const session = await raceSession({ + steps: 1, + policy: () => + answered({ + [PICK_ID]: { + type: "choice", + choice: "o3", + probabilities: { o1: 0.5, o2: 0.3, o3: 0.1, o4: 0.06, o5: 0.04 }, + confidence: 0.2, + }, + }), + }); + const step = momentAt(session, 1); + expect(raceFork(step, 2)?.shown.map((branch) => branch.name)).toEqual(["Bird", "Pond"]); + const frame = step.frame; + if (frame === null) throw new Error("no frame"); + const unanswered = { + ...frame, + answered: false, + questions: frame.questions.map((question) => ({ + ...question, + answer: null, + choices: question.choices.map((choice) => ({ ...choice, probability: null, picked: false })), + })), + }; + const before = raceFork({ ...step, frame: unanswered }, 2); + expect(before).toMatchObject({ answered: false, shown: [{ name: "Bird" }, { name: "Water" }] }); + }); +}); + +describe("the wikirace part of the page", () => { + it("draws the start: from where to which article, no hops yet, and the target apart", async () => { + const root = mount({ ...QUICK, session: await raceSession() }); + await vi.waitFor(() => { + expect(label(root)).toBe("Start"); + }); + expect(text(root, ".race-title")).toBe("Duck → Moon"); + expect(text(root, ".race-score")).toBe("0 hopsracing"); + expect(texts(root, ".race-stop a")).toEqual(["Duck", "Moon"]); + expect(root.querySelector(".race-gap")).not.toBeNull(); + expect(root.querySelector(".race-fork")).toBeNull(); + const duck = root.querySelector(".race-stop a"); + expect([duck?.getAttribute("href"), duck?.target, duck?.rel]).toEqual([ + articleUrl("Duck"), + "_blank", + "noopener noreferrer", + ]); + expect(articleUrl("Rubber duck")).toBe("https://en.wikipedia.org/wiki/Rubber_duck"); + }); + + it("draws a step's branches from its article, likeliest first, with the picked one marked", async () => { + const root = mount({ ...QUICK, session: await raceSession() }); + click(root, "button.next"); + await vi.waitFor(() => { + expect(text(root, ".race-fork-title")).toBe("5 choices at Duck, by the model's probability"); + }); + expect(texts(root, ".race-branch .race-name")).toEqual(["Bird", "Water", "Pond", "Rubber duck", TEXTS.giveUp]); + expect(texts(root, ".race-branch.race-picked .race-name")).toEqual(["Bird"]); + expect(texts(root, ".race-p")[0]).toBe("60.0%"); + expect(root.querySelectorAll(".race-curve")).toHaveLength(5); + expect(root.querySelectorAll(".race-curve.race-picked")).toHaveLength(1); + expect(text(root, ".race-node")).toBe("Duck"); + }); + + it("lists the steps beside the drawing, each opening its step, and ends at the target", async () => { + const root = mount({ ...QUICK, session: await raceSession() }); + await vi.waitFor(() => { + expect(texts(root, ".race-step .race-dest")).toEqual(["Bird", "Sky", "Moon"]); + }); + expect(text(root, ".timeline h2")).toBe("Steps"); + root.querySelectorAll(".race-step")[2]?.click(); + await vi.waitFor(() => { + expect(label(root)).toBe("Step 3 of 3"); + }); + expect(root.querySelector(".race-step.current")?.getAttribute("aria-current")).toBe("step"); + click(root, "button.latest"); + await vi.waitFor(() => { + expect(text(root, ".race-status")).toBe("reached"); + }); + expect(text(root, ".race-score strong")).toBe("3"); + expect(texts(root, ".race-goal a")).toEqual(["Moon"]); + expect(root.querySelector(".race-gap")).toBeNull(); + expect(text(root, ".result-slot")).toContain("reached · 3 hops"); + }); + + it("names a section with its links, and the section that a step shows", async () => { + // Duck has more links than one step shows, so a step shows its lead and offers its other section. + const history = [...manyLinks(79, "H"), "Moon"]; + const pages = { Duck: { Lead: manyLinks(150, "L"), History: history }, Moon: { Lead: [] } }; + const opened = await raceSession({ + pages, + policy: following(['Open section 1, "History": H 1, H 2, H 3 and 77 more', "Moon"]), + }); + const first = raceFork(momentAt(opened, 1), 8)?.shown[0]; + expect(first).toMatchObject({ type: "section", name: "History", links: 80, picked: true }); + const root = mount({ ...QUICK, session: opened }); + click(root, "button.next"); + await vi.waitFor(() => { + expect(text(root, ".race-section")).toBe("§ History · 80 links"); + }); + expect(texts(root, ".race-step .race-dest")).toEqual(["§ History", "Moon"]); + click(root, "button.next"); + await vi.waitFor(() => { + expect(text(root, ".race-fork-title")).toBe( + "82 choices at Duck, section “History”, by the model's probability", + ); + }); + }); + + /** The part's drawing of `moment`, which must be one. */ + async function drawn(moment: Moment, from: Moment | null, session: Session, motion = 0): Promise { + const drawing = await partWith(motion).draw?.(moment, from, session); + if (drawing === undefined || drawing === null) throw new Error("no drawing"); + return drawing; + } + + it("says when a race gave up, or ended at a dead end", async () => { + const quit = await raceSession({ policy: following([TEXTS.giveUp]) }); + expect(raceSteps(quit)[0]?.picked).toMatchObject({ type: "give-up" }); + expect(text(await drawn(momentAt(quit, 2), null, quit), ".race-status")).toBe("gave up"); + const pages = { Duck: { Lead: ["Pond"] }, Pond: { Lead: [] } }; + const stuck = await raceSession({ pages, policy: following(["Pond"]) }); + expect(text(await drawn(momentAt(stuck, 2), null, stuck), ".race-status")).toBe("dead end"); + }); + + it("moves the new article and the branches in when the page steps forward", async () => { + const session = await raceSession(); + const moved = await drawn(momentAt(session, 2), momentAt(session, 1), session, 300); + expect(moved.classList.contains("race-moving")).toBe(true); + expect(moved.style.getPropertyValue("--race-motion")).toBe("300ms"); + expect(texts(moved, ".race-arrived a")).toEqual(["Bird"]); + const still = await drawn(momentAt(session, 2), null, session, 300); + expect(still.classList.contains("race-moving")).toBe(false); + }); + + it("draws nothing for another state, notes each step's article, and hides the path in the result", async () => { + const session = await raceSession(); + const part = partWith(0); + const step = momentAt(session, 1); + await expect(part.draw?.({ ...step, state: null }, null, session)).resolves.toBeNull(); + expect(part.stepNote?.(step)).toBe(" · at Duck"); + expect(part.stepNote?.({ ...step, state: null })).toBe(""); + expect(part.hidden).toEqual(["path"]); + }); +}); diff --git a/viewer/README.md b/viewer/README.md index 06fd09a..96e0b81 100644 --- a/viewer/README.md +++ b/viewer/README.md @@ -33,7 +33,10 @@ that opens without the viewer. Without a part, the page shows each state as JSON and lists what each step changed. A domain can give its own part, which draws its states in its own way. The chess example draws a board with moving pieces, arrows for the model's -likeliest moves, the players' bars, a sound button, the move list with the score, and a PGN download. +likeliest moves, the players' bars, a sound button, the move list with the score, and a PGN download. The WikiRace +example draws the route of the articles so far and each step's likeliest branches with their probabilities. A part +reads each step's questions from the page, and each choice of a pick comes with its type and its data, as the domain's +choice function gave them. A part has two halves: diff --git a/viewer/src/frames.ts b/viewer/src/frames.ts index 888a05d..2c48c36 100644 --- a/viewer/src/frames.ts +++ b/viewer/src/frames.ts @@ -16,6 +16,8 @@ export interface FrameChoice { readonly text: string; readonly probability: number | null; readonly picked: boolean; + /** The choice's type, from the question's facts, as a pick writes them; null for a question without them. */ + readonly type: string | null; /** The choice's own facts for tools, from the question's facts. */ readonly data: unknown; } @@ -85,11 +87,12 @@ type Entry = Extract; const isObject = (value: unknown): value is Readonly> => typeof value === "object" && value !== null && !Array.isArray(value); -/** The facts of the choice under `label`, as a pick writes them into its question. */ -function factsOf(question: Question, label: string): unknown { +/** The type and the facts of the choice under `label`, as a pick writes them into its question. */ +function factsOf(question: Question, label: string): Pick { const choices = isObject(question.data) ? question.data["choices"] : undefined; const facts = isObject(choices) ? choices[label] : undefined; - return isObject(facts) ? facts["data"] : undefined; + if (!isObject(facts)) return { type: null, data: undefined }; + return { type: typeof facts["type"] === "string" ? facts["type"] : null, data: facts["data"] }; } /** A question of a step, before its answer: a choice question with its choices. */ @@ -101,7 +104,7 @@ function frameQuestion(id: string, question: Question): FrameQuestion { text, probability: null, picked: false, - data: factsOf(question, label), + ...factsOf(question, label), })); return { id, question, answer: null, choices, cut }; } diff --git a/viewer/tests/model.test.ts b/viewer/tests/model.test.ts index ffbadf6..800269f 100644 --- a/viewer/tests/model.test.ts +++ b/viewer/tests/model.test.ts @@ -58,6 +58,7 @@ describe("text", () => { text: label, probability, picked: false, + type: null, data: null, }); expect(ranked([choice("a", null), choice("b", 0.2), choice("c", 0.7)]).map((item) => item.label)).toEqual([ diff --git a/viewer/tests/page.test.ts b/viewer/tests/page.test.ts index aac6a40..905092b 100644 --- a/viewer/tests/page.test.ts +++ b/viewer/tests/page.test.ts @@ -54,10 +54,17 @@ describe("storyOf", () => { expect(first).toMatchObject({ step: 1, pick: 0, answered: true, before: opened, after: afterA }); const pick = first?.questions[0]; expect(pick).toMatchObject({ id: PICK_ID, cut: 0, answer: { type: "choice", choice: "o1" } }); - expect(pick?.choices.map((choice) => [choice.label, choice.text, choice.probability, choice.picked])).toEqual([ - ["o1", "Look up a", 0.9, true], - ["o2", "Look up b", null, false], - ["o3", "Stop looking", null, false], + const read = pick?.choices.map(({ label, text, probability, picked, type }) => [ + label, + text, + probability, + picked, + type, + ]); + expect(read).toEqual([ + ["o1", "Look up a", 0.9, true, "look-up"], + ["o2", "Look up b", null, false, "look-up"], + ["o3", "Stop looking", null, false, "stop"], ]); }); @@ -116,9 +123,11 @@ describe("storyOf", () => { const [frame] = storyOf(entries, "c1").frames; expect(frame?.pick).toBeNull(); expect(frame?.questions[0]).toMatchObject({ id: "sure", answer: { probability: 0.6 }, choices: [] }); - expect(frame?.questions[1]?.choices.map((choice) => [choice.label, choice.probability, choice.picked])).toEqual([ - ["x", 0.2, false], - ["y", 0.8, true], + expect( + frame?.questions[1]?.choices.map((choice) => [choice.label, choice.probability, choice.picked, choice.type]), + ).toEqual([ + ["x", 0.2, false, null], + ["y", 0.8, true, null], ]); });