309 lines
11 KiB
TypeScript
309 lines
11 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,
|
|
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([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);
|
|
});
|
|
});
|