onejs-react 0.1.8 → 0.1.10
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/package.json +1 -1
- package/src/__tests__/collection-sync.test.tsx +436 -0
- package/src/__tests__/components.test.tsx +3 -1
- package/src/__tests__/hooks.test.tsx +581 -0
- package/src/__tests__/host-config.test.ts +7 -5
- package/src/__tests__/mocks.ts +40 -0
- package/src/__tests__/setup.ts +5 -3
- package/src/__tests__/style-parser.test.ts +6 -4
- package/src/hooks.ts +184 -22
- package/src/host-config.ts +37 -77
- package/src/index.ts +2 -2
|
@@ -0,0 +1,581 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for C# interop hooks and utilities
|
|
3
|
+
*
|
|
4
|
+
* Tests cover:
|
|
5
|
+
* - toArray: Converting C# collections (List<T>, arrays) to JS arrays
|
|
6
|
+
* - useFrameSync (simple mode): Object.is comparison for primitives
|
|
7
|
+
* - useFrameSync (selector mode): Deps-based change detection for C# proxy objects
|
|
8
|
+
* - useFrameSyncWith (deprecated): Custom equality comparison
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"
|
|
12
|
+
import React from "react"
|
|
13
|
+
import { useFrameSync, useFrameSyncWith, toArray } from "../hooks"
|
|
14
|
+
import { render, unmount } from "../renderer"
|
|
15
|
+
import { createMockContainer, flushMicrotasks } from "./mocks"
|
|
16
|
+
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
// RAF mock — needed because hooks use requestAnimationFrame for polling
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
|
|
21
|
+
type RafCallback = (time: number) => void
|
|
22
|
+
|
|
23
|
+
let rafQueue: Array<{ id: number; callback: RafCallback }> = []
|
|
24
|
+
let nextRafId = 0
|
|
25
|
+
|
|
26
|
+
/** Flush all pending requestAnimationFrame callbacks (one round). */
|
|
27
|
+
function flushRaf() {
|
|
28
|
+
const queue = [...rafQueue]
|
|
29
|
+
rafQueue = []
|
|
30
|
+
const now = Date.now()
|
|
31
|
+
for (const { callback } of queue) {
|
|
32
|
+
callback(now)
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Flush RAF then microtasks — simulates a full frame tick for the hook. */
|
|
37
|
+
async function advanceFrame() {
|
|
38
|
+
flushRaf()
|
|
39
|
+
await flushMicrotasks()
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
beforeEach(() => {
|
|
43
|
+
rafQueue = []
|
|
44
|
+
nextRafId = 0
|
|
45
|
+
;(globalThis as any).requestAnimationFrame = vi.fn((cb: RafCallback) => {
|
|
46
|
+
const id = ++nextRafId
|
|
47
|
+
rafQueue.push({ id, callback: cb })
|
|
48
|
+
return id
|
|
49
|
+
})
|
|
50
|
+
;(globalThis as any).cancelAnimationFrame = vi.fn((id: number) => {
|
|
51
|
+
rafQueue = rafQueue.filter((e) => e.id !== id)
|
|
52
|
+
})
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
afterEach(() => {
|
|
56
|
+
delete (globalThis as any).requestAnimationFrame
|
|
57
|
+
delete (globalThis as any).cancelAnimationFrame
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
// ===========================================================================
|
|
61
|
+
// toArray
|
|
62
|
+
// ===========================================================================
|
|
63
|
+
|
|
64
|
+
describe("toArray", () => {
|
|
65
|
+
it("returns empty array for null", () => {
|
|
66
|
+
expect(toArray(null)).toEqual([])
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it("returns empty array for undefined", () => {
|
|
70
|
+
expect(toArray(undefined)).toEqual([])
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
it("converts a C# List (Count property)", () => {
|
|
74
|
+
const mockList: Record<string, unknown> = {
|
|
75
|
+
Count: 3,
|
|
76
|
+
0: "alpha",
|
|
77
|
+
1: "beta",
|
|
78
|
+
2: "gamma",
|
|
79
|
+
}
|
|
80
|
+
expect(toArray(mockList)).toEqual(["alpha", "beta", "gamma"])
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
it("converts a C# array (Length property)", () => {
|
|
84
|
+
const mockArray: Record<string, unknown> = {
|
|
85
|
+
Length: 2,
|
|
86
|
+
0: 10,
|
|
87
|
+
1: 20,
|
|
88
|
+
}
|
|
89
|
+
expect(toArray(mockArray)).toEqual([10, 20])
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
it("returns empty array for empty collection with Count=0", () => {
|
|
93
|
+
expect(toArray({ Count: 0 })).toEqual([])
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
it("returns empty array for empty collection with Length=0", () => {
|
|
97
|
+
expect(toArray({ Length: 0 })).toEqual([])
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
it("prefers Count over Length when both exist", () => {
|
|
101
|
+
const mock: Record<string, unknown> = {
|
|
102
|
+
Count: 2,
|
|
103
|
+
Length: 3,
|
|
104
|
+
0: "x",
|
|
105
|
+
1: "y",
|
|
106
|
+
2: "z",
|
|
107
|
+
}
|
|
108
|
+
// Should use Count (2), not Length (3)
|
|
109
|
+
expect(toArray(mock)).toEqual(["x", "y"])
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
it("returns empty array for objects without Count or Length", () => {
|
|
113
|
+
expect(toArray({})).toEqual([])
|
|
114
|
+
expect(toArray({ foo: "bar" })).toEqual([])
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
it("returns empty array for non-numeric Count/Length", () => {
|
|
118
|
+
expect(toArray({ Count: "not a number" })).toEqual([])
|
|
119
|
+
expect(toArray({ Length: null })).toEqual([])
|
|
120
|
+
expect(toArray({ Count: true })).toEqual([])
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
it("handles collection with object elements", () => {
|
|
124
|
+
const mockList: Record<string, unknown> = {
|
|
125
|
+
Count: 2,
|
|
126
|
+
0: { id: 1, name: "Sword" },
|
|
127
|
+
1: { id: 2, name: "Shield" },
|
|
128
|
+
}
|
|
129
|
+
const result = toArray<{ id: number; name: string }>(mockList)
|
|
130
|
+
expect(result).toHaveLength(2)
|
|
131
|
+
expect(result[0].id).toBe(1)
|
|
132
|
+
expect(result[0].name).toBe("Sword")
|
|
133
|
+
expect(result[1].id).toBe(2)
|
|
134
|
+
expect(result[1].name).toBe("Shield")
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
it("handles single-element collection", () => {
|
|
138
|
+
expect(toArray({ Count: 1, 0: "only" })).toEqual(["only"])
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
it("handles large collections", () => {
|
|
142
|
+
const mock: Record<string, unknown> = { Count: 100 }
|
|
143
|
+
for (let i = 0; i < 100; i++) {
|
|
144
|
+
mock[i] = i * 2
|
|
145
|
+
}
|
|
146
|
+
const result = toArray<number>(mock)
|
|
147
|
+
expect(result).toHaveLength(100)
|
|
148
|
+
expect(result[0]).toBe(0)
|
|
149
|
+
expect(result[50]).toBe(100)
|
|
150
|
+
expect(result[99]).toBe(198)
|
|
151
|
+
})
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
// ===========================================================================
|
|
155
|
+
// useFrameSync — simple mode (no selector)
|
|
156
|
+
// ===========================================================================
|
|
157
|
+
|
|
158
|
+
describe("useFrameSync (simple mode)", () => {
|
|
159
|
+
let container: ReturnType<typeof createMockContainer>
|
|
160
|
+
|
|
161
|
+
beforeEach(() => {
|
|
162
|
+
container = createMockContainer()
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
afterEach(() => {
|
|
166
|
+
unmount(container as any)
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
function renderHookCapture<T>(
|
|
170
|
+
getter: () => T,
|
|
171
|
+
deps?: readonly unknown[]
|
|
172
|
+
) {
|
|
173
|
+
let capturedValue: T = undefined as T
|
|
174
|
+
let renderCount = 0
|
|
175
|
+
|
|
176
|
+
function TestComponent() {
|
|
177
|
+
const value = useFrameSync(getter, deps)
|
|
178
|
+
capturedValue = value
|
|
179
|
+
renderCount++
|
|
180
|
+
return null
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
render(<TestComponent />, container as any)
|
|
184
|
+
|
|
185
|
+
return {
|
|
186
|
+
get value() { return capturedValue },
|
|
187
|
+
get renderCount() { return renderCount },
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
it("returns initial value from getter", async () => {
|
|
192
|
+
const capture = renderHookCapture(() => 42)
|
|
193
|
+
await flushMicrotasks()
|
|
194
|
+
expect(capture.value).toBe(42)
|
|
195
|
+
})
|
|
196
|
+
|
|
197
|
+
it("updates when primitive value changes", async () => {
|
|
198
|
+
let counter = 0
|
|
199
|
+
const capture = renderHookCapture(() => counter)
|
|
200
|
+
await flushMicrotasks()
|
|
201
|
+
await advanceFrame()
|
|
202
|
+
const initialRenders = capture.renderCount
|
|
203
|
+
|
|
204
|
+
counter = 1
|
|
205
|
+
await advanceFrame()
|
|
206
|
+
expect(capture.renderCount).toBeGreaterThan(initialRenders)
|
|
207
|
+
expect(capture.value).toBe(1)
|
|
208
|
+
})
|
|
209
|
+
|
|
210
|
+
it("does not re-render when value is unchanged", async () => {
|
|
211
|
+
const capture = renderHookCapture(() => "stable")
|
|
212
|
+
await flushMicrotasks()
|
|
213
|
+
await advanceFrame()
|
|
214
|
+
const initialRenders = capture.renderCount
|
|
215
|
+
|
|
216
|
+
await advanceFrame()
|
|
217
|
+
await advanceFrame()
|
|
218
|
+
expect(capture.renderCount).toBe(initialRenders)
|
|
219
|
+
})
|
|
220
|
+
|
|
221
|
+
it("handles null return from getter", async () => {
|
|
222
|
+
const capture = renderHookCapture(() => null)
|
|
223
|
+
await flushMicrotasks()
|
|
224
|
+
expect(capture.value).toBeNull()
|
|
225
|
+
})
|
|
226
|
+
|
|
227
|
+
it("handles getter that throws", async () => {
|
|
228
|
+
const capture = renderHookCapture(() => { throw new Error("boom") })
|
|
229
|
+
await flushMicrotasks()
|
|
230
|
+
expect(capture.value).toBeUndefined()
|
|
231
|
+
})
|
|
232
|
+
|
|
233
|
+
it("stops polling after unmount", async () => {
|
|
234
|
+
const getter = vi.fn(() => 42)
|
|
235
|
+
renderHookCapture(getter)
|
|
236
|
+
await flushMicrotasks()
|
|
237
|
+
await advanceFrame()
|
|
238
|
+
|
|
239
|
+
const callsBeforeUnmount = getter.mock.calls.length
|
|
240
|
+
|
|
241
|
+
unmount(container as any)
|
|
242
|
+
await flushMicrotasks()
|
|
243
|
+
|
|
244
|
+
await advanceFrame()
|
|
245
|
+
await advanceFrame()
|
|
246
|
+
await advanceFrame()
|
|
247
|
+
|
|
248
|
+
// At most one extra call from the in-flight RAF callback
|
|
249
|
+
expect(getter.mock.calls.length).toBeLessThanOrEqual(callsBeforeUnmount + 1)
|
|
250
|
+
})
|
|
251
|
+
|
|
252
|
+
it("uses Object.is for comparison (NaN === NaN)", async () => {
|
|
253
|
+
let val = NaN
|
|
254
|
+
const capture = renderHookCapture(() => val)
|
|
255
|
+
await flushMicrotasks()
|
|
256
|
+
await advanceFrame()
|
|
257
|
+
const initialRenders = capture.renderCount
|
|
258
|
+
|
|
259
|
+
// NaN should be Object.is equal to NaN — no re-render
|
|
260
|
+
val = NaN
|
|
261
|
+
await advanceFrame()
|
|
262
|
+
expect(capture.renderCount).toBe(initialRenders)
|
|
263
|
+
})
|
|
264
|
+
})
|
|
265
|
+
|
|
266
|
+
// ===========================================================================
|
|
267
|
+
// useFrameSync — selector mode
|
|
268
|
+
// ===========================================================================
|
|
269
|
+
|
|
270
|
+
describe("useFrameSync (selector mode)", () => {
|
|
271
|
+
let container: ReturnType<typeof createMockContainer>
|
|
272
|
+
|
|
273
|
+
beforeEach(() => {
|
|
274
|
+
container = createMockContainer()
|
|
275
|
+
})
|
|
276
|
+
|
|
277
|
+
afterEach(() => {
|
|
278
|
+
unmount(container as any)
|
|
279
|
+
})
|
|
280
|
+
|
|
281
|
+
function renderHookCapture<T>(
|
|
282
|
+
getter: () => T,
|
|
283
|
+
select: (v: T) => readonly unknown[],
|
|
284
|
+
deps?: readonly unknown[]
|
|
285
|
+
) {
|
|
286
|
+
let capturedValue: T = undefined as T
|
|
287
|
+
let renderCount = 0
|
|
288
|
+
|
|
289
|
+
function TestComponent() {
|
|
290
|
+
const value = useFrameSync(getter, select, deps)
|
|
291
|
+
capturedValue = value
|
|
292
|
+
renderCount++
|
|
293
|
+
return null
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
render(<TestComponent />, container as any)
|
|
297
|
+
|
|
298
|
+
return {
|
|
299
|
+
get value() { return capturedValue },
|
|
300
|
+
get renderCount() { return renderCount },
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
it("returns initial value from getter on first render", async () => {
|
|
305
|
+
const capture = renderHookCapture(
|
|
306
|
+
() => ({ name: "Town", population: 100 }),
|
|
307
|
+
(v) => [v.name, v.population]
|
|
308
|
+
)
|
|
309
|
+
await flushMicrotasks()
|
|
310
|
+
|
|
311
|
+
expect(capture.value).toEqual({ name: "Town", population: 100 })
|
|
312
|
+
})
|
|
313
|
+
|
|
314
|
+
it("returns null when getter returns null", async () => {
|
|
315
|
+
const capture = renderHookCapture(
|
|
316
|
+
() => null,
|
|
317
|
+
(v) => [v]
|
|
318
|
+
)
|
|
319
|
+
await flushMicrotasks()
|
|
320
|
+
|
|
321
|
+
expect(capture.value).toBeNull()
|
|
322
|
+
})
|
|
323
|
+
|
|
324
|
+
it("returns undefined when getter throws", async () => {
|
|
325
|
+
const capture = renderHookCapture(
|
|
326
|
+
() => { throw new Error("destroyed") },
|
|
327
|
+
(v) => [v]
|
|
328
|
+
)
|
|
329
|
+
await flushMicrotasks()
|
|
330
|
+
|
|
331
|
+
expect(capture.value).toBeUndefined()
|
|
332
|
+
})
|
|
333
|
+
|
|
334
|
+
it("re-renders when a selected dep changes", async () => {
|
|
335
|
+
const data = { name: "Town A", count: 1, untracked: "foo" }
|
|
336
|
+
|
|
337
|
+
const capture = renderHookCapture(
|
|
338
|
+
() => data,
|
|
339
|
+
(v) => [v.name, v.count]
|
|
340
|
+
)
|
|
341
|
+
await flushMicrotasks()
|
|
342
|
+
const initialRenders = capture.renderCount
|
|
343
|
+
|
|
344
|
+
// Advance one frame with no changes — should not re-render
|
|
345
|
+
await advanceFrame()
|
|
346
|
+
expect(capture.renderCount).toBe(initialRenders)
|
|
347
|
+
|
|
348
|
+
// Change a tracked dep
|
|
349
|
+
data.name = "Town B"
|
|
350
|
+
await advanceFrame()
|
|
351
|
+
|
|
352
|
+
expect(capture.renderCount).toBeGreaterThan(initialRenders)
|
|
353
|
+
expect(capture.value.name).toBe("Town B")
|
|
354
|
+
})
|
|
355
|
+
|
|
356
|
+
it("does not re-render when untracked properties change", async () => {
|
|
357
|
+
const data = { name: "Town", count: 1, untracked: "a" }
|
|
358
|
+
|
|
359
|
+
const capture = renderHookCapture(
|
|
360
|
+
() => data,
|
|
361
|
+
(v) => [v.name, v.count]
|
|
362
|
+
)
|
|
363
|
+
await flushMicrotasks()
|
|
364
|
+
// Let the effect run and schedule the first RAF check
|
|
365
|
+
await advanceFrame()
|
|
366
|
+
const afterInit = capture.renderCount
|
|
367
|
+
|
|
368
|
+
// Change only an untracked property
|
|
369
|
+
data.untracked = "b"
|
|
370
|
+
await advanceFrame()
|
|
371
|
+
|
|
372
|
+
expect(capture.renderCount).toBe(afterInit)
|
|
373
|
+
})
|
|
374
|
+
|
|
375
|
+
it("detects changes in the number of selected deps", async () => {
|
|
376
|
+
let depCount = 2
|
|
377
|
+
|
|
378
|
+
const capture = renderHookCapture(
|
|
379
|
+
() => "value",
|
|
380
|
+
() => {
|
|
381
|
+
// Return different-length arrays based on external state
|
|
382
|
+
const arr: unknown[] = []
|
|
383
|
+
for (let i = 0; i < depCount; i++) arr.push(i)
|
|
384
|
+
return arr
|
|
385
|
+
}
|
|
386
|
+
)
|
|
387
|
+
await flushMicrotasks()
|
|
388
|
+
await advanceFrame()
|
|
389
|
+
const afterInit = capture.renderCount
|
|
390
|
+
|
|
391
|
+
// Change the number of deps
|
|
392
|
+
depCount = 3
|
|
393
|
+
await advanceFrame()
|
|
394
|
+
|
|
395
|
+
expect(capture.renderCount).toBeGreaterThan(afterInit)
|
|
396
|
+
})
|
|
397
|
+
|
|
398
|
+
it("simulates C# proxy caching — same object reference, property changes", async () => {
|
|
399
|
+
// This is the core scenario: a C# proxy always returns the same
|
|
400
|
+
// JS object reference, but its properties change because they
|
|
401
|
+
// read through to C# on each access.
|
|
402
|
+
const proxy = { Name: "Village", NPCCount: 3, Version: 1 }
|
|
403
|
+
|
|
404
|
+
const capture = renderHookCapture(
|
|
405
|
+
() => proxy, // Always returns the SAME object reference
|
|
406
|
+
(p) => [p.Name, p.NPCCount, p.Version]
|
|
407
|
+
)
|
|
408
|
+
await flushMicrotasks()
|
|
409
|
+
await advanceFrame()
|
|
410
|
+
expect(capture.value.Name).toBe("Village")
|
|
411
|
+
const afterInit = capture.renderCount
|
|
412
|
+
|
|
413
|
+
// Mutate the proxy (simulates C# property change via proxy)
|
|
414
|
+
proxy.Name = "City"
|
|
415
|
+
proxy.Version = 2
|
|
416
|
+
await advanceFrame()
|
|
417
|
+
|
|
418
|
+
expect(capture.renderCount).toBeGreaterThan(afterInit)
|
|
419
|
+
expect(capture.value.Name).toBe("City")
|
|
420
|
+
})
|
|
421
|
+
|
|
422
|
+
it("handles nullable C# references with optional chaining in select", async () => {
|
|
423
|
+
let currentPlace: { Name: string; Items: { Count: number } } | null = {
|
|
424
|
+
Name: "Tavern",
|
|
425
|
+
Items: { Count: 5 },
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
const capture = renderHookCapture(
|
|
429
|
+
() => currentPlace,
|
|
430
|
+
(p) => [p?.Name, p?.Items?.Count]
|
|
431
|
+
)
|
|
432
|
+
await flushMicrotasks()
|
|
433
|
+
await advanceFrame()
|
|
434
|
+
expect(capture.value?.Name).toBe("Tavern")
|
|
435
|
+
const afterInit = capture.renderCount
|
|
436
|
+
|
|
437
|
+
// Set to null — should detect change (deps go from ["Tavern", 5] to [undefined, undefined])
|
|
438
|
+
currentPlace = null
|
|
439
|
+
await advanceFrame()
|
|
440
|
+
|
|
441
|
+
expect(capture.renderCount).toBeGreaterThan(afterInit)
|
|
442
|
+
expect(capture.value).toBeNull()
|
|
443
|
+
})
|
|
444
|
+
|
|
445
|
+
it("works with version stamp pattern", async () => {
|
|
446
|
+
const gameState = { Version: 1, data: "initial" }
|
|
447
|
+
|
|
448
|
+
const capture = renderHookCapture(
|
|
449
|
+
() => gameState,
|
|
450
|
+
(s) => [s.Version]
|
|
451
|
+
)
|
|
452
|
+
await flushMicrotasks()
|
|
453
|
+
await advanceFrame()
|
|
454
|
+
const afterInit = capture.renderCount
|
|
455
|
+
|
|
456
|
+
// No version change — no re-render
|
|
457
|
+
gameState.data = "changed but version same"
|
|
458
|
+
await advanceFrame()
|
|
459
|
+
expect(capture.renderCount).toBe(afterInit)
|
|
460
|
+
|
|
461
|
+
// Bump version — triggers re-render
|
|
462
|
+
gameState.Version = 2
|
|
463
|
+
gameState.data = "updated"
|
|
464
|
+
await advanceFrame()
|
|
465
|
+
expect(capture.renderCount).toBeGreaterThan(afterInit)
|
|
466
|
+
expect(capture.value.data).toBe("updated")
|
|
467
|
+
})
|
|
468
|
+
|
|
469
|
+
it("stops polling after unmount", async () => {
|
|
470
|
+
const getter = vi.fn(() => 42)
|
|
471
|
+
|
|
472
|
+
renderHookCapture(getter, (v) => [v])
|
|
473
|
+
await flushMicrotasks()
|
|
474
|
+
await advanceFrame()
|
|
475
|
+
|
|
476
|
+
const callsBeforeUnmount = getter.mock.calls.length
|
|
477
|
+
|
|
478
|
+
// Unmount
|
|
479
|
+
unmount(container as any)
|
|
480
|
+
await flushMicrotasks()
|
|
481
|
+
|
|
482
|
+
// Advance more frames — getter should not be called
|
|
483
|
+
await advanceFrame()
|
|
484
|
+
await advanceFrame()
|
|
485
|
+
await advanceFrame()
|
|
486
|
+
|
|
487
|
+
// At most one extra call from the in-flight RAF callback
|
|
488
|
+
expect(getter.mock.calls.length).toBeLessThanOrEqual(callsBeforeUnmount + 1)
|
|
489
|
+
})
|
|
490
|
+
|
|
491
|
+
it("continues polling across multiple frames", async () => {
|
|
492
|
+
const data = { value: 0 }
|
|
493
|
+
|
|
494
|
+
const capture = renderHookCapture(
|
|
495
|
+
() => data,
|
|
496
|
+
(d) => [d.value]
|
|
497
|
+
)
|
|
498
|
+
await flushMicrotasks()
|
|
499
|
+
await advanceFrame()
|
|
500
|
+
const afterInit = capture.renderCount
|
|
501
|
+
|
|
502
|
+
// Frame 2: change value
|
|
503
|
+
data.value = 1
|
|
504
|
+
await advanceFrame()
|
|
505
|
+
expect(capture.renderCount).toBeGreaterThan(afterInit)
|
|
506
|
+
const afterFirst = capture.renderCount
|
|
507
|
+
|
|
508
|
+
// Frame 3: change value again
|
|
509
|
+
data.value = 2
|
|
510
|
+
await advanceFrame()
|
|
511
|
+
expect(capture.renderCount).toBeGreaterThan(afterFirst)
|
|
512
|
+
expect(capture.value.value).toBe(2)
|
|
513
|
+
})
|
|
514
|
+
|
|
515
|
+
it("always returns fresh value from getter on render", async () => {
|
|
516
|
+
// Even between detected changes, the returned value should
|
|
517
|
+
// be the current live getter result (not a stale snapshot).
|
|
518
|
+
let callCount = 0
|
|
519
|
+
const capture = renderHookCapture(
|
|
520
|
+
() => {
|
|
521
|
+
callCount++
|
|
522
|
+
return { fresh: callCount }
|
|
523
|
+
},
|
|
524
|
+
() => [1] // Deps never change after init
|
|
525
|
+
)
|
|
526
|
+
await flushMicrotasks()
|
|
527
|
+
|
|
528
|
+
// The value should reflect a recent getter call
|
|
529
|
+
expect(capture.value.fresh).toBeGreaterThan(0)
|
|
530
|
+
})
|
|
531
|
+
})
|
|
532
|
+
|
|
533
|
+
// ===========================================================================
|
|
534
|
+
// useFrameSyncWith (deprecated)
|
|
535
|
+
// ===========================================================================
|
|
536
|
+
|
|
537
|
+
describe("useFrameSyncWith (deprecated)", () => {
|
|
538
|
+
let container: ReturnType<typeof createMockContainer>
|
|
539
|
+
|
|
540
|
+
beforeEach(() => {
|
|
541
|
+
container = createMockContainer()
|
|
542
|
+
})
|
|
543
|
+
|
|
544
|
+
afterEach(() => {
|
|
545
|
+
unmount(container as any)
|
|
546
|
+
})
|
|
547
|
+
|
|
548
|
+
it("works with custom equality for new JS objects", async () => {
|
|
549
|
+
// useFrameSyncWith works when the getter returns a NEW object each time
|
|
550
|
+
// (not a cached proxy). This is its valid use case.
|
|
551
|
+
let x = 1, y = 2, z = 3
|
|
552
|
+
let capturedValue: { x: number; y: number; z: number } = undefined as any
|
|
553
|
+
|
|
554
|
+
function TestComponent() {
|
|
555
|
+
const value = useFrameSyncWith(
|
|
556
|
+
() => ({ x, y, z }),
|
|
557
|
+
(a, b) => a.x === b.x && a.y === b.y && a.z === b.z
|
|
558
|
+
)
|
|
559
|
+
capturedValue = value
|
|
560
|
+
return null
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
render(<TestComponent />, container as any)
|
|
564
|
+
await flushMicrotasks()
|
|
565
|
+
expect(capturedValue).toEqual({ x: 1, y: 2, z: 3 })
|
|
566
|
+
|
|
567
|
+
// No change — should not update (custom equality says they're equal)
|
|
568
|
+
await advanceFrame()
|
|
569
|
+
const stableValue = capturedValue
|
|
570
|
+
|
|
571
|
+
await advanceFrame()
|
|
572
|
+
// The reference may change (new object each getter call) but
|
|
573
|
+
// custom equality prevents unnecessary state updates
|
|
574
|
+
expect(capturedValue).toEqual({ x: 1, y: 2, z: 3 })
|
|
575
|
+
|
|
576
|
+
// Change a value — should trigger update
|
|
577
|
+
x = 10
|
|
578
|
+
await advanceFrame()
|
|
579
|
+
expect(capturedValue.x).toBe(10)
|
|
580
|
+
})
|
|
581
|
+
})
|
|
@@ -663,21 +663,23 @@ describe('host-config', () => {
|
|
|
663
663
|
});
|
|
664
664
|
|
|
665
665
|
describe('visibility', () => {
|
|
666
|
-
it('hideInstance sets display to
|
|
666
|
+
it('hideInstance sets display to DisplayStyle.None', () => {
|
|
667
667
|
const instance = createInstance('ojs-view', {});
|
|
668
668
|
|
|
669
669
|
hideInstance(instance);
|
|
670
670
|
|
|
671
|
-
|
|
671
|
+
const DisplayStyle = (globalThis as any).CS.UnityEngine.UIElements.DisplayStyle;
|
|
672
|
+
expect(instance.element.style.display).toBe(DisplayStyle.None);
|
|
672
673
|
});
|
|
673
674
|
|
|
674
|
-
it('unhideInstance
|
|
675
|
+
it('unhideInstance sets display to DisplayStyle.Flex', () => {
|
|
675
676
|
const instance = createInstance('ojs-view', {});
|
|
676
|
-
|
|
677
|
+
const DisplayStyle = (globalThis as any).CS.UnityEngine.UIElements.DisplayStyle;
|
|
678
|
+
instance.element.style.display = DisplayStyle.None;
|
|
677
679
|
|
|
678
680
|
unhideInstance(instance, {});
|
|
679
681
|
|
|
680
|
-
expect(instance.element.style.display).toBe(
|
|
682
|
+
expect(instance.element.style.display).toBe(DisplayStyle.Flex);
|
|
681
683
|
});
|
|
682
684
|
});
|
|
683
685
|
|
package/src/__tests__/mocks.ts
CHANGED
|
@@ -225,12 +225,17 @@ export class MockImage extends MockVisualElement {
|
|
|
225
225
|
|
|
226
226
|
/**
|
|
227
227
|
* Create the mock CS global object that mirrors QuickJSBootstrap.js proxy
|
|
228
|
+
*
|
|
229
|
+
* Enum values match Unity's actual enum definitions so that tests
|
|
230
|
+
* verify the real mapping behavior (CSS string -> Unity enum number).
|
|
228
231
|
*/
|
|
229
232
|
export function createMockCS() {
|
|
230
233
|
return {
|
|
231
234
|
UnityEngine: {
|
|
232
235
|
// Core types
|
|
233
236
|
Color: MockColor,
|
|
237
|
+
Rect: class { constructor(public x: number, public y: number, public width: number, public height: number) {} },
|
|
238
|
+
ScaleMode: { StretchToFill: 0, ScaleAndCrop: 1, ScaleToFit: 2 },
|
|
234
239
|
// UI Elements
|
|
235
240
|
UIElements: {
|
|
236
241
|
VisualElement: MockVisualElement,
|
|
@@ -246,6 +251,39 @@ export function createMockCS() {
|
|
|
246
251
|
Length: MockLength,
|
|
247
252
|
LengthUnit: MockLengthUnit,
|
|
248
253
|
StyleKeyword: MockStyleKeyword,
|
|
254
|
+
// Enums (values match Unity's actual enum definitions)
|
|
255
|
+
FlexDirection: { Column: 0, ColumnReverse: 1, Row: 2, RowReverse: 3 },
|
|
256
|
+
Wrap: { NoWrap: 0, Wrap: 1, WrapReverse: 2 },
|
|
257
|
+
Align: { Auto: 0, FlexStart: 1, Center: 2, FlexEnd: 3, Stretch: 4 },
|
|
258
|
+
Justify: { FlexStart: 0, Center: 1, FlexEnd: 2, SpaceBetween: 3, SpaceAround: 4 },
|
|
259
|
+
Position: { Relative: 0, Absolute: 1 },
|
|
260
|
+
Overflow: { Visible: 0, Hidden: 1 },
|
|
261
|
+
DisplayStyle: { Flex: 0, None: 1 },
|
|
262
|
+
Visibility: { Visible: 0, Hidden: 1 },
|
|
263
|
+
WhiteSpace: { Normal: 0, NoWrap: 1 },
|
|
264
|
+
TextOverflow: { Clip: 0, Ellipsis: 1 },
|
|
265
|
+
TextOverflowPosition: { End: 0, Start: 1, Middle: 2 },
|
|
266
|
+
OverflowClipBox: { PaddingBox: 0, ContentBox: 1 },
|
|
267
|
+
PickingMode: { Position: 0, Ignore: 1 },
|
|
268
|
+
SliderDirection: { Horizontal: 0, Vertical: 1 },
|
|
269
|
+
// ScrollView enums
|
|
270
|
+
ScrollViewMode: { Vertical: 0, Horizontal: 1, VerticalAndHorizontal: 2 },
|
|
271
|
+
ScrollerVisibility: { Auto: 0, AlwaysVisible: 1, Hidden: 2 },
|
|
272
|
+
TouchScrollBehavior: { Unrestricted: 0, Elastic: 1, Clamped: 2 },
|
|
273
|
+
NestedInteractionKind: { Default: 0, StopScrolling: 1, ForwardScrolling: 2 },
|
|
274
|
+
// ListView enums
|
|
275
|
+
SelectionType: { None: 0, Single: 1, Multiple: 2 },
|
|
276
|
+
ListViewReorderMode: { Simple: 0, Animated: 1 },
|
|
277
|
+
AlternatingRowBackground: { None: 0, ContentOnly: 1, All: 2 },
|
|
278
|
+
CollectionVirtualizationMethod: { FixedHeight: 0, DynamicHeight: 1 },
|
|
279
|
+
},
|
|
280
|
+
},
|
|
281
|
+
OneJS: {
|
|
282
|
+
GPU: {
|
|
283
|
+
GPUBridge: {
|
|
284
|
+
SetElementBackgroundImage: () => {},
|
|
285
|
+
ClearElementBackgroundImage: () => {},
|
|
286
|
+
},
|
|
249
287
|
},
|
|
250
288
|
},
|
|
251
289
|
};
|
|
@@ -323,5 +361,7 @@ export function getEventAPI() {
|
|
|
323
361
|
addEventListener: ReturnType<typeof import('vitest').vi.fn>;
|
|
324
362
|
removeEventListener: ReturnType<typeof import('vitest').vi.fn>;
|
|
325
363
|
removeAllEventListeners: ReturnType<typeof import('vitest').vi.fn>;
|
|
364
|
+
setParent: ReturnType<typeof import('vitest').vi.fn>;
|
|
365
|
+
removeParent: ReturnType<typeof import('vitest').vi.fn>;
|
|
326
366
|
};
|
|
327
367
|
}
|