#![allow(unused_imports, clippy::all)]
use epics_base_rs::error::{CaError, CaResult};
use epics_base_rs::server::record::*;
use epics_base_rs::server::records::ai::AiRecord;
use epics_base_rs::server::records::ao::AoRecord;
use epics_base_rs::server::records::bi::BiRecord;
use epics_base_rs::server::records::stringin::StringinRecord;
use epics_base_rs::types::{DbFieldType, EpicsValue, PvString};
#[test]
fn test_ai_record_type() {
let rec = AiRecord::new(25.0);
assert_eq!(rec.record_type(), "ai");
}
#[test]
fn test_ai_get_val() {
let rec = AiRecord::new(42.0);
match rec.get_field("VAL") {
Some(EpicsValue::Double(v)) => assert!((v - 42.0).abs() < 1e-10),
other => panic!("expected Double(42.0), got {:?}", other),
}
}
#[test]
fn test_ai_put_val() {
let mut rec = AiRecord::new(0.0);
rec.put_field("VAL", EpicsValue::Double(99.0)).unwrap();
match rec.get_field("VAL") {
Some(EpicsValue::Double(v)) => assert!((v - 99.0).abs() < 1e-10),
other => panic!("expected Double(99.0), got {:?}", other),
}
}
#[test]
fn test_ai_string_field() {
let mut rec = AiRecord::default();
rec.put_field("EGU", EpicsValue::String("celsius".into()))
.unwrap();
match rec.get_field("EGU") {
Some(EpicsValue::String(s)) => assert_eq!(s, "celsius"),
other => panic!("expected String, got {:?}", other),
}
}
#[test]
fn test_ai_field_list() {
let rec = AiRecord::default();
let fields = rec.field_list();
assert_eq!(fields[0].name, "VAL");
assert_eq!(fields[0].dbf_type, DbFieldType::Double);
for declared in ["INP", "EGU", "PREC", "LINR", "SIMM", "SIML", "SIOL"] {
assert!(
fields.iter().any(|f| f.name == declared),
"ai.{declared} is in aiRecord.dbd but not in field_list()"
);
}
let linr = fields.iter().find(|f| f.name == "LINR").unwrap();
assert_eq!(linr.dbf_type, DbFieldType::Enum);
assert_eq!(linr.menu.unwrap()[0], "NO CONVERSION");
}
#[test]
fn test_ai_unknown_field() {
let rec = AiRecord::default();
assert!(rec.get_field("NONEXISTENT").is_none());
}
#[test]
fn test_ai_put_type_mismatch() {
let mut rec = AiRecord::default();
let result = rec.put_field("VAL", EpicsValue::String("bad".into()));
assert!(result.is_err());
}
#[test]
fn test_ai_put_unknown_field() {
let mut rec = AiRecord::default();
let result = rec.put_field("NONEXISTENT", EpicsValue::Double(1.0));
assert!(result.is_err());
}
#[test]
fn test_ao_record() {
let mut rec = AoRecord::new(10.0);
assert_eq!(rec.record_type(), "ao");
rec.put_field("VAL", EpicsValue::Double(20.0)).unwrap();
match rec.get_field("VAL") {
Some(EpicsValue::Double(v)) => assert!((v - 20.0).abs() < 1e-10),
other => panic!("expected Double(20.0), got {:?}", other),
}
}
#[test]
fn test_ao_readback_inverts_full_conversion_chain() {
let mut rec = AoRecord::new(0.0);
rec.roff = 1;
rec.aslo = 2.0;
rec.aoff = 3.0;
rec.linr = 2; rec.eslo = 5.0;
rec.eoff = 7.0;
assert!(
rec.apply_raw_readback(10),
"ao owns the raw->eng readback convert"
);
assert_eq!(rec.rval, 10, "raw value stored to RVAL");
match rec.get_field("VAL") {
Some(EpicsValue::Double(v)) => assert!((v - 132.0).abs() < 1e-9, "got {v}"),
other => panic!("expected Double(132.0), got {other:?}"),
}
}
#[test]
fn test_ao_readback_no_conversion_passthrough() {
let mut rec = AoRecord::new(0.0);
rec.linr = 0;
assert!(rec.apply_raw_readback(42));
assert_eq!(rec.rval, 42);
match rec.get_field("VAL") {
Some(EpicsValue::Double(v)) => assert!((v - 42.0).abs() < 1e-9, "got {v}"),
other => panic!("expected Double(42.0), got {other:?}"),
}
}
#[test]
fn test_ao_readback_skip_convert_bypasses_forward_convert() {
let mut rec = AoRecord::new(0.0);
rec.linr = 2; rec.eslo = 1.0;
rec.eoff = 0.0;
rec.drvl = 0.0;
rec.drvh = 100.0; assert!(rec.apply_raw_readback(150)); assert_eq!(rec.rval, 150);
assert!((rec.val - 150.0).abs() < 1e-9);
rec.set_device_did_compute(true);
let _ = rec.process().unwrap();
assert!(
(rec.val - 150.0).abs() < 1e-9,
"readback VAL must not be drive-clamped by a skipped forward convert"
);
assert_eq!(rec.rval, 150, "readback RVAL preserved");
let _ = rec.process().unwrap();
assert!(
(rec.val - 100.0).abs() < 1e-9,
"forward convert now clamps VAL to DRVH"
);
}
#[test]
fn test_ao_float64_readback_applies_aslo_aoff_val_only() {
let mut rec = AoRecord::new(0.0);
rec.aslo = 2.0;
rec.aoff = 1.0;
rec.rval = 999; assert!(rec.apply_float64_readback(10.0));
match rec.get_field("VAL") {
Some(EpicsValue::Double(v)) => assert!(
(v - 21.0).abs() < 1e-9,
"VAL = raw*ASLO + AOFF = 10*2 + 1 = 21, got {v}"
),
other => panic!("expected Double(21.0), got {other:?}"),
}
assert_eq!(rec.rval, 999, "float64 ao readback leaves RVAL untouched");
}
#[test]
fn test_mbbo_declines_float64_readback() {
use epics_base_rs::server::records::mbbo::MbboRecord;
let mut rec = MbboRecord::new(0);
assert!(
!rec.apply_float64_readback(3.0),
"mbbo declines the float64 readback hook (keeps the int32 raw path)"
);
}
#[test]
fn test_ai_does_not_claim_raw_readback() {
let mut rec = AiRecord::new(0.0);
assert!(
!rec.apply_raw_readback(5),
"ai keeps the legacy raw->RVAL + framework-convert path"
);
}
#[test]
fn test_mbbo_readback_maps_raw_to_state_index() {
use epics_base_rs::server::records::mbbo::MbboRecord;
let mut rec = MbboRecord::new(0);
rec.put_field("ONVL", EpicsValue::ULong(1)).unwrap();
rec.put_field("TWVL", EpicsValue::ULong(2)).unwrap();
rec.init_record(0).unwrap(); rec.mask = 0x0C; rec.shft = 2;
assert!(
rec.apply_raw_readback(0x08),
"mbbo owns the readback state map"
);
assert_eq!(rec.rval, 0x08, "RVAL keeps the masked (unshifted) raw");
assert_eq!(rec.val, 2, "shifted raw 2 → state index 2 (TWVL)");
}
#[test]
fn test_mbbo_readback_unknown_state_is_65535() {
use epics_base_rs::server::records::mbbo::MbboRecord;
let mut rec = MbboRecord::new(0);
rec.put_field("ONVL", EpicsValue::ULong(1)).unwrap();
rec.init_record(0).unwrap();
rec.mask = 0x0C;
rec.shft = 2;
assert!(rec.apply_raw_readback(0x0C));
assert_eq!(rec.rval, 0x0C);
assert_eq!(rec.val, 65535, "no matching state → unknown sentinel");
}
#[test]
fn test_bo_readback_maps_raw_to_binary_both_mask_modes() {
use epics_base_rs::server::records::bo::BoRecord;
let mut rec = BoRecord::new(0);
rec.mask = 0x04;
assert!(rec.apply_raw_readback(0x04));
assert_eq!(rec.rval, 0x04);
assert_eq!(rec.val, 1);
assert!(rec.apply_raw_readback(0x02));
assert_eq!(rec.rval, 0);
assert_eq!(rec.val, 0);
let mut rec2 = BoRecord::new(0);
rec2.mask = 0;
assert!(rec2.apply_raw_readback(0x02));
assert_eq!(
rec2.rval, 0x02,
"asynInt32 bo keeps the unmasked raw in RVAL"
);
assert_eq!(rec2.val, 1);
}
#[test]
fn test_bo_readback_skip_convert_preserves_rval() {
use epics_base_rs::server::records::bo::BoRecord;
let mut rec = BoRecord::new(0);
rec.mask = 0; assert!(rec.apply_raw_readback(0x2A)); assert_eq!(rec.rval, 0x2A);
assert_eq!(rec.val, 1);
rec.set_device_did_compute(true);
let _ = rec.process().unwrap();
assert_eq!(rec.rval, 0x2A, "skip_convert preserves the readback RVAL");
let _ = rec.process().unwrap();
assert_eq!(
rec.rval, 1,
"forward convert recomputes rval from val (mask=0)"
);
}
#[test]
fn test_mbbo_direct_readback_maps_raw_and_skip_convert_preserves_low_bits() {
use epics_base_rs::server::records::mbbo_direct::MbboDirectRecord;
let mut rec = MbboDirectRecord::default();
rec.mask = 0x0F;
rec.shft = 2;
assert!(rec.apply_raw_readback(0x0F));
assert_eq!(rec.rval, 0x0F, "RVAL keeps the masked (unshifted) raw");
assert_eq!(rec.val, 3, "VAL = masked raw >> SHFT");
assert_eq!(rec.bits[0], 1);
assert_eq!(rec.bits[1], 1);
rec.set_device_did_compute(true);
let _ = rec.process().unwrap();
assert_eq!(
rec.rval, 0x0F,
"skip_convert preserves the readback RVAL with its sub-SHFT bits"
);
let _ = rec.process().unwrap();
assert_eq!(
rec.rval, 0x0C,
"forward convert loses the sub-SHFT bits the readback preserved"
);
}
#[test]
fn test_bi_readback_maps_raw_to_binary_both_mask_modes() {
let mut rec = BiRecord::new(0);
rec.mask = 0;
assert!(rec.apply_raw_readback(5), "bi claims the device readback");
assert_eq!(rec.rval, 5, "asynInt32 bi keeps the unmasked raw in RVAL");
assert_eq!(rec.val, 1, "VAL is 0/1, not the raw count");
assert!(rec.apply_raw_readback(0));
assert_eq!(rec.val, 0);
let mut rec2 = BiRecord::new(0);
rec2.mask = 0x80;
assert!(rec2.apply_raw_readback(0x80));
assert_eq!(rec2.rval, 0x80);
assert_eq!(
rec2.val, 1,
"high-bit mask hit → val 1 (C gives 1, not 128)"
);
assert!(rec2.apply_raw_readback(0x01));
assert_eq!(rec2.rval, 0);
assert_eq!(rec2.val, 0, "bits outside MASK do not set VAL");
}
#[test]
fn test_mbbi_direct_readback_maps_raw_mask_shift_bits() {
use epics_base_rs::server::records::mbbi_direct::MbbiDirectRecord;
let mut rec = MbbiDirectRecord::default();
rec.mask = 0x3C; rec.shft = 2;
assert!(
rec.apply_raw_readback(0x3C),
"mbbiDirect claims the readback"
);
assert_eq!(rec.rval, 0x3C, "RVAL keeps the masked (unshifted) raw");
assert_eq!(rec.val, 0x0F, "VAL = masked raw >> SHFT");
assert_eq!(rec.bits[0], 1);
assert_eq!(rec.bits[3], 1);
assert_eq!(rec.bits[4], 0);
rec.set_device_did_compute(true);
let _ = rec.process().unwrap();
assert_eq!(rec.rval, 0x3C, "skip_convert preserves the readback RVAL");
assert_eq!(rec.val, 0x0F);
assert!(rec.apply_raw_readback(0xC0));
assert_eq!(rec.rval, 0);
assert_eq!(rec.val, 0, "out-of-mask bits do not reach VAL");
}
#[test]
fn test_longin_declines_raw_readback() {
use epics_base_rs::server::records::longin::LonginRecord;
assert!(
!LonginRecord::new(0).apply_raw_readback(3),
"longin VAL is the raw — no convert to claim"
);
}
#[test]
fn test_mbbi_readback_masks_shifts_and_maps_state() {
use epics_base_rs::server::records::mbbi::MbbiRecord;
let mut rec = MbbiRecord::new(0);
rec.put_field("ONVL", EpicsValue::ULong(1)).unwrap();
rec.put_field("TWVL", EpicsValue::ULong(2)).unwrap();
rec.init_record(0).unwrap(); rec.mask = 0x0C; rec.shft = 2;
assert!(
rec.apply_raw_readback(0x08),
"mbbi claims the device readback"
);
assert_eq!(rec.rval, 0x08, "RVAL = raw & mask (C processMbbi)");
assert_eq!(rec.val, 2, "shifted raw 2 -> state index 2 (TWVL)");
assert!(rec.apply_raw_readback(0x88));
assert_eq!(rec.rval, 0x08, "out-of-mask 0x80 bit masked away");
assert_eq!(rec.val, 2, "still resolves to TWVL, not an unknown state");
}
#[test]
fn test_mbbi_set_val_is_soft_channel_passthrough() {
use epics_base_rs::server::records::mbbi::MbbiRecord;
let mut rec = MbbiRecord::new(0);
rec.put_field("ONVL", EpicsValue::ULong(1)).unwrap();
rec.put_field("TWVL", EpicsValue::ULong(2)).unwrap();
rec.init_record(0).unwrap();
rec.set_val(EpicsValue::Long(2)).unwrap();
assert_eq!(rec.val, 2, "soft-link value is the index, not state-mapped");
rec.set_val(EpicsValue::Long(7)).unwrap();
assert_eq!(
rec.val, 7,
"pass-through, not the 65535 unknown-state sentinel"
);
rec.set_val(EpicsValue::Enum(1)).unwrap();
assert_eq!(rec.val, 1);
}
#[test]
fn test_bi_record() {
let mut rec = BiRecord::new(0);
assert_eq!(rec.record_type(), "bi");
rec.put_field("VAL", EpicsValue::Enum(1)).unwrap();
match rec.get_field("VAL") {
Some(EpicsValue::Enum(v)) => assert_eq!(v, 1),
other => panic!("expected Enum(1), got {:?}", other),
}
rec.put_field("ZNAM", EpicsValue::String("Off".into()))
.unwrap();
rec.put_field("ONAM", EpicsValue::String("On".into()))
.unwrap();
match rec.get_field("ZNAM") {
Some(EpicsValue::String(s)) => assert_eq!(s, "Off"),
other => panic!("expected String, got {:?}", other),
}
}
#[test]
fn test_bi_raw_soft_channel_applies_mask() {
let mut rec = BiRecord::new(0);
rec.mask = 0x0F;
rec.raw_soft_input(RawSoftEntry::Read, EpicsValue::Long(0xFF))
.expect("bi has a SoftRaw dset")
.unwrap();
assert_eq!(rec.rval, 0x0F, "mask must clamp RVAL to low nibble");
let _ = rec.process().unwrap();
match rec.get_field("VAL") {
Some(EpicsValue::Enum(v)) => assert_eq!(v, 1, "masked-non-zero RVAL → VAL=1"),
other => panic!("expected Enum, got {:?}", other),
}
}
#[test]
fn test_bi_raw_soft_channel_mask_zero_passthrough() {
let mut rec = BiRecord::new(0);
rec.mask = 0;
rec.raw_soft_input(RawSoftEntry::Read, EpicsValue::Long(0xDEAD_BEEFu32 as i32))
.expect("bi has a SoftRaw dset")
.unwrap();
assert_eq!(rec.rval, 0xDEAD_BEEF_u32);
}
#[test]
fn test_bo_mask_rval_high_bit_round_trip() {
use epics_base_rs::server::records::bo::BoRecord;
let mut rec = BoRecord::new(0);
rec.put_field("MASK", EpicsValue::ULong(0x8000_0000))
.unwrap();
assert_eq!(rec.get_field("MASK"), Some(EpicsValue::ULong(0x8000_0000)));
rec.put_field("RVAL", EpicsValue::ULong(0xDEAD_BEEF))
.unwrap();
assert_eq!(rec.get_field("RVAL"), Some(EpicsValue::ULong(0xDEAD_BEEF)));
}
#[test]
fn test_mbbo_zrvl_high_bit_round_trip() {
use epics_base_rs::server::records::mbbo::MbboRecord;
let mut rec = MbboRecord::new(0);
rec.put_field("ZRVL", EpicsValue::ULong(0x8000_0000))
.unwrap();
assert_eq!(rec.get_field("ZRVL"), Some(EpicsValue::ULong(0x8000_0000)));
rec.init_record(0).unwrap();
rec.seed_deadband_tracking();
assert_eq!(rec.get_field("RVAL"), Some(EpicsValue::ULong(0x8000_0000)));
}
#[test]
fn test_bi_raw_soft_channel_mask_to_zero() {
let mut rec = BiRecord::new(1);
rec.mask = 0x01;
rec.raw_soft_input(RawSoftEntry::Read, EpicsValue::Long(0xFE))
.expect("bi has a SoftRaw dset")
.unwrap();
assert_eq!(rec.rval, 0);
let _ = rec.process().unwrap();
match rec.get_field("VAL") {
Some(EpicsValue::Enum(v)) => assert_eq!(v, 0),
other => panic!("expected Enum, got {:?}", other),
}
}
#[test]
fn test_stringin_record() {
let rec = StringinRecord::new("hello");
assert_eq!(rec.record_type(), "stringin");
match rec.get_field("VAL") {
Some(EpicsValue::String(s)) => assert_eq!(s, "hello"),
other => panic!("expected String, got {:?}", other),
}
}
#[test]
fn test_val_and_set_val() {
let mut rec = AiRecord::new(5.0);
match rec.val() {
Some(EpicsValue::Double(v)) => assert!((v - 5.0).abs() < 1e-10),
other => panic!("expected Double(5.0), got {:?}", other),
}
rec.set_val(EpicsValue::Double(10.0)).unwrap();
match rec.val() {
Some(EpicsValue::Double(v)) => assert!((v - 10.0).abs() < 1e-10),
other => panic!("expected Double(10.0), got {:?}", other),
}
}
#[test]
fn test_record_instance() {
let rec = AiRecord::new(25.0);
let instance = RecordInstance::new("TEMP".into(), rec);
assert_eq!(instance.name, "TEMP");
match instance.record.get_field("VAL") {
Some(EpicsValue::Double(v)) => assert!((v - 25.0).abs() < 1e-10),
other => panic!("expected Double(25.0), got {:?}", other),
}
}
#[test]
fn test_read_only_field() {
use epics_macros_rs::EpicsRecord;
#[derive(EpicsRecord)]
#[record(type = "test", crate_path = "epics_base_rs")]
struct TestRecord {
#[field(type = "Double")]
pub val: f64,
#[field(type = "String", read_only)]
pub name: String,
}
let mut rec = TestRecord {
val: 1.0,
name: "fixed".into(),
};
match rec.get_field("NAME") {
Some(EpicsValue::String(s)) => assert_eq!(s, "fixed"),
other => panic!("expected String, got {:?}", other),
}
let result = rec.put_field("NAME", EpicsValue::String("changed".into()));
assert!(result.is_err());
rec.put_field("VAL", EpicsValue::Double(2.0)).unwrap();
match rec.get_field("VAL") {
Some(EpicsValue::Double(v)) => assert!((v - 2.0).abs() < 1e-10),
other => panic!("expected Double(2.0), got {:?}", other),
}
}
#[test]
fn test_parse_pv_name() {
use epics_base_rs::server::database::parse_pv_name;
assert_eq!(parse_pv_name("TEMP"), ("TEMP", "VAL"));
assert_eq!(parse_pv_name("TEMP.EGU"), ("TEMP", "EGU"));
assert_eq!(parse_pv_name("TEMP.HOPR"), ("TEMP", "HOPR"));
assert_eq!(parse_pv_name("A.B.C"), ("A.B", "C"));
}
#[test]
fn test_resolve_field_priority() {
let rec = AiRecord::new(25.0);
let instance = RecordInstance::new("TEMP".into(), rec);
assert!(matches!(
instance.resolve_field("VAL"),
Some(EpicsValue::Double(_))
));
assert!(matches!(
instance.resolve_field("SEVR"),
Some(EpicsValue::Short(0))
));
assert!(matches!(
instance.resolve_field("SCAN"),
Some(EpicsValue::Enum(0))
));
match instance.resolve_field("NAME") {
Some(EpicsValue::String(s)) => assert_eq!(s, "TEMP"),
other => panic!("expected String(TEMP), got {:?}", other),
}
match instance.resolve_field("RTYP") {
Some(EpicsValue::String(s)) => assert_eq!(s, "ai"),
other => panic!("expected String(ai), got {:?}", other),
}
assert!(instance.resolve_field("HIHI").is_some());
assert!(instance.resolve_field("NONEXISTENT").is_none());
}
#[test]
fn test_common_field_put() {
let rec = AiRecord::new(25.0);
let mut instance = RecordInstance::new("TEMP".into(), rec);
let result = instance
.put_common_field("SCAN", EpicsValue::String("1 second".into()))
.unwrap();
assert!(matches!(result, CommonFieldPutResult::ScanChanged { .. }));
assert_eq!(instance.common.scan, ScanType::Sec1);
instance
.put_common_field("HIHI", EpicsValue::Double(100.0))
.unwrap();
assert_eq!(
instance.common.analog_alarm.as_ref().unwrap().hihi,
AlarmLimit::Double(100.0)
);
}
#[test]
fn test_evaluate_alarms() {
use epics_base_rs::server::recgbl;
let rec = AiRecord::new(0.0);
let mut instance = RecordInstance::new("TEMP".into(), rec);
instance.common.udf = 0;
instance
.put_common_field("HIHI", EpicsValue::Double(100.0))
.unwrap();
instance
.put_common_field("HHSV", EpicsValue::Short(AlarmSeverity::Major as i16))
.unwrap();
instance
.put_common_field("HIGH", EpicsValue::Double(80.0))
.unwrap();
instance
.put_common_field("HSV", EpicsValue::Short(AlarmSeverity::Minor as i16))
.unwrap();
instance.evaluate_alarms();
recgbl::rec_gbl_reset_alarms(&mut instance.common);
assert_eq!(instance.common.sevr, AlarmSeverity::NoAlarm);
instance.record.set_val(EpicsValue::Double(85.0)).unwrap();
instance.evaluate_alarms();
recgbl::rec_gbl_reset_alarms(&mut instance.common);
assert_eq!(instance.common.sevr, AlarmSeverity::Minor);
instance.record.set_val(EpicsValue::Double(105.0)).unwrap();
instance.evaluate_alarms();
recgbl::rec_gbl_reset_alarms(&mut instance.common);
assert_eq!(instance.common.sevr, AlarmSeverity::Major);
}
#[test]
fn test_parse_link() {
assert!(parse_link("").is_none());
let link = parse_link("TEMP").unwrap();
assert_eq!(link.record, "TEMP");
assert_eq!(link.field, "VAL");
let link = parse_link("TEMP.EGU").unwrap();
assert_eq!(link.record, "TEMP");
assert_eq!(link.field, "EGU");
let link = parse_link("TEMP.VAL PP").unwrap();
assert_eq!(link.record, "TEMP");
assert_eq!(link.field, "VAL");
assert_eq!(link.policy, LinkProcessPolicy::ProcessPassive);
let link = parse_link("TEMP.VAL NPP").unwrap();
assert_eq!(link.policy, LinkProcessPolicy::NoProcess);
}
#[test]
fn test_parse_link_v2() {
assert_eq!(parse_link_v2(""), ParsedLink::None);
assert_eq!(parse_link_v2(" "), ParsedLink::None);
assert_eq!(parse_link_v2("42"), ParsedLink::Constant("42".to_string()));
assert_eq!(
parse_link_v2("3.14"),
ParsedLink::Constant("3.14".to_string())
);
assert_eq!(
parse_link_v2("-1.5"),
ParsedLink::Constant("-1.5".to_string())
);
assert_eq!(
parse_link_v2("TEMP"),
ParsedLink::Db(DbLink {
record: "TEMP".into(),
field: "VAL".into(),
policy: LinkProcessPolicy::NoProcess,
monitor_switch: MonitorSwitch::NoMaximize,
})
);
assert_eq!(
parse_link_v2("TEMP.EGU"),
ParsedLink::Db(DbLink {
record: "TEMP".into(),
field: "EGU".into(),
policy: LinkProcessPolicy::NoProcess,
monitor_switch: MonitorSwitch::NoMaximize,
})
);
assert_eq!(
parse_link_v2("TEMP.EGU NPP"),
ParsedLink::Db(DbLink {
record: "TEMP".into(),
field: "EGU".into(),
policy: LinkProcessPolicy::NoProcess,
monitor_switch: MonitorSwitch::NoMaximize,
})
);
assert_eq!(
parse_link_v2("ca://PV:NAME"),
ParsedLink::Ca(CaLink::new("PV:NAME"))
);
assert_eq!(
parse_link_v2("pva://PV:NAME"),
ParsedLink::Pva("PV:NAME".to_string())
);
assert!(matches!(parse_link_v2("\"hello\""), ParsedLink::Db(_)));
let c = parse_link_v2("3.15");
assert_eq!(c.constant_value(), Some(EpicsValue::Double(3.15)));
assert_eq!(parse_link_v2("\"hello\"").constant_value(), None);
assert_eq!(parse_link_v2("TEMP").constant_value(), None);
}
#[test]
fn test_link_cache_invalidation() {
let rec = AiRecord::new(0.0);
let mut instance = RecordInstance::new("TEMP".into(), rec);
assert_eq!(instance.parsed_inp, ParsedLink::None);
instance
.put_common_field("INP", EpicsValue::String("SOURCE.VAL".into()))
.unwrap();
if let ParsedLink::Db(ref db) = instance.parsed_inp {
assert_eq!(db.record, "SOURCE");
} else {
panic!("expected Db link");
}
instance
.put_common_field("INP", EpicsValue::String("OTHER".into()))
.unwrap();
if let ParsedLink::Db(ref db) = instance.parsed_inp {
assert_eq!(db.record, "OTHER");
assert_eq!(db.field, "VAL");
} else {
panic!("expected Db link");
}
instance
.put_common_field("INP", EpicsValue::String("".into()))
.unwrap();
assert_eq!(instance.parsed_inp, ParsedLink::None);
}
#[test]
fn test_ai_linear_conversion() {
let mut rec = AiRecord::default();
rec.linr = 1;
rec.eguf = 100.0;
rec.egul = 0.0;
rec.eslo = 1.0;
rec.roff = 0;
rec.aslo = 1.0;
rec.aoff = 0.0;
rec.rval = 50;
rec.process().unwrap();
assert!((rec.val - 50.0).abs() < 1e-10);
}
#[test]
fn test_ai_linear_with_offsets() {
let mut rec = AiRecord::default();
rec.linr = 2;
rec.eoff = 10.0;
rec.eslo = 0.5;
rec.roff = 100;
rec.aslo = 2.0;
rec.aoff = 5.0;
rec.rval = 200;
rec.process().unwrap();
assert!((rec.val - 312.5).abs() < 1e-10);
}
#[test]
fn test_ai_smoothing() {
let mut rec = AiRecord::default();
rec.linr = 1;
rec.eslo = 1.0;
rec.aslo = 1.0;
rec.smoo = 0.5;
rec.init_record(0).unwrap();
rec.rval = 100;
rec.process().unwrap();
assert!((rec.val - 100.0).abs() < 1e-10);
assert!(
!rec.init.is_initial(),
"C clears INIT at the end of every process (aiRecord.c:170)"
);
rec.rval = 200;
rec.process().unwrap();
assert!((rec.val - 150.0).abs() < 1e-10);
}
#[test]
fn test_ai_no_conversion() {
let mut rec = AiRecord::default();
rec.linr = 0;
rec.rval = 42;
rec.process().unwrap();
assert!((rec.val - 42.0).abs() < 1e-10);
}
#[test]
fn test_common_fields_desc() {
let rec = AiRecord::new(25.0);
let mut instance = RecordInstance::new("TEMP".into(), rec);
instance
.put_common_field("DESC", EpicsValue::String("Temperature".into()))
.unwrap();
match instance.get_common_field("DESC") {
Some(EpicsValue::String(s)) => assert_eq!(s, "Temperature"),
other => panic!("expected String, got {:?}", other),
}
match instance.resolve_field("DESC") {
Some(EpicsValue::String(s)) => assert_eq!(s, "Temperature"),
other => panic!("expected String, got {:?}", other),
}
}
#[test]
fn test_common_fields_new() {
let rec = AiRecord::new(0.0);
let mut instance = RecordInstance::new("TEST".into(), rec);
assert_eq!(instance.common.phas, 0);
instance
.put_common_field("PHAS", EpicsValue::Short(2))
.unwrap();
assert_eq!(instance.common.phas, 2);
assert_eq!(instance.common.disv, 1);
instance
.put_common_field("HYST", EpicsValue::Double(5.0))
.unwrap();
assert!((instance.common.hyst - 5.0).abs() < 1e-10);
}
#[test]
fn test_hyst_alarm_hysteresis() {
use epics_base_rs::server::recgbl;
let rec = AiRecord::new(0.0);
let mut instance = RecordInstance::new("TEMP".into(), rec);
instance.common.udf = 0;
instance
.put_common_field("HIGH", EpicsValue::Double(80.0))
.unwrap();
instance
.put_common_field("HSV", EpicsValue::Short(AlarmSeverity::Minor as i16))
.unwrap();
instance
.put_common_field("HYST", EpicsValue::Double(5.0))
.unwrap();
instance.record.set_val(EpicsValue::Double(85.0)).unwrap();
instance.evaluate_alarms();
recgbl::rec_gbl_reset_alarms(&mut instance.common);
assert_eq!(instance.common.sevr, AlarmSeverity::Minor);
instance.record.set_val(EpicsValue::Double(82.0)).unwrap();
instance.evaluate_alarms();
recgbl::rec_gbl_reset_alarms(&mut instance.common);
assert_eq!(instance.common.sevr, AlarmSeverity::Minor);
instance.record.set_val(EpicsValue::Double(78.0)).unwrap();
instance.evaluate_alarms();
recgbl::rec_gbl_reset_alarms(&mut instance.common);
assert_eq!(instance.common.sevr, AlarmSeverity::Minor);
instance.record.set_val(EpicsValue::Double(76.0)).unwrap();
instance.evaluate_alarms();
recgbl::rec_gbl_reset_alarms(&mut instance.common);
assert_eq!(instance.common.sevr, AlarmSeverity::Minor);
instance.record.set_val(EpicsValue::Double(74.0)).unwrap();
instance.evaluate_alarms();
recgbl::rec_gbl_reset_alarms(&mut instance.common);
assert_eq!(instance.common.sevr, AlarmSeverity::NoAlarm);
}
#[test]
fn test_deadband_mdel() {
use epics_base_rs::server::recgbl::EventMask;
let mut rec = AiRecord::default();
rec.mdel = 5.0;
rec.adel = 0.0;
let mut instance = RecordInstance::new("TEST".into(), rec);
let val_mask = |snap: &epics_base_rs::server::record::ProcessSnapshot| {
snap.changed_fields
.iter()
.find(|(k, _, _)| k == "VAL")
.map(|(_, _, m)| *m)
.unwrap_or(EventMask::NONE)
};
instance.record.set_val(EpicsValue::Double(0.0)).unwrap();
instance.record.set_device_did_compute(true);
let (snap, _alarm_posts) = instance.process_local().unwrap();
assert_eq!(val_mask(&snap), EventMask::ALARM);
instance.record.set_val(EpicsValue::Double(3.0)).unwrap();
instance.record.set_device_did_compute(true);
let (snap, _alarm_posts) = instance.process_local().unwrap();
assert_eq!(val_mask(&snap), EventMask::LOG);
instance.record.set_val(EpicsValue::Double(6.0)).unwrap();
instance.record.set_device_did_compute(true);
let (snap, _alarm_posts) = instance.process_local().unwrap();
assert_eq!(val_mask(&snap), EventMask::VALUE | EventMask::LOG);
instance.record.set_val(EpicsValue::Double(10.0)).unwrap();
instance.record.set_device_did_compute(true);
let (snap, _alarm_posts) = instance.process_local().unwrap();
assert_eq!(val_mask(&snap), EventMask::LOG);
instance.record.set_val(EpicsValue::Double(12.0)).unwrap();
instance.record.set_device_did_compute(true);
let (snap, _alarm_posts) = instance.process_local().unwrap();
assert_eq!(val_mask(&snap), EventMask::VALUE | EventMask::LOG);
}
#[test]
fn test_deadband_mdel_zero() {
use epics_base_rs::server::recgbl::EventMask;
let mut rec = AiRecord::default();
rec.mdel = 0.0;
let mut instance = RecordInstance::new("TEST".into(), rec);
instance.record.set_val(EpicsValue::Double(0.0)).unwrap();
instance.record.set_device_did_compute(true);
let (snap, _alarm_posts) = instance.process_local().unwrap();
assert_eq!(
snap.changed_fields
.iter()
.find(|(k, _, _)| k == "VAL")
.map(|(_, _, m)| *m),
Some(EventMask::ALARM),
"no deadband fired — only the initial alarm transition"
);
instance.record.set_val(EpicsValue::Double(0.001)).unwrap();
instance.record.set_device_did_compute(true);
let (snap, _alarm_posts) = instance.process_local().unwrap();
assert!(snap.changed_fields.iter().any(|(k, _, _)| k == "VAL"));
}
#[test]
fn test_deadband_mdel_negative() {
let mut rec = AiRecord::default();
rec.mdel = -1.0;
let mut instance = RecordInstance::new("TEST".into(), rec);
instance.record.set_val(EpicsValue::Double(0.0)).unwrap();
instance.record.set_device_did_compute(true);
let (snap, _alarm_posts) = instance.process_local().unwrap();
assert!(snap.changed_fields.iter().any(|(k, _, _)| k == "VAL"));
}
#[test]
fn test_bi_state_alarm() {
use epics_base_rs::server::recgbl;
let mut rec = BiRecord::new(0);
rec.zsv = AlarmSeverity::Major as i16;
rec.osv = AlarmSeverity::Minor as i16;
let mut instance = RecordInstance::new("SW".into(), rec);
instance.common.udf = 0;
instance.record.check_alarms(&mut instance.common);
instance.evaluate_alarms();
recgbl::rec_gbl_reset_alarms(&mut instance.common);
assert_eq!(instance.common.sevr, AlarmSeverity::Major);
instance.record.set_val(EpicsValue::Enum(1)).unwrap();
instance.record.check_alarms(&mut instance.common);
instance.evaluate_alarms();
recgbl::rec_gbl_reset_alarms(&mut instance.common);
assert_eq!(instance.common.sevr, AlarmSeverity::Minor);
}
#[test]
fn test_mbbi_state_alarm() {
use epics_base_rs::server::recgbl;
use epics_base_rs::server::records::mbbi::MbbiRecord;
let mut rec = MbbiRecord::new(0);
rec.onsv = AlarmSeverity::Minor as i16;
rec.twsv = AlarmSeverity::Major as i16;
let mut instance = RecordInstance::new("SEL".into(), rec);
instance.common.udf = 0;
instance.record.check_alarms(&mut instance.common);
instance.evaluate_alarms();
recgbl::rec_gbl_reset_alarms(&mut instance.common);
assert_eq!(instance.common.sevr, AlarmSeverity::NoAlarm);
instance.record.set_val(EpicsValue::Enum(1)).unwrap();
instance.record.check_alarms(&mut instance.common);
instance.evaluate_alarms();
recgbl::rec_gbl_reset_alarms(&mut instance.common);
assert_eq!(instance.common.sevr, AlarmSeverity::Minor);
instance.record.set_val(EpicsValue::Enum(2)).unwrap();
instance.record.check_alarms(&mut instance.common);
instance.evaluate_alarms();
recgbl::rec_gbl_reset_alarms(&mut instance.common);
assert_eq!(instance.common.sevr, AlarmSeverity::Major);
}
#[test]
fn test_mbbi_unsv() {
use epics_base_rs::server::records::mbbi::MbbiRecord;
let mut rec = MbbiRecord::new(0);
rec.unsv = AlarmSeverity::Invalid as i16;
let mut instance = RecordInstance::new("SEL".into(), rec);
instance.record.set_val(EpicsValue::Enum(15)).unwrap();
instance.evaluate_alarms();
assert_eq!(instance.common.sevr, AlarmSeverity::NoAlarm);
}
#[test]
fn test_deadband_alarm_on_change_bypasses_value_deadband() {
use epics_base_rs::server::recgbl::EventMask;
let mut rec = AiRecord::default();
rec.mdel = 100.0; let mut instance = RecordInstance::new("TEST".into(), rec);
instance.common.analog_alarm = Some(AnalogAlarmConfig {
hihi: AlarmLimit::Double(1000.0),
high: AlarmLimit::Double(0.5),
low: AlarmLimit::Double(-1000.0),
lolo: AlarmLimit::Double(-2000.0),
hhsv: AlarmSeverity::Major as i16,
hsv: AlarmSeverity::Major as i16,
lsv: AlarmSeverity::Minor as i16,
llsv: AlarmSeverity::Major as i16,
});
instance.record.set_val(EpicsValue::Double(1.0)).unwrap();
instance.record.set_device_did_compute(true);
let (snap, alarm_posts) = instance.process_local().unwrap();
let val_mask = snap
.changed_fields
.iter()
.find(|(k, _, _)| k == "VAL")
.map(|(_, _, m)| *m);
assert_eq!(
val_mask,
Some(EventMask::LOG | EventMask::ALARM),
"alarm transition under a silent MDEL must post VAL with \
DBE_LOG|DBE_ALARM, without DBE_VALUE"
);
assert!(!snap.changed_fields.iter().any(|(k, _, _)| k == "SEVR"));
assert!(!snap.changed_fields.iter().any(|(k, _, _)| k == "STAT"));
let sevr_mask = alarm_posts
.iter()
.find(|(f, _)| *f == "SEVR")
.map(|(_, m)| *m);
assert_eq!(
sevr_mask,
Some(EventMask::VALUE),
"SEVR must post with DBE_VALUE only"
);
let stat_mask = alarm_posts
.iter()
.find(|(f, _)| *f == "STAT")
.map(|(_, m)| *m);
assert_eq!(
stat_mask,
Some(EventMask::ALARM | EventMask::VALUE),
"STAT must post with DBE_ALARM | DBE_VALUE on a sevr+stat change"
);
let amsg_mask = alarm_posts
.iter()
.find(|(f, _)| *f == "AMSG")
.map(|(_, m)| *m);
assert_eq!(
amsg_mask, stat_mask,
"AMSG must be posted with the same mask as STAT"
);
}
#[test]
fn test_per_field_masks_narrow_deadband_and_aux_posts() {
use epics_base_rs::server::recgbl::EventMask;
use epics_base_rs::types::DbFieldType;
let mut rec = AiRecord::default();
rec.mdel = 100.0; rec.adel = 0.5; let mut instance = RecordInstance::new("TEST".into(), rec);
let _rval_rx = instance
.add_subscriber("RVAL", 1, DbFieldType::Long, EventMask::VALUE.bits())
.expect("RVAL subscriber");
instance.record.set_device_did_compute(true);
let _ = instance.process_local().unwrap();
instance.record.set_val(EpicsValue::Double(1.0)).unwrap();
instance
.record
.put_field("RVAL", EpicsValue::Long(42))
.unwrap();
instance.record.set_device_did_compute(true);
let (snap, _) = instance.process_local().unwrap();
let mask_of = |f: &str| {
snap.changed_fields
.iter()
.find(|(k, _, _)| k == f)
.map(|(_, _, m)| *m)
};
assert_eq!(
mask_of("VAL"),
Some(EventMask::LOG),
"ADEL-only crossing posts VAL with DBE_LOG alone; the changed \
RVAL must not leak DBE_VALUE into the deadband post"
);
assert_eq!(
mask_of("RVAL"),
Some(EventMask::LOG),
"ai RVAL posts with VAL's raw monitor_mask (DBE_LOG here, MDEL \
uncrossed), not a forced DBE_VALUE|DBE_LOG (aiRecord.c:463)"
);
}
#[test]
fn test_acks_posts_once_with_dbe_value_only() {
use epics_base_rs::server::recgbl::EventMask;
use epics_base_rs::types::DbFieldType;
let mut rec = AiRecord::default();
rec.mdel = 100.0;
let mut instance = RecordInstance::new("TEST".into(), rec);
let _acks_rx = instance
.add_subscriber("ACKS", 1, DbFieldType::Enum, EventMask::VALUE.bits())
.expect("ACKS subscriber");
instance.common.analog_alarm = Some(AnalogAlarmConfig {
hihi: AlarmLimit::Double(1000.0),
high: AlarmLimit::Double(0.5),
low: AlarmLimit::Double(-1000.0),
lolo: AlarmLimit::Double(-2000.0),
hhsv: AlarmSeverity::Major as i16,
hsv: AlarmSeverity::Major as i16,
lsv: AlarmSeverity::Minor as i16,
llsv: AlarmSeverity::Major as i16,
});
instance.record.set_val(EpicsValue::Double(1.0)).unwrap();
instance.record.set_device_did_compute(true);
let (snap, alarm_posts) = instance.process_local().unwrap();
assert_eq!(
instance.common.acks,
AlarmSeverity::Major,
"the ack rule fired and raised ACKS"
);
let acks_posts: Vec<_> = alarm_posts.iter().filter(|(f, _)| *f == "ACKS").collect();
assert_eq!(acks_posts.len(), 1, "exactly one ACKS post");
assert_eq!(
*acks_posts[0],
("ACKS", EventMask::VALUE),
"C posts ACKS with a literal DBE_VALUE (recGbl.c:216)"
);
assert!(
!snap.changed_fields.iter().any(|(k, _, _)| k == "ACKS"),
"ACKS must not also ride the generic snapshot — C posts it once"
);
}
#[test]
fn test_no_alarm_change_does_not_post_sevr_stat() {
let mut rec = AiRecord::default();
rec.mdel = 100.0;
let mut instance = RecordInstance::new("TEST".into(), rec);
instance.record.set_val(EpicsValue::Double(1.0)).unwrap();
instance.record.set_device_did_compute(true);
let (_first, first_posts) = instance.process_local().unwrap();
assert!(
first_posts.iter().any(|(f, _)| *f == "STAT"),
"STAT moved UDF -> NO_ALARM, and C posts the field that changed \
(recGbl.c:206-207). SEVR did not move on this bare instance, so C \
posts no SEVR (recGbl.c:202-205)."
);
instance.record.set_val(EpicsValue::Double(1.0)).unwrap();
instance.record.set_device_did_compute(true);
let (snap, alarm_posts) = instance.process_local().unwrap();
assert!(!snap.changed_fields.iter().any(|(k, _, _)| k == "SEVR"));
assert!(!snap.changed_fields.iter().any(|(k, _, _)| k == "STAT"));
assert!(!alarm_posts.iter().any(|(f, _)| *f == "SEVR"));
assert!(!alarm_posts.iter().any(|(f, _)| *f == "STAT"));
}
#[test]
fn test_alarm_cycle_does_not_fan_out_for_default_records() {
use epics_base_rs::server::recgbl::EventMask;
use epics_base_rs::types::DbFieldType;
let mut rec = AiRecord::default();
rec.mdel = 100.0;
let mut instance = RecordInstance::new("TEST".into(), rec);
instance.common.analog_alarm = Some(AnalogAlarmConfig {
hihi: AlarmLimit::Double(1000.0),
high: AlarmLimit::Double(0.5),
low: AlarmLimit::Double(-1000.0),
lolo: AlarmLimit::Double(-2000.0),
hhsv: AlarmSeverity::Major as i16,
hsv: AlarmSeverity::Major as i16,
lsv: AlarmSeverity::Minor as i16,
llsv: AlarmSeverity::Major as i16,
});
let _rval_rx = instance
.add_subscriber("RVAL", 1, DbFieldType::Long, EventMask::ALARM.bits())
.expect("RVAL subscriber");
instance.record.set_val(EpicsValue::Double(1.0)).unwrap();
instance.record.set_device_did_compute(true);
let (snap, _alarm_posts) = instance.process_local().unwrap();
assert!(
snap.changed_fields.iter().any(|(k, _, _)| k == "VAL"),
"the alarm transition posts VAL"
);
assert!(
!snap.changed_fields.iter().any(|(k, _, _)| k == "RVAL"),
"ai has no alarm-cycle fanout list: unchanged RVAL must not post"
);
}
#[test]
fn test_ai_rval_not_posted_when_val_within_deadband() {
use epics_base_rs::server::recgbl::EventMask;
use epics_base_rs::types::DbFieldType;
let mut rec = AiRecord::default();
rec.mdel = 100.0;
rec.adel = 100.0;
let mut instance = RecordInstance::new("TEST".into(), rec);
let _rval_rx = instance
.add_subscriber("RVAL", 1, DbFieldType::Long, EventMask::VALUE.bits())
.expect("RVAL subscriber");
instance.record.set_device_did_compute(true);
let _ = instance.process_local().unwrap();
instance.record.set_val(EpicsValue::Double(1.0)).unwrap();
instance
.record
.put_field("RVAL", EpicsValue::Long(42))
.unwrap();
instance.record.set_device_did_compute(true);
let (snap, _) = instance.process_local().unwrap();
assert!(
!snap.changed_fields.iter().any(|(k, _, _)| k == "VAL"),
"VAL within both deadbands: monitor_mask == 0, VAL not posted"
);
assert!(
!snap.changed_fields.iter().any(|(k, _, _)| k == "RVAL"),
"RVAL must not post when monitor_mask == 0 (C nests it in `if(monitor_mask)`)"
);
}
#[test]
fn test_ai_rval_alarm_only_cycle_posts_alarm_mask_not_value_log() {
use epics_base_rs::server::recgbl::EventMask;
use epics_base_rs::types::DbFieldType;
let mut rec = AiRecord::default();
rec.mdel = 100.0;
rec.adel = 100.0;
let mut instance = RecordInstance::new("TEST".into(), rec);
instance.common.analog_alarm = Some(AnalogAlarmConfig {
hihi: AlarmLimit::Double(1000.0),
high: AlarmLimit::Double(0.5),
low: AlarmLimit::Double(-1000.0),
lolo: AlarmLimit::Double(-2000.0),
hhsv: AlarmSeverity::Major as i16,
hsv: AlarmSeverity::Major as i16,
lsv: AlarmSeverity::Minor as i16,
llsv: AlarmSeverity::Major as i16,
});
let _rval_rx = instance
.add_subscriber("RVAL", 1, DbFieldType::Long, EventMask::ALARM.bits())
.expect("RVAL subscriber");
instance.record.set_device_did_compute(true);
let _ = instance.process_local().unwrap();
instance.record.set_val(EpicsValue::Double(1.0)).unwrap();
instance
.record
.put_field("RVAL", EpicsValue::Long(7))
.unwrap();
instance.record.set_device_did_compute(true);
let (snap, _) = instance.process_local().unwrap();
let rval_mask = snap
.changed_fields
.iter()
.find(|(k, _, _)| k == "RVAL")
.map(|(_, _, m)| *m);
assert_eq!(
rval_mask,
Some(EventMask::ALARM),
"alarm-only cycle posts RVAL with DBE_ALARM alone (raw monitor_mask), not VALUE|LOG"
);
}
#[test]
fn test_ai_udf_cycle_leaves_lalm_and_zeroes_afvl() {
let mut rec = AiRecord::default();
rec.aftc = 10.0; let mut instance = RecordInstance::new("TEST".into(), rec);
instance.common.analog_alarm = Some(AnalogAlarmConfig {
hihi: AlarmLimit::Double(1.0),
high: AlarmLimit::Double(0.5),
low: AlarmLimit::Double(-1000.0),
lolo: AlarmLimit::Double(-2000.0),
hhsv: AlarmSeverity::Major as i16,
hsv: AlarmSeverity::Major as i16,
lsv: AlarmSeverity::Minor as i16,
llsv: AlarmSeverity::Major as i16,
});
instance
.record
.put_field("LALM", EpicsValue::Double(0.5))
.unwrap();
instance
.record
.put_field("AFVL", EpicsValue::Double(3.0))
.unwrap();
instance
.record
.set_val(EpicsValue::Double(f64::NAN))
.unwrap();
instance.common.udf = 1;
instance.evaluate_alarms();
assert_eq!(
instance.record.get_field("LALM").and_then(|v| v.to_f64()),
Some(0.5),
"UDF cycle must leave LALM untouched (C returns before the range check)"
);
assert_eq!(
instance.record.get_field("AFVL").and_then(|v| v.to_f64()),
Some(0.0),
"UDF cycle zeroes AFVL (aiRecord.c:321)"
);
assert_eq!(
instance.common.nsev,
AlarmSeverity::Invalid,
"UDF cycle raises UDFS severity (default INVALID)"
);
}
#[test]
fn test_waveform_onchange_gates_val_and_posts_hash() {
use epics_base_rs::server::recgbl::EventMask;
use epics_base_rs::server::records::waveform::WaveformRecord;
use epics_base_rs::types::DbFieldType;
let mut rec = WaveformRecord::new(10, DbFieldType::Long);
rec.mpst = 1; rec.apst = 1; let mut instance = RecordInstance::new("WF".into(), rec);
let _val_rx = instance
.add_subscriber("VAL", 1, DbFieldType::Long, EventMask::VALUE.bits())
.expect("VAL subscriber");
let _hash_rx = instance
.add_subscriber("HASH", 2, DbFieldType::ULong, EventMask::VALUE.bits())
.expect("HASH subscriber");
instance
.record
.put_field("VAL", EpicsValue::LongArray(vec![1, 2, 3]))
.unwrap();
let (snap, _) = instance.process_local().unwrap();
assert!(
snap.changed_fields.iter().any(|(k, _, _)| k == "VAL"),
"On Change: VAL posts when content hash changes"
);
let hash_post = snap.changed_fields.iter().find(|(k, _, _)| k == "HASH");
assert!(hash_post.is_some(), "HASH posts on a hash change");
assert_eq!(
hash_post.unwrap().2,
EventMask::VALUE,
"HASH posts with a literal DBE_VALUE (waveformRecord.c:319)"
);
assert_eq!(
instance.record.get_field("HASH"),
Some(EpicsValue::ULong(0x3429_76d1)),
"HASH equals epicsMemHash(i32[1,2,3] LE)"
);
instance
.record
.put_field("VAL", EpicsValue::LongArray(vec![1, 2, 3]))
.unwrap();
let (snap, _) = instance.process_local().unwrap();
assert!(
!snap.changed_fields.iter().any(|(k, _, _)| k == "VAL"),
"On Change: VAL suppressed when content hash is unchanged"
);
assert!(
!snap.changed_fields.iter().any(|(k, _, _)| k == "HASH"),
"HASH not posted when the hash is unchanged"
);
instance
.record
.put_field("VAL", EpicsValue::LongArray(vec![1, 2, 4]))
.unwrap();
let (snap, _) = instance.process_local().unwrap();
assert!(
snap.changed_fields.iter().any(|(k, _, _)| k == "VAL"),
"On Change: VAL posts again on new content"
);
assert!(
snap.changed_fields.iter().any(|(k, _, _)| k == "HASH"),
"HASH posts again on new content"
);
}
#[test]
fn test_waveform_always_mode_never_posts_hash() {
use epics_base_rs::server::recgbl::EventMask;
use epics_base_rs::server::records::waveform::WaveformRecord;
use epics_base_rs::types::DbFieldType;
let rec = WaveformRecord::new(10, DbFieldType::Long);
let mut instance = RecordInstance::new("WF".into(), rec);
let _hash_rx = instance
.add_subscriber("HASH", 1, DbFieldType::ULong, EventMask::VALUE.bits())
.expect("HASH subscriber");
instance
.record
.put_field("VAL", EpicsValue::LongArray(vec![1, 2, 3]))
.unwrap();
let (snap, _) = instance.process_local().unwrap();
assert!(
!snap.changed_fields.iter().any(|(k, _, _)| k == "HASH"),
"Always mode: HASH is never posted (C never enters the hash block)"
);
assert_eq!(
instance.record.get_field("HASH"),
Some(EpicsValue::ULong(0)),
"Always mode: HASH stays 0 (never computed)"
);
}
#[test]
fn test_pact_reads_zero_when_idle() {
let instance = RecordInstance::new("TEST".into(), AoRecord::new(0.0));
match instance.get_common_field("PACT") {
Some(EpicsValue::Char(0)) => {}
other => panic!("expected Char(0), got {:?}", other),
}
}
#[test]
fn test_pact_write_rejected() {
let mut instance = RecordInstance::new("TEST".into(), AoRecord::new(0.0));
let result = instance.put_common_field("PACT", EpicsValue::Char(1));
assert!(matches!(result, Err(CaError::ReadOnlyField(_))));
}
#[test]
fn test_lcnt_zero_after_process() {
let mut instance = RecordInstance::new("TEST".into(), AoRecord::new(0.0));
instance.common.lcnt = 5;
let _ = instance.process_local().unwrap();
assert_eq!(instance.common.lcnt, 0);
}
#[test]
fn test_lcnt_increments_on_reentrance() {
let mut instance = RecordInstance::new("TEST".into(), AoRecord::new(0.0));
instance.enter_pact();
let _ = instance.process_local().unwrap();
assert_eq!(instance.common.lcnt, 1);
let _ = instance.process_local().unwrap();
assert_eq!(instance.common.lcnt, 2);
}
#[test]
fn test_lcnt_alarm_threshold() {
let mut instance = RecordInstance::new("TEST".into(), AoRecord::new(0.0));
instance.enter_pact();
for _ in 0..10 {
let _ = instance.process_local().unwrap();
}
assert_eq!(instance.common.lcnt, 10);
assert_eq!(
instance.common.sevr,
AlarmSeverity::NoAlarm,
"C fires at lcnt++ == MAX_LOCK: 10 attempts only increment"
);
let _ = instance.process_local().unwrap();
assert_eq!(instance.common.sevr, AlarmSeverity::Invalid);
assert_eq!(
instance.common.stat,
epics_base_rs::server::recgbl::alarm_status::SCAN_ALARM
);
assert_eq!(instance.common.amsg, "Async in progress");
}
#[test]
fn test_lcnt_alarm_posts_exactly_once() {
let mut instance = RecordInstance::new("TEST".into(), AoRecord::new(0.0));
instance.enter_pact();
for _ in 0..10 {
let _ = instance.process_local().unwrap();
}
let (snapshot, _) = instance.process_local().unwrap();
assert!(
snapshot.changed_fields.iter().any(|(f, _, _)| f == "SEVR"),
"the fresh SCAN_ALARM raise must post SEVR"
);
let (snapshot, _) = instance.process_local().unwrap();
assert!(
snapshot.changed_fields.is_empty(),
"an already-raised SCAN_ALARM must not re-post on later \
reentrant attempts, got {:?}",
snapshot.changed_fields
);
}
#[test]
fn test_lcnt_reset_on_success() {
let mut instance = RecordInstance::new("TEST".into(), AoRecord::new(0.0));
instance.common.lcnt = 5;
let _ = instance.process_local().unwrap();
assert_eq!(instance.common.lcnt, 0);
}
#[test]
fn test_proc_get_put() {
let mut instance = RecordInstance::new("TEST".into(), AoRecord::new(0.0));
match instance.get_common_field("PROC") {
Some(EpicsValue::UChar(0)) => {}
other => panic!("expected UChar(0), got {:?}", other),
}
instance
.put_common_field("PROC", EpicsValue::Char(1))
.unwrap();
assert_eq!(instance.common.proc_field, 1);
match instance.get_common_field("PROC") {
Some(EpicsValue::UChar(1)) => {}
other => panic!("expected UChar(1), got {:?}", other),
}
}
#[test]
fn test_disp_get_put() {
let mut instance = RecordInstance::new("TEST".into(), AoRecord::new(0.0));
match instance.get_common_field("DISP") {
Some(EpicsValue::UChar(0)) => {}
other => panic!("expected UChar(0), got {:?}", other),
}
instance
.put_common_field("DISP", EpicsValue::Char(1))
.unwrap();
assert!(instance.common.disp != 0);
match instance.get_common_field("DISP") {
Some(EpicsValue::UChar(1)) => {}
other => panic!("expected UChar(1), got {:?}", other),
}
}
use std::sync::Arc;
use std::sync::atomic::{AtomicU32, Ordering};
struct HookTrackingRecord {
val: f64,
special_before_count: Arc<AtomicU32>,
special_after_count: Arc<AtomicU32>,
on_put_count: Arc<AtomicU32>,
reject_field: Option<String>,
}
impl Record for HookTrackingRecord {
fn record_type(&self) -> &'static str {
"test_hook"
}
fn get_field(&self, name: &str) -> Option<EpicsValue> {
match name {
"VAL" => Some(EpicsValue::Double(self.val)),
_ => None,
}
}
fn put_field(&mut self, name: &str, value: EpicsValue) -> CaResult<()> {
match name {
"VAL" => {
if let EpicsValue::Double(v) = value {
self.val = v;
Ok(())
} else {
Err(CaError::InvalidValue("bad type".into()))
}
}
_ => Err(CaError::FieldNotFound(name.into())),
}
}
fn declared_fields(&self) -> &'static [FieldDesc] {
static FIELDS: &[FieldDesc] = &[FieldDesc::new("VAL", DbFieldType::Double, false)];
FIELDS
}
fn validate_put(&self, field: &str, _value: &EpicsValue) -> CaResult<()> {
if let Some(ref reject) = self.reject_field {
if field == reject {
return Err(CaError::InvalidValue("rejected by validate_put".into()));
}
}
Ok(())
}
fn on_put(&mut self, _field: &str) {
self.on_put_count.fetch_add(1, Ordering::SeqCst);
}
fn special(&mut self, _field: &str, after: bool) -> CaResult<()> {
if after {
self.special_after_count.fetch_add(1, Ordering::SeqCst);
} else {
self.special_before_count.fetch_add(1, Ordering::SeqCst);
}
Ok(())
}
}
#[test]
fn test_special_called_on_common_put() {
let special_before = Arc::new(AtomicU32::new(0));
let special_after = Arc::new(AtomicU32::new(0));
let rec = HookTrackingRecord {
val: 0.0,
special_before_count: special_before.clone(),
special_after_count: special_after.clone(),
on_put_count: Arc::new(AtomicU32::new(0)),
reject_field: None,
};
let mut instance = RecordInstance::new("TEST".into(), rec);
instance
.put_common_field("DESC", EpicsValue::String("hello".into()))
.unwrap();
assert_eq!(special_before.load(Ordering::SeqCst), 1);
assert_eq!(special_after.load(Ordering::SeqCst), 1);
}
#[test]
fn test_validate_put_rejects_common_field() {
let rec = HookTrackingRecord {
val: 0.0,
special_before_count: Arc::new(AtomicU32::new(0)),
special_after_count: Arc::new(AtomicU32::new(0)),
on_put_count: Arc::new(AtomicU32::new(0)),
reject_field: Some("SCAN".into()),
};
let mut instance = RecordInstance::new("TEST".into(), rec);
let result = instance.put_common_field("SCAN", EpicsValue::String("1 second".into()));
assert!(result.is_err());
}
#[test]
fn test_on_put_called_for_common_field() {
let on_put = Arc::new(AtomicU32::new(0));
let rec = HookTrackingRecord {
val: 0.0,
special_before_count: Arc::new(AtomicU32::new(0)),
special_after_count: Arc::new(AtomicU32::new(0)),
on_put_count: on_put.clone(),
reject_field: None,
};
let mut instance = RecordInstance::new("TEST".into(), rec);
instance
.put_common_field("DESC", EpicsValue::String("test".into()))
.unwrap();
assert_eq!(on_put.load(Ordering::SeqCst), 1);
}
#[test]
fn test_phas_change_returns_result() {
let rec = AiRecord::new(0.0);
let mut instance = RecordInstance::new("TEST".into(), rec);
instance
.put_common_field("SCAN", EpicsValue::String("1 second".into()))
.unwrap();
let result = instance
.put_common_field("PHAS", EpicsValue::Short(5))
.unwrap();
assert!(matches!(
result,
CommonFieldPutResult::PhasChanged {
old_phas: 0,
new_phas: 5,
..
}
));
}
#[test]
fn test_phas_change_passive_no_result() {
let rec = AiRecord::new(0.0);
let mut instance = RecordInstance::new("TEST".into(), rec);
let result = instance
.put_common_field("PHAS", EpicsValue::Short(5))
.unwrap();
assert_eq!(result, CommonFieldPutResult::NoChange);
}
#[test]
fn test_scan_change_includes_phas() {
let rec = AiRecord::new(0.0);
let mut instance = RecordInstance::new("TEST".into(), rec);
instance
.put_common_field("PHAS", EpicsValue::Short(3))
.unwrap();
let result = instance
.put_common_field("SCAN", EpicsValue::String("1 second".into()))
.unwrap();
match result {
CommonFieldPutResult::ScanChanged { phas, .. } => assert_eq!(phas, 3),
other => panic!("expected ScanChanged, got {:?}", other),
}
}
struct NoUdfClearRecord {
val: f64,
}
impl Record for NoUdfClearRecord {
fn record_type(&self) -> &'static str {
"test_noudf"
}
fn get_field(&self, name: &str) -> Option<EpicsValue> {
match name {
"VAL" => Some(EpicsValue::Double(self.val)),
_ => None,
}
}
fn put_field(&mut self, name: &str, value: EpicsValue) -> CaResult<()> {
match name {
"VAL" => {
if let EpicsValue::Double(v) = value {
self.val = v;
Ok(())
} else {
Err(CaError::InvalidValue("bad".into()))
}
}
_ => Err(CaError::FieldNotFound(name.into())),
}
}
fn declared_fields(&self) -> &'static [FieldDesc] {
&[]
}
fn clears_udf(&self) -> bool {
false
}
}
#[test]
fn test_udf_cleared_after_process() {
let rec = AiRecord::new(1.0);
let mut instance = RecordInstance::new("TEST".into(), rec);
assert!(instance.common.udf != 0);
instance.process_local().unwrap();
assert!(instance.common.udf == 0);
}
#[test]
fn test_udf_not_cleared_when_clears_udf_false() {
let rec = NoUdfClearRecord { val: 1.0 };
let mut instance = RecordInstance::new("TEST".into(), rec);
assert!(instance.common.udf != 0);
instance.process_local().unwrap();
assert!(instance.common.udf != 0);
}
#[test]
fn test_udf_alarm_persists() {
use epics_base_rs::server::recgbl;
let rec = NoUdfClearRecord { val: 1.0 };
let mut instance = RecordInstance::new("TEST".into(), rec);
instance.common.udf = 1;
instance.process_local().unwrap();
assert!(instance.common.udf != 0);
instance.evaluate_alarms();
let result = recgbl::rec_gbl_reset_alarms(&mut instance.common);
assert!(result.alarm_changed || instance.common.sevr == AlarmSeverity::Invalid);
}
#[test]
fn test_snapshot_ai_with_display_metadata() {
let mut rec = AiRecord::new(42.0);
rec.egu = "degC".into();
rec.prec = 3;
rec.hopr = 100.0;
rec.lopr = -50.0;
let mut inst = RecordInstance::new("AI:TEST".into(), rec);
inst.common.analog_alarm = Some(AnalogAlarmConfig {
hihi: AlarmLimit::Double(90.0),
high: AlarmLimit::Double(80.0),
low: AlarmLimit::Double(-20.0),
lolo: AlarmLimit::Double(-40.0),
hhsv: AlarmSeverity::Major as i16,
hsv: AlarmSeverity::Minor as i16,
lsv: AlarmSeverity::Minor as i16,
llsv: AlarmSeverity::Major as i16,
});
let snap = inst.snapshot_for_field("VAL").unwrap();
assert_eq!(snap.value, EpicsValue::Double(42.0));
let disp = snap.display.as_ref().unwrap();
assert_eq!(disp.units, "degC");
assert_eq!(disp.precision, 3);
assert_eq!(disp.upper_disp_limit, 100.0);
assert_eq!(disp.lower_disp_limit, -50.0);
assert_eq!(disp.upper_alarm_limit, 90.0);
assert_eq!(disp.upper_warning_limit, 80.0);
assert_eq!(disp.lower_warning_limit, -20.0);
assert_eq!(disp.lower_alarm_limit, -40.0);
let ctrl = snap.control.as_ref().unwrap();
assert_eq!(ctrl.upper_ctrl_limit, 100.0);
assert_eq!(ctrl.lower_ctrl_limit, -50.0);
assert!(snap.enums.is_none());
}
#[test]
fn test_snapshot_ao_with_drvh_drvl() {
let mut rec = AoRecord::new(10.0);
rec.egu = "V".into();
rec.hopr = 100.0;
rec.lopr = 0.0;
rec.drvh = 50.0;
rec.drvl = 5.0;
let inst = RecordInstance::new("AO:TEST".into(), rec);
let snap = inst.snapshot_for_field("VAL").unwrap();
let ctrl = snap.control.as_ref().unwrap();
assert_eq!(ctrl.upper_ctrl_limit, 50.0);
assert_eq!(ctrl.lower_ctrl_limit, 5.0);
let disp = snap.display.as_ref().unwrap();
assert_eq!(disp.units, "V");
}
#[test]
fn test_snapshot_bi_enum_strings() {
let mut rec = BiRecord::new(0);
rec.znam = "Off".into();
rec.onam = "On".into();
let inst = RecordInstance::new("BI:TEST".into(), rec);
let snap = inst.snapshot_for_field("VAL").unwrap();
let disp = snap.display.as_ref().unwrap();
assert!(disp.units.is_empty());
assert!(disp.description.is_empty());
assert!(snap.control.is_none());
let enums = snap.enums.as_ref().unwrap();
assert_eq!(enums.strings.len(), 2);
assert_eq!(enums.strings[0], "Off");
assert_eq!(enums.strings[1], "On");
}
#[test]
fn test_snapshot_mbbi_16_strings() {
use epics_base_rs::server::records::mbbi::MbbiRecord;
let mut rec = MbbiRecord::default();
rec.zrst = "Zero".into();
rec.onst = "One".into();
rec.twst = "Two".into();
rec.ffst = "Fifteen".into();
let inst = RecordInstance::new("MBBI:TEST".into(), rec);
let snap = inst.snapshot_for_field("VAL").unwrap();
let enums = snap.enums.as_ref().unwrap();
assert_eq!(enums.strings.len(), 16);
assert_eq!(enums.strings[0], "Zero");
assert_eq!(enums.strings[1], "One");
assert_eq!(enums.strings[2], "Two");
assert_eq!(enums.strings[15], "Fifteen");
assert_eq!(enums.strings[3], "");
}
#[test]
fn test_snapshot_sel_selm_menu_choices() {
use epics_base_rs::server::records::sel::SelRecord;
let mut rec = SelRecord::default();
rec.selm = 2; let inst = RecordInstance::new("SEL:TEST".into(), rec);
let snap = inst.snapshot_for_field("SELM").unwrap();
assert_eq!(snap.value, EpicsValue::Enum(2));
let enums = snap.enums.as_ref().unwrap();
assert_eq!(
enums.strings,
vec!["Specified", "High Signal", "Low Signal", "Median Signal"]
);
}
#[test]
fn test_snapshot_ai_simm_is_menusimm_three_choices() {
let mut rec = AiRecord::new(1.0);
rec.simm = 2; let inst = RecordInstance::new("AI:SIMM".into(), rec);
let snap = inst.snapshot_for_field("SIMM").unwrap();
assert_eq!(snap.value, EpicsValue::Enum(2));
assert_eq!(
snap.enums.as_ref().unwrap().strings,
vec!["NO", "YES", "RAW"]
);
}
#[test]
fn test_snapshot_longout_simm_is_menuyesno_two_choices() {
use epics_base_rs::server::records::longout::LongoutRecord;
let mut rec = LongoutRecord::new(0);
rec.simm = 1; let inst = RecordInstance::new("LO:SIMM".into(), rec);
let snap = inst.snapshot_for_field("SIMM").unwrap();
assert_eq!(snap.value, EpicsValue::Enum(1));
assert_eq!(snap.enums.as_ref().unwrap().strings, vec!["NO", "YES"]);
}
#[test]
fn test_ai_simm_get_field_stays_short() {
let mut rec = AiRecord::new(1.0);
rec.simm = 2;
assert_eq!(rec.get_field("SIMM"), Some(EpicsValue::Short(2)));
}
#[test]
fn test_snapshot_dfanout_omsl_ivoa_serve_as_enum() {
use epics_base_rs::server::records::dfanout::DfanoutRecord;
let mut rec = DfanoutRecord::new(0.0);
rec.omsl = 1; rec.ivoa = 2; assert_eq!(rec.get_field("OMSL"), Some(EpicsValue::Short(1)));
assert_eq!(rec.get_field("IVOA"), Some(EpicsValue::Short(2)));
let inst = RecordInstance::new("DFAN:MENU".into(), rec);
let omsl = inst.snapshot_for_field("OMSL").unwrap();
assert_eq!(omsl.value, EpicsValue::Enum(1));
assert_eq!(
omsl.enums.as_ref().unwrap().strings,
vec!["supervisory", "closed_loop"]
);
let ivoa = inst.snapshot_for_field("IVOA").unwrap();
assert_eq!(ivoa.value, EpicsValue::Enum(2));
assert_eq!(
ivoa.enums.as_ref().unwrap().strings,
vec![
"Continue normally",
"Don't drive outputs",
"Set output to IVOV"
]
);
}
#[test]
fn test_snapshot_dbcommon_menu_fields_serve_as_enum() {
use epics_base_rs::server::record::{AlarmSeverity, PiniMode};
let mut inst = RecordInstance::new("COMMON:MENU".into(), AiRecord::new(1.0));
inst.common.sevr = AlarmSeverity::Major; inst.common.stat = 14; inst.common.nsev = AlarmSeverity::Minor;
inst.common.nsta = 17; inst.common.acks = AlarmSeverity::Invalid;
inst.common.ackt = false;
inst.common.diss = AlarmSeverity::Minor as i16;
inst.common.udfs = AlarmSeverity::Invalid as i16;
inst.common.pini = PiniMode::Run as i16;
let sevr = inst.snapshot_for_field("SEVR").unwrap();
assert_eq!(sevr.value, EpicsValue::Enum(2));
assert_eq!(
sevr.enums.as_ref().unwrap().strings,
vec!["NO_ALARM", "MINOR", "MAJOR", "INVALID"]
);
let stat = inst.snapshot_for_field("STAT").unwrap();
assert_eq!(stat.value, EpicsValue::Enum(14));
let stat_strings = &stat.enums.as_ref().unwrap().strings;
assert_eq!(stat_strings.len(), 22);
assert_eq!(stat_strings[14], "LINK");
let nsev = inst.snapshot_for_field("NSEV").unwrap();
assert_eq!(nsev.value, EpicsValue::Enum(1));
let nsta = inst.snapshot_for_field("NSTA").unwrap();
assert_eq!(nsta.value, EpicsValue::Enum(17));
assert_eq!(nsta.enums.as_ref().unwrap().strings[17], "UDF");
let acks = inst.snapshot_for_field("ACKS").unwrap();
assert_eq!(acks.value, EpicsValue::Enum(3));
assert_eq!(acks.enums.as_ref().unwrap().strings[3], "INVALID");
let ackt = inst.snapshot_for_field("ACKT").unwrap();
assert_eq!(ackt.value, EpicsValue::Enum(0));
assert_eq!(ackt.enums.as_ref().unwrap().strings, vec!["NO", "YES"]);
let pini = inst.snapshot_for_field("PINI").unwrap();
assert_eq!(pini.value, EpicsValue::Enum(2));
assert_eq!(
pini.enums.as_ref().unwrap().strings,
vec!["NO", "YES", "RUN", "RUNNING", "PAUSE", "PAUSED"]
);
let diss = inst.snapshot_for_field("DISS").unwrap();
assert_eq!(diss.value, EpicsValue::Enum(1));
assert_eq!(diss.enums.as_ref().unwrap().strings[1], "MINOR");
let udfs = inst.snapshot_for_field("UDFS").unwrap();
assert_eq!(udfs.value, EpicsValue::Enum(3));
assert_eq!(udfs.enums.as_ref().unwrap().strings[3], "INVALID");
}
#[test]
fn test_put_dbcommon_menu_field_resolves_label() {
use epics_base_rs::server::record::{AlarmSeverity, PiniMode};
let mut inst = RecordInstance::new("COMMON:PUT".into(), AiRecord::new(1.0));
inst.put_common_field("DISS", EpicsValue::String("MAJOR".into()))
.unwrap();
assert_eq!(inst.common.diss, AlarmSeverity::Major as i16);
inst.put_common_field("UDFS", EpicsValue::String("MINOR".into()))
.unwrap();
assert_eq!(inst.common.udfs, AlarmSeverity::Minor as i16);
inst.put_common_field("PINI", EpicsValue::String("RUN".into()))
.unwrap();
assert_eq!(inst.common.pini, PiniMode::Run as i16);
inst.put_common_field("PINI", EpicsValue::String("3".into()))
.unwrap();
assert_eq!(inst.common.pini, PiniMode::Running as i16);
inst.put_common_field("PINI", EpicsValue::String("NO".into()))
.unwrap();
assert_eq!(inst.common.pini, PiniMode::No as i16);
assert!(
inst.put_common_field("PINI", EpicsValue::String("MAYBE".into()))
.is_err()
);
inst.put_common_field("ACKT", EpicsValue::String("NO".into()))
.unwrap();
assert!(!inst.common.ackt);
inst.put_common_field("ACKT", EpicsValue::String("YES".into()))
.unwrap();
assert!(inst.common.ackt);
}
#[test]
fn test_snapshot_lsi_mpst_menupost_order() {
use epics_base_rs::server::records::lsi::LsiRecord;
let mut rec = LsiRecord::new("x");
rec.mpst = 1; let inst = RecordInstance::new("LSI:MPST".into(), rec);
let snap = inst.snapshot_for_field("MPST").unwrap();
assert_eq!(snap.value, EpicsValue::Enum(1));
assert_eq!(
snap.enums.as_ref().unwrap().strings,
vec!["On Change", "Always"]
);
}
#[test]
fn test_snapshot_ai_sims_alarm_severity_choices() {
let mut rec = AiRecord::new(1.0);
rec.sims = 3; let inst = RecordInstance::new("AI:SIMS".into(), rec);
let snap = inst.snapshot_for_field("SIMS").unwrap();
assert_eq!(snap.value, EpicsValue::Enum(3));
assert_eq!(
snap.enums.as_ref().unwrap().strings,
vec!["NO_ALARM", "MINOR", "MAJOR", "INVALID"]
);
}
#[test]
fn test_snapshot_ao_oif_menu_choices() {
let mut rec = AoRecord::new(0.0);
rec.oif = 1; let inst = RecordInstance::new("AO:OIF".into(), rec);
let snap = inst.snapshot_for_field("OIF").unwrap();
assert_eq!(snap.value, EpicsValue::Enum(1));
assert_eq!(
snap.enums.as_ref().unwrap().strings,
vec!["Full", "Incremental"]
);
}
#[test]
fn test_snapshot_histogram_cmd_menu_choices() {
use epics_base_rs::server::records::histogram::HistogramRecord;
let mut rec = HistogramRecord::default();
rec.cmd = 2; let inst = RecordInstance::new("HIST:CMD".into(), rec);
let snap = inst.snapshot_for_field("CMD").unwrap();
assert_eq!(snap.value, EpicsValue::Enum(2));
assert_eq!(
snap.enums.as_ref().unwrap().strings,
vec!["Read", "Clear", "Start", "Stop"]
);
}
#[test]
fn test_snapshot_scalcout_oopt_includes_never() {
use epics_base_rs::server::records::scalcout::ScalcoutRecord;
let mut rec = ScalcoutRecord::default();
rec.oopt = 6; let inst = RecordInstance::new("SCALC:OOPT".into(), rec);
let snap = inst.snapshot_for_field("OOPT").unwrap();
assert_eq!(snap.value, EpicsValue::Enum(6));
let strings = &snap.enums.as_ref().unwrap().strings;
assert_eq!(strings.len(), 7);
assert_eq!(strings[0], "Every Time");
assert_eq!(strings[6], "Never");
}
#[test]
fn test_snapshot_longin_display() {
use epics_base_rs::server::records::longin::LonginRecord;
let mut rec = LonginRecord::new(999);
rec.egu = "counts".into();
rec.hopr = 10000;
rec.lopr = 0;
let inst = RecordInstance::new("LONGIN:TEST".into(), rec);
let snap = inst.snapshot_for_field("VAL").unwrap();
let disp = snap.display.as_ref().unwrap();
assert_eq!(disp.units, "counts");
assert_eq!(disp.precision, 0);
assert_eq!(disp.upper_disp_limit, 10000.0);
assert_eq!(disp.lower_disp_limit, 0.0);
let ctrl = snap.control.as_ref().unwrap();
assert_eq!(ctrl.upper_ctrl_limit, 10000.0);
assert_eq!(ctrl.lower_ctrl_limit, 0.0);
}
#[test]
fn test_snapshot_stringin_no_metadata() {
let rec = StringinRecord::new("hello");
let inst = RecordInstance::new("SI:TEST".into(), rec);
let snap = inst.snapshot_for_field("VAL").unwrap();
assert_eq!(snap.value, EpicsValue::String("hello".into()));
let disp = snap.display.as_ref().unwrap();
assert!(disp.units.is_empty());
assert_eq!(disp.upper_disp_limit, 0.0);
assert!(snap.control.is_none());
assert!(snap.enums.is_none());
}
#[test]
fn test_snapshot_field_not_found() {
let rec = AiRecord::new(1.0);
let inst = RecordInstance::new("AI:TEST".into(), rec);
assert!(inst.snapshot_for_field("NONEXISTENT").is_none());
}
#[test]
fn test_snapshot_alarm_state() {
let rec = AiRecord::new(1.0);
let mut inst = RecordInstance::new("AI:TEST".into(), rec);
inst.common.stat = 7;
inst.common.sevr = AlarmSeverity::Minor;
let snap = inst.snapshot_for_field("VAL").unwrap();
assert_eq!(snap.alarm.status, 7);
assert_eq!(snap.alarm.severity, 1);
}
#[test]
fn test_bi_serves_aftc_and_afvl_fields() {
use epics_base_rs::server::records::bi::BiRecord;
let mut rec = BiRecord::new(0);
rec.put_field("AFTC", EpicsValue::Double(5.0)).unwrap();
assert_eq!(
rec.get_field("AFTC").and_then(|v| v.to_f64()),
Some(5.0),
"AFTC must be a settable, readable DBF_DOUBLE"
);
assert_eq!(
rec.get_field("AFVL").and_then(|v| v.to_f64()),
Some(0.0),
"AFVL must be a readable DBF_DOUBLE, defaulting to 0"
);
}
#[test]
fn test_bi_aftc_filter_seeds_afvl() {
use epics_base_rs::server::records::bi::BiRecord;
let mut rec = BiRecord::new(1); rec.osv = AlarmSeverity::Major as i16;
rec.aftc = 5.0;
rec.afvl = 0.0; let mut inst = RecordInstance::new("BI:AFTC".into(), rec);
inst.common.udf = 0;
inst.record.check_alarms(&mut inst.common);
assert_eq!(
inst.record.get_field("AFVL").and_then(|v| v.to_f64()),
Some(AlarmSeverity::Major as u16 as f64),
"first AFTC cycle seeds AFVL with the raw OSV severity (biRecord.c:256-257)"
);
}
#[test]
fn test_bi_udf_cycle_leaves_afvl_untouched() {
use epics_base_rs::server::records::bi::BiRecord;
let mut rec = BiRecord::new(1);
rec.aftc = 10.0;
let mut inst = RecordInstance::new("BI:UDF".into(), rec);
inst.record
.put_field("AFVL", EpicsValue::Double(3.0))
.unwrap();
inst.common.udf = 1;
inst.record.check_alarms(&mut inst.common);
assert_eq!(
inst.record.get_field("AFVL").and_then(|v| v.to_f64()),
Some(3.0),
"bi UDF cycle must leave AFVL untouched (biRecord.c has no afvl=0 on the UDF path)"
);
}
#[test]
fn test_mbbi_aftc_writes_afvl_back_each_cycle() {
use epics_base_rs::server::records::mbbi::MbbiRecord;
let mut rec = MbbiRecord::new(1); rec.onsv = AlarmSeverity::Major as i16;
rec.aftc = 2.0;
rec.afvl = 0.0;
let mut inst = RecordInstance::new("MBBI:AFTC".into(), rec);
inst.common.udf = 0;
inst.record.check_alarms(&mut inst.common);
inst.evaluate_alarms();
let afvl_after_first = inst
.record
.get_field("AFVL")
.and_then(|v| v.to_f64())
.expect("AFVL readable after first cycle");
assert!(
afvl_after_first != 0.0,
"AFVL must be non-zero after first AFTC cycle (was the writeback dropped?)"
);
inst.record.check_alarms(&mut inst.common);
inst.evaluate_alarms();
let afvl_after_second = inst
.record
.get_field("AFVL")
.and_then(|v| v.to_f64())
.expect("AFVL readable after second cycle");
assert!(
afvl_after_second.abs() > 0.0,
"AFVL must remain non-zero after the second cycle"
);
}
#[test]
fn test_ai_aftc_filter_engages_and_seeds() {
let mut rec = AiRecord::new(150.0); rec.aftc = 5.0;
rec.afvl = 0.0; let mut inst = RecordInstance::new("AI:AFTC".into(), rec);
inst.common.udf = 0;
inst.common.analog_alarm = Some(AnalogAlarmConfig {
hihi: AlarmLimit::Double(100.0),
high: AlarmLimit::Double(80.0),
low: AlarmLimit::Double(-20.0),
lolo: AlarmLimit::Double(-40.0),
hhsv: AlarmSeverity::Major as i16,
hsv: AlarmSeverity::Minor as i16,
lsv: AlarmSeverity::Minor as i16,
llsv: AlarmSeverity::Major as i16,
});
inst.evaluate_alarms();
epics_base_rs::server::recgbl::rec_gbl_reset_alarms(&mut inst.common);
assert_eq!(
inst.common.sevr,
AlarmSeverity::Major,
"initial AFTC sample passes the raw HIHI severity through"
);
let afvl = inst
.record
.get_field("AFVL")
.and_then(|v| v.to_f64())
.expect("AFVL readable");
assert!(
(afvl - 5.0).abs() < 1e-9,
"AFVL must seed to the raw HIHI alarmRange 5.0 (filter engaged), got {afvl}"
);
}
#[test]
fn test_longin_aftc_filter_engages_and_seeds() {
use epics_base_rs::server::records::longin::LonginRecord;
let mut rec = LonginRecord::new(150); rec.aftc = 5.0;
rec.afvl = 0.0;
let mut inst = RecordInstance::new("LONGIN:AFTC".into(), rec);
inst.common.udf = 0;
inst.common.analog_alarm = Some(AnalogAlarmConfig {
hihi: AlarmLimit::Double(100.0),
high: AlarmLimit::Double(80.0),
low: AlarmLimit::Double(-20.0),
lolo: AlarmLimit::Double(-40.0),
hhsv: AlarmSeverity::Major as i16,
hsv: AlarmSeverity::Minor as i16,
lsv: AlarmSeverity::Minor as i16,
llsv: AlarmSeverity::Major as i16,
});
inst.evaluate_alarms();
epics_base_rs::server::recgbl::rec_gbl_reset_alarms(&mut inst.common);
assert_eq!(inst.common.sevr, AlarmSeverity::Major);
let afvl = inst
.record
.get_field("AFVL")
.and_then(|v| v.to_f64())
.expect("AFVL readable");
assert!(
(afvl - 5.0).abs() < 1e-9,
"longin AFVL must seed to the raw HIHI alarmRange 5.0, got {afvl}"
);
}
#[test]
fn test_int64in_aftc_filter_engages_and_seeds() {
use epics_base_rs::server::records::int64in::Int64inRecord;
let mut rec = Int64inRecord::new(150); rec.aftc = 5.0;
rec.afvl = 0.0;
let mut inst = RecordInstance::new("INT64IN:AFTC".into(), rec);
inst.common.udf = 0;
inst.common.analog_alarm = Some(AnalogAlarmConfig {
hihi: AlarmLimit::Double(100.0),
high: AlarmLimit::Double(80.0),
low: AlarmLimit::Double(-20.0),
lolo: AlarmLimit::Double(-40.0),
hhsv: AlarmSeverity::Major as i16,
hsv: AlarmSeverity::Minor as i16,
lsv: AlarmSeverity::Minor as i16,
llsv: AlarmSeverity::Major as i16,
});
inst.evaluate_alarms();
epics_base_rs::server::recgbl::rec_gbl_reset_alarms(&mut inst.common);
assert_eq!(inst.common.sevr, AlarmSeverity::Major);
let afvl = inst
.record
.get_field("AFVL")
.and_then(|v| v.to_f64())
.expect("AFVL readable");
assert!(
(afvl - 5.0).abs() < 1e-9,
"int64in AFVL must seed to the raw HIHI alarmRange 5.0, got {afvl}"
);
}
#[test]
fn test_ai_aftc_disabled_resets_stale_afvl() {
let mut rec = AiRecord::new(0.0); rec.aftc = 0.0; rec.afvl = 3.0; let mut inst = RecordInstance::new("AI:AFTC".into(), rec);
inst.common.udf = 0;
inst.common.analog_alarm = Some(AnalogAlarmConfig {
hihi: AlarmLimit::Double(100.0),
high: AlarmLimit::Double(80.0),
low: AlarmLimit::Double(-20.0),
lolo: AlarmLimit::Double(-40.0),
hhsv: AlarmSeverity::Major as i16,
hsv: AlarmSeverity::Minor as i16,
lsv: AlarmSeverity::Minor as i16,
llsv: AlarmSeverity::Major as i16,
});
inst.evaluate_alarms();
let afvl = inst
.record
.get_field("AFVL")
.and_then(|v| v.to_f64())
.expect("AFVL readable");
assert_eq!(
afvl, 0.0,
"AFTC<=0 must reset the stale AFVL accumulator to 0"
);
}
#[test]
fn test_mbbi_lalm_updates_when_cosv_set() {
use epics_base_rs::server::records::mbbi::MbbiRecord;
let mut rec = MbbiRecord::new(0);
rec.cosv = AlarmSeverity::Major as i16; rec.put_field("LALM", EpicsValue::Enum(0)).unwrap();
let mut inst = RecordInstance::new("MBBI:LALM".into(), rec);
inst.common.udf = 0;
inst.record.set_val(EpicsValue::Enum(2)).unwrap();
inst.record.check_alarms(&mut inst.common);
inst.evaluate_alarms();
let lalm_after_first = inst
.record
.get_field("LALM")
.and_then(|v| match v {
EpicsValue::UShort(s) => Some(s),
_ => None,
})
.expect("LALM readable");
assert_eq!(
lalm_after_first, 2,
"LALM must advance to new val even when COSV fires"
);
inst.record.set_val(EpicsValue::Enum(0)).unwrap();
inst.record.check_alarms(&mut inst.common);
inst.evaluate_alarms();
let lalm_after_second = inst
.record
.get_field("LALM")
.and_then(|v| match v {
EpicsValue::UShort(s) => Some(s),
_ => None,
})
.expect("LALM readable");
assert_eq!(
lalm_after_second, 0,
"LALM must advance again on the next transition"
);
let nsev_after_second = inst.common.nsev;
assert_eq!(
nsev_after_second,
AlarmSeverity::Major,
"COS alarm must re-fire on the second transition (LALM-update bug regression)"
);
}
#[test]
fn test_bi_lalm_updates_when_cosv_set() {
let mut rec = BiRecord::new(0);
rec.cosv = AlarmSeverity::Major as i16;
rec.put_field("LALM", EpicsValue::Enum(0)).unwrap();
let mut inst = RecordInstance::new("BI:LALM".into(), rec);
inst.common.udf = 0;
inst.record.set_val(EpicsValue::Enum(1)).unwrap();
inst.record.check_alarms(&mut inst.common);
inst.evaluate_alarms();
let lalm = inst
.record
.get_field("LALM")
.and_then(|v| match v {
EpicsValue::UShort(s) => Some(s),
_ => None,
})
.expect("LALM readable");
assert_eq!(
lalm, 1,
"bi LALM must advance to new val even when COSV fires"
);
}
#[test]
fn desc_preserves_non_utf8_bytes() {
let rec = AiRecord::new(0.0);
let mut inst = RecordInstance::new("DESC:NONUTF8".into(), rec);
let raw = vec![0xffu8, 0x00, 0x80];
inst.put_common_field(
"DESC",
EpicsValue::String(PvString::from_bytes(raw.clone())),
)
.expect("DESC put");
match inst.get_common_field("DESC") {
Some(EpicsValue::String(s)) => assert_eq!(
s.as_bytes(),
raw.as_slice(),
"DESC must round-trip the raw bytes, not lossily decode them"
),
other => panic!("expected EpicsValue::String, got {other:?}"),
}
}
fn dbr_string_of(inst: &RecordInstance, field: &str) -> String {
let snap = inst.snapshot_for_field(field).unwrap();
let bytes = epics_base_rs::types::encode_dbr(0, &snap).unwrap();
let end = bytes.iter().position(|b| *b == 0).unwrap_or(bytes.len());
String::from_utf8_lossy(&bytes[..end]).into_owned()
}
fn mbbi_with_states() -> epics_base_rs::server::records::mbbi::MbbiRecord {
use epics_base_rs::server::records::mbbi::MbbiRecord;
let mut rec = MbbiRecord::default();
rec.zrst = "zero".into();
rec.onst = "one".into();
rec
}
#[test]
fn r21_enum_val_defined_slot_renders_its_state() {
let mut rec = mbbi_with_states();
rec.val = 1;
let inst = RecordInstance::new("MBBI:R21".into(), rec);
assert_eq!(dbr_string_of(&inst, "VAL"), "one");
}
#[test]
fn r21_enum_val_undefined_slot_in_range_renders_empty() {
let mut rec = mbbi_with_states();
rec.val = 5;
let inst = RecordInstance::new("MBBI:R21".into(), rec);
assert_eq!(dbr_string_of(&inst, "VAL"), "");
}
#[test]
fn r21_enum_val_past_the_slots_renders_illegal_value() {
let mut rec = mbbi_with_states();
rec.val = 20;
let inst = RecordInstance::new("MBBI:R21".into(), rec);
assert_eq!(dbr_string_of(&inst, "VAL"), "Illegal Value");
}
#[test]
fn r21_enum_val_with_no_states_renders_empty_not_zero() {
use epics_base_rs::server::records::mbbi::MbbiRecord;
let inst = RecordInstance::new("MBBI:R21".into(), MbbiRecord::default());
assert_eq!(dbr_string_of(&inst, "VAL"), "");
}
#[test]
fn r21_binary_enum_val_empty_onam_renders_empty() {
let mut rec = BiRecord::new(0);
rec.znam = "off".into();
rec.val = 1;
let inst = RecordInstance::new("BI:R21".into(), rec);
assert_eq!(
inst.snapshot_for_field("VAL")
.unwrap()
.enums
.unwrap()
.strings
.len(),
1
);
assert_eq!(dbr_string_of(&inst, "VAL"), "");
}
#[test]
fn r21_menu_field_renders_its_own_choice_not_the_records_states() {
let rec = mbbi_with_states();
let inst = RecordInstance::new("MBBI:R21".into(), rec);
assert_eq!(dbr_string_of(&inst, "SCAN"), "Passive");
}