mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-08-23 11:36:19 -05:00
Add ingest test scenarios
This commit is contained in:
@@ -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),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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
|
||||
|
||||
511
app/features/scanner-ingest/tests/harness.ts
Normal file
511
app/features/scanner-ingest/tests/harness.ts
Normal 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),
|
||||
})),
|
||||
};
|
||||
}
|
||||
453
app/features/scanner-ingest/tests/ingest-scenarios.test.ts
Normal file
453
app/features/scanner-ingest/tests/ingest-scenarios.test.ts
Normal 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("");
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user