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
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
//! R16-76: subArray is C's documented exception to the load-once constant-INP
//! rule (R15-78) — `devSASoft.c::read_sa` (92-123) re-reads its INP on EVERY
//! process:
//!
//! ```c
//! rt.nRequest = prec->indx + prec->nelm;
//! if (rt.nRequest > prec->malm) rt.nRequest = prec->malm;
//! if (dbLinkIsConstant(&prec->inp)) {
//! status = dbLoadLinkArray(&prec->inp, prec->ftvl, prec->bptr, &rt.nRequest);
//! if (status == S_db_badField) { /* INP was empty */
//! rt.nRequest = prec->nord;
//! status = 0;
//! }
//! }
//! else { ... dbGetLink ... }
//! if (!status) subset(prec, rt.nRequest); /* shift by INDX, set NORD */
//! ```
//!
//! So a constant INP is re-loaded and re-sliced each cycle, and an EMPTY INP
//! still subsets — with `nRequest = NORD`, i.e. the record re-slices the array
//! a client wrote into VAL. In the port, `set_val` was the only slicing site and
//! ran only on link delivery, and a constant INP delivers nothing at process
//! (R15-78) — so INDX was inert on exactly the two configurations C documents.
//!
//! Every expectation below was taken from a built softIoc
//! (`/home/stevek/work/epics-base/bin/linux-x86_64/softIoc`) driving the same
//! record definitions over CA.
use std::collections::HashSet;
use epics_base_rs::server::ioc_builder::IocBuilder;
use epics_base_rs::types::EpicsValue;
const DB: &str = r#"
record(waveform, "SRC") {
field(FTVL, "DOUBLE")
field(NELM, "8")
}
record(subArray, "SA:CONST") {
field(FTVL, "DOUBLE")
field(INP, "[1, 2, 3, 4]")
field(MALM, "8")
field(NELM, "3")
field(INDX, "0")
}
record(subArray, "SA:EMPTY") {
field(FTVL, "DOUBLE")
field(MALM, "8")
field(NELM, "3")
field(INDX, "1")
}
record(subArray, "SA:LINK") {
field(FTVL, "DOUBLE")
field(INP, "SRC")
field(MALM, "8")
field(NELM, "3")
field(INDX, "1")
}
"#;
async fn build() -> std::sync::Arc<epics_base_rs::server::database::PvDatabase> {
IocBuilder::new()
.db_string(DB, &std::collections::HashMap::new())
.unwrap()
.build()
.await
.unwrap()
.0
}
async fn process(db: &epics_base_rs::server::database::PvDatabase, rec: &str) {
let mut visited = HashSet::new();
db.process_record_with_links(rec, &mut visited, 0)
.await
.unwrap();
}
async fn nord(db: &epics_base_rs::server::database::PvDatabase, rec: &str) -> f64 {
db.get_pv(&format!("{rec}.NORD")).unwrap().to_f64().unwrap()
}
async fn udf(db: &epics_base_rs::server::database::PvDatabase, rec: &str) -> bool {
db.get_record(rec).unwrap().read().common.udf != 0
}
/// C: `dbLoadLinkArray` + `subset` in `devSASoft.c::init_record` (58-73) — the
/// INDX window of the constant is in VAL before any process. softIoc:
/// `SA:CONST` reads `1 2 3`, NORD=3, UDF=0.
#[epics_macros_rs::epics_test]
async fn constant_inp_slice_is_loaded_at_init() {
let db = build().await;
assert_eq!(
db.get_pv("SA:CONST").unwrap(),
EpicsValue::DoubleArray(vec![1.0, 2.0, 3.0])
);
assert_eq!(nord(&db, "SA:CONST").await, 3.0);
assert!(!udf(&db, "SA:CONST").await);
}
/// C: `read_sa` re-runs `dbLoadLinkArray` on the constant EVERY process, so a
/// client's caput to VAL is gone on the next cycle. softIoc: after
/// `caput -a SA:CONST 5 9 9 9 9 9` + process, VAL is `1 2 3` again.
///
/// This is the R15-78 rule INVERTED for this one record type, so the aai/wf
/// half of that rule is pinned alongside it (they must still NOT re-load).
#[epics_macros_rs::epics_test]
async fn constant_inp_is_re_loaded_and_re_sliced_at_process() {
let db = build().await;
db.put_pv(
"SA:CONST",
EpicsValue::DoubleArray(vec![9.0, 9.0, 9.0, 9.0, 9.0]),
)
.await
.unwrap();
process(&db, "SA:CONST").await;
assert_eq!(
db.get_pv("SA:CONST").unwrap(),
EpicsValue::DoubleArray(vec![1.0, 2.0, 3.0]),
"a constant INP subArray restores the INDX window of the constant every process"
);
assert_eq!(nord(&db, "SA:CONST").await, 3.0);
}
/// C: the INDX window moves with INDX because the constant is re-loaded whole
/// (`nRequest = min(INDX+NELM, MALM)`) before the shift. softIoc: `caput
/// SA:CONST.INDX 1` → VAL `2 3 4`, NORD=3.
#[epics_macros_rs::epics_test]
async fn constant_inp_indx_selects_the_window() {
let db = build().await;
db.put_pv("SA:CONST.INDX", EpicsValue::Long(1))
.await
.unwrap();
process(&db, "SA:CONST").await;
assert_eq!(
db.get_pv("SA:CONST").unwrap(),
EpicsValue::DoubleArray(vec![2.0, 3.0, 4.0])
);
assert_eq!(nord(&db, "SA:CONST").await, 3.0);
// INDX past the constant's length: `ecount = nRequest - indx <= 0` → NORD=0,
// and `readValue`'s `nord <= 0 → status = -1` makes the record UDF.
db.put_pv("SA:CONST.INDX", EpicsValue::Long(6))
.await
.unwrap();
process(&db, "SA:CONST").await;
assert_eq!(nord(&db, "SA:CONST").await, 0.0);
assert!(
udf(&db, "SA:CONST").await,
"empty slice → C `prec->udf = !!status` with status = -1"
);
}
/// C: an EMPTY INP still subsets, with `nRequest = prec->nord` — the record
/// slices the array the client wrote into VAL. The shift is `memmove` in place,
/// so each further process eats INDX more elements. softIoc, INDX=1 NELM=3
/// MALM=8 after `caput -a SA:EMPTY 5 10 20 30 40 50`:
///
/// | cycle | VAL | NORD |
/// |-------|------------|------|
/// | put | `20 30 40` | 3 |
/// | +1 | `30 40` | 2 |
/// | +2 | `40` | 1 |
/// | +3 | (empty) | 0 | → UDF/INVALID
#[epics_macros_rs::epics_test]
async fn empty_inp_re_slices_the_client_written_val() {
let db = build().await;
db.put_pv(
"SA:EMPTY",
EpicsValue::DoubleArray(vec![10.0, 20.0, 30.0, 40.0, 50.0]),
)
.await
.unwrap();
// The client's 5 elements survive the put: a subArray's buffer is MALM
// (=8) wide, not NELM (=3) — C `cvt_dbaddr` `no_elements = prec->malm`.
assert_eq!(nord(&db, "SA:EMPTY").await, 5.0);
process(&db, "SA:EMPTY").await;
assert_eq!(
db.get_pv("SA:EMPTY").unwrap(),
EpicsValue::DoubleArray(vec![20.0, 30.0, 40.0]),
"empty INP: process slices VAL[INDX .. INDX+NELM]"
);
assert_eq!(nord(&db, "SA:EMPTY").await, 3.0);
process(&db, "SA:EMPTY").await;
assert_eq!(
db.get_pv("SA:EMPTY").unwrap(),
EpicsValue::DoubleArray(vec![30.0, 40.0])
);
assert_eq!(nord(&db, "SA:EMPTY").await, 2.0);
process(&db, "SA:EMPTY").await;
assert_eq!(
db.get_pv("SA:EMPTY").unwrap(),
EpicsValue::DoubleArray(vec![40.0])
);
assert_eq!(nord(&db, "SA:EMPTY").await, 1.0);
process(&db, "SA:EMPTY").await;
assert_eq!(nord(&db, "SA:EMPTY").await, 0.0);
assert!(
udf(&db, "SA:EMPTY").await,
"NORD == 0 → readValue status -1 → UDF (softIoc: SEVR INVALID, STAT UDF)"
);
}
/// The DB-link path is unchanged: `read_sa`'s `else` arm reads INDX+NELM
/// elements from the source and subsets them, and the source is re-read every
/// cycle (so no in-place eating).
#[epics_macros_rs::epics_test]
async fn db_link_inp_re_reads_the_source_every_cycle() {
let db = build().await;
db.put_pv(
"SRC",
EpicsValue::DoubleArray(vec![1.0, 2.0, 3.0, 4.0, 5.0]),
)
.await
.unwrap();
process(&db, "SA:LINK").await;
assert_eq!(
db.get_pv("SA:LINK").unwrap(),
EpicsValue::DoubleArray(vec![2.0, 3.0, 4.0]),
"INDX=1 NELM=3 of the source"
);
// Idempotent across cycles — the source, not the record's own buffer, is
// the thing being sliced.
process(&db, "SA:LINK").await;
assert_eq!(
db.get_pv("SA:LINK").unwrap(),
EpicsValue::DoubleArray(vec![2.0, 3.0, 4.0])
);
assert_eq!(nord(&db, "SA:LINK").await, 3.0);
}