@webspatial/core-sdk 1.4.0 → 1.6.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,402 @@
1
+ import { describe, it, expect, vi } from 'vitest'
2
+
3
+ vi.mock('./JSBCommand', () => {
4
+ return {
5
+ createSpatialSceneCommand: vi.fn().mockImplementation(() => ({
6
+ executeSync: vi.fn().mockReturnValue({
7
+ data: { id: 'scene-1', windowProxy: {} },
8
+ }),
9
+ })),
10
+ FocusScene: vi.fn().mockImplementation(() => ({
11
+ execute: vi.fn().mockResolvedValue(undefined),
12
+ })),
13
+ }
14
+ })
15
+
16
+ function addDataManifest(manifest: any) {
17
+ const link = document.createElement('link')
18
+ link.rel = 'manifest'
19
+ const json = JSON.stringify(manifest)
20
+ link.href = 'data:application/manifest+json,' + encodeURIComponent(json)
21
+ document.head.appendChild(link)
22
+ return () => {
23
+ document.head.removeChild(link)
24
+ }
25
+ }
26
+
27
+ async function waitTick() {
28
+ await Promise.resolve()
29
+ await new Promise(r => setTimeout(r, 0))
30
+ }
31
+
32
+ describe('setupManifest applies overrides to xr_window_defaults / xr_volume_defaults', () => {
33
+ it('applies window overrides without affecting volume defaults', async () => {
34
+ vi.resetModules()
35
+ const cleanup = addDataManifest({
36
+ xr_spatial_scene: {
37
+ defaultSize: { width: '200px', height: '300px' },
38
+ resizability: { minWidth: '300px', minHeight: '400px' },
39
+ worldScaling: 'dynamic',
40
+ worldAlignment: 'gravityAligned',
41
+ baseplateVisibility: 'visible',
42
+ overrides: {
43
+ window_scene: {
44
+ defaultSize: { width: '1000px' },
45
+ resizability: { minWidth: '500px', maxWidth: '1200px' },
46
+ worldAlignment: 'automatic',
47
+ },
48
+ },
49
+ },
50
+ })
51
+ const { hijackWindowOpen, initScene } = await import('./scene-polyfill')
52
+ hijackWindowOpen(window)
53
+ await waitTick()
54
+
55
+ let winDefaults: any
56
+ initScene(
57
+ 'w',
58
+ pre => {
59
+ winDefaults = pre
60
+ return pre
61
+ },
62
+ { type: 'window' },
63
+ )
64
+
65
+ let volDefaults: any
66
+ initScene(
67
+ 'v',
68
+ pre => {
69
+ volDefaults = pre
70
+ return pre
71
+ },
72
+ { type: 'volume' },
73
+ )
74
+
75
+ // window uses overrides + formatted to px
76
+ expect(winDefaults).toEqual(
77
+ expect.objectContaining({
78
+ defaultSize: { width: '1000px', height: '300px' },
79
+ resizability: expect.objectContaining({
80
+ minWidth: '500px',
81
+ minHeight: '400px',
82
+ maxWidth: '1200px',
83
+ }),
84
+ worldAlignment: 'automatic',
85
+ worldScaling: 'dynamic',
86
+ baseplateVisibility: 'visible',
87
+ }),
88
+ )
89
+
90
+ // volume remains from top-level only (no volume override) and formats:
91
+ // - pre passed to initScene is the raw manifest values (unformatted)
92
+ expect(volDefaults).toEqual(
93
+ expect.objectContaining({
94
+ defaultSize: {
95
+ width: '200px',
96
+ height: '300px',
97
+ // depth may be omitted in manifest; defaults keep only provided keys
98
+ },
99
+ resizability: expect.objectContaining({
100
+ minWidth: '300px',
101
+ minHeight: '400px',
102
+ }),
103
+ worldAlignment: 'gravityAligned',
104
+ worldScaling: 'dynamic',
105
+ baseplateVisibility: 'visible',
106
+ }),
107
+ )
108
+
109
+ cleanup()
110
+ })
111
+
112
+ it('applies volume overrides without affecting window defaults', async () => {
113
+ vi.resetModules()
114
+ const cleanup = addDataManifest({
115
+ xr_spatial_scene: {
116
+ defaultSize: { width: '120px', height: '240px' },
117
+ resizability: { minWidth: '200px', minHeight: '300px' },
118
+ overrides: {
119
+ volume_scene: {
120
+ defaultSize: { width: '2m', height: '2m', depth: '2m' },
121
+ resizability: { minWidth: '1m' },
122
+ baseplateVisibility: 'automatic',
123
+ },
124
+ },
125
+ },
126
+ })
127
+ const { hijackWindowOpen, initScene } = await import('./scene-polyfill')
128
+ hijackWindowOpen(window)
129
+ await waitTick()
130
+
131
+ let volDefaults: any
132
+ initScene(
133
+ 'v',
134
+ pre => {
135
+ volDefaults = pre
136
+ return pre
137
+ },
138
+ { type: 'volume' },
139
+ )
140
+ let winDefaults: any
141
+ initScene(
142
+ 'w',
143
+ pre => {
144
+ winDefaults = pre
145
+ return pre
146
+ },
147
+ { type: 'window' },
148
+ )
149
+
150
+ // volume uses overrides + formatting (m for defaultSize, px for resizability)
151
+ expect(volDefaults).toEqual(
152
+ expect.objectContaining({
153
+ defaultSize: { width: '2m', height: '2m', depth: '2m' },
154
+ resizability: expect.objectContaining({
155
+ minWidth: '1m',
156
+ }),
157
+ baseplateVisibility: 'automatic',
158
+ }),
159
+ )
160
+
161
+ // window remains from top-level only (no window override)
162
+ expect(winDefaults).toEqual(
163
+ expect.objectContaining({
164
+ defaultSize: { width: '120px', height: '240px' },
165
+ resizability: expect.objectContaining({
166
+ minWidth: '200px',
167
+ minHeight: '300px',
168
+ }),
169
+ }),
170
+ )
171
+
172
+ cleanup()
173
+ })
174
+
175
+ it('applies provided mixed-case defaults and empty volume override; volume pre is formatted as expected', async () => {
176
+ vi.resetModules()
177
+ const cleanup = addDataManifest({
178
+ xr_spatial_scene: {
179
+ default_size: {
180
+ width: '1024px',
181
+ height: '1024px',
182
+ depth: '55px',
183
+ },
184
+ resizability: {
185
+ minWidth: '1024px',
186
+ minHeight: '1024px',
187
+ maxWidth: '2000px',
188
+ maxHeight: '2000px',
189
+ },
190
+ worldScaling: 'automatic',
191
+ worldAlignment: 'automatic',
192
+ baseplateVisibility: 'visible',
193
+ overrides: {
194
+ window_scene: {
195
+ default_size: {
196
+ width: '500px',
197
+ height: '500px',
198
+ depth: '55px',
199
+ },
200
+ resizability: {
201
+ minWidth: '500px',
202
+ minHeight: '500px',
203
+ maxWidth: '1000px',
204
+ maxHeight: '1000px',
205
+ },
206
+ },
207
+ volume_scene: {},
208
+ },
209
+ },
210
+ })
211
+ const { hijackWindowOpen, initScene } = await import('./scene-polyfill')
212
+ hijackWindowOpen(window)
213
+ await waitTick()
214
+
215
+ const pxToM = (px: number) => px / 1360
216
+
217
+ let volDefaults: any
218
+ initScene(
219
+ 'sa',
220
+ pre => {
221
+ volDefaults = pre
222
+ return { ...pre }
223
+ },
224
+ { type: 'volume' },
225
+ )
226
+
227
+ expect(volDefaults).toEqual(
228
+ expect.objectContaining({
229
+ defaultSize: {
230
+ width: '1024px',
231
+ height: '1024px',
232
+ depth: '55px',
233
+ },
234
+ resizability: expect.objectContaining({
235
+ minWidth: '1024px',
236
+ minHeight: '1024px',
237
+ maxWidth: '2000px',
238
+ maxHeight: '2000px',
239
+ }),
240
+ worldScaling: 'automatic',
241
+ worldAlignment: 'automatic',
242
+ baseplateVisibility: 'visible',
243
+ }),
244
+ )
245
+
246
+ // Snapshot current 'sa' internal config before cleanup
247
+ const { __getSceneConfigSnapshotForTest } = await import('./scene-polyfill')
248
+ const snap = __getSceneConfigSnapshotForTest('sa')
249
+ expect(snap).toEqual(
250
+ expect.objectContaining({
251
+ type: 'volume',
252
+ defaultSize: {
253
+ width: pxToM(1024),
254
+ height: pxToM(1024),
255
+ depth: pxToM(55),
256
+ },
257
+ resizability: expect.objectContaining({
258
+ minWidth: 1024,
259
+ minHeight: 1024,
260
+ maxWidth: 2000,
261
+ maxHeight: 2000,
262
+ }),
263
+ }),
264
+ )
265
+
266
+ cleanup()
267
+ })
268
+ })
269
+
270
+ describe('manifest error paths and empty configs', () => {
271
+ function addInvalidDataManifest() {
272
+ const link = document.createElement('link')
273
+ link.rel = 'manifest'
274
+ // Invalid JSON payload to force parse failure in getPWAManifest
275
+ link.href = 'data:application/manifest+json,INVALID_JSON'
276
+ document.head.appendChild(link)
277
+ return () => {
278
+ document.head.removeChild(link)
279
+ }
280
+ }
281
+
282
+ it('falls back to built-in defaults when no manifest link is present', async () => {
283
+ vi.resetModules()
284
+ const { hijackWindowOpen, initScene } = await import('./scene-polyfill')
285
+ hijackWindowOpen(window)
286
+ await waitTick()
287
+
288
+ let winPre: any
289
+ initScene(
290
+ 'no-manifest-win',
291
+ pre => {
292
+ winPre = pre
293
+ return pre
294
+ },
295
+ { type: 'window' },
296
+ )
297
+ let volPre: any
298
+ initScene(
299
+ 'no-manifest-vol',
300
+ pre => {
301
+ volPre = pre
302
+ return pre
303
+ },
304
+ { type: 'volume' },
305
+ )
306
+
307
+ // Window defaults: numbers in px domain for width/height
308
+ expect(winPre).toEqual(
309
+ expect.objectContaining({
310
+ defaultSize: { width: 1280, height: 720 },
311
+ }),
312
+ )
313
+ // Volume defaults: strings in meters for width/height/depth
314
+ expect(volPre).toEqual(
315
+ expect.objectContaining({
316
+ defaultSize: { width: '0.94m', height: '0.94m', depth: '0.94m' },
317
+ }),
318
+ )
319
+ })
320
+
321
+ it('falls back to built-in defaults when manifest parsing fails', async () => {
322
+ vi.resetModules()
323
+ const cleanup = addInvalidDataManifest()
324
+ const { hijackWindowOpen, initScene } = await import('./scene-polyfill')
325
+ hijackWindowOpen(window)
326
+ await waitTick()
327
+
328
+ let winPre: any
329
+ initScene(
330
+ 'bad-manifest-win',
331
+ pre => {
332
+ winPre = pre
333
+ return pre
334
+ },
335
+ { type: 'window' },
336
+ )
337
+ let volPre: any
338
+ initScene(
339
+ 'bad-manifest-vol',
340
+ pre => {
341
+ volPre = pre
342
+ return pre
343
+ },
344
+ { type: 'volume' },
345
+ )
346
+
347
+ expect(winPre).toEqual(
348
+ expect.objectContaining({
349
+ defaultSize: { width: 1280, height: 720 },
350
+ }),
351
+ )
352
+ expect(volPre).toEqual(
353
+ expect.objectContaining({
354
+ defaultSize: { width: '0.94m', height: '0.94m', depth: '0.94m' },
355
+ }),
356
+ )
357
+
358
+ cleanup()
359
+ })
360
+
361
+ it('ignores empty xr_spatial_scene object and preserves built-in defaults', async () => {
362
+ vi.resetModules()
363
+ const cleanup = addDataManifest({
364
+ xr_spatial_scene: {},
365
+ })
366
+ const { hijackWindowOpen, initScene } = await import('./scene-polyfill')
367
+ hijackWindowOpen(window)
368
+ await waitTick()
369
+
370
+ let winPre: any
371
+ initScene(
372
+ 'empty-xr-win',
373
+ pre => {
374
+ winPre = pre
375
+ return pre
376
+ },
377
+ { type: 'window' },
378
+ )
379
+ let volPre: any
380
+ initScene(
381
+ 'empty-xr-vol',
382
+ pre => {
383
+ volPre = pre
384
+ return pre
385
+ },
386
+ { type: 'volume' },
387
+ )
388
+
389
+ expect(winPre).toEqual(
390
+ expect.objectContaining({
391
+ defaultSize: { width: 1280, height: 720 },
392
+ }),
393
+ )
394
+ expect(volPre).toEqual(
395
+ expect.objectContaining({
396
+ defaultSize: { width: '0.94m', height: '0.94m', depth: '0.94m' },
397
+ }),
398
+ )
399
+
400
+ cleanup()
401
+ })
402
+ })
@@ -1,6 +1,12 @@
1
1
  import { describe, expect, test, vi, beforeEach, afterEach, it } from 'vitest'
