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
30 changes: 28 additions & 2 deletions src/components/CharacterPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -204,13 +204,39 @@ export function CharacterPanel({
{isSelf && (
<>
{" "}
<span className="ml-1 text-[10px] text-muted-foreground/50 font-normal">(YOU)</span>
<span
data-testid={`party-member-you-badge-${c.player_id}`}
className="ml-1 text-[11px] text-muted-foreground/60 font-normal"
>
(YOU)
</span>
</>
)}
{isActing && (
<>
{" "}
<span className="ml-1 text-[10px] text-primary font-semibold uppercase">(ACTING)</span>
<span
data-testid={`party-member-acting-badge-${c.player_id}`}
className="ml-1 inline-flex items-center gap-1 align-middle rounded-sm bg-primary/15 px-1.5 py-0.5 text-[11px] font-bold uppercase tracking-wider text-primary ring-1 ring-primary/40"
>
<span
data-testid={`party-member-acting-pulse-${c.player_id}`}
aria-hidden="true"
className="inline-block h-1.5 w-1.5 rounded-full bg-primary animate-pulse"
/>
ACTING
</span>
</>
)}
{isWaiting && (
<>
{" "}
<span
data-testid={`party-member-waiting-badge-${c.player_id}`}
className="ml-1 inline-block align-middle rounded-sm border border-muted-foreground/30 px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wider text-muted-foreground/70"
>
Waiting
</span>
</>
)}
</span>
Expand Down
33 changes: 19 additions & 14 deletions src/components/GameBoard/GameBoard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -272,7 +272,7 @@ export function GameBoard({
[audio, genreSlug],
);

const { chapterTitle } = useRunningHeader(messages);
const { chapterTitle } = useRunningHeader(messages, characters, currentPlayerId);

// Story 33-11: content signals drive the mobile tab notification badges.
// Each entry is a change-detection scalar for a tab's visible content —
Expand Down Expand Up @@ -606,23 +606,28 @@ export function GameBoard({
/>
</div>

{/* Turn status */}
{characters.length > 1 && activePlayerName && (
{/* Turn status — canonical multi-PC turn coordination signal.
Renders ONLY the structured TurnStatusPanel ("Waiting on:" widget)
which is load-bearing for sealed-letter chargen turns. The plain
`[ Paul's turn ]` / `[ Your turn ]` chip was removed as part of the
S2-UX banner-cluster dedupe (2026-04-26): the new
MultiplayerTurnBanner above the InputBar already announces whose
turn it is, the CharacterPanel party-section ACTING badge gives
the spatially-located cue, and the InputBar placeholder
("Waiting for X…") covers input gating. Keeping all four was
quadruple-banner ambiguity. The structured TurnStatusPanel branch
stays because it's a richer per-player roster, not a duplicate
of "whose turn it is". */}
{characters.length > 1 && activePlayerName && turnStatusEntries.length > 0 && (
<div
data-testid="turn-indicator"
className="px-4 py-1.5 text-xs text-center text-muted-foreground/60 border-t border-border/30 bg-card/20 shrink-0"
>
{turnStatusEntries.length > 0 ? (
<TurnStatusPanel
entries={turnStatusEntries}
localPlayerId={currentPlayerId}
gameMode="structured"
/>
) : (
waitingForPlayer
? `[ ${activePlayerName}'s turn ]`
: "[ Your turn ]"
)}
<TurnStatusPanel
entries={turnStatusEntries}
localPlayerId={currentPlayerId}
gameMode="structured"
/>
</div>
)}

Expand Down
162 changes: 162 additions & 0 deletions src/components/GameBoard/__tests__/runningHeader-wiring.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
/**
* Wiring test — verifies the running header location chip derives from
* PARTY_STATUS (per-PC `current_location`) rather than only from
* CHAPTER_MARKER messages.
*
* Per CLAUDE.md "Every Test Suite Needs a Wiring Test": this drives the chip
* through the actual top-level GameBoard component and asserts the chip text
* updates when the party-status snapshot moves to a new location, *without*
* any page refresh or remount. This guards the S2-UX (c) cache-invalidation
* regression where the chip showed a stale "BRIDGE — OUTER COYOTE REACH"
* even after the prose moved to Docking Crescent.
*
* Same file also asserts the S2-UX (d) banner-cluster dedupe — the redundant
* `[ Paul's turn ]` chip is no longer rendered when there are no per-player
* turn entries.
*/
import { render, screen } from "@testing-library/react";
import { describe, it, expect, vi, beforeAll, afterAll } from "vitest";
import { GameBoard, type GameBoardProps } from "../GameBoard";
import { ImageBusProvider } from "@/providers/ImageBusProvider";
import type { CharacterSummary } from "@/types/party";

const originalMatchMedia = window.matchMedia;

beforeAll(() => {
// Force desktop breakpoint so the desktop GameBoard layout (with the
// running-header div above the dockview workspace) renders. The default
// test-setup forces mobile, which routes GameBoard through MobileTabView
// and skips the top header entirely.
Object.defineProperty(window, "matchMedia", {
writable: true,
configurable: true,
value: (query: string) => ({
matches: query.includes("min-width: 1200px"),
media: query,
onchange: null,
addListener: () => {},
removeListener: () => {},
addEventListener: () => {},
removeEventListener: () => {},
dispatchEvent: () => false,
}),
});
});

afterAll(() => {
Object.defineProperty(window, "matchMedia", {
writable: true,
configurable: true,
value: originalMatchMedia,
});
});

function makeChar(player_id: string, current_location: string): CharacterSummary {
return {
player_id,
name: player_id,
character_name: player_id,
portrait_url: "",
hp: 10,
hp_max: 10,
status_effects: [],
class: "Pilot",
level: 1,
current_location,
};
}

function renderBoard(props: Partial<GameBoardProps>) {
const defaults: GameBoardProps = {
messages: [],
characters: [makeChar("p1", "Bridge")],
onSend: vi.fn(),
disabled: false,
currentPlayerId: "p1",
};
const merged = { ...defaults, ...props };
return render(
<ImageBusProvider messages={merged.messages}>
<GameBoard {...merged} />
</ImageBusProvider>,
);
}

describe("GameBoard running header — S2-UX (c) wiring", () => {
it("renders the running-header chip with the local player's current_location", () => {
renderBoard({
characters: [makeChar("p1", "Docking Crescent")],
currentPlayerId: "p1",
});
const header = screen.getByTestId("running-header");
expect(header).toHaveTextContent("Docking Crescent");
});

it("updates the chip when party state moves to a new location (no remount, no refresh)", () => {
const { rerender } = renderBoard({
characters: [makeChar("p1", "Bridge — Outer Coyote Reach")],
currentPlayerId: "p1",
});
expect(screen.getByTestId("running-header")).toHaveTextContent(
"Bridge — Outer Coyote Reach",
);

// PARTY_STATUS arrives with a new location for the local player. The chip
// must reflect it on the very next render, with no other state changes.
rerender(
<ImageBusProvider messages={[]}>
<GameBoard
messages={[]}
characters={[makeChar("p1", "Docking Crescent")]}
onSend={vi.fn()}
disabled={false}
currentPlayerId="p1"
/>
</ImageBusProvider>,
);
expect(screen.getByTestId("running-header")).toHaveTextContent(
"Docking Crescent",
);
// And the stale value is gone.
expect(screen.getByTestId("running-header")).not.toHaveTextContent(
"Bridge — Outer Coyote Reach",
);
});
});

describe("GameBoard turn indicator — S2-UX (d) banner-cluster dedupe", () => {
it("does NOT render the redundant `[ Paul's turn ]` chip when there are no per-player turn entries", () => {
// The bottom turn-indicator strip used to render the plain text
// "[ Paul's turn ]" / "[ Your turn ]" whenever activePlayerName was set.
// That signal was already carried by:
// - the MultiplayerTurnBanner above the InputBar,
// - the CharacterPanel party-section ACTING badge,
// - the InputBar placeholder ("Waiting for X…").
// Four banners saying the same thing was the bug. The strip is now
// reserved for the structured TurnStatusPanel ("Waiting on:" widget)
// and only renders when turnStatusEntries are present.
renderBoard({
characters: [makeChar("p1", "Bridge"), makeChar("p2", "Bridge")],
currentPlayerId: "p1",
activePlayerName: "p2",
activePlayerId: "p2",
waitingForPlayer: "p2",
turnStatusEntries: [],
});
expect(screen.queryByTestId("turn-indicator")).not.toBeInTheDocument();
});

it("structured TurnStatusPanel still renders when entries are present (chargen path preserved)", () => {
renderBoard({
characters: [makeChar("p1", "Bridge"), makeChar("p2", "Bridge")],
currentPlayerId: "p1",
activePlayerName: "p2",
activePlayerId: "p2",
turnStatusEntries: [
{ player_id: "p1", character_name: "Paul", status: "submitted" },
{ player_id: "p2", character_name: "John", status: "pending" },
],
});
expect(screen.getByTestId("turn-indicator")).toBeInTheDocument();
});
});
137 changes: 137 additions & 0 deletions src/components/__tests__/CharacterPanel.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -404,6 +404,143 @@ describe("CharacterPanel — AC-6: integrated party list", () => {
});
});

// ---------------------------------------------------------------------------
// S2-UX (a, b): YOU/ACTING badges legible + non-acting peers show Waiting
// ---------------------------------------------------------------------------

describe("CharacterPanel — S2-UX: turn-state badges are legible and unambiguous", () => {
const PARTY = [
{
player_id: "p1",
name: "Kael",
character_name: "Kael",
portrait_url: "/renders/kael.png",
hp: 24,
hp_max: 30,
status_effects: [],
class: "Ranger",
level: 3,
current_location: "The Rusty Cantina",
},
{
player_id: "p2",
name: "Lyra",
character_name: "Lyra Dawnforge",
portrait_url: "",
hp: 30,
hp_max: 40,
status_effects: [],
class: "Cleric",
level: 5,
current_location: "The Rusty Cantina",
},
{
player_id: "p3",
name: "Thane",
character_name: "Thane",
portrait_url: "",
hp: 15,
hp_max: 28,
status_effects: [],
class: "Fighter",
level: 4,
current_location: "The Rusty Cantina",
},
];

it("renders YOU badge on the local player party row", () => {
render(
<CharacterPanel
character={CHARACTER}
characters={PARTY}
currentPlayerId="p1"
/>,
);
const youBadge = screen.getByTestId("party-member-you-badge-p1");
expect(youBadge).toBeInTheDocument();
expect(youBadge).toHaveTextContent("(YOU)");
});

it("renders ACTING badge with a pulse dot on the active player row", () => {
render(
<CharacterPanel
character={CHARACTER}
characters={PARTY}
currentPlayerId="p1"
activePlayerId="p2"
/>,
);
const actingBadge = screen.getByTestId("party-member-acting-badge-p2");
expect(actingBadge).toBeInTheDocument();
expect(actingBadge).toHaveTextContent(/ACTING/);
// The pulse dot is the kinetic signal Alex needs to see at a glance
// that someone is mid-turn (per S2-UX (b)).
expect(within(actingBadge).getByTestId("party-member-acting-pulse-p2")).toBeInTheDocument();
});

it("ACTING pulse uses animate-pulse so it draws the eye", () => {
render(
<CharacterPanel
character={CHARACTER}
characters={PARTY}
currentPlayerId="p1"
activePlayerId="p2"
/>,
);
const pulse = screen.getByTestId("party-member-acting-pulse-p2");
expect(pulse.className).toMatch(/animate-pulse/);
});

it("ACTING badge is bumped from text-[10px] (the prior undersized class) to text-[11px]", () => {
// Regression guard for S2-UX (a) — badge was text-[10px], hard to spot.
render(
<CharacterPanel
character={CHARACTER}
characters={PARTY}
currentPlayerId="p1"
activePlayerId="p1"
/>,
);
const actingBadge = screen.getByTestId("party-member-acting-badge-p1");
expect(actingBadge.className).toMatch(/text-\[11px\]/);
});

it("renders Waiting indicator on every non-acting peer when a turn is in flight", () => {
render(
<CharacterPanel
character={CHARACTER}
characters={PARTY}
currentPlayerId="p1"
activePlayerId="p2"
/>,
);
// p2 is acting — no waiting badge on its row.
expect(
screen.queryByTestId("party-member-waiting-badge-p2"),
).not.toBeInTheDocument();
// p1 (local) and p3 (peer) are waiting — both rows get the badge so
// Alex can see at a glance "the table is waiting on Lyra, not me".
expect(screen.getByTestId("party-member-waiting-badge-p1")).toBeInTheDocument();
expect(screen.getByTestId("party-member-waiting-badge-p3")).toBeInTheDocument();
});

it("does NOT render any Waiting / ACTING badges when no player is acting", () => {
render(
<CharacterPanel
character={CHARACTER}
characters={PARTY}
currentPlayerId="p1"
activePlayerId={null}
/>,
);
expect(screen.queryByTestId("party-member-waiting-badge-p1")).not.toBeInTheDocument();
expect(screen.queryByTestId("party-member-waiting-badge-p2")).not.toBeInTheDocument();
expect(screen.queryByTestId("party-member-waiting-badge-p3")).not.toBeInTheDocument();
expect(screen.queryByTestId("party-member-acting-badge-p1")).not.toBeInTheDocument();
expect(screen.queryByTestId("party-member-acting-badge-p2")).not.toBeInTheDocument();
});
});

// ---------------------------------------------------------------------------
// HP badge in the header — Sebastien-axis (mechanical visibility)
// ---------------------------------------------------------------------------
Expand Down
Loading
Loading