import { afterEach, beforeEach, describe, expect, setSystemTime, test } from 'bun:test'; import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { tournamentConfig } from '../tourney'; import type { Follow } from '../link'; import type { OcrLine } from './desktop'; import { FakeDesktop, fakeTosu, tourneyFrame, windowInfo } from './fakes'; import { findRoomLine, parseRoomName, pickManagerWindow, savedLogin, teamsMatch, TourneyController } from './tourney'; const line = (text: string, y: number): OcrLine => ({ text, x: 10, y, width: 600, height: 30 }); describe('room names and matching', () => { test('parses the acronym and both team names', () => { expect(parseRoomName('VASH: (Red Team) vs (Blue: Team)')).toEqual({ acronym: 'VASH', teams: ['Red Team', 'Blue: Team'] }); expect(parseRoomName('not a tourney room')).toBeNull(); expect(parseRoomName(null)).toBeNull(); }); test('finds the room by its lobby id, even with OCR look-alike letters', () => { const lines = [ line('118000001 : @VASH: (Alpha) vs (Beta) () 0/16', 0), line('11800OO42 : @VASH: (Alpha) vs (Beta) () 2/16', 40) ]; const match = findRoomLine(lines, 118000042, 'VASH: (Alpha) vs (Beta)'); expect(match).toMatchObject({ found: true, by: 'lobby id', line: { y: 40 } }); }); test('falls back to the room name only when exactly one room has it', () => { const one = [line('VASH: (Other) vs (Team)', 0), line('VASH: (Alpha) vs (Beta)', 40)]; expect(findRoomLine(one, 5, 'VASH: (Alpha) vs (Beta)')).toMatchObject({ found: true, by: 'room name', line: { y: 40 } }); const two = [line('VASH: (Alpha) vs (Beta)', 0), line('VASH: (Alpha) vs (Beta)', 40)]; const ambiguous = findRoomLine(two, 5, 'VASH: (Alpha) vs (Beta)'); expect(ambiguous.found).toBe(false); expect(ambiguous.found || ambiguous.reason).toContain('2 rooms are named'); expect( findRoomLine([line('Make a multiplayer room with format:', 0)], 5, 'VASH: (A) vs (B)') ).toMatchObject({ found: false, reason: 'the room is not listed yet' }); }); test('confirms the room from the team names tosu reads', () => { expect(teamsMatch({ left: 'Alpha', right: 'Beta' }, 'VASH: (Alpha) vs (Beta)')).toBe(true); expect(teamsMatch({ left: 'beta', right: 'ALPHA' }, 'VASH: (Alpha) vs (Beta)')).toBe(true); expect(teamsMatch({ left: 'Alpha', right: 'Gamma' }, 'VASH: (Alpha) vs (Beta)')).toBe(false); expect(teamsMatch({ left: '', right: '' }, 'VASH: ( ) vs ( )')).toBe(false); expect(teamsMatch(null, 'VASH: (Alpha) vs (Beta)')).toBe(false); }); test('picks the manager window (control panel below 16:9), not a spectator window', () => { const spectator = windowInfo({ hwnd: 1, clientWidth: 640, clientHeight: 360 }); const manager = windowInfo({ hwnd: 2, clientWidth: 1280, clientHeight: 920 }); const hidden = windowInfo({ hwnd: 3, clientWidth: 1920, clientHeight: 1300, visible: false }); expect(pickManagerWindow([spectator, hidden, manager])?.hwnd).toBe(2); expect(pickManagerWindow([spectator])).toBeNull(); }); test('reads a remembered login from osu!..cfg', () => { expect(savedLogin('Username = spectator\r\nPassword = AQAAANCM\r\nSavePassword = 1')).toEqual({ username: 'spectator', saved: true }); expect(savedLogin('Username = spectator\nPassword = \n')).toEqual({ username: 'spectator', saved: false }); expect(savedLogin('')).toEqual({ username: null, saved: false }); }); test('tournament.cfg keeps other keys and sets ours', () => { expect( tournamentConfig('privateserver = 0\r\nTeamSize = 4\r\nacronym = OWC\r\n', { teamSize: 2, height: 720, acronym: 'VASH' }) ).toBe('privateserver = 0\nTeamSize = 2\nacronym = VASH\nHeight = 720\n'); }); }); describe('TourneyController', () => { let dir: string; let desktop: FakeDesktop; let tosu: ReturnType; let launches: number; let tourney: TourneyController; let now: number; const advance = (ms: number) => { now += ms; setSystemTime(new Date(now)); }; const follow = (overrides: Partial = {}): Follow => ({ scene: 'gameplay', matchId: 'm1', lobbyId: 118000042, roomName: 'VASH: (Alpha) vs (Beta)', teamSize: 2, title: 'Alpha vs Beta', ...overrides }); beforeEach(async () => { now = Date.parse('2026-09-29T12:00:00Z'); setSystemTime(new Date(now)); dir = await mkdtemp(join(tmpdir(), 'vash-tourney-')); await writeFile(join(dir, 'osu!.stan.cfg'), 'Username = spectator\nPassword = secret\n'); desktop = new FakeDesktop(); tosu = fakeTosu(); launches = 0; tourney = new TourneyController({ osuDir: dir, snapshots: join(dir, 'snapshots'), height: 720, windowTitle: 'Vash osu!tourney (test)', desktop, tosu, log: () => {}, launch: () => { launches++; desktop.running.push({ pid: 10 + launches, path: join(dir, 'osu!.exe') }); } }); }); afterEach(async () => { setSystemTime(); await rm(dir, { recursive: true, force: true }); }); test('launches with the match team size, selects the room by lobby id and confirms it', async () => { tourney.follow(follow()); await tourney.tick(desktop.sessionInfo); expect(launches).toBe(1); expect(tourney.state).toBe('starting'); expect(await readFile(join(dir, 'tournament.cfg'), 'utf8')).toBe( 'TeamSize = 2\nacronym = VASH\nHeight = 720\n' ); desktop.windowList = [windowInfo({ pid: 11 })]; await tourney.tick(desktop.sessionInfo); expect(desktop.called('title')[0].args).toEqual([100, 'Vash osu!tourney (test)']); expect(desktop.called('click')).toHaveLength(0); advance(9000); desktop.ocrLines = [ line('118000001 : @VASH: (Other) vs (Team) () 0/16', 20), line('118000042 : @VASH: (Alpha) vs (Beta) () 2/16', 60) ]; await tourney.tick(desktop.sessionInfo); // Panel at client (8, 31 + 720), captured at 2.5×: the second line's start. expect(desktop.called('click')[0].args).toEqual([52, 781]); expect(tourney.state).toBe('selecting'); expect(tourney.verified).toBe(false); tosu.frame = tourneyFrame('Alpha', 'Beta'); advance(2000); await tourney.tick(desktop.sessionInfo); expect(tourney.state).toBe('spectating'); expect(tourney.verified).toBe(true); }); test('restarts osu!tourney for a different team size, and not for an idle channel', async () => { tourney.follow(follow()); await tourney.tick(desktop.sessionInfo); desktop.windowList = [windowInfo({ pid: 11 })]; tourney.follow( follow({ matchId: null, lobbyId: null, roomName: null, teamSize: null, title: null }) ); await tourney.tick(desktop.sessionInfo); expect(launches).toBe(1); expect(tourney.state).toBe('idle'); tourney.follow(follow({ lobbyId: 5, teamSize: 1, roomName: 'VASH: (C) vs (D)' })); await tourney.tick(desktop.sessionInfo); expect(desktop.called('close')).toHaveLength(1); expect(launches).toBe(2); expect(await readFile(join(dir, 'tournament.cfg'), 'utf8')).toContain('TeamSize = 1'); }); test('does not click while Windows is locked', async () => { tourney.follow(follow()); await tourney.tick(desktop.sessionInfo); desktop.windowList = [windowInfo({ pid: 11 })]; await tourney.tick(desktop.sessionInfo); advance(9000); desktop.sessionInfo = { ...desktop.sessionInfo, inputDesktop: 'Winlogon' }; await tourney.tick(desktop.sessionInfo); expect(tourney.state).toBe('desktop-locked'); expect(desktop.called('click')).toHaveLength(0); }); test('selects again when tosu shows a different room after the click', async () => { tourney.follow(follow()); await tourney.tick(desktop.sessionInfo); desktop.windowList = [windowInfo({ pid: 11 })]; await tourney.tick(desktop.sessionInfo); advance(9000); desktop.ocrLines = [line('118000042 : @VASH: (Alpha) vs (Beta) () 2/16', 60)]; await tourney.tick(desktop.sessionInfo); expect(desktop.called('click')).toHaveLength(1); tosu.frame = tourneyFrame('Other', 'Team'); advance(5000); await tourney.tick(desktop.sessionInfo); expect(tourney.state).toBe('selecting'); advance(13_000); await tourney.tick(desktop.sessionInfo); expect(tourney.verified).toBe(false); advance(11_000); await tourney.tick(desktop.sessionInfo); expect(desktop.called('click')).toHaveLength(2); }); test('waits for the room when it is not listed, and never clicks another room', async () => { tourney.follow(follow()); await tourney.tick(desktop.sessionInfo); desktop.windowList = [windowInfo({ pid: 11 })]; await tourney.tick(desktop.sessionInfo); advance(9000); desktop.ocrLines = [line('118000001 : @VASH: (Other) vs (Team) () 0/16', 20)]; await tourney.tick(desktop.sessionInfo); expect(tourney.state).toBe('waiting-for-lobby'); expect(tourney.detail).toContain('1 room listed'); expect(desktop.called('click')).toHaveLength(0); }); test('asks for the one-time sign-in instead of launching without a saved login', async () => { await writeFile(join(dir, 'osu!.stan.cfg'), 'Username = \n'); tourney.follow(follow()); await tourney.tick(desktop.sessionInfo); expect(tourney.state).toBe('login-required'); expect(launches).toBe(0); }); });