Follow OS color scheme by default, with a three-way toggle (System / Light / Dark) in the kebab menu. Light theme uses warm, neutral tones with soft card-to-background contrast. Semantic colors (damage, healing, conditions) keep their hue across themes. Closes #10 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
165 lines
4.2 KiB
TypeScript
165 lines
4.2 KiB
TypeScript
// @vitest-environment jsdom
|
|
import "@testing-library/jest-dom/vitest";
|
|
|
|
import { combatantId } from "@initiative/domain";
|
|
import { cleanup, render, screen } from "@testing-library/react";
|
|
import userEvent from "@testing-library/user-event";
|
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
import { CombatantRow } from "../combatant-row";
|
|
import { PLAYER_COLOR_HEX } from "../player-icon-map";
|
|
|
|
afterEach(cleanup);
|
|
|
|
const defaultProps = {
|
|
onRename: vi.fn(),
|
|
onSetInitiative: vi.fn(),
|
|
onRemove: vi.fn(),
|
|
onSetHp: vi.fn(),
|
|
onAdjustHp: vi.fn(),
|
|
onSetAc: vi.fn(),
|
|
onToggleCondition: vi.fn(),
|
|
onToggleConcentration: vi.fn(),
|
|
};
|
|
|
|
function renderRow(
|
|
overrides: Partial<{
|
|
combatant: Parameters<typeof CombatantRow>[0]["combatant"];
|
|
isActive: boolean;
|
|
onRollInitiative: (id: ReturnType<typeof combatantId>) => void;
|
|
onRemove: (id: ReturnType<typeof combatantId>) => void;
|
|
onShowStatBlock: () => void;
|
|
}> = {},
|
|
) {
|
|
const combatant = overrides.combatant ?? {
|
|
id: combatantId("1"),
|
|
name: "Goblin",
|
|
initiative: 15,
|
|
maxHp: 10,
|
|
currentHp: 10,
|
|
ac: 13,
|
|
};
|
|
const props = {
|
|
...defaultProps,
|
|
combatant,
|
|
isActive: overrides.isActive ?? false,
|
|
onRollInitiative: overrides.onRollInitiative,
|
|
onShowStatBlock: overrides.onShowStatBlock,
|
|
onRemove: overrides.onRemove ?? defaultProps.onRemove,
|
|
};
|
|
return render(<CombatantRow {...props} />);
|
|
}
|
|
|
|
describe("CombatantRow", () => {
|
|
it("renders combatant name", () => {
|
|
renderRow();
|
|
expect(screen.getByText("Goblin")).toBeInTheDocument();
|
|
});
|
|
|
|
it("renders initiative value", () => {
|
|
renderRow();
|
|
expect(screen.getByText("15")).toBeInTheDocument();
|
|
});
|
|
|
|
it("renders current HP", () => {
|
|
renderRow({
|
|
combatant: {
|
|
id: combatantId("1"),
|
|
name: "Goblin",
|
|
maxHp: 10,
|
|
currentHp: 7,
|
|
},
|
|
});
|
|
expect(screen.getByText("7")).toBeInTheDocument();
|
|
});
|
|
|
|
it("active combatant gets active border styling", () => {
|
|
const { container } = renderRow({ isActive: true });
|
|
const row = container.firstElementChild;
|
|
expect(row?.className).toContain("border-active-row-border");
|
|
});
|
|
|
|
it("unconscious combatant (currentHp === 0) gets dimmed styling", () => {
|
|
renderRow({
|
|
combatant: {
|
|
id: combatantId("1"),
|
|
name: "Goblin",
|
|
maxHp: 10,
|
|
currentHp: 0,
|
|
},
|
|
});
|
|
// The name area should have opacity-50
|
|
const nameEl = screen.getByText("Goblin");
|
|
const nameContainer = nameEl.closest(".opacity-50");
|
|
expect(nameContainer).not.toBeNull();
|
|
});
|
|
|
|
it("shows '--' for current HP when no maxHp is set", () => {
|
|
renderRow({
|
|
combatant: {
|
|
id: combatantId("1"),
|
|
name: "Goblin",
|
|
},
|
|
});
|
|
expect(screen.getByLabelText("No HP set")).toBeInTheDocument();
|
|
});
|
|
|
|
it("shows concentration icon when isConcentrating is true", () => {
|
|
renderRow({
|
|
combatant: {
|
|
id: combatantId("1"),
|
|
name: "Goblin",
|
|
isConcentrating: true,
|
|
},
|
|
});
|
|
const concButton = screen.getByRole("button", {
|
|
name: "Toggle concentration",
|
|
});
|
|
expect(concButton.className).toContain("text-purple-400");
|
|
});
|
|
|
|
it("shows player character icon and color when set", () => {
|
|
const { container } = renderRow({
|
|
combatant: {
|
|
id: combatantId("1"),
|
|
name: "Aragorn",
|
|
color: "red",
|
|
icon: "sword",
|
|
},
|
|
});
|
|
// The icon should be rendered with the player color
|
|
const svgIcon = container.querySelector("svg[style]");
|
|
expect(svgIcon).not.toBeNull();
|
|
expect(svgIcon).toHaveStyle({ color: PLAYER_COLOR_HEX.red });
|
|
});
|
|
|
|
it("remove button calls onRemove after confirmation", async () => {
|
|
const user = userEvent.setup();
|
|
const onRemove = vi.fn();
|
|
renderRow({ onRemove });
|
|
const removeBtn = screen.getByRole("button", {
|
|
name: "Remove combatant",
|
|
});
|
|
// First click enters confirm state
|
|
await user.click(removeBtn);
|
|
// Second click confirms
|
|
const confirmBtn = screen.getByRole("button", {
|
|
name: "Confirm remove combatant",
|
|
});
|
|
await user.click(confirmBtn);
|
|
expect(onRemove).toHaveBeenCalledWith(combatantId("1"));
|
|
});
|
|
|
|
it("shows d20 roll button when initiative is undefined and onRollInitiative is provided", () => {
|
|
renderRow({
|
|
combatant: {
|
|
id: combatantId("1"),
|
|
name: "Goblin",
|
|
},
|
|
onRollInitiative: vi.fn(),
|
|
});
|
|
expect(
|
|
screen.getByRole("button", { name: "Roll initiative" }),
|
|
).toBeInTheDocument();
|
|
});
|
|
});
|