Persistent player character templates (name, AC, HP, color, icon) with full CRUD, bestiary-style search to add PCs to encounters with pre-filled stats, and color/icon visual distinction in combatant rows. Also stops the stat block panel from auto-opening when adding a creature. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
228 lines
4.4 KiB
TypeScript
228 lines
4.4 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import { createPlayerCharacter } from "../create-player-character.js";
|
|
import type { PlayerCharacter } from "../player-character-types.js";
|
|
import { playerCharacterId } from "../player-character-types.js";
|
|
import { isDomainError } from "../types.js";
|
|
|
|
const id = playerCharacterId("pc-1");
|
|
|
|
function success(
|
|
characters: readonly PlayerCharacter[],
|
|
name: string,
|
|
ac: number,
|
|
maxHp: number,
|
|
color = "blue",
|
|
icon = "sword",
|
|
) {
|
|
const result = createPlayerCharacter(
|
|
characters,
|
|
id,
|
|
name,
|
|
ac,
|
|
maxHp,
|
|
color,
|
|
icon,
|
|
);
|
|
if (isDomainError(result)) {
|
|
throw new Error(`Expected success, got error: ${result.message}`);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
describe("createPlayerCharacter", () => {
|
|
it("creates a valid player character", () => {
|
|
const { characters, events } = success(
|
|
[],
|
|
"Aragorn",
|
|
16,
|
|
120,
|
|
"green",
|
|
"shield",
|
|
);
|
|
|
|
expect(characters).toHaveLength(1);
|
|
expect(characters[0]).toEqual({
|
|
id,
|
|
name: "Aragorn",
|
|
ac: 16,
|
|
maxHp: 120,
|
|
color: "green",
|
|
icon: "shield",
|
|
});
|
|
expect(events).toEqual([
|
|
{
|
|
type: "PlayerCharacterCreated",
|
|
playerCharacterId: id,
|
|
name: "Aragorn",
|
|
},
|
|
]);
|
|
});
|
|
|
|
it("trims whitespace from name", () => {
|
|
const { characters } = success([], " Gandalf ", 12, 80);
|
|
expect(characters[0].name).toBe("Gandalf");
|
|
});
|
|
|
|
it("appends to existing characters", () => {
|
|
const existing: PlayerCharacter = {
|
|
id: playerCharacterId("pc-0"),
|
|
name: "Legolas",
|
|
ac: 14,
|
|
maxHp: 90,
|
|
color: "green",
|
|
icon: "eye",
|
|
};
|
|
const { characters } = success([existing], "Gimli", 18, 100, "red", "axe");
|
|
expect(characters).toHaveLength(2);
|
|
expect(characters[0]).toEqual(existing);
|
|
expect(characters[1].name).toBe("Gimli");
|
|
});
|
|
|
|
it("rejects empty name", () => {
|
|
const result = createPlayerCharacter([], id, "", 10, 50, "blue", "sword");
|
|
expect(isDomainError(result)).toBe(true);
|
|
if (isDomainError(result)) {
|
|
expect(result.code).toBe("invalid-name");
|
|
}
|
|
});
|
|
|
|
it("rejects whitespace-only name", () => {
|
|
const result = createPlayerCharacter(
|
|
[],
|
|
id,
|
|
" ",
|
|
10,
|
|
50,
|
|
"blue",
|
|
"sword",
|
|
);
|
|
expect(isDomainError(result)).toBe(true);
|
|
if (isDomainError(result)) {
|
|
expect(result.code).toBe("invalid-name");
|
|
}
|
|
});
|
|
|
|
it("rejects negative AC", () => {
|
|
const result = createPlayerCharacter(
|
|
[],
|
|
id,
|
|
"Test",
|
|
-1,
|
|
50,
|
|
"blue",
|
|
"sword",
|
|
);
|
|
expect(isDomainError(result)).toBe(true);
|
|
if (isDomainError(result)) {
|
|
expect(result.code).toBe("invalid-ac");
|
|
}
|
|
});
|
|
|
|
it("rejects non-integer AC", () => {
|
|
const result = createPlayerCharacter(
|
|
[],
|
|
id,
|
|
"Test",
|
|
10.5,
|
|
50,
|
|
"blue",
|
|
"sword",
|
|
);
|
|
expect(isDomainError(result)).toBe(true);
|
|
if (isDomainError(result)) {
|
|
expect(result.code).toBe("invalid-ac");
|
|
}
|
|
});
|
|
|
|
it("allows AC of 0", () => {
|
|
const { characters } = success([], "Test", 0, 50);
|
|
expect(characters[0].ac).toBe(0);
|
|
});
|
|
|
|
it("rejects maxHp of 0", () => {
|
|
const result = createPlayerCharacter(
|
|
[],
|
|
id,
|
|
"Test",
|
|
10,
|
|
0,
|
|
"blue",
|
|
"sword",
|
|
);
|
|
expect(isDomainError(result)).toBe(true);
|
|
if (isDomainError(result)) {
|
|
expect(result.code).toBe("invalid-max-hp");
|
|
}
|
|
});
|
|
|
|
it("rejects negative maxHp", () => {
|
|
const result = createPlayerCharacter(
|
|
[],
|
|
id,
|
|
"Test",
|
|
10,
|
|
-5,
|
|
"blue",
|
|
"sword",
|
|
);
|
|
expect(isDomainError(result)).toBe(true);
|
|
if (isDomainError(result)) {
|
|
expect(result.code).toBe("invalid-max-hp");
|
|
}
|
|
});
|
|
|
|
it("rejects non-integer maxHp", () => {
|
|
const result = createPlayerCharacter(
|
|
[],
|
|
id,
|
|
"Test",
|
|
10,
|
|
50.5,
|
|
"blue",
|
|
"sword",
|
|
);
|
|
expect(isDomainError(result)).toBe(true);
|
|
if (isDomainError(result)) {
|
|
expect(result.code).toBe("invalid-max-hp");
|
|
}
|
|
});
|
|
|
|
it("rejects invalid color", () => {
|
|
const result = createPlayerCharacter(
|
|
[],
|
|
id,
|
|
"Test",
|
|
10,
|
|
50,
|
|
"neon",
|
|
"sword",
|
|
);
|
|
expect(isDomainError(result)).toBe(true);
|
|
if (isDomainError(result)) {
|
|
expect(result.code).toBe("invalid-color");
|
|
}
|
|
});
|
|
|
|
it("rejects invalid icon", () => {
|
|
const result = createPlayerCharacter(
|
|
[],
|
|
id,
|
|
"Test",
|
|
10,
|
|
50,
|
|
"blue",
|
|
"banana",
|
|
);
|
|
expect(isDomainError(result)).toBe(true);
|
|
if (isDomainError(result)) {
|
|
expect(result.code).toBe("invalid-icon");
|
|
}
|
|
});
|
|
|
|
it("emits exactly one event on success", () => {
|
|
const { events } = success([], "Test", 10, 50);
|
|
expect(events).toHaveLength(1);
|
|
expect(events[0].type).toBe("PlayerCharacterCreated");
|
|
});
|
|
});
|