From 40df99c115e31f9ac2e9a31ab630ded26833fa3f Mon Sep 17 00:00:00 2001 From: Josh Wright Date: Wed, 7 Oct 2026 16:24:12 -0400 Subject: [PATCH] Simplify autoscaling status and policy details --- docs/guides/autoscaling.md | 6 +- src/lib/ui/autoscaling.ts | 81 +++++++ .../components/autoscaling/PolicyCard.svelte | 229 ++++++++++++++++++ src/routes/autoscaling/+page.svelte | 46 ++-- tests/unit/ui-autoscaling.test.ts | 91 +++++++ 5 files changed, 426 insertions(+), 27 deletions(-) create mode 100644 src/lib/ui/autoscaling.ts create mode 100644 src/lib/ui/components/autoscaling/PolicyCard.svelte create mode 100644 tests/unit/ui-autoscaling.test.ts diff --git a/docs/guides/autoscaling.md b/docs/guides/autoscaling.md index a78d45d..68c3f35 100644 --- a/docs/guides/autoscaling.md +++ b/docs/guides/autoscaling.md @@ -10,7 +10,7 @@ T5 game-wide policies cover the ten standard Zombies maps. Dead Ops Arcade is no Choose **Add policy**, select the game and scope, and enter an explicit maximum. The initial editor selects Off, with a minimum of one running lobby, one empty spare, and a 15-minute empty interval. Counts must be between 1 and 20; the minimum and spare target cannot exceed the maximum. -Choose **Save policy** to apply changes directly. Saving checks the current fleet, policy revision, ownership conflicts, and host readiness. If saving is blocked, the form keeps your settings and shows the reason. Saved policies show their lobbies, key availability, current decision, and blockers. +Choose **Save policy** to apply changes directly. Saving checks the current fleet, policy revision, ownership conflicts, and host readiness. If saving is blocked, the form keeps your settings and shows the reason. Saved policies show lobby counts, key availability, and the current status. An active job appears once with its latest stage and a **View job** link. Use **Lobbies** for per-map spare coverage and occupancy evidence, **Activity** for decision changes grouped by job, and **Metrics** for observed capacity and provisioning measurements. | Autoscaling | Behavior | | --- | --- | @@ -33,7 +33,7 @@ Current positive occupancy from either telemetry or RCON vetoes an empty claim. ## Pause and cancellation -Use **Pause all** to stop new decisions globally, or **Edit or pause → Off** for one policy. A global pause retains each live policy's ownership; setting it Off releases ownership once its outstanding operation settles. Resuming the global switch preserves each policy's On or Off setting. +Use **Pause all** to stop new decisions globally, or **Pause** on one policy. Use **Resume** to enable it again; the usual activation checks still apply. A global pause retains each live policy's ownership; setting it Off releases ownership once its outstanding operation settles. Resuming the global switch preserves each policy's On or Off setting. Unsubmitted creates and queued removals are withdrawn where possible. Cancelling an autoscaling operation from Provisioning also turns its policy Off so it cannot immediately request a replacement. @@ -45,7 +45,7 @@ Turning a policy Off does not recreate lobbies it removed or remove lobbies it c ## Delete a policy -Choose **Delete policy** and confirm to remove its settings, decisions, and measurements. Existing lobbies, saved keys, and Provisioning history are kept. Deleting a live policy releases ownership without changing the lineup, and a new policy can reuse the same scope. +Open the policy’s **More actions** menu, choose **Delete policy**, and confirm to remove its settings, decisions, and measurements. Existing lobbies, saved keys, and Provisioning history are kept. Deleting a live policy releases ownership without changing the lineup, and a new policy can reuse the same scope. Deletion requires the current policy revision and no unsettled autoscaling operation. If work is outstanding, turn the policy Off and wait for its operation to settle before deleting. Delivered or uncertain host commands retain their policy and admission evidence until reconciliation finishes. diff --git a/src/lib/ui/autoscaling.ts b/src/lib/ui/autoscaling.ts new file mode 100644 index 0000000..e724cc8 --- /dev/null +++ b/src/lib/ui/autoscaling.ts @@ -0,0 +1,81 @@ +import type { ScalingHistory, ScalingMember, ScalingView } from '../autoscaling.js'; +import { parseServerName, zombieMapBySlug, zombieMaps } from '../map-aliases.js'; + +export function lobbyLabel(name: string): string { + const parsed = parseServerName(name); + if (!parsed) return name; + const map = zombieMapBySlug(parsed.game, parsed.mapSlug); + return map ? `${map.friendly}${parsed.lobby === null ? '' : ` · Lobby ${parsed.lobby}`}` : name; +} + +// Job identity belongs in its URL, not in every status description. +export function scalingReason(reason: string): string { + return reason.replace(/Provisioning [\w-]+: /g, ''); +} + +export function policyStatus(view: ScalingView, globallyPaused: boolean) { + if (view.policy.pausedReason) return { label: 'Needs attention', reason: scalingReason(view.policy.pausedReason), tone: 'warning' as const }; + if (globallyPaused || view.policy.mode === 'off') return { + label: globallyPaused ? 'Globally paused' : 'Paused', + reason: view.unsettled.length ? 'No new scaling requests. Outstanding jobs are still tracked.' : 'No new scaling requests.', + tone: 'neutral' as const + }; + if (view.decision.action === 'create') return { label: 'Adding capacity', reason: scalingReason(view.decision.reason), tone: 'info' as const }; + if (view.decision.action === 'destroy') return { label: 'Removing surplus', reason: scalingReason(view.decision.reason), tone: 'info' as const }; + if (view.decision.reason.endsWith('Empty spare target is satisfied.') && view.decision.deficit === 0) return { label: 'Spare targets satisfied', reason: '', tone: 'online' as const }; + if (view.decision.reason.endsWith('Waiting for a continuous empty interval.')) return { label: 'Waiting to remove surplus', reason: `Lobbies must stay empty for ${view.policy.emptyMinutes} minutes.`, tone: 'neutral' as const }; + return { label: 'Scaling on hold', reason: scalingReason(view.decision.reason), tone: 'warning' as const }; +} + +export function lobbyGroups(view: ScalingView) { + const maps = view.policy.mapSlug === '*' ? zombieMaps(view.policy.game) : zombieMaps(view.policy.game).filter(map => map.slug === view.policy.mapSlug); + const assigned = new Set(); + const groups = maps.map(map => { + const members = view.snapshot.members.filter(member => (member.mapSlug ?? parseServerName(member.name)?.mapSlug) === map.slug); + members.forEach(member => assigned.add(member)); + return { slug: map.slug, label: map.friendly, members, ready: members.filter(member => member.state === 'empty').length, target: view.policy.spares }; + }); + const unassigned = view.snapshot.members.filter(member => !assigned.has(member)); + if (unassigned.length) groups.push({ slug: 'unassigned', label: 'Other lobbies', members: unassigned, ready: unassigned.filter(member => member.state === 'empty').length, target: 0 }); + return groups; +} + +export interface ScalingActivity { + id: string; + at: number; + lastSeenAt: number; + operationId: string | null; + operationState: string | null; + action: ScalingHistory['decision']['action']; + server: string | null; + reason: string; + evaluations: number; +} + +export function scalingActivity(view: ScalingView): ScalingActivity[] { + const operationIds = new Set([...view.history.map(item => item.operationId), ...view.unsettled.map(item => item.id)].filter((id): id is string => !!id)); + const groups = new Map(); + for (const item of view.history) { + // Older wait decisions store the job reference in their reason rather than operationId. + const reference = item.decision.reason.match(/Provisioning ([\w-]+):/)?.[1]; + const operationId = item.operationId ?? (reference && operationIds.has(reference) ? reference : null); + const key = operationId ?? item.id; + const existing = groups.get(key); + if (existing) { + existing.at = Math.min(existing.at, item.at); + existing.lastSeenAt = Math.max(existing.lastSeenAt, item.lastSeenAt); + existing.evaluations++; + if (item.operationId) { + existing.action = item.decision.action; + existing.server = item.decision.server; + existing.operationState = item.operationState; + existing.reason = scalingReason(item.decision.reason); + } + } else groups.set(key, { + id: key, at: item.at, lastSeenAt: item.lastSeenAt, operationId, + operationState: item.operationState, action: item.decision.action, + server: item.decision.server, reason: scalingReason(item.decision.reason), evaluations: 1 + }); + } + return [...groups.values()].sort((a, b) => b.lastSeenAt - a.lastSeenAt); +} diff --git a/src/lib/ui/components/autoscaling/PolicyCard.svelte b/src/lib/ui/components/autoscaling/PolicyCard.svelte new file mode 100644 index 0000000..c35be4e --- /dev/null +++ b/src/lib/ui/components/autoscaling/PolicyCard.svelte @@ -0,0 +1,229 @@ + + + { if (event.key === 'Escape' && menuOpen) { menuOpen = false; menuElement?.querySelector('button')?.focus(); } }} /> + +
+
+
+

