/**
* @fileoverview Tests for QualityMetrics aggregate
*/
import { describe, it, expect, beforeEach } from 'vitest';
import { QualityMetrics } from '../quality-metrics.aggregate.js';
import type {
CreateQualityMetricsParams,
MetricConfiguration,
UpdateMetricConfigParams,
RecordMeasurementParams,
MetricHistoryEntry,
} from '../quality-metrics.types.js';
import type { ProjectKey, GraphQLNodeId } from '../../../../types/branded.js';
import { MetricShortcode, MetricKey } from '../../../../models/metrics.js';
import { ThresholdValue } from '../../../value-objects/threshold-value.js';
import { MetricValue } from '../../../value-objects/metric-value.js';
describe('QualityMetrics Aggregate', () => {
let validParams: CreateQualityMetricsParams;
let projectKey: ProjectKey;
let repositoryId: GraphQLNodeId;
let configuration: MetricConfiguration;
beforeEach(() => {
projectKey = 'test-project' as ProjectKey;
repositoryId = 'repo-456' as GraphQLNodeId;
configuration = {
name: 'Line Coverage',
description: 'Percentage of lines covered by tests',
shortcode: MetricShortcode.LCV,
metricKey: MetricKey.AGGREGATE,
unit: '%',
minAllowed: 0,
maxAllowed: 100,
positiveDirection: 'UPWARD',
isReported: true,
isThresholdEnforced: true,
threshold: ThresholdValue.createPercentage(80),
};
validParams = {
projectKey,
repositoryId,
configuration,
};
});
describe('create', () => {
it('should create quality metrics with valid parameters', () => {
const metrics = QualityMetrics.create(validParams);
expect(metrics.projectKey).toBe(projectKey);
expect(metrics.repositoryId).toBe(repositoryId);
expect(metrics.configuration).toEqual(configuration);
expect(metrics.currentValue).toBeNull();
expect(metrics.history).toHaveLength(0);
expect(metrics.domainEvents).toHaveLength(1);
expect(metrics.domainEvents[0].eventType).toBe('QualityMetricsCreated');
});
it('should create metrics with current value', () => {
const currentValue = MetricValue.create(85.5, '%');
const params = {
...validParams,
currentValue,
};
const metrics = QualityMetrics.create(params);
expect(metrics.currentValue).toEqual(currentValue);
});
it('should create metrics with history', () => {
const history: MetricHistoryEntry[] = [
{
value: MetricValue.create(75, '%'),
threshold: ThresholdValue.createPercentage(80),
thresholdStatus: 'FAILING',
commitOid: 'commit1',
recordedAt: new Date('2024-01-01'),
},
];
const params = {
...validParams,
history,
};
const metrics = QualityMetrics.create(params);
expect(metrics.history).toHaveLength(1);
expect(metrics.history[0]).toEqual(history[0]);
});
it('should create composite ID from project key, metric key, and shortcode', () => {
const metrics = QualityMetrics.create(validParams);
// The ID should be a composite of projectKey:metricKey:shortcode
expect(metrics.id).toBe(`${projectKey}:${MetricKey.AGGREGATE}:${MetricShortcode.LCV}`);
});
it('should throw error for empty metric name', () => {
const params = {
...validParams,
configuration: {
...configuration,
name: '',
},
};
expect(() => QualityMetrics.create(params)).toThrow('Metric name cannot be empty');
});
it('should throw error for whitespace-only metric name', () => {
const params = {
...validParams,
configuration: {
...configuration,
name: ' ',
},
};
expect(() => QualityMetrics.create(params)).toThrow('Metric name cannot be empty');
});
it('should throw error when min allowed >= max allowed', () => {
const params = {
...validParams,
configuration: {
...configuration,
minAllowed: 100,
maxAllowed: 100,
},
};
expect(() => QualityMetrics.create(params)).toThrow(
'Min allowed value must be less than max allowed value'
);
});
it('should create metrics without threshold', () => {
const params = {
...validParams,
configuration: {
...configuration,
threshold: null,
},
};
const metrics = QualityMetrics.create(params);
expect(metrics.configuration.threshold).toBeNull();
});
it('should emit QualityMetricsCreated event with correct payload', () => {
const metrics = QualityMetrics.create(validParams);
const events = metrics.domainEvents;
expect(events).toHaveLength(1);
const event = events[0];
expect(event.eventType).toBe('QualityMetricsCreated');
expect(event.payload).toEqual({
projectKey,
metricKey: MetricKey.AGGREGATE,
shortcode: MetricShortcode.LCV,
threshold: 80,
});
});
});
describe('fromPersistence', () => {
it('should recreate metrics from persistence without events', () => {
const currentValue = MetricValue.create(85.5, '%');
const history: MetricHistoryEntry[] = [
{
value: MetricValue.create(80, '%'),
threshold: ThresholdValue.createPercentage(80),
thresholdStatus: 'PASSING',
commitOid: 'commit1',
recordedAt: new Date('2024-01-01'),
},
];
const persistenceData = {
id: `${projectKey}:${MetricKey.AGGREGATE}:${MetricShortcode.LCV}`,
projectKey,
repositoryId,
configuration,
currentValue,
history,
lastUpdated: new Date('2024-01-02'),
};
const metrics = QualityMetrics.fromPersistence(persistenceData);
expect(metrics.projectKey).toBe(projectKey);
expect(metrics.currentValue).toEqual(currentValue);
expect(metrics.history).toEqual(history);
expect(metrics.domainEvents).toHaveLength(0); // No events when loading
expect(metrics.lastUpdated).toEqual(persistenceData.lastUpdated);
});
});
describe('threshold management', () => {
describe('updateThreshold', () => {
it('should update threshold to new value', () => {
const metrics = QualityMetrics.create(validParams);
metrics.clearEvents();
const newThreshold = ThresholdValue.createPercentage(90);
metrics.updateThreshold(newThreshold);
expect(metrics.configuration.threshold).toEqual(newThreshold);
expect(metrics.domainEvents).toHaveLength(2); // MetricThresholdUpdated + AggregateModified
expect(metrics.domainEvents[0].eventType).toBe('MetricThresholdUpdated');
expect(metrics.domainEvents[0].payload).toEqual({
oldThreshold: 80,
newThreshold: 90,
unit: '%',
});
});
it('should allow removing threshold', () => {
const metrics = QualityMetrics.create(validParams);
metrics.clearEvents();
metrics.updateThreshold(null);
expect(metrics.configuration.threshold).toBeNull();
expect(metrics.domainEvents[0].payload).toEqual({
oldThreshold: 80,
newThreshold: undefined,
unit: '%',
});
});
it('should throw error for mismatched units', () => {
const metrics = QualityMetrics.create(validParams);
const wrongThreshold = ThresholdValue.create(80, 'ms', 0, 1000);
expect(() => metrics.updateThreshold(wrongThreshold)).toThrow(
"Threshold unit 'ms' does not match metric unit '%'"
);
});
it('should throw error for threshold outside allowed range', () => {
const metrics = QualityMetrics.create(validParams);
const outOfRangeThreshold = ThresholdValue.create(150, '%', 0, 200);
expect(() => metrics.updateThreshold(outOfRangeThreshold)).toThrow(
'Threshold value is outside allowed range for this metric'
);
});
it('should update lastUpdated timestamp', async () => {
const metrics = QualityMetrics.create(validParams);
const originalLastUpdated = metrics.lastUpdated;
// Wait a bit to ensure timestamp difference
await new Promise((resolve) => setTimeout(resolve, 10));
metrics.updateThreshold(ThresholdValue.createPercentage(85));
expect(metrics.lastUpdated.getTime()).toBeGreaterThan(originalLastUpdated.getTime());
});
});
});
describe('configuration management', () => {
describe('updateConfiguration', () => {
it('should update metric name', () => {
const metrics = QualityMetrics.create(validParams);
metrics.clearEvents();
const params: UpdateMetricConfigParams = {
name: 'Updated Line Coverage',
};
metrics.updateConfiguration(params);
expect(metrics.configuration.name).toBe('Updated Line Coverage');
expect(metrics.domainEvents[0].eventType).toBe('MetricConfigurationUpdated');
expect(metrics.domainEvents[0].payload).toEqual(params);
});
it('should trim metric name', () => {
const metrics = QualityMetrics.create(validParams);
metrics.updateConfiguration({ name: ' New Name ' });
expect(metrics.configuration.name).toBe('New Name');
});
it('should throw error for empty name update', () => {
const metrics = QualityMetrics.create(validParams);
expect(() => metrics.updateConfiguration({ name: '' })).toThrow(
'Metric name cannot be empty'
);
});
it('should update description', () => {
const metrics = QualityMetrics.create(validParams);
metrics.updateConfiguration({ description: 'New description' });
expect(metrics.configuration.description).toBe('New description');
});
it('should update isReported flag', () => {
const metrics = QualityMetrics.create(validParams);
metrics.updateConfiguration({ isReported: false });
expect(metrics.configuration.isReported).toBe(false);
});
it('should update isThresholdEnforced flag', () => {
const metrics = QualityMetrics.create(validParams);
metrics.updateConfiguration({ isThresholdEnforced: false });
expect(metrics.configuration.isThresholdEnforced).toBe(false);
});
it('should update multiple fields at once', () => {
const metrics = QualityMetrics.create(validParams);
metrics.clearEvents();
const params: UpdateMetricConfigParams = {
name: 'New Name',
description: 'New description',
isReported: false,
isThresholdEnforced: false,
};
metrics.updateConfiguration(params);
expect(metrics.configuration.name).toBe('New Name');
expect(metrics.configuration.description).toBe('New description');
expect(metrics.configuration.isReported).toBe(false);
expect(metrics.configuration.isThresholdEnforced).toBe(false);
expect(metrics.domainEvents).toHaveLength(2);
});
it('should not emit event when no changes made', () => {
const metrics = QualityMetrics.create(validParams);
metrics.clearEvents();
// Update with same values
metrics.updateConfiguration({
name: configuration.name,
isReported: configuration.isReported,
});
expect(metrics.domainEvents).toHaveLength(0);
});
it('should update lastUpdated timestamp on change', async () => {
const metrics = QualityMetrics.create(validParams);
const originalLastUpdated = metrics.lastUpdated;
await new Promise((resolve) => setTimeout(resolve, 10));
metrics.updateConfiguration({ name: 'New Name' });
expect(metrics.lastUpdated.getTime()).toBeGreaterThan(originalLastUpdated.getTime());
});
});
});
describe('measurement recording', () => {
describe('recordMeasurement', () => {
it('should record a new measurement', () => {
const metrics = QualityMetrics.create(validParams);
metrics.clearEvents();
const params: RecordMeasurementParams = {
value: 85.5,
commitOid: 'abc123',
};
metrics.recordMeasurement(params);
expect(metrics.currentValue).not.toBeNull();
expect(metrics.currentValue?.value).toBe(85.5);
expect(metrics.currentValue?.unit).toBe('%');
expect(metrics.history).toHaveLength(1);
expect(metrics.history[0].commitOid).toBe('abc123');
expect(metrics.domainEvents[0].eventType).toBe('MeasurementRecorded');
});
it('should use provided timestamp', () => {
const metrics = QualityMetrics.create(validParams);
const customDate = new Date('2024-01-15');
metrics.recordMeasurement({
value: 85.5,
commitOid: 'abc123',
measuredAt: customDate,
});
expect(metrics.history[0].recordedAt).toEqual(customDate);
expect(metrics.currentValue?.measuredAt).toEqual(customDate);
});
it('should throw error for value outside allowed range', () => {
const metrics = QualityMetrics.create(validParams);
expect(() =>
metrics.recordMeasurement({
value: 150,
commitOid: 'abc123',
})
).toThrow('Value 150 is outside allowed range [0, 100]');
expect(() =>
metrics.recordMeasurement({
value: -10,
commitOid: 'abc123',
})
).toThrow('Value -10 is outside allowed range [0, 100]');
});
it('should update threshold status in history', () => {
const metrics = QualityMetrics.create(validParams);
// Record passing value
metrics.recordMeasurement({
value: 85,
commitOid: 'commit1',
});
expect(metrics.history[0].thresholdStatus).toBe('PASSING');
// Record failing value
metrics.recordMeasurement({
value: 75,
commitOid: 'commit2',
});
expect(metrics.history[1].thresholdStatus).toBe('FAILING');
});
it('should handle metrics without threshold', () => {
const params = {
...validParams,
configuration: {
...configuration,
threshold: null,
},
};
const metrics = QualityMetrics.create(params);
metrics.recordMeasurement({
value: 85,
commitOid: 'commit1',
});
expect(metrics.history[0].thresholdStatus).toBe('UNKNOWN');
});
it('should trim history to max entries', () => {
const metrics = QualityMetrics.create(validParams);
// Record more than MAX_HISTORY_ENTRIES (100)
for (let i = 0; i < 105; i++) {
metrics.recordMeasurement({
value: 80 + (i % 20),
commitOid: `commit${i}`,
});
}
expect(metrics.history).toHaveLength(100);
// First entries should be removed
expect(metrics.history[0].commitOid).toBe('commit5');
expect(metrics.history[99].commitOid).toBe('commit104');
});
it('should emit MeasurementRecorded event with correct payload', () => {
const metrics = QualityMetrics.create(validParams);
metrics.clearEvents();
metrics.recordMeasurement({
value: 85.5,
commitOid: 'abc123',
});
const event = metrics.domainEvents[0];
expect(event.eventType).toBe('MeasurementRecorded');
expect(event.payload).toEqual({
value: 85.5,
unit: '%',
commitOid: 'abc123',
thresholdStatus: 'PASSING',
});
});
});
});
describe('compliance evaluation', () => {
it('should evaluate as compliant when value meets upward threshold', () => {
const metrics = QualityMetrics.create(validParams);
metrics.recordMeasurement({
value: 85,
commitOid: 'commit1',
});
expect(metrics.isCompliant).toBe(true);
expect(metrics.thresholdStatus).toBe('PASSING');
});
it('should evaluate as non-compliant when value fails upward threshold', () => {
const metrics = QualityMetrics.create(validParams);
metrics.recordMeasurement({
value: 75,
commitOid: 'commit1',
});
expect(metrics.isCompliant).toBe(false);
expect(metrics.thresholdStatus).toBe('FAILING');
});
it('should evaluate downward metrics correctly', () => {
const params = {
...validParams,
configuration: {
...configuration,
shortcode: MetricShortcode.DDP, // Duplicate code percentage
positiveDirection: 'DOWNWARD' as const,
threshold: ThresholdValue.createPercentage(10), // Max 10% duplication
},
};
const metrics = QualityMetrics.create(params);
// 5% duplication - should pass
metrics.recordMeasurement({
value: 5,
commitOid: 'commit1',
});
expect(metrics.isCompliant).toBe(true);
// 15% duplication - should fail
metrics.recordMeasurement({
value: 15,
commitOid: 'commit2',
});
expect(metrics.isCompliant).toBe(false);
});
it('should be compliant when no threshold set', () => {
const params = {
...validParams,
configuration: {
...configuration,
threshold: null,
},
};
const metrics = QualityMetrics.create(params);
metrics.recordMeasurement({
value: 50,
commitOid: 'commit1',
});
expect(metrics.isCompliant).toBe(true);
expect(metrics.thresholdStatus).toBe('UNKNOWN');
});
it('should be compliant when no value recorded', () => {
const metrics = QualityMetrics.create(validParams);
expect(metrics.isCompliant).toBe(true);
expect(metrics.thresholdStatus).toBe('UNKNOWN');
});
describe('evaluateCompliance', () => {
it('should evaluate arbitrary values', () => {
const metrics = QualityMetrics.create(validParams);
expect(metrics.evaluateCompliance(90)).toBe(true);
expect(metrics.evaluateCompliance(70)).toBe(false);
expect(metrics.evaluateCompliance(80)).toBe(true); // Exactly at threshold
});
it('should return true when no threshold', () => {
const params = {
...validParams,
configuration: {
...configuration,
threshold: null,
},
};
const metrics = QualityMetrics.create(params);
expect(metrics.evaluateCompliance(50)).toBe(true);
expect(metrics.evaluateCompliance(0)).toBe(true);
});
});
});
describe('trend analysis', () => {
describe('getTrend', () => {
it('should return null with insufficient data', () => {
const metrics = QualityMetrics.create(validParams);
expect(metrics.getTrend()).toBeNull();
metrics.recordMeasurement({
value: 80,
commitOid: 'commit1',
});
expect(metrics.getTrend()).toBeNull(); // Still only 1 entry
});
it('should calculate improving trend for upward metrics', () => {
const metrics = QualityMetrics.create(validParams);
const now = new Date();
const daysAgo = (days: number) => {
const date = new Date(now);
date.setDate(date.getDate() - days);
return date;
};
// Record measurements over time
metrics.recordMeasurement({
value: 70,
commitOid: 'commit1',
measuredAt: daysAgo(20),
});
metrics.recordMeasurement({
value: 75,
commitOid: 'commit2',
measuredAt: daysAgo(10),
});
metrics.recordMeasurement({
value: 80,
commitOid: 'commit3',
measuredAt: daysAgo(5),
});
metrics.recordMeasurement({
value: 85,
commitOid: 'commit4',
measuredAt: now,
});
const trend = metrics.getTrend(30);
expect(trend).not.toBeNull();
expect(trend?.direction).toBe('IMPROVING');
expect(trend?.changePercentage).toBeCloseTo(21.43, 1); // (85-70)/70 * 100
expect(trend?.periodDays).toBe(30);
});
it('should calculate degrading trend for upward metrics', () => {
const metrics = QualityMetrics.create(validParams);
const now = new Date();
const daysAgo = (days: number) => {
const date = new Date(now);
date.setDate(date.getDate() - days);
return date;
};
metrics.recordMeasurement({
value: 90,
commitOid: 'commit1',
measuredAt: daysAgo(20),
});
metrics.recordMeasurement({
value: 80,
commitOid: 'commit2',
measuredAt: now,
});
const trend = metrics.getTrend(30);
expect(trend?.direction).toBe('DEGRADING');
expect(trend?.changePercentage).toBeCloseTo(-11.11, 1);
});
it('should calculate stable trend for minimal changes', () => {
const metrics = QualityMetrics.create(validParams);
const now = new Date();
const daysAgo = (days: number) => {
const date = new Date(now);
date.setDate(date.getDate() - days);
return date;
};
metrics.recordMeasurement({
value: 80,
commitOid: 'commit1',
measuredAt: daysAgo(20),
});
metrics.recordMeasurement({
value: 80.5,
commitOid: 'commit2',
measuredAt: now,
});
const trend = metrics.getTrend(30);
expect(trend?.direction).toBe('STABLE'); // Less than 1% change
});
it('should handle downward metrics correctly', () => {
const params = {
...validParams,
configuration: {
...configuration,
positiveDirection: 'DOWNWARD' as const,
},
};
const metrics = QualityMetrics.create(params);
const now = new Date();
const daysAgo = (days: number) => {
const date = new Date(now);
date.setDate(date.getDate() - days);
return date;
};
// For downward metrics, decreasing values are improving
metrics.recordMeasurement({
value: 20,
commitOid: 'commit1',
measuredAt: daysAgo(20),
});
metrics.recordMeasurement({
value: 15,
commitOid: 'commit2',
measuredAt: now,
});
const trend = metrics.getTrend(30);
expect(trend?.direction).toBe('IMPROVING'); // Decreasing is good for downward metrics
});
it('should respect period parameter', () => {
const metrics = QualityMetrics.create(validParams);
const now = new Date();
const daysAgo = (days: number) => {
const date = new Date(now);
date.setDate(date.getDate() - days);
return date;
};
// Old measurement outside period
metrics.recordMeasurement({
value: 60,
commitOid: 'commit1',
measuredAt: daysAgo(40),
});
// Recent measurements
metrics.recordMeasurement({
value: 80,
commitOid: 'commit2',
measuredAt: daysAgo(20),
});
metrics.recordMeasurement({
value: 85,
commitOid: 'commit3',
measuredAt: now,
});
const trend = metrics.getTrend(30); // Only last 30 days
// Should only consider last two measurements
expect(trend?.changePercentage).toBeCloseTo(6.25, 1); // (85-80)/80 * 100
});
});
});
describe('getters', () => {
it('should return immutable configuration', () => {
const metrics = QualityMetrics.create(validParams);
const config = metrics.configuration;
// Verify it's a copy
expect(config).not.toBe(configuration);
expect(config).toEqual(configuration);
});
it('should return history as array', () => {
const metrics = QualityMetrics.create(validParams);
metrics.recordMeasurement({
value: 80,
commitOid: 'commit1',
});
metrics.recordMeasurement({
value: 85,
commitOid: 'commit2',
});
const history = metrics.history;
expect(Array.isArray(history)).toBe(true);
expect(history).toHaveLength(2);
});
});
describe('toPersistence', () => {
it('should convert to persistence format', () => {
const metrics = QualityMetrics.create(validParams);
metrics.recordMeasurement({
value: 85.5,
commitOid: 'abc123',
});
const persistence = metrics.toPersistence();
expect(persistence).toEqual({
id: `${projectKey}:${MetricKey.AGGREGATE}:${MetricShortcode.LCV}`,
projectKey,
repositoryId,
configuration,
currentValue: expect.objectContaining({
value: 85.5,
unit: '%',
}),
history: expect.arrayContaining([
expect.objectContaining({
value: expect.objectContaining({ value: 85.5 }),
commitOid: 'abc123',
}),
]),
lastUpdated: expect.any(Date),
});
});
it('should preserve all data through persistence round-trip', () => {
const metrics = QualityMetrics.create(validParams);
metrics.recordMeasurement({
value: 75,
commitOid: 'commit1',
});
metrics.updateThreshold(ThresholdValue.createPercentage(70));
metrics.recordMeasurement({
value: 85,
commitOid: 'commit2',
});
const persistence = metrics.toPersistence();
const reconstructed = QualityMetrics.fromPersistence(persistence);
expect(reconstructed.projectKey).toBe(metrics.projectKey);
expect(reconstructed.configuration).toEqual(metrics.configuration);
expect(reconstructed.currentValue?.value).toBe(85);
expect(reconstructed.history).toHaveLength(2);
expect(reconstructed.lastUpdated).toEqual(metrics.lastUpdated);
});
});
describe('domain events', () => {
it('should accumulate multiple events', () => {
const metrics = QualityMetrics.create(validParams);
metrics.clearEvents();
metrics.updateThreshold(ThresholdValue.createPercentage(90));
metrics.recordMeasurement({
value: 95,
commitOid: 'commit1',
});
metrics.updateConfiguration({ name: 'New Name' });
// Each operation emits 2 events (operation + AggregateModified)
expect(metrics.domainEvents).toHaveLength(6);
const eventTypes = metrics.domainEvents.map((e) => e.eventType);
expect(eventTypes).toEqual([
'MetricThresholdUpdated',
'AggregateModified',
'MeasurementRecorded',
'AggregateModified',
'MetricConfigurationUpdated',
'AggregateModified',
]);
});
});
describe('edge cases', () => {
it('should handle all metric shortcodes', () => {
const shortcodes = [
MetricShortcode.LCV,
MetricShortcode.BCV,
MetricShortcode.DCV,
MetricShortcode.DDP,
MetricShortcode.SCV,
MetricShortcode.TCV,
MetricShortcode.CMP,
];
shortcodes.forEach((shortcode) => {
const params = {
...validParams,
configuration: {
...configuration,
shortcode,
},
};
const metrics = QualityMetrics.create(params);
expect(metrics.configuration.shortcode).toBe(shortcode);
});
});
it('should handle all metric keys', () => {
const metricKeys = [
MetricKey.AGGREGATE,
MetricKey.PYTHON,
MetricKey.JAVASCRIPT,
MetricKey.TYPESCRIPT,
MetricKey.GO,
MetricKey.JAVA,
MetricKey.RUBY,
MetricKey.RUST,
];
metricKeys.forEach((metricKey) => {
const params = {
...validParams,
configuration: {
...configuration,
metricKey,
},
};
const metrics = QualityMetrics.create(params);
expect(metrics.configuration.metricKey).toBe(metricKey);
});
});
it('should handle negative value ranges', () => {
const params = {
...validParams,
configuration: {
...configuration,
minAllowed: -100,
maxAllowed: 100,
threshold: ThresholdValue.create(0, 'delta', -100, 100),
},
};
const metrics = QualityMetrics.create(params);
metrics.recordMeasurement({
value: -50,
commitOid: 'commit1',
});
expect(metrics.currentValue?.value).toBe(-50);
});
it('should handle exact threshold values', () => {
const metrics = QualityMetrics.create(validParams);
// Record value exactly at threshold
metrics.recordMeasurement({
value: 80,
commitOid: 'commit1',
});
expect(metrics.isCompliant).toBe(true); // Threshold is inclusive
expect(metrics.thresholdStatus).toBe('PASSING');
});
});
});