From a991198d69f502eaee76b92860048b0f41c7d2de Mon Sep 17 00:00:00 2001 From: Josh Wright Date: Tue, 6 Oct 2026 17:09:36 -0400 Subject: [PATCH] Simplify library lists and game file readiness --- src/lib/ui/components/ConfigInspector.svelte | 2 +- src/lib/ui/components/ConfigLibrary.svelte | 64 +++++--------- src/lib/ui/components/GameFileHosts.svelte | 51 +++++++++++ src/lib/ui/components/LibraryVersions.svelte | 23 +++++ src/lib/ui/components/PluginUpdates.svelte | 3 +- src/lib/ui/library.css | 19 ++++ src/routes/game-files/+page.svelte | 48 +++------- src/routes/plugins/+page.svelte | 87 +++++++----------- src/routes/scripts/+page.svelte | 92 +++++++------------- tests/unit/ui-library.test.ts | 63 ++++++++++++++ 10 files changed, 253 insertions(+), 199 deletions(-) create mode 100644 src/lib/ui/components/GameFileHosts.svelte create mode 100644 src/lib/ui/components/LibraryVersions.svelte create mode 100644 src/lib/ui/library.css create mode 100644 tests/unit/ui-library.test.ts diff --git a/src/lib/ui/components/ConfigInspector.svelte b/src/lib/ui/components/ConfigInspector.svelte index ff7c85a..a7d3826 100644 --- a/src/lib/ui/components/ConfigInspector.svelte +++ b/src/lib/ui/components/ConfigInspector.svelte @@ -74,7 +74,7 @@
-
{artifactId ? 'Draft preview' : 'Follow every value to its source'}

{artifactId ? 'On this server' : 'Effective configuration'}

+
{artifactId ? 'Draft preview' : 'Follow every value to its source'}

{artifactId ? 'On this server' : 'Promoted configuration'}

{artifactId ? 'Your draft composed with the other promoted configs. Saving creates an untested version.' : 'The intended file from promoted configs and the selected map. Live files and active betas may differ.'}

