/** * Golden-file tests for the ObjectiveDetector over every fixture in * objective/, mirroring map-start.test.ts, plus cross-negative sweeps in * both directions: the objective gate must stay quiet on every other * detector's positives (and the shared negatives), and their gates must * stay quiet on the objective fixtures. */ import assert from "node:assert/strict"; import { loadOpenCV } from "@sendou/scanner-core/cv"; import { createDeathDetector } from "@sendou/scanner-core/detectors/death/index"; import { createMapStartDetector } from "@sendou/scanner-core/detectors/map-start/index"; import { createMinimapDetector } from "@sendou/scanner-core/detectors/minimap/index"; import { createObjectiveDetector, type ObjectiveData, } from "@sendou/scanner-core/detectors/objective/index"; import { createScoreboardDetector } from "@sendou/scanner-core/detectors/scoreboard/index"; import { createScoreboardBattleLogReplayDetector } from "@sendou/scanner-core/detectors/scoreboard-battle-log-replay/index"; import { createScoreboardOwnDetector } from "@sendou/scanner-core/detectors/scoreboard-own/index"; import type { DetectedEvent, Detector, } from "@sendou/scanner-core/detectors/types"; import { hueDistance, hueOf } from "@sendou/scanner-core/ink-color"; import { type Fixture, isFieldSkipped, loadFixtures, runDetectorOnFixture, } from "../node/fixtures"; import { loadScoreboardResources } from "../node/resources"; import test from "./node-test-compat"; await loadOpenCV(); const resources = await loadScoreboardResources(); const detector = createObjectiveDetector(resources); const fixtures = loadFixtures("objective"); test("objective fixtures exist", () => { assert.ok(fixtures.length > 0, "no fixtures found under objective/"); }); for (const fixture of fixtures) { test(`objective/${fixture.name}`, async (t) => { const { gate, events: allEvents } = await runDetectorOnFixture( detector, fixture, ); const events = allEvents.filter( (event) => event.type === "Objective", ) as DetectedEvent[]; const expectPositive = fixture.expected.event === "Objective"; await t.test("gate", () => { assert.equal( gate.pass, expectPositive, `gate ${gate.pass ? "fired" : "did not fire"} (score=${gate.score.toFixed(3)}), expected ${expectPositive ? "fire" : "no fire"}`, ); }); if (!expectPositive) return; const event = events[0]; assert.ok( event, gate.pass ? "gate passed but parse emitted no event (no readable count?)" : "no event (gate did not fire)", ); const expected = fixture.expected.data ?? {}; const debug = () => JSON.stringify(event.debug); await t.test( "mode", { skip: expected.mode === undefined || skip(fixture, "mode") }, () => { assert.equal(event.data.mode, expected.mode); }, ); await t.test( "time", { skip: expected.time === undefined || skip(fixture, "time") }, () => { assert.equal( event.data.time, expected.time, `time mismatch (${debug()})`, ); }, ); for (const side of [0, 1] as const) { await t.test( `score[${side}]`, { skip: expected.score === undefined || skip(fixture, `score.${side}`), }, () => { assert.equal( event.data.score[side], expected.score![side], `score[${side}] mismatch (${debug()})`, ); }, ); await t.test( `penalty[${side}]`, { skip: expected.penalty === undefined || skip(fixture, `penalty.${side}`), }, () => { assert.equal( event.data.penalty[side], expected.penalty![side], `penalty[${side}] mismatch (${debug()})`, ); }, ); await t.test( `control[${side}]`, { skip: expected.control === undefined || skip(fixture, `control.${side}`), }, () => { assert.equal( event.data.control[side], expected.control![side], `control[${side}] mismatch (${debug()})`, ); }, ); } }); } // The cast fixture pair captures the same game under both camera // arrangements (the specced team's plate sits left, so purple is left in // one frame and right in the other): each frame's two ink hues must // separate cleanly, and cross-frame the same team's hue must land on the // same cluster with the sides swapped — the invariant cast score tracking // (match-builder's color orientation) rests on. test("cast fixture pair: team ink hues identify sides across camera swaps", async () => { const pair = [ "splat-zones-cast-specced-purple-left", "splat-zones-cast-overhead-purple-right", ].map((name) => fixtures.find((fixture) => fixture.name === name)); assert.ok(pair[0] && pair[1], "cast fixture pair missing"); const colors = []; for (const fixture of pair) { const { events } = await runDetectorOnFixture(detector, fixture!); const teamColor = ( events.find((event) => event.type === "Objective") as | DetectedEvent | undefined )?.data.teamColor; assert.ok(teamColor?.[0] && teamColor[1], "side ink color unreadable"); colors.push([teamColor[0], teamColor[1]] as const); } for (const [left, right] of colors) { assert.ok( hueDistance(hueOf(left), hueOf(right)) >= 90, "the two teams' ink hues do not separate", ); } const [specced, overhead] = colors; assert.ok(hueDistance(hueOf(specced![0]), hueOf(overhead![1])) <= 20); assert.ok(hueDistance(hueOf(specced![1]), hueOf(overhead![0])) <= 20); }); // Screens that replace gameplay can never show the counters — the gate must // stay quiet on their positives. const otherPositiveSets = [ ["scoreboard", "Scoreboard"], ["scoreboard-battle-log-replay", "ScoreboardBattleLogReplay"], ["scoreboard-own", "ScoreboardOwn"], ["map-start", "MapStart"], ["minimap", "Minimap"], ] as const; for (const [dir, eventType] of otherPositiveSets) { for (const fixture of loadFixtures(dir).filter( (f) => f.expected.event === eventType, )) { test(`objective gate stays quiet on ${dir}/${fixture.name}`, async () => { const { gate } = await runDetectorOnFixture(detector, fixture); assert.equal( gate.pass, false, `objective gate fired (score=${gate.score.toFixed(3)})`, ); }); } } // No sweep over death positives: the death overlay rides live gameplay // whose counter HUD stays visible (several death fixtures show full, // correctly-readable plates), so neither gate- nor parse-level quiet can be // promised there. Death frames with counters make fine objective fixtures. // Shared negatives (tests/fixtures/negative/): frames no detector may fire on. for (const fixture of loadFixtures("negative")) { test(`objective gate stays quiet on negative/${fixture.name}`, async () => { const { gate } = await runDetectorOnFixture(detector, fixture); assert.equal( gate.pass, false, `objective gate fired (score=${gate.score.toFixed(3)})`, ); }); } const otherDetectors: readonly [string, Detector][] = [ ["scoreboard", createScoreboardDetector(resources) as Detector], [ "scoreboard-battle-log-replay", createScoreboardBattleLogReplayDetector(resources) as Detector, ], [ "scoreboard-own", createScoreboardOwnDetector(resources) as Detector, ], ["death", createDeathDetector(resources) as Detector], ["map-start", createMapStartDetector(resources) as Detector], ["minimap", createMinimapDetector(resources) as Detector], ]; for (const fixture of fixtures.filter( (f) => f.expected.event === "Objective", )) { test(`other gates stay quiet on objective/${fixture.name}`, async () => { for (const [name, other] of otherDetectors) { const { gate } = await runDetectorOnFixture(other, fixture); assert.equal( gate.pass, false, `${name} gate fired (score=${gate.score.toFixed(3)})`, ); } }); } function skip(fixture: Fixture, field: string): boolean | string { return isFieldSkipped(fixture, field) ? "skipFields" : false; }