diff --git a/src/__tests__/chargen-stats-grid-wiring.test.tsx b/src/__tests__/chargen-stats-grid-wiring.test.tsx new file mode 100644 index 0000000..dff3509 --- /dev/null +++ b/src/__tests__/chargen-stats-grid-wiring.test.tsx @@ -0,0 +1,219 @@ +/** + * Wiring test (per CLAUDE.md "Every Test Suite Needs a Wiring Test"): + * proves the new stats grid renders when a `CHARACTER_CREATION` confirmation + * scene arrives through the *real* WebSocket → App → CharacterCreation + * pipeline, not just when the component is rendered in isolation. + * + * Modeled on `lobby-start-ws-open.test.tsx` (the current canonical pattern + * for App + WebSocket integration tests, using `jest-websocket-mock`). + * + * Bug provenance: playtest 2026-04-26 Mawdeep MP — Alex/Sebastien-axis + * scannability finding. See `/Users/slabgorb/Projects/sq-playtest-pingpong.md` + * `[UX] Stats line on the chargen sheet is dense horizontal text`. + */ +import { StrictMode } from "react"; +import { render, screen, act, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { MemoryRouter } from "react-router-dom"; +import { WS } from "jest-websocket-mock"; +import { + installWebAudioMock, + installLocalStorageMock, +} from "@/audio/__tests__/web-audio-mock"; +import { AudioEngine } from "@/audio/AudioEngine"; +import App from "../App"; + +const LOBBY_STORAGE_KEY = "sidequest-connect"; + +const GENRES_RESPONSE = { + low_fantasy: { + name: "Low Fantasy", + description: "Gritty medieval adventures.", + worlds: [ + { + slug: "greyhawk", + name: "Greyhawk", + description: "The Flanaess.", + era: null, + setting: null, + inspirations: [], + axis_snapshot: {}, + hero_image: null, + }, + ], + }, +}; + +const SLUG = "2026-04-26-stats-grid-wiring"; +const GAME_META = { + genre_slug: "low_fantasy", + world_slug: "greyhawk", + mode: "solo", +}; + +function makeFetchMock() { + return vi.fn().mockImplementation((url: string, opts?: RequestInit) => { + if ( + typeof url === "string" && + /\/api\/games\/[^?]+/.test(url) && + (!opts || opts.method !== "POST") + ) { + return Promise.resolve( + new Response(JSON.stringify(GAME_META), { + status: 200, + headers: { "Content-Type": "application/json" }, + }), + ); + } + if (typeof url === "string" && url === "/api/games" && opts?.method === "POST") { + return Promise.resolve( + new Response(JSON.stringify({ slug: SLUG, mode: "solo" }), { + status: 201, + headers: { "Content-Type": "application/json" }, + }), + ); + } + if (typeof url === "string" && url.startsWith("/api/sessions")) { + return Promise.resolve( + new Response(JSON.stringify({ sessions: [] }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }), + ); + } + if (typeof url === "string" && url.includes("/api/genres")) { + return Promise.resolve( + new Response(JSON.stringify(GENRES_RESPONSE), { + status: 200, + headers: { "Content-Type": "application/json" }, + }), + ); + } + return Promise.resolve(new Response(JSON.stringify([]), { status: 200 })); + }); +} + +beforeEach(() => { + AudioEngine.resetInstance(); + installWebAudioMock(); + installLocalStorageMock(); + vi.stubGlobal("fetch", makeFetchMock()); +}); + +afterEach(() => { + WS.clean(); + AudioEngine.resetInstance(); + vi.unstubAllGlobals(); + localStorage.clear(); + document.documentElement.removeAttribute("data-archetype"); +}); + +describe("chargen stats-grid wiring (App → WS → CharacterCreation confirmation)", () => { + it("renders the stats grid when a CHARACTER_CREATION confirmation arrives over the live socket", async () => { + // Pre-seed the lobby so the Start button is one-click reachable. + localStorage.setItem( + LOBBY_STORAGE_KEY, + JSON.stringify({ + playerName: "Ralph", + genre: "low_fantasy", + world: "greyhawk", + }), + ); + localStorage.setItem("sq:display-name", "Ralph"); + + const wsUrl = `ws://${location.host}/ws`; + const server = new WS(wsUrl, { jsonProtocol: true }); + + const user = userEvent.setup(); + + render( + + + + + , + ); + + const startBtn = await screen.findByTestId("lobby-start-button"); + await user.click(startBtn); + + // Wait for AppInner to send SESSION_EVENT{connect, game_slug:...}. + await server.connected; + const connectMsg = (await server.nextMessage) as { + type: string; + payload: Record; + }; + expect(connectMsg.type).toBe("SESSION_EVENT"); + expect(connectMsg.payload.event).toBe("connect"); + + // Server tells client this is a brand-new player so chargen mounts. + act(() => { + server.send({ + type: "SESSION_EVENT", + payload: { + event: "connected", + player_name: "Ralph", + has_character: false, + }, + }); + }); + + // Drive the chargen confirmation scene directly. The unit suite + // (`CharacterCreation.stats-grid.test.tsx`) covers intermediate steps; + // this test's job is to prove the *wiring*: that a real + // CHARACTER_CREATION{phase:"confirmation"} frame routes through + // App → CharacterCreation and produces the grid in the live DOM. + act(() => { + server.send({ + type: "CHARACTER_CREATION", + payload: { + phase: "confirmation", + scene_index: 3, + total_scenes: 4, + input_type: "confirm", + message: "Confirm your character?", + character_preview: { + Name: "Ralph", + Race: "Beastkin", + Class: "Delver", + // Verbatim shape from chargen_summary.py ~line 193. + Stats: "STR 10 DEX 7 CON 12 INT 17 WIS 5 CHA 11", + }, + }, + }); + }); + + // Wiring assertion: the grid is reachable from the live App tree. + const grid = await screen.findByTestId("review-stats-grid"); + expect(grid).toBeInTheDocument(); + expect(grid.className).toContain("grid-cols-3"); + + // All six stat cells present with the right testids. + const cellTestIds = [ + "review-stat-STR", + "review-stat-DEX", + "review-stat-CON", + "review-stat-INT", + "review-stat-WIS", + "review-stat-CHA", + ]; + for (const tid of cellTestIds) { + expect(within(grid).getByTestId(tid)).toBeInTheDocument(); + } + + // Each cell exposes label + value as a definition-list pair so the + // contract is screen-reader-meaningful, not just visually grouped. + expect(within(grid).getAllByRole("term").map((n) => n.textContent)).toEqual( + ["STR", "DEX", "CON", "INT", "WIS", "CHA"], + ); + expect( + within(grid).getAllByRole("definition").map((n) => n.textContent), + ).toEqual(["10", "7", "12", "17", "5", "11"]); + + // The dense one-liner that motivated the bug must NOT be on screen. + expect( + screen.queryByText(/STR 10\s+DEX 7\s+CON 12\s+INT 17\s+WIS 5\s+CHA 11/), + ).not.toBeInTheDocument(); + }); +}); diff --git a/src/components/CharacterCreation/CharacterCreation.tsx b/src/components/CharacterCreation/CharacterCreation.tsx index 5031f94..42ba9f6 100644 --- a/src/components/CharacterCreation/CharacterCreation.tsx +++ b/src/components/CharacterCreation/CharacterCreation.tsx @@ -1,6 +1,7 @@ import { useState } from "react"; import { Pencil } from "lucide-react"; import { toRoman } from "@/lib/utils"; +import { parseStatLine } from "./parseStatLine"; interface CreationChoice { label: string; @@ -129,27 +130,61 @@ export function CharacterCreation({ scene, loading, onRespond }: CharacterCreati
Character Sheet
{previewEntries.length > 0 ? ( - previewEntries.map(([key, value], index) => ( -
-
- {key} -

{String(value)}

-
- -
- )) +
+ {key} + {parsedStats ? ( +
+ {parsedStats.map(([statName, statValue]) => ( +
+
+ {statName} +
+
+ {statValue} +
+
+ ))} +
+ ) : ( +

{String(value)}

+ )} +
+ +
+ ); + }) ) : (
{scene.summary}
)} diff --git a/src/components/CharacterCreation/__tests__/CharacterCreation.stats-grid.test.tsx b/src/components/CharacterCreation/__tests__/CharacterCreation.stats-grid.test.tsx new file mode 100644 index 0000000..dfb8fb0 --- /dev/null +++ b/src/components/CharacterCreation/__tests__/CharacterCreation.stats-grid.test.tsx @@ -0,0 +1,200 @@ +import { describe, expect, it } from "vitest"; +import { render, screen, within } from "@testing-library/react"; +import { CharacterCreation } from "../CharacterCreation"; +import { parseStatLine } from "../parseStatLine"; + +/** + * Bug context (playtest 2026-04-26 Mawdeep MP, screenshot 003-p1-ralph-sheet): + * The "Your Character" review surface rendered the stats row as a single + * dense horizontal line — `STR 10 DEX 7 CON 12 INT 17 WIS 5 CHA 11` — + * because the server emits stats as a flat string in `character_preview.stats`. + * + * Per CLAUDE.md playgroup notes: Alex (slow reader) loses this at-a-glance + * and Sebastien (mechanics-first) needs stats scannable. These tests pin + * the 3-col label-above-value mini-grid presentation so a future refactor + * cannot silently regress to the dense one-liner. Pure presentational — + * the data shape on the wire is unchanged. + */ + +describe("CharacterCreation confirmation preview — stats grid", () => { + // ------------------------------------------------------------------- + // parseStatLine — pure helper boundary + // ------------------------------------------------------------------- + describe("parseStatLine helper", () => { + it("parses the canonical D&D-style six-stat line into ordered pairs", () => { + const result = parseStatLine("STR 10 DEX 7 CON 12 INT 17 WIS 5 CHA 11"); + expect(result).toEqual([ + ["STR", "10"], + ["DEX", "7"], + ["CON", "12"], + ["INT", "17"], + ["WIS", "5"], + ["CHA", "11"], + ]); + }); + + it("tolerates single-space separators (server has joined with double-space, but never trust the wire)", () => { + const result = parseStatLine("STR 10 DEX 7 CON 12 INT 17 WIS 5 CHA 11"); + expect(result).toEqual([ + ["STR", "10"], + ["DEX", "7"], + ["CON", "12"], + ["INT", "17"], + ["WIS", "5"], + ["CHA", "11"], + ]); + }); + + it("accepts negative stat values (low-fantasy / mutant_wasteland penalties)", () => { + const result = parseStatLine("STR 8 DEX -1 CON 12 INT 14"); + expect(result).toEqual([ + ["STR", "8"], + ["DEX", "-1"], + ["CON", "12"], + ["INT", "14"], + ]); + }); + + it("returns null for non-string values so non-stat rows fall through", () => { + expect(parseStatLine(undefined)).toBeNull(); + expect(parseStatLine(null)).toBeNull(); + expect(parseStatLine(42)).toBeNull(); + expect(parseStatLine({ STR: 10 })).toBeNull(); + }); + + it("returns null for prose values that should NOT be reformatted", () => { + // A backstory or freeform string must render as plain text — the + // detector cannot accidentally chop a sentence into "stat tokens". + expect(parseStatLine("Beastkin")).toBeNull(); + expect(parseStatLine("Returned from the Colonies")).toBeNull(); + expect(parseStatLine("Brooding")).toBeNull(); + expect(parseStatLine("STR 10 because I rolled well")).toBeNull(); + // Odd token count cannot be a stat line. + expect(parseStatLine("STR 10 DEX")).toBeNull(); + }); + }); + + // ------------------------------------------------------------------- + // Grid render — the actual UI assertion + // ------------------------------------------------------------------- + it("renders the stats row as a 3-col label-above-value mini-grid (not a horizontal line)", () => { + render( + {}} + />, + ); + + // The grid container exists with the expected testid. + const grid = screen.getByTestId("review-stats-grid"); + expect(grid).toBeInTheDocument(); + + // It is a 3-col Tailwind grid (CSS class assertion — pinning the + // visual contract so a refactor that drops `grid-cols-3` fails the + // test rather than silently shipping a single-column stack). + expect(grid.className).toContain("grid-cols-3"); + + // Every D&D-style stat appears as its own cell with a definition-list + // shape (label =
, value =
), and the values are present. + const labels = within(grid).getAllByRole("term").map((n) => n.textContent); + const values = within(grid) + .getAllByRole("definition") + .map((n) => n.textContent); + expect(labels).toEqual(["STR", "DEX", "CON", "INT", "WIS", "CHA"]); + expect(values).toEqual(["10", "7", "12", "17", "5", "11"]); + + // The dense one-liner that lives in the wire data MUST NOT appear + // verbatim on screen. (`getAllByText` would throw if the substring + // matched any element — `queryByText` returns null on miss.) + expect( + screen.queryByText(/STR 10\s+DEX 7\s+CON 12/), + ).not.toBeInTheDocument(); + + // The Stats row's Edit button is still wired — the grid swap must + // not break the existing affordance from Bug #1's fix. + expect(screen.getByLabelText(/Edit Stats/i)).toBeInTheDocument(); + }); + + it("falls back to plain text for non-stat rows in the same preview", () => { + render( + {}} + />, + ); + + // Race row stays a regular value — no accidental grid re-format. + expect(screen.getByTestId("review-section-Race")).toHaveTextContent( + "Beastkin", + ); + expect( + within(screen.getByTestId("review-section-Race")).queryByTestId( + "review-stats-grid", + ), + ).not.toBeInTheDocument(); + + // Stats row IS a grid. + expect( + within(screen.getByTestId("review-section-Stats")).getByTestId( + "review-stats-grid", + ), + ).toBeInTheDocument(); + }); + + it("works under a non-default key name (genre packs label this 'Attributes' / 'Vitals' etc.)", () => { + // The detector keys off the *value shape*, not the literal "Stats" key, + // so a future genre pack that names the field differently still gets + // the scannable layout. + render( + {}} + />, + ); + + const grid = within(screen.getByTestId("review-section-Vitals")).getByTestId( + "review-stats-grid", + ); + expect(grid).toBeInTheDocument(); + expect(within(grid).getAllByRole("definition").map((n) => n.textContent)) + .toEqual(["9", "12", "11", "16"]); + }); +}); diff --git a/src/components/CharacterCreation/parseStatLine.ts b/src/components/CharacterCreation/parseStatLine.ts new file mode 100644 index 0000000..4173f79 --- /dev/null +++ b/src/components/CharacterCreation/parseStatLine.ts @@ -0,0 +1,30 @@ +/** + * Detect the stat-line shape the server emits for `character_preview.stats` + * (see `chargen_summary.py` ~line 193 — `" ".join(f"{name} {val}" for ...)`) + * and split it into (label, value) pairs. Returns `null` for any other + * value so non-stat rows fall through to the default plain-text render. + * + * Conservative on purpose: only values whose every token pair matches + * `<2-4 uppercase letters> ` are reformatted. Prose like + * "Returned from the Colonies" or class names like "Beastkin" stay text. + * + * Lives in its own module (instead of next to `CharacterCreation`) because + * react-refresh requires component files to export only components — and + * the unit test for this helper imports it directly. + */ +export function parseStatLine(value: unknown): Array<[string, string]> | null { + if (typeof value !== "string") return null; + const trimmed = value.trim(); + if (!trimmed) return null; + const tokens = trimmed.split(/\s+/); + if (tokens.length < 4 || tokens.length % 2 !== 0) return null; + const pairs: Array<[string, string]> = []; + for (let i = 0; i < tokens.length; i += 2) { + const label = tokens[i]; + const num = tokens[i + 1]; + if (!/^[A-Z]{2,4}$/.test(label)) return null; + if (!/^-?\d+$/.test(num)) return null; + pairs.push([label, num]); + } + return pairs; +}