WIP! A BB-style forum, on the ATmosphere!
We're still working... we'll be back soon when we have something to show off!
node
typescript
hono
htmx
atproto
1import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
2import { ThemeCache, type CachedPolicy, type CachedTheme } from "../theme-cache.js";
3
4const TTL_MS = 5 * 60 * 1000; // 5 minutes
5
6const MOCK_POLICY: CachedPolicy = {
7 defaultLightThemeUri: "at://did:plc:forum/space.atbb.forum.theme/3lbllight",
8 defaultDarkThemeUri: "at://did:plc:forum/space.atbb.forum.theme/3lbldark",
9 allowUserChoice: true,
10 availableThemes: [
11 { uri: "at://did:plc:forum/space.atbb.forum.theme/3lbllight", cid: "bafylight" },
12 { uri: "at://did:plc:forum/space.atbb.forum.theme/3lbldark", cid: "bafydark" },
13 ],
14};
15
16const MOCK_THEME: CachedTheme = {
17 cid: "bafylight",
18 tokens: { "color-bg": "#fff" },
19 cssOverrides: null,
20 fontUrls: null,
21};
22
23describe("ThemeCache — policy", () => {
24 let cache: ThemeCache;
25
26 beforeEach(() => {
27 vi.useFakeTimers();
28 cache = new ThemeCache(TTL_MS);
29 });
30
31 afterEach(() => {
32 vi.useRealTimers();
33 });
34
35 it("returns null when policy has not been set", () => {
36 expect(cache.getPolicy()).toBeNull();
37 });
38
39 it("returns policy immediately after setting", () => {
40 cache.setPolicy(MOCK_POLICY);
41 expect(cache.getPolicy()).toEqual(MOCK_POLICY);
42 });
43
44 it("returns null after TTL expires", () => {
45 cache.setPolicy(MOCK_POLICY);
46 vi.advanceTimersByTime(TTL_MS + 1);
47 expect(cache.getPolicy()).toBeNull();
48 });
49
50 it("returns policy just before TTL expires", () => {
51 cache.setPolicy(MOCK_POLICY);
52 vi.advanceTimersByTime(TTL_MS - 1);
53 expect(cache.getPolicy()).toEqual(MOCK_POLICY);
54 });
55
56 it("can be refreshed before expiry (re-set resets TTL)", () => {
57 cache.setPolicy(MOCK_POLICY);
58 vi.advanceTimersByTime(TTL_MS - 1);
59 cache.setPolicy({ ...MOCK_POLICY, allowUserChoice: false });
60 vi.advanceTimersByTime(TTL_MS - 1);
61 // Total elapsed: (TTL-1) + (TTL-1) = 2*TTL - 2ms, but the re-set happened at TTL-1
62 // so the new entry expires at (TTL-1) + TTL = 2*TTL - 1ms from start
63 const result = cache.getPolicy();
64 expect(result).not.toBeNull();
65 expect(result!.allowUserChoice).toBe(false);
66 });
67});
68
69describe("ThemeCache — themes", () => {
70 let cache: ThemeCache;
71
72 beforeEach(() => {
73 vi.useFakeTimers();
74 cache = new ThemeCache(TTL_MS);
75 });
76
77 afterEach(() => {
78 vi.useRealTimers();
79 });
80
81 it("returns null on cache miss", () => {
82 expect(
83 cache.getTheme("at://did:plc:forum/space.atbb.forum.theme/3lbllight", "light")
84 ).toBeNull();
85 });
86
87 it("returns theme immediately after setting", () => {
88 const uri = "at://did:plc:forum/space.atbb.forum.theme/3lbllight";
89 cache.setTheme(uri, "light", MOCK_THEME);
90 expect(cache.getTheme(uri, "light")).toEqual(MOCK_THEME);
91 });
92
93 it("returns null after TTL expires", () => {
94 const uri = "at://did:plc:forum/space.atbb.forum.theme/3lbllight";
95 cache.setTheme(uri, "light", MOCK_THEME);
96 vi.advanceTimersByTime(TTL_MS + 1);
97 expect(cache.getTheme(uri, "light")).toBeNull();
98 });
99
100 it("treats light and dark as separate cache entries", () => {
101 const uri = "at://did:plc:forum/space.atbb.forum.theme/shared";
102 const lightTheme: CachedTheme = { cid: "bafylight", tokens: { "color-bg": "#fff" }, cssOverrides: null, fontUrls: null };
103 const darkTheme: CachedTheme = { cid: "bafydark", tokens: { "color-bg": "#111" }, cssOverrides: null, fontUrls: null };
104
105 cache.setTheme(uri, "light", lightTheme);
106 cache.setTheme(uri, "dark", darkTheme);
107
108 expect(cache.getTheme(uri, "light")).toEqual(lightTheme);
109 expect(cache.getTheme(uri, "dark")).toEqual(darkTheme);
110 });
111
112 it("different URIs are stored independently", () => {
113 const lightUri = "at://did/col/light";
114 const darkUri = "at://did/col/dark";
115 const lightTheme: CachedTheme = { cid: "bafylight", tokens: { "color-bg": "#fff" }, cssOverrides: null, fontUrls: null };
116 const darkTheme: CachedTheme = { cid: "bafydark", tokens: { "color-bg": "#111" }, cssOverrides: null, fontUrls: null };
117
118 cache.setTheme(lightUri, "light", lightTheme);
119 cache.setTheme(darkUri, "dark", darkTheme);
120
121 expect(cache.getTheme(lightUri, "light")).toEqual(lightTheme);
122 expect(cache.getTheme(darkUri, "dark")).toEqual(darkTheme);
123 expect(cache.getTheme(lightUri, "dark")).toBeNull();
124 expect(cache.getTheme(darkUri, "light")).toBeNull();
125 });
126
127 it("deleteTheme removes a specific entry before TTL", () => {
128 const uri = "at://did/col/theme";
129 cache.setTheme(uri, "light", MOCK_THEME);
130 cache.deleteTheme(uri, "light");
131 expect(cache.getTheme(uri, "light")).toBeNull();
132 });
133
134 it("deleteTheme only removes the targeted uri:colorScheme entry", () => {
135 const uri = "at://did/col/theme";
136 const darkTheme: CachedTheme = { cid: "bafydark", tokens: { "color-bg": "#111" }, cssOverrides: null, fontUrls: null };
137 cache.setTheme(uri, "light", MOCK_THEME);
138 cache.setTheme(uri, "dark", darkTheme);
139 cache.deleteTheme(uri, "light");
140 expect(cache.getTheme(uri, "light")).toBeNull();
141 expect(cache.getTheme(uri, "dark")).toEqual(darkTheme);
142 });
143
144 it("evicts stale entry from the map on expired access", () => {
145 const uri = "at://did/col/theme";
146 cache.setTheme(uri, "light", MOCK_THEME);
147 vi.advanceTimersByTime(TTL_MS + 1);
148 // Access after expiry
149 expect(cache.getTheme(uri, "light")).toBeNull();
150 // After eviction, setting a new entry works correctly
151 const newTheme = { ...MOCK_THEME, cid: "bafynew" };
152 cache.setTheme(uri, "light", newTheme);
153 expect(cache.getTheme(uri, "light")).toEqual(newTheme);
154 });
155});