Skip to content

Commit 88906fb

Browse files
committed
test: Add property-based tests for AudioWorklet engine modules
6 new property test files with 68 tests covering: - RingBuffer: capacity bounds, insertion order, overflow FIFO, clear/push - Percentile/mean/stddev: monotonicity, bounds, translation invariance - AudioMetricsCollector: sampling rates, snapshot percentile ordering, reset, drift lookup - LFO waveforms: range bounds, symmetry, destination scaling, amount=0 identity - Scheduler parity: worklet step duration/swing/tied duration match canonical - Pitch-shift: Hann window symmetry/endpoints/overlap-add, pitch ratio invertibility https://claude.ai/code/session_01SMDAri4k9iSxMrkZP7HXu8
1 parent 6ebb9d8 commit 88906fb

File tree

6 files changed

+1190
-0
lines changed

6 files changed

+1190
-0
lines changed
Lines changed: 276 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,276 @@
1+
/**
2+
* Property-Based Tests for AudioMetricsCollector
3+
*
4+
* Verifies invariants of sampling, snapshot, and reset:
5+
* - Sampling records exactly 1 in every N events
6+
* - Reset clears all accumulated state
7+
* - Snapshot percentiles are monotonically ordered (p50 ≤ p95 ≤ p99 ≤ max)
8+
* - Sample rate floor is 1 (no zero-division)
9+
* - Drift lookup returns closest sample
10+
*/
11+
12+
import fc from 'fast-check';
13+
import { describe, it, expect, beforeEach, vi } from 'vitest';
14+
import { AudioMetricsCollector } from './audio-metrics';
15+
16+
// ─── Setup ──────────────────────────────────────────────────────────────
17+
18+
// Stub PerformanceObserver to avoid browser-only API errors
19+
vi.stubGlobal('PerformanceObserver', undefined);
20+
21+
let collector: AudioMetricsCollector;
22+
23+
beforeEach(() => {
24+
collector = new AudioMetricsCollector();
25+
});
26+
27+
// ─── Arbitraries ────────────────────────────────────────────────────────
28+
29+
const arbSampleRate = fc.integer({ min: 1, max: 50 });
30+
const arbJitterValues = fc.array(
31+
fc.double({ min: 0, max: 100, noNaN: true }),
32+
{ minLength: 1, maxLength: 500 }
33+
);
34+
const arbLatencyValues = fc.array(
35+
fc.double({ min: 0, max: 200, noNaN: true }),
36+
{ minLength: 1, maxLength: 500 }
37+
);
38+
39+
// ─── Sampling Properties ────────────────────────────────────────────────
40+
41+
describe('AudioMetricsCollector sampling properties', () => {
42+
it('records exactly floor(N / sampleRate) jitter samples from N events', () => {
43+
fc.assert(
44+
fc.property(arbSampleRate, arbJitterValues, (rate, values) => {
45+
collector = new AudioMetricsCollector();
46+
collector.setSampleRate(rate);
47+
48+
for (const v of values) {
49+
collector.recordJitter(v);
50+
}
51+
52+
const snapshot = collector.getSnapshot();
53+
const expectedSamples = Math.floor(values.length / rate);
54+
expect(snapshot.scheduler.samples).toBe(expectedSamples);
55+
}),
56+
{ numRuns: 200 }
57+
);
58+
});
59+
60+
it('records exactly floor(N / sampleRate) latency samples from N events', () => {
61+
fc.assert(
62+
fc.property(arbSampleRate, arbLatencyValues, (rate, values) => {
63+
collector = new AudioMetricsCollector();
64+
collector.setSampleRate(rate);
65+
66+
for (const v of values) {
67+
collector.recordInputLatency(v);
68+
}
69+
70+
const snapshot = collector.getSnapshot();
71+
const expectedSamples = Math.floor(values.length / rate);
72+
expect(snapshot.inputLatency.samples).toBe(expectedSamples);
73+
}),
74+
{ numRuns: 200 }
75+
);
76+
});
77+
78+
it('sampleRate=1 records every event', () => {
79+
fc.assert(
80+
fc.property(arbJitterValues, (values) => {
81+
collector = new AudioMetricsCollector();
82+
collector.setSampleRate(1);
83+
84+
for (const v of values) {
85+
collector.recordJitter(v);
86+
}
87+
88+
const snapshot = collector.getSnapshot();
89+
expect(snapshot.scheduler.samples).toBe(values.length);
90+
}),
91+
{ numRuns: 100 }
92+
);
93+
});
94+
95+
it('setSampleRate floors to 1 (no zero/negative rates)', () => {
96+
fc.assert(
97+
fc.property(
98+
fc.integer({ min: -100, max: 0 }),
99+
arbJitterValues,
100+
(rate, values) => {
101+
collector = new AudioMetricsCollector();
102+
collector.setSampleRate(rate);
103+
104+
// Should behave as sampleRate=1 (record every event)
105+
for (const v of values) {
106+
collector.recordJitter(v);
107+
}
108+
109+
const snapshot = collector.getSnapshot();
110+
expect(snapshot.scheduler.samples).toBe(values.length);
111+
}
112+
),
113+
{ numRuns: 50 }
114+
);
115+
});
116+
});
117+
118+
// ─── Snapshot Percentile Ordering ───────────────────────────────────────
119+
120+
describe('AudioMetricsCollector snapshot properties', () => {
121+
it('jitter percentiles are ordered: p50 ≤ p95 ≤ p99 ≤ max', () => {
122+
fc.assert(
123+
fc.property(arbJitterValues, (values) => {
124+
collector = new AudioMetricsCollector();
125+
collector.setSampleRate(1);
126+
127+
for (const v of values) {
128+
collector.recordJitter(v);
129+
}
130+
131+
const snapshot = collector.getSnapshot();
132+
expect(snapshot.scheduler.p50).toBeLessThanOrEqual(snapshot.scheduler.p95 + 1e-10);
133+
expect(snapshot.scheduler.p95).toBeLessThanOrEqual(snapshot.scheduler.p99 + 1e-10);
134+
expect(snapshot.scheduler.p99).toBeLessThanOrEqual(snapshot.scheduler.max + 1e-10);
135+
}),
136+
{ numRuns: 200 }
137+
);
138+
});
139+
140+
it('latency percentiles are ordered: p50 ≤ p95 ≤ p99', () => {
141+
fc.assert(
142+
fc.property(arbLatencyValues, (values) => {
143+
collector = new AudioMetricsCollector();
144+
collector.setSampleRate(1);
145+
146+
for (const v of values) {
147+
collector.recordInputLatency(v);
148+
}
149+
150+
const snapshot = collector.getSnapshot();
151+
expect(snapshot.inputLatency.p50).toBeLessThanOrEqual(snapshot.inputLatency.p95 + 1e-10);
152+
expect(snapshot.inputLatency.p95).toBeLessThanOrEqual(snapshot.inputLatency.p99 + 1e-10);
153+
}),
154+
{ numRuns: 200 }
155+
);
156+
});
157+
158+
it('snapshot with no data returns zeros', () => {
159+
collector = new AudioMetricsCollector();
160+
const snapshot = collector.getSnapshot();
161+
expect(snapshot.scheduler.p50).toBe(0);
162+
expect(snapshot.scheduler.p95).toBe(0);
163+
expect(snapshot.scheduler.p99).toBe(0);
164+
expect(snapshot.scheduler.max).toBe(0);
165+
expect(snapshot.scheduler.samples).toBe(0);
166+
expect(snapshot.inputLatency.samples).toBe(0);
167+
});
168+
169+
it('max equals the maximum of all recorded jitter values', () => {
170+
fc.assert(
171+
fc.property(arbJitterValues, (values) => {
172+
collector = new AudioMetricsCollector();
173+
collector.setSampleRate(1);
174+
175+
for (const v of values) {
176+
collector.recordJitter(v);
177+
}
178+
179+
const snapshot = collector.getSnapshot();
180+
const expectedMax = Math.max(...values);
181+
expect(snapshot.scheduler.max).toBeCloseTo(expectedMax, 10);
182+
}),
183+
{ numRuns: 200 }
184+
);
185+
});
186+
});
187+
188+
// ─── Reset Properties ──────────────────────────────────────────────────
189+
190+
describe('AudioMetricsCollector reset properties', () => {
191+
it('reset clears all jitter and latency samples', () => {
192+
fc.assert(
193+
fc.property(arbJitterValues, arbLatencyValues, (jitters, latencies) => {
194+
collector = new AudioMetricsCollector();
195+
collector.setSampleRate(1);
196+
197+
for (const v of jitters) collector.recordJitter(v);
198+
for (const v of latencies) collector.recordInputLatency(v);
199+
200+
collector.reset();
201+
202+
const snapshot = collector.getSnapshot();
203+
expect(snapshot.scheduler.samples).toBe(0);
204+
expect(snapshot.inputLatency.samples).toBe(0);
205+
expect(snapshot.scheduler.max).toBe(0);
206+
}),
207+
{ numRuns: 100 }
208+
);
209+
});
210+
211+
it('recording after reset starts fresh', () => {
212+
fc.assert(
213+
fc.property(
214+
arbJitterValues,
215+
fc.array(fc.double({ min: 0, max: 50, noNaN: true }), { minLength: 1, maxLength: 100 }),
216+
(before, after) => {
217+
collector = new AudioMetricsCollector();
218+
collector.setSampleRate(1);
219+
220+
for (const v of before) collector.recordJitter(v);
221+
collector.reset();
222+
for (const v of after) collector.recordJitter(v);
223+
224+
const snapshot = collector.getSnapshot();
225+
expect(snapshot.scheduler.samples).toBe(after.length);
226+
expect(snapshot.scheduler.max).toBeCloseTo(Math.max(...after), 10);
227+
}
228+
),
229+
{ numRuns: 100 }
230+
);
231+
});
232+
});
233+
234+
// ─── Drift Lookup Properties ────────────────────────────────────────────
235+
236+
describe('AudioMetricsCollector drift properties', () => {
237+
it('getDriftAtStep returns 0 when no drift recorded', () => {
238+
collector = new AudioMetricsCollector();
239+
expect(collector.getDriftAtStep(100)).toBe(0);
240+
});
241+
242+
it('getDriftAtStep returns closest sample by step count', () => {
243+
fc.assert(
244+
fc.property(
245+
fc.array(
246+
fc.record({
247+
stepCount: fc.integer({ min: 0, max: 10000 }),
248+
driftMs: fc.double({ min: -10, max: 10, noNaN: true }),
249+
}),
250+
{ minLength: 1, maxLength: 50 }
251+
),
252+
fc.integer({ min: 0, max: 10000 }),
253+
(samples, queryStep) => {
254+
collector = new AudioMetricsCollector();
255+
256+
for (const s of samples) {
257+
collector.recordDrift(s.stepCount, s.driftMs);
258+
}
259+
260+
const result = collector.getDriftAtStep(queryStep);
261+
262+
// Find the expected closest sample
263+
let closest = samples[0];
264+
for (const s of samples) {
265+
if (Math.abs(s.stepCount - queryStep) < Math.abs(closest.stepCount - queryStep)) {
266+
closest = s;
267+
}
268+
}
269+
270+
expect(result).toBeCloseTo(closest.driftMs, 10);
271+
}
272+
),
273+
{ numRuns: 100 }
274+
);
275+
});
276+
});

0 commit comments

Comments
 (0)