Files
electric-cherry/tests/unit/ui-metrics-page.test.ts
T
2026-10-10 16:11:24 -04:00

419 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { beforeEach, expect, it, vi } from 'vitest';
import { render } from 'svelte/server';
import type { MetricsData, MapMetrics, ServerMetrics } from '../../src/lib/metrics.js';
import MetricsPage from '../../src/routes/metrics/+page.svelte';
import MetricsSummary from '../../src/lib/ui/components/metrics/MetricsSummary.svelte';
import MetricsTables from '../../src/lib/ui/components/metrics/MetricsTables.svelte';
import MetricsExplore from '../../src/lib/ui/components/metrics/MetricsExplore.svelte';
import MetricsChart from '../../src/lib/ui/components/metrics/MetricsChart.svelte';
import { metricPage } from '../../src/lib/ui/metrics.js';
import { page } from '$app/state';
vi.mock('$app/state', () => ({ page: { url: new URL('http://localhost/metrics'), state: {} } }));
vi.mock('$app/navigation', () => ({
beforeNavigate: vi.fn(),
afterNavigate: vi.fn(),
goto: vi.fn(),
replaceState: vi.fn()
}));
const map = (i: number, changes: Partial<MapMetrics> = {}): MapMetrics => ({
game: 't5',
map: `Map ${String(i).padStart(2, '0')}`,
observed: 10,
active: 5,
idle: 5,
unknown: 0,
playerHours: i,
noEmpty: 1,
availabilityObserved: 4,
availabilityUnknown: 0,
noEmptyAtMaximum: 0,
noEmptyBelowMaximum: 0,
policy: null,
...changes
});
const server = (i: number): ServerMetrics => ({
server: `t5_zombie_theater_${i}`,
currentRegistered: i !== 1,
observed: 10,
active: i / 10,
idle: 10 - i / 10,
unknown: 0,
playerHours: i
});
function report(): MetricsData {
const now = Date.parse('2026-10-06T12:00:00Z');
const summary = { observed: 10, active: 5, idle: 5, unknown: 2, playerHours: 20 };
return {
days: 7,
since: now - 7 * 86400000,
now,
firstRetainedSampleAt: now - 14 * 86400000,
latestSampleAt: now,
registeredServerCount: 24,
autoscalingPaused: false,
scope: { game: null, map: null, timeZone: 'America/New_York' },
scopeOptions: { games: ['t5'], maps: [] },
scopeFirstSampleAt: now - 14 * 86400000,
scopeLatestSampleAt: now,
summary,
running: { average: 12, peak: 24, capturedHours: 1 },
maps: Array.from({ length: 24 }, (_, i) => map(i + 1)),
servers: Array.from({ length: 24 }, (_, i) => server(i + 1)),
points: [
{
...summary,
at: now - 86400000,
startAt: now - 86400000,
endAt: now,
partial: true,
capturedHours: 1
}
],
previous: {
since: now - 14 * 86400000,
until: now - 7 * 86400000,
summary: { ...summary, active: 4, idle: 6, playerHours: 10 },
running: { average: 12, peak: 24, capturedHours: 1 },
firstSampleAt: now - 14 * 86400000,
lastSampleAt: now - 7 * 86400000,
partialHistory: true
},
pooledAvailability: {
value: 25,
noEmptyMapHours: 1,
knownMapHours: 4,
unknownMapHours: 0,
coveredMaps: 24,
totalMaps: 24
},
heatmap: { timeZone: 'America/New_York', cells: [] }
};
}
function scope(search: string, stateSearch?: string) {
Object.assign(page, {
url: new URL(`http://localhost/metrics${search}`),
state: stateSearch ? { metricsTableSearch: stateSearch } : {}
});
}
beforeEach(() => scope(''));
it('shows a summary and one paginated breakdown, preserving scope on range links', () => {
scope('?game=t5&days=7&view=maps&mapPage=2');
const body = render(MetricsPage, {
props: { data: { ...report(), user: null, setupRequired: false } }
}).body;
expect(body).toContain('aria-label="Metrics breakdown"');
expect(body).toContain('aria-label="Map metrics table"');
expect(body).not.toContain('aria-label="Instance metrics table"');
expect(body.replace(/\s+/g, ' ')).toContain('16–24 of 24 maps');
expect(body).toContain('game=t5&amp;days=30&amp;view=maps');
expect(body).toContain('Partial previous history');
expect(body).toContain('About this report');
expect(body).toContain('Average lobbies running:');
expect(body.replace(/\s+/g, ' ')).toContain('Captured wall time:');
expect(body).not.toContain('Percentage points show');
expect(body).not.toContain('Explore this period');
expect(body).toContain('Updates every minute');
expect(body).not.toContain('>Refresh<');
const heatmap = body.slice(
body.indexOf('class="heatmap'),
body.indexOf('aria-label="Metrics breakdown"')
);
expect(heatmap).toContain('<h2');
expect(heatmap).toContain('Activity by weekday and hour');
expect(body).not.toContain('class="weekly');
});
it('shows concurrent lobbies and empty spare share with unknown lobby-hours in the denominator', () => {
const data = report();
let body = render(MetricsSummary, { props: { report: data } }).body;
expect(body).toContain('12 avg');
expect(body).toContain('Peak 24 lobbies at once');
expect(body.replace(/\s+/g, ' ')).toContain(
'5 h of 12 h captured lobby-hours were empty spares (41.7%)'
);
expect(body).toContain('+100%');
expect(body).toContain('Time with no empty lobby');
expect(body).toContain('25.0%');
expect(body).not.toContain('capacity data');
data.pooledAvailability = {
value: null,
noEmptyMapHours: 0,
knownMapHours: 0,
unknownMapHours: 4,
coveredMaps: 0,
totalMaps: 24
};
data.maps = [map(1, { availabilityObserved: 0, availabilityUnknown: 4 })];
data.previous.summary.playerHours = 0;
body = render(MetricsSummary, { props: { report: data } }).body;
expect(body).toContain('Lobby availability unavailable');
expect(body).toContain('New · no previous player-hours');
expect(body).not.toContain('Infinity');
data.summary = { observed: 0, active: 0, idle: 0, playerHours: 0, unknown: 12 };
body = render(MetricsSummary, { props: { report: data } }).body;
expect(body).toContain('Comparison unavailable');
expect(body).toContain('12 avg');
expect(body.replace(/\s+/g, ' ')).toContain(
'0 h of 12 h captured lobby-hours were empty spares (0.0%)'
);
data.running = { average: null, peak: 0, capturedHours: 0 };
data.summary.unknown = 0;
body = render(MetricsSummary, { props: { report: data } }).body;
expect(body).toContain('Peak unavailable');
expect(body).not.toContain('Peak 0 lobbies');
});
it('searches and sorts the complete map set before pagination and clamps invalid pages', () => {
const data = report();
scope('?mapPage=2');
let body = render(MetricsTables, { props: { maps: data.maps, servers: data.servers } }).body;
expect(body).toContain('Map 09');
expect(body).not.toContain('Map 24');
scope('?mapPage=2', '?mapQ=Map+24&mapPage=100&mapSort=playerHours');
body = render(MetricsTables, { props: { maps: data.maps, servers: data.servers } }).body;
expect(body).toContain('Map 24');
expect(body.replace(/\s+/g, ' ')).toContain('1–1 of 1 maps');
expect(body).not.toContain('>Next<');
expect(metricPage(data.maps, '-1').page).toBe(1);
expect(metricPage(data.maps, 'Infinity').page).toBe(1);
expect(metricPage([], '20')).toMatchObject({ page: 1, start: 0, end: 0, total: 0 });
});
it('keeps unknown availability last when sorting and distinguishes it from known empty lobbies', () => {
scope('?mapSort=noEmpty&mapDirection=asc');
const maps = [
map(1, { availabilityObserved: 0, availabilityUnknown: 4 }),
map(2, { noEmpty: 0 }),
map(3)
];
const body = render(MetricsTables, { props: { maps, servers: [] } }).body;
expect(body.indexOf('Map 02')).toBeLessThan(body.indexOf('Map 03'));
expect(body.indexOf('Map 03')).toBeLessThan(body.indexOf('Map 01'));
expect(body).toContain('0.0%');
expect(body).toContain('Lobby availability unavailable');
expect(body).toContain('No empty lobby');
expect(body).not.toContain('All lobbies full');
});
it('preserves instance return context and distinguishes historic names without current links', () => {
scope('?view=instances&serverQ=theater&days=30');
const body = render(MetricsTables, {
props: { maps: [], servers: [server(1), server(2)], view: 'instances' }
}).body;
expect(body).toContain('Historical');
expect(body).not.toContain('href="/servers/t5_zombie_theater_1');
expect(body).toContain('href="/servers/t5_zombie_theater_2?returnTo=');
expect(body).toContain('serverQ%3Dtheater');
expect(body).not.toContain('>Idle</button>');
expect(body).not.toContain('Open current server');
});
it('highlights at most three supported policy changes and collapses tentative findings', () => {
const data = report();
data.maps.push(map(25, { observed: 0.1, active: 0.1, idle: 0, availabilityObserved: 0 }));
const body = render(MetricsExplore, { props: { report: data } }).body;
const fold = body.indexOf('Other findings');
expect(body.slice(0, fold).match(/class="recommendation /g)).toHaveLength(3);
expect(body.indexOf('Map 25')).toBeGreaterThan(fold);
expect(body).toContain('Limited evidence');
expect(body).not.toMatch(/<details[^>]*open/);
data.maps = [map(1, { availabilityObserved: 0 })];
expect(render(MetricsExplore, { props: { report: data } }).body).toContain(
'No policy changes meet the evidence threshold'
);
});
it('shows a global pause once while keeping Kino’s shortage ahead of quiet maps', () => {
const data = report();
data.autoscalingPaused = true;
const policy = {
id: 't5-all',
mode: 'create-destroy' as const,
minimum: 1,
maximum: 3,
spares: 1,
pausedReason: null
};
data.maps = [
map(1, {
map: 'Kino Der Toten',
policy,
observed: 100,
active: 10,
idle: 90,
availabilityObserved: 100,
noEmpty: 4.1,
noEmptyAtMaximum: 4.1
}),
...Array.from({ length: 5 }, (_, i) => map(i + 2, { policy, noEmpty: 0 }))
];
let body = render(MetricsExplore, { props: { report: data } }).body;
expect(body.match(/Autoscaling is globally paused\./g)).toHaveLength(1);
expect(body).toContain('Consider raising the policy maximum');
expect(body).toContain('4.1%');
expect(body).toContain('href="/autoscaling#t5-all"');
expect(body).not.toContain('Review and resume the autoscaling policy');
const highlighted = body.slice(0, body.indexOf('Other findings'));
expect(highlighted.match(/class="recommendation /g)).toHaveLength(1);
expect(highlighted).toContain('Kino Der Toten');
data.autoscalingPaused = false;
body = render(MetricsExplore, { props: { report: data } }).body;
expect(body).not.toContain('Autoscaling is globally paused.');
expect(body).toContain('Consider raising the policy maximum');
});
it('keeps accessible chart selection, partial periods and gaps while collapsing inspection controls', () => {
const data = report();
data.points.push({
at: data.now,
startAt: data.now,
endAt: data.now + 1000,
partial: false,
observed: 0,
active: 0,
idle: 0,
unknown: 0,
playerHours: 0,
capturedHours: 0
});
const body = render(MetricsChart, {
props: { points: data.points, days: data.days, timeZone: data.scope.timeZone }
}).body;
expect(body).toContain('aria-label="Selected period summary"');
expect(body).toContain('No captured time · not evidence of empty lobbies');
expect(body).toContain('tabindex="0"');
expect(body).toContain('Inspect a period');
expect(body).toContain('Partial period');
expect(body).not.toMatch(/<details[^>]*open/);
expect(body).toContain('Weekdays show when each period starts in America/New York.');
expect(body).toContain('Daily periods are 24-hour windows.');
expect(body).toContain('Lobbies running over time');
expect(body).toContain('Average lobbies running');
expect(body).toContain('5 with players · 5 empty spares · 2 unknown on average');
});
it('keeps average lobby bar heights comparable across partial periods and collection gaps', () => {
const data = report();
const point = data.points[0];
data.points = [
{
...point,
observed: 48,
active: 12,
idle: 36,
unknown: 24,
capturedHours: 12,
partial: false
},
{
...point,
at: data.now,
startAt: data.now,
endAt: data.now + 2 * 3600000,
observed: 8,
active: 2,
idle: 6,
unknown: 4,
capturedHours: 2,
partial: true
}
];
const body = render(MetricsChart, {
props: { points: data.points, days: data.days, timeZone: data.scope.timeZone }
}).body;
const bars = body.match(/<button class="bar\b[\s\S]*?<\/button>/g) ?? [];
expect(bars).toHaveLength(2);
const heights = bars.map((bar) =>
[...bar.matchAll(/style="height:\s*([^"]+)"/g)].map((match) => match[1])
);
expect(heights[0]).toHaveLength(3);
expect(heights[0]).toEqual(heights[1]);
expect(heights[0]).toContain('50%;');
expect(
bars.every((bar) => bar.includes('1 with players · 3 empty spares · 2 unknown on average'))
).toBe(true);
});
it('renders whole-lobby axis ticks without rounding fractional averages or other measures', () => {
const data = report();
data.points[0] = { ...data.points[0], active: 1.5, idle: 1, observed: 2.5, unknown: 1.25 };
const renderChart = () =>
render(MetricsChart, {
props: { points: data.points, days: data.days, timeZone: data.scope.timeZone }
}).body;
const axisLabels = (body: string) => {
const axis = body.match(/<div class="y-axis[\s\S]*?<\/div>/)?.[0] ?? '';
return [...axis.matchAll(/<span[^>]*>([^<]+)<\/span>/g)].map((match) => match[1]);
};
let body = renderChart();
expect(axisLabels(body)).toEqual(['4', '3', '2', '1', '0']);
expect(body).toContain('1.5 with players');
scope('?chartMode=percent');
body = renderChart();
expect(axisLabels(body)).toEqual(['100%', '75%', '50%', '25%', '0%']);
scope('?chartMode=hours');
body = renderChart();
expect(axisLabels(body)).toEqual(['4', '3', '2', '1', '0']);
data.points[0] = { ...data.points[0], active: 0.5, idle: 0, observed: 0.5, unknown: 0 };
expect(axisLabels(renderChart())).toContain('0.375');
});
it('uses shallow URL state for the selected breakdown while retaining the common summary', () => {
scope('?view=maps', '?view=instances&serverQ=theater&serverPage=2');
let body = render(MetricsPage, {
props: { data: { ...report(), user: null, setupRequired: false } }
}).body;
expect(body).toContain('aria-label="Instance metrics table"');
expect(body).not.toContain('aria-label="Map metrics table"');
expect(body.replace(/\s+/g, ' ')).toContain('16–24 of 24 instances');
scope('?view=maps', '?view=insights');
body = render(MetricsPage, {
props: { data: { ...report(), user: null, setupRequired: false } }
}).body;
expect(body).toContain('id="metrics-explore-title"');
expect(body).toContain('Time with no empty lobby');
expect(body).toContain('Autoscaling signals');
expect(body).not.toContain('aria-label="Map metrics table"');
expect(body).not.toContain('aria-label="Instance metrics table"');
});
it('filters other signals without hiding the highlighted supported policy changes', () => {
scope('?view=insights&insightQ=Map+24&insightSort=map');
const body = render(MetricsExplore, { props: { report: report() } }).body;
expect(body).toContain('Map 01');
expect(body).toContain('Map 24');
expect(body).not.toContain('Map 23');
});
it('links autoscaling signals to current policies and explains their historical comparison', () => {
const data = report();
data.maps = [
map(1, {
noEmptyAtMaximum: 0.75,
noEmptyBelowMaximum: 0.25,
policy: {
id: 'policy-1',
mode: 'create-destroy',
minimum: 1,
maximum: 3,
spares: 1,
pausedReason: null
}
})
];
let body = render(MetricsExplore, { props: { report: data } }).body;
expect(body).toContain('href="/autoscaling#policy-1"');
expect(body).toContain('Review policy');
expect(body).toContain('Current policy: 1–3 lobbies, 1 spare');
expect(body.replace(/\s+/g, ' ')).toContain(
'Current settings are applied to historical lobby counts'
);
expect(body).not.toContain('player limits, and observation');
data.maps[0].policy = null;
body = render(MetricsExplore, { props: { report: data } }).body;
expect(body).toContain('href="/autoscaling"');
expect(body).toContain('Add policy');
});