#![allow(clippy::field_reassign_with_default)]
use epics_base_rs::server::record::{ProcessAction, Record};
use epics_base_rs::types::EpicsValue;
use std_rs::ThrottleRecord;
#[test]
fn test_record_type() {
let rec = ThrottleRecord::default();
assert_eq!(rec.record_type(), "throttle");
}
#[test]
fn test_default_values() {
let rec = ThrottleRecord::default();
assert_eq!(rec.val, 0.0);
assert_eq!(rec.dly, 0.0);
assert_eq!(rec.drvlh, 0.0);
assert_eq!(rec.drvll, 0.0);
assert_eq!(rec.drvlc, 0); assert_eq!(rec.wait, 0); assert_eq!(rec.sts, 0); }
#[test]
fn test_get_put_val() {
let mut rec = ThrottleRecord::default();
rec.put_field("VAL", EpicsValue::Double(42.0)).unwrap();
assert_eq!(rec.get_field("VAL"), Some(EpicsValue::Double(42.0)));
}
#[test]
fn test_get_put_dly() {
let mut rec = ThrottleRecord::default();
rec.put_field("DLY", EpicsValue::Double(1.5)).unwrap();
assert_eq!(rec.get_field("DLY"), Some(EpicsValue::Double(1.5)));
}
#[test]
fn test_get_put_limits() {
let mut rec = ThrottleRecord::default();
rec.put_field("DRVLH", EpicsValue::Double(100.0)).unwrap();
rec.put_field("DRVLL", EpicsValue::Double(0.0)).unwrap();
assert_eq!(rec.get_field("DRVLH"), Some(EpicsValue::Double(100.0)));
assert_eq!(rec.get_field("DRVLL"), Some(EpicsValue::Double(0.0)));
}
#[test]
fn test_read_only_fields() {
let mut rec = ThrottleRecord::default();
assert!(rec.put_field("OVAL", EpicsValue::Double(1.0)).is_err());
assert!(rec.put_field("SENT", EpicsValue::Double(1.0)).is_err());
assert!(rec.put_field("OSENT", EpicsValue::Double(1.0)).is_err());
assert!(rec.put_field("WAIT", EpicsValue::Short(1)).is_err());
assert!(rec.put_field("DRVLS", EpicsValue::Short(1)).is_err());
assert!(
rec.put_field("VER", EpicsValue::String("x".into()))
.is_err()
);
assert!(rec.put_field("STS", EpicsValue::Short(1)).is_err());
assert!(rec.put_field("OV", EpicsValue::Short(1)).is_err());
assert!(rec.put_field("SIV", EpicsValue::Short(1)).is_err());
}
#[test]
fn test_type_mismatch() {
let mut rec = ThrottleRecord::default();
assert!(
rec.put_field("VAL", EpicsValue::String("bad".into()))
.is_err()
);
assert!(rec.put_field("PREC", EpicsValue::Double(1.0)).is_err());
}
#[test]
fn test_unknown_field() {
let rec = ThrottleRecord::default();
assert!(rec.get_field("NONEXISTENT").is_none());
let mut rec = rec;
assert!(
rec.put_field("NONEXISTENT", EpicsValue::Double(1.0))
.is_err()
);
}
#[test]
fn test_process_sends_value_no_delay() {
let mut rec = ThrottleRecord::default();
rec.dly = 0.0; rec.out = "OUTPUT:PV".to_string();
rec.val = 42.0;
rec.process().unwrap();
assert_eq!(rec.sent, 42.0);
assert_eq!(rec.sts, 2); assert_eq!(rec.wait, 0); }
#[test]
fn test_process_sends_value_with_delay() {
let mut rec = ThrottleRecord::default();
rec.dly = 1.0; rec.out = "OUTPUT:PV".to_string();
rec.val = 42.0;
let outcome = rec.process().unwrap();
assert_eq!(rec.sent, 42.0);
assert_eq!(rec.wait, 1); let has_reprocess = outcome
.actions
.iter()
.any(|a| matches!(a, ProcessAction::ReprocessAfter(_)));
assert!(has_reprocess, "Should have ReprocessAfter action");
let has_write = outcome
.actions
.iter()
.any(|a| matches!(a, ProcessAction::WriteDbLink { .. }));
assert!(has_write, "Should have WriteDbLink action for OUT");
}
#[test]
fn test_process_queues_during_delay() {
let mut rec = ThrottleRecord::default();
rec.dly = 10.0; rec.out = "OUTPUT:PV".to_string();
rec.val = 42.0;
rec.process().unwrap(); assert_eq!(rec.sent, 42.0);
assert_eq!(rec.wait, 1);
rec.val = 99.0;
let outcome = rec.process().unwrap();
let has_reprocess = outcome
.actions
.iter()
.any(|a| matches!(a, ProcessAction::ReprocessAfter(_)));
assert!(
has_reprocess,
"Should have ReprocessAfter for pending drain"
);
assert_eq!(rec.sent, 42.0); }
#[test]
fn test_process_updates_oval() {
let mut rec = ThrottleRecord::default();
rec.dly = 0.0;
rec.val = 10.0;
rec.process().unwrap();
assert_eq!(rec.oval, 10.0);
rec.val = 20.0;
rec.process().unwrap();
assert_eq!(rec.oval, 20.0);
}
#[test]
fn test_process_osent_tracking() {
let mut rec = ThrottleRecord::default();
rec.dly = 0.0;
rec.out = "OUTPUT:PV".to_string();
rec.val = 10.0;
rec.process().unwrap();
assert_eq!(rec.sent, 10.0);
assert_eq!(rec.osent, 0.0);
rec.val = 20.0;
rec.process().unwrap();
assert_eq!(rec.sent, 20.0);
assert_eq!(rec.osent, 10.0); }
#[test]
fn test_limit_clipping_on() {
let mut rec = ThrottleRecord::default();
rec.drvlh = 100.0;
rec.drvll = 0.0;
rec.drvlc = 1; rec.dly = 0.0; rec.out = "OUTPUT:PV".to_string();
rec.init_record(1).unwrap();
rec.val = 150.0;
rec.process().unwrap();
assert_eq!(rec.sent, 100.0);
assert_eq!(rec.drvls, 2); assert_eq!(rec.sts, 2); }
#[test]
fn test_limit_clipping_low() {
let mut rec = ThrottleRecord::default();
rec.drvlh = 100.0;
rec.drvll = 10.0;
rec.drvlc = 1; rec.dly = 0.0;
rec.out = "OUTPUT:PV".to_string();
rec.init_record(1).unwrap();
rec.val = 5.0;
rec.process().unwrap();
assert_eq!(rec.sent, 10.0);
assert_eq!(rec.drvls, 1); }
#[test]
fn test_limit_rejection() {
let mut rec = ThrottleRecord::default();
rec.drvlh = 100.0;
rec.drvll = 0.0;
rec.drvlc = 0; rec.dly = 0.0;
rec.init_record(1).unwrap();
rec.oval = 50.0; rec.val = 150.0; rec.process().unwrap();
assert_eq!(rec.val, 50.0, "VAL restored to OVAL on rejection");
assert_eq!(
rec.sts, 0,
"STS must stay Unknown — C never sets STS in the limit block"
);
assert_eq!(rec.drvls, 2, "DRVLS reports High limit (C line 272)");
assert_eq!(rec.sent, 0.0, "nothing sent on a rejected value");
}
#[test]
fn test_no_limits_when_equal() {
let mut rec = ThrottleRecord::default();
rec.drvlh = 0.0;
rec.drvll = 0.0; rec.dly = 0.0;
rec.out = "OUTPUT:PV".to_string();
rec.init_record(1).unwrap();
rec.val = 999.0;
rec.process().unwrap();
assert_eq!(rec.sent, 999.0);
assert_eq!(rec.drvls, 0); }
#[test]
fn test_pending_value_clamped_to_drive_limit_on_drain() {
let mut rec = ThrottleRecord::default();
rec.drvlh = 100.0;
rec.drvll = 0.0;
rec.drvlc = 1; rec.dly = 0.05; rec.out = "OUTPUT:PV".to_string();
rec.init_record(1).unwrap();
rec.val = 50.0;
rec.process().unwrap();
assert_eq!(rec.sent, 50.0);
assert_eq!(rec.wait, 1);
rec.val = 150.0;
rec.process().unwrap();
assert_eq!(rec.sent, 50.0, "queued value must not be sent yet");
std::thread::sleep(std::time::Duration::from_millis(60));
let outcome = rec.process().unwrap();
assert_eq!(rec.sent, 100.0, "drained value must be clamped to DRVLH");
assert_eq!(rec.drvls, 2, "DRVLS must report High limit");
let written = outcome.actions.iter().find_map(|a| match a {
ProcessAction::WriteDbLink { value, .. } => Some(value),
_ => None,
});
assert_eq!(
written,
Some(&EpicsValue::Double(100.0)),
"value written to OUT must be the clamped 100.0, not raw 150.0"
);
}
#[test]
fn test_pending_value_rejected_on_drain_when_clipping_off() {
let mut rec = ThrottleRecord::default();
rec.drvlh = 100.0;
rec.drvll = 0.0;
rec.drvlc = 0; rec.dly = 0.05;
rec.out = "OUTPUT:PV".to_string();
rec.init_record(1).unwrap();
rec.val = 50.0;
rec.process().unwrap();
assert_eq!(rec.sent, 50.0);
rec.val = 150.0; rec.process().unwrap();
assert_eq!(rec.val, 50.0, "out-of-range value restored to OVAL=50");
assert_eq!(rec.drvls, 2, "DRVLS reports High limit");
std::thread::sleep(std::time::Duration::from_millis(60));
let outcome = rec.process().unwrap();
assert_eq!(rec.sent, 50.0, "rejected value must not reach OUT");
assert_eq!(
rec.sts, 2,
"STS stays Success from the first send — C never sets STS on a limit rejection"
);
let has_write = outcome
.actions
.iter()
.any(|a| matches!(a, ProcessAction::WriteDbLink { .. }));
assert!(!has_write, "drain has nothing queued — no OUT write");
}
#[test]
fn test_special_dly_clamp_negative() {
let mut rec = ThrottleRecord::default();
rec.dly = -5.0;
rec.special("DLY", true).unwrap();
assert_eq!(rec.dly, 0.0);
}
#[test]
fn test_special_dly_positive() {
let mut rec = ThrottleRecord::default();
rec.dly = 2.5;
rec.special("DLY", true).unwrap();
assert_eq!(rec.dly, 2.5); }
#[test]
fn test_special_drvlh_drvll_enables_limits() {
let mut rec = ThrottleRecord::default();
rec.drvlh = 100.0;
rec.drvll = 0.0;
rec.special("DRVLH", true).unwrap();
rec.val = 150.0;
rec.process().unwrap();
assert_eq!(rec.sts, 0, "STS unchanged on a limit rejection");
assert_eq!(rec.drvls, 2, "DRVLS reports High limit");
assert_eq!(rec.sent, 0.0, "rejected value is not sent");
}
#[test]
fn test_special_drvlh_drvll_recomputes_drvls() {
let mut rec = ThrottleRecord::default();
rec.val = 500.0;
rec.drvlh = 100.0;
rec.drvll = 0.0;
rec.special("DRVLH", true).unwrap();
assert_eq!(rec.drvls, 2, "VAL above DRVLH -> DRVLS High");
rec.val = -5.0;
rec.special("DRVLL", true).unwrap();
assert_eq!(rec.drvls, 1, "VAL below DRVLL -> DRVLS Low");
rec.val = 50.0;
rec.special("DRVLH", true).unwrap();
assert_eq!(rec.drvls, 0, "VAL within limits -> DRVLS Normal");
rec.val = 500.0;
rec.drvlh = 0.0;
rec.drvll = 0.0;
rec.special("DRVLH", true).unwrap();
assert_eq!(rec.drvls, 0, "limits disabled -> DRVLS Normal");
}
#[test]
fn test_ver_string() {
let rec = ThrottleRecord::default();
assert_eq!(rec.ver, "0-2-1");
assert_eq!(
rec.get_field("VER"),
Some(EpicsValue::String("0-2-1".into()))
);
}
#[test]
fn test_init_record_resets_state() {
let mut rec = ThrottleRecord::default();
rec.val = 42.0;
rec.sts = 2;
rec.init_record(1).unwrap();
assert_eq!(rec.val, 0.0, "init_record pass 1 resets VAL to 0");
assert_eq!(rec.sts, 0, "init_record pass 1 resets STS to Unknown");
}
#[test]
fn test_limit_clipping_low_bound_order() {
let mut rec = ThrottleRecord::default();
rec.drvlh = 100.0;
rec.drvll = 10.0;
rec.drvlc = 1; rec.dly = 0.0;
rec.out = "OUTPUT:PV".to_string();
rec.init_record(1).unwrap();
rec.val = -50.0; rec.process().unwrap();
assert_eq!(rec.sent, 10.0, "clamped to DRVLL");
assert_eq!(rec.drvls, 1, "DRVLS Low");
}
#[test]
fn test_sync_pre_process_actions_and_reset() {
use epics_base_rs::server::record::Record;
let mut rec = ThrottleRecord::default();
rec.sync = 1;
let actions = rec.pre_process_actions();
assert_eq!(actions.len(), 1, "Should have one ReadDbLink action");
assert_eq!(
rec.sync, 0,
"sync should be reset after pre_process_actions"
);
let actions = rec.pre_process_actions();
assert!(actions.is_empty());
}
#[test]
fn test_can_device_write() {
let rec = ThrottleRecord::default();
assert!(rec.can_device_write());
}
#[test]
fn test_should_fire_forward_link_only_when_out_written() {
let mut rec = ThrottleRecord::default();
rec.dly = 0.0;
rec.out = "OUTPUT:PV".to_string();
rec.val = 42.0;
rec.process().unwrap();
assert!(
rec.should_fire_forward_link(),
"a cycle that wrote OUT must fire FLNK"
);
}
#[test]
fn test_no_forward_link_on_queuing_cycle() {
let mut rec = ThrottleRecord::default();
rec.dly = 10.0; rec.out = "OUTPUT:PV".to_string();
rec.val = 42.0;
rec.process().unwrap(); assert!(
rec.should_fire_forward_link(),
"first send wrote OUT — FLNK fires"
);
rec.val = 99.0;
rec.process().unwrap();
assert!(
!rec.should_fire_forward_link(),
"a queuing-during-delay cycle writes no OUT — C never fires FLNK \
(recGblFwdLink commented out in process(), valuePut not reached)"
);
}
#[test]
fn test_no_forward_link_on_rejected_cycle() {
let mut rec = ThrottleRecord::default();
rec.drvlh = 100.0;
rec.drvll = 0.0;
rec.drvlc = 0; rec.dly = 0.0;
rec.out = "OUTPUT:PV".to_string();
rec.init_record(1).unwrap();
rec.oval = 50.0;
rec.val = 150.0; rec.process().unwrap();
assert!(
!rec.should_fire_forward_link(),
"a rejected out-of-range cycle writes no OUT — no FLNK"
);
}
#[test]
fn test_no_forward_link_on_drain_with_nothing_queued() {
let mut rec = ThrottleRecord::default();
rec.dly = 0.05;
rec.out = "OUTPUT:PV".to_string();
rec.val = 10.0;
rec.process().unwrap(); assert!(rec.should_fire_forward_link());
std::thread::sleep(std::time::Duration::from_millis(60));
rec.process().unwrap();
assert!(
!rec.should_fire_forward_link(),
"a drain cycle with nothing queued writes no OUT — no FLNK"
);
}
#[test]
fn test_constant_out_link_reports_error_and_no_write() {
let mut rec = ThrottleRecord::default();
rec.dly = 0.0;
rec.out = "5.0".to_string(); rec.val = 42.0;
let outcome = rec.process().unwrap();
assert_eq!(
rec.sts, 1,
"a CONSTANT OUT link must report STS=Error (C valuePut else branch)"
);
assert_eq!(
rec.sent, 0.0,
"a CONSTANT OUT link is never written — SENT must not advance"
);
let has_write = outcome
.actions
.iter()
.any(|a| matches!(a, ProcessAction::WriteDbLink { .. }));
assert!(!has_write, "a CONSTANT OUT link emits no WriteDbLink");
assert!(
!rec.should_fire_forward_link(),
"a CONSTANT OUT cycle writes no OUT — no FLNK (C fires FLNK only \
in valuePut's non-CONSTANT branch)"
);
}
#[test]
fn test_empty_out_link_reports_error_and_no_write() {
let mut rec = ThrottleRecord::default();
rec.dly = 0.0;
rec.val = 42.0;
let outcome = rec.process().unwrap();
assert_eq!(rec.sts, 1, "an empty OUT link must report STS=Error");
assert_eq!(rec.sent, 0.0, "an empty OUT link is never written");
let has_write = outcome
.actions
.iter()
.any(|a| matches!(a, ProcessAction::WriteDbLink { .. }));
assert!(!has_write, "an empty OUT link emits no WriteDbLink");
}
#[test]
fn test_real_out_link_reports_success_and_writes() {
let mut rec = ThrottleRecord::default();
rec.dly = 0.0;
rec.out = "OUTPUT:PV".to_string(); rec.val = 42.0;
let outcome = rec.process().unwrap();
assert_eq!(rec.sts, 2, "a real OUT link write reports STS=Success");
assert_eq!(rec.sent, 42.0, "a real OUT link advances SENT");
let has_write = outcome
.actions
.iter()
.any(|a| matches!(a, ProcessAction::WriteDbLink { .. }));
assert!(has_write, "a real OUT link emits a WriteDbLink");
}
#[test]
fn test_dly_infinity_rejected() {
let mut rec = ThrottleRecord::default();
assert!(
rec.put_field("DLY", EpicsValue::Double(f64::INFINITY))
.is_err(),
"a CA put of DLY = +inf must be rejected, not stored"
);
assert_eq!(rec.dly, 0.0, "DLY must keep its prior finite value");
}
#[test]
fn test_dly_neg_infinity_rejected() {
let mut rec = ThrottleRecord::default();
assert!(
rec.put_field("DLY", EpicsValue::Double(f64::NEG_INFINITY))
.is_err(),
"a CA put of DLY = -inf must be rejected"
);
assert_eq!(rec.dly, 0.0);
}
#[test]
fn test_dly_nan_rejected() {
let mut rec = ThrottleRecord::default();
assert!(
rec.put_field("DLY", EpicsValue::Double(f64::NAN)).is_err(),
"a CA put of DLY = NaN must be rejected"
);
assert_eq!(rec.dly, 0.0);
}
#[test]
fn test_dly_infinity_does_not_panic_process() {
let mut rec = ThrottleRecord::default();
rec.out = "OUTPUT:PV".to_string();
let _ = rec.put_field("DLY", EpicsValue::Double(f64::INFINITY));
rec.val = 1.0;
rec.process().unwrap();
}
#[test]
fn test_dly_huge_finite_rejected() {
let mut rec = ThrottleRecord::default();
assert!(
rec.put_field("DLY", EpicsValue::Double(1e300)).is_err(),
"a CA put of DLY = 1e300 must be rejected, not stored"
);
assert_eq!(rec.dly, 0.0, "DLY must keep its prior finite value");
}
#[test]
fn test_dly_huge_finite_does_not_panic_process() {
let mut rec = ThrottleRecord::default();
rec.out = "OUTPUT:PV".to_string();
assert!(
rec.put_field("DLY", EpicsValue::Double(1e300)).is_err(),
"DLY = 1e300 must be rejected"
);
assert_eq!(rec.dly, 0.0, "rejected put must leave DLY unchanged");
rec.val = 1.0;
let outcome = rec.process().unwrap();
assert_eq!(rec.sent, 1.0, "value must have been sent");
assert_eq!(rec.wait, 0, "no delay armed, WAIT stays clear");
let _ = outcome;
}
#[test]
fn test_dly_huge_finite_assigned_directly_does_not_panic_process() {
let mut rec = ThrottleRecord::default();
rec.out = "OUTPUT:PV".to_string();
rec.dly = 1e300;
rec.special("DLY", true).unwrap();
assert!(
rec.dly.is_finite() && rec.dly <= 86_400.0,
"special() must clamp a huge DLY to a Duration-safe ceiling, got {}",
rec.dly
);
rec.val = 1.0;
let outcome = rec.process().unwrap();
assert_eq!(rec.wait, 1, "a positive DLY arms the delay, WAIT set");
let _ = outcome;
}