feat(dm): add dashboard with oauth, dice, initiative, open5e, notes

This commit is contained in:
emil
2026-05-14 23:48:36 +03:00
parent 7efe188a9a
commit 1a092e05c1
144 changed files with 8643 additions and 35 deletions
+102
View File
@@ -0,0 +1,102 @@
import { describe, it, expect } from 'vitest';
import {
getAuthPanelState,
getAuthLinks,
type AuthUser,
} from '../src/lib/client/auth-panel';
describe('AuthPanel', () => {
describe('getAuthPanelState', () => {
it('returns guest state when user is null', () => {
const state = getAuthPanelState(null);
expect(state.type).toBe('guest');
});
it('returns guest state when user is undefined', () => {
const state = getAuthPanelState(undefined as unknown as null);
expect(state.type).toBe('guest');
});
it('returns authenticated state when user is present', () => {
const user: AuthUser = { name: 'Test User', avatar: 'https://example.com/avatar.png' };
const state = getAuthPanelState(user);
expect(state.type).toBe('authenticated');
if (state.type === 'authenticated') {
expect(state.user.name).toBe('Test User');
expect(state.user.avatar).toBe('https://example.com/avatar.png');
}
});
it('handles user with null avatar', () => {
const user: AuthUser = { name: 'No Avatar', avatar: null };
const state = getAuthPanelState(user);
expect(state.type).toBe('authenticated');
if (state.type === 'authenticated') {
expect(state.user.avatar).toBeNull();
}
});
});
describe('getAuthLinks', () => {
it('returns VK and Yandex login links for guest', () => {
const state = getAuthPanelState(null);
const links = getAuthLinks(state);
expect(links).toHaveLength(2);
expect(links[0]).toEqual({
href: '/api/auth/login/vk',
label: 'Войти через VK',
style: 'vk',
});
expect(links[1]).toEqual({
href: '/api/auth/login/yandex',
label: 'Войти через Яндекс',
style: 'yandex',
});
});
it('returns logout link for authenticated user', () => {
const user: AuthUser = { name: 'Test', avatar: null };
const state = getAuthPanelState(user);
const links = getAuthLinks(state);
expect(links).toHaveLength(1);
expect(links[0]).toEqual({
href: '/api/auth/logout',
label: 'Выйти',
style: 'logout',
});
});
});
describe('AuthPanel rendering logic', () => {
it('guest view should show login buttons', () => {
const state = getAuthPanelState(null);
expect(state.type).toBe('guest');
const links = getAuthLinks(state);
const vkLink = links.find((l) => l.style === 'vk');
const yandexLink = links.find((l) => l.style === 'yandex');
expect(vkLink).toBeDefined();
expect(yandexLink).toBeDefined();
expect(vkLink!.label).toBe('Войти через VK');
expect(yandexLink!.label).toBe('Войти через Яндекс');
});
it('authenticated view should show user info', () => {
const user: AuthUser = { name: 'Test User', avatar: 'https://example.com/avatar.png' };
const state = getAuthPanelState(user);
expect(state.type).toBe('authenticated');
if (state.type === 'authenticated') {
expect(state.user.name).toBe('Test User');
expect(state.user.avatar).toBe('https://example.com/avatar.png');
}
});
it('logout action clears session via /api/auth/logout', () => {
const user: AuthUser = { name: 'Test', avatar: null };
const state = getAuthPanelState(user);
const links = getAuthLinks(state);
const logoutLink = links.find((l) => l.style === 'logout');
expect(logoutLink).toBeDefined();
expect(logoutLink!.href).toBe('/api/auth/logout');
});
});
});
+107
View File
@@ -0,0 +1,107 @@
import { describe, it, expect } from "vitest";
import { parseDiceNotation, rollDice } from "../src/lib/client/dice";
describe("Dice Roller", () => {
describe("parseDiceNotation", () => {
it("parses d20 notation", () => {
expect(parseDiceNotation("d20")).toEqual({
count: 1,
sides: 20,
modifier: 0,
});
});
it("parses 2d6+3 notation", () => {
expect(parseDiceNotation("2d6+3")).toEqual({
count: 2,
sides: 6,
modifier: 3,
});
});
it("parses 3d8-1 notation", () => {
expect(parseDiceNotation("3d8-1")).toEqual({
count: 3,
sides: 8,
modifier: -1,
});
});
it("parses 1d4 notation", () => {
expect(parseDiceNotation("1d4")).toEqual({
count: 1,
sides: 4,
modifier: 0,
});
});
it("parses 5d100+10 notation", () => {
expect(parseDiceNotation("5d100+10")).toEqual({
count: 5,
sides: 100,
modifier: 10,
});
});
it("parses notation with spaces", () => {
expect(parseDiceNotation(" 2d6 + 3 ")).toEqual({
count: 2,
sides: 6,
modifier: 3,
});
});
it("throws on invalid notation", () => {
expect(() => parseDiceNotation("abc")).toThrow();
expect(() => parseDiceNotation("")).toThrow();
});
});
describe("rollDice", () => {
it("rolls within valid range for 1d20", () => {
const result = rollDice(1, 20, 0);
expect(result.total).toBeGreaterThanOrEqual(1);
expect(result.total).toBeLessThanOrEqual(20);
expect(result.rolls).toHaveLength(1);
expect(result.rolls[0]).toBeGreaterThanOrEqual(1);
expect(result.rolls[0]).toBeLessThanOrEqual(20);
});
it("rolls 2d6 within 2-12 range", () => {
const result = rollDice(2, 6, 0);
expect(result.total).toBeGreaterThanOrEqual(2);
expect(result.total).toBeLessThanOrEqual(12);
expect(result.rolls).toHaveLength(2);
});
it("applies positive modifier", () => {
const result = rollDice(1, 6, 5);
expect(result.total).toBeGreaterThanOrEqual(6);
expect(result.total).toBeLessThanOrEqual(11);
expect(result.modifier).toBe(5);
});
it("applies negative modifier", () => {
const result = rollDice(1, 6, -2);
expect(result.total).toBeGreaterThanOrEqual(-1);
expect(result.total).toBeLessThanOrEqual(4);
expect(result.modifier).toBe(-2);
});
it("returns correct structure", () => {
const result = rollDice(3, 8, 1);
expect(result).toHaveProperty("rolls");
expect(result).toHaveProperty("total");
expect(result).toHaveProperty("modifier");
expect(Array.isArray(result.rolls)).toBe(true);
expect(typeof result.total).toBe("number");
expect(typeof result.modifier).toBe("number");
});
it("sum of rolls plus modifier equals total", () => {
const result = rollDice(4, 10, 3);
const sum = result.rolls.reduce((a, b) => a + b, 0);
expect(result.total).toBe(sum + result.modifier);
});
});
});
+35
View File
@@ -0,0 +1,35 @@
import { describe, it, expect } from "vitest";
describe("Infrastructure", () => {
it("vitest works", () => {
expect(true).toBe(true);
});
it("can resolve @/* aliases in tests", async () => {
const schema = await import("@/lib/generator-schema");
expect(schema).toBeDefined();
expect(schema.generatorSchema).toBeDefined();
});
it("happy-dom provides a DOM environment", () => {
expect(typeof document).toBe("object");
expect(typeof window).toBe("object");
});
it("crypto.getRandomValues is available", () => {
const arr = new Uint8Array(4);
crypto.getRandomValues(arr);
expect(arr.length).toBe(4);
});
});
describe("Test Utils", () => {
it("mock helpers are importable", async () => {
const utils = await import("./utils");
expect(typeof utils.mockAstroLocals).toBe("function");
expect(typeof utils.setupMockFetch).toBe("function");
expect(typeof utils.createMockStorage).toBe("function");
expect(typeof utils.mockJwtToken).toBe("function");
expect(typeof utils.waitFor).toBe("function");
});
});
+83
View File
@@ -0,0 +1,83 @@
import { describe, it, expect } from "vitest";
import {
sortByInitiative,
getNextActiveIndex,
rollInitiative,
type Combatant,
} from "../src/lib/client/initiative";
describe("sortByInitiative", () => {
it("sorts combatants by initiative descending", () => {
const combatants: Combatant[] = [
{ id: "1", name: "Goblin", initiative: 15 },
{ id: "2", name: "Warrior", initiative: 18 },
{ id: "3", name: "Mage", initiative: 12 },
];
const sorted = sortByInitiative(combatants);
expect(sorted[0].name).toBe("Warrior");
expect(sorted[1].name).toBe("Goblin");
expect(sorted[2].name).toBe("Mage");
});
it("places higher initiative first when values differ", () => {
const combatants: Combatant[] = [
{ id: "1", name: "A", initiative: 5 },
{ id: "2", name: "B", initiative: 20 },
];
expect(sortByInitiative(combatants)[0].name).toBe("B");
});
it("returns empty array for empty input", () => {
expect(sortByInitiative([])).toEqual([]);
});
it("does not mutate the original array", () => {
const combatants: Combatant[] = [
{ id: "1", name: "Goblin", initiative: 15 },
{ id: "2", name: "Warrior", initiative: 18 },
];
const originalOrder = combatants.map((c) => c.name);
sortByInitiative(combatants);
expect(combatants.map((c) => c.name)).toEqual(originalOrder);
});
});
describe("getNextActiveIndex", () => {
it("cycles to next active combatant", () => {
expect(getNextActiveIndex(0, 3)).toBe(1);
expect(getNextActiveIndex(1, 3)).toBe(2);
expect(getNextActiveIndex(2, 3)).toBe(0);
});
it("wraps from last to first", () => {
expect(getNextActiveIndex(2, 3)).toBe(0);
});
it("returns 0 when list has one combatant", () => {
expect(getNextActiveIndex(0, 1)).toBe(0);
});
it("returns 0 for empty list", () => {
expect(getNextActiveIndex(0, 0)).toBe(0);
});
});
describe("rollInitiative", () => {
it("returns a number between 1 and 20 plus modifier", () => {
const result = rollInitiative(0);
expect(result).toBeGreaterThanOrEqual(1);
expect(result).toBeLessThanOrEqual(20);
});
it("applies positive modifier", () => {
const result = rollInitiative(5);
expect(result).toBeGreaterThanOrEqual(6);
expect(result).toBeLessThanOrEqual(25);
});
it("applies negative modifier", () => {
const result = rollInitiative(-2);
expect(result).toBeGreaterThanOrEqual(-1);
expect(result).toBeLessThanOrEqual(18);
});
});
+29
View File
@@ -0,0 +1,29 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { saveNotes, loadNotes, clearNotes } from '../src/lib/client/notes';
describe('Notes', () => {
beforeEach(() => {
localStorage.clear();
});
it('saves notes to localStorage', () => {
saveNotes('Test note');
expect(loadNotes()).toBe('Test note');
});
it('loads empty string when no notes saved', () => {
expect(loadNotes()).toBe('');
});
it('clears notes', () => {
saveNotes('Test');
clearNotes();
expect(loadNotes()).toBe('');
});
it('overwrites existing notes', () => {
saveNotes('First');
saveNotes('Second');
expect(loadNotes()).toBe('Second');
});
});
+327
View File
@@ -0,0 +1,327 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import {
searchMonsters,
getMonster,
searchSpells,
getSpell,
} from "../src/lib/open5e/client";
import { createMockStorage } from "./utils";
const API_BASE = "https://api.open5e.com/v2";
describe("Open5e Client", () => {
let mockStorage: ReturnType<typeof createMockStorage>;
beforeEach(() => {
mockStorage = createMockStorage();
Object.defineProperty(globalThis, "localStorage", {
value: mockStorage,
writable: true,
});
vi.clearAllMocks();
});
afterEach(() => {
vi.restoreAllMocks();
});
describe("searchMonsters", () => {
it("searches monsters by name", async () => {
const mockData = {
results: [
{
name: "Adult Red Dragon",
key: "adult-red-dragon",
challenge_rating_decimal: "17.00",
type: "dragon",
hit_points: 256,
armor_class: 19,
},
],
};
global.fetch = vi.fn(() =>
Promise.resolve({
ok: true,
json: () => Promise.resolve(mockData),
})
) as unknown as typeof fetch;
const results = await searchMonsters("dragon");
expect(results).toHaveLength(1);
expect(results[0].name).toBe("Adult Red Dragon");
expect(results[0].key).toBe("adult-red-dragon");
});
it("includes filters in query string", async () => {
const mockData = { results: [] };
global.fetch = vi.fn(() =>
Promise.resolve({
ok: true,
json: () => Promise.resolve(mockData),
})
) as unknown as typeof fetch;
await searchMonsters("dragon", { type: "dragon", cr__lte: "5" });
expect(global.fetch).toHaveBeenCalledTimes(1);
const url = (global.fetch as ReturnType<typeof vi.fn>).mock.calls[0][0];
expect(url).toContain("name__icontains=dragon");
expect(url).toContain("type=dragon");
expect(url).toContain("cr__lte=5");
});
it("handles API errors by throwing", async () => {
global.fetch = vi.fn(() =>
Promise.resolve({
ok: false,
status: 500,
statusText: "Internal Server Error",
})
) as unknown as typeof fetch;
await expect(searchMonsters("dragon")).rejects.toThrow();
});
});
describe("getMonster", () => {
it("fetches a single monster by key", async () => {
const mockData = {
name: "Goblin",
key: "goblin",
challenge_rating_decimal: "0.25",
type: "humanoid",
hit_points: 7,
armor_class: 15,
};
global.fetch = vi.fn(() =>
Promise.resolve({
ok: true,
json: () => Promise.resolve(mockData),
})
) as unknown as typeof fetch;
const result = await getMonster("goblin");
expect(result.name).toBe("Goblin");
expect(result.key).toBe("goblin");
expect(global.fetch).toHaveBeenCalledWith(
`${API_BASE}/creatures/goblin/`,
expect.any(Object)
);
});
it("throws on 404", async () => {
global.fetch = vi.fn(() =>
Promise.resolve({
ok: false,
status: 404,
statusText: "Not Found",
})
) as unknown as typeof fetch;
await expect(getMonster("nonexistent")).rejects.toThrow("Not Found");
});
});
describe("searchSpells", () => {
it("searches spells by name", async () => {
const mockData = {
results: [
{
name: "Fireball",
key: "fireball",
level: 3,
school: "evocation",
},
],
};
global.fetch = vi.fn(() =>
Promise.resolve({
ok: true,
json: () => Promise.resolve(mockData),
})
) as unknown as typeof fetch;
const results = await searchSpells("fire");
expect(results).toHaveLength(1);
expect(results[0].name).toBe("Fireball");
expect(results[0].level).toBe(3);
});
it("includes filters in query string", async () => {
const mockData = { results: [] };
global.fetch = vi.fn(() =>
Promise.resolve({
ok: true,
json: () => Promise.resolve(mockData),
})
) as unknown as typeof fetch;
await searchSpells("heal", { level__lte: "3", school: "evocation" });
const url = (global.fetch as ReturnType<typeof vi.fn>).mock.calls[0][0];
expect(url).toContain("name__icontains=heal");
expect(url).toContain("level__lte=3");
expect(url).toContain("school=evocation");
});
});
describe("getSpell", () => {
it("fetches a single spell by key", async () => {
const mockData = {
name: "Magic Missile",
key: "magic-missile",
level: 1,
school: "evocation",
};
global.fetch = vi.fn(() =>
Promise.resolve({
ok: true,
json: () => Promise.resolve(mockData),
})
) as unknown as typeof fetch;
const result = await getSpell("magic-missile");
expect(result.name).toBe("Magic Missile");
expect(result.level).toBe(1);
});
});
describe("cache integration", () => {
it("returns cached data without calling fetch", async () => {
const cached = [
{
name: "Cached Dragon",
key: "cached-dragon",
challenge_rating_decimal: "10.00",
type: "dragon",
hit_points: 200,
armor_class: 18,
},
];
mockStorage.setItem(
"open5e:monsters:dragon",
JSON.stringify({ data: cached, timestamp: Date.now() })
);
global.fetch = vi.fn(() => Promise.resolve({ ok: true })) as unknown as typeof fetch;
const results = await searchMonsters("dragon");
expect(results).toHaveLength(1);
expect(results[0].name).toBe("Cached Dragon");
expect(global.fetch).not.toHaveBeenCalled();
});
it("stores data in cache after fetch", async () => {
const mockData = {
results: [
{
name: "New Monster",
key: "new-monster",
challenge_rating_decimal: "1.00",
type: "beast",
hit_points: 10,
armor_class: 12,
},
],
};
global.fetch = vi.fn(() =>
Promise.resolve({
ok: true,
json: () => Promise.resolve(mockData),
})
) as unknown as typeof fetch;
await searchMonsters("new");
const stored = mockStorage.getItem("open5e:monsters:new");
expect(stored).not.toBeNull();
const parsed = JSON.parse(stored!);
expect(parsed.data).toEqual(mockData.results);
expect(typeof parsed.timestamp).toBe("number");
});
it("falls back to stale cache on API error", async () => {
const cached = [
{
name: "Stale Dragon",
key: "stale-dragon",
challenge_rating_decimal: "5.00",
type: "dragon",
hit_points: 100,
armor_class: 16,
},
];
mockStorage.setItem(
"open5e:monsters:dragon",
JSON.stringify({ data: cached, timestamp: Date.now() })
);
global.fetch = vi.fn(() =>
Promise.resolve({
ok: false,
status: 500,
statusText: "Internal Server Error",
})
) as unknown as typeof fetch;
const results = await searchMonsters("dragon");
expect(results).toEqual(cached);
});
it("triggers new fetch when cache is expired", async () => {
const staleData = [
{
name: "Old Monster",
key: "old-monster",
challenge_rating_decimal: "1.00",
type: "beast",
hit_points: 10,
armor_class: 12,
},
];
mockStorage.setItem(
"open5e:monsters:dragon",
JSON.stringify({ data: staleData, timestamp: Date.now() - 25 * 60 * 60 * 1000 })
);
const freshData = {
results: [
{
name: "Fresh Monster",
key: "fresh-monster",
challenge_rating_decimal: "2.00",
type: "beast",
hit_points: 20,
armor_class: 14,
},
],
};
global.fetch = vi.fn(() =>
Promise.resolve({
ok: true,
json: () => Promise.resolve(freshData),
})
) as unknown as typeof fetch;
const results = await searchMonsters("dragon");
expect(results[0].name).toBe("Fresh Monster");
expect(global.fetch).toHaveBeenCalledTimes(1);
});
it("uses correct cache keys for different endpoints", async () => {
global.fetch = vi.fn(() =>
Promise.resolve({
ok: true,
json: () => Promise.resolve({ results: [] }),
})
) as unknown as typeof fetch;
await searchMonsters("dragon");
await getMonster("goblin");
await searchSpells("fire");
await getSpell("fireball");
expect(mockStorage.getItem("open5e:monsters:dragon")).not.toBeNull();
expect(mockStorage.getItem("open5e:monster:goblin")).not.toBeNull();
expect(mockStorage.getItem("open5e:spells:fire")).not.toBeNull();
expect(mockStorage.getItem("open5e:spell:fireball")).not.toBeNull();
});
});
});
+428
View File
@@ -0,0 +1,428 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import {
Open5eUIManager,
type UIState,
} from "../src/lib/client/open5e-ui";
vi.mock("../src/lib/open5e/client", () => ({
searchMonsters: vi.fn(),
searchSpells: vi.fn(),
getMonster: vi.fn(),
getSpell: vi.fn(),
}));
import {
searchMonsters,
searchSpells,
getMonster,
getSpell,
} from "../src/lib/open5e/client";
import type { Monster } from "../src/lib/open5e/client";
const mockedSearchMonsters = vi.mocked(searchMonsters);
const mockedSearchSpells = vi.mocked(searchSpells);
const mockedGetMonster = vi.mocked(getMonster);
const mockedGetSpell = vi.mocked(getSpell);
describe("Open5eUIManager", () => {
let manager: Open5eUIManager;
let stateUpdates: UIState[];
beforeEach(() => {
vi.clearAllMocks();
stateUpdates = [];
manager = new Open5eUIManager((state) => {
stateUpdates.push({ ...state });
});
});
afterEach(() => {
vi.restoreAllMocks();
});
describe("initial state", () => {
it("starts with monsters tab", () => {
expect(manager.state.tab).toBe("monsters");
});
it("starts with empty query", () => {
expect(manager.state.query).toBe("");
});
it("starts on page 1", () => {
expect(manager.state.page).toBe(1);
});
it("starts with no results", () => {
expect(manager.state.results).toEqual([]);
});
it("starts not loading", () => {
expect(manager.state.loading).toBe(false);
});
it("starts with no error", () => {
expect(manager.state.error).toBeNull();
});
it("starts with no selected item", () => {
expect(manager.state.selectedItem).toBeNull();
});
});
describe("setTab", () => {
it("switches to spells tab", () => {
manager.setTab("spells");
expect(manager.state.tab).toBe("spells");
});
it("resets page to 1 when switching tabs", () => {
manager.state.page = 3;
manager.setTab("spells");
expect(manager.state.page).toBe(1);
});
it("clears selected item when switching tabs", () => {
manager.state.selectedItem = { name: "Goblin", key: "goblin" };
manager.setTab("spells");
expect(manager.state.selectedItem).toBeNull();
});
it("triggers search when switching tabs", async () => {
mockedSearchSpells.mockResolvedValue([]);
manager.state.query = "fire";
await manager.setTab("spells");
expect(mockedSearchSpells).toHaveBeenCalled();
});
});
describe("setQuery", () => {
it("updates query state", () => {
manager.setQuery("goblin");
expect(manager.state.query).toBe("goblin");
});
it("resets page to 1", () => {
manager.state.page = 3;
manager.setQuery("dragon");
expect(manager.state.page).toBe(1);
});
it("searches monsters after debounce", async () => {
mockedSearchMonsters.mockResolvedValue([
{
name: "Goblin",
key: "goblin",
challenge_rating_decimal: "0.25",
type: "humanoid",
hit_points: 7,
armor_class: 15,
},
]);
await manager.setQuery("goblin");
expect(mockedSearchMonsters).toHaveBeenCalledWith("goblin", undefined);
});
it("searches spells in spells tab", async () => {
mockedSearchSpells.mockResolvedValue([
{
name: "Fireball",
key: "fireball",
level: 3,
school: "evocation",
},
]);
manager.setTab("spells");
await manager.setQuery("fire");
expect(mockedSearchSpells).toHaveBeenCalledWith("fire", undefined);
});
});
describe("monster CR filter", () => {
it("sets CR filter", () => {
manager.setMonsterCrFilter("0.25");
expect(manager.state.monsterCrFilter).toBe("0.25");
});
it("searches with CR filter", async () => {
mockedSearchMonsters.mockResolvedValue([]);
manager.setMonsterCrFilter("1");
await manager.search();
expect(mockedSearchMonsters).toHaveBeenCalledWith("", {
challenge_rating_decimal: "1",
});
});
it("clears CR filter when empty string", async () => {
mockedSearchMonsters.mockResolvedValue([]);
manager.state.monsterCrFilter = "2";
manager.setMonsterCrFilter("");
await manager.search();
expect(mockedSearchMonsters).toHaveBeenCalledWith("", undefined);
});
});
describe("spell level filter", () => {
it("sets spell level filter", () => {
manager.setSpellLevelFilter("3");
expect(manager.state.spellLevelFilter).toBe("3");
});
it("searches with level filter", async () => {
mockedSearchSpells.mockResolvedValue([]);
manager.setTab("spells");
manager.setSpellLevelFilter("1");
await manager.search();
expect(mockedSearchSpells).toHaveBeenCalledWith("", { level: "1" });
});
it("searches with cantrip filter as level 0", async () => {
mockedSearchSpells.mockResolvedValue([]);
manager.setTab("spells");
manager.setSpellLevelFilter("0");
await manager.search();
expect(mockedSearchSpells).toHaveBeenCalledWith("", { level: "0" });
});
});
describe("search results", () => {
it("stores monster results", async () => {
const mockMonsters = [
{
name: "Goblin",
key: "goblin",
challenge_rating_decimal: "0.25",
type: "humanoid",
hit_points: 7,
armor_class: 15,
},
{
name: "Orc",
key: "orc",
challenge_rating_decimal: "0.50",
type: "humanoid",
hit_points: 15,
armor_class: 13,
},
];
mockedSearchMonsters.mockResolvedValue(mockMonsters);
await manager.search();
expect(manager.state.results).toHaveLength(2);
expect(manager.state.results[0].name).toBe("Goblin");
});
it("stores spell results", async () => {
const mockSpells = [
{
name: "Magic Missile",
key: "magic-missile",
level: 1,
school: "evocation",
},
];
mockedSearchSpells.mockResolvedValue(mockSpells);
manager.setTab("spells");
await manager.search();
expect(manager.state.results).toHaveLength(1);
expect(manager.state.results[0].name).toBe("Magic Missile");
});
it("sets loading during search", async () => {
let resolveSearch!: (value: Monster[]) => void;
const searchPromise = new Promise<Monster[]>((resolve) => {
resolveSearch = resolve;
});
mockedSearchMonsters.mockReturnValue(searchPromise);
const searchPromise2 = manager.search();
expect(manager.state.loading).toBe(true);
resolveSearch([]);
await searchPromise2;
expect(manager.state.loading).toBe(false);
});
it("clears error on successful search", async () => {
manager.state.error = "Previous error";
mockedSearchMonsters.mockResolvedValue([]);
await manager.search();
expect(manager.state.error).toBeNull();
});
});
describe("error handling", () => {
it("sets error on API failure", async () => {
mockedSearchMonsters.mockRejectedValue(new Error("Network error"));
await manager.search();
expect(manager.state.error).toBe("Network error");
});
it("clears results on error", async () => {
manager.state.results = [
{ name: "Goblin", key: "goblin" },
];
mockedSearchMonsters.mockRejectedValue(new Error("Failed"));
await manager.search();
expect(manager.state.results).toEqual([]);
});
});
describe("empty state", () => {
it("has empty results when search returns nothing", async () => {
mockedSearchMonsters.mockResolvedValue([]);
await manager.search();
expect(manager.state.results).toHaveLength(0);
});
});
describe("pagination", () => {
const mockMonsters = Array.from({ length: 25 }, (_, i) => ({
name: `Monster ${i + 1}`,
key: `monster-${i + 1}`,
challenge_rating_decimal: "1.00",
type: "beast",
hit_points: 10,
armor_class: 12,
}));
beforeEach(() => {
mockedSearchMonsters.mockResolvedValue(mockMonsters);
});
it("returns paginated results", async () => {
await manager.search();
const paginated = manager.getPaginatedResults();
expect(paginated).toHaveLength(10);
expect(paginated[0].name).toBe("Monster 1");
});
it("calculates total pages", async () => {
await manager.search();
expect(manager.state.totalPages).toBe(3);
});
it("advances to next page", async () => {
await manager.search();
manager.nextPage();
expect(manager.state.page).toBe(2);
const paginated = manager.getPaginatedResults();
expect(paginated[0].name).toBe("Monster 11");
});
it("goes back to previous page", async () => {
await manager.search();
manager.nextPage();
manager.prevPage();
expect(manager.state.page).toBe(1);
const paginated = manager.getPaginatedResults();
expect(paginated[0].name).toBe("Monster 1");
});
it("does not go below page 1", () => {
manager.prevPage();
expect(manager.state.page).toBe(1);
});
it("does not go above total pages", async () => {
await manager.search();
manager.state.page = 3;
manager.nextPage();
expect(manager.state.page).toBe(3);
});
});
describe("detail view", () => {
it("fetches monster details", async () => {
const mockMonster = {
name: "Adult Red Dragon",
key: "adult-red-dragon",
challenge_rating_decimal: "17.00",
type: "dragon",
hit_points: 256,
armor_class: 19,
};
mockedGetMonster.mockResolvedValue(mockMonster);
await manager.selectItem("adult-red-dragon");
expect(manager.state.selectedItem).toEqual(mockMonster);
expect(mockedGetMonster).toHaveBeenCalledWith("adult-red-dragon");
});
it("fetches spell details", async () => {
const mockSpell = {
name: "Fireball",
key: "fireball",
level: 3,
school: "evocation",
};
mockedGetSpell.mockResolvedValue(mockSpell);
manager.setTab("spells");
await manager.selectItem("fireball");
expect(manager.state.selectedItem).toEqual(mockSpell);
expect(mockedGetSpell).toHaveBeenCalledWith("fireball");
});
it("clears selected item", () => {
manager.state.selectedItem = { name: "Goblin", key: "goblin" };
manager.clearSelectedItem();
expect(manager.state.selectedItem).toBeNull();
});
});
describe("CR formatting", () => {
it("formats known CR values", () => {
expect(Open5eUIManager.formatCr("0.125")).toBe("1/8");
expect(Open5eUIManager.formatCr("0.25")).toBe("1/4");
expect(Open5eUIManager.formatCr("0.5")).toBe("1/2");
expect(Open5eUIManager.formatCr("1.00")).toBe("1");
expect(Open5eUIManager.formatCr("5.00")).toBe("5");
});
it("returns raw value for unknown CR", () => {
expect(Open5eUIManager.formatCr("3.50")).toBe("3.5");
});
});
describe("spell level formatting", () => {
it("formats cantrip", () => {
expect(Open5eUIManager.formatSpellLevel(0)).toBe("Заговор");
});
it("formats level 1-9", () => {
expect(Open5eUIManager.formatSpellLevel(1)).toBe("1-й уровень");
expect(Open5eUIManager.formatSpellLevel(5)).toBe("5-й уровень");
expect(Open5eUIManager.formatSpellLevel(9)).toBe("9-й уровень");
});
});
describe("debounced search", () => {
beforeEach(() => {
vi.useFakeTimers({ shouldAdvanceTime: true });
});
afterEach(() => {
vi.useRealTimers();
});
it("debounces search by 300ms", async () => {
mockedSearchMonsters.mockResolvedValue([]);
manager.setQueryDebounced("g");
manager.setQueryDebounced("go");
manager.setQueryDebounced("gob");
manager.setQueryDebounced("goblin");
expect(mockedSearchMonsters).not.toHaveBeenCalled();
vi.advanceTimersByTime(300);
await Promise.resolve();
expect(mockedSearchMonsters).toHaveBeenCalledTimes(1);
expect(mockedSearchMonsters).toHaveBeenCalledWith("goblin", undefined);
});
});
});
+14
View File
@@ -0,0 +1,14 @@
import "vitest";
Object.defineProperty(globalThis, "crypto", {
value: {
getRandomValues: (arr: Uint8Array) => {
for (let i = 0; i < arr.length; i++) {
arr[i] = Math.floor(Math.random() * 256);
}
return arr;
},
subtle: {},
randomUUID: () => "00000000-0000-0000-0000-000000000000",
},
});
+44
View File
@@ -0,0 +1,44 @@
import { vi } from "vitest";
export function mockAstroLocals(user?: { id: string; name: string; avatar?: string }) {
return {
user: user ?? null,
};
}
export function mockFetchResponse(data: unknown, status = 200) {
return Promise.resolve(
new Response(JSON.stringify(data), {
status,
headers: { "Content-Type": "application/json" },
})
);
}
export function setupMockFetch(responseData: unknown, status = 200) {
const mockFetch = vi.fn(() => mockFetchResponse(responseData, status));
globalThis.fetch = mockFetch as unknown as typeof fetch;
return mockFetch;
}
export function createMockStorage() {
const store: Record<string, string> = {};
return {
getItem: vi.fn((key: string) => store[key] ?? null),
setItem: vi.fn((key: string, value: string) => { store[key] = value; }),
removeItem: vi.fn((key: string) => { delete store[key]; }),
clear: vi.fn(() => { Object.keys(store).forEach((k) => delete store[k]); }),
getStore: () => ({ ...store }),
};
}
export function mockJwtToken(payload: Record<string, unknown> = {}) {
const header = btoa(JSON.stringify({ alg: "HS256", typ: "JWT" }));
const body = btoa(JSON.stringify({ sub: "user123", iat: Date.now(), ...payload }));
const signature = btoa("mock-signature");
return `${header}.${body}.${signature}`;
}
export function waitFor(ms = 0) {
return new Promise<void>((resolve) => setTimeout(resolve, ms));
}