Skip to content

Commit 99096fb

Browse files
test(render): property tests on geometry invariants
5 fast-check properties × 100 runs each = 500 generated cases: - every draw produces >= 1 lineTo per visible channel - lastSlotMicrovolts is finite + positive - lastTotalChannels matches input, lastChannelOffset clamped - partial_fill polyline X stays within (partial/total) * plotW - events outside [t0, t1] never produce a fillText Pairs with the existing example tests in unit-traces-*.test.mjs: examples pin specific edge cases; properties sweep the continuous input space looking for unanticipated failures.
1 parent 38fb3f9 commit 99096fb

1 file changed

Lines changed: 192 additions & 0 deletions

File tree

tests/prop-render.test.mjs

Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
1+
// tests/prop-render.test.mjs
2+
//
3+
// Property-based tests for TraceRenderer.draw() geometry invariants.
4+
// Each property must hold for any valid input regardless of channel
5+
// count, sample count, gain, window size.
6+
//
7+
// Pairs with the example-based tests in unit-traces-*.test.mjs:
8+
// examples pin specific edge cases; properties sweep the continuous
9+
// input space looking for unanticipated failures.
10+
11+
import { test } from 'node:test';
12+
import { strict as assert } from 'node:assert';
13+
import { createRequire } from 'node:module';
14+
import { fc } from './_arbitraries.mjs';
15+
import { makeRecordingCanvas } from './_render-invariants.mjs';
16+
17+
const require = createRequire(import.meta.url);
18+
globalThis.window = globalThis.window || {};
19+
globalThis.ResizeObserver = globalThis.ResizeObserver || class {
20+
observe() {} unobserve() {} disconnect() {}
21+
};
22+
globalThis.window.devicePixelRatio = 1;
23+
const TraceRenderer = require('../traces.js');
24+
25+
const validChannelCount = fc.integer({ min: 1, max: 64 });
26+
const validSampleCount = fc.integer({ min: 2, max: 5000 });
27+
const validGain = fc.float({ min: Math.fround(0.1), max: Math.fround(10), noNaN: true });
28+
const validFs = fc.constantFrom(100, 250, 500, 1000, 2000);
29+
30+
function buildChannels(nCh, nSamp) {
31+
const out = [];
32+
for (let c = 0; c < nCh; c++) {
33+
const d = new Float32Array(nSamp);
34+
for (let i = 0; i < nSamp; i++) d[i] = Math.sin(i * 0.1 + c) * (10 + c);
35+
out.push(d);
36+
}
37+
return out;
38+
}
39+
40+
test('property: every draw produces at least 1 polyline lineTo per visible channel', () => {
41+
fc.assert(
42+
fc.property(validChannelCount, validSampleCount, validGain, validFs,
43+
(nCh, nSamp, gain, fs) => {
44+
const channels = buildChannels(nCh, nSamp);
45+
const { canvas, calls } = makeRecordingCanvas(800, 600);
46+
TraceRenderer.draw(canvas, {
47+
channels,
48+
channel_labels: channels.map((_, i) => `Ch${i+1}`),
49+
channel_types: channels.map(() => 'EEG'),
50+
n_samples_visible: nSamp,
51+
fs,
52+
start_sec: 0,
53+
gain,
54+
transparent: false,
55+
});
56+
const lineToCount = calls.filter(c => c.op === 'lineTo').length;
57+
const maxVisible = TraceRenderer.lastMaxVisibleChannels || nCh;
58+
const visibleCh = Math.min(maxVisible, nCh);
59+
return lineToCount >= visibleCh;
60+
}),
61+
{ numRuns: 100 },
62+
);
63+
});
64+
65+
test('property: lastSlotMicrovolts is finite and positive on any draw', () => {
66+
fc.assert(
67+
fc.property(validChannelCount, validSampleCount, validGain,
68+
(nCh, nSamp, gain) => {
69+
const channels = buildChannels(nCh, nSamp);
70+
const { canvas } = makeRecordingCanvas(800, 600);
71+
TraceRenderer.draw(canvas, {
72+
channels,
73+
channel_labels: channels.map((_, i) => `Ch${i+1}`),
74+
channel_types: channels.map(() => 'EEG'),
75+
n_samples_visible: nSamp,
76+
fs: 250,
77+
start_sec: 0,
78+
gain,
79+
transparent: false,
80+
});
81+
return isFinite(TraceRenderer.lastSlotMicrovolts) &&
82+
TraceRenderer.lastSlotMicrovolts > 0;
83+
}),
84+
{ numRuns: 100 },
85+
);
86+
});
87+
88+
test('property: lastTotalChannels equals input nCh, lastChannelOffset clamped', () => {
89+
fc.assert(
90+
fc.property(validChannelCount, fc.integer({ min: 0, max: 1000 }),
91+
(nCh, offsetRaw) => {
92+
const channels = buildChannels(nCh, 200);
93+
const { canvas } = makeRecordingCanvas(800, 600);
94+
TraceRenderer.draw(canvas, {
95+
channels,
96+
channel_labels: channels.map((_, i) => `Ch${i+1}`),
97+
channel_types: channels.map(() => 'EEG'),
98+
channel_offset: offsetRaw,
99+
n_samples_visible: 200,
100+
fs: 250,
101+
start_sec: 0,
102+
gain: 1,
103+
transparent: false,
104+
});
105+
if (TraceRenderer.lastTotalChannels !== nCh) return false;
106+
if (TraceRenderer.lastChannelOffset < 0) return false;
107+
if (TraceRenderer.lastChannelOffset > Math.max(0, nCh - 1)) return false;
108+
return true;
109+
}),
110+
{ numRuns: 100 },
111+
);
112+
});
113+
114+
test('property: partial_fill polyline X-bounds stay within (samples_visible/total) * plotW', () => {
115+
fc.assert(
116+
fc.property(
117+
fc.integer({ min: 500, max: 5000 }),
118+
fc.float({ min: Math.fround(0.05), max: Math.fround(0.95), noNaN: true }),
119+
(total, ratio) => {
120+
const partial = Math.max(2, Math.floor(total * ratio));
121+
const fullCh = buildChannels(1, total)[0];
122+
const partialCh = fullCh.subarray(0, partial);
123+
const { canvas, calls } = makeRecordingCanvas(800, 600);
124+
TraceRenderer.draw(canvas, {
125+
channels: [partialCh],
126+
channel_labels: ['Ch1'],
127+
channel_types: ['EEG'],
128+
n_samples_visible: partial,
129+
fs: 250,
130+
start_sec: 0,
131+
gain: 1,
132+
transparent: false,
133+
partial_fill: { sample_start: 0, sample_end: partial - 1, total_samples: total },
134+
});
135+
const plotX0 = TraceRenderer.PAD_LEFT;
136+
const plotW = 800 - TraceRenderer.PAD_RIGHT - TraceRenderer.PAD_LEFT;
137+
const PAD_TOP = TraceRenderer.PAD_TOP;
138+
const PAD_BOTTOM = TraceRenderer.PAD_BOTTOM;
139+
const plotX1 = 800 - TraceRenderer.PAD_RIGHT;
140+
const plotY1 = 600 - PAD_BOTTOM;
141+
// Filter to polyline-only lineTos: inside the plot box on both axes.
142+
// Axis baselines/ticks sit at y > plotY1 (below the plot area), and
143+
// the scale-bar sits at x > plotX1 (right of the plot area).
144+
const polylineXs = calls
145+
.filter(c => c.op === 'lineTo'
146+
&& c.args[0] >= plotX0 && c.args[0] <= plotX1
147+
&& c.args[1] >= PAD_TOP && c.args[1] <= plotY1)
148+
.map(c => c.args[0]);
149+
if (polylineXs.length === 0) return true;
150+
const expectedMax = plotX0 + (partial / total) * plotW;
151+
const actualMax = Math.max(...polylineXs);
152+
return actualMax <= expectedMax + 4;
153+
}),
154+
{ numRuns: 100 },
155+
);
156+
});
157+
158+
test('property: events outside [t0, t1] never produce a fillText', () => {
159+
fc.assert(
160+
fc.property(
161+
fc.float({ min: 0, max: 100, noNaN: true }),
162+
fc.float({ min: 0.5, max: 30, noNaN: true }),
163+
(start, duration) => {
164+
const channels = buildChannels(2, 500);
165+
const fs = 250;
166+
const events = [
167+
{ onset: start - 5, label: 'past1' },
168+
{ onset: start - 1, label: 'past2' },
169+
{ onset: start + duration + 0.1, label: 'fut1' },
170+
{ onset: start + duration + 10, label: 'fut2' },
171+
{ onset: start + duration * 100, label: 'fut3' },
172+
];
173+
const { canvas, calls } = makeRecordingCanvas(800, 600);
174+
TraceRenderer.draw(canvas, {
175+
channels,
176+
channel_labels: channels.map((_, i) => `Ch${i+1}`),
177+
channel_types: channels.map(() => 'EEG'),
178+
n_samples_visible: Math.floor(duration * fs),
179+
fs,
180+
start_sec: start,
181+
gain: 1,
182+
transparent: false,
183+
events,
184+
});
185+
for (const ev of events) {
186+
if (calls.some(c => c.op === 'fillText' && c.args[0] === ev.label)) return false;
187+
}
188+
return true;
189+
}),
190+
{ numRuns: 100 },
191+
);
192+
});

0 commit comments

Comments
 (0)