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