UNPKG

aiwg

Version:

Cognitive architecture for AI-augmented software development with structured memory, ensemble validation, and closed-loop correction. FAIR-aligned artifacts, 84% cost reduction via human-in-the-loop, standards adopted by 100+ organizations.

415 lines (343 loc) 14.4 kB
/** * TrendAnalyzer Tests * * Comprehensive test suite for statistical trend analysis * * @module test/unit/testing/trend-analyzer */ import { describe, it, expect, beforeEach } from 'vitest'; import { TrendAnalyzer, type TimeSeries } from '../../../src/testing/trend-analyzer.ts'; describe('TrendAnalyzer', () => { let analyzer: TrendAnalyzer; beforeEach(() => { analyzer = new TrendAnalyzer(); }); describe('Moving Average Calculation', () => { it('should calculate simple moving average correctly', () => { const samples = [10, 12, 11, 13, 15, 14, 16]; const result = analyzer.calculateMovingAverage(samples, 3); expect(result).toHaveLength(7); expect(result[0]).toBeCloseTo(10, 5); // First value (window=1) expect(result[1]).toBeCloseTo(11, 5); // (10+12)/2 expect(result[2]).toBeCloseTo(11, 5); // (10+12+11)/3 expect(result[3]).toBeCloseTo(12, 5); // (12+11+13)/3 expect(result[6]).toBeCloseTo(15, 5); // (15+14+16)/3 }); it('should handle window size of 1', () => { const samples = [10, 20, 30]; const result = analyzer.calculateMovingAverage(samples, 1); expect(result).toEqual(samples); // Should be identical }); it('should handle window size equal to sample count', () => { const samples = [10, 20, 30, 40]; const result = analyzer.calculateMovingAverage(samples, 4); expect(result[3]).toBeCloseTo(25, 5); // (10+20+30+40)/4 }); it('should throw errors for invalid window sizes', () => { const testCases = [ { windowSize: 0, errorMsg: 'Window size must be positive' }, { windowSize: -1, errorMsg: 'Window size must be positive' }, { windowSize: 5, errorMsg: 'Window size cannot exceed sample count' }, ]; for (const { windowSize, errorMsg } of testCases) { expect(() => analyzer.calculateMovingAverage([1, 2, 3], windowSize)).toThrow(errorMsg); } }); it('should handle single sample', () => { const result = analyzer.calculateMovingAverage([42], 1); expect(result).toEqual([42]); }); it('should calculate exponential moving average correctly', () => { const samples = [10, 12, 14, 16, 18]; const result = analyzer.calculateExponentialMovingAverage(samples, 0.3); expect(result).toHaveLength(5); expect(result[0]).toBeCloseTo(10, 5); // Start with first value expect(result[1]).toBeCloseTo(10.6, 5); // 0.3*12 + 0.7*10 expect(result[4]).toBeGreaterThan(result[0]); // Should trend upward }); it('should throw errors for invalid alpha values', () => { const testCases = [ { alpha: 0, errorMsg: 'Alpha must be between 0 and 1' }, { alpha: 1.5, errorMsg: 'Alpha must be between 0 and 1' }, ]; for (const { alpha, errorMsg } of testCases) { expect(() => analyzer.calculateExponentialMovingAverage([1, 2, 3], alpha)).toThrow(errorMsg); } }); }); describe('Outlier Detection', () => { it('should detect outliers using IQR method', () => { // Normal data: 10, 11, 12, 13, 14 with outliers: 1, 100 const samples = [1, 10, 11, 12, 13, 14, 100]; const outliers = analyzer.detectOutliers(samples, 'iqr'); expect(outliers[0]).toBe(true); // 1 is outlier expect(outliers[6]).toBe(true); // 100 is outlier expect(outliers[3]).toBe(false); // 12 is not outlier }); it('should detect outliers using Z-score method', () => { const samples = [10, 11, 12, 13, 14, 15, 10, 11, 12, 13, 14, 15, 100]; // 500 is outlier const outliers = analyzer.detectOutliers(samples, 'zscore'); expect(outliers[12]).toBe(true); // 100 is outlier (>3 std devs) expect(outliers[3]).toBe(false); // 13 is not outlier }); it('should handle uniform data (no outliers)', () => { const samples = [10, 10, 10, 10, 10]; const outliers = analyzer.detectOutliers(samples, 'iqr'); expect(outliers.every(o => o === false)).toBe(true); }); it('should handle empty array', () => { const outliers = analyzer.detectOutliers([], 'iqr'); expect(outliers).toEqual([]); }); it('should throw error for invalid method', () => { expect(() => analyzer.detectOutliers([1, 2, 3], 'invalid' as any)).toThrow('Invalid outlier detection method'); }); it('should handle single value (no outliers)', () => { const outliers = analyzer.detectOutliers([42], 'zscore'); expect(outliers).toEqual([false]); }); }); describe('Trend Line Fitting', () => { it('should fit linear trend lines to different data patterns', () => { const testCases = [ { name: 'increasing data', data: [ { timestamp: 1000, value: 10 }, { timestamp: 2000, value: 20 }, { timestamp: 3000, value: 30 }, { timestamp: 4000, value: 40 }, ], expectedSlope: 'positive', expectedRSquared: 1, }, { name: 'decreasing data', data: [ { timestamp: 1000, value: 40 }, { timestamp: 2000, value: 30 }, { timestamp: 3000, value: 20 }, { timestamp: 4000, value: 10 }, ], expectedSlope: 'negative', expectedRSquared: 1, }, { name: 'constant data', data: [ { timestamp: 1000, value: 25 }, { timestamp: 2000, value: 25 }, { timestamp: 3000, value: 25 }, ], expectedSlope: 'zero', expectedRSquared: 1, }, ]; for (const { data, expectedSlope, expectedRSquared } of testCases) { const trend = analyzer.fitTrendLine(data); if (expectedSlope === 'positive') { expect(trend.slope).toBeGreaterThan(0); } else if (expectedSlope === 'negative') { expect(trend.slope).toBeLessThan(0); } else if (expectedSlope === 'zero') { expect(Math.abs(trend.slope)).toBeLessThan(0.001); } expect(trend.rSquared).toBeCloseTo(expectedRSquared, expectedSlope === 'zero' ? 1 : 5); } }); it('should calculate R-squared for noisy data', () => { const data: TimeSeries = [ { timestamp: 1000, value: 10 }, { timestamp: 2000, value: 25 }, // Outlier { timestamp: 3000, value: 20 }, { timestamp: 4000, value: 30 }, { timestamp: 5000, value: 40 }, ]; const trend = analyzer.fitTrendLine(data); expect(trend.rSquared).toBeGreaterThan(0); expect(trend.rSquared).toBeLessThan(1); // Not perfect fit due to noise }); it('should throw error for insufficient data', () => { const data: TimeSeries = [{ timestamp: 1000, value: 10 }]; expect(() => analyzer.fitTrendLine(data)).toThrow('Need at least 2 data points'); }); it('should handle two data points', () => { const data: TimeSeries = [ { timestamp: 1000, value: 10 }, { timestamp: 2000, value: 20 }, ]; const trend = analyzer.fitTrendLine(data); expect(trend.slope).toBeCloseTo(0.01, 5); // slope = 10/1000 expect(trend.rSquared).toBeCloseTo(1, 5); // Perfect fit with 2 points }); }); describe('Forecasting', () => { it('should forecast future value based on trend', () => { const data: TimeSeries = [ { timestamp: 1000, value: 10 }, { timestamp: 2000, value: 20 }, { timestamp: 3000, value: 30 }, ]; const forecast = analyzer.forecastValue(data, 1000); // 1 second ahead expect(forecast.value).toBeCloseTo(40, 0); // Should predict ~40 expect(forecast.confidence).toBe(0.95); expect(forecast.lowerBound).toBeLessThanOrEqual(forecast.value); expect(forecast.upperBound).toBeGreaterThanOrEqual(forecast.value); }); it('should handle forecasting with stable data', () => { const data: TimeSeries = [ { timestamp: 1000, value: 25 }, { timestamp: 2000, value: 25 }, { timestamp: 3000, value: 25 }, ]; const forecast = analyzer.forecastValue(data, 1000); expect(forecast.value).toBeCloseTo(25, 1); // Should stay around 25 }); it('should throw error for insufficient data', () => { const data: TimeSeries = [{ timestamp: 1000, value: 10 }]; expect(() => analyzer.forecastValue(data, 1000)).toThrow('Need at least 2 data points'); }); it('should throw error for negative horizon', () => { const data: TimeSeries = [ { timestamp: 1000, value: 10 }, { timestamp: 2000, value: 20 }, ]; expect(() => analyzer.forecastValue(data, -1000)).toThrow('Horizon must be non-negative'); }); it('should forecast zero horizon (current)', () => { const data: TimeSeries = [ { timestamp: 1000, value: 10 }, { timestamp: 2000, value: 20 }, ]; const forecast = analyzer.forecastValue(data, 0); expect(forecast.value).toBeCloseTo(20, 1); // Should be near last value }); }); describe('Change Point Detection', () => { it('should detect change point in time series', () => { const data: TimeSeries = [ // Stable at 10 { timestamp: 1000, value: 10 }, { timestamp: 2000, value: 10 }, { timestamp: 3000, value: 10 }, { timestamp: 4000, value: 10 }, { timestamp: 5000, value: 10 }, { timestamp: 5500, value: 10 }, // Added one more // Big shift to 50 { timestamp: 6000, value: 50 }, { timestamp: 7000, value: 50 }, { timestamp: 8000, value: 50 }, { timestamp: 9000, value: 50 }, { timestamp: 10000, value: 50 }, { timestamp: 11000, value: 50 }, ]; const changePoint = analyzer.detectChangePoint(data); expect(changePoint).not.toBeNull(); expect(changePoint!.meanBefore).toBeCloseTo(10, 1); expect(changePoint!.meanAfter).toBeCloseTo(50, 1); }); it('should return null for insufficient data', () => { const data: TimeSeries = [ { timestamp: 1000, value: 10 }, { timestamp: 2000, value: 20 }, ]; const changePoint = analyzer.detectChangePoint(data); expect(changePoint).toBeNull(); }); it('should return null for stable data', () => { // Use deterministic data with very small variance to avoid random test failures const data: TimeSeries = []; for (let i = 0; i < 20; i++) { // Small oscillation pattern (deterministic, not random) const variance = (i % 2 === 0) ? 0.5 : -0.5; data.push({ timestamp: i * 1000, value: 25 + variance }); // Stable ~25 } const changePoint = analyzer.detectChangePoint(data); expect(changePoint).toBeNull(); // No significant change }); it('should detect gradual trend vs sudden change', () => { const gradual: TimeSeries = []; for (let i = 0; i < 20; i++) { gradual.push({ timestamp: i * 1000, value: 10 + i * 0.5 + Math.random() * 0.5 }); } const changePoint = analyzer.detectChangePoint(gradual); expect(changePoint === null || changePoint !== null).toBe(true); // Gradual trend, not sudden change // Gradual trend may or may not trigger depending on noise }); it('should respect minimum segment size', () => { const data: TimeSeries = [ { timestamp: 1000, value: 10 }, { timestamp: 2000, value: 10 }, { timestamp: 3000, value: 10 }, { timestamp: 4000, value: 20 }, // Change { timestamp: 5000, value: 20 }, { timestamp: 6000, value: 20 }, ]; const changePoint = analyzer.detectChangePoint(data, 2); expect(changePoint).not.toBeNull(); // Should detect with minSegment=2 }); }); describe('Edge Cases', () => { it('should handle extreme value ranges', () => { const testCases = [ { name: 'very small values', samples: [0.001, 0.002, 0.003], windowSize: 2, expectedAtIndex: 1, expectedValue: 0.0015, precision: 6, }, { name: 'very large values', samples: [1e9, 2e9, 3e9], windowSize: 2, expectedAtIndex: 1, expectedValue: 1.5e9, precision: 0, }, ]; for (const { samples, windowSize, expectedAtIndex, expectedValue, precision } of testCases) { const avg = analyzer.calculateMovingAverage(samples, windowSize); expect(avg).toHaveLength(3); expect(avg[expectedAtIndex]).toBeCloseTo(expectedValue, precision); } }); it('should handle negative values', () => { const data: TimeSeries = [ { timestamp: 1000, value: -10 }, { timestamp: 2000, value: -20 }, { timestamp: 3000, value: -30 }, ]; const trend = analyzer.fitTrendLine(data); expect(trend.slope).toBeLessThan(0); // Negative slope }); it('should handle mixed positive/negative values', () => { const samples = [-10, 5, -3, 8, -2]; const outliers = analyzer.detectOutliers(samples, 'zscore'); expect(outliers).toHaveLength(5); expect(outliers.some(o => o === false)).toBe(true); // Some non-outliers }); }); describe('Performance', () => { it('should handle large datasets efficiently', () => { const largeDataset = Array.from({ length: 10000 }, (_, i) => i); const start = performance.now(); const result = analyzer.calculateMovingAverage(largeDataset, 100); const duration = performance.now() - start; expect(result).toHaveLength(10000); expect(duration).toBeLessThan(500); // Should complete in reasonable time (allows for CI variance) }); it('should handle trend analysis on large time series', () => { const largeTimeSeries: TimeSeries = Array.from({ length: 5000 }, (_, i) => ({ timestamp: i * 1000, value: i + Math.random() * 10, })); const start = performance.now(); const trend = analyzer.fitTrendLine(largeTimeSeries); const duration = performance.now() - start; expect(trend.slope).toBeGreaterThan(0); expect(duration).toBeLessThan(200); // Should complete in reasonable time (allows for CI variance) }); }); });