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
text/typescript
/**
* 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)
});
});
});