diff --git a/src/lib/ui/components/ConfigLibrary.svelte b/src/lib/ui/components/ConfigLibrary.svelte index b5de921..9e114d9 100644 --- a/src/lib/ui/components/ConfigLibrary.svelte +++ b/src/lib/ui/components/ConfigLibrary.svelte @@ -8,7 +8,8 @@ import { afterNavigate, goto } from '$app/navigation'; import SectionHeader from './primitives/SectionHeader.svelte'; import Button from './primitives/Button.svelte'; - import Badge from './primitives/Badge.svelte'; + import LibraryVersions from './LibraryVersions.svelte'; + import '$ui/library.css'; import EmptyState from './primitives/EmptyState.svelte'; import ConfigTextEditor from './ConfigTextEditor.svelte'; import ConfigInspector from './ConfigInspector.svelte'; @@ -46,8 +47,6 @@ let saving = $state(false); let createError = $state(''); const baselines = $derived(artifacts.filter(a => a.kind === 'baseline')); - const overlays = $derived(artifacts.filter(a => a.kind === 'config')); - const pending = $derived(artifacts.filter(a => a.latestVersion && a.latestVersion.id !== a.current_version_id).length); const gameOptions = $derived([...new Set([...scopeOptions.games, ...artifacts.filter(a => a.scope_type === 'game').map(a => a.scope_value!).filter(Boolean)])].sort()); const baselineExists = $derived(kind === 'baseline' && baselines.some(a => a.scope_type === 'game' && a.scope_value === scopeGame)); const selectedScope = $derived(kind === 'baseline' || scopeType === 'game' ? scopeGame : scopeMap); @@ -106,18 +105,17 @@
- - {#snippet actions()}{/snippet} + + {#snippet actions()}{/snippet} - {#if artifacts.length}
{baselines.length} baselines{overlays.length} overlays{pending} awaiting promotion
{/if}
{#if error}{/if} {#if view === 'inspect'} {:else} - {#if loading || artifacts.length || error}
- + {#if loading || artifacts.length || error}
+ @@ -129,30 +127,27 @@ {:else if !filtered.length && !error} {:else} {#if filteredBaselines.length} -

Baselines {filteredBaselines.length}

One foundation per game

- -
+

Baselines {filteredBaselines.length}

{@render configTable(filteredBaselines)}
{/if} {#if filteredOverlays.length} -

Overlays {filteredOverlays.length}

Small changes with a clear scope

- -
+

Overlays {filteredOverlays.length}

{@render configTable(filteredOverlays.slice(0,limit))}
{/if} {/if} - {#if artifacts.length}

Choose a config to edit and preview a draft, or inspect a server to trace its promoted settings.

{/if} {/if}
-{#snippet versionBadges(artifact: Artifact)} - {#if artifact.currentVersion}v{artifact.currentVersion.version} promoted{:else}Never promoted{/if} - {#if artifact.latestVersion && artifact.latestVersion.id !== artifact.current_version_id}v{artifact.latestVersion.version} {artifact.latestVersion.state.replace('-', ' ')}{/if} - {#if artifact.runningBetaId}β{artifact.runningBetaId} active{/if} +{#snippet configTable(rows: Artifact[])} +
+ + {#each rows as artifact (artifact.id)} + + + + + {/each} +
ConfigScopeVersion / statusEligible servers
{artifact.name}{artifact.scope_type === 'game' ? `All ${artifact.scope_value?.toUpperCase()} servers` : artifact.scope_type === 'map' ? mapLabel(artifact.scope_value ?? '') : 'Unscoped'}{artifact.servers.length}
{/snippet} { if (saving) event.preventDefault(); }}> @@ -175,32 +170,16 @@ diff --git a/src/lib/ui/components/LibraryVersions.svelte b/src/lib/ui/components/LibraryVersions.svelte new file mode 100644 index 0000000..2a67378 --- /dev/null +++ b/src/lib/ui/components/LibraryVersions.svelte @@ -0,0 +1,23 @@ + + +
+ {#if newer}
{label(newer)}{state}
{/if} + {#if current}
{label(current)}{#if newer}Promoted{:else}Promoted{/if}
+ {:else}{latest ? 'Never promoted' : 'No versions'}{/if} + {#if betaId !== null && !newer}Beta active{/if} +
+ + diff --git a/src/lib/ui/components/PluginUpdates.svelte b/src/lib/ui/components/PluginUpdates.svelte index 95e8142..712a1bc 100644 --- a/src/lib/ui/components/PluginUpdates.svelte +++ b/src/lib/ui/components/PluginUpdates.svelte @@ -3,6 +3,7 @@ import type { PluginUpdate } from '$lib/plugin-github.js'; import Card from './primitives/Card.svelte'; import Button from './primitives/Button.svelte'; + let { onchecked }: { onchecked?: (updates: PluginUpdate[]) => void } = $props(); let updates = $state([]); let checking = $state(false); let checked = $state(false); @@ -15,7 +16,7 @@ const response = await fetch('/api/plugins/updates'); const body = await response.json(); if (!response.ok) throw new Error(body.error ?? 'Could not check updates.'); - updates = body.updates; checked = true; + updates = body.updates; checked = true; onchecked?.(updates); } catch (e) { error = (e as Error).message; } finally { checking = false; } } diff --git a/src/lib/ui/library.css b/src/lib/ui/library.css new file mode 100644 index 0000000..d5bfeb1 --- /dev/null +++ b/src/lib/ui/library.css @@ -0,0 +1,19 @@ +.library-toolbar { display:flex; align-items:center; flex-wrap:wrap; gap:10px; } +.library-toolbar .search { flex:1 1 260px; min-width:0; } +.library-toolbar input, .library-toolbar select { box-sizing:border-box; width:100%; min-height:40px; padding:9px 12px; border:1px solid var(--border-control); border-radius:var(--radius-sm); background:var(--surface-sunken); color:var(--text-primary); font:var(--text-sm) var(--font-body); } +.library-toolbar>select { width:auto; } +.library-table { overflow-x:auto; border:1px solid var(--border-subtle); border-radius:var(--radius-md); } +.library-table table { width:100%; min-width:680px; border-collapse:collapse; text-align:left; font-size:var(--text-sm); } +.library-table th { padding:10px 16px; color:var(--text-secondary); font-size:var(--text-xs); font-weight:500; background:var(--surface-sunken); } +.library-table td { padding:12px 16px; border-top:1px solid var(--border-subtle); background:var(--surface); vertical-align:middle; } +.library-table tr:hover td { background:var(--surface-raised); } +.library-table .name { color:var(--text-primary); font-weight:600; text-decoration:none; overflow-wrap:anywhere; } +.library-table a { color:var(--accent-primary-hover); } +.library-table .name:hover { color:var(--accent-primary-hover); text-decoration:underline; } +.library-table .secondary { display:block; color:var(--text-secondary); font-size:var(--text-xs); margin-top:5px; overflow-wrap:anywhere; } +.library-table .numeric { font-variant-numeric:tabular-nums; } +.library-table .select-cell { width:20px; padding-right:0; } +.library-table tr.selected td { background:color-mix(in srgb, var(--accent-primary) 8%, var(--surface)); } +.library-table input[type='checkbox'] { width:16px; height:16px; accent-color:var(--accent-primary); } +.library-table a:focus-visible, .library-table input:focus-visible, .library-toolbar :focus-visible { outline:2px solid var(--accent-primary); outline-offset:3px; } +@media(max-width:640px) { .library-toolbar>select, .library-toolbar>label { flex:1 1 140px; } .library-toolbar .search { flex-basis:100%; } } diff --git a/src/routes/game-files/+page.svelte b/src/routes/game-files/+page.svelte index 6f34059..450bc7d 100644 --- a/src/routes/game-files/+page.svelte +++ b/src/routes/game-files/+page.svelte @@ -11,7 +11,7 @@ import SectionHeader from '$ui/components/primitives/SectionHeader.svelte'; import Button from '$ui/components/primitives/Button.svelte'; import Badge from '$ui/components/primitives/Badge.svelte'; - import GameFileTransferProgress from '$ui/components/GameFileTransferProgress.svelte'; + import GameFileHosts from '$ui/components/GameFileHosts.svelte'; type OverviewLibrary = Omit & { versions: Omit[] }; interface UploadFile { path: string; size: number; lastModified: number; receivedBytes: number; complete: boolean } @@ -39,14 +39,6 @@ const hostName = (id: string | null) => id ? hosts.find(host => host.id === id)?.name ?? id : 'Browser upload'; const gameName = (game: 't5' | 't6') => game === 't5' ? 'Black Ops I (T5)' : 'Black Ops II (T6)'; const uploadDate = (item: OverviewLibrary['versions'][number]) => new Date(item.publishedAt ?? item.createdAt).toLocaleString(); - function automaticDownloadDetail(host: Host | undefined): string { - if (host?.decommission) return 'Automatic setup is paused during host deletion.'; - if (!host?.enrolled) return 'Waiting for host enrollment.'; - if (host.mode !== 'active') return 'Automatic setup resumes when the host is active.'; - if (!host.credentialActive || !host.connected) return 'Automatic setup resumes when the host reconnects.'; - if (host.readiness === 'degraded') return host.readinessReasons[0] ?? 'The host needs attention before automatic setup can continue.'; - return 'Download starts automatically after runtime preparation and earlier work finish.'; - } async function responseBody(response: Response) { let body; @@ -210,7 +202,7 @@ Game Files | Electric Cherry
- + {#if $page.url.searchParams.has('returnTo')}{/if} {#if error}{/if} {#if notice}

{notice}

{/if} @@ -218,7 +210,7 @@ {#if canManage && showUpload}

{phase === 'published' ? `${game.toUpperCase()} files uploaded` : uploadId ? `Continue ${game.toUpperCase()} upload` : `Upload ${game.toUpperCase()} game folder`}

-

Select your complete {gameName(game)} game folder, including its installed DLC. Once verified, this upload becomes the latest files and hosts download it automatically.

+

Select your complete {gameName(game)} game folder, including its installed DLC. Once verified, this upload becomes the latest files and hosts download it automatically. Running servers keep using their existing files.

{#if uploadError}{/if}
{#if phase !== 'verifying' && phase !== 'published'} @@ -248,14 +240,12 @@ {@const history = library.versions.filter(item => item.state === 'ready' && item.id !== library.latestVersionId)}
-

{gameName(library.game)}

{latest ? 'Latest verified game files' : 'Game folder not uploaded yet'}

+

{gameName(library.game)}

{#if latest}

{formatGameBytes(latest.sizeBytes)} · {latest.fileCount.toLocaleString()} files

{/if}
{#if canManage}{/if}
{#if latest} -

{formatGameBytes(latest.sizeBytes)} · {latest.fileCount.toLocaleString()} files · Uploaded {uploadDate(latest)}

Available
-

Active hosts keep these files ready automatically, including hosts added later. Running servers keep using their existing files.

- {@render hostCopies(latest, true)} - {@render uploadDetails(latest)} +
Latest verified upload cache.status === 'failed') ? 'danger' : latest.caches.length && latest.caches.every(cache => cache.status === 'ready') ? 'online' : 'neutral'}>{latest.caches.length ? `${latest.caches.filter(cache => cache.status === 'ready').length} / ${latest.caches.length} host copies ready` : 'No hosts'}
+ {:else}

Upload the complete {library.game.toUpperCase()} game folder once. Hosts will download and verify it automatically as part of setup.

{/if} @@ -275,6 +265,8 @@ {/each}
{/if} + {#if latest || history.length}
Details
+ {#if latest}

Uploaded {uploadDate(latest)}

{@render uploadDetails(latest)}{@render downloadControls(latest)}{/if} {#if history.length}
Previous uploads ({history.length}) @@ -285,11 +277,12 @@ {#if item.filesRemoved}

Files removed from controller · upload history retained

{:else if item.filesRemoving}

Removing unused controller files · upload history retained

{/if}

{formatGameBytes(item.sizeBytes)} · {item.fileCount.toLocaleString()} files

{@render uploadDetails(item)} -
Host copies{@render hostCopies(item, false)}
+
Host copies{@render downloadControls(item)}
{/each}
{/if} +
{/if}
{/each} @@ -298,25 +291,13 @@
Upload details
Upload ID
{item.id}
Manifest SHA256
{item.manifestSha256 ?? 'Available after verification'}
Uploaded from
{!item.sourceHostId ? 'Game folder in browser' : `${hostName(item.sourceHostId)} / ${item.sourceVolume}`}
View complete file manifest
{/snippet} -{#snippet hostCopies(item: OverviewLibrary['versions'][number], latest: boolean)} - {#if !hosts.length}

Files are ready. Add a host to prepare them automatically.

{/if} -
- {#each item.caches as cache} - {@const cacheHost = hosts.find(host => host.id === cache.hostId)} - {@const awaitingAutomaticDownload = latest && cache.status === 'missing'} - - - - - - - {:else}{/each} -
HostFile statusProgressDetail
{hostName(cache.hostId)}{awaitingAutomaticDownload ? 'Waiting' : cache.status}{!cacheHost?.connected ? ' · offline' : ''}{#if cache.status !== 'missing'}{:else}—{/if}{cache.error ?? (awaitingAutomaticDownload ? automaticDownloadDetail(cacheHost) : '')}{#if awaitingAutomaticDownload || cache.status === 'failed'} View host setup{/if}
No host copies yet.
+{#snippet downloadControls(item: OverviewLibrary['versions'][number])} {#if canManage && !item.filesRemoved && !item.filesRemoving}
Advanced: download or verify these files

Retry a download or prepare a previous upload on a host. The latest files are downloaded automatically.

{/if} {/snippet} diff --git a/src/routes/plugins/+page.svelte b/src/routes/plugins/+page.svelte index af683a7..1263d46 100644 --- a/src/routes/plugins/+page.svelte +++ b/src/routes/plugins/+page.svelte @@ -5,14 +5,14 @@ import { afterNavigate } from '$app/navigation'; import Dialog from '$ui/components/primitives/Dialog.svelte'; import Tabs from '$ui/components/primitives/Tabs.svelte'; - import StatusBadge from '$ui/components/primitives/StatusBadge.svelte'; + import LibraryVersions from '$ui/components/LibraryVersions.svelte'; + import type { PluginUpdate } from '$lib/plugin-github.js'; + import '$ui/library.css'; import ListFooter from '$ui/components/primitives/ListFooter.svelte'; import { mapLabel } from '$lib/script-library.js'; import PluginGithubImport from '$ui/components/PluginGithubImport.svelte'; import PluginUpdates from '$ui/components/PluginUpdates.svelte'; import { artifactVersionLabel } from '$lib/artifact-version.js'; - import Card from '$ui/components/primitives/Card.svelte'; - import Badge from '$ui/components/primitives/Badge.svelte'; import Button from '$ui/components/primitives/Button.svelte'; import SectionHeader from '$ui/components/primitives/SectionHeader.svelte'; import EmptyState from '$ui/components/primitives/EmptyState.svelte'; @@ -47,6 +47,7 @@ let importComplete = $state<{ pluginId: number; betaUrl: string; alreadyImported: boolean } | null>(null); let method = $state('upload'); let updatesOpen = $state(false); + let updates = $state([]); let search = $state(''); let game = $state(''); let status = $state(''); @@ -170,7 +171,7 @@
- {#snippet actions()}{/snippet} + {#snippet actions()}{#if plugins.length}{/if}{/snippet} {#if importComplete}{/if} {#if successBanner} @@ -181,48 +182,37 @@ {/if} {#if flash && !addOpen}{/if} - {#if loading || plugins.length || flash}
- + {#if loading || plugins.length || flash}
+ - +
{/if} {#if game}

Game results include unscoped plugins that can use explicitly selected beta servers.

{/if} - {#if plugins.length}
updatesOpen = event.currentTarget.open}>Check plugin updates{#if updatesOpen}{#key importCount}{/key}{/if}
{/if} + {#if updatesOpen}
{ if (!event.currentTarget.open) updatesOpen = false; }}>Update check details{#key importCount} updates = results} />{/key}
{/if}
-
-

All plugins

- {plugins.length} -
+
{visible.length} of {plugins.length} plugins{#if search || game || status}{/if}
{#if loading}
Loading…
{:else if visible.length === 0 && !flash} {:else} - +
+ + {#each visible.slice(0,limit) as a (a.id)} + {@const update = updates.find(update => update.pluginId === a.id && update.status === 'available')} + + + + + + + + {/each} +
PluginGame / scopeVersion / statusEligible serversNext action
{a.name}{#if a.target_rel_path !== `${a.name}.dll`}{a.target_rel_path}{/if} + {#if update}{update.release?.tag} available{/if}{scopeLabel(a)}{a.scope_type ? a.servers.length : 'Explicit targets'}{#if update}Review update + {:else if a.runningBetaId !== null}View beta + {:else if a.latestVersion && a.latestVersion.id !== a.current_version_id}Review version{/if}
{/if}
@@ -231,7 +221,7 @@
- {#if method === 'github'} { importComplete = result; importCount++; addOpen = false; await load(); }} /> + {#if method === 'github'} { importComplete = result; updates = updates.filter(update => update.pluginId !== result.pluginId); importCount++; addOpen = false; await load(); }} /> {:else} @@ -296,25 +286,8 @@ .flash { color: var(--state-danger); font-family: var(--font-mono); font-size: var(--text-xs); } .section { display: flex; flex-direction: column; gap: var(--space-3); } - .section-head { display: flex; align-items: center; gap: var(--space-3); } - .section-head h2 { margin: 0; font-family: var(--font-body); font-size: var(--text-md); font-weight: var(--weight-semibold); color: var(--text-primary); } - - .rows { display: flex; flex-direction: column; gap: var(--space-2); } - .row { - display: flex; flex-direction: column; gap: var(--space-2); - padding: var(--space-4) var(--space-5); - background: color-mix(in oklab, var(--surface) 88%, transparent); - border: 1px solid var(--border-subtle); - border-radius: var(--radius-md); - color: var(--text-primary); text-decoration: none; - transition: background-color var(--dur-fast) var(--ease-standard), border-color var(--dur-fast) var(--ease-standard); - } - .row:hover { background: var(--surface-raised); border-color: var(--border-strong); } - .row:focus-visible { outline: none; box-shadow: var(--focus-ring); } - .row-top { display: flex; gap: var(--space-2); align-items: center; flex-wrap: wrap; } - .name { font-size: var(--text-md); font-weight: var(--weight-semibold); color: var(--text-primary); letter-spacing: var(--tracking-tight); } - .row-meta { display: flex; gap: var(--space-5); color: var(--text-tertiary); font-family: var(--font-mono); font-size: var(--text-xs); flex-wrap: wrap; } - .path { color: var(--text-secondary); } - + .list-heading { display:flex; gap:16px; align-items:center; color:var(--text-secondary); font-size:var(--text-xs); } + summary { cursor:pointer; color:var(--text-secondary); font-size:var(--text-sm); margin-bottom:12px; } + .sr-only { position:absolute; width:1px; height:1px; overflow:hidden; clip-path:inset(50%); white-space:nowrap; } @media (max-width: 720px) { .upload-form { grid-template-columns: 1fr; } } diff --git a/src/routes/scripts/+page.svelte b/src/routes/scripts/+page.svelte index 947b6ce..14b267d 100644 --- a/src/routes/scripts/+page.svelte +++ b/src/routes/scripts/+page.svelte @@ -4,13 +4,13 @@ import { onMount } from 'svelte'; import { afterNavigate } from '$app/navigation'; import ListFooter from '$ui/components/primitives/ListFooter.svelte'; - import StatusBadge from '$ui/components/primitives/StatusBadge.svelte'; - import Badge from '$ui/components/primitives/Badge.svelte'; + import LibraryVersions from '$ui/components/LibraryVersions.svelte'; + import '$ui/library.css'; import Button from '$ui/components/primitives/Button.svelte'; import SectionHeader from '$ui/components/primitives/SectionHeader.svelte'; import EmptyState from '$ui/components/primitives/EmptyState.svelte'; import ScriptUploadDrawer from '$ui/components/ScriptUploadDrawer.svelte'; - import { betaSelectionHref, canTest, mapLabel, scopeLabel, scriptStatus, statusLabels, type LibraryScript, type ScriptStatus } from '$lib/script-library.js'; + import { betaSelectionHref, canTest, mapLabel, scopeLabel, scriptStatus, type LibraryScript, type ScriptStatus } from '$lib/script-library.js'; import { zombieMaps } from '$lib/map-aliases.js'; import { libraryLimit, saveLibraryContext } from '$ui/library-context.js'; @@ -36,6 +36,7 @@ { value: 'testing', label: 'Beta active' }, { value: 'failed', label: 'Test failed' }, { value: 'tested', label: 'Test passed' }, { value: 'promoted', label: 'Promoted' } ]; + const failedCount = $derived(scripts.filter(script => scriptStatus(script) === 'failed').length); const priority = { failed: 0, untested: 1, testing: 2, tested: 3, promoted: 4, empty: 5 }; const maps = $derived([...new Set(scripts.filter(s => s.scope_type === 'map' && (!game || zombieMaps(game).some(m => m.canonical === s.scope_value))).map(s => s.scope_value!))].sort()); const visible = $derived(scripts.filter(s => { @@ -67,7 +68,7 @@ onMount(() => { const params = new URLSearchParams(window.location.search); search = params.get('q') ?? ''; game = params.get('game') ?? ''; map = params.get('map') ?? ''; - status = tabs.some(t => t.value === params.get('status')) ? params.get('status') as ScriptStatus : 'all'; + status = [...tabs, { value: 'empty' }].some(t => t.value === params.get('status')) ? params.get('status') as ScriptStatus : 'all'; sort = ['attention', 'recent', 'name'].includes(params.get('sort') ?? '') ? params.get('sort')! : 'attention'; limit = libraryLimit(params); selected = (params.get('selected') ?? '').split(',').map(Number).filter(id => Number.isSafeInteger(id) && id > 0).slice(0, 500); void load(); @@ -97,23 +98,17 @@ {#snippet actions()}{/snippet} - {#if loading || scripts.length || error}
- {#each tabs as tab} - - {/each} -
{/if}
- {#if loading || scripts.length || error}
+ {#if loading || scripts.length || error}
+
- {visible.length} of {scripts.length} scripts + {#if failedCount && status !== 'failed'}{/if} {#if search || status !== 'all' || game || map}{/if}
{/if} {#if game}

Game results include unscoped scripts that can use explicitly selected beta servers.

{/if} @@ -125,39 +120,31 @@ {:else if !visible.length && !error} {:else} -
- {#each visible.slice(0,limit) as s (s.id)} - {@const state = scriptStatus(s)} -
- toggle(s.id)} /> -
- -
{s.target_rel_path}
-
{scopeLabel(s)}{s.scope_type ? `${s.servers.length} scope matches` : 'Explicit beta targets'}{s.versionCount} version{s.versionCount === 1 ? '' : 's'}
-
-
- {s.currentVersion ? `Promoted v${s.currentVersion.version}` : 'Never promoted'} - {#if s.latestVersion && s.latestVersion.id !== s.current_version_id}Latest v{s.latestVersion.version} · {s.latestVersion.state === 'untested' ? 'awaiting test' : s.latestVersion.state.replaceAll('-', ' ')}{/if} -
-
- {#if s.runningBetaId !== null} - {:else if state === 'failed'} - {:else if canTest(s)} - {:else}{/if} -
-
- {/each} -
+
+ + {#each visible.slice(0,limit) as s (s.id)} + + + + + + + + + {/each} +
ScriptGame / scopeVersion / statusEligible serversNext action
toggle(s.id)} />{s.name}{#if s.target_rel_path !== `${s.name}.gsc`}{s.target_rel_path}{/if}{scopeLabel(s)}{s.scope_type ? s.servers.length : 'Explicit targets'}{#if s.runningBetaId !== null}View beta + {:else if scriptStatus(s) === 'failed'}Review failure + {:else if canTest(s)}Create beta{/if}
{/if} -

Drop files anywhere to add scripts or recognize new versions. Scope matches describe eligible servers, not installed versions.

+
{#if selectedScripts.length}
- {selectedScripts.length} selected{hiddenSelected ? ` · ${hiddenSelected} hidden` : ''}Latest versions will be carried into beta setup. + {selectedScripts.length} selected{hiddenSelected ? ` · ${hiddenSelected} hidden` : ''}Latest versions selected - + {#if reviewSelection}
    {#each selectedScripts as script}
  • {script.name} · v{script.latestVersion?.version}
  • {/each}
{/if}
{/if} @@ -167,23 +154,10 @@ diff --git a/tests/unit/ui-library.test.ts b/tests/unit/ui-library.test.ts new file mode 100644 index 0000000..a34fa82 --- /dev/null +++ b/tests/unit/ui-library.test.ts @@ -0,0 +1,63 @@ +import { expect, it } from 'vitest'; +import { render } from 'svelte/server'; +import LibraryVersions from '../../src/lib/ui/components/LibraryVersions.svelte'; +import GameFileHosts from '../../src/lib/ui/components/GameFileHosts.svelte'; +import type { HostLibraryCache } from '../../src/lib/game-files.js'; +import type { Host } from '../../src/lib/hosts.js'; + +it('shows the promoted version once and keeps a newer failed version distinct', () => { + const current = { id: 1, version: 1 }; + const promoted = render(LibraryVersions, { props: { current, latest: current } }).body; + expect(promoted.match(/v1/g)).toHaveLength(1); + expect(promoted.match(/Promoted/g)).toHaveLength(1); + const newer = render(LibraryVersions, { props: { current, latest: { id: 2, version: 2, state: 'test-failed' } } }).body; + expect(newer).toContain('Test failed'); + expect(newer).toContain('v2'); + expect(newer).toContain('v1'); + expect(newer).toContain('Promoted'); +}); + +it('disambiguates two builds with the same release tag only when needed', () => { + const current = { id: 1, version: 1, release_version: 'v1.1.0' }; + expect(render(LibraryVersions, { props: { current, latest: current } }).body).not.toContain('upload'); + const body = render(LibraryVersions, { props: { current, latest: { ...current, id: 2, version: 2, state: 'untested' } } }).body; + expect(body).toContain('v1.1.0 (upload 1)'); + expect(body).toContain('v1.1.0 (upload 2)'); + expect(body).toContain('Needs testing'); +}); + +it('does not present an active beta as passed or an empty artifact as promoted', () => { + expect(render(LibraryVersions, { props: { current: null, latest: null } }).body).toContain('No versions'); + const body = render(LibraryVersions, { props: { current: null, latest: { id: 1, version: 1, state: 'tested' }, betaId: 12 } }).body; + expect(body).toContain('Beta active'); + expect(body).not.toContain('Test passed'); + expect(body).toContain('Never promoted'); +}); + +const host = (id: string, connected = true) => ({ id, name: id, connected, enrolled: true, credentialActive: true, mode: 'active', readiness: 'ready' } as Host); +const cache = (id: string, status: HostLibraryCache['status'], error: string | null = null): HostLibraryCache => ({ + hostId: id, libraryVersionId: 'upload', status, error, bytesDownloaded: 100, bytesTotal: 100, + manifestSha256: null, updatedAt: '2026-10-06T12:00:00Z', operationId: null +}); + +it('surfaces failures, transfers and offline hosts before collapsed healthy copies', () => { + const body = render(GameFileHosts, { props: { latest: true, + hosts: [host('healthy'), host('failed'), host('transfer'), host('offline', false)], + caches: [cache('healthy', 'ready'), cache('failed', 'failed', 'Checksum mismatch'), cache('transfer', 'verifying'), cache('offline', 'ready')] + } }).body; + const ready = body.indexOf('Ready hosts (1)'); + expect(body.indexOf('Checksum mismatch')).toBeLessThan(ready); + expect(body.indexOf('Host verification progress')).toBeLessThan(ready); + expect(body.indexOf('Offline')).toBeLessThan(ready); + expect(body.indexOf('href="/hosts/healthy"')).toBeGreaterThan(ready); + expect(body).toMatch(/]*>]*>Ready hosts \(1\)/); + expect(body).not.toMatch(/]*open/); + expect(body).toContain('View host setup'); +}); + +it('explains pending automatic downloads and keeps copy errors visible even for ready status', () => { + const body = render(GameFileHosts, { props: { latest: true, hosts: [host('waiting', false), host('inconsistent')], caches: [cache('waiting', 'missing'), cache('inconsistent', 'ready', 'Host report needs review')] } }).body; + expect(body).toContain('Automatic setup resumes when the host reconnects.'); + expect(body).toContain('Host report needs review'); + expect(body).not.toContain('Ready hosts'); +});