539 lines
17 KiB
TypeScript
539 lines
17 KiB
TypeScript
import { describe, it, expect, beforeEach } from "vitest";
|
|
import { mount } from "@vue/test-utils";
|
|
import { createPinia, setActivePinia } from "pinia";
|
|
import RunnerCard from "~/components/Game/RunnerCard.vue";
|
|
import { useGameStore } from "~/store/game";
|
|
import type { LineupPlayerState } from "~/types/game";
|
|
|
|
describe("RunnerCard", () => {
|
|
let pinia: ReturnType<typeof createPinia>;
|
|
|
|
beforeEach(() => {
|
|
pinia = createPinia();
|
|
setActivePinia(pinia);
|
|
});
|
|
|
|
const mockRunner: LineupPlayerState = {
|
|
lineup_id: 1,
|
|
batting_order: 1,
|
|
position: "LF",
|
|
card_id: 101,
|
|
};
|
|
|
|
describe("empty base state", () => {
|
|
it("renders empty state when no runner provided", () => {
|
|
const wrapper = mount(RunnerCard, {
|
|
global: { plugins: [pinia] },
|
|
props: {
|
|
base: "1B",
|
|
runner: null,
|
|
isSelected: false,
|
|
teamColor: "#3b82f6",
|
|
},
|
|
});
|
|
|
|
expect(wrapper.find(".runner-pill.empty").exists()).toBe(true);
|
|
expect(wrapper.text()).toContain("Empty");
|
|
});
|
|
|
|
it("displays base label for empty base", () => {
|
|
const wrapper = mount(RunnerCard, {
|
|
global: { plugins: [pinia] },
|
|
props: {
|
|
base: "2B",
|
|
runner: null,
|
|
isSelected: false,
|
|
teamColor: "#3b82f6",
|
|
},
|
|
});
|
|
|
|
expect(wrapper.text()).toContain("2B");
|
|
});
|
|
|
|
it("shows hollow circle for empty base", () => {
|
|
const wrapper = mount(RunnerCard, {
|
|
global: { plugins: [pinia] },
|
|
props: {
|
|
base: "3B",
|
|
runner: null,
|
|
isSelected: false,
|
|
teamColor: "#3b82f6",
|
|
},
|
|
});
|
|
|
|
const circle = wrapper.find(".rounded-full.border-dashed");
|
|
expect(circle.exists()).toBe(true);
|
|
});
|
|
|
|
it("does not emit click event for empty base", async () => {
|
|
const wrapper = mount(RunnerCard, {
|
|
global: { plugins: [pinia] },
|
|
props: {
|
|
base: "1B",
|
|
runner: null,
|
|
isSelected: false,
|
|
teamColor: "#3b82f6",
|
|
},
|
|
});
|
|
|
|
await wrapper.trigger("click");
|
|
expect(wrapper.emitted("click")).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe("occupied base state", () => {
|
|
beforeEach(() => {
|
|
const gameStore = useGameStore();
|
|
// Set game state first so updateLineup knows the team ID
|
|
gameStore.setGameState({
|
|
id: 1,
|
|
home_team_id: 1,
|
|
away_team_id: 2,
|
|
status: "active",
|
|
inning: 1,
|
|
half: "top",
|
|
outs: 0,
|
|
home_score: 0,
|
|
away_score: 0,
|
|
home_team_abbrev: "NYY",
|
|
away_team_abbrev: "BOS",
|
|
home_team_dice_color: "3b82f6",
|
|
current_batter: null,
|
|
current_pitcher: null,
|
|
on_first: null,
|
|
on_second: null,
|
|
on_third: null,
|
|
decision_phase: "idle",
|
|
play_count: 0,
|
|
});
|
|
|
|
gameStore.updateLineup(1, [
|
|
{
|
|
id: 1,
|
|
lineup_id: 1,
|
|
team_id: 1,
|
|
batting_order: 1,
|
|
position: "LF",
|
|
is_active: true,
|
|
player: {
|
|
id: 101,
|
|
name: "Mike Trout",
|
|
image: "https://example.com/trout.jpg",
|
|
headshot: "https://example.com/trout-headshot.jpg",
|
|
},
|
|
},
|
|
]);
|
|
});
|
|
|
|
it("renders occupied state when runner provided", () => {
|
|
const wrapper = mount(RunnerCard, {
|
|
global: { plugins: [pinia] },
|
|
props: {
|
|
base: "1B",
|
|
runner: mockRunner,
|
|
isSelected: false,
|
|
teamColor: "#3b82f6",
|
|
},
|
|
});
|
|
|
|
expect(wrapper.find(".runner-pill.occupied").exists()).toBe(true);
|
|
expect(wrapper.find(".runner-pill.empty").exists()).toBe(false);
|
|
});
|
|
|
|
it("displays runner name", () => {
|
|
const wrapper = mount(RunnerCard, {
|
|
global: { plugins: [pinia] },
|
|
props: {
|
|
base: "1B",
|
|
runner: mockRunner,
|
|
isSelected: false,
|
|
teamColor: "#3b82f6",
|
|
},
|
|
});
|
|
|
|
expect(wrapper.text()).toContain("Mike Trout");
|
|
});
|
|
|
|
it("displays base label", () => {
|
|
const wrapper = mount(RunnerCard, {
|
|
global: { plugins: [pinia] },
|
|
props: {
|
|
base: "2B",
|
|
runner: mockRunner,
|
|
isSelected: false,
|
|
teamColor: "#3b82f6",
|
|
},
|
|
});
|
|
|
|
expect(wrapper.text()).toContain("2B");
|
|
});
|
|
|
|
it("displays player headshot when available", () => {
|
|
const wrapper = mount(RunnerCard, {
|
|
global: { plugins: [pinia] },
|
|
props: {
|
|
base: "1B",
|
|
runner: mockRunner,
|
|
isSelected: false,
|
|
teamColor: "#3b82f6",
|
|
},
|
|
});
|
|
|
|
const img = wrapper.find('img[alt="Mike Trout"]');
|
|
expect(img.exists()).toBe(true);
|
|
expect(img.attributes("src")).toBe(
|
|
"https://example.com/trout-headshot.jpg",
|
|
);
|
|
});
|
|
|
|
it("applies team color to border", () => {
|
|
const wrapper = mount(RunnerCard, {
|
|
global: { plugins: [pinia] },
|
|
props: {
|
|
base: "1B",
|
|
runner: mockRunner,
|
|
isSelected: false,
|
|
teamColor: "#ff0000",
|
|
},
|
|
});
|
|
|
|
const avatar = wrapper.find(".rounded-full.border-2");
|
|
expect(avatar.attributes("style")).toContain(
|
|
"border-color: #ff0000",
|
|
);
|
|
});
|
|
|
|
it("emits click event when clicked", async () => {
|
|
const wrapper = mount(RunnerCard, {
|
|
global: { plugins: [pinia] },
|
|
props: {
|
|
base: "1B",
|
|
runner: mockRunner,
|
|
isSelected: false,
|
|
teamColor: "#3b82f6",
|
|
},
|
|
});
|
|
|
|
await wrapper.trigger("click");
|
|
expect(wrapper.emitted("click")).toHaveLength(1);
|
|
});
|
|
});
|
|
|
|
describe("selected state", () => {
|
|
beforeEach(() => {
|
|
const gameStore = useGameStore();
|
|
gameStore.setGameState({
|
|
id: 1,
|
|
home_team_id: 1,
|
|
away_team_id: 2,
|
|
status: "active",
|
|
inning: 1,
|
|
half: "top",
|
|
outs: 0,
|
|
home_score: 0,
|
|
away_score: 0,
|
|
home_team_abbrev: "NYY",
|
|
away_team_abbrev: "BOS",
|
|
home_team_dice_color: "3b82f6",
|
|
current_batter: null,
|
|
current_pitcher: null,
|
|
on_first: null,
|
|
on_second: null,
|
|
on_third: null,
|
|
decision_phase: "idle",
|
|
play_count: 0,
|
|
});
|
|
gameStore.updateLineup(1, [
|
|
{
|
|
id: 1,
|
|
lineup_id: 1,
|
|
team_id: 1,
|
|
batting_order: 1,
|
|
position: "LF",
|
|
is_active: true,
|
|
player: {
|
|
id: 101,
|
|
name: "Mike Trout",
|
|
image: "https://example.com/trout-card.jpg",
|
|
headshot: "https://example.com/trout-headshot.jpg",
|
|
},
|
|
},
|
|
]);
|
|
});
|
|
|
|
it("does not apply selected class when not selected", () => {
|
|
const wrapper = mount(RunnerCard, {
|
|
global: { plugins: [pinia] },
|
|
props: {
|
|
base: "1B",
|
|
runner: mockRunner,
|
|
isSelected: false,
|
|
teamColor: "#3b82f6",
|
|
},
|
|
});
|
|
|
|
expect(wrapper.find(".runner-pill.selected").exists()).toBe(false);
|
|
});
|
|
|
|
it("applies selected class when isSelected is true", () => {
|
|
const wrapper = mount(RunnerCard, {
|
|
global: { plugins: [pinia] },
|
|
props: {
|
|
base: "1B",
|
|
runner: mockRunner,
|
|
isSelected: true,
|
|
teamColor: "#3b82f6",
|
|
},
|
|
});
|
|
|
|
expect(wrapper.find(".runner-pill.selected").exists()).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("player name handling", () => {
|
|
it('shows "Unknown Runner" when player not found in store', () => {
|
|
const wrapper = mount(RunnerCard, {
|
|
global: { plugins: [pinia] },
|
|
props: {
|
|
base: "1B",
|
|
runner: mockRunner,
|
|
isSelected: false,
|
|
teamColor: "#3b82f6",
|
|
},
|
|
});
|
|
|
|
expect(wrapper.text()).toContain("Unknown Runner");
|
|
});
|
|
});
|
|
|
|
describe("hold runner icon", () => {
|
|
it("does not show hold icon by default", () => {
|
|
/**
|
|
* When neither isHeld nor holdInteractive is set, the hold icon
|
|
* should not appear — keeps the pill clean for non-defensive contexts.
|
|
*/
|
|
const wrapper = mount(RunnerCard, {
|
|
global: { plugins: [pinia] },
|
|
props: {
|
|
base: "1B",
|
|
runner: mockRunner,
|
|
isSelected: false,
|
|
teamColor: "#3b82f6",
|
|
},
|
|
});
|
|
|
|
expect(wrapper.find(".hold-icon").exists()).toBe(false);
|
|
});
|
|
|
|
it("shows hold icon when holdInteractive is true", () => {
|
|
/**
|
|
* During the defensive decision phase, holdInteractive is true
|
|
* and the icon should appear even when the runner is not held.
|
|
*/
|
|
const wrapper = mount(RunnerCard, {
|
|
global: { plugins: [pinia] },
|
|
props: {
|
|
base: "1B",
|
|
runner: mockRunner,
|
|
isSelected: false,
|
|
teamColor: "#3b82f6",
|
|
holdInteractive: true,
|
|
isHeld: false,
|
|
},
|
|
});
|
|
|
|
expect(wrapper.find(".hold-icon").exists()).toBe(true);
|
|
});
|
|
|
|
it("shows hold icon when isHeld is true (read-only)", () => {
|
|
/**
|
|
* After submission, isHeld shows the current hold state as a
|
|
* non-interactive indicator even when holdInteractive is false.
|
|
*/
|
|
const wrapper = mount(RunnerCard, {
|
|
global: { plugins: [pinia] },
|
|
props: {
|
|
base: "1B",
|
|
runner: mockRunner,
|
|
isSelected: false,
|
|
teamColor: "#3b82f6",
|
|
isHeld: true,
|
|
holdInteractive: false,
|
|
},
|
|
});
|
|
|
|
const icon = wrapper.find(".hold-icon");
|
|
expect(icon.exists()).toBe(true);
|
|
expect(icon.attributes("disabled")).toBeDefined();
|
|
});
|
|
|
|
it("applies amber styling when held", () => {
|
|
const wrapper = mount(RunnerCard, {
|
|
global: { plugins: [pinia] },
|
|
props: {
|
|
base: "1B",
|
|
runner: mockRunner,
|
|
isSelected: false,
|
|
teamColor: "#3b82f6",
|
|
isHeld: true,
|
|
holdInteractive: true,
|
|
},
|
|
});
|
|
|
|
const icon = wrapper.find(".hold-icon");
|
|
expect(icon.classes()).toContain("bg-amber-500");
|
|
});
|
|
|
|
it("applies gray styling when not held", () => {
|
|
const wrapper = mount(RunnerCard, {
|
|
global: { plugins: [pinia] },
|
|
props: {
|
|
base: "1B",
|
|
runner: mockRunner,
|
|
isSelected: false,
|
|
teamColor: "#3b82f6",
|
|
isHeld: false,
|
|
holdInteractive: true,
|
|
},
|
|
});
|
|
|
|
const icon = wrapper.find(".hold-icon");
|
|
expect(icon.classes()).toContain("bg-gray-200");
|
|
});
|
|
|
|
it("emits toggleHold when clicked in interactive mode", async () => {
|
|
/**
|
|
* Clicking the hold icon should emit toggleHold so the parent
|
|
* can update the composable state.
|
|
*/
|
|
const wrapper = mount(RunnerCard, {
|
|
global: { plugins: [pinia] },
|
|
props: {
|
|
base: "1B",
|
|
runner: mockRunner,
|
|
isSelected: false,
|
|
teamColor: "#3b82f6",
|
|
isHeld: false,
|
|
holdInteractive: true,
|
|
},
|
|
});
|
|
|
|
await wrapper.find(".hold-icon").trigger("click");
|
|
expect(wrapper.emitted("toggleHold")).toHaveLength(1);
|
|
});
|
|
|
|
it("does not emit toggleHold when not interactive", async () => {
|
|
const wrapper = mount(RunnerCard, {
|
|
global: { plugins: [pinia] },
|
|
props: {
|
|
base: "1B",
|
|
runner: mockRunner,
|
|
isSelected: false,
|
|
teamColor: "#3b82f6",
|
|
isHeld: true,
|
|
holdInteractive: false,
|
|
},
|
|
});
|
|
|
|
await wrapper.find(".hold-icon").trigger("click");
|
|
expect(wrapper.emitted("toggleHold")).toBeUndefined();
|
|
});
|
|
|
|
it("does not emit click (selection) when hold icon is clicked", async () => {
|
|
/**
|
|
* The hold icon uses @click.stop so tapping it should NOT trigger
|
|
* the pill's selection behavior — only the hold toggle.
|
|
*/
|
|
const wrapper = mount(RunnerCard, {
|
|
global: { plugins: [pinia] },
|
|
props: {
|
|
base: "1B",
|
|
runner: mockRunner,
|
|
isSelected: false,
|
|
teamColor: "#3b82f6",
|
|
isHeld: false,
|
|
holdInteractive: true,
|
|
},
|
|
});
|
|
|
|
await wrapper.find(".hold-icon").trigger("click");
|
|
expect(wrapper.emitted("toggleHold")).toHaveLength(1);
|
|
expect(wrapper.emitted("click")).toBeUndefined();
|
|
});
|
|
|
|
it("applies held class to the pill when isHeld", () => {
|
|
const wrapper = mount(RunnerCard, {
|
|
global: { plugins: [pinia] },
|
|
props: {
|
|
base: "1B",
|
|
runner: mockRunner,
|
|
isSelected: false,
|
|
teamColor: "#3b82f6",
|
|
isHeld: true,
|
|
},
|
|
});
|
|
|
|
expect(wrapper.find(".runner-pill.held").exists()).toBe(true);
|
|
});
|
|
|
|
it("does not show hold icon on empty bases", () => {
|
|
const wrapper = mount(RunnerCard, {
|
|
global: { plugins: [pinia] },
|
|
props: {
|
|
base: "1B",
|
|
runner: null,
|
|
isSelected: false,
|
|
teamColor: "#3b82f6",
|
|
holdInteractive: true,
|
|
},
|
|
});
|
|
|
|
expect(wrapper.find(".hold-icon").exists()).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("base label variations", () => {
|
|
it("displays 1B correctly", () => {
|
|
const wrapper = mount(RunnerCard, {
|
|
global: { plugins: [pinia] },
|
|
props: {
|
|
base: "1B",
|
|
runner: null,
|
|
isSelected: false,
|
|
teamColor: "#3b82f6",
|
|
},
|
|
});
|
|
|
|
expect(wrapper.text()).toContain("1B");
|
|
});
|
|
|
|
it("displays 2B correctly", () => {
|
|
const wrapper = mount(RunnerCard, {
|
|
global: { plugins: [pinia] },
|
|
props: {
|
|
base: "2B",
|
|
runner: null,
|
|
isSelected: false,
|
|
teamColor: "#3b82f6",
|
|
},
|
|
});
|
|
|
|
expect(wrapper.text()).toContain("2B");
|
|
});
|
|
|
|
it("displays 3B correctly", () => {
|
|
const wrapper = mount(RunnerCard, {
|
|
global: { plugins: [pinia] },
|
|
props: {
|
|
base: "3B",
|
|
runner: null,
|
|
isSelected: false,
|
|
teamColor: "#3b82f6",
|
|
},
|
|
});
|
|
|
|
expect(wrapper.text()).toContain("3B");
|
|
});
|
|
});
|
|
});
|