This commit is contained in:
@@ -206,6 +206,54 @@ it('adds match report invalidation on upgrade without invalidating for score hea
|
||||
expect(upgraded.prepare('SELECT * FROM leaderboard_match_players').all()).toEqual([]);
|
||||
});
|
||||
|
||||
it('upgrades player report invalidation from version 15 for participation clocks and durable identities', async () => {
|
||||
const { db, closeDb } = await import('../../src/lib/server/db/index.js');
|
||||
const { installMetricsMatchRevisions } =
|
||||
await import('../../src/lib/server/reports/revisions.js');
|
||||
const handle = db();
|
||||
handle.exec(
|
||||
'DROP TRIGGER report_metrics_leaderboard_match_players_UPDATE; DROP INDEX idx_leaderboard_players_guid_first_seen'
|
||||
);
|
||||
for (const action of ['INSERT', 'UPDATE', 'DELETE'])
|
||||
handle.exec(`DROP TRIGGER report_metrics_leaderboard_players_${action}`);
|
||||
installMetricsMatchRevisions(handle);
|
||||
handle.pragma('user_version = 15');
|
||||
closeDb();
|
||||
const upgraded = db();
|
||||
const revision = () =>
|
||||
(
|
||||
upgraded
|
||||
.prepare("SELECT revision FROM report_revisions WHERE topic='metrics'")
|
||||
.get() as { revision: number }
|
||||
).revision;
|
||||
upgraded.exec(`INSERT INTO leaderboard_matches
|
||||
(id,server,game,map,map_key,map_name,started_at,last_received_at,last_event,last_fingerprint)
|
||||
VALUES(1,'removed','t5','moon','moon','Moon','2026-10-10T12:00:00.000Z','2026-10-10T12:00:00.000Z','game_start','test');
|
||||
INSERT INTO leaderboard_match_players(match_id,guid,first_seen_at,last_seen_at)
|
||||
VALUES(1,'player','2026-10-10T12:00:00.000Z','2026-10-10T12:00:00.000Z');`);
|
||||
let before = revision();
|
||||
upgraded.exec("UPDATE leaderboard_match_players SET last_seen_at='2026-10-10T13:00:00.000Z'");
|
||||
expect(revision()).toBeGreaterThan(before);
|
||||
before = revision();
|
||||
upgraded.exec(`INSERT INTO leaderboard_players(game,guid,name,first_seen_at,last_seen_at)
|
||||
VALUES('t5','player','Player','2026-10-10T12:00:00.000Z','2026-10-10T13:00:00.000Z')`);
|
||||
expect(revision()).toBeGreaterThan(before);
|
||||
before = revision();
|
||||
upgraded.exec(
|
||||
"UPDATE leaderboard_players SET name='Renamed',last_seen_at='2026-10-10T14:00:00.000Z'"
|
||||
);
|
||||
expect(revision()).toBe(before);
|
||||
upgraded.exec("UPDATE leaderboard_players SET first_seen_at='2026-10-09T12:00:00.000Z'");
|
||||
expect(revision()).toBeGreaterThan(before);
|
||||
expect(
|
||||
upgraded
|
||||
.prepare(
|
||||
"SELECT name FROM sqlite_master WHERE name='idx_leaderboard_players_guid_first_seen'"
|
||||
)
|
||||
.get()
|
||||
).toBeDefined();
|
||||
});
|
||||
|
||||
it('adds provisioning report invalidation at version 14 without invalidating for progress updates', async () => {
|
||||
const { db, closeDb } = await import('../../src/lib/server/db/index.js');
|
||||
const handle = db();
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { newFixture, type Fixture } from '../helpers/fixture.js';
|
||||
import type { MetricsDays } from '../../src/lib/metrics.js';
|
||||
|
||||
let fixture: Fixture;
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
fixture = await newFixture([]);
|
||||
});
|
||||
afterEach(async () => {
|
||||
vi.restoreAllMocks();
|
||||
await fixture.cleanup();
|
||||
});
|
||||
|
||||
const hour = 3600_000;
|
||||
const day = 24 * hour;
|
||||
const now = Date.UTC(2026, 9, 10, 12, 30);
|
||||
const iso = (at: number) => new Date(at).toISOString();
|
||||
|
||||
async function match(start: number, end: number, guids: string[], game = 't5', map = 'Moon') {
|
||||
const { db } = await import('../../src/lib/server/db/index.js');
|
||||
const handle = db();
|
||||
const { lastInsertRowid: id } = handle
|
||||
.prepare(
|
||||
`INSERT INTO leaderboard_matches
|
||||
(server,game,map,map_key,map_name,started_at,last_received_at,status,last_event,last_fingerprint)
|
||||
VALUES ('removed-server',?,?,?, ?,?,?,'completed','game_end','test')`
|
||||
)
|
||||
.run(game, map, `${game}:${map}`, map, iso(start), iso(end));
|
||||
for (const guid of guids) {
|
||||
handle
|
||||
.prepare(
|
||||
`INSERT INTO leaderboard_match_players
|
||||
(match_id,guid,first_seen_at,last_seen_at) VALUES(?,?,?,?)`
|
||||
)
|
||||
.run(id, guid, iso(start), iso(end));
|
||||
handle
|
||||
.prepare(
|
||||
`INSERT INTO leaderboard_players(game,guid,name,first_seen_at,last_seen_at)
|
||||
VALUES(?,?,?,?,?) ON CONFLICT(game,guid) DO UPDATE SET last_seen_at=excluded.last_seen_at`
|
||||
)
|
||||
.run(game, guid, guid, iso(start), iso(end));
|
||||
}
|
||||
return Number(id);
|
||||
}
|
||||
|
||||
describe('metrics player history', () => {
|
||||
it.each<MetricsDays>([1, 7, 30])(
|
||||
'counts spans and repeated matches once per period and range for %i days',
|
||||
async (days) => {
|
||||
const { readMetrics } = await import('../../src/lib/server/fleet/metrics.js');
|
||||
const bucket = days === 1 ? hour : day;
|
||||
const boundary = Math.floor((now - bucket) / bucket) * bucket;
|
||||
await match(now - 40 * day, now - 40 * day, ['regular']);
|
||||
await match(boundary - 10_000, boundary + 10_000, ['regular', 'newcomer']);
|
||||
await match(boundary + 20_000, boundary + 30_000, ['regular', 'newcomer']);
|
||||
const result = readMetrics(days, now);
|
||||
const before = result.points.find((point) => point.at === boundary - bucket)!;
|
||||
const after = result.points.find((point) => point.at === boundary)!;
|
||||
expect(before).toMatchObject({ uniquePlayers: 2, newPlayers: 1 });
|
||||
expect(after).toMatchObject({ uniquePlayers: 2, newPlayers: 0 });
|
||||
expect(result.players).toMatchObject({ uniquePlayers: 2, newPlayers: 1 });
|
||||
expect(result.points.reduce((sum, point) => sum + (point.uniquePlayers ?? 0), 0)).toBe(
|
||||
4
|
||||
);
|
||||
expect(result.summary.observed).toBe(0);
|
||||
}
|
||||
);
|
||||
|
||||
it('uses participation rather than match start and includes both range endpoints', async () => {
|
||||
const { readMetrics } = await import('../../src/lib/server/fleet/metrics.js');
|
||||
const since = now - day;
|
||||
await match(since - 2 * hour, since - 1, ['ended-before']);
|
||||
await match(since - hour, since + hour, ['spanning-start']);
|
||||
await match(since, since, ['at-start']);
|
||||
await match(now - hour, now + hour, ['spanning-end']);
|
||||
await match(now, now, ['at-end']);
|
||||
await match(now + 1, now + 1, ['future']);
|
||||
const result = readMetrics(1, now);
|
||||
expect(result.players).toMatchObject({ uniquePlayers: 4, newPlayers: 3 });
|
||||
expect(result.points[0]).toMatchObject({ uniquePlayers: 2, newPlayers: 1, partial: true });
|
||||
expect(result.points.at(-1)).toMatchObject({
|
||||
uniquePlayers: 2,
|
||||
newPlayers: 1,
|
||||
partial: true
|
||||
});
|
||||
});
|
||||
|
||||
it('assigns a single sighting on an internal boundary only to the following period', async () => {
|
||||
const { readMetrics } = await import('../../src/lib/server/fleet/metrics.js');
|
||||
await match(now - 2 * day, now - 2 * day, []);
|
||||
const boundary = Date.UTC(2026, 9, 10, 11);
|
||||
await match(boundary, boundary, ['boundary']);
|
||||
const result = readMetrics(1, now);
|
||||
expect(result.points.find((point) => point.at === boundary - hour)).toMatchObject({
|
||||
uniquePlayers: 0,
|
||||
newPlayers: 0
|
||||
});
|
||||
expect(result.points.find((point) => point.at === boundary)).toMatchObject({
|
||||
uniquePlayers: 1,
|
||||
newPlayers: 1
|
||||
});
|
||||
expect(result.points.find((point) => point.at === boundary + hour)).toMatchObject({
|
||||
uniquePlayers: 0,
|
||||
newPlayers: 0
|
||||
});
|
||||
});
|
||||
|
||||
it('deduplicates identity across games and scopes new players to their original match', async () => {
|
||||
const { readMetrics } = await import('../../src/lib/server/fleet/metrics.js');
|
||||
await match(now - 2 * day, now - 2 * day, ['regular']);
|
||||
await match(
|
||||
now - 4 * hour,
|
||||
now - 4 * hour,
|
||||
['both-games', 'regular'],
|
||||
't6',
|
||||
'Town Survival'
|
||||
);
|
||||
await match(now - 3 * hour, now - 3 * hour, ['both-games', 'regular', 'moon-new']);
|
||||
await match(
|
||||
now - 2 * hour,
|
||||
now - 2 * hour,
|
||||
['moon-new', 'kino-new'],
|
||||
't5',
|
||||
'Kino Der Toten'
|
||||
);
|
||||
await match(now - hour, now - hour, ['town-new'], 't6', 'Town Survival');
|
||||
await match(now - hour, now - hour, ['grief-new'], 't6', 'Town Grief');
|
||||
const read = (game: string | null, map: string | null = null) =>
|
||||
readMetrics({ days: 1, game, map, timeZone: 'UTC' }, now);
|
||||
expect(read(null).players).toMatchObject({ uniquePlayers: 6, newPlayers: 5 });
|
||||
expect(read('t5').players).toMatchObject({ uniquePlayers: 4, newPlayers: 2 });
|
||||
expect(read('t5', 'Moon').players).toMatchObject({ uniquePlayers: 3, newPlayers: 1 });
|
||||
expect(read('t5', 'Kino Der Toten').players).toMatchObject({
|
||||
uniquePlayers: 2,
|
||||
newPlayers: 1
|
||||
});
|
||||
expect(read('t6', 'Town Survival').players).toMatchObject({
|
||||
uniquePlayers: 3,
|
||||
newPlayers: 2
|
||||
});
|
||||
expect(read('t6', 'Town Grief').players).toMatchObject({ uniquePlayers: 1, newPlayers: 1 });
|
||||
expect(read('t5', 'Missing').players).toMatchObject({ uniquePlayers: 0, newPlayers: 0 });
|
||||
expect(read('t5', 'Missing').points.every((point) => point.uniquePlayers === 0)).toBe(true);
|
||||
});
|
||||
|
||||
it('leaves periods before recording as gaps and marks the first retained period partial', async () => {
|
||||
const { readMetrics } = await import('../../src/lib/server/fleet/metrics.js');
|
||||
const start = now - 2 * hour;
|
||||
await match(start, start + 10_000, ['first']);
|
||||
const result = readMetrics(1, now);
|
||||
expect(result.players).toEqual({ historyStartAt: start, uniquePlayers: 1, newPlayers: 1 });
|
||||
expect(
|
||||
result.points
|
||||
.filter((point) => point.endAt <= start)
|
||||
.every((point) => point.uniquePlayers === null && point.newPlayers === null)
|
||||
).toBe(true);
|
||||
expect(
|
||||
result.points.find((point) => point.at === Math.floor(start / hour) * hour)
|
||||
).toMatchObject({
|
||||
uniquePlayers: 1,
|
||||
newPlayers: 1,
|
||||
partial: false,
|
||||
playerHistoryPartial: true
|
||||
});
|
||||
expect(result.points.at(-1)).toMatchObject({ uniquePlayers: 0, newPlayers: 0 });
|
||||
});
|
||||
|
||||
it('moves the history bound after retention without making returning players new', async () => {
|
||||
const { readMetrics } = await import('../../src/lib/server/fleet/metrics.js');
|
||||
const { db } = await import('../../src/lib/server/db/index.js');
|
||||
const firstMatch = await match(now - 40 * day, now - 40 * day, ['regular']);
|
||||
await match(now - day, now - day + hour, ['regular'], 't6', 'Town Survival');
|
||||
db().prepare('DELETE FROM leaderboard_matches WHERE id=?').run(firstMatch);
|
||||
const result = readMetrics(
|
||||
{ days: 30, game: 't6', map: 'Town Survival', timeZone: 'UTC' },
|
||||
now
|
||||
);
|
||||
expect(result.players).toEqual({
|
||||
historyStartAt: now - day,
|
||||
uniquePlayers: 1,
|
||||
newPlayers: 0
|
||||
});
|
||||
expect(result.points[0].uniquePlayers).toBeNull();
|
||||
expect(db().prepare('SELECT COUNT(*) AS n FROM leaderboard_players').get()).toEqual({
|
||||
n: 2
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps no history unavailable, including when only durable player identities remain', async () => {
|
||||
const { readMetrics } = await import('../../src/lib/server/fleet/metrics.js');
|
||||
const { db } = await import('../../src/lib/server/db/index.js');
|
||||
const assertMissing = () => {
|
||||
const result = readMetrics(1, now);
|
||||
expect(result.players).toEqual({
|
||||
historyStartAt: null,
|
||||
uniquePlayers: null,
|
||||
newPlayers: null
|
||||
});
|
||||
expect(
|
||||
result.points.every(
|
||||
(point) => point.uniquePlayers === null && point.newPlayers === null
|
||||
)
|
||||
).toBe(true);
|
||||
};
|
||||
assertMissing();
|
||||
await match(now - day, now - day, ['old']);
|
||||
db().prepare('DELETE FROM leaderboard_matches').run();
|
||||
await match(now + hour, now + hour, ['future']);
|
||||
assertMissing();
|
||||
});
|
||||
});
|
||||
@@ -18,6 +18,7 @@ const report = (days: number) => ({
|
||||
summary: totals,
|
||||
running: { average: null, peak: 0, capturedHours: 0 },
|
||||
matches: emptyMatches,
|
||||
players: { uniquePlayers: null, newPlayers: null, historyStartAt: null },
|
||||
autoscaling: {
|
||||
added: 0,
|
||||
removed: 0,
|
||||
@@ -286,11 +287,22 @@ describe('metrics background refresh', () => {
|
||||
startAt: 0,
|
||||
endAt: 1000,
|
||||
partial: true,
|
||||
uniquePlayers: 0,
|
||||
newPlayers: 0,
|
||||
playerHistoryPartial: false,
|
||||
capturedHours: 1 / 3600
|
||||
}
|
||||
]
|
||||
};
|
||||
const invalid = [
|
||||
{ ...value, players: undefined },
|
||||
{ ...value, players: { uniquePlayers: 1, newPlayers: 0 } },
|
||||
...[undefined, -1, 1.5, '2'].map((newPlayers) => ({
|
||||
...value,
|
||||
players: { ...value.players, newPlayers }
|
||||
})),
|
||||
{ ...value, points: [{ ...value.points[0], uniquePlayers: undefined }] },
|
||||
{ ...value, points: [{ ...value.points[0], playerHistoryPartial: undefined }] },
|
||||
{ ...value, maps: [{ ...value.maps[0], running: undefined }] },
|
||||
{
|
||||
...value,
|
||||
|
||||
@@ -99,6 +99,32 @@ it('refreshes cached match metrics after active matches and recorded participant
|
||||
expect((await report()).scopeOptions).toEqual({ games: [], maps: [] });
|
||||
});
|
||||
|
||||
it('refreshes player trends when participation crosses a period and first-seen identity changes', async () => {
|
||||
const { db } = await import('../../src/lib/server/db/index.js');
|
||||
const { metricsReport } = await import('../../src/lib/server/reports/index.js');
|
||||
const now = Date.UTC(2026, 9, 10, 12, 30);
|
||||
vi.spyOn(Date, 'now').mockReturnValue(now);
|
||||
const query = parseMetricsQuery(new URLSearchParams('days=1'));
|
||||
db().exec(`INSERT INTO leaderboard_matches
|
||||
(id,server,game,map,map_key,map_name,started_at,last_received_at,last_event,last_fingerprint)
|
||||
VALUES(1,'removed','t5','moon','moon','Moon','2026-10-10T11:30:00.000Z','2026-10-10T12:30:00.000Z','heartbeat','test');
|
||||
INSERT INTO leaderboard_match_players(match_id,guid,first_seen_at,last_seen_at)
|
||||
VALUES(1,'player','2026-10-10T11:30:00.000Z','2026-10-10T11:59:00.000Z');
|
||||
INSERT INTO leaderboard_players(game,guid,name,first_seen_at,last_seen_at)
|
||||
VALUES('t5','player','Player','2026-10-10T11:30:00.000Z','2026-10-10T11:59:00.000Z');`);
|
||||
const first = await metricsReport(query);
|
||||
expect(first.players).toMatchObject({ uniquePlayers: 1, newPlayers: 1 });
|
||||
expect(first.points.at(-1)).toMatchObject({ uniquePlayers: 0, newPlayers: 0 });
|
||||
db().exec("UPDATE leaderboard_match_players SET last_seen_at='2026-10-10T12:15:00.000Z'");
|
||||
expect((await metricsReport(query)).points.at(-1)).toMatchObject({
|
||||
uniquePlayers: 1,
|
||||
newPlayers: 0
|
||||
});
|
||||
db().exec(`INSERT INTO leaderboard_players(game,guid,name,first_seen_at,last_seen_at)
|
||||
VALUES('t6','player','Player','2026-10-01T11:30:00.000Z','2026-10-01T11:30:00.000Z')`);
|
||||
expect((await metricsReport(query)).players).toMatchObject({ uniquePlayers: 1, newPlayers: 0 });
|
||||
});
|
||||
|
||||
it('refreshes policy signals after policy edits and global pauses without new samples', async () => {
|
||||
const { db } = await import('../../src/lib/server/db/index.js');
|
||||
const { recordMetrics } = await import('../../src/lib/server/fleet/metrics.js');
|
||||
|
||||
@@ -7,6 +7,7 @@ import MetricsAutoscaling from '../../src/lib/ui/components/metrics/MetricsAutos
|
||||
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 MetricsPlayers from '../../src/lib/ui/components/metrics/MetricsPlayers.svelte';
|
||||
import { metricPage } from '../../src/lib/ui/metrics.js';
|
||||
import { page } from '$app/state';
|
||||
|
||||
@@ -66,6 +67,7 @@ function report(): MetricsData {
|
||||
scopeFirstSampleAt: now - 14 * 86400000,
|
||||
scopeLatestSampleAt: now,
|
||||
matches: matches(),
|
||||
players: { uniquePlayers: 104, newPlayers: 70, historyStartAt: now - 14 * 86400000 },
|
||||
autoscaling: {
|
||||
added: 73,
|
||||
removed: 48,
|
||||
@@ -86,6 +88,9 @@ function report(): MetricsData {
|
||||
startAt: now - 86400000,
|
||||
endAt: now,
|
||||
partial: true,
|
||||
uniquePlayers: 28,
|
||||
newPlayers: 5,
|
||||
playerHistoryPartial: false,
|
||||
capturedHours: 1
|
||||
}
|
||||
],
|
||||
@@ -500,6 +505,68 @@ it('shows a global pause once while keeping Kino’s shortage ahead of quiet map
|
||||
expect(body).toContain('Consider raising the policy maximum');
|
||||
});
|
||||
|
||||
it('renders player totals separately from periods with accessible selection, gaps and partial history', () => {
|
||||
const data = report();
|
||||
const original = data.points[0];
|
||||
data.points = [
|
||||
{
|
||||
...original,
|
||||
at: original.at - 86400000,
|
||||
startAt: original.startAt - 86400000,
|
||||
endAt: original.startAt,
|
||||
partial: false,
|
||||
uniquePlayers: null,
|
||||
newPlayers: null
|
||||
},
|
||||
{ ...original, partial: false, playerHistoryPartial: true }
|
||||
];
|
||||
data.players.historyStartAt = original.startAt + 3600000;
|
||||
const body = render(MetricsPlayers, {
|
||||
props: {
|
||||
points: data.points,
|
||||
players: data.players,
|
||||
days: data.days,
|
||||
since: data.since,
|
||||
timeZone: data.scope.timeZone
|
||||
}
|
||||
}).body;
|
||||
expect(body).toContain('Players over time');
|
||||
expect(body).toContain('>104</dd>');
|
||||
expect(body).toContain('>70</dd>');
|
||||
expect(body).toContain('28 unique players · 5 new');
|
||||
expect(body).toContain('Retained player history starts');
|
||||
expect(body.replace(/\s+/g, ' ')).toContain('Earlier periods are gaps');
|
||||
expect(body).toContain('Range totals count each player once');
|
||||
expect(body).toContain('aria-label="Selected player period summary"');
|
||||
expect(body).toContain('aria-label="Player time data" tabindex="0"');
|
||||
expect(body).toContain('role="status"');
|
||||
expect(body).toContain('View exact time data');
|
||||
expect(body).toContain('No retained history');
|
||||
const periods = body.match(/<button[^>]*class="period\b[\s\S]*?<\/button>/g) ?? [];
|
||||
expect(periods).toHaveLength(2);
|
||||
expect(periods[0]).not.toContain('class="dot');
|
||||
expect(periods[1]).toContain('partial');
|
||||
expect(periods[1]).toContain('tabindex="0"');
|
||||
expect(body).not.toMatch(/<details[^>]*open/);
|
||||
});
|
||||
|
||||
it('shows player trends without occupancy and shows unavailable counts when no match history remains', () => {
|
||||
const data = report();
|
||||
data.summary = { observed: 0, active: 0, idle: 0, unknown: 0, playerHours: 0 };
|
||||
let body = render(MetricsPage, {
|
||||
props: { data: { ...data, user: null, setupRequired: false } }
|
||||
}).body;
|
||||
expect(body).toContain('Players over time');
|
||||
expect(body).toContain('>104</dd>');
|
||||
data.players = { historyStartAt: null, uniquePlayers: null, newPlayers: null };
|
||||
data.points = data.points.map((point) => ({ ...point, uniquePlayers: null, newPlayers: null }));
|
||||
body = render(MetricsPage, {
|
||||
props: { data: { ...data, user: null, setupRequired: false } }
|
||||
}).body;
|
||||
expect(body).toContain('No retained player history yet');
|
||||
expect(body).toContain('>—</dd>');
|
||||
});
|
||||
|
||||
it('keeps accessible chart selection, partial periods and gaps while collapsing inspection controls', () => {
|
||||
const data = report();
|
||||
data.points.push({
|
||||
@@ -512,7 +579,10 @@ it('keeps accessible chart selection, partial periods and gaps while collapsing
|
||||
idle: 0,
|
||||
unknown: 0,
|
||||
playerHours: 0,
|
||||
capturedHours: 0
|
||||
capturedHours: 0,
|
||||
uniquePlayers: null,
|
||||
newPlayers: null,
|
||||
playerHistoryPartial: false
|
||||
});
|
||||
const body = render(MetricsChart, {
|
||||
props: { points: data.points, days: data.days, timeZone: data.scope.timeZone }
|
||||
|
||||
@@ -6,10 +6,17 @@ import {
|
||||
metricLobbyAxis,
|
||||
metricNumber,
|
||||
metricPercent,
|
||||
metricPeriodLabels
|
||||
metricPeriodLabels,
|
||||
metricPlayerPaths
|
||||
} from '../../src/lib/ui/metrics.js';
|
||||
|
||||
describe('metric presentation', () => {
|
||||
it('breaks player lines at missing history while retaining real zeroes and isolated points', () => {
|
||||
const paths = metricPlayerPaths([null, 2, 0, null, 1], 2);
|
||||
expect(paths.line).toBe('M30,0 L50,100 M90,50');
|
||||
expect(paths.area).toBe('M30,0 L50,100 L50,100 L30,100 Z M90,50 L90,100 L90,100 Z');
|
||||
expect(metricPlayerPaths([null, null], 1)).toEqual({ line: '', area: '' });
|
||||
});
|
||||
it('normalizes partial buckets by captured wall time and preserves unknown lobby counts', () => {
|
||||
expect(metricAverageLobbies(14 * 24, 24)).toBe(14);
|
||||
expect(metricAverageLobbies(14 * 0.5, 0.5)).toBe(14);
|
||||
|
||||
Reference in New Issue
Block a user