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()
.port(0)
.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(0),
"WAIT clear after the immediate send — no value queued yet"
);
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 wait = server.get("TEST:THR.WAIT").await.unwrap();
assert_eq!(
wait,
EpicsValue::Short(1),
"WAIT set while a value is queued during the delay"
);
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()
.port(0)
.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()
.port(0)
.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()
.port(0)
.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()
.port(0)
.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()
.port(0)
.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:PIDSUP", "PROC", EpicsValue::Short(1))
.await
.unwrap();
tokio::time::sleep(std::time::Duration::from_millis(5)).await;
}
let udf = server.get("TEST:PIDSUP.UDF").await.unwrap();
assert_eq!(
udf,
EpicsValue::UChar(1),
"UDF must stay set across 5 cycles when nothing writes VAL — \
only a value-field put (dbAccess.c:1414-1415) or the record's \
own STPL conditions may clear it"
);
let p = server.get("TEST:PIDSUP.P").await.unwrap();
assert_eq!(
p,
EpicsValue::Double(0.0),
"do_pid must not have run while udf is TRUE (epidRecord.c:195)"
);
server
.put("TEST:PIDSUP.VAL", EpicsValue::Double(100.0))
.await
.unwrap();
db.put_record_field_from_ca("TEST:PIDSUP", "PROC", EpicsValue::Short(1))
.await
.unwrap();
let udf = server.get("TEST:PIDSUP.UDF").await.unwrap();
assert_eq!(
udf,
EpicsValue::UChar(0),
"a value-field put defines the record (C dbPut clears udf)"
);
let p = server.get("TEST:PIDSUP.P").await.unwrap();
assert_eq!(
p,
EpicsValue::Double(200.0),
"with udf cleared by the VAL put, do_pid runs: P = KP * (VAL - CVAL)"
);
}
#[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()
.port(0)
.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::UChar(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()
.port(0)
.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::UChar(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()
.port(0)
.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.as_str_lossy().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::ULong(v) => assert!(v > 0, "RVAL should be positive"),
other => panic!("expected ULong, got {:?}", other),
}
}
#[tokio::test]
async fn test_timestamp_non_pp_put_does_not_process() {
let db_str = r#"
record(timestamp, "TEST:TSNP") {
field(TST, "0")
}
"#;
let macros = HashMap::new();
let server = CaServerBuilder::new()
.port(0)
.register_record_type("timestamp", || Box::new(std_rs::TimestampRecord::default()))
.db_string(db_str, ¯os)
.unwrap()
.build()
.await
.unwrap();
let db = server.database().clone();
assert_eq!(
server.get("TEST:TSNP.RVAL").await.unwrap(),
EpicsValue::ULong(0),
"RVAL must be 0 before any process"
);
db.put_record_field_from_ca("TEST:TSNP", "TST", EpicsValue::Short(4))
.await
.unwrap();
tokio::time::sleep(std::time::Duration::from_millis(20)).await;
assert_eq!(
server.get("TEST:TSNP.RVAL").await.unwrap(),
EpicsValue::ULong(0),
"a put to TST must NOT process — RVAL must stay 0 (clock not re-read)"
);
db.put_record_field_from_ca("TEST:TSNP", "PROC", EpicsValue::Short(1))
.await
.unwrap();
tokio::time::sleep(std::time::Duration::from_millis(20)).await;
match server.get("TEST:TSNP.RVAL").await.unwrap() {
EpicsValue::ULong(v) => assert!(v > 0, "PROC must process and set RVAL > 0"),
other => panic!("expected ULong, 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()
.port(0)
.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)"
);
}
#[tokio::test]
async fn test_throttle_sync_reads_sinp_into_val_no_out_write() {
let db_str = r#"
record(ao, "TEST:THRSYNC:SRC") {
field(VAL, "7.5")
}
record(ao, "TEST:THRSYNC:TGT") {
field(VAL, "0")
}
record(throttle, "TEST:THRSYNC") {
field(SINP, "TEST:THRSYNC:SRC")
field(OUT, "TEST:THRSYNC:TGT PP")
}
"#;
let macros = HashMap::new();
let server = CaServerBuilder::new()
.port(0)
.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();
tokio::time::sleep(std::time::Duration::from_millis(30)).await;
assert_eq!(
server.get("TEST:THRSYNC.SIV").await.unwrap(),
EpicsValue::Short(2),
"a local SINP must classify as SIV=Local PV(2)"
);
db.put_record_field_from_ca("TEST:THRSYNC", "SYNC", EpicsValue::Short(1))
.await
.unwrap();
tokio::time::sleep(std::time::Duration::from_millis(30)).await;
assert_eq!(
server.get("TEST:THRSYNC.VAL").await.unwrap(),
EpicsValue::Double(7.5),
"SYNC must read SINP (7.5) into VAL"
);
assert_eq!(
server.get("TEST:THRSYNC.STS").await.unwrap(),
EpicsValue::Short(2),
"a successful SINP read sets STS=Success(2)"
);
assert_eq!(
server.get("TEST:THRSYNC.SYNC").await.unwrap(),
EpicsValue::Short(0),
"SYNC resets to Idle(0) after the sync"
);
assert_eq!(
server.get("TEST:THRSYNC.SENT").await.unwrap(),
EpicsValue::Double(0.0),
"valueSync must NOT write OUT — SENT must not advance"
);
assert_eq!(
server.get("TEST:THRSYNC:TGT.VAL").await.unwrap(),
EpicsValue::Double(0.0),
"valueSync must NOT process — the OUT target stays unwritten"
);
}
#[tokio::test]
async fn test_throttle_ov_siv_link_classification() {
let db_str = r#"
record(ao, "TEST:THROV:TGT") {
field(VAL, "0")
}
record(throttle, "TEST:THROV") {
field(OUT, "TEST:THROV:TGT")
field(SINP, "2.5")
}
record(throttle, "TEST:THROV2") {
field(OUT, "TEST:THROV:NOSUCHPV")
}
"#;
let macros = HashMap::new();
let server = CaServerBuilder::new()
.port(0)
.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();
tokio::time::sleep(std::time::Duration::from_millis(30)).await;
assert_eq!(
server.get("TEST:THROV.OV").await.unwrap(),
EpicsValue::Short(2),
"a local OUT PV must classify as OV=Local PV(2)"
);
assert_eq!(
server.get("TEST:THROV.SIV").await.unwrap(),
EpicsValue::Short(3),
"a constant SINP must classify as SIV=Constant(3)"
);
assert_eq!(
server.get("TEST:THROV2.OV").await.unwrap(),
EpicsValue::Short(0),
"an unresolvable OUT link must classify as OV=Ext PV NC(0)"
);
assert_eq!(
server.get("TEST:THROV2.SIV").await.unwrap(),
EpicsValue::Short(3),
"an empty SINP link classifies as SIV=Constant(3)"
);
}
#[tokio::test]
async fn test_throttle_non_val_put_does_not_process() {
let db_str = r#"
record(ao, "TEST:THRPP:TGT") {
field(VAL, "0")
}
record(throttle, "TEST:THRPP") {
field(DLY, "0")
field(OUT, "TEST:THRPP:TGT PP")
}
"#;
let macros = HashMap::new();
let server = CaServerBuilder::new()
.port(0)
.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:THRPP", EpicsValue::Double(42.0))
.await
.unwrap();
db.put_record_field_from_ca("TEST:THRPP", "DLY", EpicsValue::Double(1.0))
.await
.unwrap();
tokio::time::sleep(std::time::Duration::from_millis(20)).await;
assert_eq!(
server.get("TEST:THRPP.SENT").await.unwrap(),
EpicsValue::Double(0.0),
"a put to DLY must NOT process the throttle — nothing sent"
);
assert_eq!(
server.get("TEST:THRPP:TGT.VAL").await.unwrap(),
EpicsValue::Double(0.0),
"a put to DLY must NOT write the OUT target"
);
assert_eq!(
server.get("TEST:THRPP.DLY").await.unwrap(),
EpicsValue::Double(1.0),
"the DLY put itself still stored the new value"
);
}
#[tokio::test]
async fn test_epid_non_val_put_does_not_process() {
let db_str = r#"
record(ao, "TEST:PIDNV:SRC") {
field(VAL, "100")
}
record(epid, "TEST:PIDNV") {
field(SMSL, "1")
field(STPL, "TEST:PIDNV:SRC.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()
.port(0)
.register_record_type("epid", || Box::new(std_rs::EpidRecord::default()))
.register_record_type("ao", || Box::new(AoRecord::default()))
.db_string(db_str, ¯os)
.unwrap()
.build()
.await
.unwrap();
let db = server.database().clone();
db.put_record_field_from_ca("TEST:PIDNV", "PROC", EpicsValue::Short(1))
.await
.unwrap();
tokio::time::sleep(std::time::Duration::from_millis(5)).await;
let p_before = server.get("TEST:PIDNV.P").await.unwrap();
assert!(
matches!(p_before, EpicsValue::Double(v) if (v - 200.0).abs() < 1.0),
"after one process P should be ~200 (KP=2.0), got {p_before:?}"
);
db.put_record_field_from_ca("TEST:PIDNV", "KP", EpicsValue::Double(5.0))
.await
.unwrap();
tokio::time::sleep(std::time::Duration::from_millis(5)).await;
assert_eq!(
server.get("TEST:PIDNV.KP").await.unwrap(),
EpicsValue::Double(5.0),
"the KP put itself still stored the new gain"
);
let p_after = server.get("TEST:PIDNV.P").await.unwrap();
assert!(
matches!(p_after, EpicsValue::Double(v) if (v - 200.0).abs() < 1.0),
"a put to KP must NOT process the epid — P must stay ~200, not \
recompute to ~500 (got {p_after:?})"
);
}