Redesign top bar with dedicated round badge and centered combatant name
Replace the combined "Round N — Name" string with a three-zone flex layout:
left (prev button + R{n} pill badge), center (prominent combatant name with
truncation), right (action buttons + next button). Adds 13 unit tests
covering all user stories including layout robustness and empty state.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
219
apps/web/src/components/__tests__/turn-navigation.test.tsx
Normal file
219
apps/web/src/components/__tests__/turn-navigation.test.tsx
Normal file
@@ -0,0 +1,219 @@
|
||||
// @vitest-environment jsdom
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
|
||||
import type { Encounter } from "@initiative/domain";
|
||||
import { combatantId } from "@initiative/domain";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { TurnNavigation } from "../turn-navigation";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
function renderNav(overrides: Partial<Encounter> = {}) {
|
||||
const encounter: Encounter = {
|
||||
combatants: [
|
||||
{ id: combatantId("1"), name: "Goblin" },
|
||||
{ id: combatantId("2"), name: "Conjurer" },
|
||||
],
|
||||
activeIndex: 0,
|
||||
roundNumber: 1,
|
||||
...overrides,
|
||||
};
|
||||
|
||||
return render(
|
||||
<TurnNavigation
|
||||
encounter={encounter}
|
||||
onAdvanceTurn={vi.fn()}
|
||||
onRetreatTurn={vi.fn()}
|
||||
onClearEncounter={vi.fn()}
|
||||
onRollAllInitiative={vi.fn()}
|
||||
onOpenSourceManager={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("TurnNavigation", () => {
|
||||
describe("US1: Round badge and combatant name", () => {
|
||||
it("renders the round badge with correct round number", () => {
|
||||
renderNav({ roundNumber: 3 });
|
||||
expect(screen.getByText("R3")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders the combatant name separately from the round badge", () => {
|
||||
renderNav();
|
||||
const badge = screen.getByText("R1");
|
||||
const name = screen.getByText("Goblin");
|
||||
expect(badge).toBeInTheDocument();
|
||||
expect(name).toBeInTheDocument();
|
||||
expect(badge).not.toBe(name);
|
||||
expect(badge.closest("[class]")).not.toBe(name.closest("[class]"));
|
||||
});
|
||||
|
||||
it("does not render an em dash between round and name", () => {
|
||||
const { container } = renderNav();
|
||||
expect(container.textContent).not.toContain("—");
|
||||
});
|
||||
|
||||
it("round badge and combatant name are in separate DOM elements", () => {
|
||||
renderNav();
|
||||
const badge = screen.getByText("R1");
|
||||
const name = screen.getByText("Goblin");
|
||||
expect(badge.parentElement).not.toBe(name.parentElement);
|
||||
});
|
||||
|
||||
it("updates the round badge when round changes", () => {
|
||||
const { rerender } = render(
|
||||
<TurnNavigation
|
||||
encounter={{
|
||||
combatants: [{ id: combatantId("1"), name: "Goblin" }],
|
||||
activeIndex: 0,
|
||||
roundNumber: 2,
|
||||
}}
|
||||
onAdvanceTurn={vi.fn()}
|
||||
onRetreatTurn={vi.fn()}
|
||||
onClearEncounter={vi.fn()}
|
||||
onRollAllInitiative={vi.fn()}
|
||||
onOpenSourceManager={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("R2")).toBeInTheDocument();
|
||||
|
||||
rerender(
|
||||
<TurnNavigation
|
||||
encounter={{
|
||||
combatants: [{ id: combatantId("1"), name: "Goblin" }],
|
||||
activeIndex: 0,
|
||||
roundNumber: 3,
|
||||
}}
|
||||
onAdvanceTurn={vi.fn()}
|
||||
onRetreatTurn={vi.fn()}
|
||||
onClearEncounter={vi.fn()}
|
||||
onRollAllInitiative={vi.fn()}
|
||||
onOpenSourceManager={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("R3")).toBeInTheDocument();
|
||||
expect(screen.queryByText("R2")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders the next combatant name when turn advances", () => {
|
||||
const { rerender } = render(
|
||||
<TurnNavigation
|
||||
encounter={{
|
||||
combatants: [
|
||||
{ id: combatantId("1"), name: "Goblin" },
|
||||
{ id: combatantId("2"), name: "Conjurer" },
|
||||
],
|
||||
activeIndex: 0,
|
||||
roundNumber: 1,
|
||||
}}
|
||||
onAdvanceTurn={vi.fn()}
|
||||
onRetreatTurn={vi.fn()}
|
||||
onClearEncounter={vi.fn()}
|
||||
onRollAllInitiative={vi.fn()}
|
||||
onOpenSourceManager={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("Goblin")).toBeInTheDocument();
|
||||
|
||||
rerender(
|
||||
<TurnNavigation
|
||||
encounter={{
|
||||
combatants: [
|
||||
{ id: combatantId("1"), name: "Goblin" },
|
||||
{ id: combatantId("2"), name: "Conjurer" },
|
||||
],
|
||||
activeIndex: 1,
|
||||
roundNumber: 1,
|
||||
}}
|
||||
onAdvanceTurn={vi.fn()}
|
||||
onRetreatTurn={vi.fn()}
|
||||
onClearEncounter={vi.fn()}
|
||||
onRollAllInitiative={vi.fn()}
|
||||
onOpenSourceManager={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("Conjurer")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("US2: Layout robustness", () => {
|
||||
it("applies truncation styles to long combatant names", () => {
|
||||
const longName =
|
||||
"Ancient Red Dragon Wyrm of the Northern Wastes and Beyond";
|
||||
renderNav({
|
||||
combatants: [{ id: combatantId("1"), name: longName }],
|
||||
});
|
||||
const nameEl = screen.getByText(longName);
|
||||
expect(nameEl.className).toContain("truncate");
|
||||
});
|
||||
|
||||
it("renders three-zone layout with a single-character name", () => {
|
||||
renderNav({
|
||||
combatants: [{ id: combatantId("1"), name: "O" }],
|
||||
});
|
||||
expect(screen.getByText("R1")).toBeInTheDocument();
|
||||
expect(screen.getByText("O")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Previous turn" }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Next turn" }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps all action buttons accessible regardless of name length", () => {
|
||||
const longName = "A".repeat(60);
|
||||
renderNav({
|
||||
combatants: [{ id: combatantId("1"), name: longName }],
|
||||
});
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Previous turn" }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Next turn" }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("button", {
|
||||
name: "Roll all initiative",
|
||||
}),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("button", {
|
||||
name: "Manage cached sources",
|
||||
}),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders a 40-character name without truncation class issues", () => {
|
||||
const name40 = "A".repeat(40);
|
||||
renderNav({
|
||||
combatants: [{ id: combatantId("1"), name: name40 }],
|
||||
});
|
||||
const nameEl = screen.getByText(name40);
|
||||
expect(nameEl).toBeInTheDocument();
|
||||
// The truncate class is applied but CSS only visually truncates if content overflows
|
||||
expect(nameEl.className).toContain("truncate");
|
||||
});
|
||||
});
|
||||
|
||||
describe("US3: No combatants state", () => {
|
||||
it("shows the round badge when there are no combatants", () => {
|
||||
renderNav({ combatants: [], roundNumber: 1 });
|
||||
expect(screen.getByText("R1")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows 'No combatants' placeholder text", () => {
|
||||
renderNav({ combatants: [] });
|
||||
expect(screen.getByText("No combatants")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("disables navigation buttons when there are no combatants", () => {
|
||||
renderNav({ combatants: [] });
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Previous turn" }),
|
||||
).toBeDisabled();
|
||||
expect(screen.getByRole("button", { name: "Next turn" })).toBeDisabled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -26,34 +26,35 @@ export function TurnNavigation({
|
||||
const activeCombatant = encounter.combatants[encounter.activeIndex];
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between rounded-md border border-border bg-card px-4 py-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-foreground border-foreground hover:text-hover-action hover:border-hover-action hover:bg-transparent"
|
||||
onClick={onRetreatTurn}
|
||||
disabled={!hasCombatants || isAtStart}
|
||||
title="Previous turn"
|
||||
aria-label="Previous turn"
|
||||
>
|
||||
<StepBack className="h-5 w-5" />
|
||||
</Button>
|
||||
<div className="flex items-center gap-3 rounded-md border border-border bg-card px-4 py-3">
|
||||
<div className="flex flex-shrink-0 items-center gap-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-foreground border-foreground hover:text-hover-action hover:border-hover-action hover:bg-transparent"
|
||||
onClick={onRetreatTurn}
|
||||
disabled={!hasCombatants || isAtStart}
|
||||
title="Previous turn"
|
||||
aria-label="Previous turn"
|
||||
>
|
||||
<StepBack className="h-5 w-5" />
|
||||
</Button>
|
||||
<span className="rounded-full bg-muted text-foreground text-sm px-2 py-0.5 font-semibold">
|
||||
R{encounter.roundNumber}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="text-center text-sm">
|
||||
<div className="min-w-0 flex-1 text-center text-sm">
|
||||
{activeCombatant ? (
|
||||
<>
|
||||
<span className="font-medium">Round {encounter.roundNumber}</span>
|
||||
<span className="text-muted-foreground">
|
||||
{" "}
|
||||
— {activeCombatant.name}
|
||||
</span>
|
||||
</>
|
||||
<span className="truncate block font-medium">
|
||||
{activeCombatant.name}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground">No combatants</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex flex-shrink-0 items-center gap-3">
|
||||
<div className="flex items-center gap-0">
|
||||
<Button
|
||||
variant="ghost"
|
||||
|
||||
Reference in New Issue
Block a user