feat: add auto-memory panel, /memory command, and unified toast system

- Add /memory CLI built-in slash command
- Refactor MemoryBrowserPanel to accept isOpen/onClose props
- Add Send /memory and Refresh buttons to MemoryBrowserPanel header
- Add Memory Manager entry to NavMenu
- Create unified ToastContainer replacing AchievementNotification and
  UpdateNotification with a single stacked toast system
- Add toasts store with info (4s), achievement (5s), and persistent
  update toast types
- Move getAchievementRarity and getRarityColour helpers to toasts store
- Detect auto-memory writes in tauri.ts output listener and fire toast
- Remove action buttons from update toast; version is now a direct link

Closes #212
This commit is contained in:
2026-03-12 22:45:03 -07:00
committed by Naomi Carrigan
parent 02c8d6c990
commit 8f278da304
12 changed files with 691 additions and 528 deletions
+245
View File
@@ -0,0 +1,245 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { get } from "svelte/store";
import { getAchievementRarity, getRarityColour, toastStore } from "./toasts";
import type { AchievementUnlockedEvent } from "$lib/types/achievements";
// ---
describe("getAchievementRarity", () => {
describe("legendary tier", () => {
it("classifies TokenMaster as legendary", () => {
expect(getAchievementRarity("TokenMaster")).toBe("legendary");
});
});
describe("epic tier", () => {
it("classifies CodeMachine as epic", () => {
expect(getAchievementRarity("CodeMachine")).toBe("epic");
});
it("classifies Unstoppable as epic", () => {
expect(getAchievementRarity("Unstoppable")).toBe("epic");
});
});
describe("rare tier", () => {
it("classifies BlossomingCoder as rare", () => {
expect(getAchievementRarity("BlossomingCoder")).toBe("rare");
});
it("classifies CodeWizard as rare", () => {
expect(getAchievementRarity("CodeWizard")).toBe("rare");
});
it("classifies MasterBuilder as rare", () => {
expect(getAchievementRarity("MasterBuilder")).toBe("rare");
});
it("classifies EnduranceChamp as rare", () => {
expect(getAchievementRarity("EnduranceChamp")).toBe("rare");
});
it("classifies DeepDive as rare", () => {
expect(getAchievementRarity("DeepDive")).toBe("rare");
});
it("classifies CreativeCoder as rare", () => {
expect(getAchievementRarity("CreativeCoder")).toBe("rare");
});
});
describe("common tier", () => {
it("classifies unknown IDs as common", () => {
expect(getAchievementRarity("FirstChat")).toBe("common");
expect(getAchievementRarity("SomeNewAchievement")).toBe("common");
expect(getAchievementRarity("")).toBe("common");
});
});
});
describe("getRarityColour", () => {
it("returns yellow-to-orange gradient for legendary", () => {
expect(getRarityColour("legendary")).toBe("from-yellow-400 to-orange-500");
});
it("returns purple-to-pink gradient for epic", () => {
expect(getRarityColour("epic")).toBe("from-purple-400 to-pink-500");
});
it("returns blue-to-indigo gradient for rare", () => {
expect(getRarityColour("rare")).toBe("from-blue-400 to-indigo-500");
});
it("returns green-to-emerald gradient for common", () => {
expect(getRarityColour("common")).toBe("from-green-400 to-emerald-500");
});
it("falls back to green-to-emerald gradient for unknown rarities", () => {
expect(getRarityColour("mythic")).toBe("from-green-400 to-emerald-500");
expect(getRarityColour("")).toBe("from-green-400 to-emerald-500");
});
describe("end-to-end rarity pipeline", () => {
it("produces the correct colour for a legendary achievement", () => {
const colour = getRarityColour(getAchievementRarity("TokenMaster"));
expect(colour).toBe("from-yellow-400 to-orange-500");
});
it("produces the correct colour for an epic achievement", () => {
const colour = getRarityColour(getAchievementRarity("CodeMachine"));
expect(colour).toBe("from-purple-400 to-pink-500");
});
it("produces the correct colour for a rare achievement", () => {
const colour = getRarityColour(getAchievementRarity("CodeWizard"));
expect(colour).toBe("from-blue-400 to-indigo-500");
});
it("produces the correct colour for a common achievement", () => {
const colour = getRarityColour(getAchievementRarity("FirstChat"));
expect(colour).toBe("from-green-400 to-emerald-500");
});
});
});
// ---
describe("toastStore", () => {
beforeEach(() => {
vi.useFakeTimers();
// Clear all toasts before each test
const current = get(toastStore);
for (const toast of current) {
toastStore.remove(toast.id);
}
});
afterEach(() => {
vi.useRealTimers();
});
describe("addInfo", () => {
it("adds an info toast with the correct fields", () => {
toastStore.addInfo("Hello world", "🌍");
const toasts = get(toastStore);
expect(toasts).toHaveLength(1);
const toast = toasts[0];
expect(toast.kind).toBe("info");
if (toast.kind === "info") {
expect(toast.message).toBe("Hello world");
expect(toast.icon).toBe("🌍");
expect(typeof toast.id).toBe("string");
expect(toast.id.length).toBeGreaterThan(0);
}
});
it("uses a default icon when none is provided", () => {
toastStore.addInfo("Default icon test");
const toasts = get(toastStore);
const toast = toasts[0];
if (toast.kind === "info") {
expect(toast.icon).toBe("️");
}
});
it("auto-dismisses after 4000ms", () => {
toastStore.addInfo("Auto-dismiss test");
expect(get(toastStore)).toHaveLength(1);
vi.advanceTimersByTime(3999);
expect(get(toastStore)).toHaveLength(1);
vi.advanceTimersByTime(1);
expect(get(toastStore)).toHaveLength(0);
});
});
describe("addAchievement", () => {
const mockAchievement: AchievementUnlockedEvent["achievement"] = {
id: "FirstMessage",
name: "First Message",
description: "Sent your first message",
icon: "💬",
unlocked_at: "2026-01-01T00:00:00Z",
};
it("adds an achievement toast with the correct fields", () => {
toastStore.addAchievement(mockAchievement);
const toasts = get(toastStore);
expect(toasts).toHaveLength(1);
const toast = toasts[0];
expect(toast.kind).toBe("achievement");
if (toast.kind === "achievement") {
expect(toast.achievement).toEqual(mockAchievement);
expect(typeof toast.id).toBe("string");
}
});
it("auto-dismisses after 5000ms", () => {
toastStore.addAchievement(mockAchievement);
expect(get(toastStore)).toHaveLength(1);
vi.advanceTimersByTime(4999);
expect(get(toastStore)).toHaveLength(1);
vi.advanceTimersByTime(1);
expect(get(toastStore)).toHaveLength(0);
});
});
describe("addUpdate", () => {
it("adds a persistent update toast with the correct fields", () => {
toastStore.addUpdate("2.0.0", "1.9.0", "https://example.com/release");
const toasts = get(toastStore);
expect(toasts).toHaveLength(1);
const toast = toasts[0];
expect(toast.kind).toBe("update");
if (toast.kind === "update") {
expect(toast.latestVersion).toBe("2.0.0");
expect(toast.currentVersion).toBe("1.9.0");
expect(toast.releaseUrl).toBe("https://example.com/release");
expect(typeof toast.id).toBe("string");
}
});
it("does not auto-dismiss after a long time", () => {
toastStore.addUpdate("2.0.0", "1.9.0", "https://example.com/release");
expect(get(toastStore)).toHaveLength(1);
vi.advanceTimersByTime(60000);
expect(get(toastStore)).toHaveLength(1);
});
});
describe("remove", () => {
it("removes a toast by id", () => {
toastStore.addInfo("To be removed");
const toasts = get(toastStore);
expect(toasts).toHaveLength(1);
const id = toasts[0].id;
toastStore.remove(id);
expect(get(toastStore)).toHaveLength(0);
});
it("does not affect other toasts when removing by id", () => {
toastStore.addInfo("First toast");
toastStore.addInfo("Second toast");
const toasts = get(toastStore);
expect(toasts).toHaveLength(2);
toastStore.remove(toasts[0].id);
const remaining = get(toastStore);
expect(remaining).toHaveLength(1);
if (remaining[0].kind === "info") {
expect(remaining[0].message).toBe("Second toast");
}
});
it("is a no-op when the id does not exist", () => {
toastStore.addInfo("Existing toast");
toastStore.remove("non-existent-id");
expect(get(toastStore)).toHaveLength(1);
});
});
});