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
132 changes: 132 additions & 0 deletions src/__tests__/edge-badge-party-status-wiring.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
/**
* S2-BUG (playtest 2026-04-26) — wiring lock for ADR-014 / ADR-078 schema.
*
* Server emits PARTY_STATUS with `members[].current_hp` / `members[].max_hp`
* (legacy wire field names — the values are the EdgePool, not HP). App.tsx
* fans those into CharacterSummary.hp / hp_max, which the CharacterPanel
* renders as the Edge badge in the header AND as the inline party-row Edge.
*
* This wiring test drives that whole pipeline through the React tree so that
* a regression that re-introduces an "HP" label anywhere in the path is
* caught at the integration boundary, not just in the component unit tests.
*/
import { render, screen } from "@testing-library/react";
import { describe, it, expect } from "vitest";
import { CharacterPanel } from "@/components/CharacterPanel";
import type { CharacterSummary } from "@/types/party";
import type { CharacterSheetData } from "@/components/CharacterSheet";

// Mirror the App.tsx PARTY_STATUS handler shape (App.tsx:673-694) so that
// when this test wakes someone up two months from now, they can grep the
// app code and find the same field plumbing.
function partyStatusToSummary(
m: Record<string, unknown>,
): CharacterSummary {
return {
player_id: (m.player_id as string) ?? "",
name: (m.name as string) ?? "",
character_name:
(m.character_name as string) ?? (m.name as string) ?? "",
hp: (m.current_hp as number) ?? 0,
hp_max: (m.max_hp as number) ?? 0,
status_effects: (m.statuses as string[]) ?? [],
class: (m.class as string) ?? "",
level: (m.level as number) ?? 1,
portrait_url: (m.portrait_url as string) || undefined,
current_location: (m.current_location as string) ?? "",
};
}

function partyStatusToSheet(m: Record<string, unknown>): CharacterSheetData {
const sheet = (m.sheet as Record<string, unknown>) ?? {};
return {
name: (m.character_name as string) ?? (m.name as string) ?? "",
class: (m.class as string) ?? "",
race: (sheet.race as string) || undefined,
level: (m.level as number) ?? 1,
hp:
typeof m.current_hp === "number"
? (m.current_hp as number)
: undefined,
hp_max:
typeof m.max_hp === "number" ? (m.max_hp as number) : undefined,
stats: (sheet.stats as Record<string, number>) ?? {},
abilities: (sheet.abilities as string[]) ?? [],
backstory: (sheet.backstory as string) ?? "",
portrait_url: (m.portrait_url as string) || undefined,
current_location: (m.current_location as string) ?? "",
};
}

