import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; import { ThemeCache, type CachedPolicy, type CachedTheme } from "../theme-cache.js"; const TTL_MS = 5 * 60 * 1000; // 5 minutes const MOCK_POLICY: CachedPolicy = { defaultLightThemeUri: "at://did:plc:forum/space.atbb.forum.theme/3lbllight", defaultDarkThemeUri: "at://did:plc:forum/space.atbb.forum.theme/3lbldark", allowUserChoice: true, availableThemes: [ { uri: "at://did:plc:forum/space.atbb.forum.theme/3lbllight", cid: "bafylight" }, { uri: "at://did:plc:forum/space.atbb.forum.theme/3lbldark", cid: "bafydark" }, ], }; const MOCK_THEME: CachedTheme = { cid: "bafylight", tokens: { "color-bg": "#fff" }, cssOverrides: null, fontUrls: null, }; describe("ThemeCache — policy", () => { let cache: ThemeCache; beforeEach(() => { vi.useFakeTimers(); cache = new ThemeCache(TTL_MS); }); afterEach(() => { vi.useRealTimers(); }); it("returns null when policy has not been set", () => { expect(cache.getPolicy()).toBeNull(); }); it("returns policy immediately after setting", () => { cache.setPolicy(MOCK_POLICY); expect(cache.getPolicy()).toEqual(MOCK_POLICY); }); it("returns null after TTL expires", () => { cache.setPolicy(MOCK_POLICY); vi.advanceTimersByTime(TTL_MS + 1); expect(cache.getPolicy()).toBeNull(); }); it("returns policy just before TTL expires", () => { cache.setPolicy(MOCK_POLICY); vi.advanceTimersByTime(TTL_MS - 1); expect(cache.getPolicy()).toEqual(MOCK_POLICY); }); it("can be refreshed before expiry (re-set resets TTL)", () => { cache.setPolicy(MOCK_POLICY); vi.advanceTimersByTime(TTL_MS - 1); cache.setPolicy({ ...MOCK_POLICY, allowUserChoice: false }); vi.advanceTimersByTime(TTL_MS - 1); // Total elapsed: (TTL-1) + (TTL-1) = 2*TTL - 2ms, but the re-set happened at TTL-1 // so the new entry expires at (TTL-1) + TTL = 2*TTL - 1ms from start const result = cache.getPolicy(); expect(result).not.toBeNull(); expect(result!.allowUserChoice).toBe(false); }); }); describe("ThemeCache — themes", () => { let cache: ThemeCache; beforeEach(() => { vi.useFakeTimers(); cache = new ThemeCache(TTL_MS); }); afterEach(() => { vi.useRealTimers(); }); it("returns null on cache miss", () => { expect( cache.getTheme("at://did:plc:forum/space.atbb.forum.theme/3lbllight", "light") ).toBeNull(); }); it("returns theme immediately after setting", () => { const uri = "at://did:plc:forum/space.atbb.forum.theme/3lbllight"; cache.setTheme(uri, "light", MOCK_THEME); expect(cache.getTheme(uri, "light")).toEqual(MOCK_THEME); }); it("returns null after TTL expires", () => { const uri = "at://did:plc:forum/space.atbb.forum.theme/3lbllight"; cache.setTheme(uri, "light", MOCK_THEME); vi.advanceTimersByTime(TTL_MS + 1); expect(cache.getTheme(uri, "light")).toBeNull(); }); it("treats light and dark as separate cache entries", () => { const uri = "at://did:plc:forum/space.atbb.forum.theme/shared"; const lightTheme: CachedTheme = { cid: "bafylight", tokens: { "color-bg": "#fff" }, cssOverrides: null, fontUrls: null }; const darkTheme: CachedTheme = { cid: "bafydark", tokens: { "color-bg": "#111" }, cssOverrides: null, fontUrls: null }; cache.setTheme(uri, "light", lightTheme); cache.setTheme(uri, "dark", darkTheme); expect(cache.getTheme(uri, "light")).toEqual(lightTheme); expect(cache.getTheme(uri, "dark")).toEqual(darkTheme); }); it("different URIs are stored independently", () => { const lightUri = "at://did/col/light"; const darkUri = "at://did/col/dark"; const lightTheme: CachedTheme = { cid: "bafylight", tokens: { "color-bg": "#fff" }, cssOverrides: null, fontUrls: null }; const darkTheme: CachedTheme = { cid: "bafydark", tokens: { "color-bg": "#111" }, cssOverrides: null, fontUrls: null }; cache.setTheme(lightUri, "light", lightTheme); cache.setTheme(darkUri, "dark", darkTheme); expect(cache.getTheme(lightUri, "light")).toEqual(lightTheme); expect(cache.getTheme(darkUri, "dark")).toEqual(darkTheme); expect(cache.getTheme(lightUri, "dark")).toBeNull(); expect(cache.getTheme(darkUri, "light")).toBeNull(); }); it("deleteTheme removes a specific entry before TTL", () => { const uri = "at://did/col/theme"; cache.setTheme(uri, "light", MOCK_THEME); cache.deleteTheme(uri, "light"); expect(cache.getTheme(uri, "light")).toBeNull(); }); it("deleteTheme only removes the targeted uri:colorScheme entry", () => { const uri = "at://did/col/theme"; const darkTheme: CachedTheme = { cid: "bafydark", tokens: { "color-bg": "#111" }, cssOverrides: null, fontUrls: null }; cache.setTheme(uri, "light", MOCK_THEME); cache.setTheme(uri, "dark", darkTheme); cache.deleteTheme(uri, "light"); expect(cache.getTheme(uri, "light")).toBeNull(); expect(cache.getTheme(uri, "dark")).toEqual(darkTheme); }); it("evicts stale entry from the map on expired access", () => { const uri = "at://did/col/theme"; cache.setTheme(uri, "light", MOCK_THEME); vi.advanceTimersByTime(TTL_MS + 1); // Access after expiry expect(cache.getTheme(uri, "light")).toBeNull(); // After eviction, setting a new entry works correctly const newTheme = { ...MOCK_THEME, cid: "bafynew" }; cache.setTheme(uri, "light", newTheme); expect(cache.getTheme(uri, "light")).toEqual(newTheme); }); });