Files
sendou.ink/app/features/scanner-ingest/ScannerIngestRepository.server.test.ts
2026-08-09 07:07:58 +03:00

372 lines
11 KiB
TypeScript

import { describe, expect, test } from "vitest";
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,
ScannerMatchPlayer,
} from "~/features/scanner/core/scanner-match";
import { FULL_GROUP_SIZE } from "~/features/sendouq/q-constants";
import type { MainWeaponId } from "~/modules/in-game-lists/types";
import * as Matches from "./core/Matches";
import type { IngestableGame } from "./core/Scoreboards";
import * as ScannerIngestRepository from "./ScannerIngestRepository.server";
const NAMES = ["w1", "w2", "w3", "w4", "l1", "l2", "l3", "l4"];
const WEAPONS: MainWeaponId[] = [10, 20, 30, 40, 50, 60, 70, 80];
const PLAYED_AT = Date.UTC(2026, 7, 1, 18, 0, 0);
/** enough teams for the bracket winner to play more than one match */
const TOURNAMENT_TEAM_COUNT = 4;
describe("addOrMergeMatches", () => {
test("inserts a fresh match with hash, hints and playedAt", async () => {
const user = await UserFactory.create();
const { match: groupMatch } = await setupSendouqMatch();
const result = await ScannerIngestRepository.addOrMergeMatches({
povUserId: user.id,
submitterUserId: user.id,
matches: [testMatch()],
context: { type: "sendouq", groupMatchId: groupMatch.id },
});
expect(result.insertedCount).toBe(1);
expect(result.mergedCount).toBe(0);
expect(result.effectiveMatches).toHaveLength(1);
const rows = await fetchIngestedMatches();
expect(rows).toHaveLength(1);
expect(rows[0].id).toBe(result.effectiveMatches[0].id);
expect(rows[0].povUserId).toBe(user.id);
expect(rows[0].submitterUserId).toBe(user.id);
expect(rows[0].playedAt).toBe(Math.floor(PLAYED_AT / 1000));
expect(rows[0].matchHash).toMatch(/^[0-9a-f]{64}$/);
expect(rows[0].groupMatchIdHint).toBe(groupMatch.id);
expect(rows[0].tournamentIdHint).toBeNull();
expect(rows[0].data).toEqual(Matches.canonicalMatch(testMatch()));
});
test("identical resend is a no-op that backfills missing hints", async () => {
const user = await UserFactory.create();
const { match: groupMatch } = await setupSendouqMatch();
const first = await ScannerIngestRepository.addOrMergeMatches({
povUserId: user.id,
submitterUserId: user.id,
matches: [testMatch()],
context: null,
});
expect((await fetchIngestedMatches())[0].groupMatchIdHint).toBeNull();
const second = await ScannerIngestRepository.addOrMergeMatches({
povUserId: user.id,
submitterUserId: user.id,
matches: [testMatch()],
context: { type: "sendouq", groupMatchId: groupMatch.id },
});
expect(second.insertedCount).toBe(0);
expect(second.mergedCount).toBe(0);
expect(second.effectiveMatches[0].id).toBe(first.effectiveMatches[0].id);
const rows = await fetchIngestedMatches();
expect(rows).toHaveLength(1);
expect(rows[0].groupMatchIdHint).toBe(groupMatch.id);
});
test("a fuller re-send of the same game merges into the stored partial", async () => {
const user = await UserFactory.create();
const partial = testMatch({
playedAt: PLAYED_AT + 5 * 60 * 1000,
mode: null,
matchScores: null,
teams: [{ players: [] }, { players: [] }],
winner: null,
});
const first = await ScannerIngestRepository.addOrMergeMatches({
povUserId: user.id,
submitterUserId: user.id,
matches: [partial],
context: null,
});
expect(first.insertedCount).toBe(1);
const storedHash = (await fetchIngestedMatches())[0].matchHash;
const second = await ScannerIngestRepository.addOrMergeMatches({
povUserId: user.id,
submitterUserId: user.id,
matches: [testMatch()],
context: null,
});
expect(second.insertedCount).toBe(0);
expect(second.mergedCount).toBe(1);
expect(second.effectiveMatches[0].id).toBe(first.effectiveMatches[0].id);
expect(second.effectiveMatches[0].data.mode).toBe("SZ");
const rows = await fetchIngestedMatches();
expect(rows).toHaveLength(1);
expect(rows[0].data.mode).toBe("SZ");
expect(rows[0].data.winner).toBe(0);
expect(rows[0].data.teams[0].players.map((p) => p.name)).toEqual(
NAMES.slice(0, 4),
);
expect(rows[0].playedAt).toBe(Math.floor(partial.playedAt! / 1000));
expect(rows[0].matchHash).not.toBe(storedHash);
});
});
describe("addLinks", () => {
test("creates link rows for group match maps", async () => {
const user = await UserFactory.create();
const { maps } = await setupSendouqMatch();
const { effectiveMatches } =
await ScannerIngestRepository.addOrMergeMatches({
povUserId: null,
submitterUserId: user.id,
matches: [
testMatch(),
testMatch({ playedAt: PLAYED_AT + 60 * 60 * 1000, stage: 1 }),
],
context: null,
});
const linkedCount = await ScannerIngestRepository.addLinks({
links: effectiveMatches.map((effective, i) => ({
ingestedMatchId: effective.id,
match: effective.data,
game: sendouqGame(maps[i]),
})),
povUserId: null,
});
expect(linkedCount).toBe(2);
const links = await fetchLinks();
expect(links).toHaveLength(2);
expect(links.map((link) => link.ingestedMatchId)).toEqual(
effectiveMatches.map((effective) => effective.id),
);
expect(links.map((link) => link.groupMatchMapId)).toEqual(
maps.slice(0, 2).map((map) => map.id),
);
expect(
links.every((link) => link.tournamentMatchGameResultId === null),
).toBe(true);
expect(await fetchReportedWeapons()).toHaveLength(0);
});
test("re-sends are no-ops and only newly created links are counted", async () => {
const user = await UserFactory.create();
const { maps } = await setupSendouqMatch();
const { effectiveMatches } =
await ScannerIngestRepository.addOrMergeMatches({
povUserId: null,
submitterUserId: user.id,
matches: [
testMatch(),
testMatch({ playedAt: PLAYED_AT + 60 * 60 * 1000, stage: 1 }),
],
context: null,
});
const links = effectiveMatches.map((effective, i) => ({
ingestedMatchId: effective.id,
match: effective.data,
game: sendouqGame(maps[i]),
}));
await ScannerIngestRepository.addLinks({
links: [links[0]],
povUserId: null,
});
const secondCount = await ScannerIngestRepository.addLinks({
links,
povUserId: null,
});
expect(secondCount).toBe(1);
expect(await fetchLinks()).toHaveLength(2);
});
test("reports the POV player's weapon once", async () => {
const povUser = await UserFactory.create();
const { match: groupMatch, maps } = await setupSendouqMatch();
const { effectiveMatches } =
await ScannerIngestRepository.addOrMergeMatches({
povUserId: povUser.id,
submitterUserId: povUser.id,
matches: [testMatch({ pov: { team: 0, index: 0 } })],
context: null,
});
const links = [
{
ingestedMatchId: effectiveMatches[0].id,
match: effectiveMatches[0].data,
game: sendouqGame(maps[0]),
},
];
await ScannerIngestRepository.addLinks({ links, povUserId: povUser.id });
await ScannerIngestRepository.addLinks({ links, povUserId: povUser.id });
const reportedWeapons = await fetchReportedWeapons();
expect(reportedWeapons).toHaveLength(1);
expect(reportedWeapons[0].groupMatchId).toBe(groupMatch.id);
expect(reportedWeapons[0].tournamentMatchId).toBeNull();
expect(reportedWeapons[0].mapIndex).toBe(maps[0].index);
expect(reportedWeapons[0].userId).toBe(povUser.id);
expect(reportedWeapons[0].weaponSplId).toBe(WEAPONS[0]);
});
});
describe("gamesInTournamentMatch", () => {
test("returns the match's own games only, leaving the rest of the tournament out", async () => {
const users = await UserFactory.createMany(TOURNAMENT_TEAM_COUNT);
const tournament = await TournamentFactory.createPlayed(
{ authorId: users[0]!.id, minMembersPerTeam: 1 },
{
teamRosters: users.map((user) => [user.id]),
playedOut: 0,
},
);
// the bracket winner plays every round on the same map list, so its
// earlier round's games are the ones a live send could wrongly take
const [firstMatch, ...laterMatches] = tournament.matches;
const winnerUserId = users.find(
(user) =>
tournament.teams.find((team) => team.id === firstMatch!.winnerTeamId)
?.memberUserIds[0] === user.id,
)!.id;
const games = await ScannerIngestRepository.gamesInTournamentMatch(
firstMatch!.id,
);
expect(games.length).toBeGreaterThan(0);
expect(
games.every(
(game) =>
game.target.type === "tournament" &&
game.target.tournamentMatchId === firstMatch!.id,
),
).toBe(true);
// the tournament-wide list is what the walk would otherwise see
const allGames =
await ScannerIngestRepository.gamesPlayedByUserInTournament({
userId: winnerUserId,
tournamentId: tournament.id,
});
expect(allGames.length).toBeGreaterThan(games.length);
expect(
allGames.some(
(game) =>
game.target.type === "tournament" &&
laterMatches.some(
(match) =>
game.target.type === "tournament" &&
game.target.tournamentMatchId === match.id,
),
),
).toBe(true);
});
});
function player(name: string, weaponId: MainWeaponId): ScannerMatchPlayer {
return {
name,
weaponId,
paint: 1000,
ka: 10,
d: 5,
s: 2,
};
}
function testMatch(partial: Partial<ScannerMatch> = {}): ScannerMatch {
return {
startsAt: 100,
endsAt: 400,
playedAt: PLAYED_AT,
lobby: "PRIVATE",
mode: "SZ",
stage: 0,
matchScores: [100, 52],
replayCode: null,
cast: false,
objective: null,
playerStatus: null,
teams: [
{ players: NAMES.slice(0, 4).map((n, i) => player(n, WEAPONS[i]!)) },
{ players: NAMES.slice(4).map((n, i) => player(n, WEAPONS[4 + i]!)) },
],
winner: 0,
pov: null,
...partial,
};
}
async function setupSendouqMatch() {
const users = await UserFactory.createMany(FULL_GROUP_SIZE * 2);
const match = await SQMatchFactory.create({
alphaUserIds: users.slice(0, FULL_GROUP_SIZE).map((user) => user.id),
bravoUserIds: users.slice(FULL_GROUP_SIZE).map((user) => user.id),
});
const maps = await db
.selectFrom("GroupMatchMap")
.selectAll()
.where("matchId", "=", match.id)
.orderBy("index", "asc")
.execute();
return { match, maps };
}
function fetchIngestedMatches() {
return db
.selectFrom("IngestedMatch")
.selectAll()
.orderBy("id", "asc")
.execute();
}
function fetchLinks() {
return db
.selectFrom("IngestedMatchLink")
.selectAll()
.orderBy("id", "asc")
.execute();
}
function fetchReportedWeapons() {
return db.selectFrom("ReportedWeapon").selectAll().execute();
}
function sendouqGame(map: {
id: number;
matchId: number;
index: number;
mode: IngestableGame["mode"];
stageId: IngestableGame["stageId"];
}): IngestableGame {
return {
target: {
type: "sendouq",
groupMatchMapId: map.id,
groupMatchId: map.matchId,
},
mapIndex: map.index,
mode: map.mode,
stageId: map.stageId,
winnerInGameNames: [],
loserInGameNames: [],
playedAt: Math.floor(PLAYED_AT / 1000),
linkedPlayerNames: null,
};
}