Make UI language, timestamps and live feedback consistent

This commit is contained in:
2026-10-07 19:46:40 -04:00
parent 0ed3cd6b47
commit 70185eb98c
81 changed files with 656 additions and 332 deletions
+1 -1
View File
@@ -170,7 +170,7 @@ describe('browser folder uploads', () => {
expect(next.version).not.toBe(first.version);
expect(next.libraryId).toBe(first.libraryId);
expect(otherGame.libraryId).not.toBe(first.libraryId);
expect(listLibraries().map(library => library.name)).toEqual(['Black Ops I (T5)', 'Black Ops II (T6)']);
expect(listLibraries().map(library => library.name)).toEqual(['Black Ops I', 'Black Ops II']);
});
it('follows completion order and preserves the latest successful files during failed or unfinished uploads', async () => {
+2 -2
View File
@@ -43,8 +43,8 @@ function legacyVersion(id: string, libraryId: string, createdAt: string, publish
describe('fixed game file slots', () => {
it('always lists exactly T5 and T6, including before any files are uploaded', () => {
expect(listLibraries()).toEqual([
{ id: 't5', game: 't5', name: 'Black Ops I (T5)', description: '', createdAt: '', latestVersionId: null, versions: [] },
{ id: 't6', game: 't6', name: 'Black Ops II (T6)', description: '', createdAt: '', latestVersionId: null, versions: [] }
{ id: 't5', game: 't5', name: 'Black Ops I', description: '', createdAt: '', latestVersionId: null, versions: [] },
{ id: 't6', game: 't6', name: 'Black Ops II', description: '', createdAt: '', latestVersionId: null, versions: [] }
]);
expect(latestPublishedLibraries()).toEqual([]);
expect(newestReadyLibrary('t5')).toBeNull();
+3 -3
View File
@@ -137,7 +137,7 @@ describe('automatic host preparation', () => {
hosts.updateHost(id, { mode: 'disabled' });
hosts.updateHost(id, { mode: 'active' });
const status = preparation.reconcileHostPreparation(id);
expect(status.items).toContainEqual(expect.objectContaining({ label: 'Black Ops II (T6)', state: 'blocked' }));
expect(status.items).toContainEqual(expect.objectContaining({ label: 'Black Ops II', state: 'blocked' }));
expect(hosts.getHost(id)?.mode).toBe('disabled');
});
@@ -510,8 +510,8 @@ describe('automatic host preparation', () => {
hosts.updateHost(id, { minimumFreeBytes: 1_000 });
const status = preparation.reconcileHostPreparation(id);
expect(status.items).toEqual(expect.arrayContaining([
expect.objectContaining({ label: 'Black Ops I (T5)', state: 'blocked' }),
expect.objectContaining({ label: 'Black Ops II (T6)', state: 'preparing' })
expect.objectContaining({ label: 'Black Ops I', state: 'blocked' }),
expect.objectContaining({ label: 'Black Ops II', state: 'preparing' })
]));
expect(operations(id)).toHaveLength(1);
expect(operations(id)[0].payload.libraryVersionId).toBe(small.id);
+21 -1
View File
@@ -1,6 +1,6 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { get } from 'svelte/store';
import { availableTimeZones, createTimeFormatter, DEFAULT_TIME_ZONE, validTimeZone } from '../../src/lib/time-zone.js';
import { availableTimeZones, createTimeFormatter, DEFAULT_TIME_ZONE, validTimeZone, timestampISO } from '../../src/lib/time-zone.js';
import { startTimeZonePreference, timeFormat, timeZone, timeZonePersistenceError, TIME_ZONE_STORAGE_KEY } from '../../src/lib/ui/stores/time-zone.js';
let stop: (() => void) | undefined;
@@ -52,9 +52,29 @@ describe('timezone formatting', () => {
it('renders missing or invalid timestamps without crashing the page', () => {
for (const value of [null, undefined, '', 'bad timestamp', NaN, new Date(NaN)]) {
expect(ny.dateTime(value)).toBe('—');
expect(ny.relative(value)).toBe('—');
expect(timestampISO(value)).toBeUndefined();
}
});
it('shares compact recent times, exact older dates, and UTC normalization', () => {
const now = Date.parse('2026-10-07T18:00:00Z');
expect(ny.relative(now - 2_000, now)).toBe('Just now');
expect(ny.relative('2026-10-07 17:59:48', now)).toBe('12 s ago');
expect(ny.relative(now - 120_000, now)).toBe('2 min ago');
expect(ny.relative(now - 23 * 3_600_000, now)).toBe('23 h ago');
expect(ny.relative('2026-10-06T17:00:00Z', now)).toBe('Yesterday, 1:00 PM');
expect(ny.relative('2026-10-05T17:00:00Z', now)).toBe('Oct 5, 2026, 1:00 PM EDT');
expect(ny.relative('2026-10-08T18:00:00Z', now)).toBe('Oct 8, 2026, 2:00 PM EDT');
expect(timestampISO('2026-10-07 17:59:48')).toBe('2026-10-07T17:59:48.000Z');
});
it('uses timezone calendar dates for yesterday across DST and year boundaries', () => {
expect(ny.relative('2026-11-01T04:30:00Z', Date.parse('2026-11-02T05:15:00Z'))).toBe('Yesterday, 12:30 AM');
expect(ny.relative('2025-12-31T05:00:00Z', Date.parse('2026-01-01T06:00:00Z'))).toBe('Yesterday, 12:00 AM');
expect(createTimeFormatter('invalid').relative('2026-10-05T17:00:00Z', Date.parse('2026-10-07T18:00:00Z'))).toBe('Oct 5, 2026, 1:00 PM EDT');
});
it('offers valid zones including New York and UTC, with an older-browser fallback', () => {
expect(availableTimeZones()).toContain(DEFAULT_TIME_ZONE);
expect(availableTimeZones()).toContain('UTC');
+1 -1
View File
@@ -44,7 +44,7 @@ it('shows relative recent timestamps and preserves accurate older dates and inva
expect(activityTimeLabel('2026-10-07T16:00:00Z', 'America/New_York', now)).toBe('2 h ago');
expect(activityTimeLabel('2026-10-06T17:00:00Z', 'America/New_York', now)).toBe('Yesterday, 1:00 PM');
expect(activityTimeLabel('not a date', 'UTC', now)).toBe('Time unavailable');
expect(activityTimeLabel('2026-10-08T18:00:00Z', 'America/New_York', now)).toBe('Oct 8, 2:00 PM');
expect(activityTimeLabel('2026-10-08T18:00:00Z', 'America/New_York', now)).toBe('Oct 8, 2026, 2:00 PM EDT');
});
it('renders one page heading and keeps management tools available only to operators', () => {
+4 -4
View File
@@ -36,7 +36,7 @@ it('keeps current estimates and host comparison before history with scoped histo
expect(body.indexOf('aria-label="History range"')).toBeGreaterThan(body.indexOf('id="capacity-history-title"'));
expect(body).toContain('range=7d&game=t5&host=ec-1');
expect(body).toContain('3 of 3 available');
expect(body).toContain('No queued instances');
expect(body).toContain('No queued servers');
expect(body).not.toContain('href="/provisioning"');
expect(body).not.toContain('Manage hosts');
expect(body).toContain('Updates every 30 seconds');
@@ -48,13 +48,13 @@ it('keeps fleet-wide queue scope explicit and preserves unavailable estimates',
const body = render(CapacityPage, { props: { data: report } }).body;
expect(body).toContain('Capacity estimate unavailable');
expect(body).toContain('href="/provisioning"');
expect(body).toContain('2 instances queued');
expect(body).toContain('2 servers queued');
expect(body).toContain('Fleet-wide');
});
it('keeps ineligible hosts and their reasons visible while collapsing detailed assumptions', () => {
const body = render(CapacityHosts, { props: { returnTo: '/capacity', hosts: [host(), host({ id: 'missing-files', name: 'Missing files', eligible: false, estimatedServers: 0, limitingResource: null, reasons: ['Latest game files are not ready.'] }), host({ id: 'offline', name: 'Offline host', connected: false, eligible: false, estimatedServers: 0, cpuCoresAvailable: null, memoryBytesAvailable: null, limitingResource: null, reasons: ['Waiting for a fresh host report.'] })] } }).body;
expect(body).toContain('Additional instances</th>');
expect(body).toContain('Additional servers</th>');
expect(body).toContain('Not eligible');
expect(body).toContain('Latest game files are not ready.');
expect(body).toContain('Offline');
@@ -62,7 +62,7 @@ it('keeps ineligible hosts and their reasons visible while collapsing detailed a
expect(body).not.toContain('>Ready<');
expect(body).toMatch(/<tr hidden[^>]*id="capacity-host-ec-1"/);
expect(body).toContain('aria-expanded="false"');
expect(body).toContain('Running instance');
expect(body).toContain('Running server');
expect(body).toContain('aria-label="Host capacity comparison" tabindex="0"');
});
+51
View File
@@ -0,0 +1,51 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { render } from 'svelte/server';
import Freshness from '../../src/lib/ui/components/Freshness.svelte';
import { stripGameColorCodes } from '../../src/lib/ui/log-text.js';
afterEach(() => vi.useRealTimers());
describe('quiet refresh feedback', () => {
it('shows relative receipt time with an exact tooltip without announcing every tick', () => {
vi.useFakeTimers(); vi.setSystemTime('2026-10-07T12:03:00Z');
const body = render(Freshness, { props: { updatedAt: Date.parse('2026-10-07T12:00:00Z'), intervalLabel: 'Updates every 5 seconds' } }).body;
expect(body).toContain('Live');
expect(body).toContain('3 min ago');
expect(body).toContain('datetime="2026-10-07T12:00:00.000Z"');
expect(body).toMatch(/<time[^>]+title="[^"]+"/);
expect(body).toContain('Updates every 5 seconds');
expect(body).not.toMatch(/aria-live|role="status"|>Retry</);
});
it('does not claim live updates or an automatic schedule while paused', () => {
const body = render(Freshness, { props: { paused: true, updatedAt: Date.now(), intervalLabel: 'Updates every 5 seconds' } }).body;
expect(body).toContain('Paused');
expect(body).not.toMatch(/Live|Updates every/);
});
it('replaces live claims with a retry on failures and treats invalid timestamps as unknown', () => {
const body = render(Freshness, { props: { updatedAt: Date.now(), error: 'Log updates interrupted.', intervalLabel: 'Updates every 5 seconds', onRetry: () => {}, retryDisabled: true } }).body;
expect(body).toContain('Log updates interrupted.');
expect(body).toMatch(/<button[^>]+disabled[^>]*>Retry<\/button>/);
expect(body).not.toMatch(/Live|Updates every/);
const waiting = render(Freshness, { props: { updatedAt: Number.NaN } }).body;
expect(waiting).toContain('Waiting for update');
expect(waiting).not.toContain('<time');
});
});
describe('readable game logs', () => {
it('strips digit colour directives while preserving log whitespace and other carets', () => {
const raw = ' [12:30] ^1Red^7 player\t^2joined^0\r\npath ^name, exponent x^y, trailing ^';
expect(stripGameColorCodes(raw)).toBe(' [12:30] Red player\tjoined\r\npath ^name, exponent x^y, trailing ^');
expect(raw).toContain('^1Red^7');
});
it('allows searches across embedded colour changes without mutating downloadable source lines', () => {
const raw = ['^1Con^2nection^7 accepted', ' Player ^3Jamie^7 joined '];
const displayed = raw.map(stripGameColorCodes);
expect(displayed.filter(line => line.toLowerCase().includes('connection'))).toEqual(['Connection accepted']);
expect(displayed.join('\n')).toBe('Connection accepted\n Player Jamie joined ');
expect(raw.join('\n')).toBe('^1Con^2nection^7 accepted\n Player ^3Jamie^7 joined ');
});
});
@@ -0,0 +1,34 @@
import { describe, expect, it, vi } from 'vitest';
import { render } from 'svelte/server';
import { writable } from 'svelte/store';
import HostOperations from '../../src/routes/hosts/operations/+page.svelte';
import type { HostOperationSummary } from '../../src/lib/hosts.js';
vi.mock('$app/stores', () => ({ page: writable({ url: new URL('http://localhost/hosts/operations') }), navigating: writable(null) }));
vi.mock('$app/navigation', () => ({ beforeNavigate: vi.fn() }));
const operation: HostOperationSummary = {
id: 'retained-op', hostId: 'host-1', hostName: 'Dallas 1', kind: 'instance.inspect',
instanceId: 'container-1', generation: 1, state: 'completed', error: null,
serverName: 't6_zombie_town_1', targetPath: null, source: 'maintenance', routine: true,
createdAt: '2026-10-07 17:00:00', updatedAt: '2026-10-07 17:00:00'
};
describe('host operation history presentation', () => {
it('uses readable operation, server and state labels while keeping identifiers in technical detail', () => {
const body = render(HostOperations, { props: { data: {
history: { operations: [operation], page: 1, pageSize: 25, totalPages: 1, total: 1, filter: 'all' },
scope: {}, hostName: null
} as any } }).body;
expect(body).toContain('Check server container');
expect(body).toContain('Town Survival · Lobby 1');
expect(body).toContain('Completed');
expect(body).toContain('Technical details</summary>');
expect(body.indexOf('instance.inspect')).toBeGreaterThan(body.indexOf('Technical details</summary>'));
expect(body).toContain('retained-op');
expect(body).toContain('datetime="2026-10-07T17:00:00.000Z"');
expect(body).toContain('title="Oct 7, 2026, 1:00:00 PM EDT"');
expect(body).toContain('Updates every 15 seconds');
expect(body).not.toContain('>Refresh</button>');
});
});
+3 -3
View File
@@ -57,8 +57,8 @@ it('distinguishes real zero usage from missing or invalid metrics', () => {
it('renders compact host links and workload without duplicate identity, setup metadata or deletion actions', () => {
const body = render(HostList, { props: { hosts: [{ ...host(), serverCount: 3, playerCount: 12, unreportedInstances: 0 }], now } }).body;
expect(body).toContain('3 assigned instances on ec-1');
expect(body).toContain('>Instances</th>');
expect(body).toContain('3 assigned servers on ec-1');
expect(body).toContain('>Servers</th>');
expect(body).toContain('>Players</th>');
expect(body).toContain('>12</td>');
expect(body).toContain('0.0%');
@@ -76,7 +76,7 @@ it('renders compact host links and workload without duplicate identity, setup me
it('marks partial player totals and distinguishes unavailable counts from empty hosts', () => {
const partial = render(HostList, { props: { hosts: [{ ...host(), serverCount: 3, playerCount: 4, unreportedInstances: 1 }], now } }).body;
expect(partial).toContain('>4+</td>');
expect(partial).toContain('Player counts unavailable for 1 of 3 instances');
expect(partial).toContain('Player counts unavailable for 1 of 3 servers');
const unknown = render(HostList, { props: { hosts: [{ ...host(), serverCount: 3, playerCount: 0, unreportedInstances: 3 }], now } }).body;
expect(unknown).toContain('>—</td>');
const empty = render(HostList, { props: { hosts: [{ ...host(), serverCount: 0, playerCount: 0, unreportedInstances: 0 }], now } }).body;
+72
View File
@@ -0,0 +1,72 @@
import { expect, it, vi } from 'vitest';
import { render } from 'svelte/server';
import { writable } from 'svelte/store';
import { plural, countLabel } from '../../src/lib/ui/plural.js';
import { gameTitle } from '../../src/lib/ui/server-labels.js';
import { playerCompletionLabel } from '../../src/lib/ui/player-match.js';
import { hostCalibrationHint } from '../../src/lib/ui/host-overview.js';
import { scopeLabel } from '../../src/lib/script-library.js';
import Setup from '../../src/routes/setup/+page.svelte';
import Hosts from '../../src/routes/hosts/+page.svelte';
import PlayerMatchCards from '../../src/lib/ui/components/PlayerMatchCards.svelte';
import GameFileHosts from '../../src/lib/ui/components/GameFileHosts.svelte';
import type { Host } from '../../src/lib/hosts.js';
import type { PlayerMatch } from '../../src/lib/leaderboard.js';
vi.mock('$app/stores', () => ({ page: writable({ url: new URL('http://localhost/hosts'), data: { user: { id: 'admin', role: 'admin' } } }) }));
vi.mock('$app/navigation', () => ({ goto: vi.fn(), beforeNavigate: vi.fn(), afterNavigate: vi.fn(), invalidateAll: vi.fn() }));
const now = Date.parse('2026-10-07T12:00:00Z');
const host: Host = { id: 'dallas', name: 'Dallas 1', hostname: 'dallas.example', mode: 'active', portMin: 28960, portMax: 28980,
bindAddress: '0.0.0.0', publicAddress: '', minimumFreeBytes: 100, createdAt: new Date(now).toISOString(),
lastHeartbeat: new Date(now).toISOString(), connected: false, connectionState: 'offline', readiness: 'ready', readinessReasons: [],
inventory: {}, enrolled: true, credentialActive: true, decommission: null };
const match: PlayerMatch = { id: 17, game: 't5', server: 't5_zombie_theater_1', mapName: 'Kino Der Toten', startedAt: new Date(now).toISOString(), lastSeen: new Date(now).toISOString(), status: 'completed', completed: 1, highestRound: 30, roundsSurvived: 20, kills: 1200, headshots: 200, revives: 2, downs: 1, bestScore: 45000 };
it('pluralizes zero, one, multiple, fractional and irregular counts consistently', () => {
expect(countLabel(0, 'server')).toBe('0 servers');
expect(countLabel(1, 'player')).toBe('1 player');
expect(countLabel(2000, 'host')).toBe('2,000 hosts');
expect(countLabel(1, 'match', 'matches')).toBe('1 match');
expect(countLabel(2, 'match', 'matches')).toBe('2 matches');
expect(plural(1, 'needs', 'need')).toBe('needs');
expect(plural(0, 'needs', 'need')).toBe('need');
expect(plural(1.5, 'hour')).toBe('hours');
});
it('recommends a sampling upgrade only for a known older version', () => {
for (const version of ['1.0.0', '1.4.5', 'v1.4.0']) expect(hostCalibrationHint(version)).toContain('Update this host to agent 1.4.6');
for (const version of ['1.4.6', '1.7.0', '1.10.0', '2.0.0', 'v2.0.0+build', null, undefined, 'unknown', '1.4']) {
expect(hostCalibrationHint(version)).toContain('still calibrating');
expect(hostCalibrationHint(version)).not.toContain('Update');
}
});
it('describes per-player completion as final-report evidence without inventing unfinished results', () => {
expect(playerCompletionLabel(match)).toBe('Present in final report');
expect(playerCompletionLabel({ ...match, completed: 0 })).toBe('Not present in final report');
expect(playerCompletionLabel({ ...match, status: 'active', completed: 0 })).toBe('Match in progress');
expect(playerCompletionLabel({ ...match, status: 'interrupted', completed: 0 })).toBe('Final report unavailable');
const body = render(PlayerMatchCards, { props: { matches: [match, { ...match, id: 18, completed: 0 }] } }).body;
expect(body).toContain('Present in final report'); expect(body).toContain('Not present in final report');
expect(body).not.toContain('Completion credited'); expect(body).not.toContain('No completion credit');
});
it('uses product game names while preserving unsupported game identifiers', () => {
expect(gameTitle('t5')).toBe('Black Ops I'); expect(gameTitle('T6')).toBe('Black Ops II');
expect(gameTitle('t5', true)).toBe('BO1'); expect(gameTitle('t6', true)).toBe('BO2');
expect(gameTitle('custom')).toBe('CUSTOM'); expect(gameTitle(null)).toBe('Unknown game');
expect(scopeLabel({ scope_type: 'game', scope_value: 't5' })).toBe('Black Ops I');
});
it('does not offer an existing-account sign-in path on first administrator setup', () => {
const body = render(Setup).body;
expect(body).toContain('Create the first administrator'); expect(body).not.toContain('Already have an account');
expect(body).not.toContain('href="/login"');
});
it('renders singular host attention and explicit whitespace before an offline suffix', () => {
const body = render(Hosts, { props: { data: { hosts: [{ ...host, serverCount: 0, playerCount: 0, unreportedInstances: 0 }], serverNow: now, user: null, setupRequired: false } } }).body;
expect(body).toContain('1 host'); expect(body).toContain('1 needs attention'); expect(body).not.toContain('1 need attention');
const files = render(GameFileHosts, { props: { hosts: [host], caches: [{ hostId: host.id, libraryVersionId: 'files', status: 'missing', bytesDownloaded: 0, bytesTotal: 0, manifestSha256: null, error: null, updatedAt: new Date(now).toISOString(), operationId: null }] } }).body;
expect(files).toMatch(/Dallas 1<\/a>.*?> · Offline<\/span>/);
});
+4 -4
View File
@@ -17,7 +17,7 @@ it('renders one page heading, six columns, a combined identity, and compact date
const body = render(MatchHistoryPage, { props: { data: { history: report(), user: null, setupRequired: false } } }).body;
expect(body.match(/<h1[ >]/g)).toHaveLength(1);
expect(body.match(/scope="col"/g)).toHaveLength(6);
expect(body).toContain('Today, 1:26 PM'); expect(body).toContain('Lobby 1 · Black Ops II');
expect(body).toContain('2 h ago'); expect(body).toContain('Lobby 1 · Black Ops II');
expect(body).not.toContain('>Server</th>'); expect(body).not.toContain('Show 25 more');
expect(body).toContain('matchPage=2');
});
@@ -60,7 +60,7 @@ it('offers filter reset for an empty selection and retains the scoped instance v
});
it('keeps custom instance names and calculates yesterday across DST and year boundaries', () => {
expect(matchInstanceLabel({ ...match, server: 'Weekend lobby' })).toBe('Weekend lobby · Black Ops II');
expect(matchStartedLabel('2026-03-08T06:30:00Z', 'America/New_York', new Date('2026-03-09T04:15:00Z'))).toBe('Yesterday, 1:30 AM');
expect(matchStartedLabel('2025-10-06T17:26:00Z', 'America/New_York', new Date('2026-10-06T20:00:00Z'))).toBe('Oct 6, 2025, 1:26 PM');
expect(matchStartedLabel('2026-10-06 17:26:00', 'America/New_York', new Date('2026-10-06T20:00:00Z'))).toBe('Today, 1:26 PM');
expect(matchStartedLabel('2026-03-08T06:30:00Z', 'America/New_York', new Date('2026-03-09T04:15:00Z'))).toBe('21 h ago');
expect(matchStartedLabel('2025-10-06T17:26:00Z', 'America/New_York', new Date('2026-10-06T20:00:00Z'))).toBe('Oct 6, 2025, 1:26 PM EDT');
expect(matchStartedLabel('2026-10-06 17:26:00', 'America/New_York', new Date('2026-10-06T20:00:00Z'))).toBe('2 h ago');
});
+1 -1
View File
@@ -116,7 +116,7 @@ it('keeps accessible chart selection, partial periods and gaps while collapsing
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 });
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 idle instances');
expect(body).toContain('No captured time · not evidence of idle servers');
expect(body).toContain('tabindex="0"');
expect(body).toContain('Inspect a period');
expect(body).toContain('Partial period');