use epics_base_rs::server::records::ao::AoRecord;
use epics_base_rs::types::EpicsValue;
use epics_ca_rs::server::CaServerBuilder;
use std::collections::HashMap;
#[tokio::test]
async fn test_throttle_delayed_reprocess() {
let db_str = r#"
record(ao, "TEST:THR:TGT") {
field(VAL, "0")
}
record(throttle, "TEST:THR") {
field(DLY, "0.2")
field(PREC, "2")
field(OUT, "TEST:THR:TGT PP")
}
"#;
let macros = HashMap::new();
let server = CaServerBuilder::new()
.register_record_type("throttle", || Box::new(std_rs::ThrottleRecord::default()))
.register_record_type("ao", || Box::new(AoRecord::default()))
.db_string(db_str, ¯os)
.unwrap()
.build()
.await
.unwrap();
let db = server.database().clone();
server
.put("TEST:THR", EpicsValue::Double(10.0))
.await
.unwrap();
db.put_record_field_from_ca("TEST:THR", "PROC", EpicsValue::Short(1))
.await
.unwrap();
tokio::time::sleep(std::time::Duration::from_millis(20)).await;
let sent = server.get("TEST:THR.SENT").await.unwrap();
assert_eq!(
sent,
EpicsValue::Double(10.0),
"First value should be sent immediately"
);
let wait = server.get("TEST:THR.WAIT").await.unwrap();
assert_eq!(
wait,
EpicsValue::Short(1),
"WAIT should be 1 during delay period"
);
server
.put("TEST:THR", EpicsValue::Double(20.0))
.await
.unwrap();
db.put_record_field_from_ca("TEST:THR", "PROC", EpicsValue::Short(1))
.await
.unwrap();
tokio::time::sleep(std::time::Duration::from_millis(20)).await;
let sent = server.get("TEST:THR.SENT").await.unwrap();
assert_eq!(
sent,
EpicsValue::Double(10.0),
"Second value should NOT be sent yet"
);
tokio::time::sleep(std::time::Duration::from_millis(400)).await;
let sent = server.get("TEST:THR.SENT").await.unwrap();
assert_eq!(
sent,
EpicsValue::Double(20.0),
"After delay, pending value should be sent"
);
}
#[tokio::test]
async fn test_throttle_no_delay_immediate() {
let db_str = r#"
record(ao, "TEST:THR2:TGT") {
field(VAL, "0")
}
record(throttle, "TEST:THR2") {
field(DLY, "0")
field(OUT, "TEST:THR2:TGT PP")
}
"#;
let macros = HashMap::new();
let server = CaServerBuilder::new()
.register_record_type("throttle", || Box::new(std_rs::ThrottleRecord::default()))
.register_record_type("ao", || Box::new(AoRecord::default()))
.db_string(db_str, ¯os)
.unwrap()
.build()
.await
.unwrap();
let db = server.database().clone();
server
.put("TEST:THR2", EpicsValue::Double(42.0))
.await
.unwrap();
db.put_record_field_from_ca("TEST:THR2", "PROC", EpicsValue::Short(1))
.await
.unwrap();
tokio::time::sleep(std::time::Duration::from_millis(20)).await;
let sent = server.get("TEST:THR2.SENT").await.unwrap();
assert_eq!(sent, EpicsValue::Double(42.0));
let wait = server.get("TEST:THR2.WAIT").await.unwrap();
assert_eq!(
wait,
EpicsValue::Short(0),
"No delay means WAIT should be 0"
);
}
#[tokio::test]
async fn test_throttle_limit_clipping_via_framework() {
let db_str = r#"
record(ao, "TEST:THR3:TGT") {
field(VAL, "0")
}
record(throttle, "TEST:THR3") {
field(DLY, "0")
field(DRVLH, "100")
field(DRVLL, "0")
field(DRVLC, "1")
field(OUT, "TEST:THR3:TGT PP")
}
"#;
let macros = HashMap::new();
let server = CaServerBuilder::new()
.register_record_type("throttle", || Box::new(std_rs::ThrottleRecord::default()))
.register_record_type("ao", || Box::new(AoRecord::default()))
.db_string(db_str, ¯os)
.unwrap()
.build()
.await
.unwrap();
let db = server.database().clone();
server
.put("TEST:THR3", EpicsValue::Double(150.0))
.await
.unwrap();
db.put_record_field_from_ca("TEST:THR3", "PROC", EpicsValue::Short(1))
.await
.unwrap();
tokio::time::sleep(std::time::Duration::from_millis(20)).await;
let sent = server.get("TEST:THR3.SENT").await.unwrap();
assert_eq!(
sent,
EpicsValue::Double(100.0),
"Should be clipped to DRVLH"
);
let drvls = server.get("TEST:THR3.DRVLS").await.unwrap();
assert_eq!(
drvls,
EpicsValue::Short(2),
"DRVLS should indicate high limit"
);
}
#[tokio::test]
async fn test_epid_pid_via_framework() {
let db_str = r#"
record(epid, "TEST:PID") {
field(STPL, "100")
field(KP, "2.0")
field(KI, "0")
field(KD, "0")
field(FBON, "1")
field(DRVH, "1000")
field(DRVL, "-1000")
}
"#;
let macros = HashMap::new();
let server = CaServerBuilder::new()
.register_record_type("epid", || Box::new(std_rs::EpidRecord::default()))
.db_string(db_str, ¯os)
.unwrap()
.build()
.await
.unwrap();
let db = server.database().clone();
db.put_record_field_from_ca("TEST:PID", "PROC", EpicsValue::Short(1))
.await
.unwrap();
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
db.put_record_field_from_ca("TEST:PID", "PROC", EpicsValue::Short(1))
.await
.unwrap();
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
let p = server.get("TEST:PID.P").await.unwrap();
match p {
EpicsValue::Double(v) => {
assert!((v - 200.0).abs() < 1.0, "P should be ~200.0, got {}", v);
}
other => panic!("expected Double, got {:?}", other),
}
let oval = server.get("TEST:PID.OVAL").await.unwrap();
match oval {
EpicsValue::Double(v) => {
assert!(v.abs() > 1.0, "OVAL should be non-zero, got {}", v);
}
other => panic!("expected Double, got {:?}", other),
}
}
#[tokio::test]
async fn test_epid_pid_via_process_record_path() {
let db_str = r#"
record(epid, "TEST:PID2") {
field(STPL, "100")
field(KP, "2.0")
field(KI, "0")
field(KD, "0")
field(FBON, "1")
field(DRVH, "1000")
field(DRVL, "-1000")
}
"#;
let macros = HashMap::new();
let server = CaServerBuilder::new()
.register_record_type("epid", || Box::new(std_rs::EpidRecord::default()))
.db_string(db_str, ¯os)
.unwrap()
.build()
.await
.unwrap();
let db = server.database().clone();
db.process_record("TEST:PID2").await.unwrap();
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
db.process_record("TEST:PID2").await.unwrap();
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
let p = server.get("TEST:PID2.P").await.unwrap();
match p {
EpicsValue::Double(v) => {
assert!(
(v - 200.0).abs() < 1.0,
"P should be ~200.0 (do_pid must run on the process_record path), got {}",
v
);
}
other => panic!("expected Double, got {:?}", other),
}
}
#[tokio::test]
async fn test_epid_supervisory_empty_stpl_never_runs_do_pid() {
let db_str = r#"
record(epid, "TEST:PIDSUP") {
field(KP, "2.0")
field(KI, "0")
field(KD, "0")
field(FBON, "1")
field(DRVH, "1000")
field(DRVL, "-1000")
}
"#;
let macros = HashMap::new();
let server = CaServerBuilder::new()
.register_record_type("epid", || Box::new(std_rs::EpidRecord::default()))
.db_string(db_str, ¯os)
.unwrap()
.build()
.await
.unwrap();
let db = server.database().clone();
server
.put("TEST:PIDSUP.VAL", EpicsValue::Double(100.0))
.await
.unwrap();
for _ in 0..5 {
db.put_record_field_from_ca("TEST:PIDSUP", "PROC", EpicsValue::Short(1))
.await
.unwrap();
tokio::time::sleep(std::time::Duration::from_millis(5)).await;
}
let p = server.get("TEST:PIDSUP.P").await.unwrap();
match p {
EpicsValue::Double(v) => {
assert_eq!(
v, 0.0,
"supervisory epid with empty STPL must NEVER run do_pid; \
P must stay 0 after 5 cycles, got {}",
v
);
}
other => panic!("expected Double, got {:?}", other),
}
let udf = server.get("TEST:PIDSUP.UDF").await.unwrap();
assert_eq!(
udf,
EpicsValue::Char(1),
"UDF must stay set for a supervisory empty-STPL epid"
);
}
#[tokio::test]
async fn test_epid_closed_loop_working_stpl_runs_do_pid() {
let db_str = r#"
record(ao, "TEST:PIDSRC") {
field(VAL, "100")
}
record(epid, "TEST:PIDCL") {
field(SMSL, "1")
field(STPL, "TEST:PIDSRC.VAL")
field(KP, "2.0")
field(KI, "0")
field(KD, "0")
field(FBON, "1")
field(DRVH, "1000")
field(DRVL, "-1000")
}
"#;
let macros = HashMap::new();
let server = CaServerBuilder::new()
.register_record_type("epid", || Box::new(std_rs::EpidRecord::default()))
.db_string(db_str, ¯os)
.unwrap()
.build()
.await
.unwrap();
let db = server.database().clone();
db.put_record_field_from_ca("TEST:PIDCL", "PROC", EpicsValue::Short(1))
.await
.unwrap();
tokio::time::sleep(std::time::Duration::from_millis(5)).await;
let p_c1 = server.get("TEST:PIDCL.P").await.unwrap();
match p_c1 {
EpicsValue::Double(v) => {
assert!(
(v - 200.0).abs() < 1.0,
"cycle 1: closed-loop epid with a resolved STPL must run \
do_pid (udf cleared in-cycle); P should be ~200.0, got {}",
v
);
}
other => panic!("expected Double, got {:?}", other),
}
let udf = server.get("TEST:PIDCL.UDF").await.unwrap();
assert_eq!(
udf,
EpicsValue::Char(0),
"closed-loop epid with a resolved STPL must have UDF cleared"
);
db.put_record_field_from_ca("TEST:PIDCL", "PROC", EpicsValue::Short(1))
.await
.unwrap();
tokio::time::sleep(std::time::Duration::from_millis(5)).await;
let p_c2 = server.get("TEST:PIDCL.P").await.unwrap();
match p_c2 {
EpicsValue::Double(v) => {
assert!(
(v - 200.0).abs() < 1.0,
"cycle 2: closed-loop epid keeps running do_pid; P should \
be ~200.0, got {}",
v
);
}
other => panic!("expected Double, got {:?}", other),
}
}
#[tokio::test]
async fn test_epid_closed_loop_failing_stpl_keeps_udf() {
let db_str = r#"
record(epid, "TEST:PIDCLF") {
field(SMSL, "1")
field(STPL, "TEST:NOSUCHREC.VAL")
field(KP, "2.0")
field(KI, "0")
field(KD, "0")
field(FBON, "1")
field(DRVH, "1000")
field(DRVL, "-1000")
}
"#;
let macros = HashMap::new();
let server = CaServerBuilder::new()
.register_record_type("epid", || Box::new(std_rs::EpidRecord::default()))
.db_string(db_str, ¯os)
.unwrap()
.build()
.await
.unwrap();
let db = server.database().clone();
for _ in 0..5 {
db.put_record_field_from_ca("TEST:PIDCLF", "PROC", EpicsValue::Short(1))
.await
.unwrap();
tokio::time::sleep(std::time::Duration::from_millis(5)).await;
}
let p = server.get("TEST:PIDCLF.P").await.unwrap();
match p {
EpicsValue::Double(v) => {
assert_eq!(
v, 0.0,
"closed-loop epid with a failing STPL must keep udf set \
and never run do_pid; P must stay 0, got {}",
v
);
}
other => panic!("expected Double, got {:?}", other),
}
let udf = server.get("TEST:PIDCLF.UDF").await.unwrap();
assert_eq!(
udf,
EpicsValue::Char(1),
"UDF must stay set when the STPL fetch fails"
);
}
#[tokio::test]
async fn test_timestamp_via_framework() {
let db_str = r#"
record(timestamp, "TEST:TS") {
field(TST, "4")
}
"#;
let macros = HashMap::new();
let server = CaServerBuilder::new()
.register_record_type("timestamp", || Box::new(std_rs::TimestampRecord::default()))
.db_string(db_str, ¯os)
.unwrap()
.build()
.await
.unwrap();
let db = server.database().clone();
db.put_record_field_from_ca("TEST:TS", "PROC", EpicsValue::Short(1))
.await
.unwrap();
tokio::time::sleep(std::time::Duration::from_millis(20)).await;
let val = server.get("TEST:TS").await.unwrap();
match val {
EpicsValue::String(s) => {
assert!(!s.is_empty(), "Timestamp should be non-empty");
assert!(s.contains(':'), "Format 4 (HH:MM:SS) should contain ':'");
}
other => panic!("expected String, got {:?}", other),
}
let rval = server.get("TEST:TS.RVAL").await.unwrap();
match rval {
EpicsValue::Long(v) => assert!(v > 0, "RVAL should be positive"),
other => panic!("expected Long, got {:?}", other),
}
}
#[tokio::test]
async fn test_ca_trig_epid_fires_flnk_exactly_once() {
let db_str = r#"
record(calc, "CTR") {
field(INPA, "CTR.VAL")
field(CALC, "A+1")
}
record(epid, "PID") {
field(DTYP, "Epid Async Soft")
field(STPL, "100")
field(KP, "1.0")
field(KI, "0")
field(KD, "0")
field(FBON, "1")
field(DRVH, "1000")
field(DRVL, "-1000")
field(MDT, "0")
field(TRIG, "ca://REMOTE:READBACK")
field(TVAL, "42.0")
field(FLNK, "CTR")
}
"#;
let macros = HashMap::new();
let server = CaServerBuilder::new()
.register_record_type("epid", || Box::new(std_rs::EpidRecord::default()))
.register_record_type("calc", || {
Box::new(epics_base_rs::server::records::calc::CalcRecord::new("A+1"))
})
.register_device_support("Epid Async Soft", || {
Box::new(
std_rs::device_support::epid_soft_callback::EpidSoftCallbackDeviceSupport::new(),
)
})
.db_string(db_str, ¯os)
.unwrap()
.build()
.await
.unwrap();
let db = server.database().clone();
assert_eq!(server.get("CTR").await.unwrap(), EpicsValue::Double(0.0));
db.put_record_field_from_ca("PID", "PROC", EpicsValue::Short(1))
.await
.unwrap();
tokio::time::sleep(std::time::Duration::from_millis(120)).await;
let count = server.get("CTR").await.unwrap();
assert_eq!(
count,
EpicsValue::Double(1.0),
"a single CA-TRIG epid cycle must fire FLNK exactly once \
(got {count:?}; 2.0 means the trigger pass wrongly ran the \
process tail as well as the reprocess pass)"
);
}