@wenathlan/debonair 2.0.94 → 2.0.118

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.
Files changed (40) hide show
  1. package/README.md +13 -1
  2. package/docs/.gitkeep +0 -0
  3. package/docs/competitor-dissection.md +572 -0
  4. package/docs/enginemap.md +94 -0
  5. package/docs/planning.ladder.md +1107 -0
  6. package/docs/planning.v2.28-completo.md +69 -0
  7. package/docs/planning.versions.2.0.97-3.0.md +48 -0
  8. package/package.json +25 -1
  9. package/tests/.gitkeep +0 -0
  10. package/tests/analyze.test.ts +54 -0
  11. package/tests/arrangement.test.ts +109 -0
  12. package/tests/arranger.test.ts +116 -0
  13. package/tests/beatgrid.test.ts +0 -0
  14. package/tests/dawview.test.ts +104 -0
  15. package/tests/ducking.test.ts +0 -0
  16. package/tests/family.test.ts +80 -0
  17. package/tests/familyurl.test.ts +32 -0
  18. package/tests/fingerprint.test.ts +61 -0
  19. package/tests/genrematrix.test.ts +116 -0
  20. package/tests/harmonyengine.test.ts +204 -0
  21. package/tests/katexis.test.ts +97 -0
  22. package/tests/loudness.test.ts +0 -0
  23. package/tests/midiwriter.test.ts +183 -0
  24. package/tests/mixergraph.test.ts +0 -0
  25. package/tests/motionpref.test.ts +82 -0
  26. package/tests/musictheory.test.ts +231 -0
  27. package/tests/onboardingstate.test.ts +68 -0
  28. package/tests/pianoroll.test.ts +336 -0
  29. package/tests/remixplan.test.ts +0 -0
  30. package/tests/rhythmgrammar.test.ts +205 -0
  31. package/tests/solcss.test.ts +148 -0
  32. package/tests/spectrum.test.ts +59 -0
  33. package/tests/speechalign.test.ts +0 -0
  34. package/tests/stems.test.ts +127 -0
  35. package/tests/takeevents.test.ts +129 -0
  36. package/tests/takestore.test.ts +93 -0
  37. package/tests/timelinemodel.test.ts +0 -0
  38. package/tests/tuning.test.ts +27 -0
  39. package/tests/vocalsynth.test.ts +91 -0
  40. package/tests/wavwriter.test.ts +137 -0
