import { test } from "node:test"; import assert from "node:assert/strict"; // Minimal in-memory localStorage so the store's window-guarded functions run in // node exactly as they do in the browser. class MemStorage { private map = new Map(); getItem(k: string): string | null { return this.map.has(k) ? (this.map.get(k) as string) : null; } setItem(k: string, v: string): void { this.map.set(k, String(v)); } removeItem(k: string): void { this.map.delete(k); } } (globalThis as { window?: unknown }).window = { localStorage: new MemStorage() }; const KEY = "ytdlp-tb:ai:chats"; function rawStore(): Storage { return (globalThis as unknown as { window: { localStorage: Storage } }).window .localStorage; } import { emptySavedChats, loadSavedChats, saveSavedChats, savedChatsEqual, type SavedChat, type SavedChatsState, } from "./askChatStorage"; import type { UiMessage } from "../lib/askConversation"; function msg(role: "user" | "assistant", content: string): UiMessage { return { role, content }; } function baseChat(over: Partial = {}): SavedChat { return { messages: [msg("user", "hi"), msg("assistant", "hello")], contextOverride: null, ...over, }; } test("loadSavedChats on empty storage returns the empty state", () => { rawStore().removeItem(KEY); assert.deepEqual(loadSavedChats(), emptySavedChats()); }); test("save → load round-trips items + active pointer", () => { rawStore().removeItem(KEY); const state: SavedChatsState = { v: 1, items: { research: baseChat({ report: "# Findings\n" }) }, activeName: "research", }; saveSavedChats(state); const back = loadSavedChats(); assert.equal(back.activeName, "research"); assert.equal(back.items.research.report, "# Findings\n"); assert.equal(back.items.research.messages.length, 2); }); test("malformed JSON parses-or-resets (returns empty + clears the key)", () => { rawStore().setItem(KEY, "{ not json"); assert.deepEqual(loadSavedChats(), emptySavedChats()); assert.equal(rawStore().getItem(KEY), null); }); test("a wrong version resets", () => { rawStore().setItem(KEY, JSON.stringify({ v: 99, items: {}, activeName: null })); assert.deepEqual(loadSavedChats(), emptySavedChats()); }); test("an active pointer to a missing chat is dropped on load", () => { rawStore().setItem( KEY, JSON.stringify({ v: 1, items: { a: baseChat() }, activeName: "ghost" }), ); assert.equal(loadSavedChats().activeName, null); }); test("a sweep checkpoint round-trips (serialize → resume yields the remaining batches)", () => { rawStore().removeItem(KEY); const chat = baseChat({ report: "# Report so far\n", checkpoint: { directive: "major contradictions", remainingSlugs: ["ch/v3", "ch/v4", "ch/v5"], done: 2, total: 5, reason: "usage limit", }, }); saveSavedChats({ v: 1, items: { paused: chat }, activeName: "paused" }); const cp = loadSavedChats().items.paused.checkpoint; assert.ok(cp); assert.deepEqual(cp.remainingSlugs, ["ch/v3", "ch/v4", "ch/v5"]); assert.equal(cp.done, 2); assert.equal(cp.total, 5); assert.equal(cp.reason, "usage limit"); }); test("a report source registry round-trips (and old saves without one still load)", () => { rawStore().removeItem(KEY); const chat = baseChat({ report: "# Findings\n\nClaim [1 @ 0:05].\n", reportSources: [ { key: "ch/v1", videoId: "v1", title: "A video", channel: "Chan", uploadDate: "20200101", snippets: [{ clock: "0:05", seconds: 5, text: "" }], }, ], }); saveSavedChats({ v: 1, items: { research: chat }, activeName: "research" }); const back = loadSavedChats().items.research; assert.equal(back.reportSources?.length, 1); assert.equal(back.reportSources?.[0].key, "ch/v1"); assert.equal(back.reportSources?.[0].snippets[0].seconds, 5); // An old save with no reportSources field loads with it simply absent. rawStore().setItem( KEY, JSON.stringify({ v: 1, items: { legacy: baseChat({ report: "old" }) }, activeName: "legacy" }), ); const legacy = loadSavedChats().items.legacy; assert.equal(legacy.report, "old"); assert.equal(legacy.reportSources, undefined); }); test("savedChatsEqual: equal on the same restorable content", () => { assert.ok(savedChatsEqual(baseChat(), baseChat())); }); test("savedChatsEqual: dirty when the report source registry differs", () => { const withSources = baseChat({ reportSources: [ { key: "ch/v1", videoId: "v1", title: "A", channel: "C", uploadDate: "20200101", snippets: [{ clock: "0:05", seconds: 5, text: "" }], }, ], }); assert.ok(!savedChatsEqual(baseChat(), withSources)); assert.ok(savedChatsEqual(withSources, withSources)); }); test("savedChatsEqual: dirty when messages / report / checkpoint differ", () => { assert.ok(!savedChatsEqual(baseChat(), baseChat({ messages: [msg("user", "x")] }))); assert.ok(!savedChatsEqual(baseChat(), baseChat({ report: "changed" }))); assert.ok( !savedChatsEqual( baseChat(), baseChat({ checkpoint: { directive: "d", remainingSlugs: ["a"], done: 0, total: 1, reason: "paused" } }), ), ); }); test("savedChatsEqual: ignores info-only fields (model, savedAt)", () => { assert.ok( savedChatsEqual( baseChat({ model: "claude-opus-4-8", savedAt: 111 }), baseChat({ model: "gemini-2.5", savedAt: 999 }), ), ); }); test("rename + delete map ops persist through save/load", () => { rawStore().removeItem(KEY); // Start with two saved chats. const s: SavedChatsState = { v: 1, items: { a: baseChat(), b: baseChat({ report: "b-report" }) }, activeName: "a", }; saveSavedChats(s); // Rename a → alpha (rebuild the map, move the active pointer). const renamed: SavedChatsState = { v: 1, items: {}, activeName: "alpha" }; for (const [k, v] of Object.entries(loadSavedChats().items)) { renamed.items[k === "a" ? "alpha" : k] = v; } saveSavedChats(renamed); let back = loadSavedChats(); assert.deepEqual(Object.keys(back.items).sort(), ["alpha", "b"]); assert.equal(back.activeName, "alpha"); // Delete b. delete back.items.b; saveSavedChats(back); back = loadSavedChats(); assert.deepEqual(Object.keys(back.items), ["alpha"]); });