1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
//! A record-owned `DBF_STRING` field must STORE a string.
//!
//! `apply_fields` (`db_loader/mod.rs:1694-1700`) parses a `.db` field value as
//! the type the record *stores* — `get_field(name).db_field_type()` first, the
//! `.dbd` row only as the fallback — because `put_field`'s arms match on the
//! stored representation and a `menu()` field is `DBF_MENU` in the `.dbd` but a
//! `Short` choice index in the record. That makes the stored type the load
//! path's parse spec, so a record that stores a `DBF_STRING` field as anything
//! else rejects the very text C accepts: `EpicsValue::parse_bytes` is handed
//! `Char` and `field(IVOV,"keep")` fails to parse as a number.
//!
//! `lso.IVOV` was that field. C declares it `DBF_STRING size(40)`
//! (`lsoRecord.dbd.pod:155-160`, R7.0.10) exactly as `stringout` does, and
//! `stringout` stores it as a string (`stringout.rs:248-253`); only `lso` returned
//! a `CharArray`, borrowing the representation its genuinely-long VAL/OVAL need
//! (`lsoRecord.c:63` `callocMustSucceed(1, sizv, …)`) for a field C gives a
//! plain `char ivov[40]`.
//!
//! Boundaries, one case each: the load the wrong type rejected, the 40-byte
//! budget the declaration carries (C `putStringString`, `dbConvert.c:923-926`,
//! `strncpy(pdst, psrc, size); *(pdst+size-1) = 0;`), and the invariant itself
//! across every record type, so the next field to drift is caught by count.
use epics_base_rs::server::ioc_builder::IocBuilder;
use epics_base_rs::server::record::dbd_generated::{RECORD_TYPES, record_fields};
use epics_base_rs::types::{DbFieldType, EpicsValue, PvString};
use std::collections::HashMap;
mod module_records;
/// The load `apply_fields` rejected: `IVOV` is `DBF_STRING`, and `keep` is not
/// a number.
#[epics_macros_rs::epics_test]
async fn a_dbf_string_field_loads_the_text_a_db_file_carries() {
let db = IocBuilder::new()
.db_string(
r#"record(lso, "L") { field(IVOV, "keep") }"#,
&HashMap::new(),
)
.expect("field(IVOV,\"keep\") is a legal .db row — C's IVOV is DBF_STRING")
.build()
.await
.unwrap()
.0;
assert_eq!(
db.get_record("L").unwrap().read().record.get_field("IVOV"),
Some(EpicsValue::String(PvString::from("keep"))),
"a DBF_STRING field reads back the string it was loaded with"
);
}
/// The budget the declaration carries. C copies at most `field_size` bytes and
/// NUL-terminates the last one, so a 40-byte field holds 39 bytes of text.
#[test]
fn a_dbf_string_field_keeps_its_forty_byte_budget() {
let mut rec = epics_base_rs::server::db_loader::create_record("lso").unwrap();
let long = "x".repeat(45);
rec.put_field("IVOV", EpicsValue::String(PvString::from(long.as_str())))
.unwrap();
let Some(EpicsValue::String(got)) = rec.get_field("IVOV") else {
panic!("IVOV must store a string");
};
assert_eq!(
got.len(),
39,
"dbConvert.c:923-926 keeps size-1 bytes and NUL-terminates: 40 -> 39"
);
}
/// The invariant, over every record type a vendored `.dbd` declares. The count
/// is asserted so that a field silently dropping out of the sweep — a `.dbd`
/// row that stops being `DBF_STRING`, a record that stops answering
/// `get_field` — shows up as a number change rather than as a quieter pass.
#[test]
fn every_dbf_string_field_reports_dbf_string() {
let mut scanned = 0usize;
let mut wrong: Vec<String> = Vec::new();
for rt in RECORD_TYPES {
// The seven module-owned types are not in `stdRecords.dbd`, so Base's
// default registry does not claim them; the sweep is an application
// that opted in, exactly as the other whole-set walkers are.
let rec = module_records::create_any(rt)
.unwrap_or_else(|e| panic!("{rt}: create_record failed: {e}"));
let fields = record_fields(rt).unwrap_or_else(|| panic!("{rt}: no .dbd field table"));
// A long-string field is `DBF_NOACCESS` + `SPC_DBADDR` in C and is
// refused at load by name, above the parse (`db_loader/mod.rs:1675`),
// so its stored `CharArray` never reaches `parse_bytes`.
let long_strings = rec.long_string_fields();
for f in fields {
if f.dbf_type != DbFieldType::String
|| long_strings.iter().any(|l| l.eq_ignore_ascii_case(f.name))
{
continue;
}
// A field the record does not answer for is framework-owned (INP,
// OUT, DESC, …); `apply_fields` falls back to the `.dbd` row for
// it, which is `DBF_STRING` by construction.
let Some(v) = rec.get_field(f.name) else {
continue;
};
scanned += 1;
if v.db_field_type() != DbFieldType::String {
wrong.push(format!(
"{rt}.{} declares DBF_STRING size({}) but stores {:?}",
f.name,
f.size,
v.db_field_type()
));
}
}
}
assert!(
wrong.is_empty(),
"a DBF_STRING field stored as another type rejects the .db text C accepts: {wrong:#?}"
);
assert_eq!(
scanned, 519,
"record-owned DBF_STRING fields on this tree; a change here means a \
field left or joined the sweep and the new count needs reading. \
507 before scalcout began answering for PAA..PLL, which this port \
serves as read-only scalar DBF_STRING (calc#42)"
);
}