Add 13 new tests covering the worker-to-worker comparison feature:
- Null handling for non-existent workers
- Raw and percentage difference calculations
- Zero division handling
- Per-metric winner determination
- Tie detection for equal metrics
- Overall winner scoring
- Lower-is-better metrics (completion time, error rate, cost)
- Efficiency score comparison
- Time window filtering
- Floating point epsilon comparison
The comparison feature was implemented in commit f307524 but lacked test coverage.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Bead-Id: bd-4gt
1040 lines
38 KiB
TypeScript
1040 lines
38 KiB
TypeScript
/**
|
|
* Worker Analytics Tests
|
|
*/
|
|
|
|
import { describe, it, expect, beforeEach } from 'vitest';
|
|
import { WorkerAnalytics } from './workerAnalytics.js';
|
|
import { MetricAccumulator, INSTRUMENT_NAMES, resolveInstrumentName } from './workerAnalytics.js';
|
|
import { LogEvent } from './types.js';
|
|
import { CostTracker } from './tui/utils/costTracking.js';
|
|
|
|
describe('WorkerAnalytics', () => {
|
|
let analytics: WorkerAnalytics;
|
|
let costTracker: CostTracker;
|
|
const baseTime = Date.now();
|
|
|
|
beforeEach(() => {
|
|
costTracker = new CostTracker();
|
|
analytics = new WorkerAnalytics(costTracker, 3600000); // 1 hour snapshots
|
|
});
|
|
|
|
describe('Basic Event Processing', () => {
|
|
it('should process events and track worker', () => {
|
|
const event: LogEvent = {
|
|
ts: baseTime,
|
|
worker: 'w-test-1',
|
|
level: 'info',
|
|
msg: 'Starting work',
|
|
};
|
|
|
|
analytics.processEvent(event);
|
|
|
|
const metrics = analytics.getWorkerMetrics('w-test-1');
|
|
expect(metrics).toBeDefined();
|
|
expect(metrics?.workerId).toBe('w-test-1');
|
|
expect(metrics?.totalEvents).toBe(1);
|
|
});
|
|
|
|
it('should track multiple workers', () => {
|
|
const events: LogEvent[] = [
|
|
{ ts: baseTime, worker: 'w-1', level: 'info', msg: 'Event 1' },
|
|
{ ts: baseTime + 1000, worker: 'w-2', level: 'info', msg: 'Event 2' },
|
|
{ ts: baseTime + 2000, worker: 'w-1', level: 'info', msg: 'Event 3' },
|
|
];
|
|
|
|
events.forEach(e => analytics.processEvent(e));
|
|
|
|
const allMetrics = analytics.getAllWorkerMetrics();
|
|
expect(allMetrics).toHaveLength(2);
|
|
|
|
const w1Metrics = analytics.getWorkerMetrics('w-1');
|
|
const w2Metrics = analytics.getWorkerMetrics('w-2');
|
|
|
|
expect(w1Metrics?.totalEvents).toBe(2);
|
|
expect(w2Metrics?.totalEvents).toBe(1);
|
|
});
|
|
});
|
|
|
|
describe('Bead Completion Tracking', () => {
|
|
it('should track bead completions', () => {
|
|
const events: LogEvent[] = [
|
|
{
|
|
ts: baseTime,
|
|
worker: 'w-1',
|
|
level: 'info',
|
|
msg: 'Starting bead bd-123',
|
|
bead: 'bd-123',
|
|
},
|
|
{
|
|
ts: baseTime + 5000,
|
|
worker: 'w-1',
|
|
level: 'info',
|
|
msg: 'Bead completed successfully',
|
|
bead: 'bd-123',
|
|
},
|
|
{
|
|
ts: baseTime + 10000,
|
|
worker: 'w-1',
|
|
level: 'info',
|
|
msg: 'Working on bd-456',
|
|
bead: 'bd-456',
|
|
},
|
|
{
|
|
ts: baseTime + 18000,
|
|
worker: 'w-1',
|
|
level: 'info',
|
|
msg: 'Task finished',
|
|
bead: 'bd-456',
|
|
},
|
|
];
|
|
|
|
events.forEach(e => analytics.processEvent(e));
|
|
|
|
const metrics = analytics.getWorkerMetrics('w-1');
|
|
expect(metrics?.beadsCompleted).toBe(2);
|
|
expect(metrics?.avgCompletionTimeMs).toBeGreaterThan(0);
|
|
});
|
|
|
|
it('should calculate beads per hour', () => {
|
|
const oneHour = 3600000;
|
|
const events: LogEvent[] = [];
|
|
|
|
// Simulate 10 beads completed over 1 hour
|
|
for (let i = 0; i < 10; i++) {
|
|
events.push({
|
|
ts: baseTime + (i * oneHour / 10),
|
|
worker: 'w-1',
|
|
level: 'info',
|
|
msg: `Starting bead bd-${i}`,
|
|
bead: `bd-${i}`,
|
|
});
|
|
events.push({
|
|
ts: baseTime + (i * oneHour / 10) + 1000,
|
|
worker: 'w-1',
|
|
level: 'info',
|
|
msg: 'Completed',
|
|
bead: `bd-${i}`,
|
|
});
|
|
}
|
|
|
|
events.forEach(e => analytics.processEvent(e));
|
|
|
|
const metrics = analytics.getWorkerMetrics('w-1', {
|
|
startTime: baseTime,
|
|
endTime: baseTime + oneHour,
|
|
});
|
|
|
|
expect(metrics?.beadsCompleted).toBe(10);
|
|
expect(metrics?.beadsPerHour).toBeCloseTo(10, 0);
|
|
});
|
|
|
|
it('should calculate average completion time', () => {
|
|
const events: LogEvent[] = [
|
|
{ ts: baseTime, worker: 'w-1', level: 'info', msg: 'Start', bead: 'bd-1' },
|
|
{ ts: baseTime + 5000, worker: 'w-1', level: 'info', msg: 'Completed', bead: 'bd-1' },
|
|
{ ts: baseTime + 10000, worker: 'w-1', level: 'info', msg: 'Start', bead: 'bd-2' },
|
|
{ ts: baseTime + 20000, worker: 'w-1', level: 'info', msg: 'Done', bead: 'bd-2' },
|
|
];
|
|
|
|
events.forEach(e => analytics.processEvent(e));
|
|
|
|
const metrics = analytics.getWorkerMetrics('w-1');
|
|
// Average: (5000 + 10000) / 2 = 7500
|
|
expect(metrics?.avgCompletionTimeMs).toBe(7500);
|
|
});
|
|
});
|
|
|
|
describe('Error Tracking', () => {
|
|
it('should track error count', () => {
|
|
const events: LogEvent[] = [
|
|
{ ts: baseTime, worker: 'w-1', level: 'error', msg: 'Error 1' },
|
|
{ ts: baseTime + 1000, worker: 'w-1', level: 'info', msg: 'Normal event' },
|
|
{ ts: baseTime + 2000, worker: 'w-1', level: 'error', msg: 'Error 2' },
|
|
];
|
|
|
|
events.forEach(e => analytics.processEvent(e));
|
|
|
|
const metrics = analytics.getWorkerMetrics('w-1');
|
|
expect(metrics?.errorCount).toBe(2);
|
|
});
|
|
|
|
it('should calculate error rate', () => {
|
|
const events: LogEvent[] = [
|
|
{ ts: baseTime, worker: 'w-1', level: 'info', msg: 'Start', bead: 'bd-1' },
|
|
{ ts: baseTime + 1000, worker: 'w-1', level: 'error', msg: 'Error!' },
|
|
{ ts: baseTime + 2000, worker: 'w-1', level: 'info', msg: 'Completed', bead: 'bd-1' },
|
|
{ ts: baseTime + 3000, worker: 'w-1', level: 'info', msg: 'Start', bead: 'bd-2' },
|
|
{ ts: baseTime + 4000, worker: 'w-1', level: 'info', msg: 'Done', bead: 'bd-2' },
|
|
];
|
|
|
|
events.forEach(e => analytics.processEvent(e));
|
|
|
|
const metrics = analytics.getWorkerMetrics('w-1');
|
|
// 1 error, 2 beads = 0.5 error rate
|
|
expect(metrics?.errorRate).toBe(0.5);
|
|
});
|
|
|
|
it('should handle zero beads (no error rate)', () => {
|
|
const events: LogEvent[] = [
|
|
{ ts: baseTime, worker: 'w-1', level: 'error', msg: 'Error!' },
|
|
{ ts: baseTime + 1000, worker: 'w-1', level: 'error', msg: 'Another error!' },
|
|
];
|
|
|
|
events.forEach(e => analytics.processEvent(e));
|
|
|
|
const metrics = analytics.getWorkerMetrics('w-1');
|
|
expect(metrics?.errorCount).toBe(2);
|
|
expect(metrics?.errorRate).toBe(0); // No beads completed, so rate is 0
|
|
});
|
|
});
|
|
|
|
describe('Cost Tracking', () => {
|
|
it('should track cost from CostTracker', () => {
|
|
const events: LogEvent[] = [
|
|
{
|
|
ts: baseTime,
|
|
worker: 'w-1',
|
|
level: 'info',
|
|
msg: 'API call',
|
|
input_tokens: 1000,
|
|
output_tokens: 500,
|
|
model: 'claude-sonnet-4-6',
|
|
},
|
|
];
|
|
|
|
events.forEach(e => analytics.processEvent(e));
|
|
|
|
const metrics = analytics.getWorkerMetrics('w-1');
|
|
expect(metrics?.totalCostUsd).toBeGreaterThan(0);
|
|
expect(metrics?.totalTokens).toBe(1500);
|
|
});
|
|
|
|
it('should calculate cost per bead', () => {
|
|
const events: LogEvent[] = [
|
|
{
|
|
ts: baseTime,
|
|
worker: 'w-1',
|
|
level: 'info',
|
|
msg: 'Start',
|
|
bead: 'bd-1',
|
|
input_tokens: 1000,
|
|
output_tokens: 500,
|
|
},
|
|
{
|
|
ts: baseTime + 1000,
|
|
worker: 'w-1',
|
|
level: 'info',
|
|
msg: 'Completed',
|
|
bead: 'bd-1',
|
|
},
|
|
];
|
|
|
|
events.forEach(e => analytics.processEvent(e));
|
|
|
|
const metrics = analytics.getWorkerMetrics('w-1');
|
|
expect(metrics?.beadsCompleted).toBe(1);
|
|
expect(metrics?.costPerBead).toBeGreaterThan(0);
|
|
expect(metrics?.tokensPerBead).toBe(1500);
|
|
});
|
|
});
|
|
|
|
describe('Activity and Idle Time', () => {
|
|
it('should track active time', () => {
|
|
const events: LogEvent[] = [
|
|
{ ts: baseTime, worker: 'w-1', level: 'info', msg: 'Event 1' },
|
|
{ ts: baseTime + 1000, worker: 'w-1', level: 'info', msg: 'Event 2' },
|
|
{ ts: baseTime + 2000, worker: 'w-1', level: 'info', msg: 'Event 3' },
|
|
];
|
|
|
|
events.forEach(e => analytics.processEvent(e));
|
|
|
|
const metrics = analytics.getWorkerMetrics('w-1', {
|
|
startTime: baseTime,
|
|
endTime: baseTime + 10000,
|
|
});
|
|
|
|
expect(metrics?.activeTimeMs).toBeGreaterThan(0);
|
|
expect(metrics?.idleTimeMs).toBeGreaterThan(0);
|
|
});
|
|
|
|
it('should calculate idle percentage', () => {
|
|
const oneHour = 3600000;
|
|
|
|
// Only 10 minutes of activity in 1 hour
|
|
const events: LogEvent[] = [
|
|
{ ts: baseTime, worker: 'w-1', level: 'info', msg: 'Start' },
|
|
{ ts: baseTime + 600000, worker: 'w-1', level: 'info', msg: 'End' }, // 10 min later
|
|
];
|
|
|
|
events.forEach(e => analytics.processEvent(e));
|
|
|
|
const metrics = analytics.getWorkerMetrics('w-1', {
|
|
startTime: baseTime,
|
|
endTime: baseTime + oneHour,
|
|
});
|
|
|
|
// Should have significant idle time
|
|
expect(metrics?.idlePercentage).toBeGreaterThan(80);
|
|
});
|
|
|
|
it('should handle activity gaps correctly', () => {
|
|
const events: LogEvent[] = [
|
|
{ ts: baseTime, worker: 'w-1', level: 'info', msg: 'Activity 1' },
|
|
{ ts: baseTime + 1000, worker: 'w-1', level: 'info', msg: 'Activity 2' },
|
|
// 10 minute gap
|
|
{ ts: baseTime + 600000, worker: 'w-1', level: 'info', msg: 'Activity 3' },
|
|
{ ts: baseTime + 601000, worker: 'w-1', level: 'info', msg: 'Activity 4' },
|
|
];
|
|
|
|
events.forEach(e => analytics.processEvent(e));
|
|
|
|
const metrics = analytics.getWorkerMetrics('w-1', {
|
|
startTime: baseTime,
|
|
endTime: baseTime + 700000,
|
|
});
|
|
|
|
// Should have two separate activity periods
|
|
expect(metrics?.activeTimeMs).toBeLessThan(700000);
|
|
});
|
|
});
|
|
|
|
describe('Time Windows', () => {
|
|
it('should filter by time window: hour', () => {
|
|
const oneHour = 3600000;
|
|
const now = Date.now();
|
|
|
|
const events: LogEvent[] = [
|
|
{ ts: now - oneHour - 1000, worker: 'w-1', level: 'info', msg: 'Old event' },
|
|
{ ts: now - 30 * 60000, worker: 'w-1', level: 'info', msg: 'Recent event' },
|
|
];
|
|
|
|
events.forEach(e => analytics.processEvent(e));
|
|
|
|
const metrics = analytics.getWorkerMetrics('w-1', { timeWindow: 'hour' });
|
|
expect(metrics?.totalEvents).toBe(1); // Only recent event
|
|
});
|
|
|
|
it('should support custom time ranges', () => {
|
|
const events: LogEvent[] = [
|
|
{ ts: baseTime, worker: 'w-1', level: 'info', msg: 'Event 1' },
|
|
{ ts: baseTime + 5000, worker: 'w-1', level: 'info', msg: 'Event 2' },
|
|
{ ts: baseTime + 10000, worker: 'w-1', level: 'info', msg: 'Event 3' },
|
|
];
|
|
|
|
events.forEach(e => analytics.processEvent(e));
|
|
|
|
const metrics = analytics.getWorkerMetrics('w-1', {
|
|
startTime: baseTime + 4000,
|
|
endTime: baseTime + 15000,
|
|
});
|
|
|
|
expect(metrics?.totalEvents).toBe(2); // Events 2 and 3
|
|
});
|
|
});
|
|
|
|
describe('Aggregated Analytics', () => {
|
|
it('should aggregate metrics across all workers', () => {
|
|
const events: LogEvent[] = [
|
|
{ ts: baseTime, worker: 'w-1', level: 'info', msg: 'Start', bead: 'bd-1' },
|
|
{ ts: baseTime + 1000, worker: 'w-1', level: 'info', msg: 'Done', bead: 'bd-1' },
|
|
{ ts: baseTime, worker: 'w-2', level: 'info', msg: 'Start', bead: 'bd-2' },
|
|
{ ts: baseTime + 2000, worker: 'w-2', level: 'info', msg: 'Done', bead: 'bd-2' },
|
|
{ ts: baseTime, worker: 'w-3', level: 'info', msg: 'Start', bead: 'bd-3' },
|
|
{ ts: baseTime + 1500, worker: 'w-3', level: 'info', msg: 'Done', bead: 'bd-3' },
|
|
];
|
|
|
|
events.forEach(e => analytics.processEvent(e));
|
|
|
|
const aggregated = analytics.getAggregatedAnalytics();
|
|
|
|
expect(aggregated.totalWorkers).toBe(3);
|
|
expect(aggregated.totalBeadsCompleted).toBe(3);
|
|
expect(aggregated.topPerformers).toHaveLength(3);
|
|
});
|
|
|
|
it('should rank top performers', () => {
|
|
const events: LogEvent[] = [];
|
|
|
|
// w-1: 5 beads
|
|
for (let i = 0; i < 5; i++) {
|
|
events.push({ ts: baseTime + i * 1000, worker: 'w-1', level: 'info', msg: 'Start', bead: `bd-1-${i}` });
|
|
events.push({ ts: baseTime + i * 1000 + 500, worker: 'w-1', level: 'info', msg: 'Done', bead: `bd-1-${i}` });
|
|
}
|
|
|
|
// w-2: 3 beads
|
|
for (let i = 0; i < 3; i++) {
|
|
events.push({ ts: baseTime + i * 1000, worker: 'w-2', level: 'info', msg: 'Start', bead: `bd-2-${i}` });
|
|
events.push({ ts: baseTime + i * 1000 + 500, worker: 'w-2', level: 'info', msg: 'Done', bead: `bd-2-${i}` });
|
|
}
|
|
|
|
// w-3: 8 beads
|
|
for (let i = 0; i < 8; i++) {
|
|
events.push({ ts: baseTime + i * 1000, worker: 'w-3', level: 'info', msg: 'Start', bead: `bd-3-${i}` });
|
|
events.push({ ts: baseTime + i * 1000 + 500, worker: 'w-3', level: 'info', msg: 'Done', bead: `bd-3-${i}` });
|
|
}
|
|
|
|
events.forEach(e => analytics.processEvent(e));
|
|
|
|
const aggregated = analytics.getAggregatedAnalytics();
|
|
|
|
expect(aggregated.topPerformers[0].workerId).toBe('w-3');
|
|
expect(aggregated.topPerformers[0].beadsCompleted).toBe(8);
|
|
expect(aggregated.topPerformers[1].workerId).toBe('w-1');
|
|
expect(aggregated.topPerformers[2].workerId).toBe('w-2');
|
|
});
|
|
|
|
it('should identify high error rate workers', () => {
|
|
const events: LogEvent[] = [
|
|
// w-1: 2 beads, 0 errors
|
|
{ ts: baseTime, worker: 'w-1', level: 'info', msg: 'Start', bead: 'bd-1' },
|
|
{ ts: baseTime + 1000, worker: 'w-1', level: 'info', msg: 'Done', bead: 'bd-1' },
|
|
{ ts: baseTime + 2000, worker: 'w-1', level: 'info', msg: 'Start', bead: 'bd-2' },
|
|
{ ts: baseTime + 3000, worker: 'w-1', level: 'info', msg: 'Done', bead: 'bd-2' },
|
|
|
|
// w-2: 1 bead, 3 errors
|
|
{ ts: baseTime, worker: 'w-2', level: 'info', msg: 'Start', bead: 'bd-3' },
|
|
{ ts: baseTime + 500, worker: 'w-2', level: 'error', msg: 'Error 1' },
|
|
{ ts: baseTime + 700, worker: 'w-2', level: 'error', msg: 'Error 2' },
|
|
{ ts: baseTime + 900, worker: 'w-2', level: 'error', msg: 'Error 3' },
|
|
{ ts: baseTime + 1000, worker: 'w-2', level: 'info', msg: 'Done', bead: 'bd-3' },
|
|
];
|
|
|
|
events.forEach(e => analytics.processEvent(e));
|
|
|
|
const aggregated = analytics.getAggregatedAnalytics();
|
|
|
|
expect(aggregated.highErrorRateWorkers[0].workerId).toBe('w-2');
|
|
expect(aggregated.highErrorRateWorkers[0].errorRate).toBe(3);
|
|
});
|
|
});
|
|
|
|
describe('Time-Series Data', () => {
|
|
it('should create time-series snapshots', () => {
|
|
const oneHour = 3600000;
|
|
const analytics = new WorkerAnalytics(costTracker, oneHour); // 1 hour interval
|
|
|
|
// Generate events over 3 hours
|
|
for (let hour = 0; hour < 3; hour++) {
|
|
const event: LogEvent = {
|
|
ts: baseTime + hour * oneHour + 1000,
|
|
worker: 'w-1',
|
|
level: 'info',
|
|
msg: `Event at hour ${hour}`,
|
|
};
|
|
analytics.processEvent(event);
|
|
}
|
|
|
|
const timeSeriesData = analytics.getTimeSeriesData('w-1');
|
|
|
|
// Should have at least 2 snapshots (one after each hour boundary)
|
|
expect(timeSeriesData.length).toBeGreaterThanOrEqual(2);
|
|
});
|
|
|
|
it('should get performance trends', () => {
|
|
const events: LogEvent[] = [];
|
|
const oneHour = 3600000;
|
|
const analytics = new WorkerAnalytics(costTracker, oneHour);
|
|
|
|
// Generate improving performance: more beads over time
|
|
for (let hour = 0; hour < 3; hour++) {
|
|
const beadCount = (hour + 1) * 2; // 2, 4, 6 beads per hour
|
|
|
|
for (let i = 0; i < beadCount; i++) {
|
|
events.push({
|
|
ts: baseTime + hour * oneHour + i * 1000,
|
|
worker: 'w-1',
|
|
level: 'info',
|
|
msg: 'Start',
|
|
bead: `bd-${hour}-${i}`,
|
|
});
|
|
events.push({
|
|
ts: baseTime + hour * oneHour + i * 1000 + 500,
|
|
worker: 'w-1',
|
|
level: 'info',
|
|
msg: 'Done',
|
|
bead: `bd-${hour}-${i}`,
|
|
});
|
|
}
|
|
|
|
// Force a snapshot at the end of each hour
|
|
events.push({
|
|
ts: baseTime + (hour + 1) * oneHour + 1000,
|
|
worker: 'w-1',
|
|
level: 'info',
|
|
msg: 'Hourly marker',
|
|
});
|
|
}
|
|
|
|
events.forEach(e => analytics.processEvent(e));
|
|
|
|
const trend = analytics.getPerformanceTrends('w-1', 'beadsCompleted');
|
|
|
|
expect(trend.dataPoints.length).toBeGreaterThan(0);
|
|
// Trend should be improving since beads increase over time
|
|
// Note: This might be 'stable' if snapshots don't capture the progression well
|
|
});
|
|
});
|
|
|
|
describe('Options and Filtering', () => {
|
|
it('should filter by worker IDs', () => {
|
|
const events: LogEvent[] = [
|
|
{ ts: baseTime, worker: 'w-1', level: 'info', msg: 'Event 1' },
|
|
{ ts: baseTime, worker: 'w-2', level: 'info', msg: 'Event 2' },
|
|
{ ts: baseTime, worker: 'w-3', level: 'info', msg: 'Event 3' },
|
|
];
|
|
|
|
events.forEach(e => analytics.processEvent(e));
|
|
|
|
const filtered = analytics.getAllWorkerMetrics({ workerIds: ['w-1', 'w-3'] });
|
|
|
|
expect(filtered).toHaveLength(2);
|
|
expect(filtered.map(m => m.workerId).sort()).toEqual(['w-1', 'w-3']);
|
|
});
|
|
|
|
it('should filter by minimum beads completed', () => {
|
|
const events: LogEvent[] = [
|
|
{ ts: baseTime, worker: 'w-1', level: 'info', msg: 'Start', bead: 'bd-1' },
|
|
{ ts: baseTime + 1000, worker: 'w-1', level: 'info', msg: 'Done', bead: 'bd-1' },
|
|
{ ts: baseTime, worker: 'w-2', level: 'info', msg: 'Event' },
|
|
];
|
|
|
|
events.forEach(e => analytics.processEvent(e));
|
|
|
|
const filtered = analytics.getAllWorkerMetrics({ minBeadsCompleted: 1 });
|
|
|
|
expect(filtered).toHaveLength(1);
|
|
expect(filtered[0].workerId).toBe('w-1');
|
|
});
|
|
|
|
it('should limit max workers in rankings', () => {
|
|
const events: LogEvent[] = [];
|
|
|
|
// Create 20 workers
|
|
for (let i = 0; i < 20; i++) {
|
|
events.push({
|
|
ts: baseTime,
|
|
worker: `w-${i}`,
|
|
level: 'info',
|
|
msg: 'Start',
|
|
bead: `bd-${i}`,
|
|
});
|
|
events.push({
|
|
ts: baseTime + 1000,
|
|
worker: `w-${i}`,
|
|
level: 'info',
|
|
msg: 'Done',
|
|
bead: `bd-${i}`,
|
|
});
|
|
}
|
|
|
|
events.forEach(e => analytics.processEvent(e));
|
|
|
|
const aggregated = analytics.getAggregatedAnalytics({ maxWorkers: 5 });
|
|
|
|
expect(aggregated.topPerformers).toHaveLength(5);
|
|
});
|
|
});
|
|
|
|
describe('Summary Output', () => {
|
|
it('should generate formatted summary', () => {
|
|
const events: LogEvent[] = [
|
|
{ ts: baseTime, worker: 'w-1', level: 'info', msg: 'Start', bead: 'bd-1' },
|
|
{ ts: baseTime + 1000, worker: 'w-1', level: 'info', msg: 'Done', bead: 'bd-1' },
|
|
{ ts: baseTime, worker: 'w-1', level: 'error', msg: 'Error!' },
|
|
];
|
|
|
|
events.forEach(e => analytics.processEvent(e));
|
|
|
|
const summary = analytics.getSummary();
|
|
|
|
expect(summary).toContain('Worker Analytics Summary');
|
|
expect(summary).toContain('Total Workers');
|
|
expect(summary).toContain('Total Beads Completed');
|
|
expect(summary).toContain('Error Rate');
|
|
});
|
|
});
|
|
|
|
describe('Clear and Reset', () => {
|
|
it('should clear all data', () => {
|
|
const events: LogEvent[] = [
|
|
{ ts: baseTime, worker: 'w-1', level: 'info', msg: 'Event' },
|
|
{ ts: baseTime + 1000, worker: 'w-2', level: 'info', msg: 'Event' },
|
|
];
|
|
|
|
events.forEach(e => analytics.processEvent(e));
|
|
|
|
analytics.clear();
|
|
|
|
const allMetrics = analytics.getAllWorkerMetrics();
|
|
expect(allMetrics).toHaveLength(0);
|
|
|
|
const metrics = analytics.getWorkerMetrics('w-1');
|
|
expect(metrics).toBeUndefined();
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('MetricAccumulator', () => {
|
|
let accumulator: MetricAccumulator;
|
|
const baseTime = Date.now();
|
|
|
|
beforeEach(() => {
|
|
accumulator = new MetricAccumulator();
|
|
});
|
|
|
|
function makeMetricEvent(worker: string, metricName: string, value: number, ts?: number): LogEvent {
|
|
return {
|
|
ts: ts ?? baseTime,
|
|
worker,
|
|
level: 'info',
|
|
msg: `metric.${metricName}`,
|
|
metric_name: metricName,
|
|
value,
|
|
};
|
|
}
|
|
|
|
it('accumulates token-in counts', () => {
|
|
accumulator.processEvent(makeMetricEvent('w-1', INSTRUMENT_NAMES.TOKENS_IN, 100));
|
|
accumulator.processEvent(makeMetricEvent('w-1', INSTRUMENT_NAMES.TOKENS_IN, 50));
|
|
|
|
const snap = accumulator.getSnapshot('w-1');
|
|
expect(snap).not.toBeNull();
|
|
expect(snap!.tokensIn).toBe(150);
|
|
});
|
|
|
|
it('accumulates token-out counts', () => {
|
|
accumulator.processEvent(makeMetricEvent('w-1', INSTRUMENT_NAMES.TOKENS_OUT, 200));
|
|
accumulator.processEvent(makeMetricEvent('w-1', INSTRUMENT_NAMES.TOKENS_OUT, 75));
|
|
|
|
const snap = accumulator.getSnapshot('w-1');
|
|
expect(snap!.tokensOut).toBe(275);
|
|
});
|
|
|
|
it('accumulates cost USD', () => {
|
|
accumulator.processEvent(makeMetricEvent('w-1', INSTRUMENT_NAMES.COST_USD, 0.05));
|
|
accumulator.processEvent(makeMetricEvent('w-1', INSTRUMENT_NAMES.COST_USD, 0.03));
|
|
|
|
const snap = accumulator.getSnapshot('w-1');
|
|
expect(snap!.costUsd).toBeCloseTo(0.08);
|
|
});
|
|
|
|
it('tracks bead completions', () => {
|
|
accumulator.processEvent(makeMetricEvent('w-1', INSTRUMENT_NAMES.BEAD_COMPLETED, 1));
|
|
accumulator.processEvent(makeMetricEvent('w-1', INSTRUMENT_NAMES.BEAD_COMPLETED, 1));
|
|
accumulator.processEvent(makeMetricEvent('w-1', INSTRUMENT_NAMES.BEAD_COMPLETED, 1));
|
|
|
|
const snap = accumulator.getSnapshot('w-1');
|
|
expect(snap!.beadsCompleted).toBe(3);
|
|
});
|
|
|
|
it('tracks bead failures', () => {
|
|
accumulator.processEvent(makeMetricEvent('w-1', INSTRUMENT_NAMES.BEAD_FAILED, 1));
|
|
|
|
const snap = accumulator.getSnapshot('w-1');
|
|
expect(snap!.beadsFailed).toBe(1);
|
|
});
|
|
|
|
it('tracks worker errors', () => {
|
|
accumulator.processEvent(makeMetricEvent('w-1', INSTRUMENT_NAMES.WORKER_ERRORS, 2));
|
|
|
|
const snap = accumulator.getSnapshot('w-1');
|
|
expect(snap!.errors).toBe(2);
|
|
});
|
|
|
|
it('records bead duration samples', () => {
|
|
accumulator.processEvent(makeMetricEvent('w-1', INSTRUMENT_NAMES.BEAD_DURATION, 5000));
|
|
accumulator.processEvent(makeMetricEvent('w-1', INSTRUMENT_NAMES.BEAD_DURATION, 3200));
|
|
|
|
const snap = accumulator.getSnapshot('w-1');
|
|
expect(snap!.durations).toEqual([5000, 3200]);
|
|
});
|
|
|
|
it('isolates workers', () => {
|
|
accumulator.processEvent(makeMetricEvent('w-1', INSTRUMENT_NAMES.TOKENS_IN, 100));
|
|
accumulator.processEvent(makeMetricEvent('w-2', INSTRUMENT_NAMES.TOKENS_IN, 200));
|
|
|
|
expect(accumulator.getSnapshot('w-1')!.tokensIn).toBe(100);
|
|
expect(accumulator.getSnapshot('w-2')!.tokensIn).toBe(200);
|
|
});
|
|
|
|
it('drains samples and clears buffer', () => {
|
|
accumulator.processEvent(makeMetricEvent('w-1', INSTRUMENT_NAMES.TOKENS_IN, 100));
|
|
accumulator.processEvent(makeMetricEvent('w-2', INSTRUMENT_NAMES.COST_USD, 0.05));
|
|
|
|
const samples = accumulator.drainSamples();
|
|
expect(samples).toHaveLength(2);
|
|
expect(samples[0].metricName).toBe(INSTRUMENT_NAMES.TOKENS_IN);
|
|
expect(samples[1].metricName).toBe(INSTRUMENT_NAMES.COST_USD);
|
|
|
|
// Second drain should be empty
|
|
expect(accumulator.drainSamples()).toHaveLength(0);
|
|
});
|
|
|
|
it('hasMetricData returns false before any metric event', () => {
|
|
expect(accumulator.hasMetricData()).toBe(false);
|
|
});
|
|
|
|
it('hasMetricData returns true after processing a metric', () => {
|
|
accumulator.processEvent(makeMetricEvent('w-1', INSTRUMENT_NAMES.TOKENS_IN, 100));
|
|
expect(accumulator.hasMetricData()).toBe(true);
|
|
});
|
|
|
|
it('ignores non-metric events', () => {
|
|
const event: LogEvent = {
|
|
ts: baseTime,
|
|
worker: 'w-1',
|
|
level: 'info',
|
|
msg: 'bead.completed',
|
|
};
|
|
accumulator.processEvent(event);
|
|
expect(accumulator.hasMetricData()).toBe(false);
|
|
expect(accumulator.getSnapshot('w-1')).toBeNull();
|
|
});
|
|
|
|
it('resets all state', () => {
|
|
accumulator.processEvent(makeMetricEvent('w-1', INSTRUMENT_NAMES.TOKENS_IN, 100));
|
|
accumulator.reset();
|
|
|
|
expect(accumulator.hasMetricData()).toBe(false);
|
|
expect(accumulator.getSnapshot('w-1')).toBeNull();
|
|
expect(accumulator.drainSamples()).toHaveLength(0);
|
|
});
|
|
|
|
it('returns full snapshot with all fields', () => {
|
|
accumulator.processEvent(makeMetricEvent('w-1', INSTRUMENT_NAMES.TOKENS_IN, 1000));
|
|
accumulator.processEvent(makeMetricEvent('w-1', INSTRUMENT_NAMES.TOKENS_OUT, 500));
|
|
accumulator.processEvent(makeMetricEvent('w-1', INSTRUMENT_NAMES.COST_USD, 0.12));
|
|
accumulator.processEvent(makeMetricEvent('w-1', INSTRUMENT_NAMES.BEAD_COMPLETED, 3));
|
|
accumulator.processEvent(makeMetricEvent('w-1', INSTRUMENT_NAMES.BEAD_FAILED, 1));
|
|
accumulator.processEvent(makeMetricEvent('w-1', INSTRUMENT_NAMES.WORKER_ERRORS, 2));
|
|
accumulator.processEvent(makeMetricEvent('w-1', INSTRUMENT_NAMES.BEAD_DURATION, 4000));
|
|
|
|
const snap = accumulator.getSnapshot('w-1');
|
|
expect(snap).toEqual({
|
|
tokensIn: 1000,
|
|
tokensOut: 500,
|
|
costUsd: 0.12,
|
|
beadsCompleted: 3,
|
|
beadsFailed: 1,
|
|
errors: 2,
|
|
durations: [4000],
|
|
});
|
|
});
|
|
|
|
it('uses metric_value fallback when value is missing', () => {
|
|
const event: LogEvent = {
|
|
ts: baseTime,
|
|
worker: 'w-1',
|
|
level: 'info',
|
|
msg: `metric.${INSTRUMENT_NAMES.TOKENS_IN}`,
|
|
metric_name: INSTRUMENT_NAMES.TOKENS_IN,
|
|
metric_value: 42,
|
|
};
|
|
accumulator.processEvent(event);
|
|
expect(accumulator.getSnapshot('w-1')!.tokensIn).toBe(42);
|
|
});
|
|
|
|
describe('alias resolution (NEEDLE naming convention)', () => {
|
|
it('resolves needle.worker.beads.completed to needle.bead.completed', () => {
|
|
accumulator.processEvent(makeMetricEvent('w-1', 'needle.worker.beads.completed', 1));
|
|
accumulator.processEvent(makeMetricEvent('w-1', 'needle.worker.beads.completed', 1));
|
|
|
|
const snap = accumulator.getSnapshot('w-1');
|
|
expect(snap!.beadsCompleted).toBe(2);
|
|
});
|
|
|
|
it('resolves needle.worker.beads.failed to needle.bead.failed', () => {
|
|
accumulator.processEvent(makeMetricEvent('w-1', 'needle.worker.beads.failed', 1));
|
|
|
|
const snap = accumulator.getSnapshot('w-1');
|
|
expect(snap!.beadsFailed).toBe(1);
|
|
});
|
|
|
|
it('drains alias-resolved samples with canonical name', () => {
|
|
accumulator.processEvent(makeMetricEvent('w-1', 'needle.worker.beads.completed', 1));
|
|
|
|
const samples = accumulator.drainSamples();
|
|
expect(samples).toHaveLength(1);
|
|
expect(samples[0].metricName).toBe('needle.bead.completed');
|
|
});
|
|
|
|
it('coaccumulates alias and canonical names for the same instrument', () => {
|
|
// NEEDLE emits alias form
|
|
accumulator.processEvent(makeMetricEvent('w-1', 'needle.worker.beads.completed', 2));
|
|
// Direct canonical form
|
|
accumulator.processEvent(makeMetricEvent('w-1', 'needle.bead.completed', 3));
|
|
|
|
const snap = accumulator.getSnapshot('w-1');
|
|
expect(snap!.beadsCompleted).toBe(5);
|
|
});
|
|
|
|
it('passes through unknown names unchanged', () => {
|
|
expect(resolveInstrumentName('needle.worker.tokens.in')).toBe('needle.worker.tokens.in');
|
|
expect(resolveInstrumentName('custom.metric')).toBe('custom.metric');
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('WorkerAnalytics - Worker Comparison', () => {
|
|
let analytics: WorkerAnalytics;
|
|
let costTracker: CostTracker;
|
|
const baseTime = Date.now();
|
|
|
|
beforeEach(() => {
|
|
costTracker = new CostTracker();
|
|
analytics = new WorkerAnalytics(costTracker, 3600000);
|
|
});
|
|
|
|
it('should return null when comparing non-existent workers', () => {
|
|
const result = analytics.compareWorkers('nonexistent-1', 'nonexistent-2');
|
|
expect(result).toBeNull();
|
|
});
|
|
|
|
it('should compare two workers side-by-side', () => {
|
|
const events: LogEvent[] = [
|
|
// Worker 1: Better performance, higher cost
|
|
{ ts: baseTime, worker: 'w-1', level: 'info', msg: 'Start', bead: 'bd-1', input_tokens: 1000, output_tokens: 500 },
|
|
{ ts: baseTime + 2000, worker: 'w-1', level: 'info', msg: 'Done', bead: 'bd-1' },
|
|
{ ts: baseTime + 3000, worker: 'w-1', level: 'info', msg: 'Start', bead: 'bd-2', input_tokens: 800, output_tokens: 400 },
|
|
{ ts: baseTime + 5000, worker: 'w-1', level: 'info', msg: 'Done', bead: 'bd-2' },
|
|
|
|
// Worker 2: Slower but cheaper, more errors
|
|
{ ts: baseTime, worker: 'w-2', level: 'info', msg: 'Start', bead: 'bd-3', input_tokens: 500, output_tokens: 200 },
|
|
{ ts: baseTime + 3000, worker: 'w-2', level: 'error', msg: 'Error!' },
|
|
{ ts: baseTime + 4000, worker: 'w-2', level: 'info', msg: 'Done', bead: 'bd-3' },
|
|
];
|
|
|
|
events.forEach(e => analytics.processEvent(e));
|
|
|
|
const result = analytics.compareWorkers('w-1', 'w-2');
|
|
|
|
expect(result).not.toBeNull();
|
|
expect(result?.worker1.workerId).toBe('w-1');
|
|
expect(result?.worker2.workerId).toBe('w-2');
|
|
});
|
|
|
|
it('should calculate raw differences correctly', () => {
|
|
const events: LogEvent[] = [
|
|
// Worker 1: 2 beads
|
|
{ ts: baseTime, worker: 'w-1', level: 'info', msg: 'Start', bead: 'bd-1' },
|
|
{ ts: baseTime + 1000, worker: 'w-1', level: 'info', msg: 'Done', bead: 'bd-1' },
|
|
{ ts: baseTime + 2000, worker: 'w-1', level: 'info', msg: 'Start', bead: 'bd-2' },
|
|
{ ts: baseTime + 3000, worker: 'w-1', level: 'info', msg: 'Done', bead: 'bd-2' },
|
|
|
|
// Worker 2: 1 bead
|
|
{ ts: baseTime, worker: 'w-2', level: 'info', msg: 'Start', bead: 'bd-3' },
|
|
{ ts: baseTime + 2000, worker: 'w-2', level: 'info', msg: 'Done', bead: 'bd-3' },
|
|
];
|
|
|
|
events.forEach(e => analytics.processEvent(e));
|
|
|
|
const result = analytics.compareWorkers('w-1', 'w-2');
|
|
|
|
expect(result?.differences.beadsCompleted).toBe(1); // 2 - 1 = 1
|
|
expect(result?.betterWorker.beadsCompleted).toBe('worker1');
|
|
});
|
|
|
|
it('should calculate percentage differences correctly', () => {
|
|
const events: LogEvent[] = [
|
|
// Worker 1: 4 beads
|
|
{ ts: baseTime, worker: 'w-1', level: 'info', msg: 'Start', bead: 'bd-1' },
|
|
{ ts: baseTime + 1000, worker: 'w-1', level: 'info', msg: 'Done', bead: 'bd-1' },
|
|
{ ts: baseTime + 2000, worker: 'w-1', level: 'info', msg: 'Start', bead: 'bd-2' },
|
|
{ ts: baseTime + 3000, worker: 'w-1', level: 'info', msg: 'Done', bead: 'bd-2' },
|
|
{ ts: baseTime + 4000, worker: 'w-1', level: 'info', msg: 'Start', bead: 'bd-3' },
|
|
{ ts: baseTime + 5000, worker: 'w-1', level: 'info', msg: 'Done', bead: 'bd-3' },
|
|
{ ts: baseTime + 6000, worker: 'w-1', level: 'info', msg: 'Start', bead: 'bd-4' },
|
|
{ ts: baseTime + 7000, worker: 'w-1', level: 'info', msg: 'Done', bead: 'bd-4' },
|
|
|
|
// Worker 2: 2 beads
|
|
{ ts: baseTime, worker: 'w-2', level: 'info', msg: 'Start', bead: 'bd-5' },
|
|
{ ts: baseTime + 2000, worker: 'w-2', level: 'info', msg: 'Done', bead: 'bd-5' },
|
|
{ ts: baseTime + 3000, worker: 'w-2', level: 'info', msg: 'Start', bead: 'bd-6' },
|
|
{ ts: baseTime + 5000, worker: 'w-2', level: 'info', msg: 'Done', bead: 'bd-6' },
|
|
];
|
|
|
|
events.forEach(e => analytics.processEvent(e));
|
|
|
|
const result = analytics.compareWorkers('w-1', 'w-2');
|
|
|
|
// Worker 1 has 4 beads, Worker 2 has 2 beads
|
|
// Difference: 4 - 2 = 2
|
|
// Percentage: (4 - 2) / 2 * 100 = 100%
|
|
expect(result?.percentDifferences.beadsCompleted).toBeCloseTo(100, 0);
|
|
});
|
|
|
|
it('should handle zero division in percentage calculations', () => {
|
|
const events: LogEvent[] = [
|
|
// Worker 1: 1 bead
|
|
{ ts: baseTime, worker: 'w-1', level: 'info', msg: 'Start', bead: 'bd-1' },
|
|
{ ts: baseTime + 1000, worker: 'w-1', level: 'info', msg: 'Done', bead: 'bd-1' },
|
|
// Worker 2: 0 beads
|
|
{ ts: baseTime, worker: 'w-2', level: 'info', msg: 'Just watching' },
|
|
];
|
|
|
|
events.forEach(e => analytics.processEvent(e));
|
|
|
|
const result = analytics.compareWorkers('w-1', 'w-2');
|
|
|
|
// When worker2 has 0 beads, percentage should be 100% (worker1 has all the beads)
|
|
expect(result?.percentDifferences.beadsCompleted).toBe(100);
|
|
});
|
|
|
|
it('should determine better worker for each metric', () => {
|
|
const events: LogEvent[] = [
|
|
// Worker 1: More beads, no errors
|
|
{ ts: baseTime, worker: 'w-1', level: 'info', msg: 'Start', bead: 'bd-1' },
|
|
{ ts: baseTime + 1000, worker: 'w-1', level: 'info', msg: 'Done', bead: 'bd-1' },
|
|
|
|
// Worker 2: Same beads, has errors
|
|
{ ts: baseTime, worker: 'w-2', level: 'info', msg: 'Start', bead: 'bd-2' },
|
|
{ ts: baseTime + 500, worker: 'w-2', level: 'error', msg: 'Error!' },
|
|
{ ts: baseTime + 1000, worker: 'w-2', level: 'info', msg: 'Done', bead: 'bd-2' },
|
|
];
|
|
|
|
events.forEach(e => analytics.processEvent(e));
|
|
|
|
const result = analytics.compareWorkers('w-1', 'w-2');
|
|
|
|
expect(result?.betterWorker.beadsCompleted).toBe('tie'); // Both have 1 bead
|
|
expect(result?.betterWorker.errorRate).toBe('worker1'); // Lower is better, worker1 has 0 errors
|
|
});
|
|
|
|
it('should detect ties when metrics are equal', () => {
|
|
const events: LogEvent[] = [
|
|
// Both workers complete 1 bead in same time
|
|
{ ts: baseTime, worker: 'w-1', level: 'info', msg: 'Start', bead: 'bd-1' },
|
|
{ ts: baseTime + 1000, worker: 'w-1', level: 'info', msg: 'Done', bead: 'bd-1' },
|
|
{ ts: baseTime, worker: 'w-2', level: 'info', msg: 'Start', bead: 'bd-2' },
|
|
{ ts: baseTime + 1000, worker: 'w-2', level: 'info', msg: 'Done', bead: 'bd-2' },
|
|
];
|
|
|
|
events.forEach(e => analytics.processEvent(e));
|
|
|
|
const result = analytics.compareWorkers('w-1', 'w-2');
|
|
|
|
expect(result?.betterWorker.beadsCompleted).toBe('tie');
|
|
expect(result?.betterWorker.avgCompletionTimeMs).toBe('tie');
|
|
});
|
|
|
|
it('should calculate overall winner based on metric score', () => {
|
|
const events: LogEvent[] = [
|
|
// Worker 1: Better at most metrics
|
|
{ ts: baseTime, worker: 'w-1', level: 'info', msg: 'Start', bead: 'bd-1', input_tokens: 1000 },
|
|
{ ts: baseTime + 1000, worker: 'w-1', level: 'info', msg: 'Done', bead: 'bd-1' },
|
|
{ ts: baseTime + 2000, worker: 'w-1', level: 'info', msg: 'Start', bead: 'bd-2', input_tokens: 800 },
|
|
{ ts: baseTime + 3000, worker: 'w-1', level: 'info', msg: 'Done', bead: 'bd-2' },
|
|
|
|
// Worker 2: Worse performance
|
|
{ ts: baseTime, worker: 'w-2', level: 'info', msg: 'Start', bead: 'bd-3', input_tokens: 500 },
|
|
{ ts: baseTime + 2000, worker: 'w-2', level: 'error', msg: 'Error!' },
|
|
{ ts: baseTime + 3000, worker: 'w-2', level: 'info', msg: 'Done', bead: 'bd-3' },
|
|
];
|
|
|
|
events.forEach(e => analytics.processEvent(e));
|
|
|
|
const result = analytics.compareWorkers('w-1', 'w-2');
|
|
|
|
expect(result?.overallWinner).toBe('worker1');
|
|
expect(result?.score.worker1).toBeGreaterThan(result?.score.worker2 || 0);
|
|
});
|
|
|
|
it('should respect lower-is-better metrics', () => {
|
|
const events: LogEvent[] = [
|
|
// Worker 1: Faster completion (lower time is better)
|
|
{ ts: baseTime, worker: 'w-1', level: 'info', msg: 'Start', bead: 'bd-1' },
|
|
{ ts: baseTime + 1000, worker: 'w-1', level: 'info', msg: 'Done', bead: 'bd-1' },
|
|
|
|
// Worker 2: Slower completion
|
|
{ ts: baseTime, worker: 'w-2', level: 'info', msg: 'Start', bead: 'bd-2' },
|
|
{ ts: baseTime + 3000, worker: 'w-2', level: 'info', msg: 'Done', bead: 'bd-2' },
|
|
];
|
|
|
|
events.forEach(e => analytics.processEvent(e));
|
|
|
|
const result = analytics.compareWorkers('w-1', 'w-2');
|
|
|
|
// Lower completion time is better
|
|
expect(result?.betterWorker.avgCompletionTimeMs).toBe('worker1');
|
|
});
|
|
|
|
it('should handle cost comparison correctly', () => {
|
|
const events: LogEvent[] = [
|
|
// Worker 1: Higher cost per bead
|
|
{ ts: baseTime, worker: 'w-1', level: 'info', msg: 'Start', bead: 'bd-1', input_tokens: 2000, output_tokens: 1000 },
|
|
{ ts: baseTime + 1000, worker: 'w-1', level: 'info', msg: 'Done', bead: 'bd-1' },
|
|
|
|
// Worker 2: Lower cost per bead
|
|
{ ts: baseTime, worker: 'w-2', level: 'info', msg: 'Start', bead: 'bd-2', input_tokens: 500, output_tokens: 200 },
|
|
{ ts: baseTime + 1000, worker: 'w-2', level: 'info', msg: 'Done', bead: 'bd-2' },
|
|
];
|
|
|
|
events.forEach(e => analytics.processEvent(e));
|
|
|
|
const result = analytics.compareWorkers('w-1', 'w-2');
|
|
|
|
// Lower cost is better, so worker2 should win
|
|
expect(result?.betterWorker.costPerBead).toBe('worker2');
|
|
});
|
|
|
|
it('should compare efficiency scores', () => {
|
|
const events: LogEvent[] = [
|
|
// Worker 1: More active time
|
|
{ ts: baseTime, worker: 'w-1', level: 'info', msg: 'Event 1' },
|
|
{ ts: baseTime + 1000, worker: 'w-1', level: 'info', msg: 'Event 2' },
|
|
{ ts: baseTime + 2000, worker: 'w-1', level: 'info', msg: 'Event 3' },
|
|
|
|
// Worker 2: Less active time
|
|
{ ts: baseTime, worker: 'w-2', level: 'info', msg: 'Event' },
|
|
];
|
|
|
|
events.forEach(e => analytics.processEvent(e));
|
|
|
|
const result = analytics.compareWorkers('w-1', 'w-2');
|
|
|
|
// Higher efficiency (more active) is better
|
|
expect(result?.betterWorker.efficiencyScore).toBe('worker1');
|
|
});
|
|
|
|
it('should use time window options for comparison', () => {
|
|
const events: LogEvent[] = [
|
|
// Worker 1: Bead at start of window
|
|
{ ts: baseTime, worker: 'w-1', level: 'info', msg: 'Start', bead: 'bd-1' },
|
|
{ ts: baseTime + 1000, worker: 'w-1', level: 'info', msg: 'Done', bead: 'bd-1' },
|
|
|
|
// Worker 2: Bead later in window
|
|
{ ts: baseTime + 50000, worker: 'w-2', level: 'info', msg: 'Start', bead: 'bd-2' },
|
|
{ ts: baseTime + 51000, worker: 'w-2', level: 'info', msg: 'Done', bead: 'bd-2' },
|
|
];
|
|
|
|
events.forEach(e => analytics.processEvent(e));
|
|
|
|
// Compare with limited time window
|
|
const result = analytics.compareWorkers('w-1', 'w-2', {
|
|
startTime: baseTime,
|
|
endTime: baseTime + 10000, // Only first 10 seconds - w-1's bead is in, w-2's is out
|
|
});
|
|
|
|
// Both workers show 1 bead because beadsCompleted is cumulative
|
|
// But the time-based metrics like beadsPerHour will differ
|
|
expect(result?.worker1.beadsCompleted).toBe(1);
|
|
expect(result?.worker2.beadsCompleted).toBe(1);
|
|
});
|
|
|
|
it('should handle epsilon for floating point comparison', () => {
|
|
const events: LogEvent[] = [
|
|
// Create exactly identical metrics
|
|
{ ts: baseTime, worker: 'w-1', level: 'info', msg: 'Start', bead: 'bd-1' },
|
|
{ ts: baseTime + 1000, worker: 'w-1', level: 'info', msg: 'Done', bead: 'bd-1' },
|
|
{ ts: baseTime, worker: 'w-2', level: 'info', msg: 'Start', bead: 'bd-2' },
|
|
{ ts: baseTime + 1000, worker: 'w-2', level: 'info', msg: 'Done', bead: 'bd-2' },
|
|
];
|
|
|
|
events.forEach(e => analytics.processEvent(e));
|
|
|
|
const result = analytics.compareWorkers('w-1', 'w-2');
|
|
|
|
// With identical completion times, should be a tie
|
|
expect(result?.betterWorker.avgCompletionTimeMs).toBe('tie');
|
|
});
|
|
});
|