import { describe, expect, it } from 'vitest'; import { defaultPlan, betaPlanIssues, requirement } from '../../src/lib/beta-plan.js'; import { readBetaDraft, compatibleBetaServers, betaCandidateServers, betaApplicableGames, betaInstanceCounts, betaScopeMatches } from '../../src/lib/beta-draft.js'; import { betaWaitingCondition, betaEvidenceSummary } from '../../src/lib/beta-evidence.js'; describe('beta draft and plan recovery', () => { it('retains exact version IDs and incomplete plan fields in a recoverable draft', () => { const plan = defaultPlan(); const raw = JSON.stringify({ version: 1, savedAt: new Date().toISOString(), step: 2, picks: [{ scriptId: 12, versionId: 45 }], servers: ['town'], testPlan: { ...plan, timeoutHours: null } }); const draft = readBetaDraft(raw)!; expect(draft.picks).toEqual([{ scriptId: 12, versionId: 45 }]); expect(draft.selectionMode).toBe('manual'); expect(draft.servers).toEqual(['town']); expect(betaPlanIssues(draft.testPlan, 1)).toContainEqual(expect.objectContaining({ field: 'timeoutHours' })); expect(readBetaDraft('{bad')).toBeNull(); expect(readBetaDraft(JSON.stringify({ version: 1, picks: [{ scriptId: '12', versionId: 45 }] }))).toBeNull(); }); it('restores the shared count and incomplete input without substituting manual selections', () => { const raw = { version: 1, savedAt: '', step: 1, picks: [{ scriptId: 12, versionId: 45 }], servers: ['previous-manual-choice'], selectionMode: 'automatic', instanceCount: 2, testPlan: defaultPlan() }; expect(readBetaDraft(JSON.stringify(raw))).toMatchObject({ selectionMode: 'automatic', instanceCount: 2 }); for (const instanceCount of [null, 1.5, -1]) { expect(readBetaDraft(JSON.stringify({ ...raw, instanceCount }))?.instanceCount).toBe(instanceCount); } expect(readBetaDraft(JSON.stringify({ ...raw, instanceCount: '2' }))).toBeNull(); expect(readBetaDraft(JSON.stringify({ ...raw, selectionMode: 'unknown' }))).toBeNull(); }); it('migrates per-game drafts without reducing coverage and revisits unequal counts', () => { const raw = { version: 1, savedAt: '', step: 3, picks: [{ scriptId: 12, versionId: 45 }], servers: [], selectionMode: 'automatic', instancesPerGame: { t5: 2, t6: 3 }, testPlan: defaultPlan() }; expect(readBetaDraft(JSON.stringify(raw))).toMatchObject({ instanceCount: 3, step: 1 }); expect(readBetaDraft(JSON.stringify({ ...raw, instancesPerGame: { t5: 2, t6: 2 } }))).toMatchObject({ instanceCount: 2, step: 3 }); expect(readBetaDraft(JSON.stringify({ ...raw, instancesPerGame: { t5: 2, t6: null } }))?.instanceCount).toBeNull(); expect(readBetaDraft(JSON.stringify({ ...raw, instancesPerGame: {} }))?.instanceCount).toBe(1); expect(readBetaDraft(JSON.stringify({ ...raw, selectionMode: 'manual' }))?.step).toBe(3); expect(readBetaDraft(JSON.stringify({ ...raw, instancesPerGame: { t5: '2' } }))).toBeNull(); }); it('defaults a legacy draft with no server choices to automatic selection', () => { expect(readBetaDraft(JSON.stringify({ version: 1, picks: [{ scriptId: 12, versionId: 45 }], servers: [], testPlan: defaultPlan() }))?.selectionMode).toBe('automatic'); }); it('keeps compatible explicit selections without selecting newly eligible servers', () => { const result = compatibleBetaServers(['town1', 'town2'], ['town2', 'town3']); expect([...result.kept]).toEqual(['town2']); expect(result.removed).toEqual(['town1']); }); it('identifies the precise requirement and rejects an impossible quorum before review', () => { const plan = { ...defaultPlan(), scope: 'quorum' as const, quorum: 3, requirements: [{ ...requirement('matches', 'matches'), target: 1.5, minPlayers: 0 }] }; expect(betaPlanIssues(plan, 2).map(issue => issue.field)).toEqual(['quorum', 'matches-target', 'matches-minPlayers']); expect(betaPlanIssues(plan, 2).find(issue => issue.field === 'matches-target')?.requirementId).toBe('matches'); }); }); describe('multi-game beta setup', () => { const servers = [ { name: 'kino', game: 't5', map: 'zombie_theater' }, { name: 'town', game: 't6', map: 'zm_transit' }, { name: 'origins', game: 't6', map: 'zm_tomb' } ]; it('offers the union of different file scopes while keeping file-to-server matches separate', () => { const files = [{ scope_type: 'game' as const, scope_value: 't5' }, { scope_type: 'map' as const, scope_value: 'zm_transit' }]; expect(betaCandidateServers(files, servers).map(server => server.name)).toEqual(['kino', 'town']); expect(betaApplicableGames(files, servers)).toEqual(['t5', 't6']); expect(betaScopeMatches(files[0], servers[1])).toBe(false); expect(betaScopeMatches(files[1], servers[0])).toBe(false); }); it('includes every server for an unscoped file and requires a selected file first', () => { expect(betaCandidateServers([{ scope_type: null, scope_value: null }], servers)).toEqual(servers); expect(betaCandidateServers([], servers)).toEqual([]); expect(betaApplicableGames([], servers)).toEqual([]); }); it('uses the shared count for every applicable game, including games without fleet matches', () => { const files = [{ scope_type: 'game' as const, scope_value: 't5' }, { scope_type: 'game' as const, scope_value: 't6' }]; expect(betaApplicableGames(files, [])).toEqual(['t5', 't6']); expect(betaInstanceCounts(betaApplicableGames(files, []), 2)).toEqual({ t5: 2, t6: 2 }); expect(betaInstanceCounts(betaApplicableGames([files[1]], servers), 3)).toEqual({ t6: 3 }); for (const count of [null, 0, -1, 1.5, 1001]) expect(betaInstanceCounts(['t5', 't6'], count)).toEqual({}); expect(betaInstanceCounts([], 2)).toEqual({}); }); }); describe('beta evidence explanations', () => { const observation = { lastGameplayAt: new Date().toISOString(), lastEvent: 'round_change', players: 2, round: 12, matchStarted: true, gameEnded: false, healthy: true, healthCheckedAt: new Date().toISOString() }; it('distinguishes missing reports, insufficient population, and report-driven time', () => { const r = { ...requirement('populated-minutes', 'play'), minPlayers: 3 }; expect(betaWaitingCondition(r)).toContain('No gameplay report'); expect(betaWaitingCondition(r, observation)).toContain('latest report has 2 players'); expect(betaWaitingCondition(r, { ...observation, players: 3 })).toContain('next report arrives'); }); it('summarizes telemetry while keeping arbitrary or legacy payloads readable', () => { const event = { id: 1, server: 'town', server_deleted_at: null, received_at: '', kind: 'game_end', payload: JSON.stringify({ round: 25, players: [{ name: 'A' }], map: 'zm_transit' }) }; expect(betaEvidenceSummary(event)).toBe('Game ended at round 25 with 1 players on zm_transit.'); expect(betaEvidenceSummary({ ...event, kind: 'old-event', payload: 'invalid' })).toBe('old event'); }); });