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
package/package.json
CHANGED
|
@@ -0,0 +1,436 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* End-to-end tests for collection state sync patterns.
|
|
3
|
+
*
|
|
4
|
+
* Simulates the real-world use case of syncing dynamic C# game state
|
|
5
|
+
* (inventories, quest logs, NPC lists) to React components using
|
|
6
|
+
* useFrameSync selector mode + toArray.
|
|
7
|
+
*
|
|
8
|
+
* Tests the parent/child pattern:
|
|
9
|
+
* - Parent watches list structure (Count) via selector
|
|
10
|
+
* - Child components each watch their own item properties via selector
|
|
11
|
+
* - When an item property changes, only that child re-renders
|
|
12
|
+
* - When items are added/removed, the parent re-renders
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"
|
|
16
|
+
import React from "react"
|
|
17
|
+
import { useFrameSync, toArray } from "../hooks"
|
|
18
|
+
import { render, unmount } from "../renderer"
|
|
19
|
+
import { createMockContainer, flushMicrotasks } from "./mocks"
|
|
20
|
+
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
// RAF mock
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
|
|
25
|
+
type RafCallback = (time: number) => void
|
|
26
|
+
|
|
27
|
+
let rafQueue: Array<{ id: number; callback: RafCallback }> = []
|
|
28
|
+
let nextRafId = 0
|
|
29
|
+
|
|
30
|
+
function flushRaf() {
|
|
31
|
+
const queue = [...rafQueue]
|
|
32
|
+
rafQueue = []
|
|
33
|
+
const now = Date.now()
|
|
34
|
+
for (const { callback } of queue) {
|
|
35
|
+
callback(now)
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async function advanceFrame() {
|
|
40
|
+
flushRaf()
|
|
41
|
+
await flushMicrotasks()
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
beforeEach(() => {
|
|
45
|
+
rafQueue = []
|
|
46
|
+
nextRafId = 0
|
|
47
|
+
;(globalThis as any).requestAnimationFrame = vi.fn((cb: RafCallback) => {
|
|
48
|
+
const id = ++nextRafId
|
|
49
|
+
rafQueue.push({ id, callback: cb })
|
|
50
|
+
return id
|
|
51
|
+
})
|
|
52
|
+
;(globalThis as any).cancelAnimationFrame = vi.fn((id: number) => {
|
|
53
|
+
rafQueue = rafQueue.filter((e) => e.id !== id)
|
|
54
|
+
})
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
afterEach(() => {
|
|
58
|
+
delete (globalThis as any).requestAnimationFrame
|
|
59
|
+
delete (globalThis as any).cancelAnimationFrame
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
// ---------------------------------------------------------------------------
|
|
63
|
+
// Mock C# proxy objects — simulates proxy caching behavior
|
|
64
|
+
//
|
|
65
|
+
// In real OneJS, accessing a C# object from JS always returns the same
|
|
66
|
+
// proxy reference (cached by handle). Properties read through to C# on
|
|
67
|
+
// each access, so mutations are visible through the same reference.
|
|
68
|
+
// ---------------------------------------------------------------------------
|
|
69
|
+
|
|
70
|
+
interface MockItem {
|
|
71
|
+
Id: number
|
|
72
|
+
Name: string
|
|
73
|
+
Durability: number
|
|
74
|
+
StackCount: number
|
|
75
|
+
Version: number
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
interface MockInventory {
|
|
79
|
+
Items: { Count: number; [index: number]: MockItem }
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function createMockInventory(items: MockItem[]): MockInventory {
|
|
83
|
+
// Simulates a C# List<Item> — same proxy object, Count and indexer
|
|
84
|
+
// read live values (mutating the items array is visible immediately)
|
|
85
|
+
const proxy: any = {
|
|
86
|
+
get Count() { return items.length },
|
|
87
|
+
}
|
|
88
|
+
// Numeric indexer — proxy reads live from the array
|
|
89
|
+
return {
|
|
90
|
+
Items: new Proxy(proxy, {
|
|
91
|
+
get(target, prop) {
|
|
92
|
+
if (prop === "Count") return items.length
|
|
93
|
+
const idx = Number(prop)
|
|
94
|
+
if (!isNaN(idx) && idx >= 0 && idx < items.length) {
|
|
95
|
+
return items[idx]
|
|
96
|
+
}
|
|
97
|
+
return target[prop]
|
|
98
|
+
}
|
|
99
|
+
})
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function createMockItem(id: number, name: string, durability: number): MockItem {
|
|
104
|
+
return { Id: id, Name: name, Durability: durability, StackCount: 1, Version: 1 }
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// ---------------------------------------------------------------------------
|
|
108
|
+
// Tests
|
|
109
|
+
// ---------------------------------------------------------------------------
|
|
110
|
+
|
|
111
|
+
describe("collection sync: parent/child pattern", () => {
|
|
112
|
+
let container: ReturnType<typeof createMockContainer>
|
|
113
|
+
|
|
114
|
+
beforeEach(() => {
|
|
115
|
+
container = createMockContainer()
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
afterEach(() => {
|
|
119
|
+
unmount(container as any)
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
it("parent re-renders when items are added, children render per item", async () => {
|
|
123
|
+
const items: MockItem[] = [
|
|
124
|
+
createMockItem(1, "Sword", 100),
|
|
125
|
+
createMockItem(2, "Shield", 80),
|
|
126
|
+
]
|
|
127
|
+
const inventory = createMockInventory(items)
|
|
128
|
+
|
|
129
|
+
let parentRenders = 0
|
|
130
|
+
const childRenders: Record<number, number> = {}
|
|
131
|
+
|
|
132
|
+
function ItemView({ item }: { item: MockItem }) {
|
|
133
|
+
const data = useFrameSync(
|
|
134
|
+
() => item,
|
|
135
|
+
(i) => [i.Name, i.Durability, i.StackCount]
|
|
136
|
+
)
|
|
137
|
+
childRenders[data.Id] = (childRenders[data.Id] || 0) + 1
|
|
138
|
+
return null
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function InventoryList() {
|
|
142
|
+
const inv = useFrameSync(
|
|
143
|
+
() => inventory,
|
|
144
|
+
(i) => [i.Items.Count]
|
|
145
|
+
)
|
|
146
|
+
parentRenders++
|
|
147
|
+
return (
|
|
148
|
+
<>
|
|
149
|
+
{toArray<MockItem>(inv.Items).map(item => (
|
|
150
|
+
<ItemView key={item.Id} item={item} />
|
|
151
|
+
))}
|
|
152
|
+
</>
|
|
153
|
+
)
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
render(<InventoryList />, container as any)
|
|
157
|
+
await flushMicrotasks()
|
|
158
|
+
await advanceFrame()
|
|
159
|
+
|
|
160
|
+
expect(parentRenders).toBeGreaterThanOrEqual(1)
|
|
161
|
+
expect(childRenders[1]).toBeGreaterThanOrEqual(1)
|
|
162
|
+
expect(childRenders[2]).toBeGreaterThanOrEqual(1)
|
|
163
|
+
|
|
164
|
+
const parentRendersAfterInit = parentRenders
|
|
165
|
+
const child1RendersAfterInit = childRenders[1]
|
|
166
|
+
const child2RendersAfterInit = childRenders[2]
|
|
167
|
+
|
|
168
|
+
// Add a new item
|
|
169
|
+
items.push(createMockItem(3, "Potion", 1))
|
|
170
|
+
await advanceFrame()
|
|
171
|
+
|
|
172
|
+
// Parent should re-render (Count changed from 2 to 3)
|
|
173
|
+
expect(parentRenders).toBeGreaterThan(parentRendersAfterInit)
|
|
174
|
+
// New child should have rendered
|
|
175
|
+
expect(childRenders[3]).toBeGreaterThanOrEqual(1)
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
it("only the affected child re-renders when an item property changes", async () => {
|
|
179
|
+
const items: MockItem[] = [
|
|
180
|
+
createMockItem(1, "Sword", 100),
|
|
181
|
+
createMockItem(2, "Shield", 80),
|
|
182
|
+
createMockItem(3, "Potion", 1),
|
|
183
|
+
]
|
|
184
|
+
const inventory = createMockInventory(items)
|
|
185
|
+
|
|
186
|
+
let parentRenders = 0
|
|
187
|
+
const childRenders: Record<number, number> = {}
|
|
188
|
+
|
|
189
|
+
function ItemView({ item }: { item: MockItem }) {
|
|
190
|
+
const data = useFrameSync(
|
|
191
|
+
() => item,
|
|
192
|
+
(i) => [i.Name, i.Durability, i.StackCount]
|
|
193
|
+
)
|
|
194
|
+
childRenders[data.Id] = (childRenders[data.Id] || 0) + 1
|
|
195
|
+
return null
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function InventoryList() {
|
|
199
|
+
const inv = useFrameSync(
|
|
200
|
+
() => inventory,
|
|
201
|
+
(i) => [i.Items.Count]
|
|
202
|
+
)
|
|
203
|
+
parentRenders++
|
|
204
|
+
return (
|
|
205
|
+
<>
|
|
206
|
+
{toArray<MockItem>(inv.Items).map(item => (
|
|
207
|
+
<ItemView key={item.Id} item={item} />
|
|
208
|
+
))}
|
|
209
|
+
</>
|
|
210
|
+
)
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
render(<InventoryList />, container as any)
|
|
214
|
+
await flushMicrotasks()
|
|
215
|
+
await advanceFrame()
|
|
216
|
+
|
|
217
|
+
const parentRendersAfterInit = parentRenders
|
|
218
|
+
const child1After = childRenders[1]
|
|
219
|
+
const child2After = childRenders[2]
|
|
220
|
+
const child3After = childRenders[3]
|
|
221
|
+
|
|
222
|
+
// Change only Sword's durability (simulates using the item)
|
|
223
|
+
items[0].Durability = 90
|
|
224
|
+
await advanceFrame()
|
|
225
|
+
|
|
226
|
+
// Parent should NOT re-render (Count unchanged)
|
|
227
|
+
expect(parentRenders).toBe(parentRendersAfterInit)
|
|
228
|
+
// Only Sword's child should re-render
|
|
229
|
+
expect(childRenders[1]).toBeGreaterThan(child1After)
|
|
230
|
+
// Shield and Potion should NOT re-render
|
|
231
|
+
expect(childRenders[2]).toBe(child2After)
|
|
232
|
+
expect(childRenders[3]).toBe(child3After)
|
|
233
|
+
})
|
|
234
|
+
|
|
235
|
+
it("version stamp on items catches any property change", async () => {
|
|
236
|
+
const items: MockItem[] = [
|
|
237
|
+
createMockItem(1, "Sword", 100),
|
|
238
|
+
createMockItem(2, "Shield", 80),
|
|
239
|
+
]
|
|
240
|
+
const inventory = createMockInventory(items)
|
|
241
|
+
|
|
242
|
+
const childRenders: Record<number, number> = {}
|
|
243
|
+
|
|
244
|
+
function ItemView({ item }: { item: MockItem }) {
|
|
245
|
+
// Watch only Version — catches all changes without listing every property
|
|
246
|
+
const data = useFrameSync(
|
|
247
|
+
() => item,
|
|
248
|
+
(i) => [i.Version]
|
|
249
|
+
)
|
|
250
|
+
childRenders[data.Id] = (childRenders[data.Id] || 0) + 1
|
|
251
|
+
return null
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function InventoryList() {
|
|
255
|
+
const inv = useFrameSync(
|
|
256
|
+
() => inventory,
|
|
257
|
+
(i) => [i.Items.Count]
|
|
258
|
+
)
|
|
259
|
+
return (
|
|
260
|
+
<>
|
|
261
|
+
{toArray<MockItem>(inv.Items).map(item => (
|
|
262
|
+
<ItemView key={item.Id} item={item} />
|
|
263
|
+
))}
|
|
264
|
+
</>
|
|
265
|
+
)
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
render(<InventoryList />, container as any)
|
|
269
|
+
await flushMicrotasks()
|
|
270
|
+
await advanceFrame()
|
|
271
|
+
|
|
272
|
+
const child1After = childRenders[1]
|
|
273
|
+
const child2After = childRenders[2]
|
|
274
|
+
|
|
275
|
+
// Change Sword's durability AND bump its version (simulates Fody)
|
|
276
|
+
items[0].Durability = 90
|
|
277
|
+
items[0].Version = 2
|
|
278
|
+
await advanceFrame()
|
|
279
|
+
|
|
280
|
+
// Sword re-renders (version changed)
|
|
281
|
+
expect(childRenders[1]).toBeGreaterThan(child1After)
|
|
282
|
+
// Shield does NOT re-render (version unchanged)
|
|
283
|
+
expect(childRenders[2]).toBe(child2After)
|
|
284
|
+
})
|
|
285
|
+
|
|
286
|
+
it("handles item removal correctly", async () => {
|
|
287
|
+
const items: MockItem[] = [
|
|
288
|
+
createMockItem(1, "Sword", 100),
|
|
289
|
+
createMockItem(2, "Shield", 80),
|
|
290
|
+
createMockItem(3, "Potion", 1),
|
|
291
|
+
]
|
|
292
|
+
const inventory = createMockInventory(items)
|
|
293
|
+
|
|
294
|
+
let parentRenders = 0
|
|
295
|
+
const childRenders: Record<number, number> = {}
|
|
296
|
+
|
|
297
|
+
function ItemView({ item }: { item: MockItem }) {
|
|
298
|
+
const data = useFrameSync(
|
|
299
|
+
() => item,
|
|
300
|
+
(i) => [i.Name, i.Durability]
|
|
301
|
+
)
|
|
302
|
+
childRenders[data.Id] = (childRenders[data.Id] || 0) + 1
|
|
303
|
+
return null
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function InventoryList() {
|
|
307
|
+
const inv = useFrameSync(
|
|
308
|
+
() => inventory,
|
|
309
|
+
(i) => [i.Items.Count]
|
|
310
|
+
)
|
|
311
|
+
parentRenders++
|
|
312
|
+
return (
|
|
313
|
+
<>
|
|
314
|
+
{toArray<MockItem>(inv.Items).map(item => (
|
|
315
|
+
<ItemView key={item.Id} item={item} />
|
|
316
|
+
))}
|
|
317
|
+
</>
|
|
318
|
+
)
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
render(<InventoryList />, container as any)
|
|
322
|
+
await flushMicrotasks()
|
|
323
|
+
await advanceFrame()
|
|
324
|
+
|
|
325
|
+
const parentRendersAfterInit = parentRenders
|
|
326
|
+
expect(childRenders[1]).toBeGreaterThanOrEqual(1)
|
|
327
|
+
expect(childRenders[2]).toBeGreaterThanOrEqual(1)
|
|
328
|
+
expect(childRenders[3]).toBeGreaterThanOrEqual(1)
|
|
329
|
+
|
|
330
|
+
// Remove Shield (index 1)
|
|
331
|
+
items.splice(1, 1)
|
|
332
|
+
await advanceFrame()
|
|
333
|
+
|
|
334
|
+
// Parent should re-render (Count changed from 3 to 2)
|
|
335
|
+
expect(parentRenders).toBeGreaterThan(parentRendersAfterInit)
|
|
336
|
+
})
|
|
337
|
+
|
|
338
|
+
it("nullable parent with optional chaining works end-to-end", async () => {
|
|
339
|
+
let currentPlace: { Name: string; NPCs: { Count: number; [i: number]: { Name: string; Dialogue: string } } } | null = {
|
|
340
|
+
Name: "Tavern",
|
|
341
|
+
NPCs: {
|
|
342
|
+
Count: 2,
|
|
343
|
+
0: { Name: "Barkeep", Dialogue: "Welcome!" },
|
|
344
|
+
1: { Name: "Bard", Dialogue: "La la la~" },
|
|
345
|
+
},
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
let parentRenders = 0
|
|
349
|
+
const npcRenders: Record<string, number> = {}
|
|
350
|
+
|
|
351
|
+
function NPCView({ npc }: { npc: { Name: string; Dialogue: string } }) {
|
|
352
|
+
const data = useFrameSync(
|
|
353
|
+
() => npc,
|
|
354
|
+
(n) => [n.Name, n.Dialogue]
|
|
355
|
+
)
|
|
356
|
+
npcRenders[data.Name] = (npcRenders[data.Name] || 0) + 1
|
|
357
|
+
return null
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
function PlaceUI() {
|
|
361
|
+
const place = useFrameSync(
|
|
362
|
+
() => currentPlace,
|
|
363
|
+
(p) => [p?.Name, p?.NPCs?.Count]
|
|
364
|
+
)
|
|
365
|
+
parentRenders++
|
|
366
|
+
|
|
367
|
+
if (!place) return null
|
|
368
|
+
|
|
369
|
+
return (
|
|
370
|
+
<>
|
|
371
|
+
{toArray<{ Name: string; Dialogue: string }>(place.NPCs).map((npc, i) => (
|
|
372
|
+
<NPCView key={npc.Name} npc={npc} />
|
|
373
|
+
))}
|
|
374
|
+
</>
|
|
375
|
+
)
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
render(<PlaceUI />, container as any)
|
|
379
|
+
await flushMicrotasks()
|
|
380
|
+
await advanceFrame()
|
|
381
|
+
|
|
382
|
+
expect(parentRenders).toBeGreaterThanOrEqual(1)
|
|
383
|
+
expect(npcRenders["Barkeep"]).toBeGreaterThanOrEqual(1)
|
|
384
|
+
expect(npcRenders["Bard"]).toBeGreaterThanOrEqual(1)
|
|
385
|
+
|
|
386
|
+
const parentAfterInit = parentRenders
|
|
387
|
+
const barkeepAfter = npcRenders["Barkeep"]
|
|
388
|
+
|
|
389
|
+
// Change Barkeep's dialogue (NPC property change)
|
|
390
|
+
currentPlace!.NPCs[0].Dialogue = "What'll ya have?"
|
|
391
|
+
await advanceFrame()
|
|
392
|
+
|
|
393
|
+
// Parent should NOT re-render (Name and NPC Count unchanged)
|
|
394
|
+
expect(parentRenders).toBe(parentAfterInit)
|
|
395
|
+
// Only Barkeep should re-render
|
|
396
|
+
expect(npcRenders["Barkeep"]).toBeGreaterThan(barkeepAfter)
|
|
397
|
+
|
|
398
|
+
const parentAfterDialogue = parentRenders
|
|
399
|
+
|
|
400
|
+
// Player leaves the tavern (set to null)
|
|
401
|
+
currentPlace = null
|
|
402
|
+
await advanceFrame()
|
|
403
|
+
|
|
404
|
+
// Parent should re-render (place changed to null)
|
|
405
|
+
expect(parentRenders).toBeGreaterThan(parentAfterDialogue)
|
|
406
|
+
})
|
|
407
|
+
|
|
408
|
+
it("multiple property changes in one frame only cause one re-render", async () => {
|
|
409
|
+
const item = createMockItem(1, "Sword", 100)
|
|
410
|
+
|
|
411
|
+
let renderCount = 0
|
|
412
|
+
|
|
413
|
+
function ItemView() {
|
|
414
|
+
const data = useFrameSync(
|
|
415
|
+
() => item,
|
|
416
|
+
(i) => [i.Name, i.Durability, i.StackCount, i.Version]
|
|
417
|
+
)
|
|
418
|
+
renderCount++
|
|
419
|
+
return null
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
render(<ItemView />, container as any)
|
|
423
|
+
await flushMicrotasks()
|
|
424
|
+
await advanceFrame()
|
|
425
|
+
const afterInit = renderCount
|
|
426
|
+
|
|
427
|
+
// Change multiple properties at once (between frames)
|
|
428
|
+
item.Name = "Broken Sword"
|
|
429
|
+
item.Durability = 0
|
|
430
|
+
item.Version = 2
|
|
431
|
+
await advanceFrame()
|
|
432
|
+
|
|
433
|
+
// Should only re-render once for all changes in the same frame
|
|
434
|
+
expect(renderCount).toBe(afterInit + 1)
|
|
435
|
+
})
|
|
436
|
+
})
|
|
@@ -58,7 +58,9 @@ describe('components', () => {
|
|
|
58
58
|
const el = container.children[0] as MockVisualElement;
|
|
59
59
|
expect(getStyleValue(el.style.width)).toBe(200);
|
|
60
60
|
expect(getStyleValue(el.style.height)).toBe(100);
|
|
61
|
-
|
|
61
|
+
// flexDirection 'row' is converted to the Unity enum value FlexDirection.Row
|
|
62
|
+
const FlexDirection = (globalThis as any).CS.UnityEngine.UIElements.FlexDirection;
|
|
63
|
+
expect(el.style.flexDirection).toBe(FlexDirection.Row);
|
|
62
64
|
expect(getStyleValue(el.style.paddingTop)).toBe(10);
|
|
63
65
|
});
|
|
64
66
|
|