@pyreon/vue-compat 0.1.0

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.
@@ -0,0 +1,698 @@
1
+ import type { ComponentFn } from "@pyreon/core"
2
+ import { mount } from "@pyreon/runtime-dom"
3
+ import {
4
+ batch,
5
+ computed,
6
+ createApp,
7
+ defineComponent,
8
+ Fragment,
9
+ h,
10
+ inject,
11
+ isRef,
12
+ nextTick,
13
+ onBeforeMount,
14
+ onBeforeUnmount,
15
+ onMounted,
16
+ onUnmounted,
17
+ onUpdated,
18
+ provide,
19
+ reactive,
20
+ readonly,
21
+ ref,
22
+ shallowReactive,
23
+ shallowRef,
24
+ toRaw,
25
+ toRef,
26
+ toRefs,
27
+ triggerRef,
28
+ unref,
29
+ watch,
30
+ watchEffect,
31
+ } from "../index"
32
+
33
+ function container(): HTMLElement {
34
+ const el = document.createElement("div")
35
+ document.body.appendChild(el)
36
+ return el
37
+ }
38
+
39
+ describe("@pyreon/vue-compat", () => {
40
+ // ─── ref ────────────────────────────────────────────────────────────────
41
+
42
+ it("ref() creates reactive ref with .value", () => {
43
+ const count = ref(0)
44
+ expect(count.value).toBe(0)
45
+ })
46
+
47
+ it("ref().value setter updates value", () => {
48
+ const count = ref(0)
49
+ count.value = 5
50
+ expect(count.value).toBe(5)
51
+ })
52
+
53
+ // ─── shallowRef ────────────────────────────────────────────────────────
54
+
55
+ it("shallowRef() creates a ref (same as ref in Pyreon)", () => {
56
+ const r = shallowRef(42)
57
+ expect(r.value).toBe(42)
58
+ expect(isRef(r)).toBe(true)
59
+ r.value = 100
60
+ expect(r.value).toBe(100)
61
+ })
62
+
63
+ // ─── triggerRef ────────────────────────────────────────────────────────
64
+
65
+ it("triggerRef forces subscribers to re-run", () => {
66
+ const r = ref(0)
67
+ let runs = 0
68
+
69
+ const stop = watchEffect(() => {
70
+ void r.value
71
+ runs++
72
+ })
73
+
74
+ expect(runs).toBe(1)
75
+ triggerRef(r)
76
+ expect(runs).toBe(3) // set undefined then set back = 2 triggers
77
+ stop()
78
+ })
79
+
80
+ // ─── isRef ─────────────────────────────────────────────────────────────
81
+
82
+ it("isRef() detects refs", () => {
83
+ const r = ref(0)
84
+ expect(isRef(r)).toBe(true)
85
+ expect(isRef(0)).toBe(false)
86
+ expect(isRef({ value: 0 })).toBe(false)
87
+ expect(isRef(null)).toBe(false)
88
+
89
+ const c = computed(() => 42)
90
+ expect(isRef(c)).toBe(true)
91
+ })
92
+
93
+ // ─── unref ─────────────────────────────────────────────────────────────
94
+
95
+ it("unref() unwraps refs", () => {
96
+ const r = ref(42)
97
+ expect(unref(r)).toBe(42)
98
+ expect(unref(99)).toBe(99)
99
+ })
100
+
101
+ // ─── computed ───────────────────────────────────────────────────────────
102
+
103
+ it("computed() derives from ref", () => {
104
+ const count = ref(2)
105
+ const doubled = computed(() => count.value * 2)
106
+ expect(doubled.value).toBe(4)
107
+
108
+ count.value = 10
109
+ expect(doubled.value).toBe(20)
110
+ })
111
+
112
+ it("computed().value is readonly", () => {
113
+ const c = computed(() => 42)
114
+ expect(() => {
115
+ ;(c as { value: number }).value = 99
116
+ }).toThrow("readonly")
117
+ })
118
+
119
+ // ─── reactive ──────────────────────────────────────────────────────────
120
+
121
+ it("reactive() creates deep reactive object", () => {
122
+ const state = reactive({ count: 0, nested: { value: "hello" } })
123
+ const values: number[] = []
124
+
125
+ const stop = watchEffect(() => {
126
+ values.push(state.count)
127
+ })
128
+
129
+ state.count = 1
130
+ state.count = 2
131
+
132
+ expect(values).toEqual([0, 1, 2])
133
+ stop()
134
+ })
135
+
136
+ // ─── shallowReactive ──────────────────────────────────────────────────
137
+
138
+ it("shallowReactive() creates reactive object (same as reactive)", () => {
139
+ const state = shallowReactive({ count: 0 })
140
+ const values: number[] = []
141
+
142
+ const stop = watchEffect(() => {
143
+ values.push(state.count)
144
+ })
145
+
146
+ state.count = 5
147
+ expect(values).toEqual([0, 5])
148
+ stop()
149
+ })
150
+
151
+ // ─── readonly ──────────────────────────────────────────────────────────
152
+
153
+ it("readonly() prevents mutations", () => {
154
+ const obj = readonly({ count: 0 })
155
+ expect(obj.count).toBe(0)
156
+ expect(() => {
157
+ ;(obj as { count: number }).count = 5
158
+ }).toThrow("readonly")
159
+ })
160
+
161
+ it("readonly() prevents delete", () => {
162
+ const obj = readonly({ count: 0 }) as Record<string, unknown>
163
+ expect(() => {
164
+ delete obj.count
165
+ }).toThrow("Cannot delete")
166
+ })
167
+
168
+ it("readonly() throws on symbol property set", () => {
169
+ const obj = readonly({ count: 0 })
170
+ const sym = Symbol("test")
171
+ // Only internal symbols (V_IS_READONLY, V_RAW) are allowed; all others throw
172
+ expect(() => {
173
+ ;(obj as Record<symbol, unknown>)[sym] = "value"
174
+ }).toThrow("readonly")
175
+ })
176
+
177
+ it("readonly() exposes V_IS_READONLY symbol", () => {
178
+ const obj = readonly({ count: 0 })
179
+ // The readonly proxy should have the V_IS_READONLY symbol accessible
180
+ expect(typeof obj).toBe("object")
181
+ })
182
+
183
+ // ─── toRaw ─────────────────────────────────────────────────────────────
184
+
185
+ it("toRaw() returns raw object for reactive", () => {
186
+ const original = { count: 0 }
187
+ const state = reactive(original)
188
+ const raw = toRaw(state)
189
+ expect(raw).toBe(original)
190
+ })
191
+
192
+ it("toRaw() returns raw object for readonly", () => {
193
+ const original = { count: 0 }
194
+ const ro = readonly(original)
195
+ const raw = toRaw(ro)
196
+ expect(raw).toBe(original)
197
+ })
198
+
199
+ it("toRaw() returns same object for plain object", () => {
200
+ const obj = { a: 1 }
201
+ expect(toRaw(obj)).toBe(obj)
202
+ })
203
+
204
+ // ─── toRef ─────────────────────────────────────────────────────────────
205
+
206
+ it("toRef() creates ref linked to reactive property", () => {
207
+ const state = reactive({ count: 0 })
208
+ const countRef = toRef(state, "count")
209
+
210
+ expect(isRef(countRef)).toBe(true)
211
+ expect(countRef.value).toBe(0)
212
+
213
+ // Writing through ref updates original
214
+ countRef.value = 10
215
+ expect(state.count).toBe(10)
216
+
217
+ // Writing to original updates ref
218
+ state.count = 20
219
+ expect(countRef.value).toBe(20)
220
+ })
221
+
222
+ // ─── toRefs ────────────────────────────────────────────────────────────
223
+
224
+ it("toRefs() converts reactive to refs", () => {
225
+ const state = reactive({ a: 1, b: "hello" })
226
+ const refs = toRefs(state)
227
+
228
+ expect(isRef(refs.a)).toBe(true)
229
+ expect(refs.a.value).toBe(1)
230
+ expect(refs.b.value).toBe("hello")
231
+
232
+ refs.a.value = 10
233
+ expect(state.a).toBe(10)
234
+ })
235
+
236
+ // ─── watch ──────────────────────────────────────────────────────────────
237
+
238
+ it("watch() fires on ref change", () => {
239
+ const count = ref(0)
240
+ const calls: number[] = []
241
+
242
+ const stop = watch(count, (newVal) => {
243
+ calls.push(newVal)
244
+ })
245
+
246
+ count.value = 1
247
+ count.value = 2
248
+
249
+ expect(calls).toEqual([1, 2])
250
+ stop()
251
+ })
252
+
253
+ it("watch() provides old and new values", () => {
254
+ const count = ref(10)
255
+ const history: [number, number | undefined][] = []
256
+
257
+ const stop = watch(count, (newVal, oldVal) => {
258
+ history.push([newVal, oldVal])
259
+ })
260
+
261
+ count.value = 20
262
+ count.value = 30
263
+
264
+ expect(history).toEqual([
265
+ [20, 10],
266
+ [30, 20],
267
+ ])
268
+ stop()
269
+ })
270
+
271
+ it("watch() with immediate fires synchronously", () => {
272
+ const count = ref(5)
273
+ const calls: [number, number | undefined][] = []
274
+
275
+ const stop = watch(
276
+ count,
277
+ (newVal, oldVal) => {
278
+ calls.push([newVal, oldVal])
279
+ },
280
+ { immediate: true },
281
+ )
282
+
283
+ expect(calls.length).toBeGreaterThanOrEqual(1)
284
+ expect(calls[0]).toEqual([5, undefined])
285
+ stop()
286
+ })
287
+
288
+ it("watch() with getter function as source", () => {
289
+ const count = ref(0)
290
+ const calls: number[] = []
291
+
292
+ const stop = watch(
293
+ () => count.value * 2,
294
+ (newVal) => {
295
+ calls.push(newVal)
296
+ },
297
+ )
298
+
299
+ count.value = 5
300
+ expect(calls).toContain(10)
301
+ stop()
302
+ })
303
+
304
+ it("watch() stop function disposes watcher", () => {
305
+ const count = ref(0)
306
+ const calls: number[] = []
307
+
308
+ const stop = watch(count, (newVal) => {
309
+ calls.push(newVal)
310
+ })
311
+
312
+ count.value = 1
313
+ expect(calls).toEqual([1])
314
+
315
+ stop()
316
+ count.value = 2
317
+ expect(calls).toEqual([1]) // no more updates
318
+ })
319
+
320
+ // ─── watchEffect ───────────────────────────────────────────────────────
321
+
322
+ it("watchEffect() tracks dependencies", () => {
323
+ const count = ref(0)
324
+ const values: number[] = []
325
+
326
+ const stop = watchEffect(() => {
327
+ values.push(count.value)
328
+ })
329
+
330
+ count.value = 1
331
+ count.value = 2
332
+
333
+ expect(values).toEqual([0, 1, 2])
334
+
335
+ stop()
336
+ count.value = 3
337
+ expect(values).toEqual([0, 1, 2])
338
+ })
339
+
340
+ // ─── nextTick ──────────────────────────────────────────────────────────
341
+
342
+ it("nextTick() resolves after flush", async () => {
343
+ const count = ref(0)
344
+ count.value = 42
345
+ await nextTick()
346
+ expect(count.value).toBe(42)
347
+ })
348
+
349
+ // ─── lifecycle ─────────────────────────────────────────────────────────
350
+
351
+ it("onMounted/onUnmounted lifecycle hooks work", () => {
352
+ const mounted: string[] = []
353
+ const unmounted: string[] = []
354
+
355
+ const Comp = defineComponent({
356
+ name: "TestComp",
357
+ setup() {
358
+ onMounted(() => {
359
+ mounted.push("mounted")
360
+ })
361
+ onUnmounted(() => {
362
+ unmounted.push("unmounted")
363
+ })
364
+ return () => h("div", null, "test")
365
+ },
366
+ })
367
+
368
+ const el = container()
369
+ const unmount = mount(h(Comp, null), el)
370
+
371
+ expect(mounted).toEqual(["mounted"])
372
+ expect(unmounted).toEqual([])
373
+
374
+ unmount()
375
+ expect(unmounted).toEqual(["unmounted"])
376
+ })
377
+
378
+ it("onBeforeMount works (maps to onMount)", () => {
379
+ const calls: string[] = []
380
+
381
+ const Comp = defineComponent({
382
+ setup() {
383
+ onBeforeMount(() => {
384
+ calls.push("beforeMount")
385
+ })
386
+ return () => h("div", null, "test")
387
+ },
388
+ })
389
+
390
+ const el = container()
391
+ const unmount = mount(h(Comp, null), el)
392
+ expect(calls).toEqual(["beforeMount"])
393
+ unmount()
394
+ })
395
+
396
+ it("onBeforeUnmount works (maps to onUnmount)", () => {
397
+ const calls: string[] = []
398
+
399
+ const Comp = defineComponent({
400
+ setup() {
401
+ onBeforeUnmount(() => {
402
+ calls.push("beforeUnmount")
403
+ })
404
+ return () => h("div", null, "test")
405
+ },
406
+ })
407
+
408
+ const el = container()
409
+ const unmount = mount(h(Comp, null), el)
410
+ expect(calls).toEqual([])
411
+ unmount()
412
+ expect(calls).toEqual(["beforeUnmount"])
413
+ })
414
+
415
+ it("onUpdated is a function", () => {
416
+ expect(typeof onUpdated).toBe("function")
417
+ })
418
+
419
+ // ─── provide / inject ─────────────────────────────────────────────────
420
+
421
+ it("provide/inject with string key", () => {
422
+ provide("theme", "dark")
423
+ expect(inject("theme")).toBe("dark")
424
+ })
425
+
426
+ it("provide/inject with symbol key", () => {
427
+ const key = Symbol("test-key")
428
+ provide(key, { value: 42 })
429
+ expect((inject(key) as { value: number }).value).toBe(42)
430
+ })
431
+
432
+ it("inject returns default value when not provided", () => {
433
+ const key = Symbol("missing-key")
434
+ expect(inject(key, "fallback")).toBe("fallback")
435
+ })
436
+
437
+ it("inject returns undefined when not provided and no default", () => {
438
+ const key = Symbol("no-default")
439
+ expect(inject(key)).toBeUndefined()
440
+ })
441
+
442
+ // ─── defineComponent ──────────────────────────────────────────────────
443
+
444
+ it("defineComponent with setup function returning render fn", () => {
445
+ const Comp = defineComponent({
446
+ name: "TestComp",
447
+ setup() {
448
+ const count = ref(0)
449
+ return () => h("div", null, String(count.value))
450
+ },
451
+ })
452
+
453
+ const el = container()
454
+ const unmount = mount(h(Comp, null), el)
455
+ expect(el.textContent).toBe("0")
456
+ unmount()
457
+ })
458
+
459
+ it("defineComponent with setup returning VNode directly", () => {
460
+ const Comp = defineComponent({
461
+ setup() {
462
+ return h("span", null, "direct")
463
+ },
464
+ })
465
+
466
+ const el = container()
467
+ const unmount = mount(h(Comp, null), el)
468
+ expect(el.textContent).toBe("direct")
469
+ unmount()
470
+ })
471
+
472
+ it("defineComponent with function shorthand", () => {
473
+ const Comp = defineComponent(() => h("div", null, "shorthand"))
474
+ const el = container()
475
+ const unmount = mount(h(Comp, null), el)
476
+ expect(el.textContent).toBe("shorthand")
477
+ unmount()
478
+ })
479
+
480
+ it("defineComponent with name sets function name", () => {
481
+ const Comp = defineComponent({
482
+ name: "MyComponent",
483
+ setup() {
484
+ return h("div", null, "named")
485
+ },
486
+ })
487
+ expect(Comp.name).toBe("MyComponent")
488
+ })
489
+
490
+ it("defineComponent without name", () => {
491
+ const Comp = defineComponent({
492
+ setup() {
493
+ return h("div", null, "unnamed")
494
+ },
495
+ })
496
+ expect(typeof Comp).toBe("function")
497
+ })
498
+
499
+ // ─── h / Fragment ─────────────────────────────────────────────────────
500
+
501
+ it("h is re-exported", () => {
502
+ expect(typeof h).toBe("function")
503
+ const vnode = h("div", null, "test")
504
+ expect(vnode.type).toBe("div")
505
+ })
506
+
507
+ it("Fragment is re-exported", () => {
508
+ expect(typeof Fragment).toBe("symbol")
509
+ })
510
+
511
+ // ─── createApp ────────────────────────────────────────────────────────
512
+
513
+ it("createApp().mount mounts to element", () => {
514
+ const Comp = () => h("div", null, "app")
515
+ const el = container()
516
+ const app = createApp(Comp)
517
+ const unmount = app.mount(el)
518
+ expect(el.textContent).toBe("app")
519
+ unmount()
520
+ })
521
+
522
+ it("createApp().mount with string selector", () => {
523
+ const el = container()
524
+ el.id = "test-app-mount"
525
+ document.body.appendChild(el)
526
+
527
+ const Comp = () => h("div", null, "selector-app")
528
+ const app = createApp(Comp)
529
+ const unmount = app.mount("#test-app-mount")
530
+ expect(el.textContent).toBe("selector-app")
531
+ unmount()
532
+ })
533
+
534
+ it("createApp().mount throws for missing selector", () => {
535
+ const Comp = () => h("div", null, "app")
536
+ const app = createApp(Comp)
537
+ expect(() => app.mount("#nonexistent-element")).toThrow("Cannot find mount target")
538
+ })
539
+
540
+ it("createApp with props passes them to component", () => {
541
+ const Comp = ((props: { name: string }) => h("div", null, props.name)) as ComponentFn
542
+ const el = container()
543
+ const app = createApp(Comp, { name: "world" })
544
+ const unmount = app.mount(el)
545
+ expect(el.textContent).toBe("world")
546
+ unmount()
547
+ })
548
+
549
+ // ─── batch ────────────────────────────────────────────────────────────
550
+
551
+ it("batch is re-exported and coalesces updates", () => {
552
+ const count = ref(0)
553
+ const values: number[] = []
554
+
555
+ const stop = watchEffect(() => {
556
+ values.push(count.value)
557
+ })
558
+
559
+ batch(() => {
560
+ count.value = 1
561
+ count.value = 2
562
+ count.value = 3
563
+ })
564
+
565
+ // Should have initial (0) and then final batch result (3)
566
+ expect(values[0]).toBe(0)
567
+ expect(values[values.length - 1]).toBe(3)
568
+ stop()
569
+ })
570
+
571
+ // ─── triggerRef edge: no _signal ────────────────────────────────────────
572
+
573
+ it("triggerRef is a no-op if ref has no _signal", () => {
574
+ // Create a fake ref without _signal
575
+ const fakeRef = { value: 42 } as unknown as ReturnType<typeof ref>
576
+ // Should not throw
577
+ expect(() => triggerRef(fakeRef)).not.toThrow()
578
+ })
579
+
580
+ // ─── isRef edge cases ──────────────────────────────────────────────────
581
+
582
+ it("isRef returns false for undefined", () => {
583
+ expect(isRef(undefined)).toBe(false)
584
+ })
585
+
586
+ it("isRef returns false for string", () => {
587
+ expect(isRef("hello")).toBe(false)
588
+ })
589
+
590
+ // ─── readonly get V_IS_READONLY ────────────────────────────────────────
591
+
592
+ it("readonly proxy reports V_IS_READONLY via symbol", () => {
593
+ const obj = readonly({ count: 0 })
594
+ // Access the internal V_IS_READONLY symbol via a known property read
595
+ // The proxy get trap handles this symbol
596
+ const _V_IS_READONLY = Symbol("__v_isReadonly")
597
+ // We can't access the private symbol directly, but we can verify it doesn't throw
598
+ // when accessing regular properties
599
+ expect(obj.count).toBe(0)
600
+ })
601
+
602
+ // ─── readonly get V_RAW ─────────────────────────────────────────────────
603
+
604
+ it("toRaw retrieves raw from readonly proxy", () => {
605
+ const original = { a: 1, b: 2 }
606
+ const ro = readonly(original)
607
+ const raw = toRaw(ro)
608
+ expect(raw).toBe(original)
609
+ })
610
+
611
+ // ─── watch with immediate + subsequent changes ─────────────────────────
612
+
613
+ it("watch with immediate tracks subsequent changes too", () => {
614
+ const count = ref(0)
615
+ const calls: [number, number | undefined][] = []
616
+
617
+ const stop = watch(
618
+ count,
619
+ (newVal, oldVal) => {
620
+ calls.push([newVal, oldVal])
621
+ },
622
+ { immediate: true },
623
+ )
624
+
625
+ // First call from immediate
626
+ expect(calls[0]).toEqual([0, undefined])
627
+
628
+ count.value = 10
629
+ // Should have the change tracked
630
+ const lastCall = calls[calls.length - 1]!
631
+ expect(lastCall[0]).toBe(10)
632
+
633
+ stop()
634
+ })
635
+
636
+ // ─── watch with getter and immediate ──────────────────────────────────
637
+
638
+ it("watch with getter function and immediate", () => {
639
+ const count = ref(5)
640
+ const calls: [number, number | undefined][] = []
641
+
642
+ const stop = watch(
643
+ () => count.value * 2,
644
+ (newVal, oldVal) => {
645
+ calls.push([newVal, oldVal])
646
+ },
647
+ { immediate: true },
648
+ )
649
+
650
+ expect(calls[0]).toEqual([10, undefined])
651
+ stop()
652
+ })
653
+
654
+ // ─── createApp with no props ───────────────────────────────────────────
655
+
656
+ it("createApp with no props", () => {
657
+ const Comp = () => h("div", null, "no-props")
658
+ const el = container()
659
+ const app = createApp(Comp)
660
+ const unmount = app.mount(el)
661
+ expect(el.textContent).toBe("no-props")
662
+ unmount()
663
+ })
664
+
665
+ // ─── defineComponent setup returning function ──────────────────────────
666
+
667
+ it("defineComponent setup returning VNodeChild (non-function) renders", () => {
668
+ const Comp = defineComponent({
669
+ setup() {
670
+ return h("p", null, "static-vnode")
671
+ },
672
+ })
673
+ const el = container()
674
+ const unmount = mount(h(Comp, null), el)
675
+ expect(el.querySelector("p")?.textContent).toBe("static-vnode")
676
+ unmount()
677
+ })
678
+
679
+ // ─── provide overwrite ──────────────────────────────────────────────────
680
+
681
+ it("provide overwrites previously provided value", () => {
682
+ const key = "overwrite-test"
683
+ provide(key, "first")
684
+ expect(inject(key)).toBe("first")
685
+ provide(key, "second")
686
+ expect(inject(key)).toBe("second")
687
+ })
688
+
689
+ // ─── getOrCreateContext reuses existing ─────────────────────────────────
690
+
691
+ it("inject returns provided value for existing string key", () => {
692
+ const key = "reuse-context-test"
693
+ provide(key, 123)
694
+ expect(inject(key)).toBe(123)
695
+ // Call again to ensure it reuses
696
+ expect(inject(key)).toBe(123)
697
+ })
698
+ })