Files
sendou.ink/app/features/scanner/tests/timeline.test.ts
2026-09-09 21:46:10 +03:00

189 lines
6.2 KiB
TypeScript

import assert from "node:assert/strict";
import type { ScoreboardData } from "../core/detectors/scoreboard/index";
import type { DetectedEvent } from "../core/detectors/types";
import { TimelineBuilder } from "../core/timeline/index";
import { test } from "./node-test-compat";
function event(
t: number,
confidence: number,
type = "Scoreboard",
): DetectedEvent {
return { type, t, confidence, data: { t } };
}
/** A ScoreboardBattleLogReplay event with just the identity-relevant data fields. */
function replay(
t: number,
confidence: number,
data: Partial<ScoreboardData> & {
timestamp?: string | null;
replayCode?: string | null;
},
): DetectedEvent {
return {
type: "ScoreboardBattleLogReplay",
t,
confidence,
data: {
lobby: null,
mode: null,
stage: null,
timestamp: null,
replayCode: null,
scores: [null, null],
players: [],
povIndex: null,
...data,
},
};
}
function players(paints: (number | null)[]): ScoreboardData["players"] {
return paints.map((paint, i) => ({
name: `p${i}`,
weaponId: null,
paint,
ka: null,
d: null,
s: null,
}));
}
test("timeline drops low-confidence events", () => {
const tl = new TimelineBuilder({ minConfidence: 0.6 });
assert.equal(tl.push(event(0, 0.5)).action, "dropped");
assert.equal(tl.events.length, 0);
});
test("timeline merges same-type events inside the window", () => {
const tl = new TimelineBuilder({ mergeWindow: 30, minConfidence: 0 });
assert.equal(tl.push(event(100, 0.8)).action, "added");
assert.equal(tl.push(event(110, 0.7)).action, "merged");
assert.equal(tl.events.length, 1);
assert.equal(tl.events[0]!.confidence, 0.8);
});
test("timeline keeps the highest-confidence version", () => {
const tl = new TimelineBuilder({ mergeWindow: 30, minConfidence: 0 });
tl.push(event(100, 0.7));
const result = tl.push(event(105, 0.9));
assert.equal(result.action, "replaced");
assert.equal(tl.events[0]!.confidence, 0.9);
});
test("timeline keeps separate events outside the window", () => {
const tl = new TimelineBuilder({ mergeWindow: 30, minConfidence: 0 });
tl.push(event(100, 0.8));
assert.equal(tl.push(event(200, 0.8)).action, "added");
assert.equal(tl.events.length, 2);
});
test("different event types never merge", () => {
const tl = new TimelineBuilder({ mergeWindow: 30, minConfidence: 0 });
tl.push(event(100, 0.8, "Scoreboard"));
assert.equal(tl.push(event(105, 0.8, "Death")).action, "added");
assert.equal(tl.events.length, 2);
});
test("scoreboards with different stages stay separate inside the window", () => {
const tl = new TimelineBuilder({ minConfidence: 0 });
assert.equal(tl.push(replay(100, 0.8, { stage: 21 })).action, "added");
assert.equal(tl.push(replay(104, 0.8, { stage: 2 })).action, "added");
assert.equal(tl.push(replay(108, 0.8, { stage: 17 })).action, "added");
// revisiting the first replay merges back into its event, skipping the
// incompatible ones in between
assert.equal(tl.push(replay(112, 0.7, { stage: 21 })).action, "merged");
assert.equal(tl.events.length, 3);
});
test("a null stage read never splits", () => {
const tl = new TimelineBuilder({ minConfidence: 0 });
tl.push(replay(100, 0.8, { stage: 21 }));
assert.equal(tl.push(replay(104, 0.7, { stage: null })).action, "merged");
});
test("different recording timestamps split, equal ones merge", () => {
const tl = new TimelineBuilder({ minConfidence: 0 });
tl.push(replay(100, 0.8, { timestamp: "3/7/2026 21:15" }));
assert.equal(
tl.push(replay(104, 0.8, { timestamp: "3/7/2026 21:22" })).action,
"added",
);
assert.equal(
tl.push(replay(108, 0.7, { timestamp: "3/7/2026 21:15" })).action,
"merged",
);
});
test("replay codes tolerate misread glyphs but split on real differences", () => {
const tl = new TimelineBuilder({ minConfidence: 0 });
tl.push(replay(100, 0.8, { replayCode: "R797-V51Y-945W-C4JJ" }));
// same replay, three glyphs misread on a low-fidelity capture
assert.equal(
tl.push(replay(104, 0.7, { replayCode: "R797-U51Y-945W-G4JL" })).action,
"merged",
);
assert.equal(
tl.push(replay(108, 0.8, { replayCode: "RVRM-XXEL-0573-Q4SV" })).action,
"added",
);
});
test("disjoint paint totals split when stage and code are unreadable", () => {
const tl = new TimelineBuilder({ minConfidence: 0 });
tl.push(
replay(100, 0.8, {
players: players([327, 408, 270, 354, 381, 613, 456, 287]),
}),
);
const other = replay(104, 0.8, {
players: players([733, 946, 1020, 878, 666, 1068, 1050, 631]),
});
assert.equal(tl.push(other).action, "added");
// same board re-read with jitter: a couple of rows misread or null
const jittered = replay(108, 0.7, {
players: players([733, 946, 1020, null, 666, 1068, 1050, 613]),
});
assert.equal(tl.push(jittered).action, "merged");
assert.equal(tl.events.length, 2);
});
test("too few readable paints never split", () => {
const tl = new TimelineBuilder({ minConfidence: 0 });
tl.push(
replay(100, 0.8, {
players: players([327, 408, 270, null, null, null, null, null]),
}),
);
const other = replay(104, 0.7, {
players: players([733, 946, 1020, null, null, null, null, null]),
});
assert.equal(tl.push(other).action, "merged");
});
test("per-type merge window: repeat death frames merge, consecutive deaths do not", () => {
const tl = new TimelineBuilder({ minConfidence: 0 });
assert.equal(tl.push(event(100, 0.8, "Death")).action, "added");
// same death, sampled again 3s later
assert.equal(tl.push(event(103, 0.7, "Death")).action, "merged");
// next death after respawn + travel: outside the 8s Death window but
// well inside the default 30s
assert.equal(tl.push(event(112, 0.8, "Death")).action, "added");
assert.equal(tl.events.length, 2);
// scoreboards keep the wide default window
tl.push(event(200, 0.8, "Scoreboard"));
assert.equal(tl.push(event(212, 0.7, "Scoreboard")).action, "merged");
});
test("kill stacks merge while unchanged and split when a row enters", () => {
const tl = new TimelineBuilder();
const kill = (t: number, names: string[]) =>
tl.push({ type: "Kill", t, confidence: 0.8, data: { time: null, names } });
assert.equal(kill(100, ["24K"]).action, "added");
assert.equal(kill(100.5, ["24K"]).action, "merged");
assert.equal(kill(101, ["datkid", "24K"]).action, "added");
assert.equal(kill(104, ["datkid", "24K"]).action, "merged");
assert.equal(tl.events.length, 2);
});