Add ingest test scenarios
Some checks failed
E2E Tests / e2e (push) Has been cancelled
Tests and checks on push / run-checks-and-tests (push) Has been cancelled
Updates translation progress / update-translation-progress-issue (push) Has been cancelled

This commit is contained in:
Kalle
2026-08-14 21:16:21 +03:00
parent 4f1dfff1c4
commit 7a5c59b8cd
9 changed files with 1152 additions and 55 deletions

View File

@@ -384,6 +384,8 @@ function sendouqGame(map: {
mapIndex: map.index,
mode: map.mode,
stageId: map.stageId,
winnerUserIds: [],
loserUserIds: [],
winnerInGameNames: [],
loserInGameNames: [],
playedAt: Math.floor(PLAYED_AT / 1000),

View File

@@ -94,7 +94,12 @@ export function gamesInTournamentMatch(tournamentMatchId: number) {
return tournamentGames({ tournamentMatchIds: [tournamentMatchId] });
}
/** Returns a SendouQ match's games (its whole map list), in map order. */
/**
* Returns a SendouQ match's reported games, in map order. The match's
* pre-generated unplayed maps are left out: only a reported game is a link
* target, so a match sent before its game's report stays unlinked (and gets
* linked by a later resend instead).
*/
export function gamesInGroupMatch(groupMatchId: number) {
return sendouqGames({ groupMatchId });
}
@@ -782,7 +787,7 @@ async function tournamentGames({
.orderBy("TournamentMatchGameResult.number", "asc")
.execute();
const inGameNamesByTeamId = await teamInGameNames(
const rostersByTeamId = await teamRosters(
rows.flatMap((row) => [row.opponentOneId, row.opponentTwoId]),
);
const linkedNames = await linkedPlayerNamesByTarget(
@@ -798,6 +803,10 @@ async function tournamentGames({
? row.opponentOneId
: null;
const winnerRoster = rostersByTeamId.get(row.winnerTeamId);
const loserRoster =
loserTeamId !== null ? rostersByTeamId.get(loserTeamId) : undefined;
return {
target: {
type: "tournament",
@@ -808,28 +817,33 @@ async function tournamentGames({
mapIndex: row.number - 1,
mode: row.mode,
stageId: row.stageId,
winnerInGameNames: inGameNamesByTeamId.get(row.winnerTeamId) ?? [],
loserInGameNames:
(loserTeamId !== null
? inGameNamesByTeamId.get(loserTeamId)
: undefined) ?? [],
winnerUserIds: winnerRoster?.userIds ?? [],
loserUserIds: loserRoster?.userIds ?? [],
winnerInGameNames: winnerRoster?.inGameNames ?? [],
loserInGameNames: loserRoster?.inGameNames ?? [],
playedAt: row.playedAt,
linkedPlayerNames: linkedNames.get(row.matchGameResultId) ?? null,
};
});
}
async function teamInGameNames(teamIds: Array<number | null>) {
interface Roster {
userIds: number[];
inGameNames: string[];
}
async function teamRosters(teamIds: Array<number | null>) {
const uniqueTeamIds = [
...new Set(teamIds.filter((id): id is number => id !== null)),
];
if (uniqueTeamIds.length === 0) return new Map<number, string[]>();
if (uniqueTeamIds.length === 0) return new Map<number, Roster>();
const members = await db
.selectFrom("TournamentTeamMember")
.innerJoin("User", "User.id", "TournamentTeamMember.userId")
.select((eb) => [
"TournamentTeamMember.tournamentTeamId",
"TournamentTeamMember.userId",
eb.fn
.coalesce("TournamentTeamMember.inGameName", "User.inGameName")
.as("inGameName"),
@@ -837,12 +851,15 @@ async function teamInGameNames(teamIds: Array<number | null>) {
.where("TournamentTeamMember.tournamentTeamId", "in", uniqueTeamIds)
.execute();
const result = new Map<number, string[]>();
const result = new Map<number, Roster>();
for (const member of members) {
if (!member.inGameName) continue;
const names = result.get(member.tournamentTeamId) ?? [];
names.push(member.inGameName);
result.set(member.tournamentTeamId, names);
const roster = result.get(member.tournamentTeamId) ?? {
userIds: [],
inGameNames: [],
};
roster.userIds.push(member.userId);
if (member.inGameName) roster.inGameNames.push(member.inGameName);
result.set(member.tournamentTeamId, roster);
}
return result;
@@ -872,7 +889,9 @@ async function sendouqGames({
"GroupMatch.createdAt as playedAt",
])
.$if(groupMatchId !== undefined, (qb) =>
qb.where("GroupMatchMap.matchId", "=", groupMatchId!),
qb
.where("GroupMatchMap.matchId", "=", groupMatchId!)
.where("GroupMatchMap.winnerGroupId", "is not", null),
)
// joined (not EXISTS) so the planner drives off the user's own
// membership index instead of scanning the whole createdAt window
@@ -888,8 +907,6 @@ async function sendouqGames({
),
),
)
// content resolution walks played games only; a current match's
// pre-generated unplayed maps would flood the candidate sequence
.$if(since !== undefined, (qb) =>
qb
.where("GroupMatch.createdAt", ">=", since!)
@@ -899,7 +916,7 @@ async function sendouqGames({
.orderBy("GroupMatchMap.index", "asc")
.execute();
const inGameNamesByGroupId = await groupInGameNames(
const rostersByGroupId = await groupRosters(
rows.flatMap((row) => [row.alphaGroupId, row.bravoGroupId]),
);
const linkedNames = await linkedPlayerNamesByTarget(
@@ -915,6 +932,13 @@ async function sendouqGames({
? row.alphaGroupId
: null;
const winnerRoster =
row.winnerGroupId !== null
? rostersByGroupId.get(row.winnerGroupId)
: undefined;
const loserRoster =
loserGroupId !== null ? rostersByGroupId.get(loserGroupId) : undefined;
return {
target: {
type: "sendouq",
@@ -925,37 +949,36 @@ async function sendouqGames({
mapIndex: row.mapIndex,
mode: row.mode,
stageId: row.stageId,
winnerInGameNames:
(row.winnerGroupId !== null
? inGameNamesByGroupId.get(row.winnerGroupId)
: undefined) ?? [],
loserInGameNames:
(loserGroupId !== null
? inGameNamesByGroupId.get(loserGroupId)
: undefined) ?? [],
winnerUserIds: winnerRoster?.userIds ?? [],
loserUserIds: loserRoster?.userIds ?? [],
winnerInGameNames: winnerRoster?.inGameNames ?? [],
loserInGameNames: loserRoster?.inGameNames ?? [],
playedAt: row.playedAt,
linkedPlayerNames: linkedNames.get(row.groupMatchMapId) ?? null,
};
});
}
async function groupInGameNames(groupIds: number[]) {
async function groupRosters(groupIds: number[]) {
const uniqueGroupIds = [...new Set(groupIds)];
if (uniqueGroupIds.length === 0) return new Map<number, string[]>();
if (uniqueGroupIds.length === 0) return new Map<number, Roster>();
const members = await db
.selectFrom("GroupMember")
.innerJoin("User", "User.id", "GroupMember.userId")
.select(["GroupMember.groupId", "User.inGameName"])
.select(["GroupMember.groupId", "GroupMember.userId", "User.inGameName"])
.where("GroupMember.groupId", "in", uniqueGroupIds)
.execute();
const result = new Map<number, string[]>();
const result = new Map<number, Roster>();
for (const member of members) {
if (!member.inGameName) continue;
const names = result.get(member.groupId) ?? [];
names.push(member.inGameName);
result.set(member.groupId, names);
const roster = result.get(member.groupId) ?? {
userIds: [],
inGameNames: [],
};
roster.userIds.push(member.userId);
if (member.inGameName) roster.inGameNames.push(member.inGameName);
result.set(member.groupId, roster);
}
return result;

View File

@@ -71,6 +71,7 @@ export const action: ActionFunction = async ({ request }) => {
const matched = Scoreboards.matchedGames({
matches: effectiveMatches.map((effective) => effective.data),
games: resolved.games,
povUserId,
});
linkedGamesCount = await ScannerIngestRepository.addLinks({
@@ -228,7 +229,11 @@ async function resolveIngestContext({
} | null = null;
for (const candidate of candidates) {
const games = await candidate.loadGames();
const matched = Scoreboards.matchedGames({ matches, games }).length;
const matched = Scoreboards.matchedGames({
matches,
games,
povUserId,
}).length;
if (!best || matched > best.matched) {
best = { candidate, games, matched };
}
@@ -260,7 +265,7 @@ async function resolveIngestContext({
}),
])
).flat();
const context = Scoreboards.resolveContext({ matches, games });
const context = Scoreboards.resolveContext({ matches, games, povUserId });
if (context) {
const key = Scoreboards.contextKey(context);
logger.debug(

View File

@@ -30,6 +30,8 @@ function testGame(
mapIndex: 0,
mode: "SZ",
stageId: 0 as StageId,
winnerUserIds: [],
loserUserIds: [],
winnerInGameNames: [],
loserInGameNames: [],
playedAt: 1000,
@@ -386,6 +388,57 @@ describe("matchedGames", () => {
expect(matched.map(tournamentMatchIdOf)).toEqual([2]);
});
test("pins the sides via the POV sender's roster, overruling contradicting names", () => {
const matched = Scoreboards.matchedGames({
matches: [
testMatch({
// names read flipped, but the sender's seat is on the winning rows
names: ["l1", "l2", "l3", "l4", "w1", "w2", "w3", "w4"],
povIndex: 0,
}),
],
games: [
testGame({
winnerUserIds: [77],
loserUserIds: [88],
winnerInGameNames: ["w1", "w2"],
loserInGameNames: ["l1", "l2"],
}),
],
povUserId: 77,
});
expect(matched).toHaveLength(1);
});
test("skips a game seating the POV sender on the wrong side", () => {
const matched = Scoreboards.matchedGames({
matches: [testMatch({ povIndex: 4 })],
games: [testGame({ winnerUserIds: [77], loserUserIds: [88] })],
// the sender won the game, yet the read has their seat on the losing rows
povUserId: 77,
});
expect(matched).toHaveLength(0);
});
test("falls back to the name check when the sender is in neither roster", () => {
const matched = Scoreboards.matchedGames({
matches: [testMatch({ povIndex: 0 })],
games: [
testGame({
winnerUserIds: [77],
loserUserIds: [88],
winnerInGameNames: ["l1", "l2"],
loserInGameNames: ["w1", "w2"],
}),
],
povUserId: 99,
});
expect(matched).toHaveLength(0);
});
test("matches known in-game names ignoring discriminator, case and unicode width", () => {
const matched = Scoreboards.matchedGames({
matches: [

View File

@@ -53,9 +53,13 @@ export interface IngestableGame {
mapIndex: number;
mode: ModeShort;
stageId: StageId;
/** known in-game names of the winning team's roster, used to validate scoreboard sides */
/** user ids of the winning team's roster; the POV sender's side pins the scan's sides to the game's teams */
winnerUserIds: number[];
/** user ids of the losing team's roster; the POV sender's side pins the scan's sides to the game's teams */
loserUserIds: number[];
/** known in-game names of the winning team's roster, the side fallback for reads without a POV seat */
winnerInGameNames: string[];
/** known in-game names of the losing team's roster, used to validate scoreboard sides */
/** known in-game names of the losing team's roster, the side fallback for reads without a POV seat */
loserInGameNames: string[];
/** database timestamp used to order games chronologically across matches */
playedAt: number;
@@ -97,9 +101,11 @@ export function contextKey(context: IngestContext): string {
export function resolveContext({
matches,
games,
povUserId = null,
}: {
matches: ScannerMatch[];
games: IngestableGameWithContext[];
povUserId?: number | null;
}): IngestContext | null {
const byContext = new Map<string, IngestableGameWithContext[]>();
for (const game of games) {
@@ -114,6 +120,7 @@ export function resolveContext({
const matched = matchedGames({
matches,
games: contextGames,
povUserId,
}).length;
if (!best || matched > best.matched) {
best = { context: contextGames[0]!.context, matched };
@@ -132,10 +139,13 @@ export function resolveContext({
* minimap-only match can never link — its winner and stats are unread).
* Matches and games are both walked in chronological order: each match is
* assigned to the next not-yet-assigned game with the same mode and stage
* whose sides don't contradict the teams' known in-game names (the winning
* rows should overlap the game winner's roster, not the loser's). Matches
* from other lobbies, with unreadable mode/stage or duplicated detections
* of the same game are skipped.
* whose sides agree with what is known. The POV seat decides where it can:
* the sender is the POV player, so which of the game's rosters they belong
* to pins the scan's sides to the game's teams — OCR'd names are too
* unreliable to overrule it. Only when no seat can decide (cast footage, no
* POV read) do the known in-game names arbitrate the sides. Matches from
* other lobbies, with unreadable mode/stage or duplicated detections of the
* same game are skipped.
*
* One session's matches may arrive over many requests (one per game), so
* games another ingest already linked to are skipped — unless the incoming
@@ -146,9 +156,12 @@ export function resolveContext({
export function matchedGames({
matches,
games,
povUserId = null,
}: {
matches: ScannerMatch[];
games: IngestableGame[];
/** the sender, who is the POV player of the request's non-cast matches */
povUserId?: number | null;
}): MatchedGame[] {
const views = dedupeViews(
matches
@@ -177,8 +190,12 @@ export function matchedGames({
if (!isLinkedDuplicate(view, game.linkedPlayerNames)) {
continue;
}
} else if (!sidesMatchKnownPlayers(view, game)) {
continue;
} else {
const agreement = povSideAgreement(view, game, povUserId);
if (agreement === false) continue;
if (agreement === null && !sidesMatchKnownPlayers(view, game)) {
continue;
}
}
result.push({ matchIndex: view.matchIndex, game });
@@ -509,7 +526,27 @@ function dedupeViews(sorted: IndexedView[]): IndexedView[] {
}
/**
* Checks that the view's sides don't contradict the teams' known rosters:
* Whether the POV seat's side in the scan agrees with the sender's side in
* the game: the sender is the POV player, so the roster their user id sits
* in says whether their seat should be on the winning rows. Null when the
* check cannot decide — no POV seat read, no sender, or the sender in
* neither roster (cast footage) — leaving the sides to the name fallback.
*/
function povSideAgreement(
view: WinnerFirstView,
game: IngestableGame,
povUserId: number | null,
): boolean | null {
if (povUserId === null || view.povIndex === null) return null;
const povOnWinningSide = view.povIndex < PLAYERS_PER_TEAM;
if (game.winnerUserIds.includes(povUserId)) return povOnWinningSide;
if (game.loserUserIds.includes(povUserId)) return !povOnWinningSide;
return null;
}
/**
* The side fallback for reads no POV seat can pin (cast footage above all):
* the winning rows should overlap the game winner's in-game names at least
* as well as the losing team's (and vice versa). A contradiction means the
* match belongs to some other game. No overlap at all (e.g. no in-game

View File

@@ -0,0 +1,511 @@
/**
* Everything the ingest scenario cases need: world builders (arrange), the
* ingest wrapper (act), page-loader wrappers and row fetchers (assert).
* Cases import only from here, plus vitest. See README.md for the design.
*/
import { addHours, addMinutes, subDays, subMinutes } from "date-fns";
import * as R from "remeda";
import { afterAll, beforeAll } from "vitest";
import { Config } from "~/config";
import { backdate } from "~/db/seed/core/backdate";
import * as SQMatchFactory from "~/db/seed/factories/SQMatchFactory";
import * as TournamentFactory from "~/db/seed/factories/TournamentFactory";
import * as UserFactory from "~/db/seed/factories/UserFactory";
import { db } from "~/db/sql";
import type { ScannerMatch } from "~/features/scanner/core/scanner-match";
import {
loader as qMatchLoader,
type SendouQMatchLoaderData,
} from "~/features/sendouq-match/loaders/q.match.$id.server";
import * as SQMatchRepository from "~/features/sendouq-match/SQMatchRepository.server";
import * as TournamentRepository from "~/features/tournament/TournamentRepository.server";
import { clearAllTournamentDataCache } from "~/features/tournament-bracket/core/Tournament.server";
import {
type TournamentMatchLoaderData,
loader as tournamentMatchLoader,
} from "~/features/tournament-match/loaders/to.$id.matches.$mid.server";
import type {
MainWeaponId,
ModeShort,
StageId,
} from "~/modules/in-game-lists/types";
import invariant from "~/utils/invariant";
import { wrappedAction, wrappedLoader } from "~/utils/Test";
import { action } from "../actions/scanner-ingest.server";
import type {
IngestResponse,
ingestBodySchema,
} from "../scanner-ingest-schemas";
/** In-game names of the SendouQ world's alpha group, in member order. */
export const ALPHA_NAMES = ["Alpha1", "Alpha2", "Alpha3", "Alpha4"];
/** In-game names of the SendouQ world's bravo group, in member order. */
export const BRAVO_NAMES = ["Bravo1", "Bravo2", "Bravo3", "Bravo4"];
/** Weapons `scanned()` reads, winner rows first — row 0 is the default POV seat. */
export const WEAPONS: MainWeaponId[] = [10, 20, 30, 40, 50, 60, 70, 80];
/** One weapon per tournament-world player, so no two rosters read alike. */
const TOURNAMENT_WEAPONS: MainWeaponId[] = [
10, 20, 30, 40, 50, 60, 70, 80, 90, 100, 0, 1, 11, 21, 22, 31,
];
const DISCRIMINATOR = "1111";
const PLAYERS_PER_TEAM = 4;
/** minutes between consecutive tournament sets' backdated timestamps */
const SET_STAGGER_MINUTES = 10;
/** A game a `scanned()` read can be derived from, so rosters/mode/stage line up. */
export interface ScannableGame {
mode: ModeShort;
stage: StageId;
/** chronological position among the world's games, spacing reads apart */
order: number;
winnerNames: string[];
loserNames: string[];
/**
* each player's weapon, keyed by name. Worlds keep this stable per player
* so two games between different opponents never read as the same match.
* Reads fall back to `WEAPONS` by row for games built outside a world.
*/
weaponFor?: (name: string) => MainWeaponId;
}
export interface ScannedOptions extends Partial<ScannerMatch> {
/** `"loser"` flips the teams array so the read is winner-last (`winner: 1`). */
seenFrom?: "winner" | "loser";
/** spectator footage: no POV seat */
cast?: boolean;
/** minimap-only read: empty rosters, winner/mode/scores unread */
partial?: boolean;
}
/**
* Forces the scanner gate open for the whole suite (fallback for
* `import.meta.env` not carrying the config's `test.env`), restoring the
* original value afterwards. Call at the top of the test file.
*/
export function setupScannerGate() {
let original: boolean;
beforeAll(() => {
original = Config.scannerEnabled;
Config.scannerEnabled = true;
});
afterAll(() => {
Config.scannerEnabled = original;
});
}
/** Runs `fn` with the scanner gate closed, restoring it even on a throw. */
export async function withScannerDisabled(fn: () => Promise<void>) {
const original = Config.scannerEnabled;
Config.scannerEnabled = false;
try {
await fn();
} finally {
Config.scannerEnabled = original;
}
}
/**
* 8 users with deterministic in-game names (`Alpha1#1111`, …) and a SendouQ
* match between them. The match starts unreported; `conclude()` plays it out
* the way the teams report it (alpha sweeps), making its maps linkable, and
* returns the refreshed map rows.
*/
export async function sendouqWorld(options: { createdAt?: Date } = {}) {
const users = await createNamedUsers([...ALPHA_NAMES, ...BRAVO_NAMES]);
const alphaUsers = users.slice(0, PLAYERS_PER_TEAM);
const bravoUsers = users.slice(PLAYERS_PER_TEAM);
const match = await SQMatchFactory.create(
{
alphaUserIds: alphaUsers.map((user) => user.id),
bravoUserIds: bravoUsers.map((user) => user.id),
},
options.createdAt ? { createdAt: options.createdAt } : undefined,
);
const maps = await groupMatchMaps(match.id);
return {
match,
maps,
alphaUsers,
bravoUsers,
povUser: alphaUsers[0]!,
conclude: async () => {
await concludeGroupMatch(match.id);
return groupMatchMaps(match.id);
},
scanned: (
map: { mode: ModeShort; stageId: StageId; index: number },
options?: ScannedOptions,
) =>
scannedGame(
{
mode: map.mode,
stage: map.stageId,
order: map.index,
winnerNames: ALPHA_NAMES,
loserNames: BRAVO_NAMES,
},
options,
),
};
}
/**
* A played single-elimination tournament: `teams` rosters of 4 users, all
* with deterministic in-game names (`T1P1#1111`, …), played out through
* `playedOut` the way `TournamentFactory.createPlayed` plays brackets.
* Match/game timestamps are staggered into the recent past in play order, so
* activity resolution and chronological walks see an unambiguous timeline.
* Creating a world clears the tournament data caches: SQLite ids restart
* after the db wipe, so a previous test's entries could otherwise serve
* stale data for a reused id.
*/
export async function tournamentWorld(
options: { teams?: number; playedOut?: number } = {},
) {
clearAllTournamentDataCache();
const teamCount = options.teams ?? 4;
const author = await UserFactory.create();
const names = R.range(0, teamCount).flatMap((teamIdx) =>
R.range(0, PLAYERS_PER_TEAM).map(
(playerIdx) => `T${teamIdx + 1}P${playerIdx + 1}`,
),
);
const users = await createNamedUsers(names);
const teamRosters = R.chunk(users, PLAYERS_PER_TEAM).map((roster) =>
roster.map((user) => user.id),
);
const tournament = await TournamentFactory.createPlayed(
{ authorId: author.id },
{ teamRosters, playedOut: options.playedOut ?? 0 },
);
await staggerTournamentTimeline(tournament.matches);
clearAllTournamentDataCache();
const nameByUserId = new Map(
users.map((user, index) => [user.id, names[index]!]),
);
const namesByTeamId = new Map(
tournament.teams.map((team) => [
team.id,
team.memberUserIds.map((userId) => nameByUserId.get(userId)!),
]),
);
const matches = tournament.matches;
const championTeamId = matches.at(-1)!.winnerTeamId;
const championTeam = tournament.teams.find(
(team) => team.id === championTeamId,
)!;
return {
tournamentId: tournament.id,
teams: tournament.teams,
matches,
author,
championTeamId,
povUser: users.find((user) => user.id === championTeam.memberUserIds[0])!,
matchesOfTeam: (teamId: number) =>
matches.filter(
(match) =>
match.winnerTeamId === teamId || match.loserTeamId === teamId,
),
games: async (matchId: number): Promise<ScannableGame[]> => {
const played = matches.find((match) => match.id === matchId);
invariant(played, `Match ${matchId} is not part of the world`);
const matchIdx = matches.indexOf(played);
const rows = await db
.selectFrom("TournamentMatchGameResult")
.select(["number", "mode", "stageId", "winnerTeamId"])
.where("matchId", "=", matchId)
.orderBy("number", "asc")
.execute();
return rows.map((row) => ({
mode: row.mode,
stage: row.stageId,
order: matchIdx * SET_STAGGER_MINUTES + row.number,
winnerNames: namesByTeamId.get(row.winnerTeamId)!,
loserNames: namesByTeamId.get(
row.winnerTeamId === played.winnerTeamId
? played.loserTeamId
: played.winnerTeamId,
)!,
weaponFor: (name: string) => TOURNAMENT_WEAPONS[names.indexOf(name)]!,
}));
},
scanned: scannedGame,
cast: (matchId: number) =>
TournamentFactory.castMatch({
tournamentId: tournament.id,
matchId,
twitchAccount: "testcaster",
}),
staff: (user: { id: number }) =>
TournamentRepository.setStaff({
tournamentId: tournament.id,
staff: [{ userId: user.id, role: "STREAMER" }],
}),
};
}
/** A user outside any world, optionally with an in-game name set. */
export function createUser(inGameName?: string) {
return UserFactory.create({
profile: inGameName ? { inGameName } : null,
});
}
/**
* Derives a full `ScannerMatch` from a sendou.ink game so mode/stage/rosters
* line up by construction; options spell out a case's deviation. The default
* read is winner-first with the POV on seat 0 of the winning team, played
* "now" (offset by `order` so multi-read requests stay chronological).
*/
export function scannedGame(
game: ScannableGame,
options: ScannedOptions = {},
): ScannerMatch {
const {
seenFrom = "winner",
cast = false,
partial = false,
...overrides
} = options;
const winners = scannedTeam(game.winnerNames, 0, game.weaponFor);
const losers = scannedTeam(game.loserNames, PLAYERS_PER_TEAM, game.weaponFor);
const startsAt = 60 + game.order * 360;
const base: ScannerMatch = {
startsAt,
endsAt: startsAt + 300,
playedAt: Date.now() + game.order * 60_000,
lobby: "PRIVATE",
mode: game.mode,
stage: game.stage,
matchScores: seenFrom === "loser" ? [48, 100] : [100, 48],
replayCode: null,
cast,
objective: null,
playerStatus: null,
teams: seenFrom === "loser" ? [losers, winners] : [winners, losers],
winner: seenFrom === "loser" ? 1 : 0,
pov: cast ? null : { team: 0, index: 0 },
};
if (partial) {
return {
...base,
mode: null,
matchScores: null,
teams: [{ players: [] }, { players: [] }],
winner: null,
pov: null,
...overrides,
};
}
return { ...base, ...overrides };
}
/** A copy of the match with every read player name passed through `rename`. */
export function renamed(
match: ScannerMatch,
rename: (name: string, rowIndex: number) => string,
): ScannerMatch {
return {
...match,
teams: [renamedTeam(match, 0, rename), renamedTeam(match, 1, rename)],
};
}
const ingestAction = wrappedAction<typeof ingestBodySchema>({
action,
isJsonSubmission: true,
});
/** Sends matches through the real ingest action, authenticated as `user`. */
export function ingest(
user: { id: number },
matches: ScannerMatch[],
): Promise<IngestResponse> {
return ingestAction({ matches }, { user: user.id });
}
const qMatchLoaderWrapped = wrappedLoader<SendouQMatchLoaderData>({
loader: qMatchLoader,
});
const tournamentMatchLoaderWrapped = wrappedLoader<TournamentMatchLoaderData>({
loader: tournamentMatchLoader,
});
/** The real `/q/match/:id` loader's data, as an anonymous visitor sees it. */
export function qMatchPage(matchId: number) {
return qMatchLoaderWrapped({ params: { id: String(matchId) } });
}
/** The real `/to/:id/matches/:mid` loader's data, as an anonymous visitor sees it. */
export function tournamentMatchPage(tournamentId: number, matchId: number) {
return tournamentMatchLoaderWrapped({
params: { id: String(tournamentId), mid: String(matchId) },
});
}
export function fetchIngestedMatches() {
return db
.selectFrom("IngestedMatch")
.selectAll()
.orderBy("id", "asc")
.execute();
}
export function fetchLinks() {
return db
.selectFrom("IngestedMatchLink")
.selectAll()
.orderBy("id", "asc")
.execute();
}
export function fetchReportedWeapons() {
return db.selectFrom("ReportedWeapon").selectAll().execute();
}
export function daysAgo(days: number) {
return subDays(new Date(), days);
}
export function hoursLater(date: Date, hours: number) {
return addHours(date, hours);
}
async function createNamedUsers(names: string[]) {
const users: Array<{ id: number }> = [];
for (const name of names) {
users.push(
await UserFactory.create({
profile: { inGameName: `${name}#${DISCRIMINATOR}` },
}),
);
}
return users;
}
function groupMatchMaps(matchId: number) {
return db
.selectFrom("GroupMatchMap")
.selectAll()
.where("matchId", "=", matchId)
.orderBy("index", "asc")
.execute();
}
/** Plays the match out, alpha winning every map, both teams agreeing on the score. */
async function concludeGroupMatch(matchId: number) {
const match = await SQMatchRepository.findById(matchId);
invariant(match, "Match not found");
const winnerId = match.groupAlpha.id;
const reportedByUserId = match.groupAlpha.members[0]!.id;
let reportedCount = 0;
let result = await SQMatchRepository.reportMapWinner({
matchId,
winnerId,
reportedByUserId,
reportedCount,
});
while (result.status === "MAP_REPORTED") {
reportedCount++;
result = await SQMatchRepository.reportMapWinner({
matchId,
winnerId,
reportedByUserId,
reportedCount,
});
}
invariant(
result.status === "MATCH_REPORTED",
`Reporting the deciding map resulted in ${result.status}`,
);
const confirmation = await SQMatchRepository.reportMapWinner({
matchId,
winnerId,
reportedByUserId: match.groupBravo.members[0]!.id,
reportedCount: reportedCount + 1,
});
invariant(
confirmation.status === "MATCH_FINALIZED",
`Confirming the score resulted in ${confirmation.status}`,
);
}
/**
* Backdates match `startedAt`s and game result `createdAt`s into the recent
* past, staggered in play order: everything production stamps within the
* same test second becomes an unambiguous timeline, so "latest match" and
* game order come out the same on every run.
*/
async function staggerTournamentTimeline(
matches: Array<{ id: number }>,
): Promise<void> {
const now = new Date();
for (const [matchIdx, match] of matches.entries()) {
const startedAt = subMinutes(
now,
(matches.length - matchIdx) * SET_STAGGER_MINUTES,
);
await backdate("TournamentMatch", match.id, { startedAt });
const games = await db
.selectFrom("TournamentMatchGameResult")
.select(["id", "number"])
.where("matchId", "=", match.id)
.execute();
for (const game of games) {
await backdate("TournamentMatchGameResult", game.id, {
createdAt: addMinutes(startedAt, game.number),
});
}
}
}
function scannedTeam(
names: string[],
weaponOffset: number,
weaponFor?: (name: string) => MainWeaponId,
) {
return {
players: names.map((name, index) => ({
name,
weaponId: weaponFor?.(name) ?? WEAPONS[weaponOffset + index]!,
paint: 1000 + (weaponOffset + index) * 100,
ka: 20 - (weaponOffset + index),
d: weaponOffset + index,
s: 8 - (weaponOffset + index),
})),
};
}
function renamedTeam(
match: ScannerMatch,
teamIndex: 0 | 1,
rename: (name: string, rowIndex: number) => string,
) {
return {
players: match.teams[teamIndex].players.map((player, playerIndex) => ({
...player,
name:
player.name === null
? null
: rename(player.name, teamIndex * PLAYERS_PER_TEAM + playerIndex),
})),
};
}

View File

@@ -0,0 +1,453 @@
import { describe, expect, test } from "vitest";
import {
ALPHA_NAMES,
BRAVO_NAMES,
createUser,
daysAgo,
fetchIngestedMatches,
fetchLinks,
fetchReportedWeapons,
hoursLater,
ingest,
qMatchPage,
renamed,
scannedGame,
sendouqWorld,
setupScannerGate,
tournamentMatchPage,
tournamentWorld,
WEAPONS,
withScannerDisabled,
} from "./harness";
setupScannerGate();
describe("gating & request filtering", () => {
test("G1 gate closed: scanner disabled and non-privileged user → 403, nothing stored", async () => {
const w = await sendouqWorld();
await withScannerDisabled(async () => {
await expect(
ingest(w.bravoUsers[1]!, [w.scanned(w.maps[0]!)]),
).rejects.toThrow("403");
});
expect(await fetchIngestedMatches()).toHaveLength(0);
});
test("G2 non-private lobby: only X-battle matches in the request → skipped entirely", async () => {
const w = await sendouqWorld();
await w.conclude();
const res = await ingest(w.povUser, [
w.scanned(w.maps[0]!, { lobby: "X" }),
]);
expect(res).toEqual({
storedMatchesCount: 0,
mergedMatchesCount: 0,
linkedGamesCount: 0,
linkedMatches: [],
contextResolved: false,
});
expect(await fetchIngestedMatches()).toHaveLength(0);
});
test("G3 mixed request keeps indices: only the private match is stored and linked", async () => {
const w = await sendouqWorld();
await w.conclude();
const res = await ingest(w.povUser, [
w.scanned(w.maps[1]!, { lobby: "X" }),
w.scanned(w.maps[0]!),
]);
expect(res.storedMatchesCount).toBe(1);
expect(res.linkedMatches).toEqual([
{ matchIndex: 1, link: { type: "sendouq", groupMatchId: w.match.id } },
]);
const rows = await fetchIngestedMatches();
expect(rows).toHaveLength(1);
expect(rows[0]!.data.lobby).toBe("PRIVATE");
});
});
describe("SendouQ flow", () => {
test("Q1 live send, map already reported: stored + linked, page shows the scoreboard", async () => {
const w = await sendouqWorld();
await w.conclude();
const scan = w.scanned(w.maps[0]!);
const res = await ingest(w.povUser, [scan]);
expect(res.storedMatchesCount).toBe(1);
expect(res.linkedGamesCount).toBe(1);
expect(res.linkedMatches).toEqual([
{ matchIndex: 0, link: { type: "sendouq", groupMatchId: w.match.id } },
]);
await ingest(w.povUser, [scan]);
const page = await qMatchPage(w.match.id);
expect(page.ingestedScoreboards).toHaveLength(1);
const scoreboard = page.ingestedScoreboards[0]!;
expect(scoreboard.mapIndex).toBe(0);
expect(scoreboard.data.players.map((p) => p.name)).toEqual([
...ALPHA_NAMES,
...BRAVO_NAMES,
]);
expect(scoreboard.data.players.map((p) => p.weaponSplId)).toEqual(WEAPONS);
expect(scoreboard.data.players[0]).toMatchObject({
paint: 1000,
ka: 20,
d: 0,
s: 8,
});
expect(page.reportedWeapons).toEqual([
{
groupMatchId: w.match.id,
mapIndex: 0,
userId: w.povUser.id,
weaponSplId: WEAPONS[0],
},
]);
});
test("Q2 send before report, resend after: hint first, link on the resend", async () => {
const w = await sendouqWorld();
const scan = w.scanned(w.maps[0]!);
const first = await ingest(w.povUser, [scan]);
expect(first.storedMatchesCount).toBe(1);
expect(first.contextResolved).toBe(true);
expect(first.linkedGamesCount).toBe(0);
expect(first.linkedMatches).toEqual([]);
expect((await fetchIngestedMatches())[0]!.groupMatchIdHint).toBe(
w.match.id,
);
expect((await qMatchPage(w.match.id)).ingestedScoreboards).toHaveLength(0);
await w.conclude();
const resend = await ingest(w.povUser, [scan]);
expect(resend.storedMatchesCount).toBe(0);
expect(resend.linkedGamesCount).toBe(1);
expect(resend.linkedMatches).toEqual([
{ matchIndex: 0, link: { type: "sendouq", groupMatchId: w.match.id } },
]);
expect(await fetchIngestedMatches()).toHaveLength(1);
expect((await qMatchPage(w.match.id)).ingestedScoreboards).toHaveLength(1);
});
test("Q3 whole-scan of a set: each played map links in order, unplayed maps stay bare", async () => {
const w = await sendouqWorld();
const maps = await w.conclude();
const playedMaps = maps.filter((map) => map.winnerGroupId !== null);
expect(playedMaps).toHaveLength(4);
const res = await ingest(
w.povUser,
playedMaps.map((map) => w.scanned(map)),
);
expect(res.linkedGamesCount).toBe(4);
expect(res.linkedMatches.map((linked) => linked.matchIndex)).toEqual([
0, 1, 2, 3,
]);
expect((await fetchLinks()).map((link) => link.groupMatchMapId)).toEqual(
playedMaps.map((map) => map.id),
);
const page = await qMatchPage(w.match.id);
expect(page.ingestedScoreboards.map((sb) => sb.mapIndex)).toEqual([
0, 1, 2, 3,
]);
});
test("Q4 POV on the losing side: flipped teams still link and derive winner-first", async () => {
const w = await sendouqWorld();
await w.conclude();
const res = await ingest(w.bravoUsers[0]!, [
w.scanned(w.maps[0]!, { seenFrom: "loser" }),
]);
expect(res.linkedGamesCount).toBe(1);
const page = await qMatchPage(w.match.id);
const scoreboard = page.ingestedScoreboards[0]!;
expect(scoreboard.data.scores).toEqual([100, 48]);
expect(scoreboard.data.players.map((p) => p.name)).toEqual([
...ALPHA_NAMES,
...BRAVO_NAMES,
]);
expect(scoreboard.data.players.map((p) => p.tournamentTeamId)).toEqual([
...Array(4).fill(w.match.alphaGroup.id),
...Array(4).fill(w.match.bravoGroup.id),
]);
expect(page.reportedWeapons).toEqual([
{
groupMatchId: w.match.id,
mapIndex: 0,
userId: w.bravoUsers[0]!.id,
weaponSplId: WEAPONS[4],
},
]);
});
test("Q5 name normalization: a POV-less read links via case/width/discriminator-insensitive names", async () => {
const w = await sendouqWorld();
await w.conclude();
const scan = renamed(
w.scanned(w.maps[0]!, { pov: null }),
(name) => `${toFullWidth(name.toUpperCase())}#9999`,
);
const res = await ingest(w.povUser, [scan]);
expect(res.linkedGamesCount).toBe(1);
expect(res.linkedMatches).toEqual([
{ matchIndex: 0, link: { type: "sendouq", groupMatchId: w.match.id } },
]);
expect(await fetchReportedWeapons()).toHaveLength(0);
});
test("Q6 POV side contradiction: seating the sender on the wrong side blocks the link", async () => {
const w = await sendouqWorld();
await w.conclude();
// bravo lost every map, yet the read claims the sender's seat on the winning rows
const res = await ingest(w.bravoUsers[0]!, [w.scanned(w.maps[0]!)]);
expect(res.storedMatchesCount).toBe(1);
expect(res.contextResolved).toBe(true);
expect(res.linkedGamesCount).toBe(0);
expect(res.linkedMatches).toEqual([]);
expect((await fetchIngestedMatches())[0]!.groupMatchIdHint).toBe(
w.match.id,
);
expect((await qMatchPage(w.match.id)).ingestedScoreboards).toHaveLength(0);
});
test("Q8 unreliable names: garbage names still link through the sender's POV seat", async () => {
const w = await sendouqWorld();
await w.conclude();
const scan = renamed(
w.scanned(w.maps[0]!),
(_, rowIndex) => `???${rowIndex + 1}`,
);
const res = await ingest(w.povUser, [scan]);
expect(res.linkedGamesCount).toBe(1);
expect(res.linkedMatches).toEqual([
{ matchIndex: 0, link: { type: "sendouq", groupMatchId: w.match.id } },
]);
const page = await qMatchPage(w.match.id);
const scoreboard = page.ingestedScoreboards[0]!;
expect(scoreboard.data.players.map((p) => p.tournamentTeamId)).toEqual([
...Array(4).fill(w.match.alphaGroup.id),
...Array(4).fill(w.match.bravoGroup.id),
]);
expect(scoreboard.data.players[0]!.userId).toBe(w.povUser.id);
expect(page.reportedWeapons).toEqual([
{
groupMatchId: w.match.id,
mapIndex: 0,
userId: w.povUser.id,
weaponSplId: WEAPONS[0],
},
]);
});
test("Q7 content-based fallback: old match resolves from mode+stage history", async () => {
const createdAt = daysAgo(10);
const w = await sendouqWorld({ createdAt });
await w.conclude();
const playedAt = hoursLater(createdAt, 3).getTime();
const res = await ingest(w.povUser, [
w.scanned(w.maps[0]!, { playedAt }),
w.scanned(w.maps[1]!, { playedAt: playedAt + 5 * 60 * 1000 }),
]);
expect(res.contextResolved).toBe(true);
expect(res.linkedGamesCount).toBe(2);
expect(res.linkedMatches).toEqual([
{ matchIndex: 0, link: { type: "sendouq", groupMatchId: w.match.id } },
{ matchIndex: 1, link: { type: "sendouq", groupMatchId: w.match.id } },
]);
const page = await qMatchPage(w.match.id);
expect(page.ingestedScoreboards.map((sb) => sb.mapIndex)).toEqual([0, 1]);
});
});
describe("tournament flow", () => {
test("T1 live send links inside the current set only", async () => {
const w = await tournamentWorld();
const sets = w.matchesOfTeam(w.championTeamId);
const round1 = sets[0]!;
const round2 = sets.at(-1)!;
const [game1] = await w.games(round2.id);
const res = await ingest(w.povUser, [w.scanned(game1!)]);
expect(res.linkedMatches).toEqual([
{
matchIndex: 0,
link: {
type: "tournament",
tournamentId: w.tournamentId,
matchId: round2.id,
},
},
]);
const round2Page = await tournamentMatchPage(w.tournamentId, round2.id);
expect(round2Page.ingestedScoreboards.map((sb) => sb.mapIndex)).toEqual([
0,
]);
const round1Page = await tournamentMatchPage(w.tournamentId, round1.id);
expect(round1Page.ingestedScoreboards).toHaveLength(0);
});
test("T2 VoD scan spanning two sets links each read into its own set", async () => {
const w = await tournamentWorld();
const [set1, set2] = w.matchesOfTeam(w.championTeamId);
const games = [...(await w.games(set1!.id)), ...(await w.games(set2!.id))];
const res = await ingest(
w.povUser,
games.map((game) => w.scanned(game, { playedAt: null })),
);
expect(res.linkedGamesCount).toBe(4);
expect(res.linkedMatches).toEqual(
[set1, set1, set2, set2].map((set, matchIndex) => ({
matchIndex,
link: {
type: "tournament",
tournamentId: w.tournamentId,
matchId: set!.id,
},
})),
);
const set1Page = await tournamentMatchPage(w.tournamentId, set1!.id);
expect(set1Page.ingestedScoreboards.map((sb) => sb.mapIndex)).toEqual([
0, 1,
]);
const set2Page = await tournamentMatchPage(w.tournamentId, set2!.id);
expect(set2Page.ingestedScoreboards.map((sb) => sb.mapIndex)).toEqual([
0, 1,
]);
});
test("T3 partial then fuller resend: the replay read merges into the stored partial and links", async () => {
const w = await tournamentWorld();
const finalMatch = w.matchesOfTeam(w.championTeamId).at(-1)!;
const [game1] = await w.games(finalMatch.id);
const partial = w.scanned(game1!, { partial: true });
const first = await ingest(w.povUser, [partial]);
expect(first.storedMatchesCount).toBe(1);
expect(first.contextResolved).toBe(true);
expect(first.linkedGamesCount).toBe(0);
const full = w.scanned(game1!, { playedAt: partial.playedAt! + 60_000 });
const second = await ingest(w.povUser, [full]);
expect(second.storedMatchesCount).toBe(0);
expect(second.mergedMatchesCount).toBe(1);
expect(second.linkedGamesCount).toBe(1);
expect(await fetchIngestedMatches()).toHaveLength(1);
const page = await tournamentMatchPage(w.tournamentId, finalMatch.id);
expect(page.ingestedScoreboards.map((sb) => sb.mapIndex)).toEqual([0]);
});
test("T4 cast footage by staff links to the casted set without POV weapons", async () => {
const w = await tournamentWorld();
const finalMatch = w.matches.at(-1)!;
await w.cast(finalMatch.id);
const caster = await createUser();
await w.staff(caster);
const games = await w.games(finalMatch.id);
const res = await ingest(
caster,
games.map((game) => w.scanned(game, { cast: true })),
);
expect(res.contextResolved).toBe(true);
expect(res.linkedGamesCount).toBe(2);
expect(res.linkedMatches).toEqual(
games.map((_, matchIndex) => ({
matchIndex,
link: {
type: "tournament",
tournamentId: w.tournamentId,
matchId: finalMatch.id,
},
})),
);
expect(await fetchReportedWeapons()).toHaveLength(0);
const page = await tournamentMatchPage(w.tournamentId, finalMatch.id);
expect(page.ingestedScoreboards.map((sb) => sb.mapIndex)).toEqual([0, 1]);
});
});
describe("response contract & idempotency", () => {
test("R1 no context: a scrim between unknown players is stored without hints or links", async () => {
const user = await createUser("Solo1#1111");
const res = await ingest(user, [
scannedGame({
mode: "SZ",
stage: 1,
order: 0,
winnerNames: ["Win1", "Win2", "Win3", "Win4"],
loserNames: ["Lose1", "Lose2", "Lose3", "Lose4"],
}),
]);
expect(res).toEqual({
storedMatchesCount: 1,
mergedMatchesCount: 0,
linkedGamesCount: 0,
linkedMatches: [],
contextResolved: false,
});
const rows = await fetchIngestedMatches();
expect(rows).toHaveLength(1);
expect(rows[0]!.tournamentIdHint).toBeNull();
expect(rows[0]!.groupMatchIdHint).toBeNull();
});
test("R2 double-send is idempotent but still reports where each match belongs", async () => {
const w = await sendouqWorld();
await w.conclude();
const scan = w.scanned(w.maps[0]!);
await ingest(w.povUser, [scan]);
const resend = await ingest(w.povUser, [scan]);
expect(resend).toEqual({
storedMatchesCount: 0,
mergedMatchesCount: 0,
linkedGamesCount: 0,
linkedMatches: [
{ matchIndex: 0, link: { type: "sendouq", groupMatchId: w.match.id } },
],
contextResolved: true,
});
expect(await fetchLinks()).toHaveLength(1);
const page = await qMatchPage(w.match.id);
expect(page.ingestedScoreboards).toHaveLength(1);
expect(page.reportedWeapons).toHaveLength(1);
});
});
function toFullWidth(name: string) {
return [...name]
.map((character) => {
const codePoint = character.codePointAt(0)!;
return codePoint >= 0x21 && codePoint <= 0x7e
? String.fromCodePoint(codePoint + 0xfee0)
: character;
})
.join("");
}

View File

@@ -16,6 +16,13 @@ import {
} from "~/features/auth/core/user-context.server";
import { logger } from "./logger";
/**
* The user a wrapped action/loader call runs as: one of the pinned seed users,
* or any user's id — scenario tests' users are participants and staff the test
* itself created, not fixed ids.
*/
export type TestUser = "admin" | "regular" | number;
export function arrayContainsSameItems<T>(arr1: T[], arr2: T[]) {
return (
arr1.length === arr2.length && arr1.every((item) => arr2.includes(item))
@@ -63,10 +70,7 @@ export function wrappedAction<T extends z.ZodTypeAny>({
}) {
return async (
args: z.infer<T>,
{
user,
params = {},
}: { user?: "admin" | "regular"; params?: Params<string> } = {},
{ user, params = {} }: { user?: TestUser; params?: Params<string> } = {},
) => {
const body = isJsonSubmission
? JSON.stringify(args)
@@ -127,7 +131,7 @@ export function wrappedLoader<T>({
user,
params = {},
}: {
user?: "admin" | "regular";
user?: TestUser;
params?: Params<string>;
} = {}) => {
const request = new Request("http://app.com/path", {
@@ -182,14 +186,18 @@ export function assertResponseErrored(response: Response, message?: string) {
}
}
async function authHeader(
user?: "admin" | "regular",
): Promise<[string, string][]> {
if (!user) return [];
async function authHeader(user?: TestUser): Promise<[string, string][]> {
if (user === undefined) return [];
const session = await authSessionStorage.getSession();
session.set(SESSION_KEY, user === "admin" ? ADMIN_ID : REGULAR_USER_TEST_ID);
session.set(SESSION_KEY, testUserId(user));
return [["Cookie", await authSessionStorage.commitSession(session)]];
}
function testUserId(user: Exclude<TestUser, undefined>): number {
if (typeof user === "number") return user;
return user === "admin" ? ADMIN_ID : REGULAR_USER_TEST_ID;
}

View File

@@ -14,6 +14,11 @@ export default defineConfig({
"app/features/scanner/tests/*.test.{ts,tsx}",
],
setupFiles: ["./app/test-setup.ts"],
// the scanner-ingest scenario suite exercises the real ingest action,
// whose gate reads Config.scannerEnabled from this variable
env: {
VITE_SCANNER_ENABLED: "true",
},
},
resolve: {
tsconfigPaths: true,