use super::*;
use std::collections::BTreeMap;
const STATS_TIMELINE_FIXTURE: &str =
"assets/replay-format-2016-11-09-v868-14-net-none-rlcs-lan.replay";
const REPLAY_FORMAT_EVOLUTION_DOC: &str = include_str!("../../../docs/replay-format-evolution.md");
fn parse_replay(path: &str) -> boxcars::Replay {
let data = std::fs::read(path).unwrap_or_else(|_| panic!("Failed to read replay file: {path}"));
boxcars::ParserBuilder::new(&data[..])
.always_check_crc()
.must_parse_network_data()
.parse()
.unwrap_or_else(|_| panic!("Failed to parse replay: {path}"))
}
fn replay_format_fixture_paths() -> Vec<String> {
REPLAY_FORMAT_EVOLUTION_DOC
.lines()
.filter_map(|line| {
let start = line.find("| `")? + 3;
let rest = &line[start..];
let end = rest.find("` |")?;
let fixture = &rest[..end];
fixture
.ends_with(".replay")
.then(|| format!("assets/{fixture}"))
})
.collect()
}
fn asset_replay_fixture_paths() -> Vec<String> {
let fixture_filter = std::env::var("SUBTR_ACTOR_REPLAY_FIXTURE").ok();
let mut replay_paths = std::fs::read_dir("assets")
.expect("expected checked-in replay asset directory")
.filter_map(|entry| {
let entry = entry.expect("expected replay asset directory entry");
let path = entry.path();
(path
.extension()
.is_some_and(|extension| extension == "replay"))
.then(|| {
path.to_str()
.expect("expected replay fixture path to be valid UTF-8")
.to_owned()
})
})
.filter(|path| {
fixture_filter
.as_ref()
.map(|filter| path.contains(filter))
.unwrap_or(true)
})
.collect::<Vec<_>>();
replay_paths.sort();
replay_paths
}
fn event_set_counts(events: &ReplayStatsTimelineEvents) -> Vec<(&'static str, usize)> {
vec![
("timeline", events.timeline.len()),
("core_player", events.core_player.len()),
("core_team", events.core_team.len()),
("possession", events.possession.len()),
("pressure", events.pressure.len()),
("movement", events.movement.len()),
("positioning", events.positioning.len()),
("rotation_player", events.rotation_player.len()),
("rotation_team", events.rotation_team.len()),
("mechanics", events.mechanics.len()),
("goal_context", events.goal_context.len()),
("backboard", events.backboard.len()),
("ceiling_shot", events.ceiling_shot.len()),
("wall_aerial", events.wall_aerial.len()),
("wall_aerial_shot", events.wall_aerial_shot.len()),
("center", events.center.len()),
("flick", events.flick.len()),
("musty_flick", events.musty_flick.len()),
("dodge_reset", events.dodge_reset.len()),
("double_tap", events.double_tap.len()),
("fifty_fifty", events.fifty_fifty.len()),
("one_timer", events.one_timer.len()),
("pass", events.pass.len()),
("pass_last_completed", events.pass_last_completed.len()),
("ball_carry", events.ball_carry.len()),
("goal_tags", events.goal_tags.len()),
("rush", events.rush.len()),
("speed_flip", events.speed_flip.len()),
("half_flip", events.half_flip.len()),
("half_volley", events.half_volley.len()),
("wavedash", events.wavedash.len()),
("whiff", events.whiff.len()),
("powerslide", events.powerslide.len()),
("touch", events.touch.len()),
("touch_ball_movement", events.touch_ball_movement.len()),
("touch_last_touch", events.touch_last_touch.len()),
("boost_pickups", events.boost_pickups.len()),
("boost_ledger", events.boost_ledger.len()),
("boost_state", events.boost_state.len()),
("bump", events.bump.len()),
]
}
fn canonical_event_sets(events: &ReplayStatsTimelineEvents) -> BTreeMap<String, Vec<String>> {
let value = serde_json::to_value(events).expect("events should serialize");
value
.as_object()
.expect("events should serialize as an object")
.iter()
.map(|(name, events)| {
let mut entries = events
.as_array()
.unwrap_or_else(|| panic!("event set {name} should serialize as an array"))
.iter()
.map(|event| serde_json::to_string(event).expect("event should serialize"))
.collect::<Vec<_>>();
entries.sort();
(name.clone(), entries)
})
.collect()
}
fn assert_canonical_event_sets_match(
context: &str,
left: &ReplayStatsTimelineEvents,
right: &ReplayStatsTimelineEvents,
) {
let left_sets = canonical_event_sets(left);
let right_sets = canonical_event_sets(right);
assert_eq!(
left_sets.keys().collect::<Vec<_>>(),
right_sets.keys().collect::<Vec<_>>()
);
for (name, left_entries) in left_sets {
let right_entries = right_sets
.get(&name)
.unwrap_or_else(|| panic!("missing right event set {name}"));
if &left_entries == right_entries {
continue;
}
let first_mismatch = left_entries
.iter()
.zip(right_entries)
.position(|(left, right)| left != right);
let mismatch_detail = first_mismatch
.map(|index| {
format!(
", left={}, right={}",
left_entries[index], right_entries[index]
)
})
.unwrap_or_default();
panic!(
"{context} event set {name} differs: left_count={}, right_count={}, first_mismatch={first_mismatch:?}{mismatch_detail}",
left_entries.len(),
right_entries.len(),
);
}
}
#[test]
fn event_timeline_graph_does_not_build_full_stats_frame_snapshots() {
let mut graph = build_timeline_event_graph();
graph
.resolve()
.expect("event timeline graph should resolve");
let node_names = graph.node_names().collect::<Vec<_>>();
assert!(node_names.contains(&"stats_timeline_events"));
assert!(
!node_names.contains(&"stats_timeline_frame"),
"event timeline transfer should not evaluate the full partial-sum frame node"
);
}
fn assert_event_timeline_scaffold_matches_full_timeline_without_stat_snapshots(replay_path: &str) {
let replay = parse_replay(replay_path);
let mut processor = ReplayProcessor::new(&replay).expect("replay processor should initialize");
let mut full_collector = StatsTimelineCollector::new();
let mut scaffold_collector = StatsTimelineEventCollector::new();
processor
.process_all(&mut [&mut full_collector, &mut scaffold_collector])
.expect("full and event stats timelines should collect from the same processor");
let full_timeline = full_collector
.into_legacy_replay_stats_timeline()
.expect("full stats timeline should assemble");
let scaffold_timeline = scaffold_collector
.into_replay_stats_timeline_scaffold()
.expect("event stats timeline scaffold should assemble");
assert_eq!(scaffold_timeline.config, full_timeline.config);
assert_eq!(scaffold_timeline.replay_meta, full_timeline.replay_meta);
assert_eq!(
event_set_counts(&scaffold_timeline.events),
event_set_counts(&full_timeline.events),
"{replay_path} event set counts should match"
);
assert_canonical_event_sets_match(
replay_path,
&scaffold_timeline.events,
&full_timeline.events,
);
assert_eq!(
scaffold_timeline.frames.len(),
full_timeline.frames.len(),
"{replay_path} frame count should match"
);
for (scaffold_frame, full_frame) in scaffold_timeline.frames.iter().zip(&full_timeline.frames) {
assert_eq!(
scaffold_frame.frame_number, full_frame.frame_number,
"{replay_path} scaffold frame number should match"
);
assert_eq!(
scaffold_frame.time, full_frame.time,
"{replay_path} scaffold frame time should match"
);
assert_eq!(
scaffold_frame.dt, full_frame.dt,
"{replay_path} scaffold frame dt should match"
);
assert_eq!(
scaffold_frame.seconds_remaining, full_frame.seconds_remaining,
"{replay_path} scaffold seconds_remaining should match"
);
assert_eq!(
scaffold_frame.game_state, full_frame.game_state,
"{replay_path} scaffold game_state should match"
);
assert_eq!(
scaffold_frame.ball_has_been_hit, full_frame.ball_has_been_hit,
"{replay_path} scaffold ball_has_been_hit should match"
);
assert_eq!(
scaffold_frame.kickoff_countdown_time, full_frame.kickoff_countdown_time,
"{replay_path} scaffold kickoff_countdown_time should match"
);
assert_eq!(
scaffold_frame.gameplay_phase, full_frame.gameplay_phase,
"{replay_path} scaffold gameplay_phase should match"
);
assert_eq!(
scaffold_frame.is_live_play, full_frame.is_live_play,
"{replay_path} scaffold live-play flag should match"
);
assert!(
scaffold_frame.team_zero.is_empty(),
"{replay_path} event scaffold should not carry team-zero stat modules"
);
assert!(
scaffold_frame.team_one.is_empty(),
"{replay_path} event scaffold should not carry team-one stat modules"
);
assert_eq!(
scaffold_frame.players.len(),
full_frame.players.len(),
"{replay_path} scaffold player count should match"
);
for (scaffold_player, full_player) in scaffold_frame.players.iter().zip(&full_frame.players)
{
assert_eq!(
scaffold_player.player_id, full_player.player_id,
"{replay_path} scaffold player id should match"
);
assert_eq!(
scaffold_player.name, full_player.name,
"{replay_path} scaffold player name should match"
);
assert_eq!(
scaffold_player.is_team_0, full_player.is_team_0,
"{replay_path} scaffold player team should match"
);
}
}
let first_scaffold_frame = scaffold_timeline
.frames
.iter()
.find(|frame| !frame.players.is_empty())
.expect("fixture should produce at least one player frame");
let serialized_frame =
serde_json::to_value(first_scaffold_frame).expect("scaffold frame should serialize");
assert_eq!(
serialized_frame
.pointer("/team_zero")
.and_then(serde_json::Value::as_object)
.map(serde_json::Map::len),
Some(0)
);
assert_eq!(
serialized_frame
.pointer("/team_one")
.and_then(serde_json::Value::as_object)
.map(serde_json::Map::len),
Some(0)
);
let player = serialized_frame
.pointer("/players/0")
.and_then(serde_json::Value::as_object)
.expect("scaffold player should serialize as an object");
assert_eq!(
player.keys().cloned().collect::<Vec<_>>(),
["is_team_0", "name", "player_id"],
"{replay_path} scaffold player should serialize identity fields only"
);
}
#[test]
fn event_timeline_scaffold_matches_full_timeline_without_stat_snapshots() {
assert_event_timeline_scaffold_matches_full_timeline_without_stat_snapshots(
STATS_TIMELINE_FIXTURE,
);
}
#[test]
#[ignore = "wide replay-format parity is slow; run explicitly when changing compact timeline transfer"]
fn replay_format_fixture_event_timeline_scaffolds_match_full_timelines() {
let fixture_paths = replay_format_fixture_paths();
assert!(
fixture_paths.len() >= 10,
"expected replay-format docs to list checked-in fixtures"
);
for replay_path in fixture_paths {
println!("checking {replay_path}");
assert_event_timeline_scaffold_matches_full_timeline_without_stat_snapshots(&replay_path);
}
}
#[test]
#[ignore = "all replay asset scaffold parity is slow; run explicitly before removing transferred partial sums"]
fn all_asset_fixture_event_timeline_scaffolds_match_full_timelines_without_stat_snapshots() {
let fixture_paths = asset_replay_fixture_paths();
assert!(
!fixture_paths.is_empty(),
"expected checked-in replay asset fixtures"
);
assert!(
std::env::var("SUBTR_ACTOR_REPLAY_FIXTURE").is_ok() || fixture_paths.len() >= 20,
"expected broad replay fixture coverage"
);
for replay_path in fixture_paths {
println!("checking {replay_path}");
assert_event_timeline_scaffold_matches_full_timeline_without_stat_snapshots(&replay_path);
}
}