mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-08-27 05:36:28 -05:00
Fixes
This commit is contained in:
@@ -30,6 +30,7 @@ import { Line } from "react-chartjs-2";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useThemeColors } from "~/hooks/useThemeColors";
|
||||
import styles from "./ObjectiveTimeline.module.css";
|
||||
import { smoothPenalties } from "./objective-timeline-utils";
|
||||
|
||||
ChartJS.register(
|
||||
LinearScale,
|
||||
@@ -40,7 +41,6 @@ ChartJS.register(
|
||||
Legend,
|
||||
);
|
||||
|
||||
const PENALTY_BRIDGE_SECONDS = 6;
|
||||
/** count-axis units of gutter kept below zero for the control lane */
|
||||
const CONTROL_LANE_DEPTH = 13;
|
||||
const CONTROL_LANE_Y = -6;
|
||||
@@ -123,7 +123,12 @@ export function ObjectiveTimeline({
|
||||
}));
|
||||
// band between score and score + penalty; its thickness is the penalty
|
||||
const penaltyDatasets = ([0, 1] as const).map((side) => {
|
||||
const penalties = smoothPenalties(sorted, side);
|
||||
const penalties = smoothPenalties(
|
||||
sorted.map((event) => ({
|
||||
t: event.t,
|
||||
penalty: event.data.penalty[side],
|
||||
})),
|
||||
);
|
||||
let lastScore: number | null = null;
|
||||
return {
|
||||
label: `${teamLabels[side]} penalty`,
|
||||
@@ -261,67 +266,6 @@ function gridColor(
|
||||
return value === 0 ? colors.borderHigh : colors.border;
|
||||
}
|
||||
|
||||
/**
|
||||
* The penalty pill is misread for a frame or two at a time: it flickers
|
||||
* between a value and null, and occasionally drops a digit ("36" read as
|
||||
* "6"). Median-filters isolated outlier values, drops one-off reads with no
|
||||
* nearby confirmation and carries the previous value across short null gaps
|
||||
* so the band renders as one steady shape instead of a picket fence.
|
||||
*/
|
||||
function smoothPenalties(
|
||||
sorted: readonly ObjectiveTimelineEvent[],
|
||||
side: 0 | 1,
|
||||
): (number | null)[] {
|
||||
const medianFiltered = medianFilterValues(
|
||||
sorted.map((event) => event.data.penalty[side]),
|
||||
);
|
||||
const kept = sorted.map((event, i) => {
|
||||
const value = medianFiltered[i]!;
|
||||
if (value === null) return null;
|
||||
const hasNearbyRead = sorted.some(
|
||||
(other, j) =>
|
||||
j !== i &&
|
||||
other.data.penalty[side] !== null &&
|
||||
Math.abs(other.t - event.t) <= PENALTY_BRIDGE_SECONDS,
|
||||
);
|
||||
return hasNearbyRead ? value : null;
|
||||
});
|
||||
|
||||
const result = [...kept];
|
||||
let prev = -1;
|
||||
for (let i = 0; i < result.length; i++) {
|
||||
if (result[i] !== null) {
|
||||
prev = i;
|
||||
continue;
|
||||
}
|
||||
if (prev === -1) continue;
|
||||
const next = result.findIndex((value, j) => j > i && value !== null);
|
||||
if (next === -1) continue;
|
||||
if (sorted[next]!.t - sorted[prev]!.t <= PENALTY_BRIDGE_SECONDS) {
|
||||
result[i] = result[prev];
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function medianFilterValues(
|
||||
values: readonly (number | null)[],
|
||||
): (number | null)[] {
|
||||
const nonNullIndexes = values.flatMap((value, i) =>
|
||||
value !== null ? [i] : [],
|
||||
);
|
||||
const result = [...values];
|
||||
for (let k = 1; k < nonNullIndexes.length - 1; k++) {
|
||||
const window = [
|
||||
values[nonNullIndexes[k - 1]!]!,
|
||||
values[nonNullIndexes[k]!]!,
|
||||
values[nonNullIndexes[k + 1]!]!,
|
||||
].sort((a, b) => a - b);
|
||||
result[nonNullIndexes[k]!] = window[1]!;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Position on the x-axis: m:ss, growing an hours part only when needed. */
|
||||
function formatElapsed(seconds: number): string {
|
||||
const hours = Math.floor(seconds / 3600);
|
||||
|
||||
@@ -43,8 +43,8 @@ import { WeaponPool } from "./WeaponPool";
|
||||
const LONG_TEAM_NAME_THRESHOLD = 16;
|
||||
|
||||
// xxx: make actual in-game score
|
||||
/** In-game team scores run 0-500p; a knockout shows as 500p for the winner. */
|
||||
const SCOREBOARD_KO_SCORE = 500;
|
||||
/** Ingested team scores run 0-100; a knockout shows as 100 for the winner. */
|
||||
const SCOREBOARD_KO_SCORE = 100;
|
||||
|
||||
const ABILITY_NAMES: ReadonlySet<string> = new Set(
|
||||
abilities.map((ability) => ability.name),
|
||||
@@ -87,7 +87,7 @@ export interface TimelineMap {
|
||||
pickedBy?: MatchSide;
|
||||
/** Ingested end-of-game scoreboard rendered as an expandable stats section below the map row. */
|
||||
scoreboard?: {
|
||||
/** [alpha, bravo] on the in-game 0-500p scale (500 = knockout) */
|
||||
/** [alpha, bravo] on the ingested 0-100 scale (100 = knockout) */
|
||||
scores: [number | null, number | null];
|
||||
alpha: TimelineScoreboardPlayer[];
|
||||
bravo: TimelineScoreboardPlayer[];
|
||||
@@ -310,7 +310,7 @@ function SideResult({
|
||||
}: {
|
||||
result: "WIN" | "LOSS";
|
||||
isKo?: boolean;
|
||||
/** in-game 0-500p team score from an ingested scoreboard (500 = knockout) */
|
||||
/** ingested 0-100 team score (100 = knockout) */
|
||||
scoreboardScore?: number | null;
|
||||
weapons?: WeaponPoolWeapon[];
|
||||
isPicked?: boolean;
|
||||
|
||||
52
app/components/objective-timeline-utils.test.ts
Normal file
52
app/components/objective-timeline-utils.test.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { type PenaltyRead, smoothPenalties } from "./objective-timeline-utils";
|
||||
|
||||
function reads(...pairs: Array<[t: number, penalty: number | null]>) {
|
||||
return pairs.map(([t, penalty]): PenaltyRead => ({ t, penalty }));
|
||||
}
|
||||
|
||||
describe("smoothPenalties", () => {
|
||||
it("passes steady reads through", () => {
|
||||
expect(smoothPenalties(reads([0, 10], [2, 10], [4, 10]))).toEqual([
|
||||
10, 10, 10,
|
||||
]);
|
||||
});
|
||||
|
||||
it("median-filters an isolated dropped-digit misread", () => {
|
||||
expect(smoothPenalties(reads([0, 36], [2, 6], [4, 36]))).toEqual([
|
||||
36, 36, 36,
|
||||
]);
|
||||
});
|
||||
|
||||
it("bridges a short null gap with the previous value", () => {
|
||||
expect(smoothPenalties(reads([0, 12], [2, null], [4, 12]))).toEqual([
|
||||
12, 12, 12,
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not bridge a gap longer than the bridge window", () => {
|
||||
expect(
|
||||
smoothPenalties(reads([0, 12], [1, 12], [20, null], [40, 8], [41, 8])),
|
||||
).toEqual([12, 12, null, 8, 8]);
|
||||
});
|
||||
|
||||
it("drops one-off reads with no nearby confirmation", () => {
|
||||
expect(smoothPenalties(reads([0, 5], [30, 12], [60, 7]))).toEqual([
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not extend past the last read", () => {
|
||||
expect(smoothPenalties(reads([0, 10], [2, 10], [4, null]))).toEqual([
|
||||
10,
|
||||
10,
|
||||
null,
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps all-null reads null", () => {
|
||||
expect(smoothPenalties(reads([0, null], [2, null]))).toEqual([null, null]);
|
||||
});
|
||||
});
|
||||
69
app/components/objective-timeline-utils.ts
Normal file
69
app/components/objective-timeline-utils.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
const PENALTY_BRIDGE_SECONDS = 6;
|
||||
|
||||
/** One penalty read: when it was made and the pill value seen (null = no pill or unreadable). */
|
||||
export interface PenaltyRead {
|
||||
/** whole seconds into the source (video, stream or game) the read was made at */
|
||||
t: number;
|
||||
penalty: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The penalty pill is misread for a frame or two at a time: it flickers
|
||||
* between a value and null, and occasionally drops a digit ("36" read as
|
||||
* "6"). Median-filters isolated outlier values, drops one-off reads with no
|
||||
* nearby confirmation and carries the previous value across short null gaps
|
||||
* so the band renders as one steady shape instead of a picket fence.
|
||||
*
|
||||
* @param reads one team's penalty reads, sorted by `t` ascending
|
||||
* @returns the smoothed penalty per read, index-aligned with the input
|
||||
*/
|
||||
export function smoothPenalties(
|
||||
reads: readonly PenaltyRead[],
|
||||
): (number | null)[] {
|
||||
const medianFiltered = medianFilterValues(reads.map((read) => read.penalty));
|
||||
const kept = reads.map((read, i) => {
|
||||
const value = medianFiltered[i]!;
|
||||
if (value === null) return null;
|
||||
const hasNearbyRead = reads.some(
|
||||
(other, j) =>
|
||||
j !== i &&
|
||||
other.penalty !== null &&
|
||||
Math.abs(other.t - read.t) <= PENALTY_BRIDGE_SECONDS,
|
||||
);
|
||||
return hasNearbyRead ? value : null;
|
||||
});
|
||||
|
||||
const result = [...kept];
|
||||
let prev = -1;
|
||||
for (let i = 0; i < result.length; i++) {
|
||||
if (result[i] !== null) {
|
||||
prev = i;
|
||||
continue;
|
||||
}
|
||||
if (prev === -1) continue;
|
||||
const next = result.findIndex((value, j) => j > i && value !== null);
|
||||
if (next === -1) continue;
|
||||
if (reads[next]!.t - reads[prev]!.t <= PENALTY_BRIDGE_SECONDS) {
|
||||
result[i] = result[prev];
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function medianFilterValues(
|
||||
values: readonly (number | null)[],
|
||||
): (number | null)[] {
|
||||
const nonNullIndexes = values.flatMap((value, i) =>
|
||||
value !== null ? [i] : [],
|
||||
);
|
||||
const result = [...values];
|
||||
for (let k = 1; k < nonNullIndexes.length - 1; k++) {
|
||||
const window = [
|
||||
values[nonNullIndexes[k - 1]!]!,
|
||||
values[nonNullIndexes[k]!]!,
|
||||
values[nonNullIndexes[k + 1]!]!,
|
||||
].sort((a, b) => a - b);
|
||||
result[nonNullIndexes[k]!] = window[1]!;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -699,7 +699,7 @@ export default function MatchPageTestRoute() {
|
||||
},
|
||||
scoreboard: {
|
||||
objective: MOCK_OBJECTIVE_EVENTS,
|
||||
scores: [500, 0],
|
||||
scores: [100, 0],
|
||||
alpha: [
|
||||
{
|
||||
name: "Sendou",
|
||||
|
||||
@@ -0,0 +1,314 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import * as SQMatchFactory from "~/db/seed/factories/SQMatchFactory";
|
||||
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);
|
||||
|
||||
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]);
|
||||
});
|
||||
});
|
||||
|
||||
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,
|
||||
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,
|
||||
};
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -3,10 +3,10 @@ import type { ActionFunction } from "react-router";
|
||||
import { Config } from "~/config";
|
||||
import { requireUser } from "~/features/auth/core/user.server";
|
||||
import type { ScannerMatch } from "~/features/scanner/core/scanner-match";
|
||||
import * as UserRepository from "~/features/user-page/UserRepository.server";
|
||||
import { isAdmin } from "~/modules/permissions/utils";
|
||||
import { dateToDatabaseTimestamp } from "~/utils/dates";
|
||||
import { logger } from "~/utils/logger";
|
||||
import { badRequestIfFalsy, forbidden, parseBody } from "~/utils/remix.server";
|
||||
import { forbidden, parseBody } from "~/utils/remix.server";
|
||||
import * as Scoreboards from "../core/Scoreboards";
|
||||
import * as ScannerIngestRepository from "../ScannerIngestRepository.server";
|
||||
import {
|
||||
@@ -28,11 +28,7 @@ export const action: ActionFunction = async ({ request }) => {
|
||||
|
||||
const data = await parseBody({ request, schema: ingestBodySchema });
|
||||
|
||||
const povUserId = data.povUserId ?? user?.id ?? null;
|
||||
|
||||
if (povUserId) {
|
||||
badRequestIfFalsy(await UserRepository.findLeanById(povUserId));
|
||||
}
|
||||
const povUserId = user.id;
|
||||
|
||||
const indexedMatches = data.matches
|
||||
.map((match, requestIndex) => ({ match, requestIndex }))
|
||||
@@ -50,13 +46,13 @@ export const action: ActionFunction = async ({ request }) => {
|
||||
const resolved = await resolveIngestContext({
|
||||
matches,
|
||||
povUserId,
|
||||
casterUserId: user?.id ?? null,
|
||||
casterUserId: user.id,
|
||||
});
|
||||
|
||||
const { insertedCount, mergedCount, effectiveMatches } =
|
||||
await ScannerIngestRepository.addOrMergeMatches({
|
||||
povUserId,
|
||||
submitterUserId: user?.id ?? null,
|
||||
submitterUserId: user.id,
|
||||
matches,
|
||||
context: resolved?.context ?? null,
|
||||
});
|
||||
@@ -238,8 +234,8 @@ async function resolveIngestContext({
|
||||
}
|
||||
|
||||
if (povUserId && hasPovMatches && countAttachableMatches(matches) >= 2) {
|
||||
const since = Math.floor(
|
||||
subDays(new Date(), CONTENT_RESOLUTION_WINDOW_DAYS).getTime() / 1000,
|
||||
const since = dateToDatabaseTimestamp(
|
||||
subDays(new Date(), CONTENT_RESOLUTION_WINDOW_DAYS),
|
||||
);
|
||||
const games = [
|
||||
...(await ScannerIngestRepository.gamesPlayedByUserSince({
|
||||
|
||||
@@ -325,22 +325,22 @@ function mergeTeam(
|
||||
return hit.player;
|
||||
},
|
||||
);
|
||||
existing.players.forEach((player, i) => {
|
||||
if (counterparts[i] || player.weaponId === null) return;
|
||||
for (const [i, player] of existing.players.entries()) {
|
||||
if (counterparts[i] || player.weaponId === null) continue;
|
||||
const hits = pool.filter(
|
||||
(entry) => !entry.used && entry.player.weaponId === player.weaponId,
|
||||
);
|
||||
if (hits.length !== 1) return;
|
||||
if (hits.length !== 1) continue;
|
||||
hits[0]!.used = true;
|
||||
counterparts[i] = hits[0]!.player;
|
||||
});
|
||||
existing.players.forEach((_, i) => {
|
||||
if (counterparts[i]) return;
|
||||
}
|
||||
for (const i of existing.players.keys()) {
|
||||
if (counterparts[i]) continue;
|
||||
const hit = pool[i]?.used === false ? pool[i]! : pool.find((e) => !e.used);
|
||||
if (!hit) return;
|
||||
if (!hit) continue;
|
||||
hit.used = true;
|
||||
counterparts[i] = hit.player;
|
||||
});
|
||||
}
|
||||
|
||||
const players = existing.players.map((player, i) => {
|
||||
const counterpart = counterparts[i];
|
||||
|
||||
@@ -279,6 +279,25 @@ describe("matchedGames", () => {
|
||||
expect(tournamentMatchIdOf(matched[0]!)).toBe(1);
|
||||
});
|
||||
|
||||
it("skips a duplicate detection despite a couple of OCR-misread names", () => {
|
||||
const matched = Scoreboards.matchedGames({
|
||||
matches: [
|
||||
testMatch({ t: 60 }),
|
||||
testMatch({
|
||||
t: 65,
|
||||
names: ["w1", "vv2", "w3", "w4", "l1", "l2", "l3", "I4"],
|
||||
}),
|
||||
],
|
||||
games: [
|
||||
testGame({ tournamentMatchId: 1, playedAt: 1000 }),
|
||||
testGame({ tournamentMatchId: 2, playedAt: 2000 }),
|
||||
],
|
||||
});
|
||||
|
||||
expect(matched).toHaveLength(1);
|
||||
expect(tournamentMatchIdOf(matched[0]!)).toBe(1);
|
||||
});
|
||||
|
||||
it("skips matches from other lobbies", () => {
|
||||
const matched = Scoreboards.matchedGames({
|
||||
matches: [testMatch({ lobby: "X" })],
|
||||
@@ -550,6 +569,21 @@ describe("deriveScoreboardData", () => {
|
||||
expect(data!.players[2]!.userId).toBe(42);
|
||||
});
|
||||
|
||||
it("does not attribute a POV whose read name contradicts its seat's merged row", () => {
|
||||
const data = derive([
|
||||
{ data: testMatch(), povUserId: null },
|
||||
{
|
||||
data: testMatch({
|
||||
povIndex: 2,
|
||||
names: ["w1", "w2", "x9", "w4", "l1", "l2", "l3", "l4"],
|
||||
}),
|
||||
povUserId: 42,
|
||||
},
|
||||
]);
|
||||
|
||||
expect(data!.players.some((p) => p.userId === 42)).toBe(false);
|
||||
});
|
||||
|
||||
it("merges a later partial's fields under the first link's values", () => {
|
||||
const withoutScores: ScannerMatch = {
|
||||
...testMatch(),
|
||||
|
||||
@@ -431,7 +431,9 @@ function attributionIndex(
|
||||
|
||||
/**
|
||||
* Drops re-detections of the same game within one request: same mode and
|
||||
* stage with every player row carrying the same name.
|
||||
* stage with enough player rows carrying the same readable name in the same
|
||||
* position — the same OCR-jitter tolerance as the cross-request duplicate
|
||||
* check (isLinkedDuplicate).
|
||||
*/
|
||||
function dedupeViews(sorted: IndexedView[]): IndexedView[] {
|
||||
const result: IndexedView[] = [];
|
||||
@@ -441,8 +443,9 @@ function dedupeViews(sorted: IndexedView[]): IndexedView[] {
|
||||
(other) =>
|
||||
other.mode === view.mode &&
|
||||
other.stage === view.stage &&
|
||||
other.players.every(
|
||||
(player, i) => player.name === view.players[i]!.name,
|
||||
isLinkedDuplicate(
|
||||
view,
|
||||
other.players.map((player) => player.name),
|
||||
),
|
||||
);
|
||||
if (!isDuplicate) result.push(view);
|
||||
|
||||
@@ -1,17 +1,15 @@
|
||||
import { z } from "zod";
|
||||
import { scannerMatchSchema } from "~/features/scanner/scanner-schemas";
|
||||
import { id } from "~/utils/zod";
|
||||
|
||||
const MAX_MATCHES_PER_REQUEST = 50;
|
||||
|
||||
/**
|
||||
* The ScannerMatch shape comes from the producer
|
||||
* (~/features/scanner/scanner-schemas — the single source of truth for the
|
||||
* scanner domain); this module only adds the ingest-specific envelope.
|
||||
* scanner domain); this module only adds the ingest-specific envelope. The
|
||||
* POV user is always the session user, never client-supplied.
|
||||
*/
|
||||
export const ingestBodySchema = z.object({
|
||||
/** the user whose point of view the matches were detected from */
|
||||
povUserId: id.optional(),
|
||||
matches: z.array(scannerMatchSchema).min(1).max(MAX_MATCHES_PER_REQUEST),
|
||||
});
|
||||
|
||||
|
||||
@@ -14,11 +14,11 @@ import type { IngestedScoreboardData } from "~/features/scanner-ingest/core/Scor
|
||||
import { useTournament } from "~/features/tournament/routes/to.$id";
|
||||
import * as PickBan from "~/features/tournament-bracket/core/PickBan";
|
||||
import { tournamentTeamToActiveRosterUserIds } from "~/features/tournament-bracket/tournament-bracket-utils";
|
||||
import type { MainWeaponId } from "~/modules/in-game-lists/types";
|
||||
import { databaseTimestampToJavascriptTimestamp } from "~/utils/dates";
|
||||
import { tournamentTeamPage } from "~/utils/urls";
|
||||
import type { TournamentMatchLoaderData } from "../loaders/to.$id.matches.$mid.server";
|
||||
import { type MatchPageTeam, useMatch } from "../match-page-context";
|
||||
import { resolveTimelineWeapons } from "../tournament-match-utils";
|
||||
import { TournamentMatchActionPickBanTab } from "./TournamentMatchActionPickBanTab";
|
||||
import { TournamentMatchActionTab } from "./TournamentMatchActionTab";
|
||||
import { TournamentMatchAdminTab } from "./TournamentMatchAdminTab";
|
||||
@@ -176,50 +176,12 @@ function resolveTimelineMaps(
|
||||
const weaponsFor = (
|
||||
roster: ReturnType<typeof resolveRoster>,
|
||||
tournamentTeamId: number,
|
||||
): WeaponPoolWeapon[] => {
|
||||
const linkedWeapons = roster.map((u) => weaponFor(u.id));
|
||||
|
||||
// an ingested row without a user is only unaccounted for if no roster
|
||||
// member already reported its weapon, otherwise it is that member's row
|
||||
// and reusing it would show their weapon twice
|
||||
const accountedForCounts = new Map<MainWeaponId, number>();
|
||||
for (const weapon of linkedWeapons) {
|
||||
if (weapon === null) continue;
|
||||
accountedForCounts.set(
|
||||
weapon,
|
||||
(accountedForCounts.get(weapon) ?? 0) + 1,
|
||||
);
|
||||
}
|
||||
|
||||
const unlinkedIngested =
|
||||
ingestedScoreboard?.data.players.flatMap((player) => {
|
||||
if (
|
||||
player.userId !== undefined ||
|
||||
player.weaponSplId === null ||
|
||||
player.tournamentTeamId !== tournamentTeamId
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const accountedFor = accountedForCounts.get(player.weaponSplId) ?? 0;
|
||||
if (accountedFor > 0) {
|
||||
accountedForCounts.set(player.weaponSplId, accountedFor - 1);
|
||||
return [];
|
||||
}
|
||||
|
||||
return [player.weaponSplId];
|
||||
}) ?? [];
|
||||
|
||||
let unlinkedIdx = 0;
|
||||
return linkedWeapons.map((linked) => {
|
||||
if (linked !== null) return linked;
|
||||
|
||||
const ingested = unlinkedIngested[unlinkedIdx++];
|
||||
return ingested !== undefined
|
||||
? { weaponSplId: ingested, unverified: true }
|
||||
: null;
|
||||
): WeaponPoolWeapon[] =>
|
||||
resolveTimelineWeapons({
|
||||
linkedWeapons: roster.map((u) => weaponFor(u.id)),
|
||||
ingestedPlayers: ingestedScoreboard?.data.players ?? [],
|
||||
tournamentTeamId,
|
||||
});
|
||||
};
|
||||
|
||||
const alphaWeapons = weaponsFor(alphaRoster, opponentOneId);
|
||||
const bravoWeapons = weaponsFor(bravoRoster, opponentTwoId);
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { mapCountPlayedInSetWithCertainty } from "./tournament-match-utils";
|
||||
import { describe, expect, it, test } from "vitest";
|
||||
import type { IngestedScoreboardPlayer } from "~/features/scanner-ingest/core/Scoreboards";
|
||||
import type { MainWeaponId } from "~/modules/in-game-lists/types";
|
||||
import {
|
||||
mapCountPlayedInSetWithCertainty,
|
||||
resolveTimelineWeapons,
|
||||
} from "./tournament-match-utils";
|
||||
|
||||
const mapCountParamsToResult: {
|
||||
bestOf: number;
|
||||
@@ -26,3 +31,91 @@ describe("mapCountPlayedInSetWithCertainty()", () => {
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const TEAM_ID = 1;
|
||||
const OTHER_TEAM_ID = 2;
|
||||
|
||||
function ingestedPlayer(
|
||||
partial: Partial<IngestedScoreboardPlayer>,
|
||||
): IngestedScoreboardPlayer {
|
||||
return {
|
||||
name: "player",
|
||||
tournamentTeamId: TEAM_ID,
|
||||
weaponSplId: 10 as MainWeaponId,
|
||||
ka: 10,
|
||||
d: 5,
|
||||
s: 2,
|
||||
paint: 1000,
|
||||
...partial,
|
||||
};
|
||||
}
|
||||
|
||||
describe("resolveTimelineWeapons()", () => {
|
||||
it("passes reported weapons through and leaves gaps null without ingested rows", () => {
|
||||
expect(
|
||||
resolveTimelineWeapons({
|
||||
linkedWeapons: [10, null, 20, null],
|
||||
ingestedPlayers: [],
|
||||
tournamentTeamId: TEAM_ID,
|
||||
}),
|
||||
).toEqual([10, null, 20, null]);
|
||||
});
|
||||
|
||||
it("fills gaps from unaccounted ingested rows, marked unverified", () => {
|
||||
expect(
|
||||
resolveTimelineWeapons({
|
||||
linkedWeapons: [10, null, null, null],
|
||||
ingestedPlayers: [
|
||||
ingestedPlayer({ weaponSplId: 30 }),
|
||||
ingestedPlayer({ weaponSplId: 40 }),
|
||||
],
|
||||
tournamentTeamId: TEAM_ID,
|
||||
}),
|
||||
).toEqual([
|
||||
10,
|
||||
{ weaponSplId: 30, unverified: true },
|
||||
{ weaponSplId: 40, unverified: true },
|
||||
null,
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not reuse an ingested row whose weapon a roster member already reported", () => {
|
||||
expect(
|
||||
resolveTimelineWeapons({
|
||||
linkedWeapons: [10, null, null, null],
|
||||
ingestedPlayers: [ingestedPlayer({ weaponSplId: 10 })],
|
||||
tournamentTeamId: TEAM_ID,
|
||||
}),
|
||||
).toEqual([10, null, null, null]);
|
||||
});
|
||||
|
||||
it("keeps the extra ingested row of a weapon two players ran when only one reported it", () => {
|
||||
expect(
|
||||
resolveTimelineWeapons({
|
||||
linkedWeapons: [10, null, null, null],
|
||||
ingestedPlayers: [
|
||||
ingestedPlayer({ weaponSplId: 10 }),
|
||||
ingestedPlayer({ weaponSplId: 10 }),
|
||||
],
|
||||
tournamentTeamId: TEAM_ID,
|
||||
}),
|
||||
).toEqual([10, { weaponSplId: 10, unverified: true }, null, null]);
|
||||
});
|
||||
|
||||
it("skips ingested rows already attributed to a user, from the other team or without a weapon", () => {
|
||||
expect(
|
||||
resolveTimelineWeapons({
|
||||
linkedWeapons: [null, null, null, null],
|
||||
ingestedPlayers: [
|
||||
ingestedPlayer({ weaponSplId: 30, userId: 42 }),
|
||||
ingestedPlayer({
|
||||
weaponSplId: 40,
|
||||
tournamentTeamId: OTHER_TEAM_ID,
|
||||
}),
|
||||
ingestedPlayer({ weaponSplId: null }),
|
||||
],
|
||||
tournamentTeamId: TEAM_ID,
|
||||
}),
|
||||
).toEqual([null, null, null, null]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
import type { TFunction } from "i18next";
|
||||
import * as R from "remeda";
|
||||
import type { WeaponPoolWeapon } from "~/components/match-page/WeaponPool";
|
||||
import type { TournamentRoundMaps } from "~/db/tables-json";
|
||||
import type { IngestedScoreboardPlayer } from "~/features/scanner-ingest/core/Scoreboards";
|
||||
import type { Tournament } from "~/features/tournament-bracket/core/Tournament";
|
||||
import type { TournamentDataTeam } from "~/features/tournament-bracket/core/Tournament.server";
|
||||
import type { ModeShort, StageId } from "~/modules/in-game-lists/types";
|
||||
import type {
|
||||
MainWeaponId,
|
||||
ModeShort,
|
||||
StageId,
|
||||
} from "~/modules/in-game-lists/types";
|
||||
import type { TournamentMaplistSource } from "~/modules/tournament-map-list-generator/types";
|
||||
import { logger } from "~/utils/logger";
|
||||
|
||||
@@ -88,6 +94,62 @@ export function pickInfoText({
|
||||
return "";
|
||||
}
|
||||
|
||||
/**
|
||||
* One team's weapons for a map row: each roster member's reported weapon,
|
||||
* with the gaps filled from the map's ingested scoreboard rows that no
|
||||
* member accounts for. An ingested row without a user is only unaccounted
|
||||
* for if no roster member already reported its weapon, otherwise it is that
|
||||
* member's row and reusing it would show their weapon twice — a multiset
|
||||
* count, so two ingested rows of a weapon survive one report of it.
|
||||
*
|
||||
* @param linkedWeapons per roster member, the weapon they reported for the map (null = none)
|
||||
* @param ingestedPlayers the map's ingested scoreboard rows (empty when none ingested)
|
||||
* @returns index-aligned with `linkedWeapons`; ingested fills are marked unverified
|
||||
*/
|
||||
export function resolveTimelineWeapons({
|
||||
linkedWeapons,
|
||||
ingestedPlayers,
|
||||
tournamentTeamId,
|
||||
}: {
|
||||
linkedWeapons: (MainWeaponId | null)[];
|
||||
ingestedPlayers: IngestedScoreboardPlayer[];
|
||||
tournamentTeamId: number;
|
||||
}): WeaponPoolWeapon[] {
|
||||
const accountedForCounts = new Map<MainWeaponId, number>();
|
||||
for (const weapon of linkedWeapons) {
|
||||
if (weapon === null) continue;
|
||||
accountedForCounts.set(weapon, (accountedForCounts.get(weapon) ?? 0) + 1);
|
||||
}
|
||||
|
||||
const unlinkedIngested = ingestedPlayers.flatMap((player) => {
|
||||
if (
|
||||
player.userId !== undefined ||
|
||||
player.weaponSplId === null ||
|
||||
player.tournamentTeamId !== tournamentTeamId
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const accountedFor = accountedForCounts.get(player.weaponSplId) ?? 0;
|
||||
if (accountedFor > 0) {
|
||||
accountedForCounts.set(player.weaponSplId, accountedFor - 1);
|
||||
return [];
|
||||
}
|
||||
|
||||
return [player.weaponSplId];
|
||||
});
|
||||
|
||||
let unlinkedIdx = 0;
|
||||
return linkedWeapons.map((linked) => {
|
||||
if (linked !== null) return linked;
|
||||
|
||||
const ingested = unlinkedIngested[unlinkedIdx++];
|
||||
return ingested !== undefined
|
||||
? { weaponSplId: ingested, unverified: true }
|
||||
: null;
|
||||
});
|
||||
}
|
||||
|
||||
export function isSetOverByResults({
|
||||
results,
|
||||
count,
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
userSearch,
|
||||
} from "./fields";
|
||||
import { SendouForm, useFormFieldContext } from "./SendouForm";
|
||||
import type { ArrayItemRenderContext } from "./types";
|
||||
|
||||
let mockFetcherData: { fieldErrors?: Record<string, string> } | undefined;
|
||||
|
||||
@@ -1415,6 +1416,269 @@ describe("SendouForm", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("array field with custom-rendered items", () => {
|
||||
const memberSchema = () =>
|
||||
z.object({
|
||||
members: array({
|
||||
label: "labels.members",
|
||||
min: 0,
|
||||
max: 10,
|
||||
field: fieldset({
|
||||
fields: z.object({
|
||||
name: textField({ label: "labels.name", maxLength: 100 }),
|
||||
role: select({
|
||||
label: "labels.staffRole",
|
||||
items: [
|
||||
{ value: "ORGANIZER", label: "options.staffRole.ORGANIZER" },
|
||||
{ value: "STREAMER", label: "options.staffRole.STREAMER" },
|
||||
],
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
function renderCustomArrayForm(options?: {
|
||||
defaultValues?: Record<string, unknown>;
|
||||
onApply?: (values: Record<string, unknown>) => void;
|
||||
}) {
|
||||
const router = createMemoryRouter(
|
||||
[
|
||||
{
|
||||
path: "/",
|
||||
element: (
|
||||
<SendouForm
|
||||
schema={memberSchema()}
|
||||
defaultValues={options?.defaultValues}
|
||||
onApply={options?.onApply}
|
||||
>
|
||||
<FormField name="members">
|
||||
{(ctx: ArrayItemRenderContext) => (
|
||||
<div>
|
||||
<div data-testid={`member-${ctx.index}`}>
|
||||
{ctx.values.name as string} /{" "}
|
||||
{ctx.values.role as string}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
ctx.setItemField(
|
||||
"name",
|
||||
`${ctx.values.name as string} edited`,
|
||||
)
|
||||
}
|
||||
>
|
||||
Edit member {ctx.index + 1}
|
||||
</button>
|
||||
{ctx.canRemove ? (
|
||||
<button type="button" onClick={() => ctx.remove()}>
|
||||
Remove member {ctx.index + 1}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</FormField>
|
||||
</SendouForm>
|
||||
),
|
||||
},
|
||||
],
|
||||
{ initialEntries: ["/"] },
|
||||
);
|
||||
|
||||
return render(<RouterProvider router={router} />);
|
||||
}
|
||||
|
||||
const memberTestIds = (screen: Awaited<ReturnType<typeof render>>) =>
|
||||
screen.container.querySelectorAll('[data-testid^="member-"]');
|
||||
|
||||
test("renders each item through the render function with its values", async () => {
|
||||
const screen = await renderCustomArrayForm({
|
||||
defaultValues: {
|
||||
members: [
|
||||
{ name: "Alice", role: "ORGANIZER" },
|
||||
{ name: "Bob", role: "STREAMER" },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
await expect
|
||||
.element(screen.getByTestId("member-0"))
|
||||
.toHaveTextContent("Alice / ORGANIZER");
|
||||
await expect
|
||||
.element(screen.getByTestId("member-1"))
|
||||
.toHaveTextContent("Bob / STREAMER");
|
||||
});
|
||||
|
||||
test("add button appends a new custom-rendered item", async () => {
|
||||
const screen = await renderCustomArrayForm();
|
||||
|
||||
expect(memberTestIds(screen).length).toBe(1);
|
||||
|
||||
await screen.getByRole("button", { name: "Add" }).click();
|
||||
|
||||
await expect
|
||||
.element(screen.getByTestId("member-1"))
|
||||
.toHaveTextContent("/ ORGANIZER");
|
||||
expect(memberTestIds(screen).length).toBe(2);
|
||||
});
|
||||
|
||||
test("remove removes exactly the clicked item", async () => {
|
||||
const onApply = vi.fn();
|
||||
const screen = await renderCustomArrayForm({
|
||||
defaultValues: {
|
||||
members: [
|
||||
{ name: "Alice", role: "ORGANIZER" },
|
||||
{ name: "Bob", role: "STREAMER" },
|
||||
{ name: "Carol", role: "STREAMER" },
|
||||
],
|
||||
},
|
||||
onApply,
|
||||
});
|
||||
|
||||
await screen.getByRole("button", { name: "Remove member 2" }).click();
|
||||
|
||||
expect(memberTestIds(screen).length).toBe(2);
|
||||
await expect
|
||||
.element(screen.getByTestId("member-0"))
|
||||
.toHaveTextContent("Alice / ORGANIZER");
|
||||
await expect
|
||||
.element(screen.getByTestId("member-1"))
|
||||
.toHaveTextContent("Carol / STREAMER");
|
||||
|
||||
await screen.getByRole("button", { name: "Submit" }).click();
|
||||
|
||||
expect(onApply).toHaveBeenCalledWith({
|
||||
members: [
|
||||
expect.objectContaining({ name: "Alice", role: "ORGANIZER" }),
|
||||
expect.objectContaining({ name: "Carol", role: "STREAMER" }),
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test("remove after add acts on the grown array, not a stale one", async () => {
|
||||
const screen = await renderCustomArrayForm({
|
||||
defaultValues: {
|
||||
members: [
|
||||
{ name: "Alice", role: "ORGANIZER" },
|
||||
{ name: "Bob", role: "STREAMER" },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
await screen.getByRole("button", { name: "Add" }).click();
|
||||
await screen.getByRole("button", { name: "Remove member 1" }).click();
|
||||
|
||||
// A stale remove would have filtered the pre-add two-item array and
|
||||
// dropped the freshly added row along with Alice.
|
||||
expect(memberTestIds(screen).length).toBe(2);
|
||||
await expect
|
||||
.element(screen.getByTestId("member-0"))
|
||||
.toHaveTextContent("Bob / STREAMER");
|
||||
});
|
||||
|
||||
test("remove after editing a different item keeps the edit", async () => {
|
||||
const onApply = vi.fn();
|
||||
const screen = await renderCustomArrayForm({
|
||||
defaultValues: {
|
||||
members: [
|
||||
{ name: "Alice", role: "ORGANIZER" },
|
||||
{ name: "Bob", role: "STREAMER" },
|
||||
{ name: "Carol", role: "STREAMER" },
|
||||
],
|
||||
},
|
||||
onApply,
|
||||
});
|
||||
|
||||
// Editing item 1 does not re-render the memoized item 3, so its remove
|
||||
// callback must read the current array instead of a stale closure.
|
||||
await screen.getByRole("button", { name: "Edit member 1" }).click();
|
||||
await screen.getByRole("button", { name: "Remove member 3" }).click();
|
||||
|
||||
await screen.getByRole("button", { name: "Submit" }).click();
|
||||
|
||||
expect(onApply).toHaveBeenCalledWith({
|
||||
members: [
|
||||
expect.objectContaining({ name: "Alice edited", role: "ORGANIZER" }),
|
||||
expect.objectContaining({ name: "Bob", role: "STREAMER" }),
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test("setItemField updates only the targeted item's field", async () => {
|
||||
const onApply = vi.fn();
|
||||
const screen = await renderCustomArrayForm({
|
||||
defaultValues: {
|
||||
members: [
|
||||
{ name: "Alice", role: "ORGANIZER" },
|
||||
{ name: "Bob", role: "STREAMER" },
|
||||
],
|
||||
},
|
||||
onApply,
|
||||
});
|
||||
|
||||
await screen.getByRole("button", { name: "Edit member 2" }).click();
|
||||
|
||||
await expect
|
||||
.element(screen.getByTestId("member-1"))
|
||||
.toHaveTextContent("Bob edited / STREAMER");
|
||||
await expect
|
||||
.element(screen.getByTestId("member-0"))
|
||||
.toHaveTextContent("Alice / ORGANIZER");
|
||||
|
||||
await screen.getByRole("button", { name: "Submit" }).click();
|
||||
|
||||
expect(onApply).toHaveBeenCalledWith({
|
||||
members: [
|
||||
expect.objectContaining({ name: "Alice", role: "ORGANIZER" }),
|
||||
expect.objectContaining({ name: "Bob edited", role: "STREAMER" }),
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test("itemName renders a nested FormField bound to the item", async () => {
|
||||
const onApply = vi.fn();
|
||||
const schema = memberSchema();
|
||||
|
||||
const router = createMemoryRouter(
|
||||
[
|
||||
{
|
||||
path: "/",
|
||||
element: (
|
||||
<SendouForm
|
||||
schema={schema}
|
||||
defaultValues={{
|
||||
members: [{ name: "Alice", role: "ORGANIZER" }],
|
||||
}}
|
||||
onApply={onApply}
|
||||
>
|
||||
<FormField name="members">
|
||||
{(ctx: ArrayItemRenderContext) => (
|
||||
<FormField name={`${ctx.itemName}.name`} />
|
||||
)}
|
||||
</FormField>
|
||||
</SendouForm>
|
||||
),
|
||||
},
|
||||
],
|
||||
{ initialEntries: ["/"] },
|
||||
);
|
||||
|
||||
const screen = await render(<RouterProvider router={router} />);
|
||||
const input = screen.getByLabelText("Name");
|
||||
|
||||
await expect.element(input).toHaveValue("Alice");
|
||||
|
||||
await userEvent.type(input.element(), " Smith");
|
||||
await screen.getByRole("button", { name: "Submit" }).click();
|
||||
|
||||
expect(onApply).toHaveBeenCalledWith({
|
||||
members: [
|
||||
expect.objectContaining({ name: "Alice Smith", role: "ORGANIZER" }),
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("array field item removal preserves remaining items", () => {
|
||||
test("removing a middle member preserves userSearch values of members below", async () => {
|
||||
let latestValues: Record<string, unknown> = {};
|
||||
|
||||
@@ -84,5 +84,12 @@ export async function up(db: Kysely<any>): Promise<void> {
|
||||
.on("IngestedMatchLink")
|
||||
.column("groupMatchMapId")
|
||||
.execute();
|
||||
|
||||
// ingest context resolution prunes reported games by a createdAt window
|
||||
await trx.schema
|
||||
.createIndex("tournament_match_game_result_created_at")
|
||||
.on("TournamentMatchGameResult")
|
||||
.column("createdAt")
|
||||
.execute();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ import * as SkillRepository from "~/features/mmr/SkillRepository.server";
|
||||
import * as NotificationRepository from "~/features/notifications/NotificationRepository.server";
|
||||
import * as PlusSuggestionRepository from "~/features/plus-suggestions/PlusSuggestionRepository.server";
|
||||
import * as PlusVotingRepository from "~/features/plus-voting/PlusVotingRepository.server";
|
||||
import * as ScannerIngestRepository from "~/features/scanner-ingest/ScannerIngestRepository.server";
|
||||
import * as ScrimMapListRepository from "~/features/scrims/ScrimMapListRepository.server";
|
||||
import * as ScrimMapRepository from "~/features/scrims/ScrimMapRepository.server";
|
||||
import * as ScrimPostRepository from "~/features/scrims/ScrimPostRepository.server";
|
||||
@@ -416,6 +417,78 @@ export function buildCases(fx: Fixtures): {
|
||||
}),
|
||||
);
|
||||
|
||||
// ScannerIngestRepository
|
||||
add(
|
||||
"ScannerIngestRepository.gamesPlayedByUserInTournament",
|
||||
fx.scannerIngest,
|
||||
(ingest) =>
|
||||
ScannerIngestRepository.gamesPlayedByUserInTournament({
|
||||
userId: ingest.povUserId,
|
||||
tournamentId: ingest.tournamentId,
|
||||
}),
|
||||
);
|
||||
add(
|
||||
"ScannerIngestRepository.gamesPlayedByUserSince",
|
||||
fx.scannerIngest,
|
||||
(ingest) =>
|
||||
ScannerIngestRepository.gamesPlayedByUserSince({
|
||||
userId: ingest.povUserId,
|
||||
since: ingest.sinceTimestamp,
|
||||
}),
|
||||
);
|
||||
add(
|
||||
"ScannerIngestRepository.castedGamesInTournament",
|
||||
fx.castedTournamentId,
|
||||
(tournamentId) =>
|
||||
ScannerIngestRepository.castedGamesInTournament(tournamentId),
|
||||
);
|
||||
add(
|
||||
"ScannerIngestRepository.gamesInGroupMatch",
|
||||
fx.heavyGroupMatchId,
|
||||
(groupMatchId) => ScannerIngestRepository.gamesInGroupMatch(groupMatchId),
|
||||
);
|
||||
add(
|
||||
"ScannerIngestRepository.sendouqGamesPlayedByUserSince",
|
||||
fx.scannerIngestSendouq,
|
||||
(sendouq) =>
|
||||
ScannerIngestRepository.sendouqGamesPlayedByUserSince({
|
||||
userId: sendouq.userId,
|
||||
since: sendouq.sinceTimestamp,
|
||||
}),
|
||||
);
|
||||
add("ScannerIngestRepository.tournamentIdAt", fx.scannerIngest, (ingest) =>
|
||||
ScannerIngestRepository.tournamentIdAt({
|
||||
userId: ingest.povUserId,
|
||||
at: ingest.atMs,
|
||||
}),
|
||||
);
|
||||
add(
|
||||
"ScannerIngestRepository.groupMatchIdAt",
|
||||
fx.scannerIngestSendouq,
|
||||
(sendouq) =>
|
||||
ScannerIngestRepository.groupMatchIdAt({
|
||||
userId: sendouq.userId,
|
||||
at: sendouq.atMs,
|
||||
}),
|
||||
);
|
||||
add(
|
||||
"ScannerIngestRepository.staffTournamentIdsAt",
|
||||
both(fx.calendarAuthorId, fx.scannerIngest),
|
||||
([userId, ingest]) =>
|
||||
ScannerIngestRepository.staffTournamentIdsAt({
|
||||
userId,
|
||||
at: ingest.atMs,
|
||||
}),
|
||||
);
|
||||
add(
|
||||
"ScannerIngestRepository.findScoreboardsByTournamentMatchId",
|
||||
fx.heavyTournamentMatchId,
|
||||
(tournamentMatchId) =>
|
||||
ScannerIngestRepository.findScoreboardsByTournamentMatchId(
|
||||
tournamentMatchId,
|
||||
),
|
||||
);
|
||||
|
||||
// ScrimMapListRepository
|
||||
add(
|
||||
"ScrimMapListRepository.findMapListsByScrimPostId",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { sub } from "date-fns";
|
||||
import { sql } from "kysely";
|
||||
import { db } from "~/db/sql";
|
||||
import type { Tables } from "~/db/tables";
|
||||
import type { SkillTeamIdentifier } from "~/features/mmr/mmr-utils";
|
||||
@@ -93,6 +94,18 @@ export interface Fixtures {
|
||||
apiTokenUserId: number | null;
|
||||
logInLinkCode: string | null;
|
||||
modNoteId: number | null;
|
||||
scannerIngest: {
|
||||
povUserId: number;
|
||||
tournamentId: number;
|
||||
atMs: number;
|
||||
sinceTimestamp: number;
|
||||
} | null;
|
||||
scannerIngestSendouq: {
|
||||
userId: number;
|
||||
atMs: number;
|
||||
sinceTimestamp: number;
|
||||
} | null;
|
||||
castedTournamentId: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -164,6 +177,9 @@ export async function resolveFixtures(): Promise<Fixtures> {
|
||||
apiTokenUserId: await resolveApiTokenUserId(),
|
||||
logInLinkCode: await resolveLogInLinkCode(),
|
||||
modNoteId: await resolveModNoteId(),
|
||||
scannerIngest: await resolveScannerIngest(),
|
||||
scannerIngestSendouq: await resolveScannerIngestSendouq(),
|
||||
castedTournamentId: await resolveCastedTournamentId(),
|
||||
};
|
||||
|
||||
const nullFixtures = Object.entries(fixtures)
|
||||
@@ -1094,3 +1110,113 @@ async function resolveModNoteId() {
|
||||
|
||||
return row?.id ?? null;
|
||||
}
|
||||
|
||||
const SCANNER_INGEST_SINCE_WINDOW_SECONDS = 365 * 24 * 60 * 60;
|
||||
|
||||
async function resolveScannerIngest() {
|
||||
const participantRow = await db
|
||||
.selectFrom("TournamentMatchGameResultParticipant")
|
||||
.select(({ fn }) => ["userId", fn.countAll<number>().as("count")])
|
||||
.groupBy("userId")
|
||||
.orderBy("count", "desc")
|
||||
.limit(1)
|
||||
.executeTakeFirst();
|
||||
if (!participantRow) return null;
|
||||
|
||||
const latestGame = await db
|
||||
.selectFrom("TournamentMatchGameResultParticipant")
|
||||
.innerJoin(
|
||||
"TournamentMatchGameResult",
|
||||
"TournamentMatchGameResult.id",
|
||||
"TournamentMatchGameResultParticipant.matchGameResultId",
|
||||
)
|
||||
.innerJoin(
|
||||
"TournamentMatch",
|
||||
"TournamentMatch.id",
|
||||
"TournamentMatchGameResult.matchId",
|
||||
)
|
||||
.innerJoin(
|
||||
"TournamentStage",
|
||||
"TournamentStage.id",
|
||||
"TournamentMatch.stageId",
|
||||
)
|
||||
.select([
|
||||
"TournamentMatchGameResult.createdAt",
|
||||
"TournamentStage.tournamentId",
|
||||
])
|
||||
.where(
|
||||
"TournamentMatchGameResultParticipant.userId",
|
||||
"=",
|
||||
participantRow.userId,
|
||||
)
|
||||
.orderBy("TournamentMatchGameResult.createdAt", "desc")
|
||||
.limit(1)
|
||||
.executeTakeFirst();
|
||||
if (!latestGame) return null;
|
||||
|
||||
return {
|
||||
povUserId: participantRow.userId,
|
||||
tournamentId: latestGame.tournamentId,
|
||||
atMs: latestGame.createdAt * 1000,
|
||||
sinceTimestamp: latestGame.createdAt - SCANNER_INGEST_SINCE_WINDOW_SECONDS,
|
||||
};
|
||||
}
|
||||
|
||||
async function resolveScannerIngestSendouq() {
|
||||
const memberRow = await db
|
||||
.selectFrom("GroupMember")
|
||||
.select(({ fn }) => ["userId", fn.countAll<number>().as("count")])
|
||||
.groupBy("userId")
|
||||
.orderBy("count", "desc")
|
||||
.limit(1)
|
||||
.executeTakeFirst();
|
||||
if (!memberRow) return null;
|
||||
|
||||
const latestMatch = await db
|
||||
.selectFrom("GroupMatch")
|
||||
.select("GroupMatch.createdAt")
|
||||
.where((eb) =>
|
||||
eb.exists(
|
||||
eb
|
||||
.selectFrom("GroupMember")
|
||||
.select("GroupMember.userId")
|
||||
.where("GroupMember.userId", "=", memberRow.userId)
|
||||
.where((memberEb) =>
|
||||
memberEb.or([
|
||||
memberEb(
|
||||
"GroupMember.groupId",
|
||||
"=",
|
||||
memberEb.ref("GroupMatch.alphaGroupId"),
|
||||
),
|
||||
memberEb(
|
||||
"GroupMember.groupId",
|
||||
"=",
|
||||
memberEb.ref("GroupMatch.bravoGroupId"),
|
||||
),
|
||||
]),
|
||||
),
|
||||
),
|
||||
)
|
||||
.orderBy("GroupMatch.createdAt", "desc")
|
||||
.limit(1)
|
||||
.executeTakeFirst();
|
||||
if (!latestMatch) return null;
|
||||
|
||||
return {
|
||||
userId: memberRow.userId,
|
||||
atMs: latestMatch.createdAt * 1000,
|
||||
sinceTimestamp: latestMatch.createdAt - SCANNER_INGEST_SINCE_WINDOW_SECONDS,
|
||||
};
|
||||
}
|
||||
|
||||
async function resolveCastedTournamentId() {
|
||||
const row = await db
|
||||
.selectFrom("Tournament")
|
||||
.select("id")
|
||||
.where("castedMatchesInfo", "is not", null)
|
||||
.orderBy(sql`length("castedMatchesInfo")`, "desc")
|
||||
.limit(1)
|
||||
.executeTakeFirst();
|
||||
|
||||
return row?.id ?? null;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user