import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { newFixture, type Fixture } from '../helpers/fixture.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(45); expect(result.summary.playerHours * 3600).toBeCloseTo(60); expect(result.points.reduce((sum, p) => sum + p.observed, 0)).toBeCloseTo(result.summary.observed); }); it('only counts map saturation when every lobby has known capacity and is full', 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, { ...sample, server: 'two', players: 2 }], at + 30_000); recordMetrics([sample, { ...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].full * 3600).toBeCloseTo(30); expect(result.maps[0].capacityObserved * 3600).toBeCloseTo(60); }); 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(30); 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, komodo_name, destroyed_at) VALUES (?, ?, ?)').run('current', 'current', null); db().prepare('INSERT INTO servers (name, komodo_name, destroyed_at) VALUES (?, ?, ?)').run('removed', '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(45); expect(later.maps[0].capacityObserved).toBe(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 pressure 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 + 120_000); const result = readMetrics(1, at + 150_000); expect(result.summary.active * 3600).toBeCloseTo(90); expect(result.summary.idle * 3600).toBeCloseTo(45); 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.capacityObserved * 3600).toBeCloseTo(30); expect(town.full * 3600).toBeCloseTo(30); expect(result.maps.find(map => map.game === 't5')!.capacityObserved).toBe(0); 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 = 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 GET({ url: new URL('http://localhost/api/metrics') }).json()).days).toBe(7); }); });