|
| 1 | +import test from 'node:test'; |
| 2 | +import assert from 'node:assert/strict'; |
| 3 | +import { |
| 4 | + requireIntInRange, |
| 5 | + clampIosSwipeDuration, |
| 6 | + shouldUseIosTapSeries, |
| 7 | + shouldUseIosDragSeries, |
| 8 | + computeDeterministicJitter, |
| 9 | + runRepeatedSeries, |
| 10 | +} from '../dispatch-series.ts'; |
| 11 | +import { AppError } from '../../utils/errors.ts'; |
| 12 | +import type { DeviceInfo } from '../../utils/device.ts'; |
| 13 | + |
| 14 | +const iosDevice: DeviceInfo = { platform: 'ios', id: 'test', name: 'iPhone', kind: 'simulator' }; |
| 15 | +const androidDevice: DeviceInfo = { |
| 16 | + platform: 'android', |
| 17 | + id: 'test', |
| 18 | + name: 'Pixel', |
| 19 | + kind: 'emulator', |
| 20 | +}; |
| 21 | + |
| 22 | +// --- requireIntInRange --- |
| 23 | + |
| 24 | +test('requireIntInRange returns value at lower bound', () => { |
| 25 | + assert.equal(requireIntInRange(0, 'x', 0, 10), 0); |
| 26 | +}); |
| 27 | + |
| 28 | +test('requireIntInRange returns value at upper bound', () => { |
| 29 | + assert.equal(requireIntInRange(10, 'x', 0, 10), 10); |
| 30 | +}); |
| 31 | + |
| 32 | +test('requireIntInRange returns value within range', () => { |
| 33 | + assert.equal(requireIntInRange(5, 'x', 0, 10), 5); |
| 34 | +}); |
| 35 | + |
| 36 | +test('requireIntInRange throws for value below minimum', () => { |
| 37 | + assert.throws( |
| 38 | + () => requireIntInRange(-1, 'x', 0, 10), |
| 39 | + (e: unknown) => e instanceof AppError && e.code === 'INVALID_ARGS', |
| 40 | + ); |
| 41 | +}); |
| 42 | + |
| 43 | +test('requireIntInRange throws for value above maximum', () => { |
| 44 | + assert.throws( |
| 45 | + () => requireIntInRange(11, 'x', 0, 10), |
| 46 | + (e: unknown) => e instanceof AppError && e.code === 'INVALID_ARGS', |
| 47 | + ); |
| 48 | +}); |
| 49 | + |
| 50 | +test('requireIntInRange throws for non-integer value', () => { |
| 51 | + assert.throws( |
| 52 | + () => requireIntInRange(5.5, 'x', 0, 10), |
| 53 | + (e: unknown) => e instanceof AppError && e.code === 'INVALID_ARGS', |
| 54 | + ); |
| 55 | +}); |
| 56 | + |
| 57 | +test('requireIntInRange throws for non-finite values', () => { |
| 58 | + for (const value of [NaN, Infinity, -Infinity]) { |
| 59 | + assert.throws( |
| 60 | + () => requireIntInRange(value, 'x', 0, 10), |
| 61 | + (e: unknown) => e instanceof AppError && e.code === 'INVALID_ARGS', |
| 62 | + ); |
| 63 | + } |
| 64 | +}); |
| 65 | + |
| 66 | +// --- clampIosSwipeDuration --- |
| 67 | + |
| 68 | +test('clampIosSwipeDuration returns value within bounds unchanged', () => { |
| 69 | + assert.equal(clampIosSwipeDuration(30), 30); |
| 70 | +}); |
| 71 | + |
| 72 | +test('clampIosSwipeDuration clamps below-minimum to 16', () => { |
| 73 | + assert.equal(clampIosSwipeDuration(5), 16); |
| 74 | +}); |
| 75 | + |
| 76 | +test('clampIosSwipeDuration clamps above-maximum to 60', () => { |
| 77 | + assert.equal(clampIosSwipeDuration(100), 60); |
| 78 | +}); |
| 79 | + |
| 80 | +test('clampIosSwipeDuration returns exact boundary values unchanged', () => { |
| 81 | + assert.equal(clampIosSwipeDuration(16), 16); |
| 82 | + assert.equal(clampIosSwipeDuration(60), 60); |
| 83 | +}); |
| 84 | + |
| 85 | +test('clampIosSwipeDuration rounds fractional input before clamping', () => { |
| 86 | + assert.equal(clampIosSwipeDuration(30.4), 30); |
| 87 | + assert.equal(clampIosSwipeDuration(15.6), 16); |
| 88 | +}); |
| 89 | + |
| 90 | +// --- shouldUseIosTapSeries --- |
| 91 | + |
| 92 | +test('shouldUseIosTapSeries returns true for iOS with count > 1 and no hold or jitter', () => { |
| 93 | + assert.equal(shouldUseIosTapSeries(iosDevice, 2, 0, 0), true); |
| 94 | +}); |
| 95 | + |
| 96 | +test('shouldUseIosTapSeries returns false for Android', () => { |
| 97 | + assert.equal(shouldUseIosTapSeries(androidDevice, 2, 0, 0), false); |
| 98 | +}); |
| 99 | + |
| 100 | +test('shouldUseIosTapSeries returns false when count is 1', () => { |
| 101 | + assert.equal(shouldUseIosTapSeries(iosDevice, 1, 0, 0), false); |
| 102 | +}); |
| 103 | + |
| 104 | +test('shouldUseIosTapSeries returns false when holdMs is non-zero', () => { |
| 105 | + assert.equal(shouldUseIosTapSeries(iosDevice, 2, 100, 0), false); |
| 106 | +}); |
| 107 | + |
| 108 | +test('shouldUseIosTapSeries returns false when jitterPx is non-zero', () => { |
| 109 | + assert.equal(shouldUseIosTapSeries(iosDevice, 2, 0, 5), false); |
| 110 | +}); |
| 111 | + |
| 112 | +// --- shouldUseIosDragSeries --- |
| 113 | + |
| 114 | +test('shouldUseIosDragSeries returns true for iOS with count > 1', () => { |
| 115 | + assert.equal(shouldUseIosDragSeries(iosDevice, 2), true); |
| 116 | +}); |
| 117 | + |
| 118 | +test('shouldUseIosDragSeries returns false for Android', () => { |
| 119 | + assert.equal(shouldUseIosDragSeries(androidDevice, 2), false); |
| 120 | +}); |
| 121 | + |
| 122 | +test('shouldUseIosDragSeries returns false when count is 1', () => { |
| 123 | + assert.equal(shouldUseIosDragSeries(iosDevice, 1), false); |
| 124 | +}); |
| 125 | + |
| 126 | +// --- computeDeterministicJitter --- |
| 127 | + |
| 128 | +test('computeDeterministicJitter scales pattern entry by jitter pixels', () => { |
| 129 | + assert.deepEqual(computeDeterministicJitter(1, 3), [3, 0]); |
| 130 | +}); |
| 131 | + |
| 132 | +test('computeDeterministicJitter returns [0, 0] at index 0', () => { |
| 133 | + assert.deepEqual(computeDeterministicJitter(0, 5), [0, 0]); |
| 134 | +}); |
| 135 | + |
| 136 | +test('computeDeterministicJitter cycles through 9-entry pattern', () => { |
| 137 | + assert.deepEqual(computeDeterministicJitter(9, 2), [0, 0]); |
| 138 | +}); |
| 139 | + |
| 140 | +test('computeDeterministicJitter returns [0, 0] when jitterPx is 0', () => { |
| 141 | + assert.deepEqual(computeDeterministicJitter(1, 0), [0, 0]); |
| 142 | +}); |
| 143 | + |
| 144 | +test('computeDeterministicJitter returns [0, 0] when jitterPx is negative', () => { |
| 145 | + assert.deepEqual(computeDeterministicJitter(1, -3), [0, 0]); |
| 146 | +}); |
| 147 | + |
| 148 | +// --- runRepeatedSeries --- |
| 149 | + |
| 150 | +test('runRepeatedSeries invokes operation for each index in order', async () => { |
| 151 | + const indices: number[] = []; |
| 152 | + await runRepeatedSeries(4, 0, async (i) => { |
| 153 | + indices.push(i); |
| 154 | + }); |
| 155 | + assert.deepEqual(indices, [0, 1, 2, 3]); |
| 156 | +}); |
| 157 | + |
| 158 | +test('runRepeatedSeries does not invoke operation when count is 0', async () => { |
| 159 | + const indices: number[] = []; |
| 160 | + await runRepeatedSeries(0, 0, async (i) => { |
| 161 | + indices.push(i); |
| 162 | + }); |
| 163 | + assert.deepEqual(indices, []); |
| 164 | +}); |
| 165 | + |
| 166 | +test('runRepeatedSeries pauses between operations but not after the last', async (t) => { |
| 167 | + const timeoutDelays: number[] = []; |
| 168 | + t.mock.method(globalThis, 'setTimeout', (cb: () => void, ms: number) => { |
| 169 | + timeoutDelays.push(ms); |
| 170 | + cb(); |
| 171 | + return 0; |
| 172 | + }); |
| 173 | + const pauseMs = 50; |
| 174 | + const calls: number[] = []; |
| 175 | + await runRepeatedSeries(3, pauseMs, async (i) => { |
| 176 | + calls.push(i); |
| 177 | + }); |
| 178 | + assert.deepEqual(calls, [0, 1, 2]); |
| 179 | + // 3 operations with pauses only between them = 2 pauses |
| 180 | + assert.deepEqual(timeoutDelays, [pauseMs, pauseMs]); |
| 181 | +}); |
| 182 | + |
| 183 | +test('runRepeatedSeries propagates operation error and stops iteration', async () => { |
| 184 | + const indices: number[] = []; |
| 185 | + await assert.rejects( |
| 186 | + () => |
| 187 | + runRepeatedSeries(5, 0, async (i) => { |
| 188 | + indices.push(i); |
| 189 | + if (i === 2) throw new Error('boom'); |
| 190 | + }), |
| 191 | + (e: unknown) => e instanceof Error && e.message === 'boom', |
| 192 | + ); |
| 193 | + assert.deepEqual(indices, [0, 1, 2]); |
| 194 | +}); |
0 commit comments