generated from nhcarrigan/template
fa906684c2
## Summary - **fix**: `show_thinking_blocks` setting now persists across sessions — it was defined on the TypeScript side but missing from the Rust `HikariConfig` struct, so serde silently dropped it on every save/load - **feat**: Tool calls are now rendered as collapsible blocks matching the Extended Thinking block aesthetic, replacing the old inline dropdown approach - **feat**: Add configurable max output tokens setting - **feat**: Use random creative names for conversation tabs - **test**: Significantly expanded frontend unit test coverage - **docs**: Require tests for all changes in CLAUDE.md - **feat**: Allow users to specify a custom terminal font (Closes #176) - **feat**: Display friendly names for memory files derived from the first heading (Closes #177) - **feat**: Add custom UI font support for the app chrome (buttons, labels, tabs) - **fix**: Apply custom UI font to the full app interface — `.app-container` was hardcoded, blocking inheritance from `body`; also renamed "Custom Font" to "Custom Terminal Font" for clarity ✨ This PR was created with help from Hikari~ 🌸 Reviewed-on: #175 Co-authored-by: Hikari <hikari@nhcarrigan.com> Co-committed-by: Hikari <hikari@nhcarrigan.com>
186 lines
6.7 KiB
TypeScript
186 lines
6.7 KiB
TypeScript
import { describe, it, expect, vi } from "vitest";
|
|
import { get } from "svelte/store";
|
|
import { setMockInvokeResult, emitMockEvent } from "../../../vitest.setup";
|
|
import {
|
|
achievementsStore,
|
|
unlockedAchievements,
|
|
lockedAchievements,
|
|
achievementsByRarity,
|
|
achievementProgress,
|
|
initAchievementsListener,
|
|
} from "./achievements";
|
|
import type { AchievementUnlockedEvent } from "$lib/types/achievements";
|
|
import { playAchievementSound } from "$lib/sounds/achievement";
|
|
|
|
vi.mock("$lib/sounds/achievement", () => ({
|
|
playAchievementSound: vi.fn(),
|
|
}));
|
|
|
|
// Helper to build a minimal unlock event
|
|
function makeEvent(id: AchievementUnlockedEvent["achievement"]["id"]): AchievementUnlockedEvent {
|
|
return {
|
|
achievement: {
|
|
id,
|
|
name: "Test",
|
|
description: "Test achievement",
|
|
icon: "🏆",
|
|
unlocked_at: null,
|
|
},
|
|
};
|
|
}
|
|
|
|
describe("achievementsStore initial state", () => {
|
|
it("all achievements start as locked", () => {
|
|
const state = get(achievementsStore);
|
|
expect(state.achievements["FirstSteps"].unlocked).toBe(false);
|
|
expect(state.achievements["GrowingStrong"].unlocked).toBe(false);
|
|
});
|
|
|
|
it("totalUnlocked starts at 0", () => {
|
|
expect(get(achievementsStore).totalUnlocked).toBe(0);
|
|
});
|
|
|
|
it("lastUnlocked starts as null", () => {
|
|
expect(get(achievementsStore).lastUnlocked).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("derived stores initial state", () => {
|
|
it("unlockedAchievements is initially empty", () => {
|
|
expect(get(unlockedAchievements)).toEqual([]);
|
|
});
|
|
|
|
it("lockedAchievements contains all achievements initially", () => {
|
|
const locked = get(lockedAchievements);
|
|
const total = Object.keys(get(achievementsStore).achievements).length;
|
|
expect(locked.length).toBe(total);
|
|
});
|
|
|
|
it("achievementsByRarity groups achievements into rarity buckets", () => {
|
|
const byRarity = get(achievementsByRarity);
|
|
expect(byRarity.common).toBeInstanceOf(Array);
|
|
expect(byRarity.rare).toBeInstanceOf(Array);
|
|
expect(byRarity.epic).toBeInstanceOf(Array);
|
|
expect(byRarity.legendary).toBeInstanceOf(Array);
|
|
expect(byRarity.common.length).toBeGreaterThan(0);
|
|
});
|
|
|
|
it("achievementProgress shows zero unlocked initially", () => {
|
|
const progress = get(achievementProgress);
|
|
expect(progress.unlocked).toBe(0);
|
|
expect(progress.total).toBeGreaterThan(0);
|
|
expect(progress.percentage).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe("achievementsStore.unlockAchievement", () => {
|
|
it("marks the achievement as unlocked and updates totalUnlocked", () => {
|
|
achievementsStore.unlockAchievement(makeEvent("GrowingStrong"));
|
|
const state = get(achievementsStore);
|
|
expect(state.achievements["GrowingStrong"].unlocked).toBe(true);
|
|
expect(state.totalUnlocked).toBe(1);
|
|
expect(state.lastUnlocked?.id).toBe("GrowingStrong");
|
|
});
|
|
|
|
it("sets unlockedAt from the event's unlocked_at timestamp", () => {
|
|
achievementsStore.unlockAchievement({
|
|
achievement: {
|
|
id: "BlossomingCoder",
|
|
name: "Blossoming Coder",
|
|
description: "100k tokens",
|
|
icon: "🌸",
|
|
unlocked_at: "2026-01-15T12:00:00.000Z",
|
|
},
|
|
});
|
|
const state = get(achievementsStore);
|
|
expect(state.achievements["BlossomingCoder"].unlockedAt).toBeInstanceOf(Date);
|
|
});
|
|
|
|
it("does nothing when the achievement is already unlocked", () => {
|
|
achievementsStore.unlockAchievement(makeEvent("TokenMaster"));
|
|
const firstTotal = get(achievementsStore).totalUnlocked;
|
|
achievementsStore.unlockAchievement(makeEvent("TokenMaster"));
|
|
expect(get(achievementsStore).totalUnlocked).toBe(firstTotal);
|
|
});
|
|
|
|
it("calls playAchievementSound when playSound is true (default)", () => {
|
|
achievementsStore.unlockAchievement(makeEvent("TokenBillionaire"));
|
|
expect(playAchievementSound).toHaveBeenCalled();
|
|
});
|
|
|
|
it("does not call playAchievementSound when playSound is false", () => {
|
|
achievementsStore.unlockAchievement(makeEvent("TokenTreasure"), false);
|
|
expect(playAchievementSound).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("logs an error when playAchievementSound throws", () => {
|
|
vi.mocked(playAchievementSound).mockImplementationOnce(() => {
|
|
throw new Error("Sound failed");
|
|
});
|
|
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
achievementsStore.unlockAchievement(makeEvent("HelloWorld"));
|
|
expect(consoleSpy).toHaveBeenCalledWith("Failed to play achievement sound:", expect.any(Error));
|
|
consoleSpy.mockRestore();
|
|
});
|
|
});
|
|
|
|
describe("derived stores after unlocks", () => {
|
|
it("unlockedAchievements includes previously unlocked achievements", () => {
|
|
const unlocked = get(unlockedAchievements);
|
|
expect(unlocked.some((a) => a.id === "GrowingStrong")).toBe(true);
|
|
});
|
|
|
|
it("lockedAchievements excludes previously unlocked achievements", () => {
|
|
const locked = get(lockedAchievements);
|
|
expect(locked.some((a) => a.id === "GrowingStrong")).toBe(false);
|
|
});
|
|
|
|
it("achievementProgress reflects the current unlocked count", () => {
|
|
const progress = get(achievementProgress);
|
|
expect(progress.unlocked).toBeGreaterThan(0);
|
|
expect(progress.percentage).toBeGreaterThan(0);
|
|
});
|
|
});
|
|
|
|
describe("achievementsStore.updateProgress", () => {
|
|
it("updates the progress value for an achievement", () => {
|
|
achievementsStore.updateProgress("FirstMessage", 50);
|
|
expect(get(achievementsStore).achievements["FirstMessage"].progress).toBe(50);
|
|
});
|
|
});
|
|
|
|
describe("achievementsStore.reset", () => {
|
|
it("resets totalUnlocked to 0 and lastUnlocked to null", () => {
|
|
achievementsStore.reset();
|
|
const state = get(achievementsStore);
|
|
expect(state.totalUnlocked).toBe(0);
|
|
expect(state.lastUnlocked).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("initAchievementsListener", () => {
|
|
it("unlocks an achievement when the achievement:unlocked event fires", async () => {
|
|
await initAchievementsListener();
|
|
emitMockEvent("achievement:unlocked", makeEvent("FirstSteps"));
|
|
expect(get(achievementsStore).achievements["FirstSteps"].unlocked).toBe(true);
|
|
});
|
|
|
|
it("loads saved achievements from the backend without playing sounds", async () => {
|
|
setMockInvokeResult("load_saved_achievements", [makeEvent("ConversationStarter")]);
|
|
await initAchievementsListener();
|
|
expect(get(achievementsStore).achievements["ConversationStarter"].unlocked).toBe(true);
|
|
expect(playAchievementSound).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("logs an error when loading saved achievements fails", async () => {
|
|
setMockInvokeResult("load_saved_achievements", new Error("Storage unavailable"));
|
|
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
await initAchievementsListener();
|
|
expect(consoleSpy).toHaveBeenCalledWith(
|
|
"Failed to load saved achievements:",
|
|
expect.any(Error)
|
|
);
|
|
consoleSpy.mockRestore();
|
|
});
|
|
});
|