diff --git a/src/lib/beta-plan-summary.ts b/src/lib/beta-plan-summary.ts new file mode 100644 index 0000000..98bcdfb --- /dev/null +++ b/src/lib/beta-plan-summary.ts @@ -0,0 +1,37 @@ +import type { BetaPlan, BetaRequirement } from './beta-plan.js'; + +const count = (n: number, word: string) => `${n} ${word}${n === 1 ? '' : 's'}`; +const duration = (minutes: number) => minutes >= 60 && minutes % 60 === 0 ? count(minutes / 60, 'hour') : count(minutes, 'minute'); +export const presetDescriptions: Record = { + 'Quick smoke test': 'A quick check that the server starts and stays healthy.', + 'Gameplay validation': 'Prove the build through real matches and populated playtime.', + 'Long soak': 'Exercise stability and participation over a longer test.' +}; +export function requirementSummary(r: BetaRequirement): string { + const players = r.minPlayers > 1 ? ` with at least ${r.minPlayers} players` : ' with players present'; + switch (r.kind) { + case 'round': return `Reach round ${r.target}${players}`; + case 'matches': return `Complete ${count(r.target, 'match').replace('matchs', 'matches')}${r.minRound ? ` reaching round ${r.minRound}` : ''}${r.minPlayers > 1 ? players : ''}${r.minMinutes ? ` lasting at least ${duration(r.minMinutes)} each` : ''}`; + case 'populated-minutes': return `Accumulate ${duration(r.target)}${players}`; + case 'player-hours': return `Accumulate ${r.target} player-hours${r.minPlayers > 1 ? players : ''}`; + case 'population': return `Reach ${count(r.target, 'player')} online`; + case 'sustained-population': return `Keep at least ${count(r.minPlayers, 'player')} online for ${duration(r.target)}`; + case 'healthy-minutes': return `Stay continuously healthy for ${duration(r.target)}`; + case 'failure-budget': return r.target === 0 ? 'Allow no health outages' : `Allow at most ${count(r.target, 'health outage')}`; + case 'restart-budget': return r.target === 0 ? 'Allow no unexpected restarts' : `Allow at most ${count(r.target, 'unexpected restart')}`; + case 'clean-logs': return r.target === 0 ? 'Allow no detected script errors' : `Allow at most ${count(r.target, 'detected script error')}`; + case 'recovery': return `Repeat the checks after ${count(r.target, 'recovery restart')}`; + case 'feature': return `Observe ${r.event || 'a named feature event'} ${count(r.target, 'time')}${players}${r.minRound ? ` at round ${r.minRound} or later` : ''}`; + case 'manual': return r.label; + } +} +export function coverageSummary(plan: BetaPlan): string { + return plan.scope === 'all' ? 'Every selected server' : plan.scope === 'quorum' ? `At least ${count(plan.quorum, 'selected server')}` + : plan.scope === 'map' ? 'One server per selected map' : plan.scope === 'game' ? 'One server per selected game' : 'One server per game / map'; +} +export function needsSessionTelemetry(r: BetaRequirement): boolean { + return ['round', 'feature', 'restart-budget'].includes(r.kind) || (r.kind === 'matches' && (r.minRound > 0 || r.minPlayers > 1)); +} +export function requirementCategory(r: BetaRequirement): string { + return r.kind === 'manual' ? 'Tester sign-off' : ['healthy-minutes', 'failure-budget', 'restart-budget', 'clean-logs'].includes(r.kind) ? 'Continuous check' : 'Milestone'; +} diff --git a/src/lib/ui/components/BetaPlanBuilder.svelte b/src/lib/ui/components/BetaPlanBuilder.svelte index 3914997..e8d68bf 100644 --- a/src/lib/ui/components/BetaPlanBuilder.svelte +++ b/src/lib/ui/components/BetaPlanBuilder.svelte @@ -1,48 +1,122 @@
-

Test plan

-

All requirements must pass on each qualifying server. Milestones stay recorded; health and error budgets remain monitored until promotion.

