/**
* Hybrid Validation Integration Tests
*
* Tests the hybrid approach: local StrudelEngine validation + browser playback.
* This validates that patterns work correctly in both contexts and that
* local validation provides meaningful, accurate results.
*
* Key tests:
* - Local vs browser validation consistency
* - Pattern analysis accuracy (BPM, complexity)
* - Longform pattern validation
* - Performance comparison (local should be >5x faster)
* - Error message quality (line numbers, suggestions)
*/
// Use the mock implementation to avoid ES module issues with Jest
jest.mock('../../services/StrudelEngine');
import { StrudelEngine, LocalValidationResult, PatternMetadata } from '../../services/StrudelEngine';
import { PatternValidator, ValidationResult } from '../../utils/PatternValidator';
import { promises as fs } from 'fs';
import path from 'path';
describe('Hybrid Validation Integration Tests', () => {
let strudelEngine: StrudelEngine;
let patternValidator: PatternValidator;
beforeEach(() => {
strudelEngine = new StrudelEngine();
patternValidator = new PatternValidator();
});
// ============================================================================
// LOCAL VS BROWSER VALIDATION CONSISTENCY
// ============================================================================
describe('Local vs Browser Validation Consistency', () => {
const validPatterns = [
{ name: 'simple kick', pattern: 's("bd*4")' },
{ name: 'techno beat', pattern: 's("bd*4, ~ cp ~ cp, hh*8").room(0.2)' },
{ name: 'with tempo', pattern: 'setcpm(130)\ns("bd*4")' },
{ name: 'stack pattern', pattern: 'stack(s("bd*4"), s("hh*8"))' },
{ name: 'note pattern', pattern: 'note("c3 e3 g3 b3")' },
{ name: 'with effects', pattern: 's("bd*4").room(0.5).delay(0.25).gain(0.8)' },
{ name: 'euclidean', pattern: 's("bd").euclid(5, 8)' },
{
name: 'complex multiline',
pattern: `setcpm(128)
stack(
s("bd*4, ~ cp ~ cp"),
s("hh*8"),
note("c2 c2 c2 c2").s("sawtooth")
).gain(0.8)`,
},
];
test.each(validPatterns)(
'should validate "$name" consistently with both validators',
({ pattern }) => {
// Local StrudelEngine validation
const localResult = strudelEngine.validate(pattern);
// PatternValidator validation
const patternValidatorResult = patternValidator.validate(pattern);
// Both should agree on validity for valid patterns
expect(localResult.valid).toBe(true);
expect(patternValidatorResult.valid).toBe(true);
expect(localResult.errors).toHaveLength(0);
expect(patternValidatorResult.errors).toHaveLength(0);
}
);
const invalidPatterns = [
{ name: 'empty', pattern: '', expectedError: /empty/i },
{ name: 'unclosed paren', pattern: 's("bd*4"', expectedError: /(paren|end|unexpected)/i },
{ name: 'whitespace only', pattern: ' \n \t ', expectedError: /empty/i },
];
test.each(invalidPatterns)(
'should reject "$name" in both validators',
({ pattern, expectedError }) => {
// Local StrudelEngine validation
const localResult = strudelEngine.validate(pattern);
// PatternValidator validation
const patternValidatorResult = patternValidator.validate(pattern);
// Both should mark as invalid
expect(localResult.valid).toBe(false);
expect(patternValidatorResult.valid).toBe(false);
// Should have meaningful error messages
expect(localResult.errors.length).toBeGreaterThan(0);
expect(patternValidatorResult.errors.length).toBeGreaterThan(0);
// At least one error should match expected pattern
const localErrorMatch = localResult.errors.some((e) => expectedError.test(e));
const validatorErrorMatch = patternValidatorResult.errors.some((e) =>
expectedError.test(e)
);
expect(localErrorMatch || validatorErrorMatch).toBe(true);
}
);
test('should reject unclosed quote with PatternValidator', () => {
// Mock doesn't detect quote issues, but PatternValidator does
const pattern = 's("bd*4)';
const validatorResult = patternValidator.validate(pattern);
expect(validatorResult.valid).toBe(false);
expect(validatorResult.errors.some((e) => /quote/i.test(e))).toBe(true);
});
test('should handle edge case: pattern with dangerous gain', () => {
const pattern = 's("bd*4").gain(10)';
const localResult = strudelEngine.validate(pattern);
const patternValidatorResult = patternValidator.validate(pattern);
// Pattern is syntactically valid but has warnings
expect(localResult.valid).toBe(true);
expect(patternValidatorResult.valid).toBe(true);
// Both should warn about dangerous gain
const localWarns = localResult.warnings.some((w) => /gain/i.test(w));
const validatorWarns = patternValidatorResult.warnings.some((w) => /gain/i.test(w));
expect(localWarns || validatorWarns).toBe(true);
});
test('should handle edge case: pattern without sound function', () => {
const pattern = 'setcpm(120)';
const localResult = strudelEngine.validate(pattern);
const patternValidatorResult = patternValidator.validate(pattern);
// Syntactically valid but missing sound source
// PatternValidator should warn
const hasWarning = patternValidatorResult.warnings.some(
(w) => /sound/i.test(w) || /s\(\)/i.test(w) || /note\(\)/i.test(w)
);
expect(hasWarning).toBe(true);
});
});
// ============================================================================
// PATTERN ANALYSIS ACCURACY
// ============================================================================
describe('Pattern Analysis Accuracy', () => {
describe('BPM Extraction', () => {
const bpmTestCases = [
{ pattern: 'setcpm(120)\ns("bd*4")', expectedBpm: 120 },
{ pattern: 'setcpm(140)\nstack(s("bd"), s("hh"))', expectedBpm: 140 },
{ pattern: 'setcpm(174)\ns("bd").fast(2)', expectedBpm: 174 },
{ pattern: 'setcpm(60)\nnote("c3 e3 g3")', expectedBpm: 60 },
{ pattern: 'setcpm(90.5)\ns("bd*4")', expectedBpm: 90.5 },
];
test.each(bpmTestCases)(
'should extract BPM=$expectedBpm from pattern',
({ pattern, expectedBpm }) => {
const metadata = strudelEngine.analyzePattern(pattern);
expect(metadata.bpm).toBe(expectedBpm);
}
);
test('should return undefined BPM when setcpm is not present', () => {
const pattern = 's("bd*4")';
const metadata = strudelEngine.analyzePattern(pattern);
expect(metadata.bpm).toBeUndefined();
});
});
describe('Complexity Scoring', () => {
test('should score simple pattern as low complexity', () => {
const pattern = 's("bd*4")';
const metadata = strudelEngine.analyzePattern(pattern);
expect(metadata.complexity).toBeGreaterThanOrEqual(0);
expect(metadata.complexity).toBeLessThanOrEqual(1);
expect(metadata.complexity).toBeLessThan(0.5);
});
test('should score complex pattern as higher complexity', () => {
const complexPattern = `setcpm(128)
stack(
s("bd*4, ~ cp ~ cp, [~ hh]*4").room(0.2).delay(0.1),
note("c2 c2 c2 c2").s("sawtooth").cutoff(800).resonance(0.3),
note("c4 e4 g4 e4").s("triangle").struct("~ 1 ~ 1").delay(0.25),
s("ride*8").gain(0.3).pan(sine.range(-1, 1).slow(4))
).gain(0.8)`;
const metadata = strudelEngine.analyzePattern(complexPattern);
expect(metadata.complexity).toBeGreaterThan(0.3);
expect(metadata.complexity).toBeLessThanOrEqual(1);
});
test('should score stack patterns as more complex than single patterns', () => {
const simplePattern = 's("bd*4")';
const stackPattern = 'stack(s("bd*4"), s("hh*8"), s("cp").slow(2))';
const simpleMeta = strudelEngine.analyzePattern(simplePattern);
const stackMeta = strudelEngine.analyzePattern(stackPattern);
expect(stackMeta.complexity).toBeGreaterThan(simpleMeta.complexity);
expect(stackMeta.isStack).toBe(true);
expect(simpleMeta.isStack).toBe(false);
});
test('complexity should be bounded between 0 and 1', () => {
// Test with extremely complex pattern
const extremePattern =
's("bd hh cp oh rim sn tom")' + '.fast(2).slow(2).room(0.5).delay(0.5)'.repeat(10);
const metadata = strudelEngine.analyzePattern(extremePattern);
expect(metadata.complexity).toBeGreaterThanOrEqual(0);
expect(metadata.complexity).toBeLessThanOrEqual(1);
});
});
describe('Pattern Type Detection', () => {
test('should detect sound patterns', () => {
const pattern = 's("bd hh cp")';
const metadata = strudelEngine.analyzePattern(pattern);
expect(metadata.usesSound).toBe(true);
expect(metadata.usesNote).toBe(false);
});
test('should detect note patterns', () => {
const pattern = 'note("c3 e3 g3 b3")';
const metadata = strudelEngine.analyzePattern(pattern);
expect(metadata.usesNote).toBe(true);
});
test('should detect stack patterns', () => {
const pattern = 'stack(s("bd"), note("c3"))';
const metadata = strudelEngine.analyzePattern(pattern);
expect(metadata.isStack).toBe(true);
expect(metadata.usesSound).toBe(true);
expect(metadata.usesNote).toBe(true);
});
test('should extract function names used', () => {
const pattern = 's("bd*4").fast(2).room(0.5).delay(0.25).gain(0.8)';
const metadata = strudelEngine.analyzePattern(pattern);
expect(metadata.functionsUsed).toContain('s');
expect(metadata.functionsUsed).toContain('fast');
expect(metadata.functionsUsed).toContain('room');
expect(metadata.functionsUsed).toContain('delay');
expect(metadata.functionsUsed).toContain('gain');
});
});
describe('Events Per Cycle', () => {
test('should count events accurately for simple pattern', () => {
const pattern = 's("bd hh sd hh")';
const metadata = strudelEngine.analyzePattern(pattern);
expect(metadata.eventsPerCycle).toBe(4);
});
test('should extract unique values', () => {
const pattern = 's("bd hh sd hh")';
const metadata = strudelEngine.analyzePattern(pattern);
expect(metadata.uniqueValues.length).toBe(3); // bd, hh, sd
expect(metadata.uniqueValues).toContain('bd');
expect(metadata.uniqueValues).toContain('hh');
expect(metadata.uniqueValues).toContain('sd');
});
});
});
// ============================================================================
// LONGFORM PATTERN VALIDATION
// ============================================================================
describe('Longform Pattern Validation', () => {
// Use process.cwd() to get the project root
const longformDir = path.join(
process.cwd(),
'patterns/examples/longform'
);
test('should validate longform patterns directory exists', async () => {
let dirExists = false;
try {
await fs.access(longformDir);
dirExists = true;
} catch {
dirExists = false;
}
// If directory doesn't exist, skip remaining tests gracefully
expect(dirExists).toBe(true);
});
test('should validate all JSON pattern files in longform directory', async () => {
let files: string[] = [];
try {
files = await fs.readdir(longformDir);
} catch {
// Directory may not exist in test environment
return;
}
const jsonFiles = files.filter((f) => f.endsWith('.json'));
for (const file of jsonFiles) {
const content = await fs.readFile(path.join(longformDir, file), 'utf-8');
const data = JSON.parse(content);
expect(data).toHaveProperty('pattern');
expect(data).toHaveProperty('name');
// Validate the pattern
const localResult = strudelEngine.validate(data.pattern);
const validatorResult = patternValidator.validate(data.pattern);
// Longform patterns should be valid
expect(localResult.valid).toBe(true);
expect(validatorResult.valid).toBe(true);
// Should have reasonable structure
const metadata = strudelEngine.analyzePattern(data.pattern);
expect(metadata.isStack || metadata.usesSound || metadata.usesNote).toBe(true);
}
});
test('should validate dark-ambient-journey pattern structure', async () => {
const filePath = path.join(longformDir, 'dark-ambient-journey.json');
let content: string;
try {
content = await fs.readFile(filePath, 'utf-8');
} catch {
// File may not exist in test environment
return;
}
const data = JSON.parse(content);
// Validate pattern parses
const localResult = strudelEngine.validate(data.pattern);
expect(localResult.valid).toBe(true);
// Validate structure
const metadata = strudelEngine.analyzePattern(data.pattern);
// Should have tempo
expect(metadata.bpm).toBe(60);
// Should be a stack pattern
expect(metadata.isStack).toBe(true);
// Should use multiple functions
expect(metadata.functionsUsed.length).toBeGreaterThan(5);
// Pattern should have expected metadata
expect(data).toHaveProperty('structure');
expect(data).toHaveProperty('bpm', 60);
expect(data).toHaveProperty('key', 'Cm');
expect(data.tags).toContain('longform');
});
test('should handle patterns with section timing functions', async () => {
// Pattern with .when() structural control (common in longform)
const pattern = `setcpm(60)
const intro = (p) => p.when("<1@16 0@80>", x => x)
stack(
note("c2 e2 g2 b2").s("sawtooth").apply(intro)
).gain(0.8)`;
// PatternValidator should validate the basic structure
const validatorResult = patternValidator.validate(pattern);
expect(validatorResult.valid).toBe(true);
// Should detect stack usage
const metadata = strudelEngine.analyzePattern(pattern);
expect(metadata.isStack).toBe(true);
});
});
// ============================================================================
// PERFORMANCE COMPARISON
// ============================================================================
describe('Performance Comparison: Local vs Mock Browser', () => {
// Mock browser validation time (simulated)
const simulateBrowserValidation = async (pattern: string): Promise<ValidationResult> => {
// Simulate browser overhead: 200-500ms for a real browser call
const browserOverhead = 200;
const start = performance.now();
// Simulate async browser operation
await new Promise((resolve) => setTimeout(resolve, browserOverhead));
const result = patternValidator.validate(pattern);
const elapsed = performance.now() - start;
return { ...result, _elapsed: elapsed } as ValidationResult & { _elapsed: number };
};
test('local validation should be significantly faster than browser', async () => {
const pattern = `setcpm(128)
stack(
s("bd*4, ~ cp ~ cp, [~ hh]*4").room(0.2),
note("c2 c2 c2 c2").s("sawtooth").cutoff(800),
note("c4 e4 g4 e4").s("triangle")
).gain(0.8)`;
// Time local validation
const localStart = performance.now();
for (let i = 0; i < 100; i++) {
strudelEngine.validate(pattern);
}
const localTime = (performance.now() - localStart) / 100;
// Time mock browser validation (simulated)
const browserStart = performance.now();
await simulateBrowserValidation(pattern);
const browserTime = performance.now() - browserStart;
// Local should be at least 5x faster
expect(browserTime / localTime).toBeGreaterThan(5);
// Local validation should complete in under 10ms
expect(localTime).toBeLessThan(10);
});
test('should document latency difference', () => {
const pattern = 's("bd*4")';
// Run multiple iterations to get average
const iterations = 100;
const times: number[] = [];
for (let i = 0; i < iterations; i++) {
const start = performance.now();
strudelEngine.validate(pattern);
times.push(performance.now() - start);
}
const avgTime = times.reduce((a, b) => a + b, 0) / iterations;
const maxTime = Math.max(...times);
// Document performance characteristics
expect(avgTime).toBeLessThan(5); // Average under 5ms
expect(maxTime).toBeLessThan(20); // No single call over 20ms
});
test('analysis should also be fast', () => {
const complexPattern = `setcpm(128)
stack(
s("bd*4, ~ cp ~ cp").room(0.2),
note("c2 c2 c2 c2").s("sawtooth"),
note("c4 e4 g4").s("triangle").fast(2)
).gain(0.8)`;
const start = performance.now();
const metadata = strudelEngine.analyzePattern(complexPattern);
const elapsed = performance.now() - start;
// Analysis should complete quickly
expect(elapsed).toBeLessThan(50);
expect(metadata).toHaveProperty('eventsPerCycle');
expect(metadata).toHaveProperty('complexity');
});
});
// ============================================================================
// ERROR MESSAGE QUALITY
// ============================================================================
describe('Error Message Quality', () => {
test('should include error location for syntax errors', () => {
// Pattern with unclosed parenthesis - mock detects this
const pattern = 's("bd*4"';
const localResult = strudelEngine.validate(pattern);
expect(localResult.valid).toBe(false);
expect(localResult.errors.length).toBeGreaterThan(0);
// Should have error location info
expect(localResult.errorLocation).toBeDefined();
expect(localResult.errorLocation).toHaveProperty('line');
expect(localResult.errorLocation).toHaveProperty('column');
expect(localResult.errorLocation!.line).toBeGreaterThanOrEqual(1);
});
test('PatternValidator should include position for unbalanced brackets', () => {
// PatternValidator detects and reports unbalanced brackets
const pattern = `s("bd*4")
.fast(2
.gain(0.8)`;
const validatorResult = patternValidator.validate(pattern);
expect(validatorResult.valid).toBe(false);
expect(validatorResult.errors.length).toBeGreaterThan(0);
// Should mention unbalanced/unclosed
expect(validatorResult.errors.some((e) => /unbalanced|unclosed/i.test(e))).toBe(true);
});
test('should provide actionable suggestions for common errors', () => {
const testCases = [
{
pattern: '',
expectSuggestion: /add|pattern|s\(/i,
},
{
pattern: 's("bd*4"',
expectSuggestion: /paren|bracket|syntax/i,
},
];
for (const { pattern, expectSuggestion } of testCases) {
const result = strudelEngine.validate(pattern);
expect(result.valid).toBe(false);
expect(result.suggestions.length).toBeGreaterThan(0);
const hasSuggestion = result.suggestions.some((s) => expectSuggestion.test(s));
expect(hasSuggestion).toBe(true);
}
});
test('should provide suggestions for valid but improvable patterns', () => {
const pattern = 's("bd*4")';
const result = strudelEngine.validate(pattern);
expect(result.valid).toBe(true);
// Should suggest tempo setting
expect(result.suggestions.some((s) => /setcpm|tempo/i.test(s))).toBe(true);
});
test('should warn about potentially problematic patterns', () => {
const pattern = 's("bd*4").gain(3)';
const localResult = strudelEngine.validate(pattern);
const validatorResult = patternValidator.validate(pattern);
// Both should warn about gain
const localWarns = localResult.warnings.some((w) => /gain/i.test(w));
const validatorWarns = validatorResult.warnings.some((w) => /gain/i.test(w));
expect(localWarns || validatorWarns).toBe(true);
});
test('should detect undefined function errors', () => {
const pattern = 'unknownFunction("bd*4")';
const validatorResult = patternValidator.validate(pattern);
// PatternValidator checks for unknown functions
const hasUnknownWarning = validatorResult.warnings.some((w) =>
/unknown/i.test(w)
);
expect(hasUnknownWarning).toBe(true);
});
test('should detect missing sound source', () => {
const pattern = 'setcpm(120)';
const validatorResult = patternValidator.validate(pattern);
// Should warn about no sound source
const hasSoundWarning = validatorResult.warnings.some(
(w) => /sound/i.test(w) || /s\(\)/i.test(w) || /note\(\)/i.test(w)
);
expect(hasSoundWarning).toBe(true);
});
});
// ============================================================================
// EDGE CASES AND ROBUSTNESS
// ============================================================================
describe('Edge Cases and Robustness', () => {
test('should handle very long patterns', () => {
const longPattern = 's("bd hh cp oh")' + '.fast(2)'.repeat(100);
const result = strudelEngine.validate(longPattern);
// Should complete without crashing
expect(result).toHaveProperty('valid');
expect(result).toHaveProperty('errors');
});
test('should handle patterns with special characters', () => {
const patterns = [
's("bd:1 bd:2 bd:3")', // Colon for sample variation
's("bd*4, hh*8")', // Comma for parallel
's("[bd sd]*2")', // Brackets for grouping
's("<bd sd cp>")', // Angle brackets for alternation
];
for (const pattern of patterns) {
const result = strudelEngine.validate(pattern);
expect(result.valid).toBe(true);
}
});
test('should handle unicode in patterns', () => {
const pattern = 's("bd*4") // kick drum';
const result = strudelEngine.validate(pattern);
expect(result.valid).toBe(true);
});
test('should handle multiline patterns with comments', () => {
const pattern = `// This is a comment
setcpm(128)
// Another comment
stack(
s("bd*4"), // kick
s("hh*8") // hi-hats
)`;
const result = strudelEngine.validate(pattern);
expect(result.valid).toBe(true);
});
test('should handle patterns with template literals', () => {
// Template literals are valid JavaScript
const pattern = 's(`bd*4`)';
const result = strudelEngine.validate(pattern);
expect(result.valid).toBe(true);
});
test('should handle deeply nested patterns', () => {
const pattern = 'stack(stack(stack(s("bd"), s("hh")), s("cp")), s("oh"))';
const result = strudelEngine.validate(pattern);
expect(result.valid).toBe(true);
const metadata = strudelEngine.analyzePattern(pattern);
expect(metadata.isStack).toBe(true);
});
});
// ============================================================================
// TRANSPILATION TESTS
// ============================================================================
describe('Transpilation', () => {
test('should transpile valid patterns successfully', () => {
const pattern = 's("bd*4").fast(2)';
const result = strudelEngine.transpile(pattern);
expect(result.success).toBe(true);
expect(result.transpiledCode).toBeDefined();
expect(result.error).toBeUndefined();
});
test('should fail transpilation for invalid syntax', () => {
const pattern = 's("bd*4"';
const result = strudelEngine.transpile(pattern);
expect(result.success).toBe(false);
expect(result.error).toBeDefined();
});
test('should provide error location on transpile failure', () => {
const pattern = 's("bd*4"';
const result = strudelEngine.transpile(pattern);
expect(result.success).toBe(false);
expect(result.errorLocation).toBeDefined();
expect(result.errorLocation?.line).toBeDefined();
expect(result.errorLocation?.column).toBeDefined();
});
});
// ============================================================================
// QUERY EVENTS TESTS
// ============================================================================
describe('Query Events', () => {
test('should query events from valid pattern', () => {
const pattern = 's("bd hh sd hh")';
const events = strudelEngine.queryEvents(pattern, 0, 1);
expect(Array.isArray(events)).toBe(true);
expect(events.length).toBe(4);
});
test('should throw for invalid pattern', () => {
const pattern = 's("bd*4"';
expect(() => {
strudelEngine.queryEvents(pattern, 0, 1);
}).toThrow(/Transpilation failed/);
});
test('should return events with timing information', () => {
const pattern = 's("bd sd")';
const events = strudelEngine.queryEvents(pattern, 0, 1);
expect(events.length).toBe(2);
expect(events[0]).toHaveProperty('start');
expect(events[0]).toHaveProperty('end');
expect(events[0]).toHaveProperty('value');
expect(events[0]).toHaveProperty('isWhole');
});
test('should scale events with cycle range', () => {
const pattern = 's("bd sd")';
const oneCycle = strudelEngine.queryEvents(pattern, 0, 1);
const twoCycles = strudelEngine.queryEvents(pattern, 0, 2);
expect(twoCycles.length).toBe(oneCycle.length * 2);
});
});
});