#![forbid(unsafe_code)]
use std::time::Duration;
use super::Animation;
const MAX_STEP_SECS: f64 = 0.004;
const DEFAULT_REST_THRESHOLD: f64 = 0.001;
const DEFAULT_VELOCITY_THRESHOLD: f64 = 0.01;
const MIN_STIFFNESS: f64 = 0.1;
#[derive(Debug, Clone)]
pub struct Spring {
position: f64,
velocity: f64,
target: f64,
initial: f64,
stiffness: f64,
damping: f64,
rest_threshold: f64,
velocity_threshold: f64,
at_rest: bool,
}
impl Spring {
#[must_use]
pub fn new(initial: f64, target: f64) -> Self {
Self {
position: initial,
velocity: 0.0,
target,
initial,
stiffness: 170.0,
damping: 26.0,
rest_threshold: DEFAULT_REST_THRESHOLD,
velocity_threshold: DEFAULT_VELOCITY_THRESHOLD,
at_rest: false,
}
}
#[must_use]
pub fn normalized() -> Self {
Self::new(0.0, 1.0)
}
#[must_use]
pub fn with_stiffness(mut self, k: f64) -> Self {
self.stiffness = k.max(MIN_STIFFNESS);
self
}
#[must_use]
pub fn with_damping(mut self, c: f64) -> Self {
self.damping = c.max(0.0);
self
}
#[must_use]
pub fn with_rest_threshold(mut self, threshold: f64) -> Self {
self.rest_threshold = threshold.abs();
self
}
#[must_use]
pub fn with_velocity_threshold(mut self, threshold: f64) -> Self {
self.velocity_threshold = threshold.abs();
self
}
#[inline]
#[must_use]
pub fn position(&self) -> f64 {
self.position
}
#[inline]
#[must_use]
pub fn velocity(&self) -> f64 {
self.velocity
}
#[inline]
#[must_use]
pub fn target(&self) -> f64 {
self.target
}
#[inline]
#[must_use]
pub fn stiffness(&self) -> f64 {
self.stiffness
}
#[inline]
#[must_use]
pub fn damping(&self) -> f64 {
self.damping
}
pub fn set_target(&mut self, target: f64) {
if (self.target - target).abs() > self.rest_threshold {
self.target = target;
self.at_rest = false;
}
}
pub fn impulse(&mut self, velocity_delta: f64) {
self.velocity += velocity_delta;
self.at_rest = false;
}
#[inline]
#[must_use]
pub fn is_at_rest(&self) -> bool {
self.at_rest
}
#[must_use]
pub fn critical_damping(&self) -> f64 {
2.0 * self.stiffness.sqrt()
}
fn step(&mut self, dt: f64) {
let displacement = self.position - self.target;
let spring_force = -self.stiffness * displacement;
let damping_force = -self.damping * self.velocity;
let acceleration = spring_force + damping_force;
self.velocity += acceleration * dt;
self.position += self.velocity * dt;
}
pub fn advance(&mut self, dt: Duration) {
if self.at_rest {
return;
}
let total_secs = dt.as_secs_f64();
if total_secs <= 0.0 {
return;
}
let mut remaining = total_secs;
while remaining > 0.0 {
let step_dt = remaining.min(MAX_STEP_SECS);
self.step(step_dt);
remaining -= step_dt;
}
let pos_delta = (self.position - self.target).abs();
let vel_abs = self.velocity.abs();
if pos_delta < self.rest_threshold && vel_abs < self.velocity_threshold {
self.position = self.target;
self.velocity = 0.0;
self.at_rest = true;
}
}
}
impl Animation for Spring {
fn tick(&mut self, dt: Duration) {
#[cfg(feature = "tracing")]
let _span = tracing::debug_span!(
"animation.tick",
animation_type = "spring",
dt_us = dt.as_micros() as u64,
at_rest = self.at_rest,
)
.entered();
self.advance(dt);
}
fn is_complete(&self) -> bool {
self.at_rest
}
fn value(&self) -> f32 {
(self.position as f32).clamp(0.0, 1.0)
}
fn reset(&mut self) {
self.position = self.initial;
self.velocity = 0.0;
self.at_rest = false;
}
}
pub mod presets {
use super::Spring;
#[must_use]
pub fn gentle() -> Spring {
Spring::normalized()
.with_stiffness(120.0)
.with_damping(20.0)
}
#[must_use]
pub fn bouncy() -> Spring {
Spring::normalized()
.with_stiffness(300.0)
.with_damping(10.0)
}
#[must_use]
pub fn stiff() -> Spring {
Spring::normalized()
.with_stiffness(400.0)
.with_damping(38.0)
}
#[must_use]
pub fn critical() -> Spring {
let k: f64 = 170.0;
let c = 2.0 * k.sqrt(); Spring::normalized().with_stiffness(k).with_damping(c)
}
#[must_use]
pub fn slow() -> Spring {
Spring::normalized().with_stiffness(50.0).with_damping(14.0)
}
}
#[cfg(test)]
mod tests {
use super::*;
const MS_16: Duration = Duration::from_millis(16);
fn simulate(spring: &mut Spring, frames: usize) {
for _ in 0..frames {
spring.tick(MS_16);
}
}
#[test]
fn spring_reaches_target() {
let mut spring = Spring::new(0.0, 100.0)
.with_stiffness(170.0)
.with_damping(26.0);
simulate(&mut spring, 200);
assert!(
(spring.position() - 100.0).abs() < 0.1,
"position: {}",
spring.position()
);
assert!(spring.is_complete());
}
#[test]
fn spring_starts_at_initial() {
let spring = Spring::new(50.0, 100.0);
assert!((spring.position() - 50.0).abs() < f64::EPSILON);
}
#[test]
fn spring_target_change() {
let mut spring = Spring::new(0.0, 100.0);
spring.set_target(200.0);
assert!((spring.target() - 200.0).abs() < f64::EPSILON);
}
#[test]
fn spring_with_high_damping_minimal_overshoot() {
let mut spring = Spring::new(0.0, 100.0)
.with_stiffness(170.0)
.with_damping(100.0);
let mut max_overshoot = 0.0_f64;
for _ in 0..300 {
spring.tick(MS_16);
let overshoot = spring.position() - 100.0;
if overshoot > max_overshoot {
max_overshoot = overshoot;
}
}
assert!(
max_overshoot < 1.0,
"High damping should minimize overshoot, got {max_overshoot}"
);
}
#[test]
fn critical_damping_no_overshoot() {
let mut spring = presets::critical();
spring.set_target(1.0);
let mut max_pos = 0.0_f64;
for _ in 0..300 {
spring.tick(MS_16);
if spring.position() > max_pos {
max_pos = spring.position();
}
}
assert!(
max_pos < 1.05,
"Critical damping should have negligible overshoot, got {max_pos}"
);
}
#[test]
fn bouncy_spring_overshoots() {
let mut spring = presets::bouncy();
let mut max_pos = 0.0_f64;
for _ in 0..200 {
spring.tick(MS_16);
if spring.position() > max_pos {
max_pos = spring.position();
}
}
assert!(
max_pos > 1.0,
"Bouncy spring should overshoot target, max was {max_pos}"
);
}
#[test]
fn normalized_spring_value_clamped() {
let mut spring = presets::bouncy();
for _ in 0..200 {
spring.tick(MS_16);
let v = spring.value();
assert!(
(0.0..=1.0).contains(&v),
"Animation::value() must be in [0,1], got {v}"
);
}
}
#[test]
fn spring_reset() {
let mut spring = Spring::new(0.0, 1.0);
simulate(&mut spring, 100);
assert!(spring.is_complete());
spring.reset();
assert!(!spring.is_complete());
assert!((spring.position() - 0.0).abs() < f64::EPSILON);
assert!((spring.velocity() - 0.0).abs() < f64::EPSILON);
}
#[test]
fn spring_impulse_wakes() {
let mut spring = Spring::new(0.0, 0.0);
simulate(&mut spring, 100);
assert!(spring.is_complete());
spring.impulse(50.0);
assert!(!spring.is_complete());
spring.tick(MS_16);
assert!(spring.position().abs() > 0.0);
}
#[test]
fn set_target_wakes_spring() {
let mut spring = Spring::new(0.0, 1.0);
simulate(&mut spring, 200);
assert!(spring.is_complete());
spring.set_target(2.0);
assert!(!spring.is_complete());
}
#[test]
fn set_target_same_value_stays_at_rest() {
let mut spring = Spring::new(0.0, 1.0);
simulate(&mut spring, 200);
assert!(spring.is_complete());
spring.set_target(1.0);
assert!(spring.is_complete());
}
#[test]
fn zero_dt_noop() {
let mut spring = Spring::new(0.0, 1.0);
let pos_before = spring.position();
spring.tick(Duration::ZERO);
assert!((spring.position() - pos_before).abs() < f64::EPSILON);
}
#[test]
fn large_dt_subdivided() {
let mut spring = Spring::new(0.0, 1.0)
.with_stiffness(170.0)
.with_damping(26.0);
spring.tick(Duration::from_secs(5));
assert!(
(spring.position() - 1.0).abs() < 0.01,
"position: {}",
spring.position()
);
}
#[test]
fn zero_stiffness_clamped() {
let spring = Spring::new(0.0, 1.0).with_stiffness(0.0);
assert!(spring.stiffness() >= MIN_STIFFNESS);
}
#[test]
fn negative_damping_clamped() {
let spring = Spring::new(0.0, 1.0).with_damping(-5.0);
assert!(spring.damping() >= 0.0);
}
#[test]
fn critical_damping_coefficient() {
let spring = Spring::new(0.0, 1.0).with_stiffness(100.0);
assert!((spring.critical_damping() - 20.0).abs() < f64::EPSILON);
}
#[test]
fn spring_negative_target() {
let mut spring = Spring::new(0.0, -1.0)
.with_stiffness(170.0)
.with_damping(26.0);
simulate(&mut spring, 200);
assert!(
(spring.position() - -1.0).abs() < 0.01,
"position: {}",
spring.position()
);
}
#[test]
fn spring_reverse_direction() {
let mut spring = Spring::new(1.0, 0.0)
.with_stiffness(170.0)
.with_damping(26.0);
simulate(&mut spring, 200);
assert!(
spring.position().abs() < 0.01,
"position: {}",
spring.position()
);
}
#[test]
fn presets_all_converge() {
let presets: Vec<(&str, Spring)> = vec![
("gentle", presets::gentle()),
("bouncy", presets::bouncy()),
("stiff", presets::stiff()),
("critical", presets::critical()),
("slow", presets::slow()),
];
for (name, mut spring) in presets {
simulate(&mut spring, 500);
assert!(
spring.is_complete(),
"preset '{name}' did not converge after 500 frames (pos: {}, vel: {})",
spring.position(),
spring.velocity()
);
}
}
#[test]
fn deterministic_across_runs() {
let run = || {
let mut spring = Spring::new(0.0, 1.0)
.with_stiffness(170.0)
.with_damping(26.0);
let mut positions = Vec::new();
for _ in 0..50 {
spring.tick(MS_16);
positions.push(spring.position());
}
positions
};
let run1 = run();
let run2 = run();
assert_eq!(run1, run2, "Spring should be deterministic");
}
#[test]
fn at_rest_spring_skips_computation() {
let mut spring = Spring::new(0.0, 1.0);
simulate(&mut spring, 200);
assert!(spring.is_complete());
let pos = spring.position();
spring.tick(MS_16);
assert!(
(spring.position() - pos).abs() < f64::EPSILON,
"At-rest spring should not change position on tick"
);
}
#[test]
fn animation_trait_value_for_normalized() {
let mut spring = Spring::normalized();
assert!((spring.value() - 0.0).abs() < f32::EPSILON);
simulate(&mut spring, 200);
assert!((spring.value() - 1.0).abs() < 0.01);
}
#[test]
fn stiff_preset_faster_than_slow() {
let mut stiff = presets::stiff();
let mut slow = presets::slow();
for _ in 0..30 {
stiff.tick(MS_16);
slow.tick(MS_16);
}
let stiff_delta = (stiff.position() - 1.0).abs();
let slow_delta = (slow.position() - 1.0).abs();
assert!(
stiff_delta < slow_delta,
"Stiff ({stiff_delta}) should be closer to target than slow ({slow_delta})"
);
}
#[test]
fn clone_independence() {
let mut spring = Spring::new(0.0, 1.0);
simulate(&mut spring, 5); let pos_after_5 = spring.position();
let mut clone = spring.clone();
simulate(&mut clone, 5);
assert!(
(clone.position() - pos_after_5).abs() > 0.01,
"clone should advance independently (clone: {}, original: {})",
clone.position(),
pos_after_5
);
assert!(
(spring.position() - pos_after_5).abs() < f64::EPSILON,
"original should not have changed"
);
}
#[test]
fn debug_format() {
let spring = Spring::new(0.0, 1.0);
let dbg = format!("{spring:?}");
assert!(dbg.contains("Spring"));
assert!(dbg.contains("position"));
assert!(dbg.contains("velocity"));
assert!(dbg.contains("target"));
}
#[test]
fn negative_stiffness_clamped() {
let spring = Spring::new(0.0, 1.0).with_stiffness(-100.0);
assert!(spring.stiffness() >= MIN_STIFFNESS);
}
#[test]
fn with_rest_threshold_builder() {
let spring = Spring::new(0.0, 1.0).with_rest_threshold(0.1);
assert!((spring.rest_threshold - 0.1).abs() < f64::EPSILON);
}
#[test]
fn with_rest_threshold_negative_takes_abs() {
let spring = Spring::new(0.0, 1.0).with_rest_threshold(-0.05);
assert!((spring.rest_threshold - 0.05).abs() < f64::EPSILON);
}
#[test]
fn with_velocity_threshold_builder() {
let spring = Spring::new(0.0, 1.0).with_velocity_threshold(0.5);
assert!((spring.velocity_threshold - 0.5).abs() < f64::EPSILON);
}
#[test]
fn with_velocity_threshold_negative_takes_abs() {
let spring = Spring::new(0.0, 1.0).with_velocity_threshold(-0.3);
assert!((spring.velocity_threshold - 0.3).abs() < f64::EPSILON);
}
#[test]
fn initial_equals_target_settles_immediately() {
let mut spring = Spring::new(5.0, 5.0);
spring.tick(MS_16);
assert!(spring.is_complete());
assert!((spring.position() - 5.0).abs() < f64::EPSILON);
}
#[test]
fn normalized_constructor() {
let spring = Spring::normalized();
assert!((spring.position() - 0.0).abs() < f64::EPSILON);
assert!((spring.target() - 1.0).abs() < f64::EPSILON);
}
#[test]
fn impulse_negative_velocity() {
let mut spring = Spring::new(0.5, 0.5);
spring.tick(MS_16); spring.impulse(-100.0);
assert!(!spring.is_complete());
spring.tick(MS_16);
assert!(
spring.position() < 0.5,
"Negative impulse should move position below target, got {}",
spring.position()
);
}
#[test]
fn impulse_on_moving_spring() {
let mut spring = Spring::new(0.0, 1.0);
spring.tick(MS_16);
let vel_before = spring.velocity();
spring.impulse(10.0);
assert!(
(spring.velocity() - (vel_before + 10.0)).abs() < f64::EPSILON,
"impulse should add to velocity"
);
}
#[test]
fn set_target_within_rest_threshold_stays_at_rest() {
let mut spring = Spring::new(0.0, 1.0).with_rest_threshold(0.01);
simulate(&mut spring, 300);
assert!(spring.is_complete());
spring.set_target(1.0 + 0.005);
assert!(
spring.is_complete(),
"set_target within rest_threshold should not wake spring"
);
}
#[test]
fn set_target_just_beyond_rest_threshold_wakes() {
let mut spring = Spring::new(0.0, 1.0).with_rest_threshold(0.01);
simulate(&mut spring, 300);
assert!(spring.is_complete());
spring.set_target(1.0 + 0.02);
assert!(
!spring.is_complete(),
"set_target beyond rest_threshold should wake spring"
);
}
#[test]
fn large_rest_threshold_settles_quickly() {
let mut spring = Spring::new(0.0, 1.0)
.with_stiffness(170.0)
.with_damping(26.0)
.with_rest_threshold(0.5)
.with_velocity_threshold(10.0);
simulate(&mut spring, 10);
assert!(
spring.is_complete(),
"Large thresholds should cause early settling (pos: {}, vel: {})",
spring.position(),
spring.velocity()
);
}
#[test]
fn value_clamps_negative_position() {
let mut spring = Spring::new(0.0, 0.0);
spring.impulse(-100.0);
spring.tick(MS_16);
assert!(spring.position() < 0.0);
assert!((spring.value() - 0.0).abs() < f32::EPSILON);
}
#[test]
fn value_clamps_above_one() {
let mut spring = Spring::new(0.0, 5.0);
simulate(&mut spring, 200);
assert!(spring.position() > 1.0);
assert!((spring.value() - 1.0).abs() < f32::EPSILON);
}
#[test]
fn zero_damping_oscillates() {
let mut spring = Spring::new(0.0, 1.0)
.with_stiffness(170.0)
.with_damping(0.0);
let mut crossed_target = false;
let mut crossed_back = false;
let mut above = false;
for _ in 0..200 {
spring.tick(MS_16);
if spring.position() > 1.0 {
above = true;
}
if above && spring.position() < 1.0 {
crossed_target = true;
}
if crossed_target && spring.position() > 1.0 {
crossed_back = true;
break;
}
}
assert!(crossed_back, "Zero-damping spring should oscillate");
}
#[test]
fn advance_at_rest_is_noop() {
let mut spring = Spring::new(0.0, 1.0);
simulate(&mut spring, 300);
assert!(spring.is_complete());
let pos = spring.position();
let vel = spring.velocity();
spring.advance(Duration::from_secs(10));
assert!((spring.position() - pos).abs() < f64::EPSILON);
assert!((spring.velocity() - vel).abs() < f64::EPSILON);
}
#[test]
fn reset_restores_initial() {
let mut spring = Spring::new(42.0, 100.0);
simulate(&mut spring, 200);
spring.reset();
assert!((spring.position() - 42.0).abs() < f64::EPSILON);
assert!((spring.velocity() - 0.0).abs() < f64::EPSILON);
assert!(!spring.is_complete());
}
#[test]
fn reset_after_impulse() {
let mut spring = Spring::new(0.0, 0.0);
spring.impulse(50.0);
spring.tick(MS_16);
spring.reset();
assert!((spring.position() - 0.0).abs() < f64::EPSILON);
assert!((spring.velocity() - 0.0).abs() < f64::EPSILON);
}
#[test]
fn multiple_set_target_chained() {
let mut spring = Spring::new(0.0, 1.0);
simulate(&mut spring, 50);
spring.set_target(2.0);
simulate(&mut spring, 50);
spring.set_target(0.0);
simulate(&mut spring, 300);
assert!(
spring.position().abs() < 0.01,
"Should converge to final target 0.0, got {}",
spring.position()
);
}
#[test]
fn animation_trait_overshoot_is_zero_for_spring() {
let mut spring = Spring::new(0.0, 1.0);
assert_eq!(spring.overshoot(), Duration::ZERO);
simulate(&mut spring, 300);
assert_eq!(spring.overshoot(), Duration::ZERO);
}
#[test]
fn preset_gentle_parameters() {
let s = presets::gentle();
assert!((s.stiffness() - 120.0).abs() < f64::EPSILON);
assert!((s.damping() - 20.0).abs() < f64::EPSILON);
}
#[test]
fn preset_bouncy_parameters() {
let s = presets::bouncy();
assert!((s.stiffness() - 300.0).abs() < f64::EPSILON);
assert!((s.damping() - 10.0).abs() < f64::EPSILON);
}
#[test]
fn preset_stiff_parameters() {
let s = presets::stiff();
assert!((s.stiffness() - 400.0).abs() < f64::EPSILON);
assert!((s.damping() - 38.0).abs() < f64::EPSILON);
}
#[test]
fn preset_slow_parameters() {
let s = presets::slow();
assert!((s.stiffness() - 50.0).abs() < f64::EPSILON);
assert!((s.damping() - 14.0).abs() < f64::EPSILON);
}
#[test]
fn preset_critical_is_critically_damped() {
let s = presets::critical();
let expected_damping = 2.0 * s.stiffness().sqrt();
assert!(
(s.damping() - expected_damping).abs() < f64::EPSILON,
"critical preset should have c = 2*sqrt(k)"
);
}
#[test]
fn timestep_independence_coarse_vs_fine() {
let total_ms = 1000u64;
let run_with_step = |step_ms: u64| -> f64 {
let mut spring = Spring::new(0.0, 1.0)
.with_stiffness(170.0)
.with_damping(26.0);
let steps = total_ms / step_ms;
let dt = Duration::from_millis(step_ms);
for _ in 0..steps {
spring.tick(dt);
}
spring.position()
};
let pos_1ms = run_with_step(1);
let pos_4ms = run_with_step(4); let pos_16ms = run_with_step(16); let pos_33ms = run_with_step(33);
let tolerance = 0.01;
assert!(
(pos_1ms - pos_4ms).abs() < tolerance,
"1ms vs 4ms: {pos_1ms} vs {pos_4ms}"
);
assert!(
(pos_1ms - pos_16ms).abs() < tolerance,
"1ms vs 16ms: {pos_1ms} vs {pos_16ms}"
);
assert!(
(pos_1ms - pos_33ms).abs() < tolerance,
"1ms vs 33ms: {pos_1ms} vs {pos_33ms}"
);
}
#[test]
fn timestep_independence_single_vs_many() {
let mut single = Spring::new(0.0, 1.0)
.with_stiffness(170.0)
.with_damping(26.0);
single.tick(Duration::from_millis(500));
let mut many = Spring::new(0.0, 1.0)
.with_stiffness(170.0)
.with_damping(26.0);
for _ in 0..500 {
many.tick(Duration::from_millis(1));
}
assert!(
(single.position() - many.position()).abs() < 0.02,
"single 500ms ({}) vs 500×1ms ({})",
single.position(),
many.position()
);
}
#[test]
fn critically_damped_settles_fastest() {
let k: f64 = 170.0;
let c_critical = 2.0 * k.sqrt();
let mut underdamped = Spring::new(0.0, 1.0)
.with_stiffness(k)
.with_damping(c_critical * 0.3);
let mut critical = Spring::new(0.0, 1.0)
.with_stiffness(k)
.with_damping(c_critical);
let mut overdamped = Spring::new(0.0, 1.0)
.with_stiffness(k)
.with_damping(c_critical * 3.0);
let threshold = 0.01;
let settle_frame = |spring: &mut Spring| -> usize {
for frame in 0..1000 {
spring.tick(MS_16);
if (spring.position() - 1.0).abs() < threshold
&& spring.velocity().abs() < threshold
{
return frame;
}
}
1000
};
let ud_frames = settle_frame(&mut underdamped);
let cd_frames = settle_frame(&mut critical);
let od_frames = settle_frame(&mut overdamped);
assert!(
cd_frames <= ud_frames,
"critical ({cd_frames}) should settle no later than underdamped ({ud_frames})"
);
assert!(
cd_frames <= od_frames,
"critical ({cd_frames}) should settle no later than overdamped ({od_frames})"
);
}
#[test]
fn overdamped_no_oscillation() {
let k: f64 = 170.0;
let c_critical = 2.0 * k.sqrt();
let mut spring = Spring::new(0.0, 1.0)
.with_stiffness(k)
.with_damping(c_critical * 3.0);
let mut prev_pos = 0.0;
for _ in 0..500 {
spring.tick(MS_16);
let pos = spring.position();
assert!(
pos >= prev_pos - f64::EPSILON,
"overdamped spring should not oscillate: prev={prev_pos}, cur={pos}"
);
prev_pos = pos;
}
}
#[test]
fn underdamped_oscillates_then_settles() {
let k: f64 = 170.0;
let c_critical = 2.0 * k.sqrt();
let mut spring = Spring::new(0.0, 1.0)
.with_stiffness(k)
.with_damping(c_critical * 0.2);
let mut overshot = false;
for _ in 0..200 {
spring.tick(MS_16);
if spring.position() > 1.0 {
overshot = true;
break;
}
}
assert!(overshot, "underdamped spring should overshoot target");
for _ in 0..2000 {
spring.tick(MS_16);
}
assert!(
spring.is_complete(),
"underdamped spring should eventually settle (pos: {}, vel: {})",
spring.position(),
spring.velocity()
);
}
#[test]
fn zero_displacement_spring_completes_immediately() {
let mut spring = Spring::new(1.0, 1.0);
spring.tick(MS_16);
assert!(
spring.is_complete(),
"spring with initial == target should settle after one tick"
);
assert!((spring.position() - 1.0).abs() < f64::EPSILON);
}
}
#[cfg(all(test, feature = "tracing"))]
mod tracing_tests {
use super::*;
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use tracing_subscriber::layer::SubscriberExt;
use tracing_subscriber::registry::LookupSpan;
#[derive(Debug, Clone)]
struct CapturedSpan {
name: String,
fields: HashMap<String, String>,
}
struct SpanCapture {
spans: Arc<Mutex<Vec<CapturedSpan>>>,
}
impl SpanCapture {
fn new() -> (Self, Arc<Mutex<Vec<CapturedSpan>>>) {
let spans = Arc::new(Mutex::new(Vec::new()));
(
Self {
spans: spans.clone(),
},
spans,
)
}
}
struct FieldVisitor(Vec<(String, String)>);
impl tracing::field::Visit for FieldVisitor {
fn record_debug(&mut self, field: &tracing::field::Field, value: &dyn std::fmt::Debug) {
self.0
.push((field.name().to_string(), format!("{value:?}")));
}
fn record_u64(&mut self, field: &tracing::field::Field, value: u64) {
self.0.push((field.name().to_string(), value.to_string()));
}
fn record_str(&mut self, field: &tracing::field::Field, value: &str) {
self.0.push((field.name().to_string(), value.to_string()));
}
fn record_bool(&mut self, field: &tracing::field::Field, value: bool) {
self.0.push((field.name().to_string(), value.to_string()));
}
}
impl<S> tracing_subscriber::Layer<S> for SpanCapture
where
S: tracing::Subscriber + for<'a> LookupSpan<'a>,
{
fn on_new_span(
&self,
attrs: &tracing::span::Attributes<'_>,
_id: &tracing::span::Id,
_ctx: tracing_subscriber::layer::Context<'_, S>,
) {
let mut visitor = FieldVisitor(Vec::new());
attrs.record(&mut visitor);
let mut fields: HashMap<String, String> = visitor.0.into_iter().collect();
for field in attrs.metadata().fields() {
fields.entry(field.name().to_string()).or_default();
}
self.spans.lock().unwrap().push(CapturedSpan {
name: attrs.metadata().name().to_string(),
fields,
});
}
}
#[test]
fn tick_emits_animation_tick_span() {
let (layer, spans) = SpanCapture::new();
let subscriber = tracing_subscriber::registry().with(layer);
tracing::subscriber::with_default(subscriber, || {
let mut spring = Spring::new(0.0, 1.0)
.with_stiffness(170.0)
.with_damping(26.0);
spring.tick(Duration::from_millis(16));
});
let captured = spans.lock().unwrap();
let tick_spans: Vec<_> = captured
.iter()
.filter(|s| s.name == "animation.tick")
.collect();
assert!(
!tick_spans.is_empty(),
"Spring::tick() should emit an animation.tick span"
);
assert_eq!(
tick_spans[0]
.fields
.get("animation_type")
.map(String::as_str),
Some("spring"),
"animation.tick span must have animation_type=spring"
);
assert!(
tick_spans[0].fields.contains_key("dt_us"),
"animation.tick span must have dt_us field"
);
assert!(
tick_spans[0].fields.contains_key("at_rest"),
"animation.tick span must have at_rest field"
);
}
#[test]
fn tick_at_rest_records_at_rest_true() {
let (layer, spans) = SpanCapture::new();
let subscriber = tracing_subscriber::registry().with(layer);
tracing::subscriber::with_default(subscriber, || {
let mut spring = Spring::new(1.0, 1.0);
spring.tick(Duration::from_millis(16));
assert!(spring.is_complete());
spring.tick(Duration::from_millis(16));
});
let captured = spans.lock().unwrap();
let tick_spans: Vec<_> = captured
.iter()
.filter(|s| s.name == "animation.tick")
.collect();
assert!(
tick_spans.len() >= 2,
"expected at least 2 animation.tick spans"
);
assert_eq!(
tick_spans[1].fields.get("at_rest").map(String::as_str),
Some("true"),
"second tick should have at_rest=true"
);
}
}