Files
electric-cherry/tests/unit/metrics-refresh.test.ts
T

338 lines
12 KiB
TypeScript

import { afterEach, describe, expect, it, vi } from 'vitest';
import { createMetricsRefresh } from '../../src/lib/ui/metrics-refresh.js';
afterEach(() => vi.useRealTimers());
const totals = { observed: 0, active: 0, idle: 0, unknown: 0, playerHours: 0 };
const emptyMatches = {
started: 0,
withRecordedPlayers: 0,
averageGroupSize: null,
solo: 0,
soloShare: null
};
const report = (days: number) => ({
days,
now: 1000,
since: 0,
summary: totals,
running: { average: null, peak: 0, capturedHours: 0 },
matches: emptyMatches,
autoscaling: {
added: 0,
removed: 0,
failed: 0,
cancelled: 0,
readinessSamples: 0,
readinessMeanSeconds: null,
readinessMaxSeconds: null
},
points: [],
maps: [],
sampledServerCount: 0,
scope: { game: null, map: null, timeZone: 'UTC' },
scopeOptions: { games: [], maps: [] },
registeredServerCount: 0,
autoscalingPaused: false,
firstRetainedSampleAt: null,
latestSampleAt: null,
scopeFirstSampleAt: null,
scopeLatestSampleAt: null,
previous: {
since: -1000,
until: 0,
summary: totals,
running: { average: null, peak: 0, capturedHours: 0 },
firstSampleAt: null,
lastSampleAt: null,
partialHistory: true
},
heatmap: { timeZone: 'UTC', cells: [] },
pooledAvailability: {
value: null,
noEmptyMapHours: 0,
knownMapHours: 0,
unknownMapHours: 0,
coveredMaps: 0,
totalMaps: 0
}
});
function setup(fetcher: typeof fetch) {
let query = 'days=7';
const onData = vi.fn(),
onError = vi.fn(),
onLoading = vi.fn();
return {
...createMetricsRefresh({
getQuery: () => query,
onData,
onError,
onLoading,
fetcher,
timeoutMs: 100
}),
onData,
onError,
onLoading,
setQuery: (value: string) => {
query = value;
}
};
}
describe('metrics background refresh', () => {
it.each([
null,
{ ...report(7).autoscaling, added: -1 },
{ ...report(7).autoscaling, cancelled: 1.5 },
{ ...report(7).autoscaling, readinessMeanSeconds: '72' },
{ ...report(7).autoscaling, readinessMaxSeconds: -1 }
])(
'keeps the previous report when autoscaling measurements are invalid (%j)',
async (autoscaling) => {
const refresh = setup(
vi.fn().mockResolvedValue(Response.json({ ...report(7), autoscaling }))
);
await refresh.refresh();
expect(refresh.onData).not.toHaveBeenCalled();
expect(refresh.onError).toHaveBeenLastCalledWith(
'The metrics response was incomplete.'
);
}
);
it.each([undefined, null, -1, 1.5, '1'])(
'rejects an invalid sampled-server count (%s) and accepts a count without server rows',
async (sampledServerCount) => {
const value = { ...report(7), sampledServerCount: 2 };
const refresh = setup(
vi
.fn()
.mockResolvedValueOnce(Response.json({ ...value, sampledServerCount }))
.mockResolvedValueOnce(Response.json(value))
);
await refresh.refresh();
expect(refresh.onData).not.toHaveBeenCalled();
expect(refresh.onError).toHaveBeenLastCalledWith(
'The metrics response was incomplete.'
);
await refresh.refresh();
expect(refresh.onData).toHaveBeenLastCalledWith(value);
}
);
it('keeps the previous report after failure and accepts a successful retry', async () => {
const fetcher = vi
.fn()
.mockResolvedValueOnce(new Response('', { status: 503 }))
.mockResolvedValueOnce(Response.json(report(7)));
const refresh = setup(fetcher);
await refresh.refresh();
expect(refresh.onData).not.toHaveBeenCalled();
expect(refresh.onError).toHaveBeenLastCalledWith('Report refresh failed (503).');
await refresh.refresh();
expect(refresh.onData).toHaveBeenLastCalledWith(report(7));
expect(refresh.onError).toHaveBeenLastCalledWith(null);
expect(refresh.onLoading).toHaveBeenLastCalledWith(false);
});
it('coalesces duplicate requests and ignores old replies even when transport ignores abort', async () => {
const resolve: ((r: Response) => void)[] = [];
const fetcher = vi.fn(() => new Promise<Response>((done) => resolve.push(done)));
const refresh = setup(fetcher);
const old = refresh.refresh();
expect(refresh.refresh()).toBe(old);
expect(fetcher).toHaveBeenCalledTimes(1);
refresh.setQuery('days=1');
const next = refresh.refresh();
resolve[1](Response.json(report(1)));
await next;
resolve[0](Response.json(report(7)));
await old;
expect(refresh.onData).toHaveBeenCalledTimes(1);
expect(refresh.onData).toHaveBeenLastCalledWith(report(1));
});
it('preserves the report when an HTTP success lacks the expanded report contract', async () => {
const fetcher = vi
.fn()
.mockResolvedValueOnce(Response.json({ ...report(7), heatmap: null }))
.mockResolvedValueOnce(
Response.json({
...report(7),
scope: { game: null, map: null, timeZone: 'Invalid/Zone' }
})
)
.mockResolvedValueOnce(Response.json(report(7)));
const refresh = setup(fetcher);
await refresh.refresh();
await refresh.refresh();
expect(refresh.onData).not.toHaveBeenCalled();
expect(refresh.onError).toHaveBeenLastCalledWith('The metrics response was incomplete.');
await refresh.refresh();
expect(refresh.onData).toHaveBeenCalledTimes(1);
});
it('suppresses errors from a cancelled request', async () => {
let reject!: (error: Error) => void;
const refresh = setup(
vi.fn(
() =>
new Promise<Response>((_, fail) => {
reject = fail;
})
)
);
const pending = refresh.refresh();
refresh.cancel();
reject(new Error('Obsolete response'));
await pending;
expect(refresh.onData).not.toHaveBeenCalled();
expect(refresh.onError.mock.calls).toEqual([[null]]);
expect(refresh.onLoading).toHaveBeenLastCalledWith(false);
});
it('preserves successful match reports when fleet or map match data is malformed', async () => {
const matches = {
started: 3,
withRecordedPlayers: 2,
averageGroupSize: 1.5,
solo: 1,
soloShare: 50
};
const map = {
...totals,
game: 't5',
map: 'Moon',
matches,
running: { average: null, peak: 0, capturedHours: 0 },
noEmpty: 0,
availabilityObserved: 0,
availabilityUnknown: 0,
noEmptyAtMaximum: 0,
noEmptyBelowMaximum: 0,
policy: null
};
const value = { ...report(7), matches, maps: [map] };
const invalidMatches = [
undefined,
null,
{ ...matches, withRecordedPlayers: undefined },
{ ...matches, started: -1 },
{ ...matches, solo: 0.5 },
{ ...matches, averageGroupSize: '1.5' },
{ ...matches, soloShare: 101 }
];
const invalid = invalidMatches.flatMap((incomplete) => [
{ ...value, matches: incomplete },
{ ...value, maps: [{ ...map, matches: incomplete }] }
]);
const fetcher = vi.fn().mockResolvedValueOnce(Response.json(value));
for (const incomplete of invalid) fetcher.mockResolvedValueOnce(Response.json(incomplete));
const empty = { ...report(7), maps: [{ ...map, matches: emptyMatches }] };
fetcher.mockResolvedValueOnce(Response.json(empty));
const refresh = setup(fetcher);
await refresh.refresh();
expect(refresh.onData).toHaveBeenLastCalledWith(value);
for (const _ of invalid) {
await refresh.refresh();
expect(refresh.onData).toHaveBeenCalledTimes(1);
expect(refresh.onError).toHaveBeenLastCalledWith(
'The metrics response was incomplete.'
);
}
await refresh.refresh();
expect(refresh.onData).toHaveBeenLastCalledWith(empty);
expect(refresh.onData).toHaveBeenCalledTimes(2);
expect(refresh.onError).toHaveBeenLastCalledWith(null);
});
it('accepts availability reports and rejects incomplete running or policy evidence', async () => {
const value = {
...report(7),
maps: [
{
...totals,
game: 't5',
map: 'Moon',
matches: emptyMatches,
running: { average: 1, peak: 1, capturedHours: 1 },
noEmpty: 0,
availabilityObserved: 0,
availabilityUnknown: 1,
noEmptyAtMaximum: 0,
noEmptyBelowMaximum: 0,
policy: {
id: 'moon',
mode: 'create-destroy',
minimum: 1,
maximum: 3,
spares: 1,
pausedReason: null
}
}
],
points: [
{
...totals,
at: 0,
startAt: 0,
endAt: 1000,
partial: true,
capturedHours: 1 / 3600
}
]
};
const invalid = [
{ ...value, maps: [{ ...value.maps[0], running: undefined }] },
{
...value,
maps: [{ ...value.maps[0], running: { average: -1, peak: 1, capturedHours: 1 } }]
},
{
...value,
maps: [{ ...value.maps[0], running: { average: 1, peak: '1', capturedHours: 1 } }]
},
{
...value,
maps: [{ ...value.maps[0], running: { average: 1, peak: 1, capturedHours: null } }]
},
{ ...value, autoscalingPaused: undefined },
{ ...value, running: undefined },
{ ...value, previous: { ...value.previous, running: undefined } },
{ ...value, maps: [{ ...value.maps[0], policy: { id: 'moon' } }] },
{ ...value, maps: [{ ...value.maps[0], availabilityUnknown: undefined }] },
{ ...value, points: [{ ...value.points[0], capturedHours: undefined }] },
{ ...value, pooledAvailability: undefined }
];
const fetcher = vi.fn();
for (const incomplete of invalid) fetcher.mockResolvedValueOnce(Response.json(incomplete));
fetcher.mockResolvedValueOnce(Response.json(value));
const refresh = setup(fetcher);
for (const _ of invalid) {
await refresh.refresh();
expect(refresh.onData).not.toHaveBeenCalled();
expect(refresh.onError).toHaveBeenLastCalledWith(
'The metrics response was incomplete.'
);
}
await refresh.refresh();
expect(refresh.onData).toHaveBeenLastCalledWith(value);
});
it('times out with retry feedback and clears loading', async () => {
vi.useFakeTimers();
const fetcher = vi.fn(
(_input, init) =>
new Promise<Response>((_, reject) => {
init?.signal?.addEventListener('abort', () => reject(new Error('aborted')));
})
) as typeof fetch;
const refresh = setup(fetcher);
const pending = refresh.refresh();
await vi.advanceTimersByTimeAsync(100);
await pending;
expect(refresh.onError).toHaveBeenLastCalledWith('Report refresh timed out. Try again.');
expect(refresh.onLoading).toHaveBeenLastCalledWith(false);
});
});