@@ -0,0 +1,183 @@
1
+ // # midiwriter.test — the SMF writer contract: the MThd/MTrk magic and the
2
+ // header fields, the VLQ round trip on the boundary values, the tempo meta
3
+ // as microseconds per quarter, the balanced on/off pairs validateMidi
4
+ // proves, the drums on the GM percussion channel 9, the schedule-to-tracks
5
+ // conservation and the determinism of the bytes.
6
+ import assert from "node:assert/strict";
7
+ import { describe, it } from "vitest";
8
+ import { planTake } from "../arranger.ts";
9
+ import {
10
+ buildTracksFromSchedule,
11
+ decodeVlq,
12
+ encodeVlq,
13
+ MIDI_CONSTANTS,
14
+ MidiError,
15
+ secondsToTicks,
16
+ ticksToSeconds,
17
+ VLQ_MAX,
18
+ validateMidi,
19
+ writeMidi,
20
+ } from "../midiwriter.ts";
21
+ import { scheduleTake } from "../takeevents.ts";
22
+
23
+ /** Reads a big-endian u16 out of the bytes (SMF is big-endian, the opposite of wav). */
24
+ const u16be = (bytes: Uint8Array, offset: number): number => (bytes[offset] << 8) | bytes[offset + 1];
25
+
26
+ /** Reads a big-endian u32 out of the bytes. */
27
+ const u32be = (bytes: Uint8Array, offset: number): number =>
28
+ ((bytes[offset] << 24) | (bytes[offset + 1] << 16) | (bytes[offset + 2] << 8) | bytes[offset + 3]) >>> 0;
29
+
30
+ describe("midiwriter — vlq", () => {
31
+ it("round trips the boundary values", () => {
32
+ for (const value of [0, 1, 0x7f, 0x80, 0xff, 0x3fff, 0x4000, 0x1fffff, 0x200000, VLQ_MAX]) {
33
+ const encoded = encodeVlq(value);
34
+ const decoded = decodeVlq(encoded);
35
+ assert.equal(decoded.value, value);
36
+ assert.equal(decoded.next, encoded.length);
37
+ }
38
+ });
39
+
40
+ it("encodes the documented byte shapes", () => {
41
+ assert.deepEqual([...encodeVlq(0)], [0x00]);
42
+ assert.deepEqual([...encodeVlq(127)], [0x7f]);
43
+ assert.deepEqual([...encodeVlq(128)], [0x81, 0x00]);
44
+ assert.deepEqual([...encodeVlq(0x200000)], [0x81, 0x80, 0x80, 0x00]);
45
+ });
46
+
47
+ it("refuses overflow and truncated streams", () => {
48
+ assert.throws(
49
+ () => encodeVlq(VLQ_MAX + 1),
50
+ (error: unknown) => error instanceof MidiError && error.code === "vlq-overflow",
51
+ );
52
+ assert.throws(
53
+ () => encodeVlq(-1),
54
+ (error: unknown) => error instanceof MidiError && error.code === "vlq-overflow",
55
+ );
56
+ assert.throws(
57
+ () => decodeVlq(new Uint8Array([0x80])),
58
+ (error: unknown) => error instanceof MidiError && error.code === "vlq-truncated",
59
+ );
60
+ });
61
+ });
62
+
63
+ describe("midiwriter — time math", () => {
64
+ it("maps seconds to ticks and back exactly", () => {
65
+ assert.equal(secondsToTicks(1, 120, 480), 960);
66
+ assert.equal(ticksToSeconds(960, 120, 480), 1);
67
+ for (const bpm of [90, 124, 174]) {
68
+ for (const seconds of [0, 0.5, 1.75, 30]) {
69
+ const ticks = secondsToTicks(seconds, bpm);
70
+ assert.equal(secondsToTicks(ticksToSeconds(ticks, bpm), bpm), ticks);
71
+ }
72
+ }
73
+ });
74
+
75
+ it("refuses impossible tempo inputs", () => {
76
+ assert.throws(
77
+ () => secondsToTicks(1, 0),
78
+ (error: unknown) => error instanceof MidiError && error.code === "bad-bpm",
79
+ );
80
+ assert.throws(
81
+ () => ticksToSeconds(1, 120, 0),
82
+ (error: unknown) => error instanceof MidiError && error.code === "bad-ppq",
83
+ );
84
+ });
85
+ });
86
+
87
+ describe("midiwriter — writeMidi", () => {
88
+ const plan = planTake({ prompt: "midnight techno, dark sub bass", seed: "k4-midi" });
89
+ const schedule = scheduleTake(plan);
90
+ const tracks = buildTracksFromSchedule(schedule, plan, plan.bpm);
91
+ const bytes = writeMidi(tracks, MIDI_CONSTANTS.ppqDefault, plan.bpm);
92
+
93
+ it("opens with MThd and declares format 1 with one track per role plus the tempo map", () => {
94
+ assert.equal(String.fromCharCode(...bytes.subarray(0, 4)), "MThd");
95
+ assert.equal(u32be(bytes, 4), 6);
96
+ assert.equal(u16be(bytes, 8), 1);
97
+ assert.equal(u16be(bytes, 10), 5);
98
+ assert.equal(u16be(bytes, 12), MIDI_CONSTANTS.ppqDefault);
99
+ });
100
+
101
+ it("opens every chunk with MTrk and walks them cleanly", () => {
102
+ let cursor = 14;
103
+ let count = 0;
104
+ while (cursor < bytes.length) {
105
+ assert.equal(String.fromCharCode(...bytes.subarray(cursor, cursor + 4)), "MTrk");
106
+ count += 1;
107
+ cursor += 8 + u32be(bytes, cursor + 4);
108
+ }
109
+ assert.equal(count, 5);
110
+ });
111
+
112
+ it("writes the tempo meta on track 0 as microseconds per quarter", () => {
113
+ const tempoAt = 14 + 8; // the first MTrk body
114
+ assert.equal(bytes[tempoAt], 0x00); // delta
115
+ assert.equal(bytes[tempoAt + 1], 0xff);
116
+ assert.equal(bytes[tempoAt + 2], 0x51);
117
+ assert.equal(bytes[tempoAt + 3], 0x03);
118
+ const us = (bytes[tempoAt + 4] << 16) | (bytes[tempoAt + 5] << 8) | bytes[tempoAt + 6];
119
+ assert.equal(us, Math.round(60e6 / plan.bpm)); // microseconds per quarter: 60e6 / bpm
120
+ const reference = writeMidi([{ name: "", channel: 0, program: -1, notes: [] }], 480, 120);
121
+ assert.equal((reference[26] << 16) | (reference[27] << 8) | reference[28], 500000); // 60e6 / 120 = 0x07a120
122
+ });
123
+
124
+ it("keeps the drums on the GM percussion channel 9", () => {
125
+ const drums = tracks.find((track) => track.name.endsWith("-drums"));
126
+ assert.ok(drums);
127
+ assert.equal(drums.channel, 9);
128
+ const drumsAt = 14 + 19; // the drums chunk rides right after the tempo map chunk (8 + 11 bytes)
129
+ const body = bytes.subarray(drumsAt + 8, drumsAt + 8 + u32be(bytes, drumsAt + 4));
130
+ assert.ok(body.includes(0x99)); // note on, channel 9
131
+ assert.ok(body.includes(0xc9)); // program change, channel 9
132
+ });
133
+
134
+ it("balances every note on with exactly one note off", () => {
135
+ assert.deepEqual(validateMidi(tracks), []);
136
+ });
137
+
138
+ it("is deterministic — the same schedule renders the same bytes", () => {
139
+ const again = writeMidi(
140
+ buildTracksFromSchedule(scheduleTake(plan), plan, plan.bpm),
141
+ MIDI_CONSTANTS.ppqDefault,
142
+ plan.bpm,
143
+ );
144
+ assert.deepEqual(again, bytes);
145
+ });
146
+
147
+ it("conserves the schedule into the tracks", () => {
148
+ const total = tracks.reduce((sum, track) => sum + track.notes.length, 0);
149
+ assert.equal(total, schedule.events.length);
150
+ for (const track of tracks) {
151
+ const role = track.name.slice(plan.id.length + 1);
152
+ assert.equal(track.notes.length, schedule.perRole[role]);
153
+ }
154
+ });
155
+
156
+ it("answers machine codes for broken tracks and options", () => {
157
+ assert.throws(
158
+ () => writeMidi(tracks, 0, plan.bpm),
159
+ (error: unknown) => error instanceof MidiError && error.code === "bad-ppq",
160
+ );
161
+ assert.throws(
162
+ () => writeMidi(tracks, 480, 0),
163
+ (error: unknown) => error instanceof MidiError && error.code === "bad-bpm",
164
+ );
165
+ const louder = tracks.map((track, index) =>
166
+ index === 0
167
+ ? { ...track, notes: track.notes.map((note, order) => (order === 0 ? { ...note, velocity: 300 } : note)) }
168
+ : track,
169
+ );
170
+ assert.ok(validateMidi(louder).some((problem) => problem.code === "bad-velocity"));
171
+ const gated = tracks.map((track, index) =>
172
+ index === 0
173
+ ? { ...track, notes: track.notes.map((note, order) => (order === 0 ? { ...note, duration: 0 } : note)) }
174
+ : track,
175
+ );
176
+ assert.ok(validateMidi(gated).some((problem) => problem.code === "unbalanced-pairs"));
177
+ assert.ok(
178
+ validateMidi([{ name: "x", channel: 16, program: 0, notes: [] }]).some(
179
+ (problem) => problem.code === "bad-channel",
180
+ ),
181
+ );
182
+ });
183
+ });
File without changes
@@ -0,0 +1,82 @@
1
+ // # motionpref.test — the in-session reduce-motion switch the settings page
2
+ // drives: the document flag (data-motion="reduced") and the session note ride
3
+ // injectable stubs, so the honest toggle is pinned without a browser. The
4
+ // stylesheet guards answer to the attribute; this test pins the attribute.
5
+ import assert from "node:assert/strict";
6
+ import { describe, it } from "vitest";
7
+ import {
8
+ MOTION_PREF_KEY,
9
+ type MotionRoot,
10
+ memoryMotionStorage,
11
+ reduceMotionApplied,
12
+ reduceMotionStored,
13
+ setReduceMotion,
14
+ toggleReduceMotion,
15
+ } from "../motionpref.ts";
16
+
17
+ /** A root stub that answers like the <html> element. */
18
+ function stubRoot(): MotionRoot & { attrs: Map<string, string> } {
19
+ const attrs = new Map<string, string>();
20
+ return {
21
+ attrs,
22
+ getAttribute: (name) => attrs.get(name) ?? null,
23
+ setAttribute: (name, value) => {
24
+ attrs.set(name, value);
25
+ },
26
+ removeAttribute: (name) => {
27
+ attrs.delete(name);
28
+ },
29
+ };
30
+ }
31
+
32
+ describe("motionpref", () => {
33
+ it("applies and clears the document flag with the session note", () => {
34
+ const root = stubRoot();
35
+ const storage = memoryMotionStorage();
36
+ setReduceMotion(true, storage, root);
37
+ assert.equal(reduceMotionApplied(root), true);
38
+ assert.equal(reduceMotionStored(storage), true);
39
+ assert.equal(root.attrs.get("data-motion"), "reduced");
40
+ assert.equal(storage.getItem(MOTION_PREF_KEY), "1");
41
+
42
+ setReduceMotion(false, storage, root);
43
+ assert.equal(reduceMotionApplied(root), false);
44
+ assert.equal(reduceMotionStored(storage), false);
45
+ assert.equal(root.attrs.has("data-motion"), false);
46
+ assert.equal(storage.getItem(MOTION_PREF_KEY), null);
47
+ });
48
+
49
+ it("toggles from the current document state", () => {
50
+ const root = stubRoot();
51
+ const storage = memoryMotionStorage();
52
+ assert.equal(toggleReduceMotion(storage, root), true);
53
+ assert.equal(reduceMotionApplied(root), true);
54
+ assert.equal(toggleReduceMotion(storage, root), false);
55
+ assert.equal(reduceMotionApplied(root), false);
56
+ });
57
+
58
+ it("degrades when the session storage refuses the write", () => {
59
+ const root = stubRoot();
60
+ const frozen = memoryMotionStorage();
61
+ const broken = {
62
+ getItem: () => null,
63
+ setItem: () => {
64
+ throw new Error("quota");
65
+ },
66
+ removeItem: () => {
67
+ throw new Error("quota");
68
+ },
69
+ };
70
+ setReduceMotion(true, broken as typeof frozen, root);
71
+ // the document flag still holds — the interface stays honest for this tab
72
+ assert.equal(reduceMotionApplied(root), true);
73
+ assert.equal(reduceMotionStored(frozen), false);
74
+ });
75
+
76
+ it("reads an untouched document and storage as motion allowed", () => {
77
+ const root = stubRoot();
78
+ const storage = memoryMotionStorage();
79
+ assert.equal(reduceMotionApplied(root), false);
80
+ assert.equal(reduceMotionStored(storage), false);
81
+ });
82
+ });
@@ -0,0 +1,231 @@
1
+ // # musictheory.test — honest unit tests for the theory kernel (scale table,
2
+ // intervals, the chord vocabulary over all twelve roots, roman-numeral
3
+ // parsing, voice-leading helpers), runnable with the vitest runner:
4
+ // pnpm test
5
+ // Every fixture is a constant table — no randomness anywhere.
6
+ import { describe, expect, it } from "vitest";
7
+ import {
8
+ CHORD_SHAPES,
9
+ CHORD_SUFFIX,
10
+ chordNotes,
11
+ chordPcs,
12
+ chordSymbol,
13
+ commonTones,
14
+ degreeToPc,
15
+ diatonicTriad,
16
+ INTERVAL_NAMES,
17
+ intervalBetween,
18
+ intervalName,
19
+ makeChord,
20
+ nearestInversion,
21
+ noteName,
22
+ noteToPc,
23
+ parseChord,
24
+ parseRoman,
25
+ romanOf,
26
+ SCALE_NAMES,
27
+ SCALE_STEPS,
28
+ scalePcs,
29
+ transposePc,
30
+ voiceLeadDistance,
31
+ } from "../musictheory.ts";
32
+
33
+ const C = { tonic: 0, mode: "major" } as const;
34
+ const Am = { tonic: 9, mode: "aeolian" } as const;
35
+
36
+ /** Asserts an optional engine value landed and returns it (the non-null guard the suite pins). */
37
+ function landed<T>(value: T | null | undefined, label: string): T {
38
+ expect(value, label).toBeTruthy();
39
+ return value as T;
40
+ }
41
+
42
+ describe("scale table", () => {
43
+ it("carries the thirteen documented scales", () => {
44
+ expect(SCALE_NAMES.length).toBe(13);
45
+ for (const name of SCALE_NAMES) {
46
+ const steps = SCALE_STEPS[name];
47
+ expect(steps[0], `${name} starts on the tonic`).toBe(0);
48
+ steps.forEach((step, i) => {
49
+ expect(i === 0 || step > steps[i - 1], `${name} ascends strictly`).toBe(true);
50
+ });
51
+ expect(steps[steps.length - 1] <= 11, `${name} stays inside the octave`).toBe(true);
52
+ }
53
+ expect(SCALE_STEPS.major).toEqual([0, 2, 4, 5, 7, 9, 11]);
54
+ expect(SCALE_STEPS["pentatonic-major"].length).toBe(5);
55
+ expect(SCALE_STEPS.blues.length).toBe(6);
56
+ });
57
+
58
+ it("answers relative keys with the same pitch classes", () => {
59
+ expect(scalePcs(C)).toEqual([0, 2, 4, 5, 7, 9, 11]);
60
+ expect([...scalePcs(Am)].sort((a, b) => a - b)).toEqual(scalePcs(C));
61
+ expect(scalePcs({ tonic: 0, mode: "phrygian" }).includes(1), "phrygian carries the flat second").toBe(true);
62
+ });
63
+
64
+ it("resolves degrees and accidentals", () => {
65
+ expect(degreeToPc(C, 3)).toBe(4);
66
+ expect(degreeToPc(C, 3, -1), "bIII of C major").toBe(3);
67
+ expect(degreeToPc(C, 4, 1), "#IV of C major").toBe(6);
68
+ expect(transposePc(11, 2)).toBe(1);
69
+ expect(transposePc(0, -1)).toBe(11);
70
+ });
71
+ });
72
+
73
+ describe("note names and intervals", () => {
74
+ it("spells all twelve classes both ways", () => {
75
+ for (let pc = 0; pc < 12; pc++) {
76
+ expect(noteToPc(noteName(pc))).toBe(pc);
77
+ expect(noteToPc(noteName(pc, true))).toBe(pc);
78
+ }
79
+ expect(noteToPc("Eb")).toBe(3);
80
+ expect(noteToPc("H")).toBeNull();
81
+ expect(noteToPc("")).toBeNull();
82
+ });
83
+
84
+ it("names the twelve intervals and reads them between classes", () => {
85
+ expect(INTERVAL_NAMES.length).toBe(12);
86
+ expect(intervalName(0)).toBe("unison");
87
+ expect(intervalName(6)).toBe("tritone");
88
+ expect(intervalName(7)).toBe("perfect fifth");
89
+ expect(intervalName(11)).toBe("major seventh");
90
+ expect(intervalBetween(0, 4)).toBe("major third");
91
+ expect(intervalBetween(4, 0)).toBe("minor sixth");
92
+ });
93
+ });
94
+
95
+ describe("chord vocabulary", () => {
96
+ it("keeps every shape ascending from the root", () => {
97
+ for (const [quality, shape] of Object.entries(CHORD_SHAPES)) {
98
+ expect(shape[0], `${quality} roots on 0`).toBe(0);
99
+ shape.forEach((step, i) => {
100
+ expect(i === 0 || step > shape[i - 1], `${quality} ascends`).toBe(true);
101
+ });
102
+ expect(shape[shape.length - 1] <= 14, `${quality} stays inside two octaves`).toBe(true);
103
+ }
104
+ for (const quality of Object.keys(CHORD_SUFFIX) as (keyof typeof CHORD_SUFFIX)[])
105
+ expect(CHORD_SHAPES[quality], `${quality} has a shape`).toBeTruthy();
106
+ for (const quality of Object.keys(CHORD_SHAPES))
107
+ expect(typeof CHORD_SUFFIX[quality as keyof typeof CHORD_SUFFIX]).toBe("string");
108
+ });
109
+
110
+ it("spells, stacks and voices chords", () => {
111
+ const cmaj7 = landed(parseChord("Cmaj7"), "Cmaj7 parses");
112
+ expect(chordPcs(cmaj7)).toEqual([0, 4, 7, 11]);
113
+ expect(chordNotes(cmaj7)).toEqual([60, 64, 67, 71]);
114
+ const slash = landed(parseChord("Am7/G"), "Am7/G parses");
115
+ expect(slash).toBeTruthy();
116
+ expect(chordPcs(slash), "the slash bass comes first").toEqual([7, 9, 0, 4, 7]);
117
+ expect(chordSymbol(slash)).toBe("Am7/G");
118
+ });
119
+
120
+ it("builds every quality over all twelve roots and round-trips the symbol", () => {
121
+ const qualities = ["maj", "min7", "dim", "sus4", "maj9"] as const;
122
+ for (let root = 0; root < 12; root++) {
123
+ for (const quality of qualities) {
124
+ const chord = makeChord(root, quality);
125
+ expect(chord.root).toBe(root);
126
+ const back = landed(parseChord(chordSymbol(chord)), `symbol ${chordSymbol(chord)} parses`);
127
+ expect(back.root).toBe(root);
128
+ expect(back.quality).toBe(quality);
129
+ }
130
+ }
131
+ });
132
+
133
+ it("parses the lead-sheet tokens and refuses the malformed ones", () => {
134
+ expect(parseChord("F#m7b5")?.quality).toBe("halfdim");
135
+ expect(parseChord("Db9")?.root).toBe(1);
136
+ expect(parseChord("C/E")?.bass).toBe(4);
137
+ expect(parseChord("Csus4")?.quality).toBe("sus4");
138
+ expect(parseChord("H")).toBeNull();
139
+ expect(parseChord("Cq")).toBeNull();
140
+ expect(parseChord("C/")).toBeNull();
141
+ });
142
+ });
143
+
144
+ describe("roman numerals", () => {
145
+ it("parses the documented table in C major", () => {
146
+ const symbolOf = (token: string) => chordSymbol(landed(parseRoman(token, C), `${token} parses in C`));
147
+ expect(symbolOf("I")).toBe("C");
148
+ expect(symbolOf("IV")).toBe("F");
149
+ expect(symbolOf("V7")).toBe("G7");
150
+ expect(symbolOf("vi")).toBe("Am");
151
+ expect(symbolOf("viio")).toBe("Bdim");
152
+ expect(symbolOf("bIII")).toBe("D#");
153
+ expect(symbolOf("#iv")).toBe("F#m");
154
+ expect(symbolOf("v7")).toBe("Gm7");
155
+ expect(symbolOf("VImaj7")).toBe("Amaj7");
156
+ expect(symbolOf("ii")).toBe("Dm");
157
+ });
158
+
159
+ it("parses against the key's own scale (A aeolian)", () => {
160
+ const symbolOf = (token: string) => chordSymbol(landed(parseRoman(token, Am), `${token} parses in Am`));
161
+ expect(symbolOf("i")).toBe("Am");
162
+ expect(symbolOf("VII")).toBe("G");
163
+ expect(symbolOf("III")).toBe("C");
164
+ expect(symbolOf("iv7")).toBe("Dm7");
165
+ expect(symbolOf("V"), "the upper case overrides the mode's minor fifth").toBe("E");
166
+ expect(parseRoman("IX", C)).toBeNull();
167
+ expect(parseRoman("x", C)).toBeNull();
168
+ expect(parseRoman("IVmaj", C)).toBeNull();
169
+ });
170
+
171
+ it("reads the diatonic triads of the seven-note scales", () => {
172
+ expect([1, 2, 3, 4, 5, 6, 7].map((d) => diatonicTriad(C, d)?.quality)).toEqual([
173
+ "maj",
174
+ "min",
175
+ "min",
176
+ "maj",
177
+ "maj",
178
+ "min",
179
+ "dim",
180
+ ]);
181
+ expect([1, 2, 3, 4, 5, 6, 7].map((d) => diatonicTriad(Am, d)?.quality)).toEqual([
182
+ "min",
183
+ "dim",
184
+ "maj",
185
+ "min",
186
+ "min",
187
+ "maj",
188
+ "maj",
189
+ ]);
190
+ expect(diatonicTriad({ tonic: 0, mode: "blues" }, 1), "short scales carry no triads").toBeNull();
191
+ });
192
+
193
+ it("serialises numerals back from chords (romanOf round-trips)", () => {
194
+ for (const [key, tokens] of [
195
+ [C, ["I", "ii", "iii", "IV", "V", "vi", "viio", "V7"]],
196
+ [Am, ["i", "iio", "III", "iv", "v", "VI", "VII", "V7"]],
197
+ ] as const) {
198
+ for (const token of tokens) {
199
+ const chord = landed(parseRoman(token, key), `${token} parses`);
200
+ const numeral = landed(romanOf(chord, key), `${token} re-annotates`);
201
+ const back = landed(parseRoman(numeral, key), `${numeral} parses back`);
202
+ expect(back.root, `${token} -> ${numeral} keeps the root`).toBe(chord.root);
203
+ expect(back.quality, `${token} -> ${numeral} keeps the quality`).toBe(chord.quality);
204
+ }
205
+ }
206
+ });
207
+ });
208
+
209
+ describe("voice-leading helpers", () => {
210
+ it("counts common tones and nearest movement", () => {
211
+ const c = chordPcs(landed(parseChord("C"), "C parses"));
212
+ const am = chordPcs(landed(parseChord("Am"), "Am parses"));
213
+ expect(commonTones(c, am)).toBe(2);
214
+ expect(voiceLeadDistance(c, am), "G->A and B->A cost two semitones").toBe(2);
215
+ expect(voiceLeadDistance(c, c)).toBe(0);
216
+ expect(voiceLeadDistance(c, chordPcs(landed(parseChord("F#dim7"), "F#dim7 parses")))).toBeGreaterThan(0);
217
+ });
218
+
219
+ it("picks the inversion whose bass sits nearest the reference", () => {
220
+ const c = landed(parseChord("C"), "C parses");
221
+ const fromG = nearestInversion(c, 55);
222
+ expect(fromG.inversion, "G sits a fifth below middle C").toBe(2);
223
+ expect(fromG.notes).toEqual([55, 60, 64]);
224
+ const fromC = nearestInversion(c, 60);
225
+ expect(fromC.inversion).toBe(0);
226
+ expect(fromC.notes).toEqual([60, 64, 67]);
227
+ const fromE = nearestInversion(c, 52);
228
+ expect(fromE.inversion).toBe(1);
229
+ expect(fromE.notes, "E G C stacks ascending from the reference").toEqual([52, 55, 60]);
230
+ });
231
+ });
@@ -0,0 +1,68 @@
1
+ // # onboardingstate.test — the pure contract of the seen flags: the host
2
+ // validation of the platform base (https only, public hosts only, mapped
3
+ // ipv6 normalized) and the seen-wins stage merge the accessor and the
4
+ // pages share. runnable with vitest: pnpm vitest run tests/onboardingstate.test.ts
5
+ import assert from "node:assert/strict";
6
+ import { describe, it } from "vitest";
7
+ import { hostallowed, mergeStage } from "../onboardingstate.ts";
8
+
9
+ describe("onboarding state", () => {
10
+ it("accepts public https bases", () => {
11
+ assert.equal(hostallowed("https://gateway.wenathlan.dev"), true);
12
+ assert.equal(hostallowed("https://gateway.wenathlan.dev/"), true);
13
+ assert.equal(hostallowed("https://debonair.example.com:8443"), true);
14
+ });
15
+
16
+ it("refuses plaintext, localhost, loopback and every private or reserved range", () => {
17
+ const refused = [
18
+ "http://gateway.wenathlan.dev",
19
+ "https://localhost",
20
+ "https://localhost:8787",
21
+ "https://app.localhost",
22
+ "https://box.local",
23
+ "https://service.internal",
24
+ "https://studio.home.arpa",
25
+ "https://127.0.0.1",
26
+ "https://0.0.0.0",
27
+ "https://10.0.0.9",
28
+ "https://172.16.0.1",
29
+ "https://172.31.255.255",
30
+ "https://192.168.1.4",
31
+ "https://169.254.10.10",
32
+ "https://100.64.0.1",
33
+ "https://192.0.2.7",
34
+ "https://198.51.100.7",
35
+ "https://203.0.113.9",
36
+ "https://198.18.0.4",
37
+ "https://224.0.0.1",
38
+ "https://[::1]",
39
+ "https://[::]",
40
+ "https://[::ffff:127.0.0.1]",
41
+ "https://[fd00::1]",
42
+ "https://[fe80::1]",
43
+ "not a url",
44
+ "",
45
+ ];
46
+ for (const base of refused) assert.equal(hostallowed(base), false, base);
47
+ });
48
+
49
+ it("normalizes the mapped ipv6 spelling the url parser produces", () => {
50
+ // `https://[::ffff:8.8.8.8]` arrives at the validator as the hex form
51
+ assert.equal(hostallowed("https://[::ffff:0808:0808]"), true);
52
+ assert.equal(hostallowed("https://[::ffff:7f00:1]"), false);
53
+ });
54
+
55
+ it("merges stages seen-wins — a stage never unsees", () => {
56
+ const fresh = { introseen: false, onboardingseen: false };
57
+ assert.deepEqual(mergeStage(fresh, "intro"), { introseen: true, onboardingseen: false });
58
+ assert.deepEqual(mergeStage(fresh, "onboarding"), { introseen: true, onboardingseen: true });
59
+ const half = { introseen: true, onboardingseen: false };
60
+ assert.deepEqual(mergeStage(half, "onboarding"), { introseen: true, onboardingseen: true });
61
+ });
62
+
63
+ it("never mutates the state it merges", () => {
64
+ const state = { introseen: false, onboardingseen: false };
65
+ mergeStage(state, "onboarding");
66
+ assert.deepEqual(state, { introseen: false, onboardingseen: false });
67
+ });
68
+ });