Fork Horse repository, listed by its owner. Publisher snapshot.
packages/forkhorse-dashboard/test/races.test.ts
Snapshot at 34e3175
1import { installDom } from "./dom.ts";
2import assert from "node:assert/strict";
3import test from "node:test";
4import { createRequire } from "node:module";
5import { createElement } from "react";
6import { renderToStaticMarkup } from "react-dom/server";
7import { TooltipProvider } from "@cloudflare/kumo";
8import { stateView } from "../src/model.ts";
9import { mountDashboard } from "../src/dashboard.ts";
10import { createHttpTransport } from "../src/transport.ts";
11import { Races, openRaceCount, orderedRaces } from "../src/races.tsx";
12import { racesFixtureState } from "./races-fixtures.ts";
13import { getByRole, queryByRole } from "./roles.ts";
14
15const { JSDOM } = createRequire(import.meta.url)("jsdom");
16const brand = { name: "Fixture product", tagline: "Fixture races" };
17const fixture = () => stateView(racesFixtureState());
18const markup = (owner = true, state = fixture()) => new JSDOM(renderToStaticMarkup(
19 createElement(TooltipProvider, null, createElement(Races, { state, owner, openRace() {} })),
20));
21
22test("FH-081 orders recorded races by open/start then finished/finish, with empty opens last", () => {
23 const races = fixture().races!;
24 const original = races.map((race) => race.packet);
25 assert.deepEqual(orderedRaces(races).map((race) => race.packet), [
26 "race-advice", "race-judged", "race-one", "race-three", "race-zero", "race-photo", "race-winner",
27 ]);
28 assert.deepEqual(races.map((race) => race.packet), original, "sorting never mutates state");
29 assert.equal(openRaceCount(races), 4);
30 assert.equal(openRaceCount(races.filter((race) => race.status === "finished" || !race.entrants.length)), 0);
31});
32
33test("FH-081 table names, lane identities, archived and photo-finish history use existing contracts", () => {
34 const dom = markup();
35 try {
36 const root = dom.window.document.body as HTMLElement;
37 const table = getByRole(root, "table", { name: "Races" });
38 assert.deepEqual([...table.querySelectorAll("thead th")].map((cell) => cell.textContent),
39 ["Race", "Status", "Entrants", "Winner", "Judge's pick", "Started"]);
40 const three = table.querySelector('[data-race-packet="race-three"]')!;
41 assert.deepEqual([...three.querySelectorAll("td:nth-child(3) .race-list-name")].map((name) => name.textContent),
42 ["birch", "cedar", "amber"]);
43 assert.deepEqual([...three.querySelectorAll("td:nth-child(3) .race-saddlecloth .sr-only")].map((name) => name.textContent),
44 ["Agent number 2", "Agent number 3", "Agent number 1"]);
45 const photo = table.querySelector('[data-race-packet="race-photo"]')!;
46 assert.match(photo.textContent!, /Archived.*Finished.*photo finish/);
47 assert.match(photo.querySelector("td:nth-child(4)")!.textContent!, /cedarwon in 12:00\.0/);
48 assert.match(photo.querySelector("td:nth-child(6)")!.textContent!, /Finished 12 min later/);
49 assert.ok(photo.querySelector("time[datetime]"));
50 const zero = table.querySelector('[data-race-packet="race-zero"]')!;
51 assert.match(zero.querySelector("td:nth-child(2)")!.textContent!, /No entrants yet/);
52 assert.equal(zero.querySelector("td:nth-child(6)")!.textContent, "—");
53 assert.equal(root.querySelectorAll('[aria-live], [role="status"]').length, 0);
54 } finally { dom.window.close(); }
55});
56
57test("FH-081 advice never becomes a winner and missing winner history stays explicit", () => {
58 const state = fixture();
59 state.races!.find((race) => race.packet === "race-winner")!.winner = null;
60 const dom = markup(true, state);
61 try {
62 const root = dom.window.document.body as HTMLElement;
63 const judged = root.querySelector('[data-race-packet="race-judged"]')!;
64 assert.equal(judged.querySelector("td:nth-child(4)")!.textContent, "—");
65 assert.match(judged.querySelector("td:nth-child(5)")!.textContent!, /birchJudge fixture-judge/);
66 assert.equal(judged.querySelectorAll("td:nth-child(5) .race-saddlecloth").length, 1);
67 assert.match(root.querySelector('[data-race-packet="race-advice"] td:nth-child(5)')!.textContent!, /fixture-judge · no advice yet/);
68 assert.match(root.querySelector('[data-race-packet="race-winner"] td:nth-child(4)')!.textContent!, /No winner recorded/);
69 assert.doesNotMatch(root.textContent!, /verdict/i);
70 } finally { dom.window.close(); }
71});
72
73test("FH-081 empty owner guidance is copy-only and agent guidance has no command", () => {
74 for (const owner of [true, false]) {
75 const dom = markup(owner, stateView(racesFixtureState("races-empty")));
76 try {
77 const root = dom.window.document.body as HTMLElement;
78 assert.match(root.textContent!, /Races are started by the owner \(CLI or owner API\)\./);
79 assert.match(root.textContent!, /No races yet/);
80 assert.match(root.textContent!, /The first approved promote wins\./);
81 assert.equal(root.querySelector("table"), null);
82 assert.equal(!!root.querySelector(".first-run-copy"), owner);
83 if (owner) {
84 assert.match(root.textContent!, /Add mode: race to the packet file\./);
85 assert.match(root.textContent!, /packet create packet.yaml --config "\$CLI_CONFIG"/);
86 } else {
87 assert.match(root.textContent!, /The owner starts races\./);
88 assert.doesNotMatch(root.textContent!, /CLI_CONFIG/);
89 }
90 assert.equal(queryByRole(root, "button", { name: /Start a race/i }), null);
91 } finally { dom.window.close(); }
92 }
93});
94
95async function until(check: () => boolean) {
96 for (let attempt = 0; attempt < 200; attempt++) {
97 if (check()) return;
98 await new Promise((resolve) => setTimeout(resolve, 5));
99 }
100 assert.fail("Races fixture DOM did not settle");
101}
102
103test("FH-081 last tab is named and read-only for both roles; race navigation returns to its source", async () => {
104 for (const owner of [true, false]) {
105 const dom = new JSDOM('<div id="dashboard"></div>', { url: "https://fixture.idiot.tax/#races" });
106 installDom(dom.window);
107 const root = dom.window.document.getElementById("dashboard")! as HTMLElement;
108 let wire = racesFixtureState();
109 const calls: { path: string; method: string }[] = [];
110 const transport = createHttpTransport({ origin: "https://fixture.idiot.tax", fetch: async (input, init) => {
111 const path = new URL(String(input)).pathname;
112 calls.push({ path, method: init?.method ?? "GET" });
113 return Response.json(path.endsWith("/state") ? wire : { activity: [], nextAfter: 0 });
114 } });
115 const app = mountDashboard(root, { brand, transport, initialProject: wire.project.id, owner });
116 try {
117 await until(() => !!root.querySelector(".table-races"));
118 assert.ok(getByRole(root, "tabpanel", { name: "Races" }));
119 const tabs = [...root.querySelectorAll('[role="tab"]')];
120 assert.equal(tabs.length, 5);
121 assert.equal(tabs[3].textContent, "Agents");
122 assert.equal(tabs[4], getByRole(root, "tab", { name: /^Races\s*,\s+4$/ }));
123 const link = getByRole(root, "link", { name: "Open race Make delivery failures readable" });
124 assert.equal(link.getAttribute("href"), "#race/race-one");
125 if (!owner) dom.window.history.pushState = () => { throw new Error("Opaque fixture history"); };
126 link.click();
127 await until(() => !!root.querySelector(".race-page"));
128 // Let hashchange run too, including the opaque-frame History API fallback.
129 await new Promise((resolve) => setTimeout(resolve, 20));
130 assert.equal(dom.window.location.hash, "#race/race-one");
131 getByRole(root, "button", { name: "← Races" }).click();
132 await until(() => !root.querySelector(".race-page"));
133 assert.equal(dom.window.location.hash, "#races");
134 getByRole(root, "tab", { name: /^Work/ }).click();
135 root.querySelector<HTMLButtonElement>('[data-packet-id="race-one"] .packet-link')!.click();
136 await until(() => !!root.querySelector(".race-page"));
137 getByRole(root, "button", { name: "← Work" }).click();
138 assert.equal(dom.window.location.hash, "#work");
139 getByRole(root, "tab", { name: /^Races/ }).click();
140 wire = { ...wire, races: [] };
141 await app.refresh();
142 assert.ok(getByRole(root, "tab", { name: "Races" }));
143 assert.match(root.querySelector("#view-races")!.textContent!, /No races yet/);
144 assert.ok(calls.every((call) => call.method === "GET" && /\/(state|activity)$/.test(call.path)));
145 } finally {
146 app.dispose();
147 dom.window.close();
148 }
149 }
150});
151
152test("tab round trips keep exactly one copy of each Overview card (unique sibling keys)", async () => {
153 for (const owner of [true, false]) {
154 const dom = new JSDOM('<div id="dashboard"></div>', { url: "https://fixture.idiot.tax/#overview" });
155 installDom(dom.window);
156 const root = dom.window.document.getElementById("dashboard")! as HTMLElement;
157 const wire = racesFixtureState();
158 const transport = createHttpTransport({ origin: "https://fixture.idiot.tax", fetch: async (input) =>
159 Response.json(String(input).endsWith("/state") ? wire : { activity: [], nextAfter: 0 }) });
160 const app = mountDashboard(root, { brand, transport, initialProject: wire.project.id, owner, agent: owner ? undefined : "fixture-agent" });
161 const cards = () => [".fork-picture", '[aria-labelledby="needs-you-title"]', ".resume-card", ".project-card"]
162 .map((selector) => root.querySelectorAll(`#view-overview ${selector}`).length);
163 try {
164 await until(() => cards()[0] > 0);
165 const first = cards();
166 for (const tab of [/^Races/, /^Work/, /^Review/, /^Agents/]) {
167 getByRole(root, "tab", { name: tab }).click();
168 getByRole(root, "tab", { name: "Overview" }).click();
169 await new Promise((resolve) => setTimeout(resolve, 10));
170 assert.deepEqual(cards(), first, `${owner ? "owner" : "agent"} Overview after ${tab}`);
171 }
172 assert.equal(first[0], 1);
173 assert.equal(first[3], 1);
174 assert.ok(first.every((count) => count <= 1), `no Overview card repeats: ${first}`);
175 } finally {
176 app.dispose();
177 dom.window.close();
178 }
179 }
180});
181