@praxisjs/decorators 0.4.1 → 0.4.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +13 -0
- package/dist/__tests__/functions.test.d.ts +2 -0
- package/dist/__tests__/functions.test.d.ts.map +1 -0
- package/dist/__tests__/functions.test.js +270 -0
- package/dist/__tests__/functions.test.js.map +1 -0
- package/dist/__tests__/memo.test.d.ts +2 -0
- package/dist/__tests__/memo.test.d.ts.map +1 -0
- package/dist/__tests__/memo.test.js +66 -0
- package/dist/__tests__/memo.test.js.map +1 -0
- package/dist/__tests__/properties-browser.test.d.ts +2 -0
- package/dist/__tests__/properties-browser.test.d.ts.map +1 -0
- package/dist/__tests__/properties-browser.test.js +178 -0
- package/dist/__tests__/properties-browser.test.js.map +1 -0
- package/dist/__tests__/properties.test.d.ts +2 -0
- package/dist/__tests__/properties.test.d.ts.map +1 -0
- package/dist/__tests__/properties.test.js +381 -0
- package/dist/__tests__/properties.test.js.map +1 -0
- package/dist/properties/history.d.ts.map +1 -1
- package/dist/properties/history.js +4 -8
- package/dist/properties/history.js.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/functions.test.ts +342 -0
- package/src/__tests__/memo.test.ts +74 -0
- package/src/__tests__/properties-browser.test.ts +234 -0
- package/src/__tests__/properties.test.ts +477 -0
- package/src/properties/history.ts +4 -8
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
import { Bind } from "../functions/bind";
|
|
4
|
+
import { Debounce } from "../functions/debounce";
|
|
5
|
+
import { Log } from "../functions/log";
|
|
6
|
+
import { Once } from "../functions/once";
|
|
7
|
+
import { Retry } from "../functions/retry";
|
|
8
|
+
import { Throttle } from "../functions/throttle";
|
|
9
|
+
|
|
10
|
+
// Helpers to simulate TC39 decorator context
|
|
11
|
+
function mockMethodContext(name: string) {
|
|
12
|
+
const initializers: Array<(this: object) => void> = [];
|
|
13
|
+
const ctx = {
|
|
14
|
+
name,
|
|
15
|
+
kind: "method" as const,
|
|
16
|
+
addInitializer(fn: (this: object) => void) {
|
|
17
|
+
initializers.push(fn);
|
|
18
|
+
},
|
|
19
|
+
runInitializers(instance: object) {
|
|
20
|
+
initializers.forEach((fn) => { fn.call(instance); });
|
|
21
|
+
},
|
|
22
|
+
};
|
|
23
|
+
return ctx;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// ── Once ─────────────────────────────────────────────────────────────────────
|
|
27
|
+
|
|
28
|
+
describe("Once", () => {
|
|
29
|
+
it("only calls the original function once", () => {
|
|
30
|
+
const original = vi.fn(() => 42);
|
|
31
|
+
const ctx = mockMethodContext("doSomething");
|
|
32
|
+
const wrapped = Once()(original, ctx as unknown as ClassMethodDecoratorContext);
|
|
33
|
+
|
|
34
|
+
const obj = {};
|
|
35
|
+
expect(wrapped.call(obj)).toBe(42);
|
|
36
|
+
expect(wrapped.call(obj)).toBe(42);
|
|
37
|
+
expect(original).toHaveBeenCalledTimes(1);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("returns the same result on subsequent calls", () => {
|
|
41
|
+
let counter = 0;
|
|
42
|
+
const original = () => ++counter;
|
|
43
|
+
const ctx = mockMethodContext("inc");
|
|
44
|
+
const wrapped = Once()(original, ctx as unknown as ClassMethodDecoratorContext);
|
|
45
|
+
|
|
46
|
+
const obj = {};
|
|
47
|
+
wrapped.call(obj);
|
|
48
|
+
wrapped.call(obj);
|
|
49
|
+
wrapped.call(obj);
|
|
50
|
+
expect(counter).toBe(1);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("is per-instance — different instances call independently", () => {
|
|
54
|
+
const original = vi.fn(() => "result");
|
|
55
|
+
const ctx = mockMethodContext("fn");
|
|
56
|
+
const wrapped = Once()(original, ctx as unknown as ClassMethodDecoratorContext);
|
|
57
|
+
|
|
58
|
+
const a = {};
|
|
59
|
+
const b = {};
|
|
60
|
+
wrapped.call(a);
|
|
61
|
+
wrapped.call(b);
|
|
62
|
+
expect(original).toHaveBeenCalledTimes(2);
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
// ── Retry ─────────────────────────────────────────────────────────────────────
|
|
67
|
+
|
|
68
|
+
describe("Retry", () => {
|
|
69
|
+
it("returns result if the first attempt succeeds", async () => {
|
|
70
|
+
const original = vi.fn(async () => "ok");
|
|
71
|
+
const ctx = mockMethodContext("fetch");
|
|
72
|
+
const wrapped = Retry(3)(original, ctx as unknown as ClassMethodDecoratorContext);
|
|
73
|
+
|
|
74
|
+
const result = await wrapped.call({});
|
|
75
|
+
expect(result).toBe("ok");
|
|
76
|
+
expect(original).toHaveBeenCalledTimes(1);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("retries and succeeds on a later attempt", async () => {
|
|
80
|
+
let attempt = 0;
|
|
81
|
+
const original = vi.fn(async () => {
|
|
82
|
+
attempt++;
|
|
83
|
+
if (attempt < 3) throw new Error("fail");
|
|
84
|
+
return "success";
|
|
85
|
+
});
|
|
86
|
+
const ctx = mockMethodContext("call");
|
|
87
|
+
const wrapped = Retry(3)(original, ctx as unknown as ClassMethodDecoratorContext);
|
|
88
|
+
|
|
89
|
+
const result = await wrapped.call({});
|
|
90
|
+
expect(result).toBe("success");
|
|
91
|
+
expect(original).toHaveBeenCalledTimes(3);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("throws after exhausting all attempts", async () => {
|
|
95
|
+
const original = vi.fn(async () => {
|
|
96
|
+
throw new Error("always fails");
|
|
97
|
+
});
|
|
98
|
+
const ctx = mockMethodContext("bad");
|
|
99
|
+
const wrapped = Retry(3)(original, ctx as unknown as ClassMethodDecoratorContext);
|
|
100
|
+
|
|
101
|
+
await expect(wrapped.call({})).rejects.toThrow("always fails");
|
|
102
|
+
expect(original).toHaveBeenCalledTimes(3);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("applies delay+backoff between retries", async () => {
|
|
106
|
+
vi.useFakeTimers();
|
|
107
|
+
const original = vi.fn(async () => { throw new Error("fail"); });
|
|
108
|
+
const ctx = mockMethodContext("delayed");
|
|
109
|
+
const wrapped = Retry(3, { delay: 100, backoff: 2 })(
|
|
110
|
+
original,
|
|
111
|
+
ctx as unknown as ClassMethodDecoratorContext,
|
|
112
|
+
);
|
|
113
|
+
const p = wrapped.call({});
|
|
114
|
+
const rejection = expect(p).rejects.toThrow("fail");
|
|
115
|
+
await vi.runAllTimersAsync();
|
|
116
|
+
await rejection;
|
|
117
|
+
expect(original).toHaveBeenCalledTimes(3);
|
|
118
|
+
vi.useRealTimers();
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("calls onRetry callback for each failed attempt (except last)", async () => {
|
|
122
|
+
const onRetry = vi.fn();
|
|
123
|
+
const original = vi.fn(async () => {
|
|
124
|
+
throw new Error("err");
|
|
125
|
+
});
|
|
126
|
+
const ctx = mockMethodContext("op");
|
|
127
|
+
const wrapped = Retry(3, { onRetry })(original, ctx as unknown as ClassMethodDecoratorContext);
|
|
128
|
+
|
|
129
|
+
await expect(wrapped.call({})).rejects.toThrow();
|
|
130
|
+
expect(onRetry).toHaveBeenCalledTimes(2); // attempts 1 and 2, not the last
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
// ── Log ──────────────────────────────────────────────────────────────────────
|
|
135
|
+
|
|
136
|
+
describe("Log", () => {
|
|
137
|
+
it("calls the original function and returns its value", () => {
|
|
138
|
+
const spy = vi.spyOn(console, "log").mockImplementation(() => {});
|
|
139
|
+
const original = vi.fn(() => 99);
|
|
140
|
+
const ctx = mockMethodContext("myMethod");
|
|
141
|
+
const wrapped = Log({ args: false, result: false })(
|
|
142
|
+
original,
|
|
143
|
+
ctx as unknown as ClassMethodDecoratorContext,
|
|
144
|
+
);
|
|
145
|
+
|
|
146
|
+
class Dummy {}
|
|
147
|
+
const result = wrapped.call(new Dummy());
|
|
148
|
+
expect(result).toBe(99);
|
|
149
|
+
spy.mockRestore();
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it("logs args when args=true", () => {
|
|
153
|
+
const spy = vi.spyOn(console, "log").mockImplementation(() => {});
|
|
154
|
+
const original = vi.fn((x: unknown) => x);
|
|
155
|
+
const ctx = mockMethodContext("greet");
|
|
156
|
+
const wrapped = Log({ args: true, result: false })(
|
|
157
|
+
original,
|
|
158
|
+
ctx as unknown as ClassMethodDecoratorContext,
|
|
159
|
+
);
|
|
160
|
+
|
|
161
|
+
class Foo {}
|
|
162
|
+
wrapped.call(new Foo(), "hello");
|
|
163
|
+
expect(spy).toHaveBeenCalledWith(expect.stringContaining("args:"), ["hello"]);
|
|
164
|
+
spy.mockRestore();
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it("does not log in production when devOnly=true", () => {
|
|
168
|
+
const spy = vi.spyOn(console, "log").mockImplementation(() => {});
|
|
169
|
+
const original = vi.fn(() => "value");
|
|
170
|
+
const ctx = mockMethodContext("prod");
|
|
171
|
+
const wrapped = Log({ devOnly: true })(
|
|
172
|
+
original,
|
|
173
|
+
ctx as unknown as ClassMethodDecoratorContext,
|
|
174
|
+
);
|
|
175
|
+
|
|
176
|
+
const prevEnv = process.env.NODE_ENV;
|
|
177
|
+
process.env.NODE_ENV = "production";
|
|
178
|
+
|
|
179
|
+
class App {}
|
|
180
|
+
wrapped.call(new App());
|
|
181
|
+
expect(spy).not.toHaveBeenCalled();
|
|
182
|
+
|
|
183
|
+
process.env.NODE_ENV = prevEnv;
|
|
184
|
+
spy.mockRestore();
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it("handles async functions — logs resolved value", async () => {
|
|
188
|
+
const spy = vi.spyOn(console, "log").mockImplementation(() => {});
|
|
189
|
+
const original = vi.fn(async () => "async-result");
|
|
190
|
+
const ctx = mockMethodContext("load");
|
|
191
|
+
const wrapped = Log({ args: false, result: true })(
|
|
192
|
+
original,
|
|
193
|
+
ctx as unknown as ClassMethodDecoratorContext,
|
|
194
|
+
);
|
|
195
|
+
|
|
196
|
+
class Svc {}
|
|
197
|
+
await wrapped.call(new Svc());
|
|
198
|
+
expect(spy).toHaveBeenCalledWith(
|
|
199
|
+
expect.stringContaining("resolved:"),
|
|
200
|
+
"async-result",
|
|
201
|
+
expect.any(String),
|
|
202
|
+
);
|
|
203
|
+
spy.mockRestore();
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it("handles async rejection — logs rejected value and re-throws", async () => {
|
|
207
|
+
const spy = vi.spyOn(console, "log").mockImplementation(() => {});
|
|
208
|
+
const original = vi.fn(async () => { throw new Error("async-error"); });
|
|
209
|
+
const ctx = mockMethodContext("failing");
|
|
210
|
+
const wrapped = Log({ args: false, result: true })(
|
|
211
|
+
original,
|
|
212
|
+
ctx as unknown as ClassMethodDecoratorContext,
|
|
213
|
+
);
|
|
214
|
+
class Svc {}
|
|
215
|
+
await expect(wrapped.call(new Svc())).rejects.toThrow("async-error");
|
|
216
|
+
expect(spy).toHaveBeenCalledWith(
|
|
217
|
+
expect.stringContaining("rejected:"),
|
|
218
|
+
expect.any(Error),
|
|
219
|
+
);
|
|
220
|
+
spy.mockRestore();
|
|
221
|
+
});
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
// ── Bind ─────────────────────────────────────────────────────────────────────
|
|
225
|
+
|
|
226
|
+
describe("Bind", () => {
|
|
227
|
+
it("binds method to the instance so 'this' is preserved", () => {
|
|
228
|
+
class Counter {
|
|
229
|
+
value = 10;
|
|
230
|
+
getValue() {
|
|
231
|
+
return this.value;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
const ctx = mockMethodContext("getValue");
|
|
235
|
+
Bind()(Counter.prototype.getValue, ctx as unknown as ClassMethodDecoratorContext);
|
|
236
|
+
|
|
237
|
+
const c = new Counter();
|
|
238
|
+
ctx.runInitializers(c);
|
|
239
|
+
|
|
240
|
+
const detached = (c as unknown as Record<string, () => number>).getValue;
|
|
241
|
+
expect(detached()).toBe(10);
|
|
242
|
+
});
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
// ── Debounce ─────────────────────────────────────────────────────────────────
|
|
246
|
+
|
|
247
|
+
describe("Debounce", () => {
|
|
248
|
+
it("defers execution until after the wait period", () => {
|
|
249
|
+
vi.useFakeTimers();
|
|
250
|
+
const fn = vi.fn();
|
|
251
|
+
const ctx = mockMethodContext("search");
|
|
252
|
+
Debounce(100)(fn, ctx as unknown as ClassMethodDecoratorContext);
|
|
253
|
+
|
|
254
|
+
const obj = {};
|
|
255
|
+
ctx.runInitializers(obj);
|
|
256
|
+
const method = (obj as Record<string, (...a: unknown[]) => void>).search;
|
|
257
|
+
|
|
258
|
+
method("a");
|
|
259
|
+
method("b");
|
|
260
|
+
method("c");
|
|
261
|
+
expect(fn).not.toHaveBeenCalled();
|
|
262
|
+
|
|
263
|
+
vi.advanceTimersByTime(100);
|
|
264
|
+
expect(fn).toHaveBeenCalledTimes(1);
|
|
265
|
+
expect(fn).toHaveBeenCalledWith("c");
|
|
266
|
+
vi.useRealTimers();
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
it("resets the timer on each call", () => {
|
|
270
|
+
vi.useFakeTimers();
|
|
271
|
+
const fn = vi.fn();
|
|
272
|
+
const ctx = mockMethodContext("onInput");
|
|
273
|
+
Debounce(50)(fn, ctx as unknown as ClassMethodDecoratorContext);
|
|
274
|
+
|
|
275
|
+
const obj = {};
|
|
276
|
+
ctx.runInitializers(obj);
|
|
277
|
+
const method = (obj as Record<string, (...a: unknown[]) => void>).onInput;
|
|
278
|
+
|
|
279
|
+
method("x");
|
|
280
|
+
vi.advanceTimersByTime(30);
|
|
281
|
+
method("y");
|
|
282
|
+
vi.advanceTimersByTime(30); // 30ms since last call — not enough
|
|
283
|
+
expect(fn).not.toHaveBeenCalled();
|
|
284
|
+
|
|
285
|
+
vi.advanceTimersByTime(20); // now 50ms since "y"
|
|
286
|
+
expect(fn).toHaveBeenCalledOnce();
|
|
287
|
+
vi.useRealTimers();
|
|
288
|
+
});
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
// ── Throttle ─────────────────────────────────────────────────────────────────
|
|
292
|
+
|
|
293
|
+
describe("Throttle", () => {
|
|
294
|
+
it("executes the first call immediately", () => {
|
|
295
|
+
vi.useFakeTimers();
|
|
296
|
+
const fn = vi.fn(() => "first");
|
|
297
|
+
const ctx = mockMethodContext("save");
|
|
298
|
+
Throttle(100)(fn, ctx as unknown as ClassMethodDecoratorContext);
|
|
299
|
+
|
|
300
|
+
const obj = {};
|
|
301
|
+
ctx.runInitializers(obj);
|
|
302
|
+
const method = (obj as Record<string, (...a: unknown[]) => void>).save;
|
|
303
|
+
|
|
304
|
+
method();
|
|
305
|
+
expect(fn).toHaveBeenCalledTimes(1);
|
|
306
|
+
vi.useRealTimers();
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
it("ignores calls within the throttle window", () => {
|
|
310
|
+
vi.useFakeTimers();
|
|
311
|
+
const fn = vi.fn();
|
|
312
|
+
const ctx = mockMethodContext("scroll");
|
|
313
|
+
Throttle(100)(fn, ctx as unknown as ClassMethodDecoratorContext);
|
|
314
|
+
|
|
315
|
+
const obj = {};
|
|
316
|
+
ctx.runInitializers(obj);
|
|
317
|
+
const method = (obj as Record<string, (...a: unknown[]) => void>).scroll;
|
|
318
|
+
|
|
319
|
+
method();
|
|
320
|
+
method();
|
|
321
|
+
method();
|
|
322
|
+
expect(fn).toHaveBeenCalledTimes(1);
|
|
323
|
+
vi.useRealTimers();
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
it("allows a new call after the window expires", () => {
|
|
327
|
+
vi.useFakeTimers();
|
|
328
|
+
const fn = vi.fn();
|
|
329
|
+
const ctx = mockMethodContext("resize");
|
|
330
|
+
Throttle(100)(fn, ctx as unknown as ClassMethodDecoratorContext);
|
|
331
|
+
|
|
332
|
+
const obj = {};
|
|
333
|
+
ctx.runInitializers(obj);
|
|
334
|
+
const method = (obj as Record<string, (...a: unknown[]) => void>).resize;
|
|
335
|
+
|
|
336
|
+
method();
|
|
337
|
+
vi.advanceTimersByTime(100);
|
|
338
|
+
method();
|
|
339
|
+
expect(fn).toHaveBeenCalledTimes(2);
|
|
340
|
+
vi.useRealTimers();
|
|
341
|
+
});
|
|
342
|
+
});
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
import { Memo } from "../functions/memo";
|
|
4
|
+
|
|
5
|
+
function mockCtx(name: string) {
|
|
6
|
+
return { name, kind: "method" as const } as ClassMethodDecoratorContext;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
describe("Memo", () => {
|
|
10
|
+
it("returns the computed value", () => {
|
|
11
|
+
const fn = vi.fn((x: unknown) => (x as number) * 2);
|
|
12
|
+
const wrapped = Memo()(fn, mockCtx("double"));
|
|
13
|
+
const obj = {};
|
|
14
|
+
expect(wrapped.call(obj, 5)).toBe(10);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it("caches result for same args — calls original only once", () => {
|
|
18
|
+
const fn = vi.fn((x: unknown) => (x as number) + 1);
|
|
19
|
+
const wrapped = Memo()(fn, mockCtx("inc"));
|
|
20
|
+
const obj = {};
|
|
21
|
+
wrapped.call(obj, 3);
|
|
22
|
+
wrapped.call(obj, 3);
|
|
23
|
+
wrapped.call(obj, 3);
|
|
24
|
+
expect(fn).toHaveBeenCalledTimes(1);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("computes separately for different args", () => {
|
|
28
|
+
const fn = vi.fn((x: unknown) => (x as number) * 10);
|
|
29
|
+
const wrapped = Memo()(fn, mockCtx("mul"));
|
|
30
|
+
const obj = {};
|
|
31
|
+
expect(wrapped.call(obj, 2)).toBe(20);
|
|
32
|
+
expect(wrapped.call(obj, 3)).toBe(30);
|
|
33
|
+
expect(fn).toHaveBeenCalledTimes(2);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("is per-instance — different instances have separate caches", () => {
|
|
37
|
+
const fn = vi.fn(() => Math.random());
|
|
38
|
+
const wrapped = Memo()(fn, mockCtx("rand"));
|
|
39
|
+
const a = {};
|
|
40
|
+
const b = {};
|
|
41
|
+
const v1 = wrapped.call(a);
|
|
42
|
+
const v2 = wrapped.call(b);
|
|
43
|
+
expect(fn).toHaveBeenCalledTimes(2);
|
|
44
|
+
expect(v1).not.toBe(v2); // different random values per instance
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("serializes no-args calls with __no_args__ key", () => {
|
|
48
|
+
const fn = vi.fn(() => 42);
|
|
49
|
+
const wrapped = Memo()(fn, mockCtx("noop"));
|
|
50
|
+
const obj = {};
|
|
51
|
+
wrapped.call(obj);
|
|
52
|
+
wrapped.call(obj);
|
|
53
|
+
expect(fn).toHaveBeenCalledTimes(1);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("serializes object args via JSON.stringify", () => {
|
|
57
|
+
const fn = vi.fn((o: unknown) => JSON.stringify(o));
|
|
58
|
+
const wrapped = Memo()(fn, mockCtx("obj"));
|
|
59
|
+
const obj = {};
|
|
60
|
+
wrapped.call(obj, { a: 1 });
|
|
61
|
+
wrapped.call(obj, { a: 1 }); // same key
|
|
62
|
+
expect(fn).toHaveBeenCalledTimes(1);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("serializes symbol args via toString()", () => {
|
|
66
|
+
const fn = vi.fn((s: unknown) => String(s));
|
|
67
|
+
const wrapped = Memo()(fn, mockCtx("sym"));
|
|
68
|
+
const obj = {};
|
|
69
|
+
const sym = Symbol("test");
|
|
70
|
+
wrapped.call(obj, sym);
|
|
71
|
+
wrapped.call(obj, sym);
|
|
72
|
+
expect(fn).toHaveBeenCalledTimes(1);
|
|
73
|
+
});
|
|
74
|
+
});
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
3
|
+
|
|
4
|
+
import { StatefulComponent } from "@praxisjs/core";
|
|
5
|
+
|
|
6
|
+
import { Persisted } from "../properties/persisted";
|
|
7
|
+
import { Slot, initSlots, getSlot } from "../properties/slot";
|
|
8
|
+
|
|
9
|
+
beforeEach(() => {
|
|
10
|
+
localStorage.clear();
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
// Helper: creates a ClassFieldDecoratorContext mock
|
|
14
|
+
function fieldCtx(name: string) {
|
|
15
|
+
const initializers: Array<(this: unknown) => void> = [];
|
|
16
|
+
return {
|
|
17
|
+
ctx: {
|
|
18
|
+
name,
|
|
19
|
+
kind: "field" as const,
|
|
20
|
+
addInitializer(fn: (this: unknown) => void) {
|
|
21
|
+
initializers.push(fn);
|
|
22
|
+
},
|
|
23
|
+
} as ClassFieldDecoratorContext,
|
|
24
|
+
run(instance: unknown) {
|
|
25
|
+
initializers.forEach((fn) => { fn.call(instance); });
|
|
26
|
+
},
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Minimal concrete component
|
|
31
|
+
class TestComponent extends StatefulComponent {
|
|
32
|
+
render() { return null; }
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// ── Persisted ─────────────────────────────────────────────────────────────────
|
|
36
|
+
|
|
37
|
+
describe("Persisted decorator", () => {
|
|
38
|
+
it("reads back the initial value when nothing is stored", () => {
|
|
39
|
+
const { ctx, run } = fieldCtx("theme");
|
|
40
|
+
Persisted<string>("theme")(undefined, ctx);
|
|
41
|
+
|
|
42
|
+
const instance = new TestComponent();
|
|
43
|
+
(instance as unknown as Record<string, unknown>).theme = "light";
|
|
44
|
+
run(instance);
|
|
45
|
+
|
|
46
|
+
expect((instance as unknown as Record<string, unknown>).theme).toBe("light");
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("persists set value to localStorage", () => {
|
|
50
|
+
const { ctx, run } = fieldCtx("lang");
|
|
51
|
+
Persisted<string>("lang")(undefined, ctx);
|
|
52
|
+
|
|
53
|
+
const instance = new TestComponent();
|
|
54
|
+
(instance as unknown as Record<string, unknown>).lang = "en";
|
|
55
|
+
run(instance);
|
|
56
|
+
|
|
57
|
+
(instance as unknown as Record<string, unknown>).lang = "pt";
|
|
58
|
+
expect(localStorage.getItem("lang")).toBe('"pt"');
|
|
59
|
+
expect((instance as unknown as Record<string, unknown>).lang).toBe("pt");
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("reads existing localStorage value over the initialValue", () => {
|
|
63
|
+
localStorage.setItem("color", '"red"');
|
|
64
|
+
const { ctx, run } = fieldCtx("color");
|
|
65
|
+
Persisted<string>("color")(undefined, ctx);
|
|
66
|
+
|
|
67
|
+
const instance = new TestComponent();
|
|
68
|
+
(instance as unknown as Record<string, unknown>).color = "blue";
|
|
69
|
+
run(instance);
|
|
70
|
+
|
|
71
|
+
expect((instance as unknown as Record<string, unknown>).color).toBe("red");
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("uses the property name as storage key when no explicit key given", () => {
|
|
75
|
+
const { ctx, run } = fieldCtx("count");
|
|
76
|
+
Persisted<number>()(undefined, ctx);
|
|
77
|
+
|
|
78
|
+
const instance = new TestComponent();
|
|
79
|
+
(instance as unknown as Record<string, unknown>).count = 0;
|
|
80
|
+
run(instance);
|
|
81
|
+
|
|
82
|
+
(instance as unknown as Record<string, unknown>).count = 7;
|
|
83
|
+
expect(localStorage.getItem("count")).toBe("7");
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("returns separate values per instance", () => {
|
|
87
|
+
const { ctx: ctxA, run: runA } = fieldCtx("val");
|
|
88
|
+
Persisted<number>("valA")(undefined, ctxA);
|
|
89
|
+
|
|
90
|
+
const { ctx: ctxB, run: runB } = fieldCtx("val");
|
|
91
|
+
Persisted<number>("valB")(undefined, ctxB);
|
|
92
|
+
|
|
93
|
+
const a = new TestComponent();
|
|
94
|
+
(a as unknown as Record<string, unknown>).val = 1;
|
|
95
|
+
runA(a);
|
|
96
|
+
|
|
97
|
+
const b = new TestComponent();
|
|
98
|
+
(b as unknown as Record<string, unknown>).val = 2;
|
|
99
|
+
runB(b);
|
|
100
|
+
|
|
101
|
+
(a as unknown as Record<string, unknown>).val = 10;
|
|
102
|
+
(b as unknown as Record<string, unknown>).val = 20;
|
|
103
|
+
|
|
104
|
+
expect((a as unknown as Record<string, unknown>).val).toBe(10);
|
|
105
|
+
expect((b as unknown as Record<string, unknown>).val).toBe(20);
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
// ── Slot ──────────────────────────────────────────────────────────────────────
|
|
110
|
+
|
|
111
|
+
describe("Slot decorator", () => {
|
|
112
|
+
it("returns an empty array before initSlots is called", () => {
|
|
113
|
+
const { ctx, run } = fieldCtx("default");
|
|
114
|
+
Slot()(undefined, ctx);
|
|
115
|
+
|
|
116
|
+
const instance = new TestComponent();
|
|
117
|
+
run(instance);
|
|
118
|
+
|
|
119
|
+
expect((instance as unknown as Record<string, unknown>).default).toEqual([]);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("returns default slot children after initSlots", () => {
|
|
123
|
+
const { ctx, run } = fieldCtx("default");
|
|
124
|
+
Slot()(undefined, ctx);
|
|
125
|
+
|
|
126
|
+
const instance = new TestComponent();
|
|
127
|
+
run(instance);
|
|
128
|
+
|
|
129
|
+
const el = document.createElement("span");
|
|
130
|
+
initSlots(instance, [el]);
|
|
131
|
+
|
|
132
|
+
expect((instance as unknown as Record<string, unknown>).default).toContain(el);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it("resolves named slot children", () => {
|
|
136
|
+
const { ctx, run } = fieldCtx("header");
|
|
137
|
+
Slot("header")(undefined, ctx);
|
|
138
|
+
|
|
139
|
+
const instance = new TestComponent();
|
|
140
|
+
run(instance);
|
|
141
|
+
|
|
142
|
+
const el = document.createElement("div");
|
|
143
|
+
el.setAttribute("slot", "header");
|
|
144
|
+
initSlots(instance, [el]);
|
|
145
|
+
|
|
146
|
+
const result = (instance as unknown as Record<string, unknown>).header as unknown[];
|
|
147
|
+
expect(result).toHaveLength(1);
|
|
148
|
+
expect(result[0]).toBe(el);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it("named slot element has slot attribute removed", () => {
|
|
152
|
+
const { ctx, run } = fieldCtx("footer");
|
|
153
|
+
Slot("footer")(undefined, ctx);
|
|
154
|
+
|
|
155
|
+
const instance = new TestComponent();
|
|
156
|
+
run(instance);
|
|
157
|
+
|
|
158
|
+
const el = document.createElement("div");
|
|
159
|
+
el.setAttribute("slot", "footer");
|
|
160
|
+
initSlots(instance, [el]);
|
|
161
|
+
|
|
162
|
+
expect(el.getAttribute("slot")).toBeNull();
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it("warns when attempting to set a slot directly (dev mode)", () => {
|
|
166
|
+
const originalEnv = process.env.NODE_ENV;
|
|
167
|
+
process.env.NODE_ENV = "development";
|
|
168
|
+
|
|
169
|
+
const { ctx, run } = fieldCtx("content");
|
|
170
|
+
Slot("content")(undefined, ctx);
|
|
171
|
+
|
|
172
|
+
const instance = new TestComponent();
|
|
173
|
+
run(instance);
|
|
174
|
+
|
|
175
|
+
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
176
|
+
(instance as unknown as Record<string, unknown>).content = ["something"];
|
|
177
|
+
expect(warn).toHaveBeenCalledWith(expect.stringContaining("[Slot]"));
|
|
178
|
+
warn.mockRestore();
|
|
179
|
+
|
|
180
|
+
process.env.NODE_ENV = originalEnv;
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it("uses property name as slot name when no name given and prop is not 'default'", () => {
|
|
184
|
+
const { ctx, run } = fieldCtx("sidebar");
|
|
185
|
+
Slot()(undefined, ctx);
|
|
186
|
+
|
|
187
|
+
const instance = new TestComponent();
|
|
188
|
+
run(instance);
|
|
189
|
+
|
|
190
|
+
const el = document.createElement("aside");
|
|
191
|
+
el.setAttribute("slot", "sidebar");
|
|
192
|
+
initSlots(instance, [el]);
|
|
193
|
+
|
|
194
|
+
const result = (instance as unknown as Record<string, unknown>).sidebar as unknown[];
|
|
195
|
+
expect(result).toHaveLength(1);
|
|
196
|
+
});
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
// ── initSlots / getSlot helpers ───────────────────────────────────────────────
|
|
200
|
+
|
|
201
|
+
describe("initSlots / getSlot", () => {
|
|
202
|
+
it("splits children into named and default slots", () => {
|
|
203
|
+
const named = document.createElement("div");
|
|
204
|
+
named.setAttribute("slot", "header");
|
|
205
|
+
|
|
206
|
+
const plain = document.createElement("p");
|
|
207
|
+
|
|
208
|
+
const instance = {};
|
|
209
|
+
initSlots(instance, [named, plain]);
|
|
210
|
+
|
|
211
|
+
expect(getSlot(instance, "header")).toContain(named);
|
|
212
|
+
expect(getSlot(instance, "default")).toContain(plain);
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
it("handles null children gracefully", () => {
|
|
216
|
+
const instance = {};
|
|
217
|
+
initSlots(instance, null);
|
|
218
|
+
expect(getSlot(instance, "default")).toEqual([]);
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
it("flattens nested arrays", () => {
|
|
222
|
+
const a = document.createElement("span");
|
|
223
|
+
const b = document.createElement("span");
|
|
224
|
+
const instance = {};
|
|
225
|
+
initSlots(instance, [[a, b]]);
|
|
226
|
+
expect(getSlot(instance, "default")).toHaveLength(2);
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it("returns empty array for unknown slot", () => {
|
|
230
|
+
const instance = {};
|
|
231
|
+
initSlots(instance, []);
|
|
232
|
+
expect(getSlot(instance, "nonexistent")).toEqual([]);
|
|
233
|
+
});
|
|
234
|
+
});
|