Preserve shortage signals during global pause (Refs #137)
CI / checks (push) Successful in 4m47s
CI / checks (push) Successful in 4m47s
This commit is contained in:
@@ -34,7 +34,8 @@ the URL; refreshes preserve table controls and chart selection.
|
|||||||
The timeline defaults to **Average lobbies running**, split into **With players**,
|
The timeline defaults to **Average lobbies running**, split into **With players**,
|
||||||
**Empty spare**, and **Unknown**. Each bucket divides those lobby-hours by its
|
**Empty spare**, and **Unknown**. Each bucket divides those lobby-hours by its
|
||||||
captured wall time, so changing lobby counts remain visible. Collection gaps are
|
captured wall time, so changing lobby counts remain visible. Collection gaps are
|
||||||
excluded from that denominator. Switch to the share view to compare proportions,
|
excluded from that denominator. The average-lobby axis uses whole-number ticks;
|
||||||
|
period details preserve fractional averages. Switch to the share view to compare proportions,
|
||||||
or hours to compare absolute lobby time. Periods without captured time remain gaps.
|
or hours to compare absolute lobby time. Periods without captured time remain gaps.
|
||||||
|
|
||||||
The timeline has labelled axes and marks partial edge periods. Select a bar or use
|
The timeline has labelled axes and marks partial edge periods. Select a bar or use
|
||||||
@@ -77,6 +78,11 @@ Game-wide policies apply independently to each supported map variant. Each signa
|
|||||||
links to the policy on **Fleet → Autoscaling**; a map without a policy gets a link
|
links to the policy on **Fleet → Autoscaling**; a map without a policy gets a link
|
||||||
to add one. Signals are advisory and never change a policy or a lobby.
|
to add one. Signals are advisory and never change a policy or a lobby.
|
||||||
|
|
||||||
|
A global pause appears once above the signals. Historical shortage signals remain
|
||||||
|
visible while the fleet is paused, including suggestions to review the maximum
|
||||||
|
or spare target. Review these findings and current settings before resuming.
|
||||||
|
An individual policy's own pause or disabled state still prompts a policy review.
|
||||||
|
|
||||||
| Recorded condition | Suggested action |
|
| Recorded condition | Suggested action |
|
||||||
| ---------------------------------------------------------------------------------------- | ---------------------------------------------------------------- |
|
| ---------------------------------------------------------------------------------------- | ---------------------------------------------------------------- |
|
||||||
| No empty lobby for at least 2% of known map time, mostly at or above the current maximum | Review raising the policy maximum. |
|
| No empty lobby for at least 2% of known map time, mostly at or above the current maximum | Review raising the policy maximum. |
|
||||||
@@ -137,5 +143,6 @@ before scope filtering, so samples from other groups still end a batch interval.
|
|||||||
their own `capturedHours`. Map rows expose `noEmpty`, `availabilityObserved`, and
|
their own `capturedHours`. Map rows expose `noEmpty`, `availabilityObserved`, and
|
||||||
`availabilityUnknown` durations, plus current `policy` context and
|
`availabilityUnknown` durations, plus current `policy` context and
|
||||||
`noEmptyAtMaximum` / `noEmptyBelowMaximum` durations. `pooledAvailability` combines
|
`noEmptyAtMaximum` / `noEmptyBelowMaximum` durations. `pooledAvailability` combines
|
||||||
map durations and coverage. Policy edits and global pause changes invalidate
|
map durations and coverage. `autoscalingPaused` reports the current global pause
|
||||||
|
separately from each policy's own `pausedReason`. Policy edits and global pause changes invalidate
|
||||||
cached metrics reports.
|
cached metrics reports.
|
||||||
|
|||||||
@@ -130,6 +130,8 @@ export interface MetricsData {
|
|||||||
firstRetainedSampleAt: number | null;
|
firstRetainedSampleAt: number | null;
|
||||||
latestSampleAt: number | null;
|
latestSampleAt: number | null;
|
||||||
registeredServerCount: number;
|
registeredServerCount: number;
|
||||||
|
/** Current fleet-wide pause, separate from each policy's own state. */
|
||||||
|
autoscalingPaused: boolean;
|
||||||
scope: MetricsScope;
|
scope: MetricsScope;
|
||||||
scopeOptions: { games: string[]; maps: { game: string; map: string }[] };
|
scopeOptions: { games: string[]; maps: { game: string; map: string }[] };
|
||||||
scopeFirstSampleAt: number | null;
|
scopeFirstSampleAt: number | null;
|
||||||
|
|||||||
@@ -123,11 +123,7 @@ export function readMetrics(input: MetricsDays | MetricsQuery, now = Date.now())
|
|||||||
minimum,
|
minimum,
|
||||||
maximum,
|
maximum,
|
||||||
spares,
|
spares,
|
||||||
pausedReason: globalPaused
|
pausedReason: row.paused_reason
|
||||||
? ['Autoscaling is globally paused.', row.paused_reason]
|
|
||||||
.filter(Boolean)
|
|
||||||
.join(' ')
|
|
||||||
: row.paused_reason
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -441,6 +437,7 @@ export function readMetrics(input: MetricsDays | MetricsQuery, now = Date.now())
|
|||||||
since,
|
since,
|
||||||
now,
|
now,
|
||||||
...metadata,
|
...metadata,
|
||||||
|
autoscalingPaused: globalPaused,
|
||||||
scope,
|
scope,
|
||||||
scopeOptions,
|
scopeOptions,
|
||||||
summary,
|
summary,
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
import {
|
import {
|
||||||
metricDuration,
|
metricDuration,
|
||||||
metricAverageLobbies,
|
metricAverageLobbies,
|
||||||
|
metricLobbyAxis,
|
||||||
metricNumber,
|
metricNumber,
|
||||||
metricPercent,
|
metricPercent,
|
||||||
metricPeriodLabels
|
metricPeriodLabels
|
||||||
@@ -34,8 +35,8 @@
|
|||||||
});
|
});
|
||||||
const selected = $derived(points[selectedIndex]);
|
const selected = $derived(points[selectedIndex]);
|
||||||
const selectedValue = $derived(selected ? String(selected.at) : '');
|
const selectedValue = $derived(selected ? String(selected.at) : '');
|
||||||
const ceiling = $derived.by(() => {
|
const axis = $derived.by(() => {
|
||||||
if (mode === 'percent') return 100;
|
if (mode === 'percent') return { ceiling: 100, ticks: [100, 75, 50, 25, 0] };
|
||||||
const max = Math.max(
|
const max = Math.max(
|
||||||
0,
|
0,
|
||||||
...points.map((point) =>
|
...points.map((point) =>
|
||||||
@@ -45,15 +46,20 @@
|
|||||||
: point.observed + point.unknown
|
: point.observed + point.unknown
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
if (!max) return 1;
|
if (mode === 'average') return metricLobbyAxis(max);
|
||||||
const magnitude = 10 ** Math.floor(Math.log10(max));
|
const magnitude = max ? 10 ** Math.floor(Math.log10(max)) : 1;
|
||||||
return Math.ceil(max / (magnitude / 2)) * (magnitude / 2);
|
const ceiling = max ? Math.ceil(max / (magnitude / 2)) * (magnitude / 2) : 1;
|
||||||
|
return {
|
||||||
|
ceiling,
|
||||||
|
ticks: [1, 0.75, 0.5, 0.25, 0].map((fraction) => ceiling * fraction)
|
||||||
|
};
|
||||||
});
|
});
|
||||||
|
const ceiling = $derived(axis.ceiling);
|
||||||
const tick = new Intl.NumberFormat('en-US', { maximumSignificantDigits: 3 });
|
const tick = new Intl.NumberFormat('en-US', { maximumSignificantDigits: 3 });
|
||||||
const ticks = $derived(
|
const ticks = $derived(
|
||||||
[1, 0.75, 0.5, 0.25, 0].map((fraction) => ({
|
axis.ticks.map((value) => ({
|
||||||
fraction,
|
fraction: value / ceiling,
|
||||||
value: `${tick.format(ceiling * fraction)}${mode === 'percent' ? '%' : ''}`
|
value: `${tick.format(value)}${mode === 'percent' ? '%' : ''}`
|
||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
const periodOptions = $derived(
|
const periodOptions = $derived(
|
||||||
|
|||||||
@@ -70,6 +70,10 @@
|
|||||||
are applied to historical lobby counts; these are not records of the policy active then. No
|
are applied to historical lobby counts; these are not records of the policy active then. No
|
||||||
reported empty lobby does not prove that players were turned away.
|
reported empty lobby does not prove that players were turned away.
|
||||||
</p>
|
</p>
|
||||||
|
{#if report.autoscalingPaused}<p class="notice">
|
||||||
|
Autoscaling is globally paused. Review the shortage signals and current settings before
|
||||||
|
resuming in <a href="/autoscaling">Autoscaling</a>.
|
||||||
|
</p>{/if}
|
||||||
{#if highlighted.length}{#each highlighted as suggestion}{@render finding(suggestion)}{/each}
|
{#if highlighted.length}{#each highlighted as suggestion}{@render finding(suggestion)}{/each}
|
||||||
{:else}<p class="empty">No policy changes meet the evidence threshold in this period.</p>{/if}
|
{:else}<p class="empty">No policy changes meet the evidence threshold in this period.</p>{/if}
|
||||||
{#if limited}<p class="notice">
|
{#if limited}<p class="notice">
|
||||||
@@ -124,8 +128,9 @@
|
|||||||
empty. Player limits are not needed for these signals.
|
empty. Player limits are not needed for these signals.
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
Maps without a current policy suggest adding one. Paused or disabled policies prompt
|
Maps without a current policy suggest adding one. Individually paused or disabled
|
||||||
a review before resuming or enabling them.
|
policies prompt a review before resuming or enabling them. A global pause does not
|
||||||
|
replace shortage signals.
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
All-unknown occupancy prompts a reporting check. Other cases suggest watching
|
All-unknown occupancy prompts a reporting check. Other cases suggest watching
|
||||||
|
|||||||
@@ -54,7 +54,7 @@
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
function description(cell: Cell) {
|
function description(cell: Cell) {
|
||||||
return `${weekdays[cell.weekday]}, ${hourLabel(cell.hour)}: ${cell.observed ? `${metricPercent(cell.active, cell.observed)} with players during ${metricDuration(cell.observed)} known server time` : cell.unknown ? `${metricDuration(cell.unknown)} unknown server time` : 'No captured time'}. ${cell.distinctDates} distinct dates.`;
|
return `${weekdays[cell.weekday]}, ${hourLabel(cell.hour)}: ${cell.observed ? `${metricPercent(cell.active, cell.observed)} with players during ${metricDuration(cell.observed)} known lobby time` : cell.unknown ? `${metricDuration(cell.unknown)} unknown lobby time` : 'No captured time'}. ${cell.distinctDates} distinct dates.`;
|
||||||
}
|
}
|
||||||
function select(day: number, hour: number) {
|
function select(day: number, hour: number) {
|
||||||
selectedKey = day * 24 + hour;
|
selectedKey = day * 24 + hour;
|
||||||
@@ -102,7 +102,7 @@
|
|||||||
<div>
|
<div>
|
||||||
<h2 id={`${id}-heatmap-title`}>Activity by weekday and hour</h2>
|
<h2 id={`${id}-heatmap-title`}>Activity by weekday and hour</h2>
|
||||||
<p>
|
<p>
|
||||||
Time with players as a share of known server time, grouped in {heatmap.timeZone.replaceAll(
|
Time with players as a share of known lobby time, grouped in {heatmap.timeZone.replaceAll(
|
||||||
'_',
|
'_',
|
||||||
' '
|
' '
|
||||||
)}.
|
)}.
|
||||||
@@ -233,7 +233,7 @@
|
|||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<dt>Unknown server time</dt>
|
<dt>Unknown lobby time</dt>
|
||||||
<dd>{metricDuration(selected.unknown)}</dd>
|
<dd>{metricDuration(selected.unknown)}</dd>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ function validReport(value: unknown): value is MetricsData {
|
|||||||
return false;
|
return false;
|
||||||
if (
|
if (
|
||||||
!numeric(value.registeredServerCount) ||
|
!numeric(value.registeredServerCount) ||
|
||||||
|
typeof value.autoscalingPaused !== 'boolean' ||
|
||||||
![
|
![
|
||||||
'firstRetainedSampleAt',
|
'firstRetainedSampleAt',
|
||||||
'latestSampleAt',
|
'latestSampleAt',
|
||||||
|
|||||||
@@ -38,6 +38,22 @@ export function metricAverageLobbies(lobbyHours: number, capturedHours: number):
|
|||||||
return lobbyHours / capturedHours;
|
return lobbyHours / capturedHours;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Use up to four whole-lobby intervals while retaining fractional averages in the data. */
|
||||||
|
export function metricLobbyAxis(maximumLobbies: number) {
|
||||||
|
const maximum = Number.isFinite(maximumLobbies) ? Math.max(1, maximumLobbies) : 1;
|
||||||
|
const targetStep = Math.max(1, maximum / 4);
|
||||||
|
const magnitude = 10 ** Math.floor(Math.log10(targetStep));
|
||||||
|
const step =
|
||||||
|
[1, 2, 5, 10]
|
||||||
|
.map((multiple) => multiple * magnitude)
|
||||||
|
.find((value) => value >= targetStep) ?? 10 * magnitude;
|
||||||
|
const intervals = Math.ceil(maximum / step);
|
||||||
|
return {
|
||||||
|
ceiling: intervals * step,
|
||||||
|
ticks: Array.from({ length: intervals + 1 }, (_, index) => (intervals - index) * step)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function metricPercent(value: number, total: number): string {
|
export function metricPercent(value: number, total: number): string {
|
||||||
if (!Number.isFinite(value) || !Number.isFinite(total) || total <= 0) return '—';
|
if (!Number.isFinite(value) || !Number.isFinite(total) || total <= 0) return '—';
|
||||||
const percent = (100 * value) / total;
|
const percent = (100 * value) / total;
|
||||||
|
|||||||
@@ -336,7 +336,7 @@
|
|||||||
<summary>About this report</summary>
|
<summary>About this report</summary>
|
||||||
<div class="context">
|
<div class="context">
|
||||||
{#if captured > 0}<span
|
{#if captured > 0}<span
|
||||||
><strong>{metricDuration(report.summary.observed)}</strong> known server time</span
|
><strong>{metricDuration(report.summary.observed)}</strong> known lobby time</span
|
||||||
><span
|
><span
|
||||||
><strong>{metricDuration(report.summary.unknown)}</strong> unknown status</span
|
><strong>{metricDuration(report.summary.unknown)}</strong> unknown status</span
|
||||||
>{/if}
|
>{/if}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ const report = (days: number) => ({
|
|||||||
scope: { game: null, map: null, timeZone: 'UTC' },
|
scope: { game: null, map: null, timeZone: 'UTC' },
|
||||||
scopeOptions: { games: [], maps: [] },
|
scopeOptions: { games: [], maps: [] },
|
||||||
registeredServerCount: 0,
|
registeredServerCount: 0,
|
||||||
|
autoscalingPaused: false,
|
||||||
firstRetainedSampleAt: null,
|
firstRetainedSampleAt: null,
|
||||||
latestSampleAt: null,
|
latestSampleAt: null,
|
||||||
scopeFirstSampleAt: null,
|
scopeFirstSampleAt: null,
|
||||||
@@ -168,6 +169,7 @@ describe('metrics background refresh', () => {
|
|||||||
]
|
]
|
||||||
};
|
};
|
||||||
const invalid = [
|
const invalid = [
|
||||||
|
{ ...value, autoscalingPaused: undefined },
|
||||||
{ ...value, running: undefined },
|
{ ...value, running: undefined },
|
||||||
{ ...value, previous: { ...value.previous, running: undefined } },
|
{ ...value, previous: { ...value.previous, running: undefined } },
|
||||||
{ ...value, maps: [{ ...value.maps[0], policy: { id: 'moon' } }] },
|
{ ...value, maps: [{ ...value.maps[0], policy: { id: 'moon' } }] },
|
||||||
|
|||||||
@@ -494,11 +494,16 @@ describe('historical metrics', () => {
|
|||||||
readMetrics(1, at + 30_000).maps.find((map) => map.game === 't5' && map.map === 'Moon')!
|
readMetrics(1, at + 30_000).maps.find((map) => map.game === 't5' && map.map === 'Moon')!
|
||||||
.policy
|
.policy
|
||||||
).toMatchObject({ id: 'moon', mode: 'off', pausedReason: 'Operator paused.' });
|
).toMatchObject({ id: 'moon', mode: 'off', pausedReason: 'Operator paused.' });
|
||||||
|
const beforeGlobalPause = readMetrics(1, at + 30_000);
|
||||||
|
expect(beforeGlobalPause.autoscalingPaused).toBe(false);
|
||||||
db().prepare("INSERT INTO settings(key,value) VALUES ('autoscaling_paused','1')").run();
|
db().prepare("INSERT INTO settings(key,value) VALUES ('autoscaling_paused','1')").run();
|
||||||
|
const globallyPaused = readMetrics(1, at + 30_000);
|
||||||
|
expect(globallyPaused.autoscalingPaused).toBe(true);
|
||||||
|
expect(globallyPaused.maps).toEqual(beforeGlobalPause.maps);
|
||||||
expect(
|
expect(
|
||||||
readMetrics(1, at + 30_000).maps.find((map) => map.game === 't5' && map.map === 'Moon')!
|
globallyPaused.maps.find((map) => map.game === 't5' && map.map === 'Moon')!.policy
|
||||||
.policy?.pausedReason
|
?.pausedReason
|
||||||
).toBe('Autoscaling is globally paused. Operator paused.');
|
).toBe('Operator paused.');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('compares no-empty intervals with the current maximum, counting unknown lobbies in the lineup', async () => {
|
it('compares no-empty intervals with the current maximum, counting unknown lobbies in the lineup', async () => {
|
||||||
|
|||||||
@@ -84,9 +84,13 @@ it('refreshes policy signals after policy edits and global pauses without new sa
|
|||||||
"INSERT INTO settings(key,value) VALUES('autoscaling_paused','1') ON CONFLICT(key) DO UPDATE SET value='1'"
|
"INSERT INTO settings(key,value) VALUES('autoscaling_paused','1') ON CONFLICT(key) DO UPDATE SET value='1'"
|
||||||
)
|
)
|
||||||
.run();
|
.run();
|
||||||
expect((await metricsReport(query)).maps[0].policy?.pausedReason).toBeTruthy();
|
const globallyPaused = await metricsReport(query);
|
||||||
|
expect(globallyPaused.autoscalingPaused).toBe(true);
|
||||||
|
expect(globallyPaused.maps[0]).toEqual(belowMaximum);
|
||||||
db().prepare("UPDATE settings SET value='0' WHERE key='autoscaling_paused'").run();
|
db().prepare("UPDATE settings SET value='0' WHERE key='autoscaling_paused'").run();
|
||||||
expect((await metricsReport(query)).maps[0].policy?.pausedReason).toBeNull();
|
const resumed = await metricsReport(query);
|
||||||
|
expect(resumed.autoscalingPaused).toBe(false);
|
||||||
|
expect(resumed.maps).toEqual(globallyPaused.maps);
|
||||||
db().prepare("DELETE FROM autoscaling_policies WHERE id='moon-policy'").run();
|
db().prepare("DELETE FROM autoscaling_policies WHERE id='moon-policy'").run();
|
||||||
expect((await metricsReport(query)).maps[0].policy).toBeNull();
|
expect((await metricsReport(query)).maps[0].policy).toBeNull();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ function report(): MetricsData {
|
|||||||
firstRetainedSampleAt: now - 14 * 86400000,
|
firstRetainedSampleAt: now - 14 * 86400000,
|
||||||
latestSampleAt: now,
|
latestSampleAt: now,
|
||||||
registeredServerCount: 24,
|
registeredServerCount: 24,
|
||||||
|
autoscalingPaused: false,
|
||||||
scope: { game: null, map: null, timeZone: 'America/New_York' },
|
scope: { game: null, map: null, timeZone: 'America/New_York' },
|
||||||
scopeOptions: { games: ['t5'], maps: [] },
|
scopeOptions: { games: ['t5'], maps: [] },
|
||||||
scopeFirstSampleAt: now - 14 * 86400000,
|
scopeFirstSampleAt: now - 14 * 86400000,
|
||||||
@@ -224,6 +225,45 @@ it('highlights at most three supported policy changes and collapses tentative fi
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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', () => {
|
it('keeps accessible chart selection, partial periods and gaps while collapsing inspection controls', () => {
|
||||||
const data = report();
|
const data = report();
|
||||||
data.points.push({
|
data.points.push({
|
||||||
@@ -296,6 +336,30 @@ it('keeps average lobby bar heights comparable across partial periods and collec
|
|||||||
).toBe(true);
|
).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', () => {
|
it('uses shallow URL state for the selected breakdown while retaining the common summary', () => {
|
||||||
scope('?view=maps', '?view=instances&serverQ=theater&serverPage=2');
|
scope('?view=maps', '?view=instances&serverQ=theater&serverPage=2');
|
||||||
let body = render(MetricsPage, {
|
let body = render(MetricsPage, {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
compareMetricValues,
|
compareMetricValues,
|
||||||
metricAverageLobbies,
|
metricAverageLobbies,
|
||||||
metricDuration,
|
metricDuration,
|
||||||
|
metricLobbyAxis,
|
||||||
metricNumber,
|
metricNumber,
|
||||||
metricPercent,
|
metricPercent,
|
||||||
metricPeriodLabels
|
metricPeriodLabels
|
||||||
@@ -17,6 +18,24 @@ describe('metric presentation', () => {
|
|||||||
expect(metricAverageLobbies(0, 0)).toBeNull();
|
expect(metricAverageLobbies(0, 0)).toBeNull();
|
||||||
expect(metricAverageLobbies(4, Number.NaN)).toBeNull();
|
expect(metricAverageLobbies(4, Number.NaN)).toBeNull();
|
||||||
});
|
});
|
||||||
|
it.each<[number, number[]]>([
|
||||||
|
[0, [1, 0]],
|
||||||
|
[0.4, [1, 0]],
|
||||||
|
[1, [1, 0]],
|
||||||
|
[2.1, [3, 2, 1, 0]],
|
||||||
|
[3.75, [4, 3, 2, 1, 0]],
|
||||||
|
[5, [6, 4, 2, 0]],
|
||||||
|
[14, [15, 10, 5, 0]],
|
||||||
|
[41.2, [60, 40, 20, 0]],
|
||||||
|
[999, [1000, 500, 0]],
|
||||||
|
[2500, [3000, 2000, 1000, 0]]
|
||||||
|
])('uses whole-lobby ticks that cover an average of %s', (maximum, expected) => {
|
||||||
|
const axis = metricLobbyAxis(maximum);
|
||||||
|
expect(axis.ticks).toEqual(expected);
|
||||||
|
expect(axis.ceiling).toBe(axis.ticks[0]);
|
||||||
|
expect(axis.ceiling).toBeGreaterThanOrEqual(maximum);
|
||||||
|
expect(axis.ticks.every(Number.isInteger)).toBe(true);
|
||||||
|
});
|
||||||
it('distinguishes unavailable evidence, zero, and tiny nonzero values', () => {
|
it('distinguishes unavailable evidence, zero, and tiny nonzero values', () => {
|
||||||
expect(metricPercent(0, 0)).toBe('—');
|
expect(metricPercent(0, 0)).toBe('—');
|
||||||
expect(metricPercent(0, 1)).toBe('0.0%');
|
expect(metricPercent(0, 1)).toBe('0.0%');
|
||||||
|
|||||||
Reference in New Issue
Block a user