import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { newFixture, type Fixture } from '../helpers/fixture.js'; import type { CapacityQuery } from '../../src/lib/capacity.js'; let fixture: Fixture; const epoch = Date.UTC(2026, 9, 5, 12); const GiB = 1024 ** 3; const query: CapacityQuery = { range: '1h', hostId: null, game: 'all' }; beforeEach(async () => { vi.resetModules(); fixture = await newFixture([]); }); afterEach(async () => { vi.restoreAllMocks(); await fixture.cleanup(); }); async function host(id: string, createdAt = epoch - 7 * 86_400_000) { const { db } = await import('../../src/lib/server/db/index.js'); db() .prepare('INSERT INTO ec_hosts (id,name,created_at) VALUES (?,?,?)') .run(id, id, new Date(createdAt).toISOString()); } async function sample(id: string, at: number, cpu = 25, memory = 4 * GiB, cores = 8) { const { db } = await import('../../src/lib/server/db/index.js'); db() .prepare( `INSERT INTO ec_host_resource_samples (host_id,sampled_at,cpu_count,cpu_usage_percent,memory_total,memory_available) VALUES (?,?,?,?,?,?)` ) .run(id, new Date(at).toISOString(), cores, cpu, 16 * GiB, memory); } describe('capacity history', () => { it('keeps separate CPU series, device drill-down, real zero and missing temperature gaps', async () => { await host('one'); await host('two'); await host('unsupported'); await host('removed'); const { recordTemperatures } = await import('../../src/lib/server/hosts/temperatures.js'); const cpu = (celsius: number) => ({ id: 'package', kind: 'cpu-package' as const, device: 'coretemp', label: 'Package id 0', celsius }); const drive = { id: 'drive', kind: 'nvme' as const, device: 'nvme', label: 'Composite', celsius: 50.9 }; recordTemperatures( 'one', { temperatures: [cpu(65), drive] }, new Date(epoch - 55_000).toISOString() ); recordTemperatures( 'one', { temperatures: [cpu(75)] }, new Date(epoch - 30_000).toISOString() ); recordTemperatures( 'one', { temperatures: [cpu(0)] }, new Date(epoch - 180_000).toISOString() ); recordTemperatures( 'two', { temperatures: [cpu(60)] }, new Date(epoch - 40_000).toISOString() ); recordTemperatures( 'removed', { temperatures: [cpu(99)] }, new Date(epoch - 40_000).toISOString() ); const { db } = await import('../../src/lib/server/db/index.js'); db() .prepare('UPDATE ec_hosts SET deleted_at=? WHERE id=?') .run(new Date(epoch).toISOString(), 'removed'); const { readCapacityHistory } = await import('../../src/lib/server/hosts/capacity-history.js'); const fleet = readCapacityHistory(query, epoch).temperatures; expect(fleet.map((s) => s.label)).toEqual(['one', 'two', 'unsupported']); expect(fleet[0].points.slice(-3).map((p) => p.celsius)).toEqual([0, null, 75]); expect(fleet[1].points.at(-1)?.celsius).toBe(60); expect(fleet[2].points.every((p) => p.celsius === null)).toBe(true); const selected = readCapacityHistory( { ...query, hostId: 'one', game: 't5' }, epoch ).temperatures; expect(selected.map((s) => s.kind)).toEqual(['cpu', 'nvme']); expect(selected[1].points.at(-1)?.celsius).toBe(50.9); expect( readCapacityHistory({ ...query, hostId: 'one', game: 't6' }, epoch).temperatures ).toEqual(selected); expect(readCapacityHistory({ ...query, hostId: 'missing' }, epoch).temperatures).toEqual( [] ); }); it('preserves temperature peaks for long ranges and includes a host enrolled during the current minute', async () => { await host('new', epoch - 20_000); const { recordTemperatures } = await import('../../src/lib/server/hosts/temperatures.js'); recordTemperatures( 'new', { temperatures: [ { id: 'pkg', device: 'coretemp', kind: 'cpu-package', label: 'Package id 0', celsius: 65 } ] }, new Date(epoch - 10_000).toISOString() ); const { readCapacityHistory } = await import('../../src/lib/server/hosts/capacity-history.js'); expect(readCapacityHistory(query, epoch).temperatures[0].points.at(-1)?.celsius).toBe(65); const long = readCapacityHistory({ ...query, range: '7d' }, epoch).temperatures[0]; expect(long.points).toHaveLength(168); expect(long.points.at(-1)?.celsius).toBe(65); }); it('averages each host before summing fleet headroom and exposes missing observations as gaps', async () => { await host('one'); await host('two'); await sample('one', epoch - 55_000, 0, 8 * GiB); await sample('one', epoch - 30_000, 50, 4 * GiB); await sample('two', epoch - 45_000, 50, 2 * GiB, 4); await sample('one', epoch - 90_000); const { readCapacityHistory } = await import('../../src/lib/server/hosts/capacity-history.js'); const history = readCapacityHistory(query, epoch); expect(history.points).toHaveLength(60); expect(history.points.at(-1)).toMatchObject({ at: epoch - 60_000, memoryBytesAvailable: 8 * GiB - 1_000_000_000, reportingHosts: 2, expectedHosts: 2 }); expect(history.points.at(-1)?.cpuCoresAvailable).toBeCloseTo(7.4); expect(history.points.at(-2)).toMatchObject({ reportingHosts: 1, expectedHosts: 2, cpuCoresAvailable: null, memoryBytesAvailable: null }); expect(history.points[0]).toMatchObject({ reportingHosts: 0, expectedHosts: 2, cpuCoresAvailable: null, memoryBytesAvailable: null }); }); it('scopes history to the selected registered host and does not add hosts before they existed', async () => { await host('one'); await host('two', epoch - 30_000); await host('removed'); await sample('one', epoch - 90_000); await sample('one', epoch - 15_000); await sample('two', epoch - 15_000); await sample('removed', epoch - 15_000); const { db } = await import('../../src/lib/server/db/index.js'); db() .prepare('UPDATE ec_hosts SET deleted_at=? WHERE id=?') .run(new Date(epoch).toISOString(), 'removed'); const { readCapacityHistory } = await import('../../src/lib/server/hosts/capacity-history.js'); const fleet = readCapacityHistory(query, epoch); expect(fleet.points.at(-2)).toMatchObject({ reportingHosts: 1, expectedHosts: 1 }); expect(fleet.points.at(-1)).toMatchObject({ reportingHosts: 2, expectedHosts: 2 }); const selected = readCapacityHistory({ ...query, hostId: 'one', game: 't6' }, epoch); expect(selected.points.at(-1)).toMatchObject({ reportingHosts: 1, expectedHosts: 1, memoryBytesAvailable: 4 * GiB - 500_000_000 }); const missing = readCapacityHistory({ ...query, hostId: 'unknown' }, epoch); expect( missing.points.every( (point) => point.expectedHosts === 0 && point.cpuCoresAvailable === null ) ).toBe(true); }); it('excludes invalid telemetry and keeps measured exhaustion as zero rather than a gap', async () => { await host('one'); await sample('one', epoch - 45_000, 100, 100_000_000); await sample('one', epoch - 30_000, -1); await sample('one', epoch - 15_000, 10, 17 * GiB); await sample('one', epoch - 90_000, 101); const { readCapacityHistory } = await import('../../src/lib/server/hosts/capacity-history.js'); const history = readCapacityHistory(query, epoch); expect(history.points.at(-1)).toMatchObject({ cpuCoresAvailable: 0, memoryBytesAvailable: 0, reportingHosts: 1 }); expect(history.points.at(-2)).toMatchObject({ cpuCoresAvailable: null, memoryBytesAvailable: null, reportingHosts: 0 }); }); it('bounds long ranges and clips samples outside the interval including future telemetry', async () => { await host('one'); await sample('one', epoch - 7 * 86_400_000 - 1); await sample('one', epoch - 7 * 86_400_000, 0); await sample('one', epoch); await sample('one', epoch + 1, 100); const { readCapacityHistory } = await import('../../src/lib/server/hosts/capacity-history.js'); const history = readCapacityHistory({ ...query, range: '7d' }, epoch); expect(history.points).toHaveLength(168); expect(history.bucketMs).toBe(3_600_000); expect(history.points[0].cpuCoresAvailable).toBeCloseTo(7.6); expect(history.points.at(-1)?.cpuCoresAvailable).toBeCloseTo(5.6); expect(readCapacityHistory({ ...query, range: '24h' }, epoch).points).toHaveLength(144); }); it('reads history on a separate worker and refreshes its bounded cache after 30 seconds', async () => { await host('one'); await sample('one', epoch - 15_000); const now = vi.spyOn(Date, 'now').mockReturnValue(epoch); const { capacityHistoryReport } = await import('../../src/lib/server/reports/index.js'); const { readCapacityHistory } = await import('../../src/lib/server/hosts/capacity-history.js'); const first = capacityHistoryReport(query); const winner = await Promise.race([ first.then(() => 'report'), new Promise((resolve) => setTimeout(() => resolve('timer'), 0)) ]); expect(winner).toBe('timer'); expect(await first).toEqual(readCapacityHistory(query, epoch)); const { db } = await import('../../src/lib/server/db/index.js'); db().prepare('UPDATE ec_host_resource_samples SET cpu_usage_percent=100').run(); expect(await capacityHistoryReport(query)).toEqual(await first); now.mockReturnValue(epoch + 30_000); expect((await capacityHistoryReport(query)).points.at(-1)?.cpuCoresAvailable).toBe(0); }); it('opens the history worker on a fresh database before any host has enrolled', async () => { const { capacityHistoryReport } = await import('../../src/lib/server/reports/index.js'); const history = await capacityHistoryReport(query); expect(history.points).toHaveLength(60); expect( history.points.every( (point) => point.expectedHosts === 0 && point.cpuCoresAvailable === null ) ).toBe(true); }); });