{title}

+ {view.policy.mode === 'off' ? 'Paused' : 'Enabled'} +
+ {#if canOperate} +
+ + +
+ + {#if menuOpen} +
+ + {#if view.unsettled.length}

Pause this policy and wait for outstanding jobs to finish before deleting it.

{/if} +
+ {/if} +
+
+ {/if} +
+ +
+ + + + +
+
+

{view.policy.mapSlug === '*' ? 'Per map: ' : ''}Min {view.policy.minimum} · Max {view.policy.maximum} · Spare target {view.policy.spares} · Remove after {view.policy.emptyMinutes} minutes empty

+

{view.snapshot.keyPool.available} / {view.snapshot.keyPool.total} game keys available · Shared across {view.policy.game.toUpperCase()}

+
+ + {#if !view.unsettled.length || globallyPaused || view.policy.mode === 'off' || view.policy.pausedReason} +
+ {status.label} + {#if status.reason}{status.reason}{/if} +
+ {/if} + {#each view.unsettled as operation (operation.id)} + {@const member = view.snapshot.members.find(member => member.operationId === operation.id)} + {@const needsAttention = ['failed', 'interrupted', 'manual', 'unknown', 'cancelled', 'warning'].includes(operation.state)} +
+ +
+
{needsAttention ? 'Review' : operation.action === 'destroy' ? 'Removing' : 'Creating'} {member ? lobbyLabel(member.name) : 'lobby'}{needsAttention ? ' job' : ''}
+

{operation.stage || 'Waiting for the next provisioning update.'}

+
+ +
+ {/each} + +
+ + {#if tab === 'lobbies' && view.policy.mapSlug === '*'}{covered} / {mapCount} maps meet spare target{/if} +
+
+ {#if tab === 'lobbies'} +
+ + + {#each groups as group (group.slug)} + + + {#each group.members as member (member.name)} + {@const pending = member.state === 'creating' || member.state === 'removing'} + + + + + + + {/each} + + {/each} +
Map / lobbyStatePlayersLatest observation
{group.label}{#if group.target}{group.ready} / {group.target} ready spares{/if}{#if !group.members.length}No lobbies yet{/if}
{lobbyLabel(member.name)}{member.name} + {#if member.blocker && !activeIds.has(member.operationId ?? '')}{scalingReason(member.blocker)}{/if} + {memberStatus(member.state)}{#if member.operationId}View job{/if}{member.players ?? '—'}{#if member.observedAt}{member.source ?? 'Source unavailable'}{:else}{pending ? 'Awaiting first observation' : 'No recent observation'}{/if}
+
+ {#each view.snapshot.blockers.filter(blocker => scalingReason(blocker) !== status.reason && blocker !== 'Autoscaling is globally paused.') as blocker}

{scalingReason(blocker)}

{/each} + {:else if tab === 'activity'} +

Recent decision changes · Provisioning updates are grouped by job.

+
    + {#each activity as item (item.id)} +
  1. +
    {item.action === 'create' ? 'Creation requested' : item.action === 'destroy' ? 'Removal requested' : item.operationId ? 'Provisioning in progress' : 'Scaling evaluated'}{item.server ? ` · ${lobbyLabel(item.server)}` : ''}{#if item.operationState}{/if}
    + {#if !item.operationId || !activeIds.has(item.operationId)}

    {item.reason}

    {/if} +
    {#if item.evaluations > 1}{item.evaluations} decision changes{/if}{#if item.operationId}View job{/if}
    +
  2. + {:else}
  3. No scaling activity yet.
  4. {/each} +
+ {:else} +

Last 31 days · {view.totals.observedMinutes.toFixed(1)} minutes observed

+
+ + + + + + +
+ {#if view.operations.readinessSamples} +

Request to verified readiness: {view.operations.readinessMeanSeconds?.toFixed(0)}s average · {view.operations.readinessMaxSeconds?.toFixed(0)}s maximum · {view.operations.readinessSamples} completed creates

+ {/if} +

Observation gaps are excluded. Readiness includes queue time and does not confirm server browser visibility or successful joins.

+ {/if} +
+
Checks every 15 seconds · Last evaluated: {view.lastEvaluatedAt ? dates(view.lastEvaluatedAt) : 'Not evaluated yet'}
+
+ + diff --git a/src/routes/autoscaling/+page.svelte b/src/routes/autoscaling/+page.svelte index 4619539..7907833 100644 --- a/src/routes/autoscaling/+page.svelte +++ b/src/routes/autoscaling/+page.svelte @@ -3,9 +3,9 @@ import { page } from '$app/state'; import { confirmAction } from '$ui/confirm.js'; import { pollVisible } from '$ui/poll.js'; - import { timeFormat } from '$ui/stores/time-zone.js'; import SectionHeader from '$ui/components/primitives/SectionHeader.svelte'; import Button from '$ui/components/primitives/Button.svelte'; + import PolicyCard from '$ui/components/autoscaling/PolicyCard.svelte'; import { zombieMaps, zombieMapBySlug } from '$lib/map-aliases.js'; import type { ScalingSettings, ScalingPolicy, ScalingReport } from '$lib/autoscaling.js'; let { data }: { data: import('./$types').PageData } = $props(); @@ -18,7 +18,11 @@ let editorOpen = $state(false); let busy = $state(false), actionError = $state(''), refreshError = $state(''), notice = $state(''); let generation = 0; - const modeLabel = (mode: string) => ({ off: 'Off', 'create-destroy': 'On' })[mode] ?? mode; + $effect(() => { + if (!notice) return; + const timer = setTimeout(() => notice = '', 6000); + return () => clearTimeout(timer); + }); async function refresh(signal?: AbortSignal) { const version = ++generation; try { @@ -43,7 +47,7 @@ busy = true; actionError = ''; notice = ''; ++generation; try { await post({ action: 'save', settings, id: editing?.id, revision: editing?.revision }); - notice = 'Policy saved. Evaluation runs every 15 seconds.'; editorOpen = false; await refresh(); + notice = 'Policy saved.'; editorOpen = false; await refresh(); } catch (error) { actionError = (error as Error).message; } finally { busy = false; } } @@ -60,9 +64,19 @@ } catch (error) { actionError = (error as Error).message; } finally { busy = false; } } + async function togglePolicy(policy: ScalingPolicy) { + busy = true; actionError = ''; notice = ''; ++generation; + try { + await post({ action: 'save', id: policy.id, revision: policy.revision, settings: { ...policy, mode: policy.mode === 'off' ? 'create-destroy' : 'off' } }); + notice = policy.mode === 'off' ? 'Policy resumed.' : 'Policy paused. Outstanding jobs are still tracked.'; + if (editing?.id === policy.id) { editing = null; editorOpen = false; } + await refresh(); + } catch (error) { actionError = (error as Error).message; } + finally { busy = false; } + } async function pauseAll() { busy = true; actionError = ''; ++generation; - try { await post({ action: 'global-pause', paused: !report.globalPaused }); notice = report.globalPaused ? 'Autoscaling resumed.' : 'Autoscaling paused. Review any unsettled operations below.'; await refresh(); } + try { await post({ action: 'global-pause', paused: !report.globalPaused }); notice = report.globalPaused ? 'Autoscaling resumed.' : 'Autoscaling paused. Outstanding jobs are still tracked.'; await refresh(); } catch (error) { actionError = (error as Error).message; } finally { busy = false; } } @@ -73,7 +87,7 @@ {#snippet actions()}{#if canOperate}{/if}{/snippet} - {#if report.globalPaused}{/if} + {#if report.globalPaused}{/if} {#if refreshError}

{refreshError}

{/if} {#if actionError}

{actionError}

{/if} {#if notice}

{notice}

{/if} @@ -100,36 +114,20 @@ {/if} {#if !report.policies.length}

No autoscaling policies. Add a policy to manage empty lobbies for a map or a whole game.

{/if} {#each report.policies as view (view.policy.id)} -
-

{view.policy.mapSlug === '*' ? 'Every map' : zombieMapBySlug(view.policy.game, view.policy.mapSlug)?.friendly ?? view.policy.mapSlug} · {view.policy.game.toUpperCase()}

{modeLabel(view.policy.mode)}{#if canOperate}{/if}
-

{view.decision.occupied} occupied · {view.decision.empty} empty and ready · {view.decision.pending} pending · {view.decision.unknown} unknown or stopped

- {#if view.unsettled.length}{/if} -

{view.policy.mapSlug === '*' ? 'Per map: ' : ''}Minimum {view.policy.minimum} · Maximum {view.policy.maximum} · Spare target {view.policy.spares} · {view.snapshot.keyPool.available} / {view.snapshot.keyPool.total} game keys available

-

Current decision: {view.decision.action}{view.decision.server ? ` ${view.decision.server}` : ''}. {view.decision.reason}

-

Last evaluated: {view.lastEvaluatedAt ? $timeFormat.dateTime(new Date(view.lastEvaluatedAt).toISOString()) : 'Not evaluated yet'}

-
Lobbies and evidence
    {#each view.snapshot.members as member}
  • {member.name} · {member.state}{member.blocker ? ` · ${member.blocker}` : ''}
    {member.source ?? 'No current source'} · {member.observedAt ? $timeFormat.dateTime(new Date(member.observedAt).toISOString()) : 'Unknown observation time'}{#if member.operationId} · Provisioning{/if}
  • {/each}
{#if !view.snapshot.members.length}

No registered or pending lobbies.

{/if}{#each view.snapshot.blockers as blocker}

{blocker}

{/each}
-
Recent decisions and measurements -

{view.totals.observedMinutes.toFixed(1)} observed minutes · {view.totals.emptyLobbyHours.toFixed(2)} empty lobby-hours · {view.totals.deficitMinutes.toFixed(1)} minutes below spare target · {view.totals.unknownMinutes.toFixed(1)} unknown minutes

-

Last 31 days: {view.operations.creates} create requests · {view.operations.removals} removal requests · {view.operations.cancelled} cancelled · {view.operations.failed} failed

- {#if view.operations.readinessSamples}

Request to verified readiness: mean {view.operations.readinessMeanSeconds?.toFixed(0)} seconds, maximum {view.operations.readinessMaxSeconds?.toFixed(0)} seconds across {view.operations.readinessSamples} completed creates. Includes queue time.

{/if} -

Up to 31 days of bounded observations; gaps are excluded. Readiness does not certify browser visibility or successful joins.

-
    {#each view.history as item}
  1. {$timeFormat.dateTime(new Date(item.at).toISOString())} · {item.decision.action} {item.decision.server ?? ''}
    {item.decision.reason}{#if item.operationId} · {item.operationState ?? 'View operation'}{/if}
  2. {/each}
-
-
+ {/each} diff --git a/tests/unit/ui-autoscaling.test.ts b/tests/unit/ui-autoscaling.test.ts new file mode 100644 index 0000000..5efe383 --- /dev/null +++ b/tests/unit/ui-autoscaling.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, it } from 'vitest'; +import { render } from 'svelte/server'; +import PolicyCard from '../../src/lib/ui/components/autoscaling/PolicyCard.svelte'; +import { lobbyGroups, lobbyLabel, policyStatus, scalingActivity } from '../../src/lib/ui/autoscaling.js'; +import type { ScalingHistory, ScalingView } from '../../src/lib/autoscaling.js'; + +const now = Date.parse('2026-10-07T20:00:00Z'); +function view(): ScalingView { + return { + policy: { id: 'policy', game: 't5', mapSlug: '*', mode: 'create-destroy', minimum: 1, maximum: 3, spares: 1, emptyMinutes: 15, revision: 1, pausedReason: null, updatedAt: '' }, + snapshot: { members: [ + { name: 't5_zombie_theater_1', mapSlug: 'theater', identity: 'one', state: 'empty', players: 0, observedAt: now, source: 'rcon', emptySince: now, blocker: null, operationId: null }, + { name: 't5_zombie_cosmodrome_1', mapSlug: 'cosmodrome', identity: 'two', state: 'creating', players: null, observedAt: null, source: null, emptySince: null, blocker: 'Provisioning job-1: Checking container readiness', operationId: 'job-1' } + ], reservedNames: [], keyPool: { available: 13, total: 15 }, blockers: [] }, + decision: { action: 'wait', reason: 'Provisioning job-1: Checking container readiness', server: null, occupied: 0, empty: 1, pending: 1, unknown: 0, deficit: 9, desired: 10 }, + unsettled: [{ id: 'job-1', action: 'create', state: 'awaiting-health', stage: 'Checking container readiness' }], + blockers: [], ownsMap: true, history: [], lastEvaluatedAt: now, + totals: { observedMinutes: 12, emptyLobbyHours: 0.2, deficitMinutes: 5, unknownMinutes: 0 }, + operations: { creates: 1, removals: 0, cancelled: 0, failed: 0, readinessSamples: 0, readinessMeanSeconds: null, readinessMaxSeconds: null } + }; +} +function body(data = view(), canOperate = true, globallyPaused = false) { + return render(PolicyCard, { props: { view: data, canOperate, globallyPaused, busy: false, onedit: () => {}, ontoggle: () => {}, ondelete: () => {} } }).body; +} +function history(id: string, at: number, action: ScalingHistory['decision']['action'], reason: string, operationId: string | null = null): ScalingHistory { + return { id, at, lastSeenAt: at, decision: { ...view().decision, action, reason, server: action === 'create' ? 't5_zombie_cosmodrome_1' : null }, operationId, operationState: operationId ? 'done' : null }; +} + +describe('autoscaling presentation', () => { + it('shows a provisioning stage once and keeps diagnostic job links without routine deletion warnings', () => { + const html = body(); + expect(html.split('Checking container readiness')).toHaveLength(2); + expect(html).toContain('Creating Ascension · Lobby 1'); + expect(html).toContain('href="/provisioning/job-1"'); + expect(html).not.toContain('Work is still settling'); + expect(html).not.toContain('Provisioning job-1:'); + expect(html).not.toContain('No current source'); + expect(html).not.toContain('before deleting'); + expect(html).not.toContain(' { + const data = view(); + data.snapshot.members.push({ ...data.snapshot.members[0], name: 'legacy-server', mapSlug: undefined }); + const groups = lobbyGroups(data); + expect(groups.find(group => group.slug === 'theater')).toMatchObject({ ready: 1, target: 1 }); + expect(groups.find(group => group.slug === 'moon')).toMatchObject({ ready: 0, target: 1, members: [] }); + expect(groups.find(group => group.slug === 'unassigned')?.members[0].name).toBe('legacy-server'); + expect(body()).toContain('1 / 10 maps meet spare target'); + }); + it('does not imply global readiness from one satisfied map', () => { + const data = view(); + data.decision.reason = 'Kino Der Toten: Empty spare target is satisfied.'; + expect(policyStatus(data, false).label).not.toBe('Spare targets satisfied'); + data.decision.deficit = 0; + expect(policyStatus(data, false).label).toBe('Spare targets satisfied'); + }); + it('preserves attention and pause notices while a job is outstanding', () => { + const data = view(); + data.policy.pausedReason = 'Uncertain host command. Review Provisioning recovery.'; + data.unsettled[0].state = 'failed'; + const html = body(data); + expect(html).toContain(data.policy.pausedReason); + expect(html).toContain('Review Ascension · Lobby 1 job'); + expect(body(view(), true, true)).toContain('Globally paused'); + data.policy.pausedReason = null; + data.policy.mode = 'off'; + expect(body(data)).toContain('No new scaling requests. Outstanding jobs are still tracked.'); + }); + it('groups job wait decisions with their original request without swallowing unrelated blockers', () => { + const data = view(); + data.history = [ + history('wait-2', now + 30, 'wait', 'Provisioning job-1: Checking readiness'), + history('blocked', now + 20, 'wait', 'No available game keys.'), + history('wait-1', now + 10, 'wait', 'Provisioning job-1: Host command running'), + history('create', now, 'create', 'Replenish empty lobby capacity.', 'job-1') + ]; + const activity = scalingActivity(data); + expect(activity).toHaveLength(2); + expect(activity[0]).toMatchObject({ operationId: 'job-1', action: 'create', at: now, lastSeenAt: now + 30, evaluations: 3, operationState: 'done' }); + expect(activity[1].reason).toBe('No available game keys.'); + }); + it('renders accessible tabs and hides mutation controls from viewers', () => { + const html = body(view(), false); + expect(html).toContain('role="tablist"'); + expect(html).toContain('aria-labelledby="tab-policy-lobbies"'); + expect(html).toContain('role="tabpanel"'); + expect(html).not.toContain('Edit policy'); + expect(html).not.toContain('More actions'); + expect(lobbyLabel('legacy-server')).toBe('legacy-server'); + }); +});