feat(dm): add dashboard with oauth, dice, initiative, open5e, notes
This commit is contained in:
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
},
|
||||
});
|
||||
@@ -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));
|
||||
}
|
||||
Reference in New Issue
Block a user