- - {#each plan.requirements as r, index (r.id)} -
- {requirementLabels[r.kind]} +
+
Validation

Build your test plan

+ Manual promotion +
+

Choose a starting point, then tailor what each server needs to prove.

+ +
+ {#each builtInPlans as preset, index (preset.name)} + + {/each} +
+ {#if savedPresets.length} + + {/if} + + {#if error}{/if} + +
+
+
Your active plan

{matchingPreset?.name ?? 'Custom plan'}

+ {plan.requirements.length} requirement{plan.requirements.length === 1 ? '' : 's'} +
+
+
Must pass on
{coverageSummary(plan)}
+
Testing window
{plan.timeoutHours} hours from opening
+
If a check fails
{failureSummary}
+
+

Every requirement below must pass on each qualifying server. You can edit any check.

+ {#if plan.requirements.some(needsSessionTelemetry)} +

This plan needs session telemetry from the game reporter.

+ {/if} + +
+ {#each plan.requirements as r, index (r.id)} +
+
+ {String(index + 1).padStart(2, '0')} +
{requirementCategory(r)}
{requirementSummary(r)}
+ +
+
+ Edit requirement {requirementLabels[r.kind]} +
{#if r.kind !== 'manual'} {/if} {#if ['round', 'matches', 'populated-minutes', 'player-hours', 'sustained-population', 'feature'].includes(r.kind)} @@ -69,51 +143,123 @@ {:else if r.kind === 'manual'}

A tester records their name and observation on the beta detail page.

{/if} - -
- {/each} -
- - -
-
- Coverage and rollout -
- - {#if plan.scope === 'quorum'}{/if} - +
+ + + {:else} +
No requirements yet

Choose a preset above or add your first check below. A beta needs at least one requirement.

+ {/each} -

Each wave must fully pass before files reach the next wave. Coverage rules apply after all selected servers have started testing.

-
-
- Deadline and failures -
- - - +
+ +
-

Missing evidence at the deadline is inconclusive. Promotion always requires a manual review.

-
-
- -
- {#if message}

{message}

{/if} + +
+
+ Coverage & rollout{coverageSummary(plan)} · {rolloutSummary}Configure +
+
+ + {#if plan.scope === 'quorum'}{/if} + +
+

Each wave must fully pass before files reach the next wave. Coverage rules apply after all selected servers have started testing. Restarts always wait for empty servers.

+
+
+
+ Deadline & failure handling{plan.timeoutHours}-hour window · {failureSummary}Configure +
+
+ + + +
+

Missing evidence at the deadline is inconclusive. Health and error checks continue until you manually promote.

+
+
+
+ Save this plan as a presetReuse your requirements and settings on future betas.Save +
+ + +
+
+
diff --git a/src/routes/betas/+page.svelte b/src/routes/betas/+page.svelte index 718c4dc..4a0d1a3 100644 --- a/src/routes/betas/+page.svelte +++ b/src/routes/betas/+page.svelte @@ -161,6 +161,7 @@ const canSubmit = $derived( !openingBeta && + testPlan.requirements.length > 0 && picks.length > 0 && picks.every((p) => p.scriptId !== null && p.versionId !== null) && candidateServers.length > 0 && diff --git a/tests/unit/beta-evaluation.test.ts b/tests/unit/beta-evaluation.test.ts index 8451507..06362b0 100644 --- a/tests/unit/beta-evaluation.test.ts +++ b/tests/unit/beta-evaluation.test.ts @@ -6,6 +6,18 @@ const signal = (event: TestSignal['event'], extra: Partial = {}): Te const planWith = (...kinds: Parameters[0][]): BetaPlan => ({ ...defaultPlan(), requirements: kinds.map((k, i) => requirement(k, `r${i}`)) }); describe('beta requirement evaluation', () => { + it('copies a reactive preset without cloning proxies or mutating the saved requirements', () => { + const source = defaultPlan(); + const proxied = new Proxy(source, { get(target, key, receiver) { + return key === 'requirements' ? new Proxy(target.requirements, {}) : Reflect.get(target, key, receiver); + } }); + const selected = parseBetaPlan(proxied); + expect(selected).toEqual(source); + selected.requirements[0].target = 15; + selected.requirements.push(requirement('round', 'round')); + expect(source.requirements).toHaveLength(1); + expect(source.requirements[0].target).toBe(1); + }); it('validates plans without accepting unknown kinds, duplicate IDs, fractional counts or unsafe numbers', () => { expect(parseBetaPlan(defaultPlan())).toEqual(defaultPlan()); expect(() => parseBetaPlan({ ...defaultPlan(), requirements: [] })).toThrow();