use super::*;
fn rigid_body(position: glam::Vec3, velocity: Option<glam::Vec3>) -> boxcars::RigidBody {
boxcars::RigidBody {
sleeping: false,
location: glam_to_vec(&position),
rotation: boxcars::Quaternion {
x: 0.0,
y: 0.0,
z: 0.0,
w: 1.0,
},
linear_velocity: velocity.map(|velocity| glam_to_vec(&velocity)),
angular_velocity: Some(glam_to_vec(&glam::Vec3::ZERO)),
}
}
fn touch_event(frame: usize, time: f32) -> TouchEvent {
TouchEvent {
touch_id: None,
time,
frame,
team_is_team_0: true,
player: None,
player_position: None,
closest_approach_distance: None,
contact_local_ball_position: None,
contact_local_hitbox_point: None,
contact_world_hitbox_point: None,
dodge_contact: false,
}
}
#[test]
fn touch_event_timestamp_ordering_uses_frame_then_time() {
let earlier = touch_event(10, 1.0);
let later_frame = touch_event(11, 0.9);
let later_time_same_frame = touch_event(10, 1.1);
assert!(TouchEvent::timestamp_ordering(&earlier, &later_frame).is_lt());
assert!(TouchEvent::timestamp_ordering(&earlier, &later_time_same_frame).is_lt());
assert!(TouchEvent::timestamp_ordering(&later_frame, &earlier).is_gt());
}
#[test]
fn shot_event_metadata_calculates_speed_distance_and_goal_alignment() {
let ball = rigid_body(
glam::Vec3::new(300.0, 1000.0, 120.0),
Some(glam::Vec3::new(0.0, 2000.0, 0.0)),
);
let player = rigid_body(
glam::Vec3::new(300.0, 850.0, 20.0),
Some(glam::Vec3::new(0.0, 1200.0, 0.0)),
);
let metadata = ShotEventMetadata::from_rigid_bodies(true, &ball, Some(&player));
assert_eq!(metadata.shot_touch_position, ball.location);
assert_eq!(metadata.ball_position, ball.location);
assert_eq!(metadata.ball_velocity, ball.linear_velocity);
assert_eq!(metadata.ball_speed, Some(2000.0));
assert_eq!(metadata.player_position, Some(player.location));
assert_eq!(metadata.player_speed, Some(1200.0));
assert!((metadata.player_distance_to_ball.unwrap() - 180.27756).abs() < 0.001);
assert_eq!(metadata.target_goal_position.y, 5120.0);
assert_eq!(metadata.target_goal_position.z, ball.location.z);
assert_eq!(metadata.distance_to_goal_line, 4120.0);
assert!(metadata.distance_to_goal_center > 4130.0);
assert!(metadata.ball_goal_alignment.unwrap() > 0.99);
assert!(metadata.ball_speed_toward_goal.unwrap() > 1990.0);
}
#[test]
fn shot_event_metadata_uses_orange_goal_direction() {
let ball = rigid_body(
glam::Vec3::new(0.0, -1000.0, 120.0),
Some(glam::Vec3::new(0.0, -1500.0, 0.0)),
);
let metadata = ShotEventMetadata::from_rigid_bodies(false, &ball, None);
assert_eq!(metadata.target_goal_position.y, -5120.0);
assert_eq!(metadata.distance_to_goal_line, 4120.0);
assert_eq!(metadata.player_position, None);
assert!(metadata.ball_goal_alignment.unwrap() > 0.99);
assert!(metadata.ball_speed_toward_goal.unwrap() > 1490.0);
}
#[test]
fn shot_event_metadata_projects_goal_line_crossing_for_saved_shots() {
let ball = rigid_body(
glam::Vec3::new(25.0, 5000.0, crate::STANDARD_BALL_RADIUS + 0.5),
Some(glam::Vec3::new(0.0, 720.0, -120.0)),
);
let metadata = ShotEventMetadata::from_rigid_bodies(true, &ball, None);
let crossing = metadata
.projected_goal_line_crossing
.expect("shot should project across the positive goal line");
assert!(crossing.time_after_shot > 0.0);
assert_eq!(crossing.position.y, crate::STANDARD_GOAL_LINE_Y);
assert_eq!(crossing.position.x, 25.0);
assert!(crossing.position.z >= crate::STANDARD_BALL_RADIUS);
assert!(crossing.inside_goal_mouth);
assert_eq!(
crossing.prediction_kind,
ShotGoalLineCrossingPredictionKind::SurfaceBounces
);
let target_hit = metadata
.projected_goal_target_hit
.expect("goal-bound shot should project a target hit");
assert_eq!(target_hit.hit_kind, ShotGoalTargetHitKind::GoalLine);
assert_eq!(target_hit.position.y, crate::STANDARD_GOAL_LINE_Y);
}
#[test]
fn shot_event_metadata_projects_goal_line_crossing_after_side_wall_bounce() {
let ball = rigid_body(
glam::Vec3::new(
crate::STANDARD_ARENA_SIDE_WALL_X - crate::STANDARD_BALL_RADIUS - 2.0,
crate::STANDARD_GOAL_LINE_Y - 240.0,
200.0,
),
Some(glam::Vec3::new(600.0, 1200.0, 0.0)),
);
let metadata = ShotEventMetadata::from_rigid_bodies(true, &ball, None);
let crossing = metadata
.projected_goal_line_crossing
.expect("shot should project across the positive goal line after wall bounce");
assert_eq!(crossing.position.y, crate::STANDARD_GOAL_LINE_Y);
assert!(crossing.position.x < ball.location.x);
assert!(!crossing.inside_goal_mouth);
assert_eq!(
crossing.prediction_kind,
ShotGoalLineCrossingPredictionKind::SurfaceBounces
);
}
#[test]
fn shot_event_metadata_projects_back_wall_hit_for_wide_shot() {
let ball = rigid_body(
glam::Vec3::new(
crate::STANDARD_GOAL_MOUTH_HALF_WIDTH_X
+ crate::STANDARD_GOAL_FRAME_RADIUS
+ crate::STANDARD_BALL_RADIUS * 2.0
+ 10.0,
crate::STANDARD_GOAL_LINE_Y - 220.0,
200.0,
),
Some(glam::Vec3::new(0.0, 1200.0, 0.0)),
);
let metadata = ShotEventMetadata::from_rigid_bodies(true, &ball, None);
let target_hit = metadata
.projected_goal_target_hit
.expect("wide shot should project a back-wall hit");
assert_eq!(target_hit.hit_kind, ShotGoalTargetHitKind::BackWall);
assert_eq!(target_hit.position.y, crate::STANDARD_GOAL_LINE_Y);
assert_eq!(target_hit.position.x, ball.location.x);
}
#[test]
fn shot_event_metadata_projects_goal_frame_hit_for_blocked_post_shot() {
let ball = rigid_body(
glam::Vec3::new(
crate::STANDARD_GOAL_MOUTH_HALF_WIDTH_X
+ crate::STANDARD_GOAL_FRAME_RADIUS
+ crate::STANDARD_BALL_RADIUS
- 5.0,
crate::STANDARD_GOAL_LINE_Y - 220.0,
200.0,
),
Some(glam::Vec3::new(0.0, 1200.0, 0.0)),
);
let metadata = ShotEventMetadata::from_rigid_bodies(true, &ball, None);
let target_hit = metadata
.projected_goal_target_hit
.expect("near-post shot should project a goal-frame hit");
assert_eq!(target_hit.hit_kind, ShotGoalTargetHitKind::GoalFrame);
assert!(target_hit.position.x > crate::STANDARD_GOAL_MOUTH_HALF_WIDTH_X);
}
#[test]
fn shot_event_metadata_falls_back_to_free_flight_crossing_when_surface_model_blocks() {
let ball = rigid_body(
glam::Vec3::new(1689.0, 3808.0, 1142.0),
Some(glam::Vec3::new(-608.0, 1090.0, -27.0)),
);
let metadata = ShotEventMetadata::from_rigid_bodies(true, &ball, None);
let crossing = metadata
.projected_goal_line_crossing
.expect("shot should fall back to free-flight goal-line crossing");
assert_eq!(crossing.position.y, crate::STANDARD_GOAL_LINE_Y);
assert!(crossing.inside_goal_mouth);
assert_eq!(
crossing.prediction_kind,
ShotGoalLineCrossingPredictionKind::FreeFlight
);
}
#[test]
fn saved_shot_free_flight_crossing_rejects_below_floor_projection() {
let below_floor_crossing = crate::BallGoalLineCrossing {
time: 0.5,
position: glam::Vec3::new(0.0, crate::STANDARD_GOAL_LINE_Y, -100.0),
velocity: Some(glam::Vec3::new(0.0, 1000.0, -500.0)),
inside_goal_mouth: false,
};
let playable_crossing = crate::BallGoalLineCrossing {
position: glam::Vec3::new(
0.0,
crate::STANDARD_GOAL_LINE_Y,
crate::STANDARD_BALL_RADIUS,
),
inside_goal_mouth: true,
..below_floor_crossing
};
assert!(!saved_shot_free_flight_crossing_is_physically_plausible(
&below_floor_crossing
));
assert!(saved_shot_free_flight_crossing_is_physically_plausible(
&playable_crossing
));
}
#[test]
fn saved_shot_prediction_keeps_playable_crossing() {
let ball = rigid_body(
glam::Vec3::new(1689.0, 3808.0, 1142.0),
Some(glam::Vec3::new(-608.0, 1090.0, -27.0)),
);
let crossing = ShotGoalLineCrossing::predict_saved_shot_from_rigid_body(true, &ball)
.expect("saved-shot projection should keep a physically plausible crossing");
assert_eq!(crossing.position.y, crate::STANDARD_GOAL_LINE_Y);
assert!(crossing.inside_goal_mouth);
assert_eq!(
crossing.prediction_kind,
ShotGoalLineCrossingPredictionKind::SavedShotPreSaveSurfaceBounces
);
}
#[test]
fn saved_shot_prediction_reports_near_post_cross_location() {
let ball = rigid_body(
glam::Vec3::new(
crate::STANDARD_GOAL_MOUTH_HALF_WIDTH_X
+ crate::STANDARD_GOAL_FRAME_RADIUS
+ crate::STANDARD_BALL_RADIUS
- 5.0,
crate::STANDARD_GOAL_LINE_Y - 220.0,
200.0,
),
Some(glam::Vec3::new(0.0, 1200.0, 0.0)),
);
let saved_crossing = ShotGoalLineCrossing::predict_saved_shot_from_rigid_body(true, &ball)
.expect("saved shot should report the counterfactual goal-line crossing location");
assert_eq!(saved_crossing.position.x, ball.location.x);
assert_eq!(saved_crossing.position.y, crate::STANDARD_GOAL_LINE_Y);
assert!(!saved_crossing.inside_goal_mouth);
assert_eq!(
saved_crossing.prediction_kind,
ShotGoalLineCrossingPredictionKind::SavedShotPreSaveSurfaceBounces
);
}
#[test]
fn shot_event_metadata_omits_crossing_when_ball_moves_away_from_goal() {
let ball = rigid_body(
glam::Vec3::new(0.0, 5000.0, 200.0),
Some(glam::Vec3::new(0.0, -1000.0, 0.0)),
);
let metadata = ShotEventMetadata::from_rigid_bodies(true, &ball, None);
assert!(metadata.projected_goal_line_crossing.is_none());
assert!(metadata.projected_goal_target_hit.is_none());
}
#[test]
fn shot_event_metadata_omits_crossing_without_ball_velocity() {
let ball = rigid_body(glam::Vec3::new(0.0, 4000.0, 200.0), None);
let metadata = ShotEventMetadata::from_rigid_bodies(true, &ball, None);
assert!(metadata.projected_goal_line_crossing.is_none());
assert!(metadata.projected_goal_target_hit.is_none());
}
fn date_header(value: &str) -> Vec<(String, boxcars::HeaderProp)> {
vec![(
"Date".to_string(),
boxcars::HeaderProp::Str(value.to_string()),
)]
}
#[test]
fn parse_header_datetime_utc_converts_plain_format_as_eastern() {
assert_eq!(
parse_header_datetime_utc("2026-04-28 14-30-00"),
Some((2026, 4, 28, 19, 30))
);
assert_eq!(
parse_header_datetime_utc("2026-04-28 20-00-00"),
Some((2026, 4, 29, 1, 0))
);
}
#[test]
fn parse_header_datetime_utc_uses_rfc3339_offset() {
assert_eq!(
parse_header_datetime_utc("2026-04-17T15:01:25-07:00"),
Some((2026, 4, 17, 22, 1))
);
}
#[test]
fn season_for_datetime_resolves_boundary_day_by_time() {
assert_eq!(
season_for_datetime((2026, 6, 10, 15, 59)),
Some(ReplaySeason::new(SeasonEra::FreeToPlay, 22))
);
assert_eq!(
season_for_datetime((2026, 6, 10, 16, 0)),
Some(ReplaySeason::new(SeasonEra::FreeToPlay, 23))
);
}
#[test]
fn parse_header_date_handles_replay_and_rfc3339_formats() {
assert_eq!(
parse_header_date("2026-04-28 14-30-00"),
Some((2026, 4, 28))
);
assert_eq!(
parse_header_date("2026-04-17T15:01:25-07:00"),
Some((2026, 4, 17))
);
assert_eq!(parse_header_date("not-a-date"), None);
assert_eq!(parse_header_date(""), None);
}
#[test]
fn season_for_date_returns_most_recent_started_season() {
let f21 = ReplaySeason::new(SeasonEra::FreeToPlay, 21);
assert_eq!(season_for_date((2025, 12, 10)), Some(f21));
assert_eq!(season_for_date((2026, 1, 14)), Some(f21));
assert_eq!(
season_for_date((2026, 4, 17)),
Some(ReplaySeason::new(SeasonEra::FreeToPlay, 22))
);
assert_eq!(
season_for_date((2026, 6, 10)),
Some(ReplaySeason::new(SeasonEra::FreeToPlay, 23))
);
assert_eq!(
season_for_date((2024, 12, 4)),
Some(ReplaySeason::new(SeasonEra::FreeToPlay, 17))
);
assert_eq!(
season_for_date((2019, 9, 1)),
Some(ReplaySeason::new(SeasonEra::Legacy, 9))
);
assert_eq!(season_for_date((2015, 1, 1)), None);
}
#[test]
fn season_start_returns_utc_go_live_instant() {
let s23 = ReplaySeason::new(SeasonEra::FreeToPlay, 23)
.start()
.expect("season 23 has a boundary entry");
assert_eq!(s23, SeasonStart::new(2026, 6, 10, 16, 0));
assert_eq!(s23.date(), (2026, 6, 10));
assert_eq!(ReplaySeason::new(SeasonEra::FreeToPlay, 99).start(), None);
}
#[test]
fn season_code_round_trips_era_and_number() {
assert_eq!(ReplaySeason::new(SeasonEra::FreeToPlay, 21).code(), "f21");
assert_eq!(ReplaySeason::new(SeasonEra::Legacy, 14).code(), "s14");
}
#[test]
fn season_from_headers_resolves_from_date() {
assert_eq!(
season_from_headers(&date_header("2026-04-17T15:01:25-07:00")),
Some(ReplaySeason::new(SeasonEra::FreeToPlay, 22))
);
assert_eq!(season_from_headers(&[]), None);
}