2
- import { formatSceneConfig, initScene, injectSceneHook } from './scene-polyfill'
2
+ import {
3
+ formatSceneConfig,
4
+ initScene,
5
+ injectSceneHook,
6
+ __getSceneConfigSnapshotForTest,
7
+ } from './scene-polyfill'
3
8
  import { SpatialSceneCreationOptions } from './types/types'
9
+ import { pointToPhysical } from './physicalMetrics'
4
10
 
5
11
  describe('test formatSceneConfig in window', () => {
6
12
  test('should format window with no unit', () => {
@@ -79,6 +85,23 @@ describe('test formatSceneConfig in window', () => {
79
85
  ])
80
86
  })
81
87
 
88
+ test('window mixed units: cm invalid and numbers treated as px', () => {
89
+ const config = {
90
+ defaultSize: {
91
+ width: '10cm',
92
+ height: 800,
93
+ },
94
+ } satisfies SpatialSceneCreationOptions
95
+ const [formatted, errors] = formatSceneConfig(config, 'window')
96
+ expect(errors).toEqual(['defaultSize.width'])
97
+ expect(formatted.defaultSize).toEqual(
98
+ expect.objectContaining({
99
+ height: 800,
100
+ }),
101
+ )
102
+ expect((formatted.defaultSize as any).width).toBeUndefined()
103
+ })
104
+
82
105
  test('should format window with meter', () => {
83
106
  const config = {
84
107
  defaultSize: {
@@ -106,6 +129,27 @@ describe('test formatSceneConfig in window', () => {
106
129
  })
107
130
  })
108
131
 
132
+ describe('formatSceneConfig invalid unit (mixed)', () => {
133
+ test('volume defaultSize width cm invalid while numbers convert', () => {
134
+ const config = {
135
+ defaultSize: {
136
+ width: '10cm',
137
+ height: 1000,
138
+ depth: 100,
139
+ },
140
+ } satisfies SpatialSceneCreationOptions
141
+ const [formattedConfig, errors] = formatSceneConfig(config, 'volume')
142
+ expect(errors).toEqual(['defaultSize.width'])
143
+ expect(formattedConfig.defaultSize).toEqual(
144
+ expect.objectContaining({
145
+ height: pointToPhysical(1000),
146
+ depth: pointToPhysical(100),
147
+ }),
148
+ )
149
+ expect((formattedConfig.defaultSize as any).width).toBeUndefined()
150
+ })
151
+ })
152
+
109
153
  describe('test formatSceneConfig in volume', () => {
110
154
  test('should format volume with no unit', () => {
111
155
  const config = {
@@ -123,15 +167,15 @@ describe('test formatSceneConfig in volume', () => {
123
167
  } satisfies SpatialSceneCreationOptions
124
168
  const [formattedConfig] = formatSceneConfig(config, 'volume')
125
169
  expect(formattedConfig.defaultSize).toEqual({
126
- width: 1,
127
- height: 1,
128
- depth: 1,
170
+ width: pointToPhysical(1),
171
+ height: pointToPhysical(1),
172
+ depth: pointToPhysical(1),
129
173
  })
130
174
  expect(formattedConfig.resizability).toEqual({
131
- minWidth: 1360,
132
- minHeight: 1360,
133
- maxWidth: 1360,
134
- maxHeight: 1360,
175
+ minWidth: 1,
176
+ minHeight: 1,
177
+ maxWidth: 1,
178
+ maxHeight: 1,
135
179
  })
136
180
  })
137
181
 
@@ -316,7 +360,7 @@ describe('injectScenePolyfill should call xrCurrentSceneDefaults and update scen
316
360
 
317
361
  expect(mockFn).toHaveBeenCalledWith(
318
362
  expect.objectContaining({
319
- defaultSize: { width: 0.94, height: 0.94, depth: 0.94 },
363
+ defaultSize: { width: '0.94m', height: '0.94m', depth: '0.94m' },
320
364
  }),
321
365
  )
322
366
 
@@ -324,12 +368,16 @@ describe('injectScenePolyfill should call xrCurrentSceneDefaults and update scen
324
368
  const { UpdateSceneConfig } = await import('./JSBCommand')
325
369
  expect(UpdateSceneConfig).toHaveBeenCalledWith({
326
370
  type: 'volume',
327
- defaultSize: { width: 1, height: 1, depth: 1 },
371
+ defaultSize: {
372
+ width: pointToPhysical(1),
373
+ height: pointToPhysical(1),
374
+ depth: pointToPhysical(1),
375
+ },
328
376
  resizability: {
329
- minWidth: 0.5 * 1360,
330
- minHeight: 1 * 1360,
331
- maxWidth: 0.5 * 1360,
332
- maxHeight: 1 * 1360,
377
+ minWidth: 0.5,
378
+ minHeight: 1,
379
+ maxWidth: 0.5,
380
+ maxHeight: 1,
333
381
  },
334
382
  })
335
383
  })
@@ -341,7 +389,7 @@ describe('initScene should receive defaultScene config by type', () => {
341
389
  .fn()
342
390
  .mockResolvedValue({ defaultSize: { width: 800, height: 600 } })
343
391
 
344
- initScene('sa', mockFn)
392
+ initScene('sa-no-type', mockFn)
345
393
 
346
394
  expect(mockFn).toHaveBeenCalledWith(
347
395
  expect.objectContaining({ defaultSize: { width: 1280, height: 720 } }),
@@ -353,7 +401,7 @@ describe('initScene should receive defaultScene config by type', () => {
353
401
  .fn()
354
402
  .mockResolvedValue({ defaultSize: { width: 800, height: 600 } })
355
403
 
356
- initScene('sa', mockFn, { type: 'window' })
404
+ initScene('sa-window', mockFn, { type: 'window' })
357
405
 
358
406
  expect(mockFn).toHaveBeenCalledWith(
359
407
  expect.objectContaining({ defaultSize: { width: 1280, height: 720 } }),
@@ -365,12 +413,54 @@ describe('initScene should receive defaultScene config by type', () => {
365
413
  .fn()
366
414
  .mockResolvedValue({ defaultSize: { width: 800, height: 600 } })
367
415
 
368
- initScene('sa', mockFn, { type: 'volume' })
416
+ initScene('sa-volume', mockFn, { type: 'volume' })
369
417
 
370
418
  expect(mockFn).toHaveBeenCalledWith(
371
419
  expect.objectContaining({
372
- defaultSize: { width: 0.94, height: 0.94, depth: 0.94 },
420
+ defaultSize: { width: '0.94m', height: '0.94m', depth: '0.94m' },
373
421
  }),
374
422
  )
375
423
  })
424
+
425
+ it('with volume type and merge pre', () => {
426
+ const cb = vi.fn().mockImplementation(pre => ({
427
+ ...pre,
428
+ defaultSize: { ...(pre.defaultSize as any), depth: '0.1m' },
429
+ }))
430
+
431
+ // pre-snapshot should be empty before initScene writes configMap
432
+ const preSnap = __getSceneConfigSnapshotForTest('sa')
433
+ expect(preSnap).toBeUndefined()
434
+
435
+ initScene('sa', cb, { type: 'volume' })
436
+
437
+ expect(cb).toHaveBeenCalledWith(
438
+ expect.objectContaining({
439
+ defaultSize: { width: '0.94m', height: '0.94m', depth: '0.94m' },
440
+ }),
441
+ )
442
+
443
+ const snap = __getSceneConfigSnapshotForTest('sa')
444
+ expect(snap).toEqual(
445
+ expect.objectContaining({
446
+ type: 'volume',
447
+ defaultSize: { width: 0.94, height: 0.94, depth: 0.1 },
448
+ }),
449
+ )
450
+ })
451
+ })
452
+
453
+ describe('initScene callback chaining', () => {
454
+ it('passes previous return value as next pre argument', () => {
455
+ const firstReturn = { defaultSize: { width: 1000, height: 1000 } }
456
+ const cb1 = vi.fn().mockReturnValue(firstReturn)
457
+ initScene('sa-chain', cb1)
458
+ expect(cb1).toHaveBeenCalledWith(
459
+ expect.objectContaining({ defaultSize: { width: 1280, height: 720 } }),
460
+ )
461
+
462
+ const cb2 = vi.fn().mockReturnValue({})
463
+ initScene('sa-chain', cb2)
464
+ expect(cb2).toHaveBeenCalledWith(firstReturn)
465
+ })
376
466
  })