import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { newFixture, type Fixture } from '../helpers/fixture.js'; import { parseMetricsQuery } from '../../src/lib/metrics.js'; let fixture: Fixture; beforeEach(async () => { vi.resetModules(); fixture = await newFixture([]); }); afterEach(async () => { vi.restoreAllMocks(); await fixture.cleanup(); }); describe('historical metrics', () => { it('weights occupancy by time and excludes failed checks and collection gaps', async () => { const { recordMetrics, readMetrics } = await import('../../src/lib/server/fleet/metrics.js'); const sample = { server: 'one', game: 't5', map: 'Moon', players: 2, capacity: 4 }; const at = Date.UTC(2026, 0, 2, 12); recordMetrics([sample], at); recordMetrics([{ ...sample, players: 0 }], at + 30_000); recordMetrics([{ ...sample, players: null }], at + 60_000); const result = readMetrics(1, at + 3600_000); expect(result.summary.active * 3600).toBeCloseTo(30); expect(result.summary.idle * 3600).toBeCloseTo(30); expect(result.summary.unknown * 3600).toBeCloseTo(90); expect(result.summary.playerHours * 3600).toBeCloseTo(60); expect(result.points.reduce((sum, p) => sum + p.observed, 0)).toBeCloseTo( result.summary.observed ); }); it('counts no-empty map time from players even without capacity or with unknown lobbies', async () => { const { recordMetrics, readMetrics } = await import('../../src/lib/server/fleet/metrics.js'); const at = Date.UTC(2026, 0, 2, 12); const sample = { server: 'one', game: 't5', map: 'Moon', players: 4, capacity: 4 }; recordMetrics([sample, { ...sample, server: 'two' }], at); recordMetrics( [ { ...sample, players: 1 }, { ...sample, server: 'two', players: 2 } ], at + 30_000 ); recordMetrics( [ { ...sample, capacity: null }, { ...sample, server: 'two', capacity: null } ], at + 60_000 ); recordMetrics([sample, { ...sample, server: 'two', players: null }], at + 90_000); const result = readMetrics(1, at + 120_000); expect(result.maps[0].noEmpty * 3600).toBeCloseTo(120); expect(result.maps[0].availabilityObserved * 3600).toBeCloseTo(120); expect(result.maps[0].availabilityUnknown).toBe(0); }); it('treats an empty spare as available and all-unknown map observations as unknown', async () => { const { recordMetrics, readMetrics } = await import('../../src/lib/server/fleet/metrics.js'); const at = Date.UTC(2026, 0, 2, 12); const sample = { server: 'one', game: 't5', map: 'Moon', players: 1, capacity: null }; recordMetrics([sample, { ...sample, server: 'two', players: 0 }], at); recordMetrics( [ { ...sample, players: null }, { ...sample, server: 'two', players: 0 } ], at + 30_000 ); recordMetrics( [ { ...sample, players: null }, { ...sample, server: 'two', players: null } ], at + 60_000 ); const result = readMetrics(1, at + 90_000); expect(result.maps[0].noEmpty).toBe(0); expect(result.maps[0].availabilityObserved * 3600).toBeCloseTo(60); expect(result.maps[0].availabilityUnknown * 3600).toBeCloseTo(30); expect(result.pooledAvailability.value).toBe(0); expect(result.pooledAvailability.unknownMapHours * 3600).toBeCloseTo(30); }); it('clips the reporting window and splits observations across UTC chart boundaries', async () => { const { recordMetrics, readMetrics } = await import('../../src/lib/server/fleet/metrics.js'); const midnight = Date.UTC(2026, 0, 2); const sample = { server: 'one', game: 't5', map: 'Moon', players: 1, capacity: 4 }; recordMetrics([sample], midnight - 15_000); let result = readMetrics(1, midnight + 15_000); expect(result.points.filter((p) => p.active > 0).map((p) => p.active * 3600)).toEqual([ 15, 15 ]); result = readMetrics(1, midnight + 86400_000); expect(result.summary.active * 3600).toBeCloseTo(75); expect(result.points.reduce((sum, p) => sum + p.active, 0)).toBeCloseTo( result.summary.active ); }); it('retains history without a live server and prunes observations older than 31 days', async () => { const { recordMetrics, readMetrics } = await import('../../src/lib/server/fleet/metrics.js'); const { db } = await import('../../src/lib/server/db/index.js'); const at = Date.UTC(2026, 0, 2); const sample = { server: 'removed', game: 't6', map: 'Town', players: 1, capacity: null }; recordMetrics([sample], at); expect(readMetrics(1, at + 30_000).servers[0].server).toBe('removed'); recordMetrics([sample], at + 32 * 86400_000); expect(db().prepare('SELECT COUNT(*) AS n FROM metric_samples').get()).toEqual({ n: 1 }); }); it('distinguishes retained history, an empty range, and current collection targets', async () => { const { recordMetrics, readMetrics } = await import('../../src/lib/server/fleet/metrics.js'); const { db } = await import('../../src/lib/server/db/index.js'); const at = Date.UTC(2026, 0, 2); expect(readMetrics(1, at)).toMatchObject({ firstRetainedSampleAt: null, latestSampleAt: null, registeredServerCount: 0, servers: [], maps: [] }); db().prepare('INSERT INTO servers (name, destroyed_at) VALUES (?, ?)').run('current', null); db() .prepare('INSERT INTO servers (name, destroyed_at) VALUES (?, ?)') .run('removed', new Date(at).toISOString()); const sample = { server: 'removed', game: 't6', map: 'Town', players: 1, capacity: null }; recordMetrics([sample], at); const emptyRange = readMetrics(1, at + 2 * 86400_000); expect(emptyRange).toMatchObject({ firstRetainedSampleAt: at, latestSampleAt: at, registeredServerCount: 1, servers: [], maps: [], summary: { observed: 0, unknown: 0 } }); expect( emptyRange.points.every((point) => point.observed === 0 && point.unknown === 0) ).toBe(true); }); it('keeps unknown status separate from no samples and preserves zero elapsed observations', async () => { const { recordMetrics, readMetrics } = await import('../../src/lib/server/fleet/metrics.js'); const at = Date.UTC(2026, 0, 2, 12); recordMetrics( [{ server: 'one', game: 't5', map: 'Moon', players: null, capacity: null }], at ); const initial = readMetrics(1, at); expect(initial.latestSampleAt).toBe(at); expect(initial.servers).toHaveLength(1); expect(initial.summary).toEqual({ observed: 0, active: 0, idle: 0, unknown: 0, playerHours: 0 }); const later = readMetrics(1, at + 120_000); expect(later.summary.observed).toBe(0); expect(later.summary.unknown * 3600).toBeCloseTo(90); expect(later.maps[0].availabilityObserved).toBe(0); expect(later.maps[0].availabilityUnknown * 3600).toBeCloseTo(90); expect(later.pooledAvailability.value).toBeNull(); expect(initial.running).toEqual({ average: null, peak: 0, capturedHours: 0 }); }); it('returns clipped interval boundaries and marks only partial UTC buckets', async () => { const { readMetrics } = await import('../../src/lib/server/fleet/metrics.js'); const now = Date.UTC(2026, 0, 8, 12, 30); for (const days of [1, 7, 30] as const) { const result = readMetrics(days, now); expect(result.points[0].startAt).toBe(result.since); expect(result.points.at(-1)?.endAt).toBe(now); expect(result.points[0].partial).toBe(true); expect(result.points.at(-1)?.partial).toBe(true); expect(result.points.slice(1, -1).every((point) => !point.partial)).toBe(true); expect(result.points.reduce((ms, point) => ms + point.endAt - point.startAt, 0)).toBe( days * 86400_000 ); } expect( readMetrics(1, Date.UTC(2026, 0, 8, 12)).points.every((point) => !point.partial) ).toBe(true); }); it('uses complete collection boundaries across fleet changes and keeps map availability in wall time', async () => { const { recordMetrics, readMetrics } = await import('../../src/lib/server/fleet/metrics.js'); const at = Date.UTC(2026, 0, 2, 23, 59, 40); const one = { server: 'one', game: 't6', map: 'Town', players: 4, capacity: 4 }; const two = { ...one, server: 'two' }; recordMetrics([one, two], at); // Removing a lobby ends its previous observation at the next complete collection. recordMetrics([{ ...one, map: 'Farm', players: 0 }], at + 30_000); // A collection outage is excluded; an identically named map in another game remains separate. recordMetrics([{ ...one, game: 't5', players: 2, capacity: null }], at + 150_000); const result = readMetrics(1, at + 180_000); expect(result.summary.active * 3600).toBeCloseTo(90); expect(result.summary.idle * 3600).toBeCloseTo(90); expect(result.summary.playerHours * 3600).toBeCloseTo(300); expect( result.servers.find((server) => server.server === 'two')!.observed * 3600 ).toBeCloseTo(30); const town = result.maps.find((map) => map.game === 't6' && map.map === 'Town')!; expect(town.observed * 3600).toBeCloseTo(60); expect(town.availabilityObserved * 3600).toBeCloseTo(30); expect(town.noEmpty * 3600).toBeCloseTo(30); expect( result.maps.find((map) => map.game === 't5')!.availabilityObserved * 3600 ).toBeCloseTo(30); for (const field of ['observed', 'active', 'idle', 'unknown', 'playerHours'] as const) { expect(result.points.reduce((sum, point) => sum + point[field], 0)).toBeCloseTo( result.summary[field] ); expect(result.servers.reduce((sum, server) => sum + server[field], 0)).toBeCloseTo( result.summary[field] ); expect(result.maps.reduce((sum, map) => sum + map[field], 0)).toBeCloseTo( result.summary[field] ); } }); it('does not include future samples in collection metadata or aggregates', async () => { const { recordMetrics, readMetrics } = await import('../../src/lib/server/fleet/metrics.js'); const at = Date.UTC(2026, 0, 2, 12); const sample = { server: 'one', game: 't5', map: 'Moon', players: 1, capacity: 4 }; recordMetrics([sample], at); recordMetrics([{ ...sample, players: 4 }], at + 60_000); const result = readMetrics(1, at + 30_000); expect(result.latestSampleAt).toBe(at); expect(result.summary.playerHours * 3600).toBeCloseTo(30); }); it('shares the range parser and response contract between SSR and the uncached refresh endpoint', async () => { const { recordMetrics } = await import('../../src/lib/server/fleet/metrics.js'); const { GET } = await import('../../src/routes/api/metrics/+server.js'); const { load } = await import('../../src/routes/metrics/+page.server.js'); const at = Date.UTC(2026, 0, 2, 12); recordMetrics([{ server: 'one', game: 't5', map: 'Moon', players: 2, capacity: 4 }], at); vi.spyOn(Date, 'now').mockReturnValue(at + 30_000); for (const range of ['1', '7', '30', 'invalid', '365', '-1', '']) { const url = new URL(`http://localhost/metrics?days=${range}`); const response = await GET({ url }); const payload = await response.json(); expect(response.headers.get('cache-control')).toBe('no-store'); expect(payload).toEqual(await load({ url } as Parameters[0])); expect(payload.days).toBe(['1', '7', '30'].includes(range) ? Number(range) : 7); } expect( (await (await GET({ url: new URL('http://localhost/api/metrics') })).json()).days ).toBe(7); for (const search of [ 'days=1&game=t5&map=Moon&timeZone=Asia%2FKathmandu', 'days=7&map=Moon', 'game=t6&timeZone=Invalid' ]) { const url = new URL(`http://localhost/metrics?${search}`); const payload = await (await GET({ url })).json(); const { days, ...scope } = parseMetricsQuery(url.searchParams); expect(payload).toEqual(await load({ url } as Parameters[0])); expect(payload).toMatchObject({ days, scope }); } }); it('normalizes malformed scopes consistently and requires a game for a map selection', () => { expect( parseMetricsQuery(new URLSearchParams('days=365&map=Moon&timeZone=Invalid/Zone')) ).toEqual({ days: 7, game: null, map: null, timeZone: 'America/New_York' }); expect( parseMetricsQuery(new URLSearchParams({ game: 't5; DROP TABLE', map: 'Moon' })) ).toMatchObject({ game: null, map: null }); expect( parseMetricsQuery( new URLSearchParams({ game: ' t5 ', map: 'Moon', timeZone: 'Asia/Kathmandu', days: '30' }) ) ).toEqual({ days: 30, game: 't5', map: 'Moon', timeZone: 'Asia/Kathmandu' }); for (const map of ['', ' ', 'a'.repeat(201), 'Moon\nTown']) { expect(parseMetricsQuery(new URLSearchParams({ game: 't5', map })).map).toBeNull(); } expect( parseMetricsQuery(new URLSearchParams({ game: 't5', map: 'Not recorded yet' })).map ).toBe('Not recorded yet'); }); it('filters every derived value by exact game and map while preserving unfiltered collection boundaries', async () => { const { recordMetrics, readMetrics } = await import('../../src/lib/server/fleet/metrics.js'); const now = Date.UTC(2026, 0, 3, 12); const since = now - 86400_000; const moon = { server: 'one', game: 't5', map: 'Moon', players: 4, capacity: 4 }; const other = { ...moon, server: 'other', game: 't6', players: 2 }; recordMetrics([moon, other], since - 30_000); recordMetrics([other], since); recordMetrics( [moon, other, { ...moon, server: 'town', map: 'Town', players: 0 }], now - 60_000 ); recordMetrics([other], now - 45_000); recordMetrics([{ ...moon, players: 0 }, other], now - 30_000); const query = parseMetricsQuery( new URLSearchParams({ days: '1', game: 't5', map: 'Moon', timeZone: 'UTC' }) ); const result = readMetrics(query, now); expect(result.scope).toEqual({ game: 't5', map: 'Moon', timeZone: 'UTC' }); expect(result.summary.active * 3600).toBeCloseTo(15); expect(result.summary.idle * 3600).toBeCloseTo(30); expect(result.summary.playerHours * 3600).toBeCloseTo(60); expect(result.previous.summary.active * 3600).toBeCloseTo(30); expect(result.previous.summary.playerHours * 3600).toBeCloseTo(120); expect(result.servers.map((server) => server.server)).toEqual(['one']); expect(result.maps.map((map) => [map.game, map.map])).toEqual([['t5', 'Moon']]); expect(result.scopeOptions).toEqual({ games: ['t5', 't6'], maps: [ { game: 't5', map: 'Moon' }, { game: 't5', map: 'Town' }, { game: 't6', map: 'Moon' } ] }); expect(result.scopeFirstSampleAt).toBe(since - 30_000); expect(result.scopeLatestSampleAt).toBe(now - 30_000); expect(result.pooledAvailability.value).toBeCloseTo(100 / 3); expect(result.running.average).toBe(1); expect(result.running.peak).toBe(1); expect(result.running.capturedHours * 3600).toBeCloseTo(45); expect(result.previous.running.average).toBe(1); expect(result.previous.running.capturedHours * 3600).toBeCloseTo(30); for (const field of ['observed', 'active', 'idle', 'unknown', 'playerHours'] as const) { expect(result.points.reduce((sum, point) => sum + point[field], 0)).toBeCloseTo( result.summary[field] ); expect(result.heatmap.cells.reduce((sum, cell) => sum + cell[field], 0)).toBeCloseTo( result.summary[field] ); } const gameOnly = readMetrics({ ...query, map: null }, now); expect(gameOnly.maps.map((map) => map.map).sort()).toEqual(['Moon', 'Town']); expect(gameOnly.running.average).toBeCloseTo(4 / 3); expect(gameOnly.running.peak).toBe(2); expect(gameOnly.running.capturedHours * 3600).toBeCloseTo(45); const missing = readMetrics({ ...query, map: 'Unrecorded' }, now); expect(missing).toMatchObject({ scopeFirstSampleAt: null, scopeLatestSampleAt: null, summary: { observed: 0, unknown: 0 }, maps: [], servers: [], pooledAvailability: { value: null }, running: { average: null, peak: 0, capturedHours: 0 }, previous: { firstSampleAt: null, lastSampleAt: null, partialHistory: true } }); expect(missing.scopeOptions).toEqual(result.scopeOptions); }); it('separates scoped evidence timestamps from global collection timestamps', async () => { const { recordMetrics, readMetrics } = await import('../../src/lib/server/fleet/metrics.js'); const at = Date.UTC(2026, 0, 3, 12); const sample = { server: 'one', game: 't5', map: 'Moon', players: 1, capacity: 4 }; recordMetrics([{ ...sample, game: 't6' }], at - 60_000); recordMetrics([sample], at - 30_000); recordMetrics([{ ...sample, game: 't6' }], at); const result = readMetrics( parseMetricsQuery(new URLSearchParams({ game: 't5' })), at + 30_000 ); expect(result.firstRetainedSampleAt).toBe(at - 60_000); expect(result.latestSampleAt).toBe(at); expect(result.scopeFirstSampleAt).toBe(at - 30_000); expect(result.scopeLatestSampleAt).toBe(at - 30_000); }); it('averages concurrent tracked lobbies over captured wall time, including unknowns and excluding gaps', async () => { const { recordMetrics, readMetrics } = await import('../../src/lib/server/fleet/metrics.js'); const now = Date.UTC(2026, 0, 3, 12); const since = now - 86400_000; const one = { server: 'one', game: 't5', map: 'Moon', players: 1, capacity: null }; const two = { ...one, server: 'two', players: 0 }; const three = { ...one, server: 'three', players: null }; const four = { ...one, server: 'four', players: null }; recordMetrics([one, two, three, four], since - 120_000); recordMetrics([one, two, three], now - 180_000); recordMetrics([three], now - 150_000); recordMetrics([one, two], now - 30_000); const result = readMetrics(1, now); expect(result.running.average).toBeCloseTo(1.6); expect(result.running.peak).toBe(3); expect(result.running.capturedHours * 3600).toBeCloseTo(150); expect(result.previous.running.average).toBe(4); expect(result.previous.running.peak).toBe(4); expect(result.previous.running.capturedHours * 3600).toBeCloseTo(90); expect(result.points.reduce((sum, point) => sum + point.capturedHours, 0)).toBeCloseTo( result.running.capturedHours ); const point = result.points.find((point) => point.capturedHours > 0)!; expect((point.observed + point.unknown) / point.capturedHours).toBeCloseTo(1.6); expect(result.summary.observed + result.summary.unknown).toBeCloseTo( result.running.average! * result.running.capturedHours ); }); it('matches current policy context through map aliases and prefers the durable owner', async () => { const { recordMetrics, readMetrics } = await import('../../src/lib/server/fleet/metrics.js'); const { db } = await import('../../src/lib/server/db/index.js'); const at = Date.UTC(2026, 0, 3, 12); const insert = db().prepare( `INSERT INTO autoscaling_policies (id,game,map_slug,settings,mode,paused_reason,owns_map,updated_at) VALUES (?,?,?,?,?,?,?,?)` ); insert.run( 't5-all', 't5', '*', JSON.stringify({ minimum: 1, maximum: 4, spares: 2 }), 'create-destroy', null, 1, 'now' ); insert.run( 'moon', 't5', 'moon', JSON.stringify({ minimum: 1, maximum: 2, spares: 1 }), 'off', 'Operator paused.', 0, 'now' ); insert.run( 'town', 't6', 'town', JSON.stringify({ minimum: 2, maximum: 3, spares: 1 }), 'create-destroy', null, 1, 'now' ); const sample = { server: 'one', game: 't5', map: 'Moon', players: 1, capacity: null }; recordMetrics( [ sample, { ...sample, server: 'two', map: 'Kino Der Toten' }, { ...sample, server: 'three', game: 't6', map: 'Town Survival' }, { ...sample, server: 'four', game: 't6', map: 'zm_transit' }, { ...sample, server: 'five', game: 't6', map: 'Moon' }, { ...sample, server: 'six', map: 'zombie_cosmodrome' }, { ...sample, server: 'seven', map: 'unknown-map' } ], at ); const result = readMetrics(1, at + 30_000); expect(result.maps.find((map) => map.game === 't5' && map.map === 'Moon')!.policy).toEqual({ id: 't5-all', mode: 'create-destroy', minimum: 1, maximum: 4, spares: 2, pausedReason: null }); expect(result.maps.find((map) => map.map === 'Kino Der Toten')!.policy?.id).toBe('t5-all'); expect(result.maps.find((map) => map.map === 'zombie_cosmodrome')!.policy?.id).toBe( 't5-all' ); expect(result.maps.find((map) => map.map === 'Town Survival')!.policy?.id).toBe('town'); expect(result.maps.find((map) => map.map === 'zm_transit')!.policy).toBeNull(); expect( result.maps.find((map) => map.game === 't6' && map.map === 'Moon')!.policy ).toBeNull(); expect(result.maps.find((map) => map.map === 'unknown-map')!.policy).toBeNull(); db().prepare('UPDATE autoscaling_policies SET owns_map=0').run(); expect( readMetrics(1, at + 30_000).maps.find((map) => map.game === 't5' && map.map === 'Moon')! .policy ).toMatchObject({ id: 'moon', mode: 'off', pausedReason: 'Operator paused.' }); db().prepare("INSERT INTO settings(key,value) VALUES ('autoscaling_paused','1')").run(); expect( readMetrics(1, at + 30_000).maps.find((map) => map.game === 't5' && map.map === 'Moon')! .policy?.pausedReason ).toBe('Autoscaling is globally paused. Operator paused.'); }); it('compares no-empty intervals with the current maximum, counting unknown lobbies in the lineup', async () => { const { recordMetrics, readMetrics } = await import('../../src/lib/server/fleet/metrics.js'); const { db } = await import('../../src/lib/server/db/index.js'); const at = Date.UTC(2026, 0, 3, 12); db() .prepare( `INSERT INTO autoscaling_policies (id,game,map_slug,settings,mode,owns_map,updated_at) VALUES (?,?,?,?,?,?,?)` ) .run( 'moon', 't5', 'moon', JSON.stringify({ minimum: 1, maximum: 2, spares: 1 }), 'create-destroy', 1, 'now' ); const one = { server: 'one', game: 't5', map: 'Moon', players: 1, capacity: null }; const two = { ...one, server: 'two', players: null }; recordMetrics([one], at); recordMetrics([one, two], at + 30_000); recordMetrics([one, two, { ...one, server: 'three' }], at + 60_000); recordMetrics([one, { ...two, players: 0 }], at + 90_000); recordMetrics([{ ...one, players: null }, two], at + 120_000); const result = readMetrics(1, at + 150_000); expect(result.maps[0].noEmpty * 3600).toBeCloseTo(90); expect(result.maps[0].noEmptyAtMaximum * 3600).toBeCloseTo(60); expect(result.maps[0].noEmptyBelowMaximum * 3600).toBeCloseTo(30); expect(result.maps[0].availabilityObserved * 3600).toBeCloseTo(120); expect(result.maps[0].availabilityUnknown * 3600).toBeCloseTo(30); db() .prepare("UPDATE autoscaling_policies SET settings=json_set(settings,'$.maximum',4)") .run(); const revised = readMetrics(1, at + 150_000).maps[0]; expect(revised.noEmptyAtMaximum).toBe(0); expect(revised.noEmptyBelowMaximum * 3600).toBeCloseTo(90); }); it('compares adjacent equal windows and splits an observation at their shared boundary', async () => { const { recordMetrics, readMetrics } = await import('../../src/lib/server/fleet/metrics.js'); const now = Date.UTC(2026, 0, 3, 12); const since = now - 86400_000; const sample = { server: 'one', game: 't5', map: 'Moon', players: 2, capacity: 4 }; recordMetrics([sample], since - 15_000); recordMetrics([{ ...sample, players: 0 }], since + 15_000); const result = readMetrics(1, now); expect(result.previous.since).toBe(now - 2 * 86400_000); expect(result.previous.until).toBe(result.since); expect(result.previous.summary.active * 3600).toBeCloseTo(15); expect(result.summary.active * 3600).toBeCloseTo(15); expect(result.summary.idle * 3600).toBeCloseTo(90); expect(result.previous).toMatchObject({ firstSampleAt: since - 15_000, lastSampleAt: since - 15_000, partialHistory: true }); }); it('returns the available prior evidence when a thirty-day comparison exceeds retained history', async () => { const { recordMetrics, readMetrics } = await import('../../src/lib/server/fleet/metrics.js'); const now = Date.UTC(2026, 2, 3, 12); const day = 86400_000; const sample = { server: 'one', game: 't5', map: 'Moon', players: 2, capacity: 4 }; recordMetrics([sample], now - 31 * day + 30_000); recordMetrics([{ ...sample, players: 0 }], now - 30 * day + 30_000); recordMetrics([sample], now - 30_000); const result = readMetrics(30, now); expect(result.previous).toMatchObject({ since: now - 60 * day, until: now - 30 * day, partialHistory: true }); expect(result.previous.summary.active * 3600).toBeCloseTo(90); expect(result.previous.firstSampleAt).toBe(now - 31 * day + 30_000); expect(result.summary.active * 3600).toBeCloseTo(30); expect(result.summary.idle * 3600).toBeCloseTo(90); }); it('marks the retained start bound separately from gaps inside the previous window', async () => { const { recordMetrics, readMetrics } = await import('../../src/lib/server/fleet/metrics.js'); const now = Date.UTC(2026, 0, 3, 12); const priorStart = now - 2 * 86400_000; const sample = { server: 'one', game: 't5', map: 'Moon', players: null, capacity: null }; recordMetrics([sample], priorStart - 15_000); recordMetrics([sample], now - 30_000); const result = readMetrics(1, now); expect(result.previous.partialHistory).toBe(false); expect(result.previous.summary.unknown * 3600).toBeCloseTo(75); expect(result.previous.summary.observed).toBe(0); expect(result.previous.firstSampleAt).toBe(priorStart - 15_000); // Having the left edge says nothing about the uncaptured time after it. expect(result.previous.summary.unknown).toBeLessThan(24); }); it('pools availability by known map-hours rather than lobby-hours or fleet elapsed time', async () => { const { recordMetrics, readMetrics } = await import('../../src/lib/server/fleet/metrics.js'); const at = Date.UTC(2026, 0, 3, 12); const occupied = { server: 'one', game: 't5', map: 'Moon', players: 1, capacity: 4 }; recordMetrics( [ occupied, { ...occupied, server: 'two' }, { ...occupied, server: 'three', map: 'Town', players: 0 }, { ...occupied, server: 'four', map: 'Farm', players: 2, capacity: null }, { ...occupied, server: 'five', map: 'Unknown', players: null } ], at ); const result = readMetrics(1, at + 30_000); expect(result.pooledAvailability).toMatchObject({ coveredMaps: 3, totalMaps: 4 }); expect(result.pooledAvailability.value).toBeCloseTo(200 / 3); expect(result.pooledAvailability.noEmptyMapHours * 3600).toBeCloseTo(60); expect(result.pooledAvailability.knownMapHours * 3600).toBeCloseTo(90); expect(result.pooledAvailability.unknownMapHours * 3600).toBeCloseTo(30); expect(result.summary.observed * 3600).toBeCloseTo(120); const empty = readMetrics( parseMetricsQuery(new URLSearchParams({ days: '1', game: 't5', map: 'Town' })), at + 30_000 ); expect(empty.pooledAvailability.value).toBe(0); const missingCapacity = readMetrics( parseMetricsQuery(new URLSearchParams({ days: '1', game: 't5', map: 'Farm' })), at + 30_000 ); expect(missingCapacity.pooledAvailability.value).toBe(100); const unknown = readMetrics( parseMetricsQuery(new URLSearchParams({ days: '1', game: 't5', map: 'Unknown' })), at + 30_000 ); expect(unknown.pooledAvailability.value).toBeNull(); }); it.each([ { zone: 'America/New_York', at: '2026-03-08T06:59:45Z', first: [6, 1], second: [6, 3] }, { zone: 'Asia/Kolkata', at: '2026-01-01T18:29:45Z', first: [3, 23], second: [4, 0] }, { zone: 'Asia/Kathmandu', at: '2026-01-01T18:14:45Z', first: [3, 23], second: [4, 0] }, { zone: 'Australia/Lord_Howe', at: '2026-10-03T15:29:45Z', first: [6, 1], second: [6, 2] } ])( 'splits heatmap time across local boundaries in $zone', async ({ zone, at: timestamp, first, second }) => { const { recordMetrics, readMetrics } = await import('../../src/lib/server/fleet/metrics.js'); const at = Date.parse(timestamp); recordMetrics( [{ server: 'one', game: 't5', map: 'Moon', players: 2, capacity: 4 }], at ); const result = readMetrics( parseMetricsQuery(new URLSearchParams({ days: '1', timeZone: zone })), at + 30_000 ); expect(result.heatmap.timeZone).toBe(zone); expect(result.heatmap.cells).toHaveLength(168); for (const [weekday, hour] of [first, second]) { const cell = result.heatmap.cells.find( (cell) => cell.weekday === weekday && cell.hour === hour )!; expect(cell.observed * 3600).toBeCloseTo(15); expect(cell.playerHours * 3600).toBeCloseTo(30); expect(cell.distinctDates).toBe(1); } expect(result.heatmap.cells.reduce((sum, cell) => sum + cell.observed, 0)).toBeCloseTo( result.summary.observed ); } ); it('weights repeated fall-back hours and counts their local date once', async () => { const { recordMetrics, readMetrics } = await import('../../src/lib/server/fleet/metrics.js'); const at = Date.parse('2026-11-01T05:30:00Z'); const sample = { server: 'one', game: 't5', map: 'Moon', players: 2, capacity: 4 }; recordMetrics([sample], at); recordMetrics([{ ...sample, players: 0 }], at + 3600_000); const result = readMetrics( parseMetricsQuery(new URLSearchParams({ days: '1', timeZone: 'America/New_York' })), at + 3630_000 ); const cell = result.heatmap.cells.find((cell) => cell.weekday === 6 && cell.hour === 1)!; expect(cell.observed * 3600).toBeCloseTo(120); expect(cell.active * 3600).toBeCloseTo(90); expect(cell.idle * 3600).toBeCloseTo(30); expect(cell.distinctDates).toBe(1); expect(result.heatmap.cells.filter((cell) => cell.observed > 0)).toHaveLength(1); }); it('counts unknown heatmap evidence and distinguishes retained names from current registrations', async () => { const { recordMetrics, readMetrics } = await import('../../src/lib/server/fleet/metrics.js'); const { db } = await import('../../src/lib/server/db/index.js'); const at = Date.UTC(2026, 0, 3, 12); const sample = { server: 'reused', game: 't5', map: 'Moon', players: null, capacity: null }; recordMetrics( [sample, { ...sample, server: 'removed' }, { ...sample, server: 'tombstone' }], at ); // A newly registered name can link to the current server without asserting // that its earlier historical observations belong to this registration. db() .prepare('INSERT INTO servers (name, created_at) VALUES (?, ?)') .run('reused', new Date(at + 15_000).toISOString()); db() .prepare('INSERT INTO servers (name, destroyed_at) VALUES (?, ?)') .run('tombstone', new Date(at).toISOString()); const result = readMetrics(1, at + 30_000); expect(result.servers.find((server) => server.server === 'reused')!.currentRegistered).toBe( true ); expect( result.servers .filter((server) => server.server !== 'reused') .every((server) => !server.currentRegistered) ).toBe(true); expect( result.heatmap.cells.reduce((sum, cell) => sum + cell.unknown, 0) * 3600 ).toBeCloseTo(90); expect(result.heatmap.cells.reduce((sum, cell) => sum + cell.observed, 0)).toBe(0); expect(result.heatmap.cells.find((cell) => cell.unknown > 0)!.distinctDates).toBe(1); }); });