generated from nhcarrigan/template
feat: multiple UI improvements, font settings, and memory file display names (#175)
## 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>
This commit was merged in pull request #175.
This commit is contained in:
@@ -0,0 +1,185 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user