Skip to content

test: add unit tests for 6 critical utility modules (#1085)#1418

Merged
ChuxiJ merged 5 commits intomainfrom
fix/issue-1085-utility-tests
Apr 4, 2026
Merged

test: add unit tests for 6 critical utility modules (#1085)#1418
ChuxiJ merged 5 commits intomainfrom
fix/issue-1085-utility-tests

Conversation

@ChuxiJ
Copy link
Copy Markdown

@ChuxiJ ChuxiJ commented Apr 3, 2026

Summary

  • Add 153 unit tests for 6 previously untested utility modules
  • Cover clipAudio, audioWarp, pitchDetection, audioQuantize, midiPatternGenerator, midiEncoder
  • All tests use specific assertions (toBeCloseTo, toEqual, exact values) — no weak truthiness checks
  • Coverage: 94–100% per file (exceeds 90% threshold)

Closes #1085

Test Coverage

File Tests Statements Branches
clipAudio.ts 50 100% 92%
audioQuantize.ts 19 100% 100%
pitchDetection.ts 24 98% 92%
midiEncoder.ts 19 97% 83%
audioWarp.ts 20 94% 91%
midiPatternGenerator.ts 25 94% 74%

routingGraph.ts already had comprehensive tests (verified).

Test plan

  • All 153 new tests pass
  • Full test suite (4160 tests) passes with 0 regressions
  • TypeScript check passes (0 errors)
  • Build succeeds

https://claude.ai/code/session_01BearNgDAuZB3ztyr3Dqgua

ChuxiJ added 2 commits April 3, 2026 22:29
Add comprehensive test coverage for previously untested utility functions:
- clipAudio.ts (50 tests): playback rate, time-stretching, waveform layout
- audioWarp.ts (20 tests): BPM detection, stretch rate, warp segments
- pitchDetection.ts (24 tests): YIN algorithm, frequency-to-MIDI, frame grouping
- audioQuantize.ts (19 tests): transient detection, warp marker computation
- midiPatternGenerator.ts (25 tests): scale pitches, deterministic pattern generation
- midiEncoder.ts (19 tests): SMF format 1, tempo/note encoding, multi-track

Coverage per file: clipAudio 100%, audioQuantize 100%, pitchDetection 98%,
midiEncoder 97%, audioWarp 94%, midiPatternGenerator 94%.

All 153 new tests use specific assertions (toBeCloseTo, toEqual, exact values).
routingGraph.ts already had comprehensive tests (verified).

Closes #1085

https://claude.ai/code/session_01BearNgDAuZB3ztyr3Dqgua
Copilot AI review requested due to automatic review settings April 3, 2026 22:31
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds comprehensive Vitest unit tests for several previously untested audio/MIDI utility modules and wires up V8-based coverage output handling.

Changes:

  • Added new unit test suites for: clipAudio, audioWarp, pitchDetection, audioQuantize, midiPatternGenerator, midiEncoder
  • Added @vitest/coverage-v8 dev dependency to support vitest run --coverage
  • Ignored generated coverage/ output in .gitignore

Reviewed changes

Copilot reviewed 7 out of 9 changed files in this pull request and generated 5 comments.

Show a summary per file
File Description
src/utils/tests/pitchDetection.test.ts Adds unit tests for frequency→MIDI, frame pitch detection, and frame→note grouping
src/utils/tests/midiPatternGenerator.test.ts Adds deterministic/unit property tests for scale pitch selection and pattern generation
src/utils/tests/midiEncoder.test.ts Adds byte-level assertions for MIDI file structure, meta events, and note encoding
src/utils/tests/clipAudio.test.ts Adds tests for playback rate, offsets/spans, audible timing, and waveform layout calculations
src/utils/tests/audioWarp.test.ts Adds tests for stretch-rate math, warped-segment construction, and BPM detection
src/utils/tests/audioQuantize.test.ts Adds tests for transient detection and warp-marker quantization behavior
package.json Adds @vitest/coverage-v8 dev dependency
package-lock.json Locks @vitest/coverage-v8 and related dependency updates
.gitignore Ignores coverage/ output directory
Comments suppressed due to low confidence (1)

package.json:73

  • @vitest/coverage-v8@4.1.2 declares a peer dependency on vitest (and related @vitest/* packages) at the same version. Since package.json currently allows vitest: ^4.1.0, installs that don't use the lockfile could resolve vitest@4.1.0 and hit peer-dependency warnings/errors. Consider bumping vitest to ^4.1.2 (or pinning both to the same exact version) to keep them aligned.
    "@vitest/coverage-v8": "^4.1.2",
    "jsdom": "^29.0.0",
    "tailwindcss": "^4.0.0",
    "typescript": "^5.7.0",
    "vite": "^6.0.0",
    "vitepress": "^1.6.4",
    "vitest": "^4.1.0",
    "ws": "^8.20.0"

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +167 to +173
const bpm = detectBpm(samples, sampleRate);
// Should detect ~120 BPM (within reasonable range due to histogram binning)
if (bpm !== null) {
expect(bpm).toBeGreaterThanOrEqual(100);
expect(bpm).toBeLessThanOrEqual(140);
}
});
Copy link

Copilot AI Apr 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The detects BPM from regular impulses at 120 BPM test becomes vacuous because it only asserts when bpm !== null. With the current implementation, a regression that always returns null would still pass. Please assert bpm is non-null for this constructed impulse signal, then assert the expected range/value.

Copilot uses AI. Check for mistakes.
Comment on lines +191 to +217
const bpm = detectBpm(samples, sampleRate);
if (bpm !== null) {
expect(bpm).toBeGreaterThanOrEqual(60);
expect(bpm).toBeLessThanOrEqual(200);
}
});

it('returns an integer BPM', () => {
const sampleRate = 44100;
const duration = 4;
const samples = new Float32Array(sampleRate * duration);
const beatInterval = 0.5;

for (let beat = 0; beat < duration / beatInterval; beat++) {
const sampleIdx = Math.floor(beat * beatInterval * sampleRate);
for (let i = 0; i < 512; i++) {
if (sampleIdx + i < samples.length) {
samples[sampleIdx + i] = Math.exp(-i / 50) * 0.8;
}
}
}

const bpm = detectBpm(samples, sampleRate);
if (bpm !== null) {
expect(Number.isInteger(bpm)).toBe(true);
}
});
Copy link

Copilot AI Apr 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Similarly, these assertions are guarded by if (bpm !== null), so the test would still pass if BPM detection regressed to always return null. For the synthetic impulse input, assert non-null first, then check normalization/integer behavior.

Copilot uses AI. Check for mistakes.
expect(getClipAudibleSourceEnd(clip)).toBe(6);
});

it('returns 0 when source is exhausted', () => {
Copy link

Copilot AI Apr 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The test name/comment says it "returns 0 when source is exhausted", but the expected value is 10 and the implementation returns audioOffset + span (span=0 when exhausted). Either rename the test to reflect the actual behavior (e.g. returns audioOffset when exhausted) or change the assertion if the intended behavior is different.

Suggested change
it('returns 0 when source is exhausted', () => {
it('returns audioOffset when source is exhausted', () => {

Copilot uses AI. Check for mistakes.
Comment on lines +258 to +270
{ time: 0.0, frequency: 440, confidence: 0.9 },
{ time: 0.01, frequency: 440, confidence: 0.9 },
{ time: 0.02, frequency: 440, confidence: 0.9 },
{ time: 0.03, frequency: 440, confidence: 0.9 },
{ time: 0.04, frequency: 440, confidence: 0.9 },
{ time: 0.05, frequency: 440, confidence: 0.9 },
];

const notes = framesToNotes(frames);
for (const note of notes) {
expect(note.pitch).toBeGreaterThanOrEqual(0);
expect(note.pitch).toBeLessThanOrEqual(127);
}
Copy link

Copilot AI Apr 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test is titled "MIDI pitch is clamped to 0-127 range", but it only uses 440 Hz (MIDI 69), which is already in-range, so it doesn't exercise the out-of-range/clamping logic in framesToNotes. Consider either renaming the test to match what it actually verifies, or using frames with frequencies that would round below 0 or above 127 so the clamping behavior is actually tested.

Suggested change
{ time: 0.0, frequency: 440, confidence: 0.9 },
{ time: 0.01, frequency: 440, confidence: 0.9 },
{ time: 0.02, frequency: 440, confidence: 0.9 },
{ time: 0.03, frequency: 440, confidence: 0.9 },
{ time: 0.04, frequency: 440, confidence: 0.9 },
{ time: 0.05, frequency: 440, confidence: 0.9 },
];
const notes = framesToNotes(frames);
for (const note of notes) {
expect(note.pitch).toBeGreaterThanOrEqual(0);
expect(note.pitch).toBeLessThanOrEqual(127);
}
{ time: 0.0, frequency: 1, confidence: 0.9 },
{ time: 0.01, frequency: 1, confidence: 0.9 },
{ time: 0.02, frequency: 1, confidence: 0.9 },
{ time: 0.03, frequency: 1, confidence: 0.9 },
{ time: 0.04, frequency: 1, confidence: 0.9 },
{ time: 0.05, frequency: 1, confidence: 0.9 },
{ time: 0.06, frequency: 20000, confidence: 0.9 },
{ time: 0.07, frequency: 20000, confidence: 0.9 },
{ time: 0.08, frequency: 20000, confidence: 0.9 },
{ time: 0.09, frequency: 20000, confidence: 0.9 },
{ time: 0.10, frequency: 20000, confidence: 0.9 },
{ time: 0.11, frequency: 20000, confidence: 0.9 },
];
const notes = framesToNotes(frames);
expect(notes).toHaveLength(2);
expect(notes[0].pitch).toBe(0);
expect(notes[1].pitch).toBe(127);

Copilot uses AI. Check for mistakes.
Comment on lines +103 to +108
it('different seeds produce different output', () => {
const a = generatePattern(makeOpts({ seed: 1 }));
const b = generatePattern(makeOpts({ seed: 2 }));
// Could theoretically be equal but extremely unlikely
const same = a.length === b.length && a.every((n, i) => n.pitch === b[i].pitch);
expect(same).toBe(false);
Copy link

Copilot AI Apr 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test asserts that different seeds produce different output, but the generator API doesn't appear to guarantee uniqueness across seeds (collisions are possible, and future algorithm changes could legitimately make seeds 1 and 2 produce the same pitch sequence). To avoid brittle failures, assert a specific expected output for a chosen seed (or verify a property that is guaranteed, like determinism for the same seed).

Suggested change
it('different seeds produce different output', () => {
const a = generatePattern(makeOpts({ seed: 1 }));
const b = generatePattern(makeOpts({ seed: 2 }));
// Could theoretically be equal but extremely unlikely
const same = a.length === b.length && a.every((n, i) => n.pitch === b[i].pitch);
expect(same).toBe(false);
it('is deterministic for another chosen seed', () => {
const a = generatePattern(makeOpts({ seed: 1 }));
const b = generatePattern(makeOpts({ seed: 1 }));
expect(a).toEqual(b);

Copilot uses AI. Check for mistakes.
- Rename misleading test: "returns 0 when exhausted" → "returns audioOffset when exhausted"
- Replace vacuous BPM detection tests: assert non-null before checking range
- Fix out-of-range MIDI test: verify frames are excluded (not clamped)
- Replace brittle different-seed test with second determinism assertion

https://claude.ai/code/session_01BearNgDAuZB3ztyr3Dqgua
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 7 out of 9 changed files in this pull request and generated 5 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +182 to +189
const bytes = readBytes(buffer);

for (let i = 0; i < bytes.length - 2; i++) {
if ((bytes[i] & 0xf0) === 0x90 && bytes[i + 1] === 60) {
expect(bytes[i + 2]).toBeLessThanOrEqual(127);
break;
}
}
Copy link

Copilot AI Apr 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In this test, the loop only asserts when it finds a note-on event for pitch 60; if no note-on is encoded at all, the test will still pass. Add an explicit found flag (as in the earlier note-on/off test) and assert that the note-on event was found before finishing the test.

Suggested change
const bytes = readBytes(buffer);
for (let i = 0; i < bytes.length - 2; i++) {
if ((bytes[i] & 0xf0) === 0x90 && bytes[i + 1] === 60) {
expect(bytes[i + 2]).toBeLessThanOrEqual(127);
break;
}
}
const bytes = readBytes(buffer);
let found = false;
for (let i = 0; i < bytes.length - 2; i++) {
if ((bytes[i] & 0xf0) === 0x90 && bytes[i + 1] === 60) {
expect(bytes[i + 2]).toBeLessThanOrEqual(127);
found = true;
break;
}
}
expect(found).toBe(true);

Copilot uses AI. Check for mistakes.
Comment on lines +196 to +202

for (let i = 0; i < bytes.length - 2; i++) {
if ((bytes[i] & 0xf0) === 0x90 && bytes[i + 1] === 60) {
expect(bytes[i + 2]).toBe(0);
break;
}
}
Copy link

Copilot AI Apr 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test has the same issue as the velocity-clamp test: if the expected note-on event is never encoded, no assertion runs and the test will still pass. Track whether a matching note-on was found and assert it at the end to ensure the behavior is actually exercised.

Suggested change
for (let i = 0; i < bytes.length - 2; i++) {
if ((bytes[i] & 0xf0) === 0x90 && bytes[i + 1] === 60) {
expect(bytes[i + 2]).toBe(0);
break;
}
}
let foundOn = false;
for (let i = 0; i < bytes.length - 2; i++) {
if ((bytes[i] & 0xf0) === 0x90 && bytes[i + 1] === 60) {
expect(bytes[i + 2]).toBe(0);
foundOn = true;
break;
}
}
expect(foundOn).toBe(true);

Copilot uses AI. Check for mistakes.
it('returns frames with null frequency for silent audio', () => {
const sampleRate = 44100;
const samples = new Float32Array(sampleRate); // 1 second of silence
const frames = detectPitchFrames(samples, sampleRate);
Copy link

Copilot AI Apr 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test can pass even if detectPitchFrames returns an empty array (the for loop would execute zero iterations). Since the intent is to verify that silence produces frames with frequency=null and confidence=0, add an assertion that frames.length is > 0 for this 1-second buffer.

Suggested change
const frames = detectPitchFrames(samples, sampleRate);
const frames = detectPitchFrames(samples, sampleRate);
expect(frames.length).toBeGreaterThan(0);

Copilot uses AI. Check for mistakes.
Comment on lines +120 to +124
// All frames should have null frequency (440 < 500 min)
for (const frame of frames) {
expect(frame.frequency).toBeNull();
}
});
Copy link

Copilot AI Apr 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Similar to the silent-audio case, this assertion loop will also silently pass if frames is empty. Since this test is meant to verify that min/max frequency options suppress detection (rather than skipping analysis entirely), add a expect(frames.length).toBeGreaterThan(0) (or otherwise assert analysis occurred) before checking that frequency is null.

Copilot uses AI. Check for mistakes.
"@types/uuid": "^10.0.0",
"@types/ws": "^8.18.1",
"@vitejs/plugin-react": "^4.0.0",
"@vitest/coverage-v8": "^4.1.2",
Copy link

Copilot AI Apr 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@vitest/coverage-v8@4.1.2 declares a peer dependency on vitest exactly 4.1.2 (per package-lock). package.json currently allows vitest: ^4.1.0, which can drift to a version that no longer satisfies that exact peer and cause install warnings/failures. Consider pinning vitest to 4.1.2 (or updating both to matching versions) to keep Vitest packages in sync.

Copilot uses AI. Check for mistakes.
- pitchDetection.test.ts: Assert frames.length > 0 before iterating
  to prevent vacuous tests when detectPitchFrames returns empty array
- midiEncoder.test.ts: Add 'found' flag to velocity clamp and zero
  velocity tests to ensure note-on events are actually present

https://claude.ai/code/session_01WPFi3zKZ13HHusnshqN3Bg
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 7 out of 9 changed files in this pull request and generated 2 comments.

Comments suppressed due to low confidence (1)

package.json:73

  • @vitest/coverage-v8@^4.1.2 declares a peer dependency on vitest version 4.1.2, but package.json currently allows vitest ^4.1.0 (which can resolve to other 4.x versions). To avoid peer-dependency warnings or future resolution drift, align the vitest devDependency to ^4.1.2 (or pin to 4.1.2) alongside the coverage package.
    "@types/ws": "^8.18.1",
    "@vitejs/plugin-react": "^4.0.0",
    "@vitest/coverage-v8": "^4.1.2",
    "jsdom": "^29.0.0",
    "tailwindcss": "^4.0.0",
    "typescript": "^5.7.0",
    "vite": "^6.0.0",
    "vitepress": "^1.6.4",
    "vitest": "^4.1.0",
    "ws": "^8.20.0"

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

@@ -0,0 +1,216 @@
import { describe, it, expect } from 'vitest';
import { detectBpm, computeStretchRate, computeWarpedSegments, type WarpSegment } from '../audioWarp';
Copy link

Copilot AI Apr 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unused type import WarpSegment is never referenced in this test file. Removing it will keep imports minimal and avoid unused-import lint failures if linting is enabled for tests.

Suggested change
import { detectBpm, computeStretchRate, computeWarpedSegments, type WarpSegment } from '../audioWarp';
import { detectBpm, computeStretchRate, computeWarpedSegments } from '../audioWarp';

Copilot uses AI. Check for mistakes.
@@ -0,0 +1,271 @@
import { describe, it, expect } from 'vitest';
import { encodeMidiFile, type MidiExportTrack, type MidiExportOptions } from '../midiEncoder';
Copy link

Copilot AI Apr 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unused type import MidiExportOptions is never used in this test file. Consider removing it to keep imports clean and prevent unused-import lint warnings.

Suggested change
import { encodeMidiFile, type MidiExportTrack, type MidiExportOptions } from '../midiEncoder';
import { encodeMidiFile, type MidiExportTrack } from '../midiEncoder';

Copilot uses AI. Check for mistakes.
- midiEncoder.test.ts: Remove unused MidiExportOptions import
- audioWarp.test.ts: Remove unused WarpSegment import

https://claude.ai/code/session_01WPFi3zKZ13HHusnshqN3Bg
@ChuxiJ ChuxiJ merged commit 900d6b4 into main Apr 4, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

fix: 86% of utility functions untested — clipAudio, audioWarp, pitchDetection

2 participants