use super::*;
fn make_point(x: f32, y: f32) -> LaserPoint {
LaserPoint::new(x, y, 65535, 0, 0, 65535)
}
#[test]
fn test_authored_frame_new_and_points() {
let pts = vec![make_point(0.0, 0.0), make_point(1.0, 1.0)];
let frame = Frame::new(pts.clone());
assert_eq!(frame.points().len(), 2);
assert_eq!(frame.points()[0].x, 0.0);
assert_eq!(frame.points()[1].x, 1.0);
}
#[test]
fn test_authored_frame_first_last_point() {
let frame = Frame::new(vec![
make_point(-1.0, -1.0),
make_point(0.0, 0.0),
make_point(1.0, 1.0),
]);
assert_eq!(frame.first_point().unwrap().x, -1.0);
assert_eq!(frame.last_point().unwrap().x, 1.0);
}
#[test]
fn test_authored_frame_empty() {
let frame = Frame::new(vec![]);
assert!(frame.is_empty());
assert_eq!(frame.len(), 0);
assert!(frame.first_point().is_none());
assert!(frame.last_point().is_none());
}
#[test]
fn test_authored_frame_len() {
let frame = Frame::new(vec![make_point(0.0, 0.0); 42]);
assert_eq!(frame.len(), 42);
assert!(!frame.is_empty());
}
#[test]
fn test_authored_frame_from_vec() {
let pts = vec![make_point(0.5, 0.5)];
let frame: Frame = pts.into();
assert_eq!(frame.len(), 1);
assert_eq!(frame.points()[0].x, 0.5);
}
#[test]
fn test_authored_frame_clone_shares_data() {
let frame = Frame::new(vec![make_point(0.0, 0.0)]);
let clone = frame.clone();
assert_eq!(frame.len(), clone.len());
assert_eq!(frame.points()[0].x, clone.points()[0].x);
}
fn call_default_transition(from: &LaserPoint, to: &LaserPoint) -> TransitionPlan {
let tf = default_transition(30_000);
tf(from, to)
}
#[test]
fn test_default_transition_scales_with_distance() {
let near = call_default_transition(&make_point(0.0, 0.0), &make_point(0.005, 0.0));
let far = call_default_transition(&make_point(-1.0, -1.0), &make_point(1.0, 1.0));
let mid = call_default_transition(&make_point(0.0, 0.0), &make_point(1.0, 0.0));
let near_pts = match near {
TransitionPlan::Transition(pts) => pts,
_ => panic!("near should produce Transition"),
};
let far_pts = match far {
TransitionPlan::Transition(pts) => pts,
_ => panic!("far should produce Transition"),
};
let mid_pts = match mid {
TransitionPlan::Transition(pts) => pts,
_ => panic!("mid should produce Transition"),
};
assert!(
near_pts.len() >= 3,
"near should still produce blanking, got {}",
near_pts.len()
);
assert!(
far_pts.len() > mid_pts.len(),
"far should produce more points than medium"
);
assert!(
mid_pts.len() > near_pts.len(),
"medium should produce more points than near"
);
}
#[test]
fn test_default_transition_always_blanks() {
let result = call_default_transition(&make_point(0.0, 0.0), &make_point(0.01, 0.0));
let pts = match result {
TransitionPlan::Transition(pts) => pts,
_ => panic!("should produce Transition"),
};
assert!(
pts.len() >= 3,
"even tiny distance should produce blanking, got {}",
pts.len()
);
let result = call_default_transition(&make_point(0.0, 0.0), &make_point(1.0, 0.0));
let pts2 = match result {
TransitionPlan::Transition(pts) => pts,
_ => panic!("should produce Transition"),
};
assert!(pts2.len() > pts.len());
}
#[test]
fn test_default_transition_all_blanked() {
let from = make_point(0.0, 0.0);
let to = make_point(1.0, 1.0);
let result = match call_default_transition(&from, &to) {
TransitionPlan::Transition(pts) => pts,
_ => panic!("expected Transition"),
};
for p in &result {
assert_eq!(p.r, 0, "r should be 0");
assert_eq!(p.g, 0, "g should be 0");
assert_eq!(p.b, 0, "b should be 0");
assert_eq!(p.intensity, 0, "intensity should be 0");
}
}
#[test]
fn test_default_transition_three_phase_structure() {
let from = make_point(0.0, 0.0);
let to = make_point(1.0, 0.0);
let result = match call_default_transition(&from, &to) {
TransitionPlan::Transition(pts) => pts,
_ => panic!("expected Transition"),
};
assert_eq!(
result.len(),
47,
"3 end_dwell + 32 transit + 12 start_dwell"
);
for p in &result[..3] {
assert_eq!(p.x, 0.0, "end dwell should be at from.x");
assert_eq!(p.intensity, 0);
}
for p in &result[3..35] {
assert!(
p.x > 0.0 && p.x < 1.0,
"transit should be between from and to"
);
assert_eq!(p.intensity, 0);
}
for p in &result[35..] {
assert_eq!(p.x, 1.0, "start dwell should be at to.x");
assert_eq!(p.intensity, 0);
}
}
#[test]
fn test_default_transition_full_diagonal() {
let from = make_point(-1.0, -1.0);
let to = make_point(1.0, 1.0);
let result = match call_default_transition(&from, &to) {
TransitionPlan::Transition(pts) => pts,
_ => panic!("expected Transition"),
};
assert_eq!(result.len(), 79, "3 end + 64 transit + 12 start");
}
#[test]
fn test_default_transition_same_point() {
let p = make_point(0.5, -0.3);
let result = match call_default_transition(&p, &p) {
TransitionPlan::Transition(pts) => pts,
_ => panic!("expected Transition"),
};
assert_eq!(result.len(), 15, "end_dwell + start_dwell, no transit");
for pt in &result {
assert_eq!(pt.intensity, 0);
assert_eq!(pt.x, 0.5);
assert_eq!(pt.y, -0.3);
}
}
#[test]
fn test_default_transition_quintic_easing() {
let from = make_point(0.0, 0.0);
let to = make_point(1.0, 0.0);
let result = match call_default_transition(&from, &to) {
TransitionPlan::Transition(pts) => pts,
_ => panic!("expected Transition"),
};
let transit = &result[3..35];
assert!(
transit[0].x < 0.25,
"first transit point should be near from, got {}",
transit[0].x
);
assert!(
transit[31].x > 0.75,
"last transit point should be near to, got {}",
transit[31].x
);
}
#[test]
fn test_default_transition_pps_scales_dwells() {
let tf_low = default_transition(10_000);
let tf_high = default_transition(100_000);
let from = make_point(0.0, 0.0);
let to = make_point(0.0, 0.0);
let low_pts = match tf_low(&from, &to) {
TransitionPlan::Transition(pts) => pts,
_ => panic!("expected Transition"),
};
let high_pts = match tf_high(&from, &to) {
TransitionPlan::Transition(pts) => pts,
_ => panic!("expected Transition"),
};
assert!(
high_pts.len() > low_pts.len(),
"higher PPS should produce more dwell points: {} vs {}",
high_pts.len(),
low_pts.len()
);
}
fn make_engine() -> PresentationEngine {
PresentationEngine::new(Box::new(|from: &LaserPoint, to: &LaserPoint| {
TransitionPlan::Transition(vec![
LaserPoint::blanked(from.x * 0.5 + to.x * 0.5, from.y * 0.5 + to.y * 0.5),
LaserPoint::blanked(to.x, to.y),
])
}))
}
fn make_engine_no_transition() -> PresentationEngine {
PresentationEngine::new(Box::new(|_: &LaserPoint, _: &LaserPoint| {
TransitionPlan::Transition(vec![])
}))
}
fn make_engine_coalesce() -> PresentationEngine {
PresentationEngine::new(Box::new(|_: &LaserPoint, _: &LaserPoint| {
TransitionPlan::Coalesce
}))
}
fn make_frame(points: Vec<LaserPoint>) -> Frame {
Frame::new(points)
}
#[test]
fn test_engine_before_first_frame_blanks_at_origin() {
let mut engine = make_engine();
let mut buffer = vec![LaserPoint::default(); 10];
let n = engine.fill_chunk(&mut buffer, 10);
assert_eq!(n, 10);
for p in &buffer {
assert_eq!(p.x, 0.0);
assert_eq!(p.y, 0.0);
assert_eq!(p.intensity, 0);
}
}
#[test]
fn test_engine_set_pending_promotes_when_no_current() {
let mut engine = make_engine_no_transition();
let frame = make_frame(vec![make_point(1.0, 0.0), make_point(2.0, 0.0)]);
engine.set_pending(frame);
assert!(engine.current_base.is_some());
assert!(engine.pending_base.is_none());
}
#[test]
fn test_engine_set_pending_overwrites_existing_pending() {
let mut engine = make_engine_no_transition();
let frame_a = make_frame(vec![make_point(1.0, 0.0)]);
let frame_b = make_frame(vec![make_point(2.0, 0.0)]);
let frame_c = make_frame(vec![make_point(3.0, 0.0)]);
engine.set_pending(frame_a); engine.set_pending(frame_b); engine.set_pending(frame_c);
assert!(engine.pending_base.is_some());
assert_eq!(engine.pending_base.as_ref().unwrap().points()[0].x, 3.0);
}
#[test]
fn test_engine_fill_chunk_cycles_frame() {
let mut engine = make_engine_no_transition();
let frame = make_frame(vec![make_point(1.0, 0.0), make_point(2.0, 0.0)]);
engine.set_pending(frame);
let mut buffer = vec![LaserPoint::default(); 6];
let n = engine.fill_chunk(&mut buffer, 6);
assert_eq!(n, 6);
assert_eq!(buffer[0].x, 1.0);
assert_eq!(buffer[1].x, 2.0);
assert_eq!(buffer[2].x, 1.0);
assert_eq!(buffer[3].x, 2.0);
}
#[test]
fn test_engine_fill_chunk_self_loop_with_transition() {
let mut engine = make_engine(); let frame = make_frame(vec![make_point(0.0, 0.0), make_point(1.0, 0.0)]);
engine.set_pending(frame);
let mut buffer = vec![LaserPoint::default(); 8];
let n = engine.fill_chunk(&mut buffer, 8);
assert_eq!(n, 8);
assert_eq!(buffer[0].x, 0.0); assert_eq!(buffer[1].x, 1.0); assert_eq!(buffer[2].intensity, 0);
assert_eq!(buffer[3].intensity, 0);
assert_eq!(buffer[4].x, 0.0);
assert_eq!(buffer[5].x, 1.0);
}
#[test]
fn test_engine_fill_chunk_frame_change_inserts_transition() {
let mut engine = make_engine(); let frame_a = make_frame(vec![make_point(0.0, 0.0)]);
let frame_b = make_frame(vec![make_point(1.0, 0.0)]);
engine.set_pending(frame_a);
engine.set_pending(frame_b);
let mut buffer = vec![LaserPoint::default(); 6];
let n = engine.fill_chunk(&mut buffer, 6);
assert_eq!(n, 6);
assert_eq!(buffer[0].x, 0.0);
assert_eq!(buffer[0].intensity, 65535);
assert_eq!(buffer[1].intensity, 0);
assert_eq!(buffer[2].intensity, 0);
assert_eq!(buffer[3].x, 1.0);
assert_eq!(buffer[3].intensity, 65535);
assert_eq!(buffer[4].intensity, 0);
assert_eq!(buffer[5].intensity, 0);
}
#[test]
fn test_engine_fill_chunk_promotes_pending_at_frame_end() {
let mut engine = make_engine_no_transition();
let frame_a = make_frame(vec![make_point(1.0, 0.0)]);
let frame_b = make_frame(vec![make_point(2.0, 0.0)]);
engine.set_pending(frame_a);
engine.set_pending(frame_b);
let mut buffer = vec![LaserPoint::default(); 4];
let n = engine.fill_chunk(&mut buffer, 4);
assert_eq!(n, 4);
assert_eq!(buffer[0].x, 1.0); assert_eq!(buffer[1].x, 2.0); assert_eq!(buffer[2].x, 2.0); }
#[test]
fn test_engine_fill_chunk_frame_skip() {
let mut engine = make_engine_no_transition();
let frame_a = make_frame(vec![make_point(1.0, 0.0)]);
let frame_b = make_frame(vec![make_point(2.0, 0.0)]);
let frame_c = make_frame(vec![make_point(3.0, 0.0)]);
engine.set_pending(frame_a);
engine.set_pending(frame_b);
engine.set_pending(frame_c);
let mut buffer = vec![LaserPoint::default(); 4];
engine.fill_chunk(&mut buffer, 4);
assert_eq!(buffer[0].x, 1.0);
assert_eq!(buffer[1].x, 3.0);
}
#[test]
fn test_engine_fill_chunk_cursor_continuity() {
let mut engine = make_engine_no_transition();
let frame = make_frame(vec![
make_point(0.0, 0.0),
make_point(1.0, 0.0),
make_point(2.0, 0.0),
]);
engine.set_pending(frame);
let mut buf1 = vec![LaserPoint::default(); 2];
engine.fill_chunk(&mut buf1, 2);
assert_eq!(buf1[0].x, 0.0);
assert_eq!(buf1[1].x, 1.0);
let mut buf2 = vec![LaserPoint::default(); 2];
engine.fill_chunk(&mut buf2, 2);
assert_eq!(buf2[0].x, 2.0);
assert_eq!(buf2[1].x, 0.0);
}
#[test]
fn test_engine_compose_hardware_frame_self_loop() {
let mut engine = make_engine(); let frame = make_frame(vec![make_point(0.0, 0.0), make_point(1.0, 0.0)]);
engine.set_pending(frame);
let composed = engine.compose_hardware_frame();
assert_eq!(composed.len(), 4);
assert_eq!(composed[0].intensity, 0);
assert_eq!(composed[1].intensity, 0);
assert_eq!(composed[2].x, 0.0);
assert_eq!(composed[3].x, 1.0);
}
#[test]
fn test_engine_compose_hardware_frame_promotes_pending() {
let mut engine = make_engine_no_transition();
let frame_a = make_frame(vec![make_point(1.0, 0.0)]);
let frame_b = make_frame(vec![make_point(2.0, 0.0)]);
engine.set_pending(frame_a);
engine.set_pending(frame_b);
let composed = engine.compose_hardware_frame();
assert_eq!(composed.len(), 1);
assert_eq!(composed[0].x, 2.0);
}
#[test]
fn test_engine_compose_hardware_frame_empty_before_first_frame() {
let mut engine = make_engine();
let composed = engine.compose_hardware_frame();
assert!(composed.is_empty());
}
#[test]
fn test_engine_compose_hardware_frame_a_to_b_transition() {
let mut engine = PresentationEngine::new(Box::new(|from: &LaserPoint, to: &LaserPoint| {
TransitionPlan::Transition(vec![LaserPoint::blanked(from.x, to.x)])
}));
let frame_a = make_frame(vec![make_point(1.0, 0.0), make_point(2.0, 0.0)]);
engine.set_pending(frame_a);
let _ = engine.compose_hardware_frame();
let frame_b = make_frame(vec![make_point(5.0, 0.0), make_point(6.0, 0.0)]);
engine.set_pending(frame_b);
let composed = engine.compose_hardware_frame();
assert_eq!(composed[0].x, 2.0, "transition 'from' should be A.last");
assert_eq!(composed[0].y, 5.0, "transition 'to' should be B.first");
assert_eq!(composed[1].x, 5.0);
assert_eq!(composed[2].x, 6.0);
}
#[test]
fn test_engine_compose_hardware_frame_self_loop_after_transition() {
let mut engine = PresentationEngine::new(Box::new(|from: &LaserPoint, to: &LaserPoint| {
TransitionPlan::Transition(vec![LaserPoint::blanked(from.x, to.x)])
}));
let frame_a = make_frame(vec![make_point(1.0, 0.0)]);
engine.set_pending(frame_a);
let _ = engine.compose_hardware_frame();
let frame_b = make_frame(vec![make_point(5.0, 0.0), make_point(6.0, 0.0)]);
engine.set_pending(frame_b);
let _ = engine.compose_hardware_frame();
let composed = engine.compose_hardware_frame();
assert_eq!(composed[0].x, 6.0, "self-loop 'from' should be B.last");
assert_eq!(composed[0].y, 5.0, "self-loop 'to' should be B.first");
assert_eq!(composed[1].x, 5.0);
assert_eq!(composed[2].x, 6.0);
}
#[test]
fn test_engine_compose_hardware_frame_skip() {
let mut engine = PresentationEngine::new(Box::new(|from: &LaserPoint, to: &LaserPoint| {
TransitionPlan::Transition(vec![LaserPoint::blanked(from.x, to.x)])
}));
let frame_a = make_frame(vec![make_point(1.0, 0.0)]);
engine.set_pending(frame_a);
let _ = engine.compose_hardware_frame();
let frame_b = make_frame(vec![make_point(5.0, 0.0)]);
engine.set_pending(frame_b);
let frame_c = make_frame(vec![make_point(9.0, 0.0)]);
engine.set_pending(frame_c);
let composed = engine.compose_hardware_frame();
assert_eq!(composed[0].x, 1.0, "transition 'from' should be A.last");
assert_eq!(
composed[0].y, 9.0,
"transition 'to' should be C.first (B skipped)"
);
assert_eq!(composed[1].x, 9.0);
}
#[test]
fn test_engine_compose_hardware_frame_empty_frame_produces_blanked_point() {
let mut engine = make_engine_no_transition();
let empty = make_frame(vec![]);
engine.set_pending(empty);
let composed = engine.compose_hardware_frame();
assert_eq!(
composed.len(),
1,
"empty frame should produce exactly 1 blanked point"
);
assert_eq!(composed[0].x, 0.0);
assert_eq!(composed[0].y, 0.0);
assert_eq!(composed[0].intensity, 0, "point should be blanked");
}
#[test]
fn test_engine_compose_hardware_frame_transition_to_empty_produces_blanked_point() {
let mut engine = make_engine_no_transition();
let frame_a = make_frame(vec![make_point(1.0, 0.0)]);
engine.set_pending(frame_a);
let _ = engine.compose_hardware_frame();
let empty = make_frame(vec![]);
engine.set_pending(empty);
let composed = engine.compose_hardware_frame();
assert!(
!composed.is_empty(),
"A→empty transition should not produce empty drawable"
);
for p in composed {
assert_eq!(p.intensity, 0, "all points should be blanked");
}
}
#[test]
fn test_engine_compose_hardware_frame_self_loop_empty_produces_blanked_point() {
let mut engine = make_engine_no_transition();
let empty = make_frame(vec![]);
engine.set_pending(empty);
let _ = engine.compose_hardware_frame();
let composed = engine.compose_hardware_frame();
assert_eq!(
composed.len(),
1,
"self-loop on empty should produce 1 blanked point"
);
assert_eq!(composed[0].intensity, 0);
}
#[test]
fn test_engine_compose_hardware_frame_clamps_to_capacity() {
let mut engine = PresentationEngine::new(Box::new(|_: &LaserPoint, _: &LaserPoint| {
TransitionPlan::Transition(vec![LaserPoint::blanked(0.0, 0.0); 10])
}));
engine.set_frame_capacity(Some(8));
let frame = make_frame(vec![
make_point(1.0, 0.0),
make_point(2.0, 0.0),
make_point(3.0, 0.0),
make_point(4.0, 0.0),
make_point(5.0, 0.0),
]);
engine.set_pending(frame);
let composed = engine.compose_hardware_frame();
assert!(
composed.len() <= 8,
"composed frame should not exceed capacity, got {}",
composed.len()
);
let frame_points: Vec<f32> = composed
.iter()
.filter(|p| p.intensity > 0)
.map(|p| p.x)
.collect();
assert_eq!(
frame_points,
vec![1.0, 2.0, 3.0, 4.0, 5.0],
"authored points must be preserved"
);
}
#[test]
fn test_engine_compose_hardware_frame_no_truncation_under_capacity() {
let mut engine = PresentationEngine::new(Box::new(|_: &LaserPoint, _: &LaserPoint| {
TransitionPlan::Transition(vec![LaserPoint::blanked(0.0, 0.0); 3])
}));
engine.set_frame_capacity(Some(100));
let frame = make_frame(vec![make_point(1.0, 0.0), make_point(2.0, 0.0)]);
engine.set_pending(frame);
let composed = engine.compose_hardware_frame();
assert_eq!(composed.len(), 5, "no truncation expected");
}
#[test]
fn test_engine_compose_hardware_frame_self_loop_clamps_to_capacity() {
let mut engine = PresentationEngine::new(Box::new(|_: &LaserPoint, _: &LaserPoint| {
TransitionPlan::Transition(vec![LaserPoint::blanked(0.0, 0.0); 20])
}));
engine.set_frame_capacity(Some(6));
let frame = make_frame(vec![make_point(0.0, 0.0), make_point(1.0, 0.0)]);
engine.set_pending(frame);
let _ = engine.compose_hardware_frame();
let composed = engine.compose_hardware_frame();
assert!(
composed.len() <= 6,
"self-loop should clamp to capacity, got {}",
composed.len()
);
}
#[test]
fn test_engine_fill_chunk_multiple_wraps_in_single_call() {
let mut engine = make_engine_no_transition();
let frame = make_frame(vec![make_point(5.0, 0.0)]);
engine.set_pending(frame);
let mut buffer = vec![LaserPoint::default(); 10];
let n = engine.fill_chunk(&mut buffer, 10);
assert_eq!(n, 10);
for p in &buffer {
assert_eq!(p.x, 5.0);
}
}
#[test]
fn test_fifo_no_stale_self_loop_seam() {
let mut engine = PresentationEngine::new(Box::new(|from: &LaserPoint, to: &LaserPoint| {
TransitionPlan::Transition(vec![LaserPoint::blanked(from.x, to.x)])
}));
let frame_a = make_frame(vec![make_point(1.0, 0.0), make_point(2.0, 0.0)]);
let frame_b = make_frame(vec![make_point(5.0, 0.0)]);
engine.set_pending(frame_a);
engine.set_pending(frame_b);
let mut buffer = vec![LaserPoint::default(); 4];
engine.fill_chunk(&mut buffer, 4);
assert_eq!(buffer[0].x, 1.0);
assert_eq!(buffer[0].intensity, 65535);
assert_eq!(buffer[1].x, 2.0);
assert_eq!(buffer[1].intensity, 65535);
assert_eq!(buffer[2].x, 2.0, "transition 'from' should be A.last");
assert_eq!(
buffer[2].y, 5.0,
"transition 'to' should be B.first, not A.first"
);
assert_eq!(buffer[2].intensity, 0);
assert_eq!(buffer[3].x, 5.0);
assert_eq!(buffer[3].intensity, 65535);
}
#[test]
fn test_fifo_pending_at_seam_uses_latest() {
let mut engine = PresentationEngine::new(Box::new(|from: &LaserPoint, to: &LaserPoint| {
TransitionPlan::Transition(vec![LaserPoint::blanked(from.x, to.x)])
}));
let frame_a = make_frame(vec![make_point(1.0, 0.0)]);
let frame_b = make_frame(vec![make_point(5.0, 0.0)]);
let frame_c = make_frame(vec![make_point(9.0, 0.0)]);
engine.set_pending(frame_a);
engine.set_pending(frame_b);
engine.set_pending(frame_c);
let mut buffer = vec![LaserPoint::default(); 4];
engine.fill_chunk(&mut buffer, 4);
assert_eq!(buffer[0].x, 1.0);
assert_eq!(buffer[1].x, 1.0, "transition 'from' = A.last");
assert_eq!(buffer[1].y, 9.0, "transition 'to' = C.first, B skipped");
assert_eq!(buffer[1].intensity, 0);
assert_eq!(buffer[2].x, 9.0);
assert_eq!(buffer[2].intensity, 65535);
}
#[test]
fn test_fifo_stale_self_loop_discarded_across_chunks() {
let mut engine = PresentationEngine::new(Box::new(|from: &LaserPoint, to: &LaserPoint| {
TransitionPlan::Transition(vec![
LaserPoint::blanked(from.x, to.x),
LaserPoint::blanked(to.x, to.y),
])
}));
let frame_a = make_frame(vec![make_point(1.0, 0.0)]);
engine.set_pending(frame_a);
let mut buf1 = vec![LaserPoint::default(); 1];
engine.fill_chunk(&mut buf1, 1);
assert_eq!(buf1[0].x, 1.0);
let frame_b = make_frame(vec![make_point(5.0, 0.0)]);
engine.set_pending(frame_b);
let mut buf2 = vec![LaserPoint::default(); 4];
engine.fill_chunk(&mut buf2, 4);
assert_eq!(
buf2[0].intensity, 0,
"should be A→B transition, not stale A→A"
);
assert_eq!(buf2[1].intensity, 0);
assert_eq!(buf2[2].x, 5.0);
assert_eq!(buf2[2].intensity, 65535);
}
#[test]
fn test_fifo_self_loop_coalesce_omits_last_point() {
let mut engine = make_engine_coalesce();
let frame = make_frame(vec![
make_point(0.0, 0.0),
make_point(1.0, 0.0),
make_point(0.0, 0.0), ]);
engine.set_pending(frame);
let mut buffer = vec![LaserPoint::default(); 6];
let n = engine.fill_chunk(&mut buffer, 6);
assert_eq!(n, 6);
assert_eq!(buffer[0].x, 0.0);
assert_eq!(buffer[1].x, 1.0);
assert_eq!(buffer[2].x, 0.0); assert_eq!(buffer[3].x, 1.0);
for p in &buffer[..6] {
assert_eq!(p.intensity, 65535);
}
}
#[test]
fn test_fifo_self_loop_coalesce_single_point_preserved() {
let mut engine = make_engine_coalesce();
let frame = make_frame(vec![make_point(5.0, 0.0)]);
engine.set_pending(frame);
let mut buffer = vec![LaserPoint::default(); 4];
let n = engine.fill_chunk(&mut buffer, 4);
assert_eq!(n, 4);
for p in &buffer {
assert_eq!(p.x, 5.0);
assert_eq!(p.intensity, 65535);
}
}
#[test]
fn test_fifo_a_to_b_coalesce_skips_incoming_first() {
let mut engine = make_engine_coalesce();
let frame_a = make_frame(vec![make_point(0.0, 0.0)]);
let frame_b = make_frame(vec![
make_point(0.0, 0.0), make_point(1.0, 0.0),
make_point(2.0, 0.0),
]);
engine.set_pending(frame_a);
engine.set_pending(frame_b);
let mut buffer = vec![LaserPoint::default(); 6];
engine.fill_chunk(&mut buffer, 6);
assert_eq!(buffer[0].x, 0.0); assert_eq!(buffer[1].x, 1.0); }
#[test]
fn test_fifo_a_to_c_skip_latest_coalesce() {
let mut engine = make_engine_coalesce();
let frame_a = make_frame(vec![make_point(0.0, 0.0)]);
let frame_b = make_frame(vec![make_point(5.0, 0.0)]); let frame_c = make_frame(vec![
make_point(0.0, 0.0), make_point(3.0, 0.0),
]);
engine.set_pending(frame_a);
engine.set_pending(frame_b);
engine.set_pending(frame_c);
let mut buffer = vec![LaserPoint::default(); 4];
engine.fill_chunk(&mut buffer, 4);
assert_eq!(buffer[0].x, 0.0);
assert_eq!(buffer[1].x, 3.0);
assert_eq!(buffer[2].x, 3.0);
assert_eq!(buffer[3].x, 3.0);
}
#[test]
fn test_frame_swap_self_loop_coalesce() {
let mut engine = make_engine_coalesce();
let frame = make_frame(vec![
make_point(0.0, 0.0),
make_point(1.0, 0.0),
make_point(0.0, 0.0), ]);
engine.set_pending(frame);
let composed = engine.compose_hardware_frame();
assert_eq!(composed.len(), 2, "coalesce should omit last point");
assert_eq!(composed[0].x, 0.0);
assert_eq!(composed[1].x, 1.0);
assert_eq!(composed[0].intensity, 65535);
assert_eq!(composed[1].intensity, 65535);
}
#[test]
fn test_frame_swap_a_to_b_coalesce() {
let mut engine = make_engine_coalesce();
let frame_a = make_frame(vec![make_point(0.0, 0.0)]);
engine.set_pending(frame_a);
let _ = engine.compose_hardware_frame();
let frame_b = make_frame(vec![make_point(0.0, 0.0), make_point(1.0, 0.0)]);
engine.set_pending(frame_b);
let composed = engine.compose_hardware_frame();
assert_eq!(composed.len(), 1);
assert_eq!(composed[0].x, 1.0);
assert_eq!(composed[0].intensity, 65535);
}
#[test]
fn test_parity_self_loop_with_transition() {
let transition_fn = || -> TransitionFn {
Box::new(|from: &LaserPoint, to: &LaserPoint| {
TransitionPlan::Transition(vec![
LaserPoint::blanked(from.x * 0.5 + to.x * 0.5, 0.0),
LaserPoint::blanked(to.x, to.y),
])
})
};
let frame = make_frame(vec![make_point(0.0, 0.0), make_point(1.0, 0.0)]);
let mut fifo_engine = PresentationEngine::new(transition_fn());
fifo_engine.set_pending(frame.clone());
let mut buffer = vec![LaserPoint::default(); 8];
fifo_engine.fill_chunk(&mut buffer, 8);
let fifo_intensities: Vec<u16> = buffer.iter().map(|p| p.intensity).collect();
assert_eq!(
fifo_intensities,
vec![65535, 65535, 0, 0, 65535, 65535, 0, 0],
"FIFO self-loop should include transition"
);
let mut swap_engine = PresentationEngine::new(transition_fn());
swap_engine.set_pending(frame);
let composed = swap_engine.compose_hardware_frame();
assert_eq!(composed.len(), 4, "FrameSwap self-loop: 2 trans + 2 frame");
assert_eq!(composed[0].intensity, 0); assert_eq!(composed[1].intensity, 0);
assert_eq!(composed[2].intensity, 65535); assert_eq!(composed[3].intensity, 65535);
}
#[test]
fn test_parity_self_loop_with_coalesce() {
let frame = make_frame(vec![
make_point(0.0, 0.0),
make_point(1.0, 0.0),
make_point(2.0, 0.0),
]);
let mut fifo_engine = make_engine_coalesce();
fifo_engine.set_pending(frame.clone());
let mut buffer = vec![LaserPoint::default(); 6];
fifo_engine.fill_chunk(&mut buffer, 6);
assert_eq!(buffer[0].x, 0.0);
assert_eq!(buffer[1].x, 1.0);
assert_eq!(buffer[2].x, 2.0);
assert_eq!(buffer[3].x, 1.0);
assert_eq!(buffer[4].x, 2.0);
assert_eq!(buffer[5].x, 1.0);
let mut swap_engine = make_engine_coalesce();
swap_engine.set_pending(frame);
let composed = swap_engine.compose_hardware_frame();
assert_eq!(composed.len(), 2);
assert_eq!(composed[0].x, 0.0);
assert_eq!(composed[1].x, 1.0);
}
#[test]
fn test_parity_a_to_b_with_transition() {
let transition_fn = || -> TransitionFn {
Box::new(|from: &LaserPoint, to: &LaserPoint| {
TransitionPlan::Transition(vec![LaserPoint::blanked(from.x, to.x)])
})
};
let frame_a = make_frame(vec![make_point(1.0, 0.0)]);
let frame_b = make_frame(vec![make_point(5.0, 0.0)]);
let mut fifo_engine = PresentationEngine::new(transition_fn());
fifo_engine.set_pending(frame_a.clone());
fifo_engine.set_pending(frame_b.clone());
let mut buffer = vec![LaserPoint::default(); 4];
fifo_engine.fill_chunk(&mut buffer, 4);
assert_eq!(buffer[0].x, 1.0);
assert_eq!(buffer[0].intensity, 65535);
assert_eq!(buffer[1].intensity, 0); assert_eq!(buffer[2].x, 5.0);
assert_eq!(buffer[2].intensity, 65535);
assert_eq!(buffer[3].intensity, 0);
let mut swap_engine = PresentationEngine::new(transition_fn());
swap_engine.set_pending(frame_a);
let _ = swap_engine.compose_hardware_frame(); swap_engine.set_pending(frame_b);
let composed = swap_engine.compose_hardware_frame();
assert_eq!(composed[0].x, 1.0);
assert_eq!(composed[0].intensity, 0);
assert_eq!(composed[1].x, 5.0);
assert_eq!(composed[1].intensity, 65535);
}
#[test]
fn test_parity_a_to_b_with_coalesce() {
let frame_a = make_frame(vec![make_point(0.0, 0.0)]);
let frame_b = make_frame(vec![
make_point(0.0, 0.0), make_point(1.0, 0.0),
make_point(2.0, 0.0),
]);
let mut fifo_engine = make_engine_coalesce();
fifo_engine.set_pending(frame_a.clone());
fifo_engine.set_pending(frame_b.clone());
let mut buffer = vec![LaserPoint::default(); 4];
fifo_engine.fill_chunk(&mut buffer, 4);
assert_eq!(buffer[0].x, 0.0); assert_eq!(buffer[1].x, 1.0);
let mut swap_engine = make_engine_coalesce();
swap_engine.set_pending(frame_a);
let _ = swap_engine.compose_hardware_frame(); swap_engine.set_pending(frame_b);
let composed = swap_engine.compose_hardware_frame();
assert_eq!(composed.len(), 2);
assert_eq!(composed[0].x, 1.0);
assert_eq!(composed[1].x, 2.0);
}
#[test]
fn test_parity_a_to_c_skip() {
let transition_fn = || -> TransitionFn {
Box::new(|from: &LaserPoint, to: &LaserPoint| {
TransitionPlan::Transition(vec![LaserPoint::blanked(from.x, to.x)])
})
};
let frame_a = make_frame(vec![make_point(1.0, 0.0)]);
let frame_b = make_frame(vec![make_point(5.0, 0.0)]); let frame_c = make_frame(vec![make_point(9.0, 0.0)]);
let mut fifo_engine = PresentationEngine::new(transition_fn());
fifo_engine.set_pending(frame_a.clone());
fifo_engine.set_pending(frame_b.clone());
fifo_engine.set_pending(frame_c.clone()); let mut buffer = vec![LaserPoint::default(); 4];
fifo_engine.fill_chunk(&mut buffer, 4);
assert_eq!(buffer[0].x, 1.0);
assert_eq!(buffer[1].intensity, 0); assert_eq!(buffer[2].x, 9.0);
assert_eq!(buffer[3].intensity, 0);
let mut swap_engine = PresentationEngine::new(transition_fn());
swap_engine.set_pending(frame_a);
let _ = swap_engine.compose_hardware_frame();
swap_engine.set_pending(frame_b);
swap_engine.set_pending(frame_c); let composed = swap_engine.compose_hardware_frame();
assert_eq!(composed[0].x, 1.0);
assert_eq!(composed[0].intensity, 0);
assert_eq!(composed[1].x, 9.0);
}
use crate::backend::{BackendKind, DacBackend, FifoBackend, FrameSwapBackend};
use crate::error::Result as DacResult;
use crate::types::RunExit;
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use std::sync::Mutex;
struct FifoTestBackend {
connected: bool,
write_count: Arc<AtomicUsize>,
points_written: Arc<AtomicUsize>,
shutter_open: Arc<AtomicBool>,
}
impl FifoTestBackend {
fn new() -> Self {
Self {
connected: false,
write_count: Arc::new(AtomicUsize::new(0)),
points_written: Arc::new(AtomicUsize::new(0)),
shutter_open: Arc::new(AtomicBool::new(false)),
}
}
}
impl DacBackend for FifoTestBackend {
fn dac_type(&self) -> crate::types::DacType {
crate::types::DacType::Custom("FifoTest".into())
}
fn caps(&self) -> &crate::types::DacCapabilities {
static CAPS: crate::types::DacCapabilities = crate::types::DacCapabilities {
pps_min: 1000,
pps_max: 100000,
max_points_per_chunk: 1000,
output_model: crate::types::OutputModel::NetworkFifo,
};
&CAPS
}
fn connect(&mut self) -> DacResult<()> {
self.connected = true;
Ok(())
}
fn disconnect(&mut self) -> DacResult<()> {
self.connected = false;
Ok(())
}
fn is_connected(&self) -> bool {
self.connected
}
fn stop(&mut self) -> DacResult<()> {
Ok(())
}
fn set_shutter(&mut self, open: bool) -> DacResult<()> {
self.shutter_open.store(open, Ordering::SeqCst);
Ok(())
}
}
impl FifoBackend for FifoTestBackend {
fn try_write_points(
&mut self,
_pps: u32,
points: &[LaserPoint],
) -> DacResult<crate::backend::WriteOutcome> {
self.write_count.fetch_add(1, Ordering::SeqCst);
self.points_written
.fetch_add(points.len(), Ordering::SeqCst);
Ok(crate::backend::WriteOutcome::Written)
}
}
struct FrameSwapTestBackend {
connected: bool,
write_count: Arc<AtomicUsize>,
last_frame_size: Arc<AtomicUsize>,
shutter_open: Arc<AtomicBool>,
ready: Arc<AtomicBool>,
}
impl FrameSwapTestBackend {
fn new() -> Self {
Self {
connected: false,
write_count: Arc::new(AtomicUsize::new(0)),
last_frame_size: Arc::new(AtomicUsize::new(0)),
shutter_open: Arc::new(AtomicBool::new(false)),
ready: Arc::new(AtomicBool::new(true)),
}
}
}
impl DacBackend for FrameSwapTestBackend {
fn dac_type(&self) -> crate::types::DacType {
crate::types::DacType::Custom("FrameSwapTest".into())
}
fn caps(&self) -> &crate::types::DacCapabilities {
static CAPS: crate::types::DacCapabilities = crate::types::DacCapabilities {
pps_min: 1000,
pps_max: 100000,
max_points_per_chunk: 4095,
output_model: crate::types::OutputModel::UsbFrameSwap,
};
&CAPS
}
fn connect(&mut self) -> DacResult<()> {
self.connected = true;
Ok(())
}
fn disconnect(&mut self) -> DacResult<()> {
self.connected = false;
Ok(())
}
fn is_connected(&self) -> bool {
self.connected
}
fn stop(&mut self) -> DacResult<()> {
Ok(())
}
fn set_shutter(&mut self, open: bool) -> DacResult<()> {
self.shutter_open.store(open, Ordering::SeqCst);
Ok(())
}
}
impl FrameSwapBackend for FrameSwapTestBackend {
fn frame_capacity(&self) -> usize {
4095
}
fn is_ready_for_frame(&mut self) -> bool {
self.ready.load(Ordering::SeqCst)
}
fn write_frame(
&mut self,
_pps: u32,
points: &[LaserPoint],
) -> DacResult<crate::backend::WriteOutcome> {
self.write_count.fetch_add(1, Ordering::SeqCst);
self.last_frame_size.store(points.len(), Ordering::SeqCst);
Ok(crate::backend::WriteOutcome::Written)
}
}
struct RetryFrameSwapTestBackend {
connected: bool,
caps: crate::types::DacCapabilities,
shutter_open: Arc<AtomicBool>,
ready: Arc<AtomicBool>,
block_next_writes: Arc<AtomicUsize>,
writes: Arc<Mutex<Vec<Vec<LaserPoint>>>>,
}
impl RetryFrameSwapTestBackend {
fn new() -> Self {
Self {
connected: false,
caps: crate::types::DacCapabilities {
pps_min: 1000,
pps_max: 100000,
max_points_per_chunk: 4095,
output_model: crate::types::OutputModel::UsbFrameSwap,
},
shutter_open: Arc::new(AtomicBool::new(false)),
ready: Arc::new(AtomicBool::new(true)),
block_next_writes: Arc::new(AtomicUsize::new(0)),
writes: Arc::new(Mutex::new(Vec::new())),
}
}
}
impl DacBackend for RetryFrameSwapTestBackend {
fn dac_type(&self) -> crate::types::DacType {
crate::types::DacType::Custom("RetryFrameSwapTest".into())
}
fn caps(&self) -> &crate::types::DacCapabilities {
&self.caps
}
fn connect(&mut self) -> DacResult<()> {
self.connected = true;
Ok(())
}
fn disconnect(&mut self) -> DacResult<()> {
self.connected = false;
Ok(())
}
fn is_connected(&self) -> bool {
self.connected
}
fn stop(&mut self) -> DacResult<()> {
Ok(())
}
fn set_shutter(&mut self, open: bool) -> DacResult<()> {
self.shutter_open.store(open, Ordering::SeqCst);
Ok(())
}
}
impl FrameSwapBackend for RetryFrameSwapTestBackend {
fn frame_capacity(&self) -> usize {
self.caps.max_points_per_chunk
}
fn is_ready_for_frame(&mut self) -> bool {
self.ready.load(Ordering::SeqCst)
}
fn write_frame(
&mut self,
_pps: u32,
points: &[LaserPoint],
) -> DacResult<crate::backend::WriteOutcome> {
self.writes.lock().unwrap().push(points.to_vec());
let remaining = self.block_next_writes.load(Ordering::SeqCst);
if remaining > 0 {
self.block_next_writes
.store(remaining - 1, Ordering::SeqCst);
return Ok(crate::backend::WriteOutcome::WouldBlock);
}
Ok(crate::backend::WriteOutcome::Written)
}
}
struct RetryUdpTimedTestBackend {
connected: bool,
caps: crate::types::DacCapabilities,
shutter_open: Arc<AtomicBool>,
block_next_writes: Arc<AtomicUsize>,
writes: Arc<Mutex<Vec<Vec<LaserPoint>>>>,
}
impl RetryUdpTimedTestBackend {
fn new() -> Self {
Self {
connected: false,
caps: crate::types::DacCapabilities {
pps_min: 1000,
pps_max: 100000,
max_points_per_chunk: 3,
output_model: crate::types::OutputModel::UdpTimed,
},
shutter_open: Arc::new(AtomicBool::new(false)),
block_next_writes: Arc::new(AtomicUsize::new(0)),
writes: Arc::new(Mutex::new(Vec::new())),
}
}
}
impl DacBackend for RetryUdpTimedTestBackend {
fn dac_type(&self) -> crate::types::DacType {
crate::types::DacType::Custom("RetryUdpTimedTest".into())
}
fn caps(&self) -> &crate::types::DacCapabilities {
&self.caps
}
fn connect(&mut self) -> DacResult<()> {
self.connected = true;
Ok(())
}
fn disconnect(&mut self) -> DacResult<()> {
self.connected = false;
Ok(())
}
fn is_connected(&self) -> bool {
self.connected
}
fn stop(&mut self) -> DacResult<()> {
Ok(())
}
fn set_shutter(&mut self, open: bool) -> DacResult<()> {
self.shutter_open.store(open, Ordering::SeqCst);
Ok(())
}
}
impl FifoBackend for RetryUdpTimedTestBackend {
fn try_write_points(
&mut self,
_pps: u32,
points: &[LaserPoint],
) -> DacResult<crate::backend::WriteOutcome> {
self.writes.lock().unwrap().push(points.to_vec());
let remaining = self.block_next_writes.load(Ordering::SeqCst);
if remaining > 0 {
self.block_next_writes
.store(remaining - 1, Ordering::SeqCst);
return Ok(crate::backend::WriteOutcome::WouldBlock);
}
Ok(crate::backend::WriteOutcome::Written)
}
}
fn wait_for_writes(
writes: &Arc<Mutex<Vec<Vec<LaserPoint>>>>,
min_len: usize,
timeout: std::time::Duration,
) {
let deadline = std::time::Instant::now() + timeout;
while std::time::Instant::now() < deadline {
if writes.lock().unwrap().len() >= min_len {
return;
}
std::thread::sleep(std::time::Duration::from_millis(1));
}
panic!(
"timed out waiting for {} writes, got {}",
min_len,
writes.lock().unwrap().len()
);
}
fn encode_transition() -> TransitionFn {
Box::new(|from: &LaserPoint, to: &LaserPoint| {
TransitionPlan::Transition(vec![LaserPoint::blanked(from.x, to.x)])
})
}
fn is_encoded_frame_swap_transition(points: &[LaserPoint], from_x: f32, to_x: f32) -> bool {
points.len() == 2
&& points[0].x == from_x
&& points[0].y == to_x
&& points[0].intensity == 0
&& points[1].x == to_x
&& points[1].intensity == 65535
}
fn is_transition_bearing_udp_chunk(points: &[LaserPoint]) -> bool {
points.iter().any(|p| p.intensity == 0) && points.iter().any(|p| p.intensity != 0)
}
#[test]
fn test_frame_session_fifo_submit_frame_writes_points() {
let backend = FifoTestBackend::new();
let points_written = backend.points_written.clone();
let backend_kind = BackendKind::Fifo(Box::new(backend));
let config = FrameSessionConfig::new(30000).with_startup_blank(std::time::Duration::ZERO);
let session = FrameSession::start(backend_kind, config, None).unwrap();
session.control().arm().unwrap();
session.send_frame(Frame::new(vec![
LaserPoint::new(0.0, 0.0, 65535, 0, 0, 65535),
LaserPoint::new(1.0, 0.0, 0, 65535, 0, 65535),
]));
std::thread::sleep(std::time::Duration::from_millis(50));
assert!(
points_written.load(Ordering::SeqCst) > 0,
"Should have written points"
);
session.control().stop().unwrap();
let exit = session.join().unwrap();
assert_eq!(exit, RunExit::Stopped);
}
#[test]
fn test_frame_session_frame_swap_writes_frames() {
let backend = FrameSwapTestBackend::new();
let write_count = backend.write_count.clone();
let last_frame_size = backend.last_frame_size.clone();
let backend_kind = BackendKind::FrameSwap(Box::new(backend));
let config = FrameSessionConfig::new(30000).with_startup_blank(std::time::Duration::ZERO);
let session = FrameSession::start(backend_kind, config, None).unwrap();
session.control().arm().unwrap();
session.send_frame(Frame::new(vec![
LaserPoint::new(0.0, 0.0, 65535, 0, 0, 65535),
LaserPoint::new(1.0, 0.0, 0, 65535, 0, 65535),
]));
std::thread::sleep(std::time::Duration::from_millis(50));
assert!(
write_count.load(Ordering::SeqCst) > 0,
"Should have written frames"
);
assert!(
last_frame_size.load(Ordering::SeqCst) > 0,
"Frame should have points"
);
session.control().stop().unwrap();
let exit = session.join().unwrap();
assert_eq!(exit, RunExit::Stopped);
}
#[test]
fn test_frame_session_frame_swap_retries_same_inflight_frame_on_wouldblock() {
let backend = RetryFrameSwapTestBackend::new();
let writes = backend.writes.clone();
let ready = backend.ready.clone();
let block_next_writes = backend.block_next_writes.clone();
let backend_kind = BackendKind::FrameSwap(Box::new(backend));
let config = FrameSessionConfig::new(30_000)
.with_transition_fn(encode_transition())
.with_startup_blank(std::time::Duration::ZERO)
.with_color_delay_points(0);
let session = FrameSession::start(backend_kind, config, None).unwrap();
session.control().arm().unwrap();
session.send_frame(Frame::new(vec![make_point(1.0, 0.0)]));
wait_for_writes(&writes, 1, std::time::Duration::from_millis(200));
ready.store(false, Ordering::SeqCst);
std::thread::sleep(std::time::Duration::from_millis(10));
let before = writes.lock().unwrap().len();
block_next_writes.store(1, Ordering::SeqCst);
session.send_frame(Frame::new(vec![make_point(5.0, 0.0)]));
ready.store(true, Ordering::SeqCst);
wait_for_writes(&writes, before + 2, std::time::Duration::from_millis(200));
let writes = writes.lock().unwrap();
let first = &writes[before];
let second = &writes[before + 1];
assert_eq!(
first, second,
"inflight A→B frame should be retried verbatim"
);
assert!(
is_encoded_frame_swap_transition(first, 1.0, 5.0),
"expected encoded A→B frame, got {:?}",
first
);
drop(writes);
session.control().stop().unwrap();
let exit = session.join();
assert!(
matches!(
exit,
Ok(RunExit::Stopped) | Err(crate::error::Error::Stopped)
),
"expected clean stop for UDP-timed session, got {exit:?}"
);
}
#[test]
fn test_frame_session_frame_swap_inflight_frame_stays_sticky_until_accepted() {
let backend = RetryFrameSwapTestBackend::new();
let writes = backend.writes.clone();
let ready = backend.ready.clone();
let block_next_writes = backend.block_next_writes.clone();
let backend_kind = BackendKind::FrameSwap(Box::new(backend));
let config = FrameSessionConfig::new(30_000)
.with_transition_fn(encode_transition())
.with_startup_blank(std::time::Duration::ZERO)
.with_color_delay_points(0);
let session = FrameSession::start(backend_kind, config, None).unwrap();
session.control().arm().unwrap();
session.send_frame(Frame::new(vec![make_point(1.0, 0.0)]));
wait_for_writes(&writes, 1, std::time::Duration::from_millis(200));
ready.store(false, Ordering::SeqCst);
std::thread::sleep(std::time::Duration::from_millis(10));
let before = writes.lock().unwrap().len();
block_next_writes.store(3, Ordering::SeqCst);
session.send_frame(Frame::new(vec![make_point(5.0, 0.0)]));
ready.store(true, Ordering::SeqCst);
wait_for_writes(&writes, before + 1, std::time::Duration::from_millis(200));
session.send_frame(Frame::new(vec![make_point(9.0, 0.0)]));
wait_for_writes(&writes, before + 5, std::time::Duration::from_millis(300));
let writes = writes.lock().unwrap();
for attempt in &writes[before..before + 4] {
assert!(
is_encoded_frame_swap_transition(attempt, 1.0, 5.0),
"expected sticky A→B frame, got {:?}",
attempt
);
}
let next = &writes[before + 4];
assert!(
is_encoded_frame_swap_transition(next, 5.0, 9.0),
"expected B→C after A→B was accepted, got {:?}",
next
);
drop(writes);
session.control().stop().unwrap();
let exit = session.join();
assert!(
matches!(
exit,
Ok(RunExit::Stopped) | Err(crate::error::Error::Stopped)
),
"expected clean stop for UDP-timed session, got {exit:?}"
);
}
#[test]
fn test_frame_session_udp_timed_retries_same_transition_chunk_on_wouldblock() {
let backend = RetryUdpTimedTestBackend::new();
let writes = backend.writes.clone();
let block_next_writes = backend.block_next_writes.clone();
let backend_kind = BackendKind::Fifo(Box::new(backend));
let config = FrameSessionConfig::new(1_000)
.with_transition_fn(encode_transition())
.with_startup_blank(std::time::Duration::ZERO)
.with_color_delay_points(0);
let session = FrameSession::start(backend_kind, config, None).unwrap();
session.control().arm().unwrap();
session.send_frame(Frame::new(vec![make_point(1.0, 0.0)]));
wait_for_writes(&writes, 1, std::time::Duration::from_millis(200));
let before = writes.lock().unwrap().len();
block_next_writes.store(1, Ordering::SeqCst);
session.send_frame(Frame::new(vec![make_point(5.0, 0.0)]));
wait_for_writes(&writes, before + 8, std::time::Duration::from_millis(400));
let writes = writes.lock().unwrap();
let retry_idx = writes[before..]
.windows(2)
.position(|window| window[0] == window[1] && is_transition_bearing_udp_chunk(&window[0]))
.unwrap_or_else(|| {
panic!(
"expected a retried transition-bearing UDP chunk, got {:?}",
&writes[before..]
)
});
let first = &writes[before + retry_idx];
assert!(
is_transition_bearing_udp_chunk(first),
"expected a transition-bearing UDP chunk, got {:?}",
first
);
if before + retry_idx + 2 < writes.len() {
assert_ne!(
first,
&writes[before + retry_idx + 2],
"retry should preserve one specific chunk, not collapse the whole output into a constant pattern"
);
}
drop(writes);
session.control().stop().unwrap();
let exit = session.join();
assert!(
matches!(
exit,
Ok(RunExit::Stopped) | Err(crate::error::Error::Stopped)
),
"expected clean stop for UDP-timed session, got {exit:?}"
);
}
#[test]
fn test_frame_session_arm_disarm() {
let backend = FifoTestBackend::new();
let shutter = backend.shutter_open.clone();
let backend_kind = BackendKind::Fifo(Box::new(backend));
let config = FrameSessionConfig::new(30000);
let session = FrameSession::start(backend_kind, config, None).unwrap();
assert!(!session.control().is_armed());
session.control().arm().unwrap();
std::thread::sleep(std::time::Duration::from_millis(20));
assert!(shutter.load(Ordering::SeqCst));
session.control().disarm().unwrap();
std::thread::sleep(std::time::Duration::from_millis(20));
assert!(!shutter.load(Ordering::SeqCst));
session.control().stop().unwrap();
session.join().unwrap();
}
#[test]
fn test_frame_session_stop() {
let backend = FifoTestBackend::new();
let backend_kind = BackendKind::Fifo(Box::new(backend));
let config = FrameSessionConfig::new(30000);
let session = FrameSession::start(backend_kind, config, None).unwrap();
session.control().stop().unwrap();
let exit = session.join().unwrap();
assert_eq!(exit, RunExit::Stopped);
}
#[test]
fn test_color_delay_line_carries_across_chunks() {
use super::ColorDelayLine;
let mut delay_line = ColorDelayLine::new(1);
let mut chunk1 = vec![
LaserPoint::new(0.0, 0.0, 100, 200, 300, 400),
LaserPoint::new(1.0, 0.0, 500, 600, 700, 800),
LaserPoint::new(2.0, 0.0, 900, 1000, 1100, 1200),
];
delay_line.apply(&mut chunk1);
assert_eq!(chunk1[0].r, 0);
assert_eq!(chunk1[1].r, 100);
assert_eq!(chunk1[2].r, 500);
let mut chunk2 = vec![
LaserPoint::new(3.0, 0.0, 1300, 1400, 1500, 1600),
LaserPoint::new(4.0, 0.0, 1700, 1800, 1900, 2000),
];
delay_line.apply(&mut chunk2);
assert_eq!(chunk2[0].r, 900);
assert_eq!(chunk2[0].g, 1000);
assert_eq!(chunk2[0].b, 1100);
assert_eq!(chunk2[0].intensity, 1200);
assert_eq!(chunk2[1].r, 1300);
assert_eq!(chunk2[0].x, 3.0);
assert_eq!(chunk2[1].x, 4.0);
}
#[test]
fn test_color_delay_line_zero_delay_is_noop() {
use super::ColorDelayLine;
let mut delay_line = ColorDelayLine::new(0);
let mut points = vec![LaserPoint::new(0.0, 0.0, 100, 200, 300, 400)];
delay_line.apply(&mut points);
assert_eq!(points[0].r, 100);
assert_eq!(points[0].g, 200);
}
#[test]
fn test_engine_empty_transition_result() {
let mut engine = make_engine_no_transition();
let frame = make_frame(vec![make_point(1.0, 0.0), make_point(2.0, 0.0)]);
engine.set_pending(frame);
let mut buffer = vec![LaserPoint::default(); 4];
engine.fill_chunk(&mut buffer, 4);
assert_eq!(buffer[0].x, 1.0);
assert_eq!(buffer[1].x, 2.0);
assert_eq!(buffer[2].x, 1.0);
assert_eq!(buffer[3].x, 2.0);
}
#[test]
fn test_compose_hardware_frame_natural_length_no_padding() {
let mut engine = make_engine(); let frame = make_frame(vec![
make_point(0.0, 0.0),
make_point(0.2, 0.0),
make_point(0.4, 0.0),
make_point(0.6, 0.0),
make_point(0.8, 0.0),
]);
engine.set_pending(frame);
let composed = engine.compose_hardware_frame();
assert_eq!(
composed.len(),
7,
"frame should be at natural length, not padded"
);
}
#[test]
fn test_compose_hardware_frame_content_not_cycled() {
let mut engine = PresentationEngine::new(Box::new(|from: &LaserPoint, to: &LaserPoint| {
TransitionPlan::Transition(vec![LaserPoint::blanked(from.x, to.x)])
}));
let frame = make_frame(vec![
make_point(1.0, 0.0),
make_point(2.0, 0.0),
make_point(3.0, 0.0),
]);
engine.set_pending(frame);
let composed = engine.compose_hardware_frame();
assert_eq!(composed.len(), 4);
assert_eq!(composed[1].x, 1.0);
assert_eq!(composed[2].x, 2.0);
assert_eq!(composed[3].x, 3.0);
}
#[test]
fn test_compose_hardware_frame_short_frame_preserves_duty_cycle() {
let mut engine = make_engine(); let frame = make_frame(vec![make_point(0.5, 0.0), make_point(-0.5, 0.0)]);
engine.set_pending(frame);
let composed = engine.compose_hardware_frame();
assert_eq!(composed.len(), 4, "no extra padding points");
assert_eq!(composed[0].intensity, 0);
assert_eq!(composed[1].intensity, 0);
assert_eq!(composed[2].intensity, 65535);
assert_eq!(composed[3].intensity, 65535);
}
#[test]
fn test_compose_hardware_frame_a_to_b_no_cycling_artifact() {
let mut engine = PresentationEngine::new(Box::new(|from: &LaserPoint, to: &LaserPoint| {
TransitionPlan::Transition(vec![
LaserPoint::blanked(from.x, from.y),
LaserPoint::blanked(to.x, to.y),
])
}));
let frame_a = make_frame(vec![make_point(-1.0, 0.0)]);
engine.set_pending(frame_a);
let _ = engine.compose_hardware_frame();
let frame_b = make_frame(vec![make_point(1.0, 0.0), make_point(1.0, 1.0)]);
engine.set_pending(frame_b);
let composed = engine.compose_hardware_frame();
assert_eq!(composed.len(), 4, "no cycling beyond transition + frame");
assert_eq!(composed[3].x, 1.0);
assert_eq!(composed[3].y, 1.0);
}
#[test]
fn test_engine_reset_clears_state() {
use super::engine::PresentationEngine;
let mut engine = PresentationEngine::new(default_transition(30_000));
let frame = Frame::new(vec![make_point(1.0, 0.0), make_point(2.0, 0.0)]);
engine.set_pending(frame);
let mut buffer = vec![LaserPoint::default(); 3];
engine.fill_chunk(&mut buffer, 3);
assert!(engine.current_base.is_some());
engine.reset();
assert!(engine.current_base.is_none());
assert!(engine.pending_base.is_none());
let mut buffer2 = vec![LaserPoint::default(); 2];
let n = engine.fill_chunk(&mut buffer2, 2);
assert_eq!(n, 2);
assert_eq!(buffer2[0].r, 0);
assert_eq!(buffer2[0].g, 0);
}
#[test]
fn test_engine_reset_then_replay_frame() {
use super::engine::PresentationEngine;
let mut engine = PresentationEngine::new(Box::new(|_: &LaserPoint, _: &LaserPoint| {
TransitionPlan::Transition(vec![])
}));
let frame = Frame::new(vec![make_point(1.0, 0.0)]);
engine.set_pending(frame.clone());
let mut buffer = vec![LaserPoint::default(); 2];
engine.fill_chunk(&mut buffer, 2);
engine.reset();
engine.set_pending(frame);
let mut buffer2 = vec![LaserPoint::default(); 2];
let n = engine.fill_chunk(&mut buffer2, 2);
assert_eq!(n, 2);
assert_eq!(buffer2[0].x, 1.0);
}
#[test]
fn test_color_delay_reset_clears_carry() {
use super::engine::ColorDelayLine;
let mut delay = ColorDelayLine::new(3);
let mut points = vec![
LaserPoint::new(0.0, 0.0, 65535, 0, 0, 65535),
LaserPoint::new(0.1, 0.0, 32000, 0, 0, 32000),
LaserPoint::new(0.2, 0.0, 16000, 0, 0, 16000),
];
delay.apply(&mut points);
delay.reset();
let mut points2 = vec![
LaserPoint::new(0.0, 0.0, 65535, 0, 0, 65535),
LaserPoint::new(0.1, 0.0, 65535, 0, 0, 65535),
LaserPoint::new(0.2, 0.0, 65535, 0, 0, 65535),
];
delay.apply(&mut points2);
assert_eq!(points2[0].r, 0);
assert_eq!(points2[0].intensity, 0);
assert_eq!(points2[1].r, 0);
assert_eq!(points2[2].r, 0);
}
#[test]
fn test_frame_fifo_buffer_estimation_matches_shared_scheduler_helper() {
assert_eq!(
crate::scheduler::conservative_buffered_points(500, None),
500
);
assert_eq!(
crate::scheduler::conservative_buffered_points(500, Some(250)),
250
);
assert_eq!(
crate::scheduler::conservative_buffered_points(500, Some(900)),
500
);
}
#[test]
fn test_frame_session_config_with_reconnect() {
let config = FrameSessionConfig::new(30_000)
.with_reconnect(crate::types::ReconnectConfig::new().max_retries(3));
assert!(config.reconnect.is_some());
assert_eq!(config.reconnect.as_ref().unwrap().max_retries, Some(3));
}
#[test]
fn test_frame_session_start_frame_session_rejects_invalid_pps_with_reconnect() {
use crate::backend::BackendKind;
use crate::stream::Dac;
use crate::types::{DacCapabilities, DacInfo, DacType, OutputModel};
let caps = DacCapabilities {
pps_min: 1000,
pps_max: 100_000,
max_points_per_chunk: 1000,
output_model: OutputModel::NetworkFifo,
};
struct MinimalBackend {
caps: DacCapabilities,
connected: bool,
}
impl crate::backend::DacBackend for MinimalBackend {
fn dac_type(&self) -> DacType {
DacType::Custom("Test".into())
}
fn caps(&self) -> &DacCapabilities {
&self.caps
}
fn connect(&mut self) -> crate::backend::Result<()> {
self.connected = true;
Ok(())
}
fn disconnect(&mut self) -> crate::backend::Result<()> {
Ok(())
}
fn is_connected(&self) -> bool {
self.connected
}
fn stop(&mut self) -> crate::backend::Result<()> {
Ok(())
}
fn set_shutter(&mut self, _: bool) -> crate::backend::Result<()> {
Ok(())
}
}
impl crate::backend::FifoBackend for MinimalBackend {
fn try_write_points(
&mut self,
_: u32,
_: &[LaserPoint],
) -> crate::backend::Result<crate::backend::WriteOutcome> {
Ok(crate::backend::WriteOutcome::Written)
}
fn queued_points(&self) -> Option<u64> {
None
}
}
let backend = MinimalBackend {
caps: caps.clone(),
connected: false,
};
let info = DacInfo::new("test", "Test", DacType::Custom("Test".into()), caps);
let mut device = Dac::new(info, BackendKind::Fifo(Box::new(backend)));
device.reconnect_target = Some(crate::reconnect::ReconnectTarget {
device_id: "test".to_string(),
discovery_factory: None,
});
let config = FrameSessionConfig::new(500).with_reconnect(crate::types::ReconnectConfig::new());
let result = device.start_frame_session(config);
assert!(result.is_err());
}