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((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((_, 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((_, 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); }); });