Files
esports/streamer/windows/tourney.test.ts
Stan Runge 54444a77f0 feat: add native Windows broadcaster and live score modules
- streamer/windows: vash-broadcast.exe (setup/login/start/stop/status/doctor)
  with per-instance OBS, tosu and FFmpeg, a supervised worker, OCR room
  selection and OBS scene reconciliation; built and tested on Linux only
- streamer/relay.ts shared with Vash TV; Cloudflare Access proxy for dev/staging
- worker status validation and live score helpers for the site
- STREAMING_HANDOFF.md and streamer/ALPHA_TEST.md record what is unverified

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 22:02:25 +00:00

251 lines
9.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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!.<user>.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<typeof fakeTosu>;
let launches: number;
let tourney: TourneyController;
let now: number;
const advance = (ms: number) => {
now += ms;
setSystemTime(new Date(now));
};
const follow = (overrides: Partial<Follow> = {}): 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);
});
});