describe("CharacterPanel — PARTY_STATUS wiring (ADR-014 schema)", () => {
it("renders Edge label end-to-end from a synthetic PARTY_STATUS payload", () => {
// Synthetic PARTY_STATUS members payload as the server actually emits it
// (sidequest/server/session_handler.py:_build_session_start_party_status).
// current_hp / max_hp are pulled from character.core.edge.current/.max
// — see ADR-014 / ADR-078.
const members = [
{
player_id: "kael-pid",
name: "KeithPlayer",
character_name: "Kael",
class: "Ranger",
level: 3,
current_hp: 18,
max_hp: 30,
statuses: ["wary"],
sheet: {
stats: { dexterity: 18 },
abilities: ["Tracker"],
backstory: "Born in the Ashwood.",
race: "Wood Elf",
},
},
{
player_id: "lyra-pid",
name: "JamesPlayer",
character_name: "Lyra",
class: "Cleric",
level: 5,
current_hp: 7,
max_hp: 40,
statuses: [],
},
];

const characters = members.map(partyStatusToSummary);
const localSheet = partyStatusToSheet(members[0]);

render(
<CharacterPanel
character={localSheet}
characters={characters}
currentPlayerId="kael-pid"
/>,
);

// Header badge: "Edge", not "HP".
const badge = screen.getByTestId("character-edge-badge");
expect(badge).toHaveTextContent("Edge 18/30");
expect(screen.queryByTestId("character-hp-badge")).not.toBeInTheDocument();

// Inline party rows: "Edge N/M", not "HP N/M".
const kaelRow = screen.getByTestId("party-member-edge-kael-pid");
expect(kaelRow).toHaveTextContent("Edge 18/30");
const lyraRow = screen.getByTestId("party-member-edge-lyra-pid");
expect(lyraRow).toHaveTextContent("Edge 7/40");
// Lyra at 17.5% should hit the destructive threshold (≤25%).
expect(lyraRow.className).toMatch(/destructive/);

// Old testids must not exist anywhere — catches a half-rename.
expect(
screen.queryByTestId("party-member-hp-kael-pid"),
).not.toBeInTheDocument();
expect(
screen.queryByTestId("party-member-hp-lyra-pid"),
).not.toBeInTheDocument();

// No "HP N/M" text appears anywhere in the panel.
const panel = screen.getByTestId("character-panel");
expect(panel.textContent ?? "").not.toMatch(/\bHP\s*\d+\s*\/\s*\d+/);
});
});
43 changes: 25 additions & 18 deletions src/components/CharacterPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -126,13 +126,15 @@ export function CharacterPanel({
>
Lv {character.level}
</div>
{/* HP/Edge — load-bearing for Sebastien-axis players (mechanical
visibility). Server emits current/max on PARTY_STATUS members
as current_hp/max_hp; App.tsx fans them out into hp/hp_max on
CharacterSheetData. Hidden when both are absent (genres that
don't model HP) so we never render a fake "0/0". */}
{/* Edge badge — load-bearing for Sebastien-axis players (mechanical
visibility). ADR-014 / ADR-078: HP was removed from CreatureCore
in favor of EdgePool (composure currency). Server emits current/max
on PARTY_STATUS members as current_hp/max_hp (legacy wire field
names — protocol rename is a follow-up); App.tsx fans them out
into hp/hp_max on CharacterSheetData. Hidden when both are absent
(genres that don't model edge) so we never render a fake "0/0". */}
{typeof character.hp === "number" && typeof character.hp_max === "number" && (
<HpBadge current={character.hp} max={character.hp_max} />
<EdgeBadge current={character.hp} max={character.hp_max} />
)}
</div>
</div>
Expand Down Expand Up @@ -242,21 +244,24 @@ export function CharacterPanel({
</span>
<span className="block text-[10px] text-muted-foreground">
{toDisplayName(c.class)} Lv.{c.level}
{/* Inline HP for party rows so glance value matches the
CharacterPanel header. Skip when the genre doesn't
report HP at all (both 0 = uninitialized). */}
{/* Inline Edge for party rows so glance value matches the
CharacterPanel header. ADR-014 / ADR-078: HP was
removed in favor of EdgePool — wire field names
(hp/hp_max on CharacterSummary) are kept until a
protocol-level rename. Skip when the genre doesn't
report edge at all (both 0 = uninitialized). */}
{(c.hp_max > 0 || c.hp > 0) && (
<>
{" · "}
<span
data-testid={`party-member-hp-${c.player_id}`}
data-testid={`party-member-edge-${c.player_id}`}
className={
c.hp_max > 0 && c.hp / c.hp_max <= 0.25
? "text-destructive font-semibold"
: "text-foreground/80"
}
>
HP {c.hp}/{c.hp_max}
Edge {c.hp}/{c.hp_max}
</span>
</>
)}
Expand All @@ -282,23 +287,25 @@ export function CharacterPanel({
}

/**
* HP / Edge badge in the CharacterPanel header. Color shifts to destructive
* when the player drops to 1/4 max so a glance is enough to know "I'm in
* trouble". Same threshold rule as the inline party-row HP for consistency.
* Edge badge in the CharacterPanel header. ADR-014 / ADR-078: edge (composure)
* replaced the legacy HP field on CreatureCore — the badge now reflects the
* actual schema. Color shifts to destructive when the player drops to 1/4 max
* so a glance is enough to know "I'm one push from a yield". Same threshold
* rule as the inline party-row edge for consistency.
*/
function HpBadge({ current, max }: { current: number; max: number }) {
function EdgeBadge({ current, max }: { current: number; max: number }) {
const ratio = max > 0 ? current / max : 1;
const tone =
ratio <= 0.25
? "border-destructive/60 text-destructive"
: "border-[var(--primary)]/40 text-[var(--primary)]";
return (
<div
data-testid="character-hp-badge"
data-testid="character-edge-badge"
className={`px-2 py-0.5 rounded-md text-xs border font-mono ${tone}`}
aria-label={`Hit points ${current} of ${max}`}
aria-label={`Edge ${current} of ${max}`}
>
HP {current}/{max}
Edge {current}/{max}
</div>
);
}
Expand Down
12 changes: 8 additions & 4 deletions src/components/CharacterSheet.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,15 @@ export interface CharacterSheetData {
* is the rulebook, not part of character identity). */
race?: string;
level: number;
/** Current edge / hit points. Sourced from PARTY_STATUS members[].current_hp.
* Surfaced in the CharacterPanel header so Sebastien-axis (mechanical)
* players can see how close they are to going down. */
/** Current edge (composure). Sourced from PARTY_STATUS members[].current_hp.
* ADR-014 / ADR-078: HP was removed from CreatureCore in favor of EdgePool;
* the wire field is still named current_hp until the protocol-level rename
* ships, but the value is character.core.edge.current. Surfaced in the
* CharacterPanel header so Sebastien-axis (mechanical) players can see
* how close they are to a yield. */
hp?: number;
/** Maximum edge / hit points. Sourced from PARTY_STATUS members[].max_hp. */
/** Maximum edge (composure ceiling). Sourced from PARTY_STATUS
* members[].max_hp. See `hp` field doc for the legacy-name caveat. */
hp_max?: number;
stats: Record<string, number>;
abilities: string[];
Expand Down
71 changes: 52 additions & 19 deletions src/components/__tests__/CharacterPanel.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -378,28 +378,41 @@ describe("CharacterPanel — AC-6: integrated party list", () => {
expect(screen.queryByTestId("party-section")).not.toBeInTheDocument();
});

it("renders inline HP per party row sourced from CharacterSummary.hp/hp_max", () => {
it("renders inline Edge per party row sourced from CharacterSummary.hp/hp_max", () => {
// ADR-014 / ADR-078: HP was removed from CreatureCore in favor of EdgePool.
// The wire field on CharacterSummary is still hp/hp_max (legacy name) but
// the value is the edge pool, and the UI label must read "Edge".
render(<CharacterPanel character={CHARACTER} characters={PARTY} />);
// Kael at 24/30 — full opacity tone
const kaelHp = screen.getByTestId("party-member-hp-p1");
expect(kaelHp).toHaveTextContent("HP 24/30");
const kaelEdge = screen.getByTestId("party-member-edge-p1");
expect(kaelEdge).toHaveTextContent("Edge 24/30");
// Lyra at 8/40 = 20% — at or below the 25% threshold, should show
// destructive tone class for at-a-glance "in trouble" signaling.
const lyraHp = screen.getByTestId("party-member-hp-p2");
expect(lyraHp).toHaveTextContent("HP 8/40");
expect(lyraHp.className).toMatch(/destructive/);
// destructive tone class for at-a-glance "one push from yielding" signal.
const lyraEdge = screen.getByTestId("party-member-edge-p2");
expect(lyraEdge).toHaveTextContent("Edge 8/40");
expect(lyraEdge.className).toMatch(/destructive/);
});

it("hides inline HP for genres that don't model HP (both 0)", () => {
const NO_HP_PARTY = [
it("renders no legacy 'HP N/M' text in any party row (ADR-014 schema lock)", () => {
render(<CharacterPanel character={CHARACTER} characters={PARTY} />);
const partySection = screen.getByTestId("party-section");
// Lock the rename: a stray "HP " prefix on a number/number row would mean
// someone re-introduced the legacy label.
expect(partySection.textContent ?? "").not.toMatch(/\bHP\s*\d+\s*\/\s*\d+/);
});

it("hides inline Edge for genres that don't model edge (both 0)", () => {
const NO_EDGE_PARTY = [
{
...PARTY[0],
player_id: "p3",
hp: 0,
hp_max: 0,
},
];
render(<CharacterPanel character={CHARACTER} characters={NO_HP_PARTY} />);
render(<CharacterPanel character={CHARACTER} characters={NO_EDGE_PARTY} />);
expect(screen.queryByTestId("party-member-edge-p3")).not.toBeInTheDocument();
// Old testid must also be gone (catches a partial rename).
expect(screen.queryByTestId("party-member-hp-p3")).not.toBeInTheDocument();
});
});
Expand Down Expand Up @@ -542,34 +555,54 @@ describe("CharacterPanel — S2-UX: turn-state badges are legible and unambiguou
});

// ---------------------------------------------------------------------------
// HP badge in the header — Sebastien-axis (mechanical visibility)
// Edge badge in the header — Sebastien-axis (mechanical visibility)
//
// ADR-014 ("Diamonds and Coal") + ADR-078 ("Edge / Composure") removed the
// hit-points field from CreatureCore. The character-panel header used to
// render an "HP N/M" badge that pulled from edge.current under a misleading
// label; the rename below locks the badge to the actual schema.
// ---------------------------------------------------------------------------

describe("CharacterPanel — HP badge in header", () => {
it("renders HP badge when hp + hp_max are present", () => {
describe("CharacterPanel — Edge badge in header (ADR-014 schema)", () => {
it("renders Edge badge when hp + hp_max are present (legacy wire fields, edge value)", () => {
render(
<CharacterPanel
character={{ ...CHARACTER, hp: 18, hp_max: 30 }}
/>,
);
const badge = screen.getByTestId("character-hp-badge");
expect(badge).toHaveTextContent("HP 18/30");
expect(badge).toHaveAttribute("aria-label", "Hit points 18 of 30");
const badge = screen.getByTestId("character-edge-badge");
expect(badge).toHaveTextContent("Edge 18/30");
expect(badge).toHaveAttribute("aria-label", "Edge 18 of 30");
});

it("flags HP badge as destructive when current is at/below 25% of max", () => {
it("flags Edge badge as destructive when current is at/below 25% of max", () => {
render(
<CharacterPanel
character={{ ...CHARACTER, hp: 5, hp_max: 30 }}
/>,
);
const badge = screen.getByTestId("character-hp-badge");
const badge = screen.getByTestId("character-edge-badge");
expect(badge.className).toMatch(/destructive/);
});

it("does not render HP badge when hp/hp_max are absent (genres without HP)", () => {
it("does not render Edge badge when hp/hp_max are absent (genres without edge)", () => {
render(<CharacterPanel character={CHARACTER} />);
expect(screen.queryByTestId("character-edge-badge")).not.toBeInTheDocument();
});

it("never renders the legacy HP badge testid (rename completeness lock)", () => {
// Wiring test (per CLAUDE.md): even when edge data is present, the
// legacy `character-hp-badge` testid must not render — that's the
// signal a regression has reintroduced the old badge component.
render(
<CharacterPanel
character={{ ...CHARACTER, hp: 18, hp_max: 30 }}
/>,
);
expect(screen.queryByTestId("character-hp-badge")).not.toBeInTheDocument();
// And no "HP N/M" text in the panel header.
const header = screen.getByTestId("character-header");
expect(header.textContent ?? "").not.toMatch(/\bHP\s*\d+\s*\/\s*\d+/);
});
});

Expand Down
Loading