Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
106 changes: 106 additions & 0 deletions src/screens/__tests__/past-journeys-mode-icon-wiring.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import { render, screen } from "@testing-library/react";
import { describe, it, expect, beforeEach, vi } from "vitest";
import { MemoryRouter } from "react-router-dom";
import { ConnectScreen } from "@/screens/ConnectScreen";
import { appendHistory } from "@/screens/lobby/historyStore";
import type { GenresResponse } from "@/types/genres";

/**
* Wiring test for the [BUG-LOW] Past Journeys mode-icon fix
* (sq-playtest-pingpong.md, 2026-04-26).
*
* Per CLAUDE.md "Every Test Suite Needs a Wiring Test" — the unit tests
* in `lobby/__tests__/JourneyHistory.test.tsx` prove the icon renders
* when JourneyHistory is mounted directly. That's not enough: we also
* need to prove the lobby (`ConnectScreen`) actually mounts JourneyHistory
* AND that the mode badge survives the full production render path
* (provider chain, prop wiring, conditional rendering).
*
* If someone deletes `<JourneyHistory />` from ConnectScreen or stops
* passing the entry's mode field through, this test fails.
*/
const GENRES: GenresResponse = {
victoria: {
name: "Victorian London",
description: "Gaslight and intrigue.",
worlds: [
{
slug: "albion",
name: "Albion",
description: "Foggy streets.",
era: null,
setting: null,
inspirations: [],
axis_snapshot: {},
hero_image: null,
},
],
},
};

describe("past-journeys mode icon — lobby wiring", () => {
beforeEach(() => {
localStorage.clear();
// ConnectScreen polls /api/sessions on mount via useSessions.
globalThis.fetch = vi.fn().mockImplementation((url: string) => {
if (typeof url === "string" && url.startsWith("/api/sessions")) {
return Promise.resolve({
ok: true,
json: () => Promise.resolve({ sessions: [] }),
});
}
return Promise.resolve({ ok: true, json: () => Promise.resolve({}) });
}) as unknown as typeof fetch;
});

it("renders solo, multiplayer, and unknown-mode icons in the lobby past-journeys list", () => {
// Seed three entries — one of each mode flavor — exactly the way the
// production code seeds them via appendHistory after a successful start.
appendHistory({
player_name: "SoloRider",
genre: "victoria",
world: "albion",
mode: "solo",
});
appendHistory({
player_name: "MPRider",
genre: "victoria",
world: "albion",
mode: "multiplayer",
});
appendHistory({
player_name: "LegacyRider",
genre: "victoria",
world: "albion",
// No `mode` — pre-2026-04-24 entry shape.
});

render(
<MemoryRouter>
<ConnectScreen genres={GENRES} />
</MemoryRouter>,
);

// The lobby must surface all three rows with distinguishable mode badges.
// Locate each row by its player-name text, then assert its row contains
// a mode-tagged span. data-mode is the contract; the literal glyph is
// an implementation detail re-asserted below for the visual spec.
const soloRow = screen.getByText("SoloRider").closest("button")!;
const mpRow = screen.getByText("MPRider").closest("button")!;
const legacyRow = screen.getByText("LegacyRider").closest("button")!;

const soloBadge = soloRow.querySelector('[data-mode="solo"]');
const mpBadge = mpRow.querySelector('[data-mode="multiplayer"]');
const legacyBadge = legacyRow.querySelector('[data-mode="unknown"]');

expect(soloBadge).not.toBeNull();
expect(mpBadge).not.toBeNull();
expect(legacyBadge).not.toBeNull();

// Visual spec — locks the chosen glyphs to the playtest report's spec
// (◈ solo, ⚑ MP) plus the legacy ◇ choice documented in modeBadge().
expect(soloBadge!.textContent).toBe("◈");
expect(mpBadge!.textContent).toBe("⚑");
expect(legacyBadge!.textContent).toBe("◇");
});
});
15 changes: 15 additions & 0 deletions src/screens/lobby/JourneyHistory.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
formatRelativeTime,
type JourneyEntry,
} from "./historyStore";
import { modeBadge } from "./modeBadge";

