1import { describe, expect, it } from 'vitest';2import { LADDER_MAX_SIMS, pickLadderRosters, rosterKey } from '../src/ladder';3import type { LadderCandidate } from '../src/ladder';45const cands = (...ratings: number[]): LadderCandidate[] =>6 ratings.map((rating, i) => ({ id: `lg_${String(i).padStart(2, '0')}`, rating }));78const NONE: ReadonlySet<string> = new Set();910describe('pickLadderRosters', () => {11 it('returns nothing with fewer than 2 candidates', () => {12 expect(pickLadderRosters([], 0, NONE)).toEqual([]);13 expect(pickLadderRosters(cands(1200), 0, NONE)).toEqual([]);14 });1516 it('is deterministic and pairs rating-adjacent scripts', () => {17 const list = cands(1500, 1400, 1300, 1200, 1100, 1000);18 const a = pickLadderRosters(list, 30, NONE);19 const b = pickLadderRosters(list, 30, NONE);20 expect(a).toEqual(b);21 // Every roster is a contiguous run of the rating-sorted (rotated) order22 // in ONE direction — each lineup plays both slot orders, so accept a23 // roster that is contiguous forward or backward.24 const sortedIds = [...list].sort((x, y) => y.rating - x.rating).map((c) => c.id);25 const contiguous = (roster: string[]): boolean => {26 const idx = roster.map((id) => sortedIds.indexOf(id));27 return idx.every(28 (v, i) => i === 0 || (v - idx[i - 1]! + sortedIds.length) % sortedIds.length === 1,29 );30 };31 for (const roster of a) {32 expect(contiguous(roster) || contiguous([...roster].reverse())).toBe(true);33 }34 // Both slot orders of each lineup are present.35 const asSet = (r: string[]): string => [...r].sort().join(',');36 for (const roster of a) {37 const twin = a.find(38 (r) => r !== roster && asSet(r) === asSet(roster) && r.join() === [...roster].reverse().join(),39 );40 expect(twin).toBeDefined();41 }42 });4344 it('covers everyone with rosters of 2-4, never leaving a singleton', () => {45 for (let n = 2; n <= 12; n++) {46 for (let day = 0; day <= 5; day++) {47 const rosters = pickLadderRosters(cands(...Array.from({ length: n }, (_, i) => 1500 - i * 10)), day, NONE);48 const seen = rosters.flat();49 // Each lineup appears twice (both slot orders), so each script shows50 // up at most twice — but never twice within one roster.51 for (const r of rosters) expect(new Set(r).size).toBe(r.length);52 for (const r of rosters) {53 expect(r.length).toBeGreaterThanOrEqual(2);54 expect(r.length).toBeLessThanOrEqual(4);55 }56 // At most one leftover >= 2 would mean a missed roster; only 0..157 // scripts may sit out for size reasons (plus none here, no skips).58 expect(new Set(seen).size).toBeGreaterThanOrEqual(n - 1);59 }60 }61 });6263 it('rotates the grouping offset with the day (variety night to night)', () => {64 const list = cands(1500, 1400, 1300, 1200, 1100, 1000, 900, 800);65 const day0 = pickLadderRosters(list, 0, NONE).map(rosterKey).join('|');66 const day1 = pickLadderRosters(list, 1, NONE).map(rosterKey).join('|');67 expect(day0).not.toBe(day1);68 });6970 it('skips rosters whose exact participants played within the window', () => {71 const list = cands(1500, 1400, 1300, 1200);72 const all = pickLadderRosters(list, 0, NONE);73 expect(all.length).toBeGreaterThan(0);74 const recent = new Set(all.map(rosterKey));75 const skipped = pickLadderRosters(list, 0, recent);76 expect(skipped).toEqual([]);77 });7879 it('caps the number of rosters per run', () => {80 const many = cands(...Array.from({ length: 100 }, (_, i) => 2000 - i));81 expect(pickLadderRosters(many, 2, NONE).length).toBeLessThanOrEqual(LADDER_MAX_SIMS);82 expect(pickLadderRosters(many, 2, NONE, 3)).toHaveLength(3);83 });84});