From bcd3b99ee484a3b41a2c0453ff32df8791c05b86 Mon Sep 17 00:00:00 2001 From: Josh Wright Date: Mon, 12 Oct 2026 19:55:26 -0400 Subject: [PATCH] Fix variant-aware fleet card map labels (Refs #150) --- src/lib/fleet-overview.ts | 4 +- src/lib/ui/components/ServerCard.svelte | 22 ++- src/lib/ui/server-labels.ts | 11 +- src/routes/+page.svelte | 3 + tests/client/ui-server-card-maps.test.ts | 190 +++++++++++++++++++++++ 5 files changed, 220 insertions(+), 10 deletions(-) create mode 100644 tests/client/ui-server-card-maps.test.ts diff --git a/src/lib/fleet-overview.ts b/src/lib/fleet-overview.ts index 33f34bb..28c0f18 100644 --- a/src/lib/fleet-overview.ts +++ b/src/lib/fleet-overview.ts @@ -66,7 +66,9 @@ export function freshServer(server: FleetServer, now: number): boolean { ); } -export function fleetIdentity(server: FleetServer) { +export function fleetIdentity( + server: Pick +) { const parsed = parseServerName(server.name); const game = server.game ?? parsed?.game ?? ''; const mode = server.game_mode ?? parsed?.gameMode; diff --git a/src/lib/ui/components/ServerCard.svelte b/src/lib/ui/components/ServerCard.svelte index 9b8d09a..13cf909 100644 --- a/src/lib/ui/components/ServerCard.svelte +++ b/src/lib/ui/components/ServerCard.svelte @@ -5,7 +5,8 @@ import type { RosterPlayer, Health } from '$ui/stores/servers.js'; import { telemetryByServer, uiNow } from '$ui/stores/servers.js'; import { elapsedSince } from '$ui/duration.js'; - import { mapTitle, serverIdentity, serverTitle } from '$ui/server-labels.js'; + import { serverIdentity, serverTitle } from '$ui/server-labels.js'; + import { fleetIdentity } from '$lib/fleet-overview.js'; import { decodeRconError, rconFixHint } from '$ui/rconError.js'; import Card from './primitives/Card.svelte'; import Badge from './primitives/Badge.svelte'; @@ -20,6 +21,9 @@ name: string; players?: number | null; map?: string | null; + game?: string | null; + mapSlug?: string | null; + gameMode?: string | null; gametype?: string | null; roster?: RosterPlayer[] | null; health?: Health; @@ -45,6 +49,9 @@ name, players = null, map = null, + game: serverGame = null, + mapSlug = null, + gameMode = null, roster = null, health = 'offline', lastSeenAt = null, @@ -76,8 +83,17 @@ const compact = $derived(compactEmpty && health === 'online' && players === 0 && !restarting); const state = $derived(restarting ? 'restarting' : compact ? 'ready' : health); const error = $derived(decodeRconError(lastError)); - const mapLabel = $derived(mapTitle(map)); - const showMap = $derived(mapLabel !== '—' && mapLabel !== title); + const mapLabel = $derived( + fleetIdentity({ + name, + game: serverGame, + map, + last_map: null, + map_slug: mapSlug, + game_mode: gameMode + }).map + ); + const showMap = $derived(map !== null && mapLabel !== '—' && mapLabel !== title); const topRoster = $derived( (roster ?? []) .slice() diff --git a/src/lib/ui/server-labels.ts b/src/lib/ui/server-labels.ts index 89652e9..d52618c 100644 --- a/src/lib/ui/server-labels.ts +++ b/src/lib/ui/server-labels.ts @@ -7,13 +7,12 @@ export function gameTitle(game: string | null | undefined, compact = false): str } export function mapTitle(map: string | null | undefined): string { - return ( - [...T5_ZOMBIE_MAPS, ...T6_ZOMBIE_MAPS].find( - (entry) => entry.canonical === map?.toLowerCase() - )?.friendly ?? - map ?? - '—' + const matches = [...T5_ZOMBIE_MAPS, ...T6_ZOMBIE_MAPS].filter( + (entry) => entry.canonical === map?.toLowerCase() ); + // Engine identifiers alone cannot distinguish selections such as Town and TranZit. + if (matches.length > 1) return `${map} (variant unknown)`; + return matches[0]?.friendly ?? map ?? '—'; } export function serverTitle(name: string): string { diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 3dd864f..3e2c8be 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -552,6 +552,9 @@ telemetry={server.telemetry} players={server.last_players} map={server.last_map ?? server.map} + game={server.game} + mapSlug={server.map_slug} + gameMode={server.game_mode} capacity={currentCapacity(server, observationNow())} gametype={server.last_gametype} roster={server.roster} diff --git a/tests/client/ui-server-card-maps.test.ts b/tests/client/ui-server-card-maps.test.ts new file mode 100644 index 0000000..ef7b64e --- /dev/null +++ b/tests/client/ui-server-card-maps.test.ts @@ -0,0 +1,190 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { flushSync, mount, unmount, type ComponentProps } from 'svelte'; +import { fromStore, writable } from 'svelte/store'; +import ServerCard from '../../src/lib/ui/components/ServerCard.svelte'; +import ServerIdentity from '../../src/lib/ui/components/ServerIdentity.svelte'; +import PlayerScoreboard from '../../src/lib/ui/components/PlayerScoreboard.svelte'; +import { fleetIdentity } from '../../src/lib/fleet-overview.js'; +import { gameEvent } from '../helpers/telemetry.js'; + +afterEach(async () => { + await Promise.all(components.splice(0).map((component) => unmount(component))); + document.body.replaceChildren(); +}); +vi.mock('$app/stores', () => ({ + page: writable({ url: new URL('http://localhost/') }) +})); +const components: ReturnType[] = []; +type MapServer = Parameters[0]; +const town: MapServer = { + name: 't6_zombie_town_1', + game: 't6', + map: 'zm_transit', + last_map: 'zm_transit' +}; + +function show( + initial: MapServer, + options: Partial> = {}, + fleetHeading = true +) { + const store = writable(initial); + const state = fromStore(store); + const identity = () => fleetIdentity(state.current); + components.push( + mount(ServerCard, { + target: document.body, + props: { + get name() { + return state.current.name; + }, + get game() { + return state.current.game; + }, + get map() { + return state.current.last_map ?? state.current.map; + }, + get mapSlug() { + return state.current.map_slug; + }, + get gameMode() { + return state.current.game_mode; + }, + get mapLabelOverride() { + return fleetHeading ? identity().map : undefined; + }, + health: 'online', + players: 4, + ...options + } + }), + mount(ServerIdentity, { + target: document.body, + props: { + get name() { + return state.current.name; + }, + get mapLabel() { + return identity().map; + } + } + }), + mount(PlayerScoreboard, { + target: document.body, + props: { + get report() { + return { + ...gameEvent( + 'heartbeat', + 12, + 4, + state.current.name, + state.current.last_map ?? state.current.map ?? '' + ), + receivedAt: new Date().toISOString() + }; + }, + get mapLabel() { + return identity().map; + } + } + }) + ); + flushSync(); + return (next: Partial) => { + store.update((server) => ({ ...server, ...next })); + flushSync(); + }; +} +function expectLabels(label: string) { + expect(document.querySelector('h3')?.textContent).toBe(label); + expect(document.querySelector('.identity .map')?.textContent).toBe(label); + expect(document.querySelector('.match-title h2')?.textContent).toBe(label); + expect(document.querySelector('.map-note')).toBeNull(); +} + +const variants = [ + ['town', 'Town Survival'], + ['farm', 'Farm Survival'], + ['busdepot', 'Bus Depot Survival'], + ['transit', 'TranZit'] +]; +describe('variant-aware rendered map labels', () => { + it.each(variants)('keeps %s consistent across card, list, and detail', (slug, label) => { + const update = show({ ...town, name: `t6_zombie_${slug}_1` }); + expectLabels(label); + update({ name: 'Friday lobby', map_slug: slug, game_mode: 'zombie' }); + expectLabels(label); + }); + + it.each([undefined, null, 'unknown'])('does not infer TranZit from variant %s', (map_slug) => { + const server = { ...town, name: 'Custom lobby', map_slug }; + show(server); + expectLabels('zm_transit (variant unknown)'); + expect(fleetIdentity(server).launch).toBeNull(); + }); + + it('lets unknown stored metadata override a recognized name', () => { + show({ ...town, map_slug: 'unknown' }); + expectLabels('zm_transit (variant unknown)'); + }); + + it('updates map and variant metadata reactively without retaining an obsolete launch shortcut', () => { + const update = show(town); + expectLabels('Town Survival'); + update({ last_map: 'zm_tomb' }); + expectLabels('Origins'); + expect(fleetIdentity({ ...town, last_map: 'zm_tomb' }).launch).toBeNull(); + update({ last_map: 'zm_transit', map_slug: 'farm' }); + expectLabels('Farm Survival'); + expect(fleetIdentity({ ...town, map_slug: 'farm' }).launch).toEqual({ + game: 't6', + slug: 'farm' + }); + update({ map_slug: 'unknown' }); + expectLabels('zm_transit (variant unknown)'); + update({ last_map: 'zm_buried' }); + expectLabels('Buried'); + }); + + it.each(['stale', 'offline'] as const)( + 'keeps the selected variant for %s observations', + (health) => { + show(town, { health, lastSeenAt: '2026-01-01T00:00:00Z' }); + expectLabels('Town Survival'); + } + ); + + it.each([true, false])('preserves empty-card compact=%s behavior', (compactEmpty) => { + show(town, { players: 0, compactEmpty }); + expectLabels('Town Survival'); + expect(document.querySelector('article')?.classList.contains('compact')).toBe(compactEmpty); + expect(document.querySelector('article .metrics') === null).toBe(compactEmpty); + }); + + it.each([ + ['online', 'Current map'], + ['stale', 'Last map'], + ['offline', 'Last map'] + ] as const)('retains useful %s map context with a name-based heading', (health, prefix) => { + const update = show(town, { health }, false); + expect(document.querySelector('.map-note')).toBeNull(); + update({ last_map: 'zm_tomb' }); + expect(document.querySelector('h3')?.textContent).toBe('Town Survival'); + expect(document.querySelector('.map-note')?.textContent?.trim()).toBe(`${prefix}: Origins`); + }); + + it('hides additional map context in compact cards', () => { + show({ ...town, last_map: 'zm_tomb' }, { players: 0 }, false); + expect(document.querySelector('.map-note')).toBeNull(); + }); + + it('uses stored variant metadata for a custom heading and does not invent a missing observation', () => { + const update = show({ ...town, name: 'Friday lobby', map_slug: 'town' }, {}, false); + expect(document.querySelector('.map-note')?.textContent?.trim()).toBe( + 'Current map: Town Survival' + ); + update({ map: null, last_map: null }); + expect(document.querySelector('.map-note')).toBeNull(); + }); +});