export interface JourneyHistoryProps {
/**
Expand Down Expand Up @@ -70,6 +71,12 @@ export function JourneyHistory({
{entries.map((entry) => {
const genreName = prettyGenre(entry.genre);
const worldName = prettyWorld(entry.genre, entry.world);
// Mode icon: helps Alex (slow reader, MP context) see at-a-glance
// whether a row is a solo or multiplayer save before clicking. Pre
// 2026-04-24 entries lack `mode` — render a hollow diamond rather
// than guessing, so an unknown row visually differs from a known
// solo/MP row instead of silently looking like one.
const { glyph: modeGlyph, label: modeLabel } = modeBadge(entry.mode);
return (
<li key={`${entry.player_name}:${entry.genre}:${entry.world}`}>
<button
Expand All @@ -83,6 +90,14 @@ export function JourneyHistory({
focus-visible:outline-none focus-visible:bg-muted/20"
>
<span className="flex items-baseline gap-2 text-sm">
<span
aria-label={modeLabel}
title={modeLabel}
data-mode={entry.mode ?? "unknown"}
className="inline-block w-3 text-center text-muted-foreground/70 tabular-nums"
>
{modeGlyph}
</span>
<span className="text-foreground/85">{entry.player_name}</span>
<span className="text-muted-foreground/60">·</span>
<span className="italic text-foreground/70">
Expand Down
71 changes: 71 additions & 0 deletions src/screens/lobby/__tests__/JourneyHistory.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, it, expect, beforeEach, vi } from "vitest";
import { JourneyHistory } from "@/screens/lobby/JourneyHistory";
import { modeBadge } from "@/screens/lobby/modeBadge";
import { appendHistory, loadHistory } from "@/screens/lobby/historyStore";

describe("JourneyHistory", () => {
Expand Down Expand Up @@ -130,6 +131,76 @@ describe("JourneyHistory", () => {
expect(onSelect).not.toHaveBeenCalled();
});

it("renders a mode icon per row (solo / multiplayer / unknown legacy)", () => {
appendHistory({
player_name: "SoloKid",
genre: "victoria",
world: "albion",
mode: "solo",
});
appendHistory({
player_name: "MPKid",
genre: "victoria",
world: "albion",
mode: "multiplayer",
});
appendHistory({
player_name: "LegacyKid",
genre: "victoria",
world: "albion",
// No mode — simulates a pre-2026-04-24 entry.
});

render(
<JourneyHistory
onSelect={vi.fn()}
prettyGenre={prettyGenre}
prettyWorld={prettyWorld}
/>,
);

// Each row carries a mode-tagged span. Use data-mode to avoid coupling
// to the literal glyph (so we can swap ◈/⚑/◇ later without breaking).
const soloBadge = screen
.getByText("SoloKid")
.closest("button")!
.querySelector('[data-mode="solo"]');
const mpBadge = screen
.getByText("MPKid")
.closest("button")!
.querySelector('[data-mode="multiplayer"]');
const legacyBadge = screen
.getByText("LegacyKid")
.closest("button")!
.querySelector('[data-mode="unknown"]');

expect(soloBadge).not.toBeNull();
expect(mpBadge).not.toBeNull();
expect(legacyBadge).not.toBeNull();
expect(soloBadge!.textContent).toBe("◈");
expect(mpBadge!.textContent).toBe("⚑");
expect(legacyBadge!.textContent).toBe("◇");
// aria-label gives screen-reader users the same signal Alex gets visually.
expect(soloBadge!.getAttribute("aria-label")).toMatch(/solo/i);
expect(mpBadge!.getAttribute("aria-label")).toMatch(/multiplayer/i);
expect(legacyBadge!.getAttribute("aria-label")).toMatch(/unknown/i);
});

it("modeBadge maps each mode value to its glyph + label", () => {
expect(modeBadge("solo")).toEqual({
glyph: "◈",
label: "solo session",
});
expect(modeBadge("multiplayer")).toEqual({
glyph: "⚑",
label: "multiplayer session",
});
expect(modeBadge(undefined)).toEqual({
glyph: "◇",
label: "unknown mode (legacy entry)",
});
});

it("hides itself after the last entry is removed", async () => {
const user = userEvent.setup();
appendHistory({
Expand Down
22 changes: 22 additions & 0 deletions src/screens/lobby/modeBadge.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
/**
* Map a journey-history entry's `mode` field to a glyph + accessible label.
*
* Lives in its own file (rather than co-located with `JourneyHistory.tsx`)
* so the React Fast Refresh boundary stays component-only — the
* `react-refresh/only-export-components` lint rule rejects mixing helper
* exports with component exports.
*
* Old entries (pre 2026-04-24) lack the `mode` field — those render as a
* hollow diamond labeled "unknown mode" rather than defaulting to solo,
* because silently displaying a solo icon for what might be an MP save
* would recreate the very misclick risk this fix addresses.
*/
export function modeBadge(mode: "solo" | "multiplayer" | undefined): {
glyph: string;
label: string;
} {
if (mode === "solo") return { glyph: "◈", label: "solo session" };
if (mode === "multiplayer")
return { glyph: "⚑", label: "multiplayer session" };
return { glyph: "◇", label: "unknown mode (legacy entry)" };
}
Loading