Skip to content

Commit a7fa379

Browse files
committed
test(url): cover round-trip, adversarial input, and properties
Property-based round-trip proves parse(serialize(x))===x for all valid states; adversarial cases assert negative/huge/float/hex/emoji/whitespace rule+seed are dropped and out-of-range tempo is clamped.
1 parent 69e5a95 commit a7fa379

1 file changed

Lines changed: 180 additions & 0 deletions

File tree

test/urlState.test.js

Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
import { describe, expect, it } from 'vitest';
2+
import fc from 'fast-check';
3+
import { parseState, serializeState } from '../src/lib/urlState.js';
4+
import { NOTE_NAMES, SCALE_NAMES } from '../src/lib/scale.js';
5+
import { TEMPO_MAX, TEMPO_MIN } from '../src/lib/tempo.js';
6+
7+
describe('serializeState', () => {
8+
it('encodes all five fields in a fixed order', () => {
9+
const query = serializeState({ rule: 90, seed: 12345, scale: 'pentatonic', root: 'A', tempo: 6 });
10+
expect(query).toBe('rule=90&seed=12345&scale=pentatonic&root=A&tempo=6');
11+
});
12+
13+
it('is stable: equal states produce byte-identical strings', () => {
14+
const a = serializeState({ rule: 30, seed: 7, scale: 'minor', root: 'D', tempo: 3 });
15+
const b = serializeState({ rule: 30, seed: 7, scale: 'minor', root: 'D', tempo: 3 });
16+
expect(a).toBe(b);
17+
});
18+
19+
it('clamps an out-of-range rule and tempo before encoding', () => {
20+
const query = serializeState({ rule: 999, seed: 1, scale: 'major', root: 'C', tempo: 99 });
21+
expect(query).toContain('rule=255');
22+
expect(query).toContain('tempo=12');
23+
});
24+
25+
it('coerces the seed to an unsigned 32-bit integer', () => {
26+
const query = serializeState({ rule: 1, seed: -1, scale: 'major', root: 'C', tempo: 4 });
27+
expect(query).toContain(`seed=${0xffffffff}`);
28+
});
29+
});
30+
31+
describe('parseState', () => {
32+
it('parses a full, valid query into every field', () => {
33+
const state = parseState('?rule=90&seed=12345&scale=pentatonic&root=A&tempo=6');
34+
expect(state).toEqual({ rule: 90, seed: 12345, scale: 'pentatonic', root: 'A', tempo: 6 });
35+
});
36+
37+
it('tolerates a missing leading question mark', () => {
38+
expect(parseState('rule=110')).toEqual({ rule: 110 });
39+
});
40+
41+
it('accepts a URLSearchParams directly', () => {
42+
const params = new URLSearchParams('rule=60&root=E');
43+
expect(parseState(params)).toEqual({ rule: 60, root: 'E' });
44+
});
45+
46+
it('returns an empty object for an empty or junk query', () => {
47+
expect(parseState('')).toEqual({});
48+
expect(parseState('?')).toEqual({});
49+
expect(parseState('foo=bar&baz=qux')).toEqual({});
50+
});
51+
52+
it('ignores unknown parameters but keeps known ones', () => {
53+
expect(parseState('rule=45&nonsense=1&color=red')).toEqual({ rule: 45 });
54+
});
55+
56+
it('includes only the fields that were present', () => {
57+
expect(parseState('scale=minor&tempo=8')).toEqual({ scale: 'minor', tempo: 8 });
58+
});
59+
60+
describe('adversarial rule values', () => {
61+
it.each([
62+
['rule=-5', 'negative'],
63+
['rule=256', 'above max'],
64+
['rule=1000', 'far above max'],
65+
['rule=3.5', 'float'],
66+
['rule=0x1f', 'hex'],
67+
['rule=abc', 'non-numeric'],
68+
['rule=', 'empty'],
69+
['rule=%20%20', 'whitespace only'],
70+
['rule=NaN', 'literal NaN'],
71+
['rule=1e3', 'scientific notation'],
72+
['rule=😀', 'emoji'],
73+
])('drops an invalid rule (%s — %s)', (query) => {
74+
expect(parseState(query).rule).toBeUndefined();
75+
});
76+
77+
it('keeps boundary rule values 0 and 255', () => {
78+
expect(parseState('rule=0').rule).toBe(0);
79+
expect(parseState('rule=255').rule).toBe(255);
80+
});
81+
});
82+
83+
describe('adversarial seed values', () => {
84+
it('keeps a valid unsigned 32-bit seed', () => {
85+
expect(parseState(`seed=${0xffffffff}`).seed).toBe(0xffffffff);
86+
});
87+
88+
it.each([
89+
['seed=-1', 'negative'],
90+
[`seed=${0xffffffff + 1}`, 'above uint32'],
91+
['seed=1.5', 'float'],
92+
['seed=deadbeef', 'hex-ish string'],
93+
['seed=', 'empty'],
94+
])('drops an invalid seed (%s — %s)', (query) => {
95+
expect(parseState(query).seed).toBeUndefined();
96+
});
97+
98+
it('keeps a seed of 0', () => {
99+
expect(parseState('seed=0').seed).toBe(0);
100+
});
101+
});
102+
103+
describe('adversarial scale/root values', () => {
104+
it('drops an unknown scale', () => {
105+
expect(parseState('scale=lydian').scale).toBeUndefined();
106+
expect(parseState('scale=MAJOR').scale).toBeUndefined();
107+
});
108+
109+
it('drops an unknown root note', () => {
110+
expect(parseState('root=H').root).toBeUndefined();
111+
expect(parseState('root=c').root).toBeUndefined();
112+
});
113+
});
114+
115+
describe('adversarial tempo values', () => {
116+
it('clamps a tempo below the minimum', () => {
117+
expect(parseState('tempo=0').tempo).toBe(TEMPO_MIN);
118+
expect(parseState('tempo=-4').tempo).toBe(TEMPO_MIN);
119+
});
120+
121+
it('clamps a tempo above the maximum', () => {
122+
expect(parseState('tempo=99').tempo).toBe(TEMPO_MAX);
123+
});
124+
125+
it('drops a non-integer tempo rather than clamping garbage', () => {
126+
expect(parseState('tempo=fast').tempo).toBeUndefined();
127+
});
128+
});
129+
});
130+
131+
describe('round-trip (property-based)', () => {
132+
it('parse(serialize(state)) recovers the original for any valid state', () => {
133+
fc.assert(
134+
fc.property(
135+
fc.record({
136+
rule: fc.integer({ min: 0, max: 255 }),
137+
seed: fc.integer({ min: 0, max: 0xffffffff }),
138+
scale: fc.constantFrom(...SCALE_NAMES),
139+
root: fc.constantFrom(...NOTE_NAMES),
140+
tempo: fc.integer({ min: TEMPO_MIN, max: TEMPO_MAX }),
141+
}),
142+
(state) => {
143+
const restored = parseState(serializeState(state));
144+
expect(restored).toEqual(state);
145+
},
146+
),
147+
);
148+
});
149+
150+
it('never throws on arbitrary string input', () => {
151+
fc.assert(
152+
fc.property(fc.string(), (raw) => {
153+
expect(() => parseState(raw)).not.toThrow();
154+
}),
155+
);
156+
});
157+
158+
it('serialize output always re-parses to a complete, valid state', () => {
159+
fc.assert(
160+
fc.property(
161+
fc.record({
162+
rule: fc.integer({ min: -1000, max: 1000 }),
163+
seed: fc.integer(),
164+
scale: fc.constantFrom(...SCALE_NAMES),
165+
root: fc.constantFrom(...NOTE_NAMES),
166+
tempo: fc.integer({ min: -100, max: 100 }),
167+
}),
168+
(state) => {
169+
const restored = parseState(serializeState(state));
170+
expect(restored.rule).toBeGreaterThanOrEqual(0);
171+
expect(restored.rule).toBeLessThanOrEqual(255);
172+
expect(restored.tempo).toBeGreaterThanOrEqual(TEMPO_MIN);
173+
expect(restored.tempo).toBeLessThanOrEqual(TEMPO_MAX);
174+
expect(SCALE_NAMES).toContain(restored.scale);
175+
expect(NOTE_NAMES).toContain(restored.root);
176+
},
177+
),
178+
);
179+
});
180+
});

0 commit comments

Comments
 (0)