a reactive (signals based) hypermedia web framework (wip)
stormlightlabs.github.io/volt/
hypermedia
frontend
signals
1import { mount } from "$core/binder";
2import { registerPlugin } from "$core/plugin";
3import { signal } from "$core/signal";
4import { persistPlugin, registerStorageAdapter } from "$plugins/persist";
5import { beforeEach, describe, expect, it, vi } from "vitest";
6
7describe("persist plugin", () => {
8 beforeEach(() => {
9 localStorage.clear();
10 sessionStorage.clear();
11 registerPlugin("persist", persistPlugin);
12 });
13
14 describe("localStorage persistence", () => {
15 it("supports attribute suffix syntax with camelCase signal and storage aliases", async () => {
16 localStorage.setItem("volt:persistedCount", "7");
17
18 const element = document.createElement("div");
19 element.dataset["voltPersist:persistedcount"] = "localStorage";
20
21 const persistedCount = signal(0);
22 mount(element, { persistedCount });
23
24 await new Promise((resolve) => setTimeout(resolve, 0));
25 expect(persistedCount.get()).toBe(7);
26
27 persistedCount.set(9);
28 await new Promise((resolve) => setTimeout(resolve, 0));
29 expect(localStorage.getItem("volt:persistedCount")).toBe("9");
30 });
31
32 it("loads persisted value from localStorage on mount", () => {
33 localStorage.setItem("volt:count", "42");
34
35 const element = document.createElement("div");
36 element.dataset.voltPersist = "count:local";
37
38 const count = signal(0);
39 mount(element, { count });
40
41 expect(count.get()).toBe(42);
42 });
43
44 it("saves signal value to localStorage on change", async () => {
45 const element = document.createElement("div");
46 element.dataset.voltPersist = "count:local";
47
48 const count = signal(0);
49 mount(element, { count });
50
51 count.set(99);
52
53 await new Promise((resolve) => setTimeout(resolve, 0));
54
55 expect(localStorage.getItem("volt:count")).toBe("99");
56 });
57
58 it("persists string values", async () => {
59 const element = document.createElement("div");
60 element.dataset.voltPersist = "name:local";
61
62 const name = signal("Alice");
63 mount(element, { name });
64
65 name.set("Bob");
66
67 await new Promise((resolve) => setTimeout(resolve, 0));
68
69 expect(localStorage.getItem("volt:name")).toBe("\"Bob\"");
70 });
71
72 it("persists object values", async () => {
73 const element = document.createElement("div");
74 element.dataset.voltPersist = "user:local";
75
76 const user = signal({ name: "Alice", age: 30 });
77 mount(element, { user });
78
79 user.set({ name: "Bob", age: 35 });
80
81 await new Promise((resolve) => setTimeout(resolve, 0));
82
83 const stored = localStorage.getItem("volt:user");
84 expect(stored).toBe("{\"name\":\"Bob\",\"age\":35}");
85 });
86
87 it("does not override signal if localStorage is empty", () => {
88 const element = document.createElement("div");
89 element.dataset.voltPersist = "count:local";
90
91 const count = signal(100);
92 mount(element, { count });
93
94 expect(count.get()).toBe(100);
95 });
96 });
97
98 describe("sessionStorage persistence", () => {
99 it("loads persisted value from sessionStorage on mount", () => {
100 sessionStorage.setItem("volt:sessionData", "123");
101
102 const element = document.createElement("div");
103 element.dataset.voltPersist = "sessionData:session";
104
105 const sessionData = signal(0);
106 mount(element, { sessionData });
107
108 expect(sessionData.get()).toBe(123);
109 });
110
111 it("saves signal value to sessionStorage on change", async () => {
112 const element = document.createElement("div");
113 element.dataset.voltPersist = "sessionData:session";
114
115 const sessionData = signal(0);
116 mount(element, { sessionData });
117
118 sessionData.set(456);
119
120 await new Promise((resolve) => setTimeout(resolve, 0));
121
122 expect(sessionStorage.getItem("volt:sessionData")).toBe("456");
123 });
124 });
125
126 describe("custom storage adapters", () => {
127 it("allows registering custom storage adapter", async () => {
128 const customStore = new Map<string, unknown>();
129 registerStorageAdapter("custom", {
130 get: (key) => customStore.get(key),
131 set: (key, value) => {
132 customStore.set(key, value);
133 },
134 remove: (key) => {
135 customStore.delete(key);
136 },
137 });
138
139 customStore.set("volt:data", 999);
140
141 const element = document.createElement("div");
142 element.dataset.voltPersist = "data:custom";
143
144 const data = signal(0);
145 mount(element, { data });
146
147 expect(data.get()).toBe(999);
148
149 data.set(777);
150
151 await new Promise((resolve) => setTimeout(resolve, 0));
152
153 expect(customStore.get("volt:data")).toBe(777);
154 });
155
156 it("supports async custom adapters", async () => {
157 const customStore = new Map<string, unknown>();
158 registerStorageAdapter("async", {
159 get: async (key) => {
160 await new Promise((resolve) => setTimeout(resolve, 10));
161 return customStore.get(key);
162 },
163 set: async (key, value) => {
164 await new Promise((resolve) => setTimeout(resolve, 10));
165 customStore.set(key, value);
166 },
167 remove: async (key) => {
168 await new Promise((resolve) => setTimeout(resolve, 10));
169 customStore.delete(key);
170 },
171 });
172
173 customStore.set("volt:asyncData", 888);
174
175 const element = document.createElement("div");
176 element.dataset.voltPersist = "asyncData:async";
177
178 const asyncData = signal(0);
179 mount(element, { asyncData });
180
181 await new Promise((resolve) => setTimeout(resolve, 20));
182
183 expect(asyncData.get()).toBe(888);
184 });
185 });
186
187 describe("error handling", () => {
188 it("logs error for invalid binding format", () => {
189 const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
190 const element = document.createElement("div");
191 element.dataset.voltPersist = "invalidformat";
192
193 mount(element, {});
194
195 expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining("Invalid persist binding"));
196
197 errorSpy.mockRestore();
198 });
199
200 it("logs error when signal not found", () => {
201 const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
202 const element = document.createElement("div");
203 element.dataset.voltPersist = "nonexistent:local";
204
205 mount(element, {});
206
207 expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining("Signal \"nonexistent\" not found"));
208
209 errorSpy.mockRestore();
210 });
211
212 it("logs error for unknown storage type", () => {
213 const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
214 const element = document.createElement("div");
215 element.dataset.voltPersist = "data:unknown";
216
217 const data = signal(0);
218 mount(element, { data });
219
220 expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining("Unknown storage type: \"unknown\""));
221
222 errorSpy.mockRestore();
223 });
224
225 it("handles storage adapter errors gracefully", async () => {
226 const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
227
228 registerStorageAdapter("faulty", {
229 get: () => {
230 throw new Error("Read error");
231 },
232 set: () => {
233 throw new Error("Write error");
234 },
235 remove: () => {},
236 });
237
238 const element = document.createElement("div");
239 element.dataset.voltPersist = "data:faulty";
240
241 const data = signal(0);
242 mount(element, { data });
243
244 await new Promise((resolve) => setTimeout(resolve, 0));
245
246 expect(errorSpy).toHaveBeenCalled();
247
248 data.set(1);
249
250 await new Promise((resolve) => setTimeout(resolve, 0));
251
252 expect(errorSpy).toHaveBeenCalled();
253
254 errorSpy.mockRestore();
255 });
256 });
257
258 describe("cleanup", () => {
259 it("stops persisting after unmount", async () => {
260 const element = document.createElement("div");
261 element.dataset.voltPersist = "count:local";
262
263 const count = signal(0);
264 const cleanup = mount(element, { count });
265
266 count.set(10);
267 await new Promise((resolve) => setTimeout(resolve, 0));
268 expect(localStorage.getItem("volt:count")).toBe("10");
269
270 cleanup();
271
272 count.set(20);
273 await new Promise((resolve) => setTimeout(resolve, 0));
274 expect(localStorage.getItem("volt:count")).toBe("10");
275 });
276 });
277});