@flighthq/sensors 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,771 @@
1
+ import { connectSignal } from '@flighthq/signals';
2
+ import type {
3
+ AmbientLightReading,
4
+ MotionReading,
5
+ OrientationReading,
6
+ PressureReading,
7
+ ProximityReading,
8
+ QuaternionReading,
9
+ RotationRateReading,
10
+ SensorsBackend,
11
+ SensorsPermissionState,
12
+ } from '@flighthq/types';
13
+
14
+ import {
15
+ attachSensors,
16
+ computeEulerFromQuaternion,
17
+ computeGravityFromOrientation,
18
+ computeQuaternionFromOrientationReading,
19
+ computeRotationMatrixFromQuaternion,
20
+ computeScreenRelativeOrientation,
21
+ computeWorldAccelerationFromDeviceAcceleration,
22
+ createAmbientLightReading,
23
+ createMotionReading,
24
+ createOrientationReading,
25
+ createPressureReading,
26
+ createProximityReading,
27
+ createQuaternionReading,
28
+ createRotationRateReading,
29
+ createSensors,
30
+ createWebSensorsBackend,
31
+ detachSensors,
32
+ disposeSensors,
33
+ getSensorsBackend,
34
+ getSensorsPermissionState,
35
+ hasAccelerometer,
36
+ hasAmbientLightSensor,
37
+ hasBarometer,
38
+ hasGravitySensor,
39
+ hasGyroscope,
40
+ hasLinearAccelerationSensor,
41
+ hasMagnetometer,
42
+ hasOrientationSensor,
43
+ hasProximitySensor,
44
+ isSensorsSupported,
45
+ requestSensorsPermission,
46
+ setSensorsBackend,
47
+ } from './sensors';
48
+
49
+ function fakeBackend(): SensorsBackend & {
50
+ fireMotion: (acceleration: MotionReading, rotationRate: RotationRateReading) => void;
51
+ fireOrientation: (orientation: OrientationReading) => void;
52
+ fireMagnetometer: (reading: MotionReading) => void;
53
+ fireLinearAcceleration: (reading: MotionReading) => void;
54
+ fireGravity: (reading: MotionReading) => void;
55
+ fireAbsoluteOrientation: (orientation: OrientationReading) => void;
56
+ fireAmbientLight: (reading: AmbientLightReading) => void;
57
+ fireBarometer: (reading: PressureReading) => void;
58
+ fireProximity: (reading: ProximityReading) => void;
59
+ fireQuaternion: (reading: QuaternionReading) => void;
60
+ } {
61
+ let motionListener: ((a: Readonly<MotionReading>, r: Readonly<RotationRateReading>) => void) | null = null;
62
+ let orientationListener: ((o: Readonly<OrientationReading>) => void) | null = null;
63
+ let magnetometerListener: ((reading: Readonly<MotionReading>) => void) | null = null;
64
+ let linearAccelerationListener: ((reading: Readonly<MotionReading>) => void) | null = null;
65
+ let gravityListener: ((reading: Readonly<MotionReading>) => void) | null = null;
66
+ let absoluteOrientationListener: ((orientation: Readonly<OrientationReading>) => void) | null = null;
67
+ let ambientLightListener: ((reading: Readonly<AmbientLightReading>) => void) | null = null;
68
+ let barometerListener: ((reading: Readonly<PressureReading>) => void) | null = null;
69
+ let proximityListener: ((reading: Readonly<ProximityReading>) => void) | null = null;
70
+ let quaternionListener: ((reading: Readonly<QuaternionReading>) => void) | null = null;
71
+
72
+ return {
73
+ getPermissionState: async (): Promise<SensorsPermissionState> => 'granted',
74
+ isAmbientLightSupported: () => true,
75
+ isBarometerSupported: () => false,
76
+ isGravitySupported: () => true,
77
+ isGyroscopeSupported: () => true,
78
+ isLinearAccelerationSupported: () => true,
79
+ isMagnetometerSupported: () => true,
80
+ isMotionSupported: () => true,
81
+ isOrientationSupported: () => true,
82
+ isProximitySupported: () => false,
83
+ requestPermission: async () => true,
84
+ subscribeAbsoluteOrientation(l) {
85
+ absoluteOrientationListener = l;
86
+ return () => {
87
+ absoluteOrientationListener = null;
88
+ };
89
+ },
90
+ subscribeAmbientLight(l) {
91
+ ambientLightListener = l;
92
+ return () => {
93
+ ambientLightListener = null;
94
+ };
95
+ },
96
+ subscribeBarometer(l) {
97
+ barometerListener = l;
98
+ return () => {
99
+ barometerListener = null;
100
+ };
101
+ },
102
+ subscribeGravity(l) {
103
+ gravityListener = l;
104
+ return () => {
105
+ gravityListener = null;
106
+ };
107
+ },
108
+ subscribeLinearAcceleration(l) {
109
+ linearAccelerationListener = l;
110
+ return () => {
111
+ linearAccelerationListener = null;
112
+ };
113
+ },
114
+ subscribeMagnetometer(l) {
115
+ magnetometerListener = l;
116
+ return () => {
117
+ magnetometerListener = null;
118
+ };
119
+ },
120
+ subscribeMotion(l) {
121
+ motionListener = l;
122
+ return () => {
123
+ motionListener = null;
124
+ };
125
+ },
126
+ subscribeOrientation(l) {
127
+ orientationListener = l;
128
+ return () => {
129
+ orientationListener = null;
130
+ };
131
+ },
132
+ subscribeProximity(l) {
133
+ proximityListener = l;
134
+ return () => {
135
+ proximityListener = null;
136
+ };
137
+ },
138
+ subscribeQuaternion(l) {
139
+ quaternionListener = l;
140
+ return () => {
141
+ quaternionListener = null;
142
+ };
143
+ },
144
+ fireAbsoluteOrientation(orientation) {
145
+ absoluteOrientationListener?.(orientation);
146
+ },
147
+ fireAmbientLight(reading) {
148
+ ambientLightListener?.(reading);
149
+ },
150
+ fireBarometer(reading) {
151
+ barometerListener?.(reading);
152
+ },
153
+ fireGravity(reading) {
154
+ gravityListener?.(reading);
155
+ },
156
+ fireLinearAcceleration(reading) {
157
+ linearAccelerationListener?.(reading);
158
+ },
159
+ fireMagnetometer(reading) {
160
+ magnetometerListener?.(reading);
161
+ },
162
+ fireMotion(acceleration, rotationRate) {
163
+ motionListener?.(acceleration, rotationRate);
164
+ },
165
+ fireOrientation(orientation) {
166
+ orientationListener?.(orientation);
167
+ },
168
+ fireProximity(reading) {
169
+ proximityListener?.(reading);
170
+ },
171
+ fireQuaternion(reading) {
172
+ quaternionListener?.(reading);
173
+ },
174
+ };
175
+ }
176
+
177
+ afterEach(() => setSensorsBackend(null));
178
+
179
+ describe('attachSensors', () => {
180
+ it('emits onAccelerometer and onGyroscope from the motion stream', () => {
181
+ const backend = fakeBackend();
182
+ setSensorsBackend(backend);
183
+ const sensors = createSensors();
184
+ let accel = 0;
185
+ let gyro = 0;
186
+ connectSignal(sensors.onAccelerometer, () => accel++);
187
+ connectSignal(sensors.onGyroscope, () => gyro++);
188
+ attachSensors(sensors);
189
+ backend.fireMotion(createMotionReading(), createRotationRateReading());
190
+ expect(accel).toBe(1);
191
+ expect(gyro).toBe(1);
192
+ });
193
+
194
+ it('emits onOrientation from the orientation stream', () => {
195
+ const backend = fakeBackend();
196
+ setSensorsBackend(backend);
197
+ const sensors = createSensors();
198
+ let orient = 0;
199
+ connectSignal(sensors.onOrientation, () => orient++);
200
+ attachSensors(sensors);
201
+ backend.fireOrientation(createOrientationReading());
202
+ expect(orient).toBe(1);
203
+ });
204
+
205
+ it('emits onMagnetometer from the magnetometer stream', () => {
206
+ const backend = fakeBackend();
207
+ setSensorsBackend(backend);
208
+ const sensors = createSensors();
209
+ let magnet = 0;
210
+ connectSignal(sensors.onMagnetometer, () => magnet++);
211
+ attachSensors(sensors);
212
+ backend.fireMagnetometer(createMotionReading());
213
+ expect(magnet).toBe(1);
214
+ });
215
+
216
+ it('emits onLinearAcceleration from the linear acceleration stream', () => {
217
+ const backend = fakeBackend();
218
+ setSensorsBackend(backend);
219
+ const sensors = createSensors();
220
+ let count = 0;
221
+ connectSignal(sensors.onLinearAcceleration, () => count++);
222
+ attachSensors(sensors);
223
+ backend.fireLinearAcceleration(createMotionReading());
224
+ expect(count).toBe(1);
225
+ });
226
+
227
+ it('emits onGravity from the gravity stream', () => {
228
+ const backend = fakeBackend();
229
+ setSensorsBackend(backend);
230
+ const sensors = createSensors();
231
+ let count = 0;
232
+ connectSignal(sensors.onGravity, () => count++);
233
+ attachSensors(sensors);
234
+ backend.fireGravity(createMotionReading());
235
+ expect(count).toBe(1);
236
+ });
237
+
238
+ it('emits onAbsoluteOrientation from the absolute orientation stream', () => {
239
+ const backend = fakeBackend();
240
+ setSensorsBackend(backend);
241
+ const sensors = createSensors();
242
+ let count = 0;
243
+ connectSignal(sensors.onAbsoluteOrientation, () => count++);
244
+ attachSensors(sensors);
245
+ backend.fireAbsoluteOrientation(createOrientationReading());
246
+ expect(count).toBe(1);
247
+ });
248
+
249
+ it('emits onAmbientLight from the ambient light stream', () => {
250
+ const backend = fakeBackend();
251
+ setSensorsBackend(backend);
252
+ const sensors = createSensors();
253
+ let count = 0;
254
+ connectSignal(sensors.onAmbientLight, () => count++);
255
+ attachSensors(sensors);
256
+ backend.fireAmbientLight(createAmbientLightReading());
257
+ expect(count).toBe(1);
258
+ });
259
+
260
+ it('emits onProximity from the proximity stream', () => {
261
+ const backend = fakeBackend();
262
+ setSensorsBackend(backend);
263
+ const sensors = createSensors();
264
+ let count = 0;
265
+ connectSignal(sensors.onProximity, () => count++);
266
+ attachSensors(sensors);
267
+ backend.fireProximity(createProximityReading());
268
+ expect(count).toBe(1);
269
+ });
270
+
271
+ it('emits onQuaternion from the quaternion stream', () => {
272
+ const backend = fakeBackend();
273
+ setSensorsBackend(backend);
274
+ const sensors = createSensors();
275
+ let count = 0;
276
+ connectSignal(sensors.onQuaternion, () => count++);
277
+ attachSensors(sensors);
278
+ backend.fireQuaternion(createQuaternionReading());
279
+ expect(count).toBe(1);
280
+ });
281
+
282
+ it('is idempotent: re-attach tears down the prior subscription', () => {
283
+ const backend = fakeBackend();
284
+ setSensorsBackend(backend);
285
+ const sensors = createSensors();
286
+ let count = 0;
287
+ connectSignal(sensors.onAccelerometer, () => count++);
288
+ attachSensors(sensors);
289
+ attachSensors(sensors);
290
+ backend.fireMotion(createMotionReading(), createRotationRateReading());
291
+ // Only one subscription should be active.
292
+ expect(count).toBe(1);
293
+ });
294
+ });
295
+
296
+ describe('computeEulerFromQuaternion', () => {
297
+ it('converts an identity quaternion to zero Euler angles', () => {
298
+ const q = createQuaternionReading();
299
+ const out = createOrientationReading();
300
+ computeEulerFromQuaternion(out, q);
301
+ // Identity quaternion → zero rotation → alpha near 0, beta near 0, gamma near 0.
302
+ expect(out.beta).toBeCloseTo(0, 5);
303
+ expect(out.gamma).toBeCloseTo(0, 5);
304
+ });
305
+
306
+ it('propagates interval, timestamp, and accuracy from the quaternion', () => {
307
+ const q = createQuaternionReading();
308
+ q.interval = 20;
309
+ q.timestamp = 99999;
310
+ q.accuracy = 'high';
311
+ const out = createOrientationReading();
312
+ computeEulerFromQuaternion(out, q);
313
+ expect(out.interval).toBe(20);
314
+ expect(out.timestamp).toBe(99999);
315
+ expect(out.accuracy).toBe('high');
316
+ });
317
+
318
+ it('is alias-safe when out is a separate object', () => {
319
+ const q = createQuaternionReading();
320
+ // 90-degree rotation around Z.
321
+ const angle = Math.PI / 4;
322
+ q.w = Math.cos(angle);
323
+ q.z = Math.sin(angle);
324
+ const out = createOrientationReading();
325
+ computeEulerFromQuaternion(out, q);
326
+ // alpha should reflect a ~90-degree rotation around Z.
327
+ expect(out.alpha).toBeGreaterThan(80);
328
+ expect(out.alpha).toBeLessThan(100);
329
+ });
330
+ });
331
+
332
+ describe('computeGravityFromOrientation', () => {
333
+ it('returns a near-zero gravity vector for zero orientation', () => {
334
+ const orientation = createOrientationReading();
335
+ const out = createMotionReading();
336
+ computeGravityFromOrientation(out, orientation);
337
+ // Device flat on table: gravity points in +Z (device frame), with ~9.8 m/s².
338
+ expect(out.x).toBeCloseTo(0, 3);
339
+ expect(out.y).toBeCloseTo(0, 3);
340
+ expect(Math.abs(out.z)).toBeCloseTo(9.80665, 3);
341
+ });
342
+
343
+ it('propagates interval, timestamp, and accuracy from the orientation', () => {
344
+ const orientation = createOrientationReading();
345
+ orientation.interval = 10;
346
+ orientation.timestamp = 5000;
347
+ orientation.accuracy = 'medium';
348
+ const out = createMotionReading();
349
+ computeGravityFromOrientation(out, orientation);
350
+ expect(out.interval).toBe(10);
351
+ expect(out.timestamp).toBe(5000);
352
+ expect(out.accuracy).toBe('medium');
353
+ });
354
+
355
+ it('is alias-safe: does not corrupt the input when out differs', () => {
356
+ const orientation = createOrientationReading();
357
+ orientation.beta = 90;
358
+ const out = createMotionReading();
359
+ computeGravityFromOrientation(out, orientation);
360
+ // When device is tilted 90° around X (beta=90), gravity aligns with -Y in device frame.
361
+ expect(out.y).toBeCloseTo(-9.80665, 3);
362
+ });
363
+ });
364
+
365
+ describe('computeQuaternionFromOrientationReading', () => {
366
+ it('converts a zero-rotation orientation to approximately identity quaternion', () => {
367
+ const orientation = createOrientationReading();
368
+ const out = createQuaternionReading();
369
+ computeQuaternionFromOrientationReading(out, orientation);
370
+ expect(out.w).toBeCloseTo(1, 5);
371
+ expect(out.x).toBeCloseTo(0, 5);
372
+ expect(out.y).toBeCloseTo(0, 5);
373
+ expect(out.z).toBeCloseTo(0, 5);
374
+ });
375
+
376
+ it('propagates interval and timestamp from the orientation', () => {
377
+ const orientation = createOrientationReading();
378
+ orientation.interval = 16;
379
+ orientation.timestamp = 12345;
380
+ const out = createQuaternionReading();
381
+ computeQuaternionFromOrientationReading(out, orientation);
382
+ expect(out.interval).toBe(16);
383
+ expect(out.timestamp).toBe(12345);
384
+ });
385
+
386
+ it('is alias-safe when out is the same object that contains orientation-like values', () => {
387
+ const orientation = createOrientationReading();
388
+ orientation.alpha = 90;
389
+ const out = createQuaternionReading();
390
+ // Capture expected value with a distinct output first.
391
+ const expected = createQuaternionReading();
392
+ computeQuaternionFromOrientationReading(expected, orientation);
393
+ computeQuaternionFromOrientationReading(out, orientation);
394
+ expect(out.w).toBeCloseTo(expected.w, 5);
395
+ expect(out.x).toBeCloseTo(expected.x, 5);
396
+ });
397
+ });
398
+
399
+ describe('computeRotationMatrixFromQuaternion', () => {
400
+ it('returns identity matrix for identity quaternion', () => {
401
+ const q = createQuaternionReading();
402
+ const out: number[] = new Array(9).fill(0);
403
+ computeRotationMatrixFromQuaternion(out, q);
404
+ // Column-major identity: diagonal elements = 1, off-diagonal = 0.
405
+ expect(out[0]).toBeCloseTo(1, 5);
406
+ expect(out[4]).toBeCloseTo(1, 5);
407
+ expect(out[8]).toBeCloseTo(1, 5);
408
+ expect(out[1]).toBeCloseTo(0, 5);
409
+ expect(out[2]).toBeCloseTo(0, 5);
410
+ expect(out[3]).toBeCloseTo(0, 5);
411
+ });
412
+
413
+ it('is alias-safe: output array separate from quaternion does not corrupt quaternion fields', () => {
414
+ const q = createQuaternionReading();
415
+ const out: number[] = new Array(9).fill(0);
416
+ computeRotationMatrixFromQuaternion(out, q);
417
+ // Quaternion should remain identity after the call.
418
+ expect(q.w).toBe(1);
419
+ expect(q.x).toBe(0);
420
+ });
421
+ });
422
+
423
+ describe('computeScreenRelativeOrientation', () => {
424
+ it('returns unchanged orientation when screenAngle is 0', () => {
425
+ const orientation = createOrientationReading();
426
+ orientation.beta = 30;
427
+ orientation.gamma = 15;
428
+ const out = createOrientationReading();
429
+ computeScreenRelativeOrientation(out, orientation, 0);
430
+ expect(out.beta).toBeCloseTo(30, 5);
431
+ expect(out.gamma).toBeCloseTo(15, 5);
432
+ expect(out.alpha).toBeCloseTo(0, 5);
433
+ });
434
+
435
+ it('propagates absolute, heading, interval, timestamp, and accuracy', () => {
436
+ const orientation = createOrientationReading();
437
+ orientation.absolute = true;
438
+ orientation.heading = 45;
439
+ orientation.interval = 16;
440
+ orientation.timestamp = 1000;
441
+ orientation.accuracy = 'high';
442
+ const out = createOrientationReading();
443
+ computeScreenRelativeOrientation(out, orientation, 0);
444
+ expect(out.absolute).toBe(true);
445
+ expect(out.heading).toBe(45);
446
+ expect(out.interval).toBe(16);
447
+ expect(out.timestamp).toBe(1000);
448
+ expect(out.accuracy).toBe('high');
449
+ });
450
+
451
+ it('is alias-safe when out is separate from orientation', () => {
452
+ const orientation = createOrientationReading();
453
+ orientation.beta = 20;
454
+ orientation.gamma = 10;
455
+ const out = createOrientationReading();
456
+ computeScreenRelativeOrientation(out, orientation, 90);
457
+ // With 90-degree screen rotation: beta' = beta*cos(90) - gamma*sin(90) = -gamma.
458
+ expect(out.beta).toBeCloseTo(-10, 3);
459
+ expect(out.gamma).toBeCloseTo(20, 3);
460
+ });
461
+ });
462
+
463
+ describe('computeWorldAccelerationFromDeviceAcceleration', () => {
464
+ it('returns the same vector for an identity quaternion orientation', () => {
465
+ const accel = createMotionReading();
466
+ accel.x = 1;
467
+ accel.y = 2;
468
+ accel.z = 3;
469
+ const q = createQuaternionReading(); // identity
470
+ const out = createMotionReading();
471
+ computeWorldAccelerationFromDeviceAcceleration(out, accel, q);
472
+ expect(out.x).toBeCloseTo(1, 5);
473
+ expect(out.y).toBeCloseTo(2, 5);
474
+ expect(out.z).toBeCloseTo(3, 5);
475
+ });
476
+
477
+ it('propagates interval, timestamp, and accuracy from acceleration', () => {
478
+ const accel = createMotionReading();
479
+ accel.interval = 8;
480
+ accel.timestamp = 42000;
481
+ accel.accuracy = 'low';
482
+ const q = createQuaternionReading();
483
+ const out = createMotionReading();
484
+ computeWorldAccelerationFromDeviceAcceleration(out, accel, q);
485
+ expect(out.interval).toBe(8);
486
+ expect(out.timestamp).toBe(42000);
487
+ expect(out.accuracy).toBe('low');
488
+ });
489
+
490
+ it('is alias-safe when out is the same object as acceleration', () => {
491
+ const accel = createMotionReading();
492
+ accel.x = 1;
493
+ accel.y = 0;
494
+ accel.z = 0;
495
+ const q = createQuaternionReading();
496
+ // Out aliases the acceleration input.
497
+ computeWorldAccelerationFromDeviceAcceleration(accel, accel, q);
498
+ expect(accel.x).toBeCloseTo(1, 5);
499
+ expect(accel.y).toBeCloseTo(0, 5);
500
+ expect(accel.z).toBeCloseTo(0, 5);
501
+ });
502
+ });
503
+
504
+ describe('createAmbientLightReading', () => {
505
+ it('allocates a zeroed reading with unknown accuracy and sentinel interval/timestamp', () => {
506
+ const r = createAmbientLightReading();
507
+ expect(r.illuminance).toBe(0);
508
+ expect(r.accuracy).toBe('unknown');
509
+ expect(r.interval).toBe(-1);
510
+ expect(r.timestamp).toBe(-1);
511
+ });
512
+ });
513
+
514
+ describe('createMotionReading', () => {
515
+ it('allocates a zeroed reading with unknown accuracy and sentinel interval/timestamp', () => {
516
+ const r = createMotionReading();
517
+ expect(r).toMatchObject({ x: 0, y: 0, z: 0, accuracy: 'unknown', interval: -1, timestamp: -1 });
518
+ });
519
+ });
520
+
521
+ describe('createOrientationReading', () => {
522
+ it('allocates a zeroed reading with unknown heading, relative frame, and sentinel fields', () => {
523
+ const r = createOrientationReading();
524
+ expect(r).toMatchObject({
525
+ alpha: 0,
526
+ beta: 0,
527
+ gamma: 0,
528
+ absolute: false,
529
+ heading: -1,
530
+ accuracy: 'unknown',
531
+ interval: -1,
532
+ timestamp: -1,
533
+ });
534
+ });
535
+ });
536
+
537
+ describe('createPressureReading', () => {
538
+ it('allocates a zeroed reading with unknown accuracy, sentinel altitude/interval/timestamp', () => {
539
+ const r = createPressureReading();
540
+ expect(r.pressure).toBe(0);
541
+ expect(r.altitude).toBe(-1);
542
+ expect(r.accuracy).toBe('unknown');
543
+ expect(r.interval).toBe(-1);
544
+ expect(r.timestamp).toBe(-1);
545
+ });
546
+ });
547
+
548
+ describe('createProximityReading', () => {
549
+ it('allocates a zeroed reading with sentinel distance and max', () => {
550
+ const r = createProximityReading();
551
+ expect(r.near).toBe(false);
552
+ expect(r.distance).toBe(-1);
553
+ expect(r.max).toBe(-1);
554
+ expect(r.accuracy).toBe('unknown');
555
+ });
556
+ });
557
+
558
+ describe('createQuaternionReading', () => {
559
+ it('allocates an identity quaternion with unknown accuracy and sentinel interval/timestamp', () => {
560
+ const r = createQuaternionReading();
561
+ expect(r).toMatchObject({ w: 1, x: 0, y: 0, z: 0, accuracy: 'unknown', interval: -1, timestamp: -1 });
562
+ });
563
+ });
564
+
565
+ describe('createRotationRateReading', () => {
566
+ it('allocates a zeroed reading with unknown accuracy and sentinel interval/timestamp', () => {
567
+ const r = createRotationRateReading();
568
+ expect(r).toMatchObject({ alpha: 0, beta: 0, gamma: 0, accuracy: 'unknown', interval: -1, timestamp: -1 });
569
+ });
570
+ });
571
+
572
+ describe('createSensors', () => {
573
+ it('creates an entity with all eleven signals', () => {
574
+ const sensors = createSensors();
575
+ expect(sensors.onAbsoluteOrientation).toBeDefined();
576
+ expect(sensors.onAccelerometer).toBeDefined();
577
+ expect(sensors.onAmbientLight).toBeDefined();
578
+ expect(sensors.onBarometer).toBeDefined();
579
+ expect(sensors.onGravity).toBeDefined();
580
+ expect(sensors.onGyroscope).toBeDefined();
581
+ expect(sensors.onLinearAcceleration).toBeDefined();
582
+ expect(sensors.onMagnetometer).toBeDefined();
583
+ expect(sensors.onOrientation).toBeDefined();
584
+ expect(sensors.onProximity).toBeDefined();
585
+ expect(sensors.onQuaternion).toBeDefined();
586
+ });
587
+ });
588
+
589
+ describe('createWebSensorsBackend', () => {
590
+ it('subscribes to all streams without throwing', () => {
591
+ const backend = createWebSensorsBackend();
592
+ const unsubs = [
593
+ backend.subscribeMotion(() => {}),
594
+ backend.subscribeLinearAcceleration(() => {}),
595
+ backend.subscribeGravity(() => {}),
596
+ backend.subscribeOrientation(() => {}),
597
+ backend.subscribeAbsoluteOrientation(() => {}),
598
+ backend.subscribeMagnetometer(() => {}),
599
+ backend.subscribeAmbientLight(() => {}),
600
+ backend.subscribeBarometer(() => {}),
601
+ backend.subscribeProximity(() => {}),
602
+ backend.subscribeQuaternion(() => {}),
603
+ ];
604
+ expect(() => unsubs.forEach((u) => u())).not.toThrow();
605
+ });
606
+
607
+ it('resolves a permission request without throwing', async () => {
608
+ expect(typeof (await createWebSensorsBackend().requestPermission())).toBe('boolean');
609
+ });
610
+
611
+ it('resolves permission state without throwing', async () => {
612
+ const state = await createWebSensorsBackend().getPermissionState();
613
+ expect(['granted', 'denied', 'prompt', 'unsupported']).toContain(state);
614
+ });
615
+ });
616
+
617
+ describe('detachSensors', () => {
618
+ it('stops further delivery', () => {
619
+ const backend = fakeBackend();
620
+ setSensorsBackend(backend);
621
+ const sensors = createSensors();
622
+ let accel = 0;
623
+ connectSignal(sensors.onAccelerometer, () => accel++);
624
+ attachSensors(sensors);
625
+ detachSensors(sensors);
626
+ backend.fireMotion(createMotionReading(), createRotationRateReading());
627
+ expect(accel).toBe(0);
628
+ });
629
+
630
+ it('is safe to call when not attached', () => {
631
+ const sensors = createSensors();
632
+ expect(() => detachSensors(sensors)).not.toThrow();
633
+ });
634
+ });
635
+
636
+ describe('disposeSensors', () => {
637
+ it('detaches the subscriptions without throwing', () => {
638
+ const backend = fakeBackend();
639
+ setSensorsBackend(backend);
640
+ const sensors = createSensors();
641
+ attachSensors(sensors);
642
+ expect(() => disposeSensors(sensors)).not.toThrow();
643
+ });
644
+ });
645
+
646
+ describe('getSensorsBackend', () => {
647
+ it('falls back to a web backend when none is installed', () => {
648
+ expect(getSensorsBackend()).not.toBeNull();
649
+ });
650
+ });
651
+
652
+ describe('getSensorsPermissionState', () => {
653
+ it('returns a valid permission state string', async () => {
654
+ setSensorsBackend(fakeBackend());
655
+ const state = await getSensorsPermissionState();
656
+ expect(['granted', 'denied', 'prompt', 'unsupported']).toContain(state);
657
+ });
658
+ });
659
+
660
+ describe('hasAccelerometer', () => {
661
+ it('returns a boolean', () => {
662
+ setSensorsBackend(fakeBackend());
663
+ expect(typeof hasAccelerometer()).toBe('boolean');
664
+ });
665
+
666
+ it('returns true when the backend reports motion support', () => {
667
+ setSensorsBackend(fakeBackend());
668
+ expect(hasAccelerometer()).toBe(true);
669
+ });
670
+ });
671
+
672
+ describe('hasAmbientLightSensor', () => {
673
+ it('returns a boolean', () => {
674
+ setSensorsBackend(fakeBackend());
675
+ expect(typeof hasAmbientLightSensor()).toBe('boolean');
676
+ });
677
+ });
678
+
679
+ describe('hasBarometer', () => {
680
+ it('returns a boolean', () => {
681
+ setSensorsBackend(fakeBackend());
682
+ expect(typeof hasBarometer()).toBe('boolean');
683
+ });
684
+ });
685
+
686
+ describe('hasGravitySensor', () => {
687
+ it('returns a boolean', () => {
688
+ setSensorsBackend(fakeBackend());
689
+ expect(typeof hasGravitySensor()).toBe('boolean');
690
+ });
691
+
692
+ it('returns true when the backend reports gravity support', () => {
693
+ setSensorsBackend(fakeBackend());
694
+ expect(hasGravitySensor()).toBe(true);
695
+ });
696
+ });
697
+
698
+ describe('hasGyroscope', () => {
699
+ it('returns a boolean', () => {
700
+ setSensorsBackend(fakeBackend());
701
+ expect(typeof hasGyroscope()).toBe('boolean');
702
+ });
703
+ });
704
+
705
+ describe('hasLinearAccelerationSensor', () => {
706
+ it('returns a boolean', () => {
707
+ setSensorsBackend(fakeBackend());
708
+ expect(typeof hasLinearAccelerationSensor()).toBe('boolean');
709
+ });
710
+
711
+ it('returns true when the backend reports linear acceleration support', () => {
712
+ setSensorsBackend(fakeBackend());
713
+ expect(hasLinearAccelerationSensor()).toBe(true);
714
+ });
715
+ });
716
+
717
+ describe('hasMagnetometer', () => {
718
+ it('returns a boolean', () => {
719
+ setSensorsBackend(fakeBackend());
720
+ expect(typeof hasMagnetometer()).toBe('boolean');
721
+ });
722
+ });
723
+
724
+ describe('hasOrientationSensor', () => {
725
+ it('returns a boolean', () => {
726
+ setSensorsBackend(fakeBackend());
727
+ expect(typeof hasOrientationSensor()).toBe('boolean');
728
+ });
729
+ });
730
+
731
+ describe('hasProximitySensor', () => {
732
+ it('returns a boolean', () => {
733
+ setSensorsBackend(fakeBackend());
734
+ expect(typeof hasProximitySensor()).toBe('boolean');
735
+ });
736
+ });
737
+
738
+ describe('isSensorsSupported', () => {
739
+ it('returns true when the fake backend reports motion support', () => {
740
+ setSensorsBackend(fakeBackend());
741
+ expect(isSensorsSupported()).toBe(true);
742
+ });
743
+
744
+ it('returns false when the backend reports no motion support', () => {
745
+ const backend = fakeBackend();
746
+ vi.spyOn(backend, 'isMotionSupported').mockReturnValue(false);
747
+ setSensorsBackend(backend);
748
+ expect(isSensorsSupported()).toBe(false);
749
+ });
750
+ });
751
+
752
+ describe('requestSensorsPermission', () => {
753
+ it('delegates to the backend', async () => {
754
+ setSensorsBackend(fakeBackend());
755
+ expect(await requestSensorsPermission()).toBe(true);
756
+ });
757
+ });
758
+
759
+ describe('setSensorsBackend', () => {
760
+ it('clears back to the web fallback when passed null', () => {
761
+ setSensorsBackend(fakeBackend());
762
+ setSensorsBackend(null);
763
+ expect(getSensorsBackend()).not.toBeNull();
764
+ });
765
+
766
+ it('installs a custom backend that is returned by getSensorsBackend', () => {
767
+ const backend = fakeBackend();
768
+ setSensorsBackend(backend);
769
+ expect(getSensorsBackend()).toBe(backend);
770
+ });
771
+ });