Skip to main content

spvirit_server/
apply.rs

1//! Functions that apply decoded PUT values to Normative Type payloads.
2
3use spvirit_codec::spvd_decode::DecodedValue;
4use spvirit_types::*;
5
6use crate::convert::*;
7use crate::types::{RecordData, RecordInstance, now_nt_timestamp};
8
9/// Apply a scalar value update from a decoded PUT body to an `NtScalar`.
10pub fn apply_value_update(nt: &mut NtScalar, val: &DecodedValue, compute_alarms: bool) -> bool {
11    if let DecodedValue::Structure(fields) = val {
12        if let Some((_, inner)) = fields.iter().find(|(name, _)| name == "value") {
13            return apply_value_update(nt, inner, compute_alarms);
14        }
15    }
16    match &mut nt.value {
17        ScalarValue::Bool(current) => {
18            if let Some(v) = decoded_to_bool(val) {
19                *current = v;
20                if compute_alarms {
21                    nt.update_alarm_from_value();
22                }
23                return true;
24            }
25        }
26        ScalarValue::I32(current) => {
27            if let Some(v) = decoded_to_i32(val) {
28                *current = v;
29                if compute_alarms {
30                    nt.update_alarm_from_value();
31                }
32                return true;
33            }
34        }
35        ScalarValue::F64(current) => {
36            if let Some(v) = decoded_to_f64(val) {
37                *current = v;
38                if compute_alarms {
39                    nt.update_alarm_from_value();
40                }
41                return true;
42            }
43        }
44        ScalarValue::Str(current) => {
45            if let Some(v) = decoded_to_string(val) {
46                *current = v;
47                if compute_alarms {
48                    nt.update_alarm_from_value();
49                }
50                return true;
51            }
52        }
53        _ => {
54            if let Some(v) = decoded_to_f64(val) {
55                match &mut nt.value {
56                    ScalarValue::I8(c) => {
57                        *c = v as i8;
58                    }
59                    ScalarValue::I16(c) => {
60                        *c = v as i16;
61                    }
62                    ScalarValue::I64(c) => {
63                        *c = v as i64;
64                    }
65                    ScalarValue::U8(c) => {
66                        *c = v as u8;
67                    }
68                    ScalarValue::U16(c) => {
69                        *c = v as u16;
70                    }
71                    ScalarValue::U32(c) => {
72                        *c = v as u32;
73                    }
74                    ScalarValue::U64(c) => {
75                        *c = v as u64;
76                    }
77                    ScalarValue::F32(c) => {
78                        *c = v as f32;
79                    }
80                    _ => return false,
81                }
82                if compute_alarms {
83                    nt.update_alarm_from_value();
84                }
85                return true;
86            }
87        }
88    }
89    false
90}
91
92/// Apply an alarm structure update to an `NtScalar`.
93pub fn apply_alarm_update(nt: &mut NtScalar, val: &DecodedValue) -> bool {
94    let DecodedValue::Structure(fields) = val else {
95        return false;
96    };
97    let mut changed = false;
98    for (name, v) in fields {
99        match name.as_str() {
100            "severity" => {
101                if let Some(i) = decoded_to_i32(v) {
102                    nt.alarm_severity = i;
103                    changed = true;
104                }
105            }
106            "status" => {
107                if let Some(i) = decoded_to_i32(v) {
108                    nt.alarm_status = i;
109                    changed = true;
110                }
111            }
112            "message" => {
113                if let Some(s) = decoded_to_string(v) {
114                    nt.alarm_message = s;
115                    changed = true;
116                }
117            }
118            _ => {}
119        }
120    }
121    changed
122}
123
124/// Apply a display structure update to an `NtScalar`.
125pub fn apply_display_update(nt: &mut NtScalar, val: &DecodedValue) -> bool {
126    let DecodedValue::Structure(fields) = val else {
127        return false;
128    };
129    let mut changed = false;
130    for (name, v) in fields {
131        match name.as_str() {
132            "low" | "limitLow" => {
133                if let Some(f) = decoded_to_f64(v) {
134                    nt.display_low = f;
135                    changed = true;
136                }
137            }
138            "high" | "limitHigh" => {
139                if let Some(f) = decoded_to_f64(v) {
140                    nt.display_high = f;
141                    changed = true;
142                }
143            }
144            "description" => {
145                if let Some(s) = decoded_to_string(v) {
146                    nt.display_description = s;
147                    changed = true;
148                }
149            }
150            "units" => {
151                if let Some(s) = decoded_to_string(v) {
152                    nt.units = s;
153                    changed = true;
154                }
155            }
156            "precision" => {
157                if let Some(i) = decoded_to_i32(v) {
158                    nt.display_precision = i;
159                    changed = true;
160                }
161            }
162            "form" => {
163                if let DecodedValue::Structure(form_fields) = v {
164                    let mut updated = false;
165                    for (fname, fval) in form_fields {
166                        match fname.as_str() {
167                            "index" => {
168                                if let Some(i) = decoded_to_i32(fval) {
169                                    nt.display_form_index = i;
170                                    updated = true;
171                                }
172                            }
173                            "choices" => {
174                                if let DecodedValue::Array(items) = fval {
175                                    let mut choices = Vec::new();
176                                    for item in items {
177                                        if let DecodedValue::String(s) = item {
178                                            choices.push(s.clone());
179                                        }
180                                    }
181                                    if !choices.is_empty() {
182                                        nt.display_form_choices = choices;
183                                        updated = true;
184                                    }
185                                }
186                            }
187                            _ => {}
188                        }
189                    }
190                    if updated {
191                        changed = true;
192                    }
193                }
194            }
195            _ => {}
196        }
197    }
198    changed
199}
200
201/// Apply a control structure update to an `NtScalar`.
202pub fn apply_control_update(nt: &mut NtScalar, val: &DecodedValue) -> bool {
203    let DecodedValue::Structure(fields) = val else {
204        return false;
205    };
206    let mut changed = false;
207    for (name, v) in fields {
208        match name.as_str() {
209            "low" | "limitLow" => {
210                if let Some(f) = decoded_to_f64(v) {
211                    nt.control_low = f;
212                    changed = true;
213                }
214            }
215            "high" | "limitHigh" => {
216                if let Some(f) = decoded_to_f64(v) {
217                    nt.control_high = f;
218                    changed = true;
219                }
220            }
221            "minStep" => {
222                if let Some(f) = decoded_to_f64(v) {
223                    nt.control_min_step = f;
224                    changed = true;
225                }
226            }
227            _ => {}
228        }
229    }
230    changed
231}
232
233/// Apply a scalar-array PUT update to an `NtScalarArray`.
234pub fn apply_scalar_array_put(
235    nt: &mut NtScalarArray,
236    nord: &mut usize,
237    value: &DecodedValue,
238) -> bool {
239    let field_value = match value {
240        DecodedValue::Structure(fields) => fields
241            .iter()
242            .find(|(name, _)| name == "value")
243            .map(|(_, v)| v)
244            .unwrap_or(value),
245        _ => value,
246    };
247    if let Some(next) = decoded_to_scalar_array(field_value, &nt.value) {
248        let changed = nt.value != next;
249        if changed {
250            *nord = next.len();
251            nt.value = next;
252        }
253        return changed;
254    }
255    false
256}
257
258/// Apply a table PUT update to an `NtTable`.
259///
260/// Does not apply `timeStamp`: stamping is owned by
261/// [`RecordInstance::apply_put`], which restamps every accepted PUT.
262pub fn apply_table_put(nt: &mut NtTable, value: &DecodedValue) -> bool {
263    let DecodedValue::Structure(fields) = value else {
264        return false;
265    };
266    let mut changed = false;
267    for (name, field_value) in fields {
268        match name.as_str() {
269            "labels" => {
270                if let DecodedValue::Array(items) = field_value {
271                    let labels: Vec<String> = items.iter().filter_map(decoded_to_string).collect();
272                    if !labels.is_empty() && nt.labels != labels {
273                        nt.labels = labels;
274                        changed = true;
275                    }
276                }
277            }
278            "value" => {
279                if let DecodedValue::Structure(cols) = field_value {
280                    for (col_name, col_value) in cols {
281                        if let Some(col) = nt.columns.iter_mut().find(|c| c.name == *col_name) {
282                            if let Some(next) = decoded_to_scalar_array(col_value, &col.values) {
283                                if col.values != next {
284                                    col.values = next;
285                                    changed = true;
286                                }
287                            }
288                        }
289                    }
290                }
291            }
292            "descriptor" => {
293                if let Some(s) = decoded_to_string(field_value) {
294                    let next = if s.is_empty() { None } else { Some(s) };
295                    if nt.descriptor != next {
296                        nt.descriptor = next;
297                        changed = true;
298                    }
299                }
300            }
301            "alarm" => {
302                if let Some(alarm) = decode_nt_alarm(field_value) {
303                    if nt.alarm.as_ref() != Some(&alarm) {
304                        nt.alarm = Some(alarm);
305                        changed = true;
306                    }
307                }
308            }
309            // `timeStamp` is not a data field: `RecordInstance::apply_put`
310            // owns stamping (it always restamps on an accepted PUT) and
311            // treating it here as well would inflate `value_changed` for a
312            // PUT that only carries a timestamp, wrongly triggering
313            // `evaluate_links` (spec rule 5). `put_nt`/`set_nt_payload`
314            // replace the whole `NtTable`/`NtNdArray` directly and never call
315            // this field walk, so removing the arm here does not affect them.
316            _ => {}
317        }
318    }
319    changed
320}
321
322/// Apply an NdArray PUT update to an `NtNdArray`.
323///
324/// Does not apply `timeStamp` or `dataTimeStamp`: stamping is owned by
325/// [`RecordInstance::apply_put`], which restamps every accepted PUT and
326/// honours a client-supplied `dataTimeStamp` itself.
327pub fn apply_ndarray_put(nt: &mut NtNdArray, value: &DecodedValue) -> bool {
328    let DecodedValue::Structure(fields) = value else {
329        return false;
330    };
331    let mut changed = false;
332    for (name, field_value) in fields {
333        match name.as_str() {
334            "value" => {
335                if let Some(next) = decoded_to_scalar_array(field_value, &nt.value) {
336                    if nt.value != next {
337                        nt.value = next;
338                        changed = true;
339                    }
340                }
341            }
342            "compressedSize" => {
343                if let Some(v) = decoded_to_i64(field_value) {
344                    if nt.compressed_size != v {
345                        nt.compressed_size = v;
346                        changed = true;
347                    }
348                }
349            }
350            "uncompressedSize" => {
351                if let Some(v) = decoded_to_i64(field_value) {
352                    if nt.uncompressed_size != v {
353                        nt.uncompressed_size = v;
354                        changed = true;
355                    }
356                }
357            }
358            "uniqueId" => {
359                if let Some(v) = decoded_to_i32(field_value) {
360                    if nt.unique_id != v {
361                        nt.unique_id = v;
362                        changed = true;
363                    }
364                }
365            }
366            "codec" => {
367                if let DecodedValue::Structure(codec_fields) = field_value {
368                    for (cname, cval) in codec_fields {
369                        if cname == "name" {
370                            if let Some(s) = decoded_to_string(cval) {
371                                if nt.codec.name != s {
372                                    nt.codec.name = s;
373                                    changed = true;
374                                }
375                            }
376                        }
377                    }
378                }
379            }
380            "dimension" => {
381                if let DecodedValue::Array(items) = field_value {
382                    let dims: Vec<NdDimension> = items
383                        .iter()
384                        .filter_map(|item| {
385                            if let DecodedValue::Structure(fs) = item {
386                                Some(NdDimension {
387                                    size: fs
388                                        .iter()
389                                        .find(|(n, _)| n == "size")
390                                        .and_then(|(_, v)| decoded_to_i32(v))
391                                        .unwrap_or(0),
392                                    offset: fs
393                                        .iter()
394                                        .find(|(n, _)| n == "offset")
395                                        .and_then(|(_, v)| decoded_to_i32(v))
396                                        .unwrap_or(0),
397                                    full_size: fs
398                                        .iter()
399                                        .find(|(n, _)| n == "fullSize")
400                                        .and_then(|(_, v)| decoded_to_i32(v))
401                                        .unwrap_or(0),
402                                    binning: fs
403                                        .iter()
404                                        .find(|(n, _)| n == "binning")
405                                        .and_then(|(_, v)| decoded_to_i32(v))
406                                        .unwrap_or(1),
407                                    reverse: fs
408                                        .iter()
409                                        .find(|(n, _)| n == "reverse")
410                                        .and_then(|(_, v)| decoded_to_bool(v))
411                                        .unwrap_or(false),
412                                })
413                            } else {
414                                None
415                            }
416                        })
417                        .collect();
418                    if !dims.is_empty() && nt.dimension != dims {
419                        nt.dimension = dims;
420                        changed = true;
421                    }
422                }
423            }
424            "descriptor" => {
425                if let Some(s) = decoded_to_string(field_value) {
426                    let next = if s.is_empty() { None } else { Some(s) };
427                    if nt.descriptor != next {
428                        nt.descriptor = next;
429                        changed = true;
430                    }
431                }
432            }
433            "alarm" => {
434                if let Some(alarm) = decode_nt_alarm(field_value) {
435                    if nt.alarm.as_ref() != Some(&alarm) {
436                        nt.alarm = Some(alarm);
437                        changed = true;
438                    }
439                }
440            }
441            // `timeStamp`/`dataTimeStamp` are not data fields here — see the
442            // comment on the matching arm removed from `apply_table_put`.
443            // `apply_put` below honours a client-supplied `dataTimeStamp`
444            // separately from `timeStamp`, since a gateway PUT can carry a
445            // relay `timeStamp` distinct from the acquisition
446            // `dataTimeStamp` and both must survive independently.
447            "display" => {
448                if let Some(display) = decode_nt_display(field_value) {
449                    if nt.display.as_ref() != Some(&display) {
450                        nt.display = Some(display);
451                        changed = true;
452                    }
453                }
454            }
455            "attribute" => {
456                if let DecodedValue::Array(items) = field_value {
457                    let attrs: Vec<NtAttribute> = items
458                        .iter()
459                        .filter_map(|item| {
460                            if let DecodedValue::Structure(fs) = item {
461                                let attr_name = fs
462                                    .iter()
463                                    .find(|(n, _)| n == "name")
464                                    .and_then(|(_, v)| decoded_to_string(v))
465                                    .unwrap_or_default();
466                                let attr_value = fs
467                                    .iter()
468                                    .find(|(n, _)| n == "value")
469                                    .map(|(_, v)| decoded_to_scalar_value(v))
470                                    .unwrap_or(ScalarValue::I32(0));
471                                let descriptor = fs
472                                    .iter()
473                                    .find(|(n, _)| n == "descriptor")
474                                    .and_then(|(_, v)| decoded_to_string(v))
475                                    .unwrap_or_default();
476                                let source_type = fs
477                                    .iter()
478                                    .find(|(n, _)| n == "sourceType")
479                                    .and_then(|(_, v)| decoded_to_i32(v))
480                                    .unwrap_or(0);
481                                let source = fs
482                                    .iter()
483                                    .find(|(n, _)| n == "source")
484                                    .and_then(|(_, v)| decoded_to_string(v))
485                                    .unwrap_or_default();
486                                Some(NtAttribute {
487                                    name: attr_name,
488                                    value: attr_value,
489                                    descriptor,
490                                    source_type,
491                                    source,
492                                })
493                            } else {
494                                None
495                            }
496                        })
497                        .collect();
498                    if !attrs.is_empty() && nt.attribute != attrs {
499                        nt.attribute = attrs;
500                        changed = true;
501                    }
502                }
503            }
504            _ => {}
505        }
506    }
507    changed
508}
509
510/// What an accepted PUT did to a record.
511#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
512pub struct PutOutcome {
513    /// Any of value/alarm/display/control actually changed.
514    pub value_changed: bool,
515    /// The PUT carried a usable `timeStamp`, so the record kept it instead of
516    /// being stamped with server time.
517    pub client_stamped: bool,
518}
519
520/// Pull a usable `timeStamp` out of a decoded PUT body.
521///
522/// Returns `None` when the field is absent or decodes to the epoch-0 default:
523/// that is the untouched value a client sends when it does not care about the
524/// timestamp, never a real acquisition time.
525fn client_timestamp(value: &DecodedValue) -> Option<NtTimeStamp> {
526    let DecodedValue::Structure(fields) = value else {
527        return None;
528    };
529    let (_, field) = fields.iter().find(|(name, _)| name == "timeStamp")?;
530    let ts = decode_nt_timestamp(field)?;
531    (ts != NtTimeStamp::default()).then_some(ts)
532}
533
534/// Pull a usable `dataTimeStamp` out of a decoded PUT body — the NdArray
535/// acquisition time, distinct from the relay `timeStamp`. Same absent/default
536/// handling as [`client_timestamp`].
537fn client_data_timestamp(value: &DecodedValue) -> Option<NtTimeStamp> {
538    let DecodedValue::Structure(fields) = value else {
539        return None;
540    };
541    let (_, field) = fields.iter().find(|(name, _)| name == "dataTimeStamp")?;
542    let ts = decode_nt_timestamp(field)?;
543    (ts != NtTimeStamp::default()).then_some(ts)
544}
545
546impl RecordInstance {
547    /// Apply a decoded client PUT to this record.
548    ///
549    /// The record is always restamped: with the client's `timeStamp` when the
550    /// PUT carried a non-default one (so a gateway can forward the
551    /// originating acquisition time), otherwise with server time. EPICS Base
552    /// advances TIME on every record process, so an accepted PUT that happens
553    /// not to change the value still moves the timestamp.
554    pub fn apply_put(&mut self, value: &DecodedValue, compute_alarms: bool) -> PutOutcome {
555        // A bare scalar arrives unwrapped; treat it as `{value: <scalar>}` so
556        // the field walk below is the only code path.
557        let wrapped;
558        let value = match value {
559            DecodedValue::Structure(_) => value,
560            other => {
561                wrapped = DecodedValue::Structure(vec![("value".to_string(), other.clone())]);
562                &wrapped
563            }
564        };
565
566        let value_changed = self.apply_put_fields(value, compute_alarms);
567
568        let client_ts = client_timestamp(value);
569        let client_stamped = client_ts.is_some();
570        self.set_time_stamp(client_ts.unwrap_or_else(now_nt_timestamp));
571
572        // NtNdArray's `dataTimeStamp` is the acquisition time and can be
573        // supplied independently of `timeStamp` (the relay time) — e.g. a
574        // gateway forwarding {value, timeStamp: T_relay, dataTimeStamp:
575        // T_acquire}. `set_time_stamp` just set both fields from the chosen
576        // `timeStamp`; override `data_time_stamp` here if the client sent its
577        // own.
578        if let RecordData::NtNdArray { nt, .. } = &mut self.data {
579            if let Some(data_ts) = client_data_timestamp(value) {
580                nt.data_time_stamp = data_ts;
581            }
582        }
583
584        PutOutcome {
585            value_changed,
586            client_stamped,
587        }
588    }
589
590    /// Apply the data-carrying fields of a PUT body. `value` is guaranteed to
591    /// be a `Structure` by the caller.
592    fn apply_put_fields(&mut self, value: &DecodedValue, compute_alarms: bool) -> bool {
593        let DecodedValue::Structure(fields) = value else {
594            return false;
595        };
596
597        match &mut self.data {
598            RecordData::Ai { nt, .. }
599            | RecordData::Ao { nt, .. }
600            | RecordData::Bi { nt, .. }
601            | RecordData::Bo { nt, .. }
602            | RecordData::StringIn { nt, .. }
603            | RecordData::StringOut { nt, .. } => {
604                // `apply_value_update` always returns `true` on a successful
605                // decode, even when the decoded value equals the current one
606                // (it has no equality check of its own). Compare the scalar
607                // before/after instead of trusting that return, so a PUT that
608                // re-sends the current value is correctly reported as
609                // unchanged while still being restamped by the caller.
610                let before = nt.value.clone();
611                let mut changed = false;
612                for (name, val) in fields {
613                    match name.as_str() {
614                        "value" => {
615                            apply_value_update(nt, val, compute_alarms);
616                        }
617                        "alarm" => changed |= apply_alarm_update(nt, val),
618                        "display" => changed |= apply_display_update(nt, val),
619                        "control" => changed |= apply_control_update(nt, val),
620                        _ => {}
621                    }
622                }
623                changed || nt.value != before
624            }
625            RecordData::Waveform { nt, nord, .. }
626            | RecordData::Aai { nt, nord, .. }
627            | RecordData::Aao { nt, nord, .. }
628            | RecordData::SubArray { nt, nord, .. } => apply_scalar_array_put(nt, nord, value),
629            RecordData::NtTable { nt, .. } => apply_table_put(nt, value),
630            RecordData::NtNdArray { nt, .. } => apply_ndarray_put(nt, value),
631            RecordData::NtEnum { nt, .. } => {
632                // Accept index updates for NtEnum PVs. Known gap #1: a wire
633                // PUT delivers `value` as a sub-structure, which no arm here
634                // matches — deliberately left alone, see known-gaps.md.
635                let mut changed = false;
636                for (name, val) in fields {
637                    if name != "value" {
638                        continue;
639                    }
640                    let idx = match val {
641                        DecodedValue::Int32(v) => Some(*v),
642                        DecodedValue::Int64(v) => Some(*v as i32),
643                        DecodedValue::Int16(v) => Some(*v as i32),
644                        DecodedValue::Int8(v) => Some(*v as i32),
645                        DecodedValue::Float64(v) => Some(*v as i32),
646                        _ => None,
647                    };
648                    if let Some(idx) = idx {
649                        if idx < 0 || (idx as usize) >= nt.choices.len() {
650                            // out-of-range index — reject, keep value
651                        } else if nt.index != idx {
652                            nt.index = idx;
653                            changed = true;
654                        }
655                    }
656                }
657                changed
658            }
659            RecordData::Generic { .. } => false,
660        }
661    }
662}
663
664#[cfg(test)]
665mod tests {
666    use super::*;
667    use crate::types::{DbCommonState, OutputMode, RecordData, RecordInstance, RecordType};
668    use spvirit_types::{NdCodec, NdDimension, NtEnum, NtNdArray, NtScalarArray, ScalarArrayValue};
669    use std::collections::HashMap;
670
671    const OLD: NtTimeStamp = NtTimeStamp {
672        seconds_past_epoch: 1_000,
673        nanoseconds: 0,
674        user_tag: 0,
675    };
676
677    fn ai_record(val: f64) -> RecordInstance {
678        let mut nt = NtScalar::from_value(ScalarValue::F64(val));
679        nt.time_stamp = Some(OLD);
680        RecordInstance {
681            name: "T".to_string(),
682            record_type: RecordType::Ai,
683            common: DbCommonState::default(),
684            data: RecordData::Ai {
685                nt,
686                inp: None,
687                siml: None,
688                siol: None,
689                simm: false,
690            },
691            raw_fields: HashMap::new(),
692        }
693    }
694
695    fn ai_record_with_alarm_limits(
696        val: f64,
697        low: f64,
698        high: f64,
699        lolo: f64,
700        hihi: f64,
701    ) -> RecordInstance {
702        let mut nt = NtScalar::from_value(ScalarValue::F64(val)).with_alarm_limits(
703            Some(low),
704            Some(high),
705            Some(lolo),
706            Some(hihi),
707        );
708        nt.time_stamp = Some(OLD);
709        RecordInstance {
710            name: "ALM".to_string(),
711            record_type: RecordType::Ai,
712            common: DbCommonState::default(),
713            data: RecordData::Ai {
714                nt,
715                inp: None,
716                siml: None,
717                siol: None,
718                simm: false,
719            },
720            raw_fields: HashMap::new(),
721        }
722    }
723
724    fn alarm_of(rec: &RecordInstance) -> (i32, String) {
725        let RecordData::Ai { nt, .. } = &rec.data else {
726            panic!("expected Ai");
727        };
728        (nt.alarm_severity, nt.alarm_message.clone())
729    }
730
731    #[test]
732    fn put_with_compute_alarms_true_sets_hihi_severity() {
733        let mut rec = ai_record_with_alarm_limits(5.0, 0.0, 8.0, -5.0, 15.0);
734        let outcome = rec.apply_put(&put_value(20.0), true);
735
736        assert!(outcome.value_changed);
737        let (severity, message) = alarm_of(&rec);
738        assert_eq!(severity, 2);
739        assert_eq!(message, "HIHI");
740    }
741
742    #[test]
743    fn put_with_compute_alarms_true_sets_high_severity() {
744        let mut rec = ai_record_with_alarm_limits(5.0, 0.0, 8.0, -5.0, 15.0);
745        let outcome = rec.apply_put(&put_value(10.0), true);
746
747        assert!(outcome.value_changed);
748        let (severity, message) = alarm_of(&rec);
749        assert_eq!(severity, 1);
750        assert_eq!(message, "HIGH");
751    }
752
753    #[test]
754    fn put_with_compute_alarms_true_clears_alarm_within_normal_range() {
755        let mut rec = ai_record_with_alarm_limits(20.0, 0.0, 8.0, -5.0, 15.0);
756        // Prime a HIHI alarm, then PUT a value back in range.
757        rec.apply_put(&put_value(20.0), true);
758        assert_eq!(alarm_of(&rec).0, 2);
759
760        let outcome = rec.apply_put(&put_value(4.0), true);
761
762        assert!(outcome.value_changed);
763        let (severity, message) = alarm_of(&rec);
764        assert_eq!(severity, 0);
765        assert_eq!(message, "");
766    }
767
768    #[test]
769    fn put_with_compute_alarms_false_does_not_update_alarm() {
770        let mut rec = ai_record_with_alarm_limits(5.0, 0.0, 8.0, -5.0, 15.0);
771        let outcome = rec.apply_put(&put_value(20.0), false);
772
773        assert!(outcome.value_changed);
774        assert_eq!(alarm_of(&rec).0, 0);
775    }
776
777    fn stamp_of(rec: &RecordInstance) -> NtTimeStamp {
778        let RecordData::Ai { nt, .. } = &rec.data else {
779            panic!("expected Ai");
780        };
781        nt.time_stamp.clone().expect("stamped")
782    }
783
784    fn ts_field(seconds: i64, nanos: i32) -> DecodedValue {
785        DecodedValue::Structure(vec![
786            ("secondsPastEpoch".to_string(), DecodedValue::Int64(seconds)),
787            ("nanoseconds".to_string(), DecodedValue::Int32(nanos)),
788            ("userTag".to_string(), DecodedValue::Int32(0)),
789        ])
790    }
791
792    fn put_value(v: f64) -> DecodedValue {
793        DecodedValue::Structure(vec![("value".to_string(), DecodedValue::Float64(v))])
794    }
795
796    #[test]
797    fn put_without_timestamp_stamps_server_time() {
798        let mut rec = ai_record(1.0);
799        let outcome = rec.apply_put(&put_value(2.0), false);
800
801        assert!(outcome.value_changed);
802        assert!(!outcome.client_stamped);
803        assert!(stamp_of(&rec).seconds_past_epoch > OLD.seconds_past_epoch);
804    }
805
806    #[test]
807    fn put_with_client_timestamp_keeps_it_verbatim() {
808        let mut rec = ai_record(1.0);
809        let body = DecodedValue::Structure(vec![
810            ("value".to_string(), DecodedValue::Float64(2.0)),
811            ("timeStamp".to_string(), ts_field(5_000, 42)),
812        ]);
813        let outcome = rec.apply_put(&body, false);
814
815        assert!(outcome.client_stamped);
816        assert_eq!(
817            stamp_of(&rec),
818            NtTimeStamp {
819                seconds_past_epoch: 5_000,
820                nanoseconds: 42,
821                user_tag: 0,
822            }
823        );
824    }
825
826    #[test]
827    fn epoch_zero_client_timestamp_falls_back_to_server_time() {
828        let mut rec = ai_record(1.0);
829        let body = DecodedValue::Structure(vec![
830            ("value".to_string(), DecodedValue::Float64(2.0)),
831            ("timeStamp".to_string(), ts_field(0, 0)),
832        ]);
833        let outcome = rec.apply_put(&body, false);
834
835        assert!(!outcome.client_stamped);
836        assert!(stamp_of(&rec).seconds_past_epoch > OLD.seconds_past_epoch);
837    }
838
839    #[test]
840    fn unchanged_value_still_restamps() {
841        let mut rec = ai_record(1.0);
842        let outcome = rec.apply_put(&put_value(1.0), false);
843
844        assert!(!outcome.value_changed);
845        assert!(stamp_of(&rec).seconds_past_epoch > OLD.seconds_past_epoch);
846    }
847
848    #[test]
849    fn bare_scalar_body_is_wrapped_and_stamped() {
850        let mut rec = ai_record(1.0);
851        let outcome = rec.apply_put(&DecodedValue::Float64(3.0), false);
852
853        assert!(outcome.value_changed);
854        assert!(stamp_of(&rec).seconds_past_epoch > OLD.seconds_past_epoch);
855        let RecordData::Ai { nt, .. } = &rec.data else {
856            panic!("expected Ai");
857        };
858        assert_eq!(nt.value, ScalarValue::F64(3.0));
859    }
860
861    #[test]
862    fn unrecognised_fields_still_restamp() {
863        let mut rec = ai_record(1.0);
864        let body =
865            DecodedValue::Structure(vec![("nosuchfield".to_string(), DecodedValue::Int32(1))]);
866        let outcome = rec.apply_put(&body, false);
867
868        assert!(!outcome.value_changed);
869        assert!(stamp_of(&rec).seconds_past_epoch > OLD.seconds_past_epoch);
870    }
871
872    fn waveform_record(vals: Vec<f64>) -> RecordInstance {
873        let mut nt = NtScalarArray::from_value(ScalarArrayValue::F64(vals.clone()));
874        nt.time_stamp = OLD;
875        RecordInstance {
876            name: "W".to_string(),
877            record_type: RecordType::Waveform,
878            common: DbCommonState::default(),
879            data: RecordData::Waveform {
880                nt,
881                nord: vals.len(),
882                nelm: 16,
883                inp: None,
884                ftvl: "DOUBLE".to_string(),
885            },
886            raw_fields: HashMap::new(),
887        }
888    }
889
890    #[test]
891    fn array_put_restamps() {
892        let mut rec = waveform_record(vec![1.0, 2.0]);
893        let body = DecodedValue::Structure(vec![(
894            "value".to_string(),
895            DecodedValue::Array(vec![DecodedValue::Float64(3.0), DecodedValue::Float64(4.0)]),
896        )]);
897        let outcome = rec.apply_put(&body, false);
898
899        assert!(outcome.value_changed);
900        let RecordData::Waveform { nt, .. } = &rec.data else {
901            panic!("expected Waveform");
902        };
903        assert!(nt.time_stamp.seconds_past_epoch > OLD.seconds_past_epoch);
904    }
905
906    #[test]
907    fn array_put_with_unchanged_value_still_restamps() {
908        let mut rec = waveform_record(vec![1.0, 2.0]);
909        let body = DecodedValue::Structure(vec![(
910            "value".to_string(),
911            DecodedValue::Array(vec![DecodedValue::Float64(1.0), DecodedValue::Float64(2.0)]),
912        )]);
913        let outcome = rec.apply_put(&body, false);
914
915        assert!(!outcome.value_changed);
916        let RecordData::Waveform { nt, .. } = &rec.data else {
917            panic!("expected Waveform");
918        };
919        assert!(nt.time_stamp.seconds_past_epoch > OLD.seconds_past_epoch);
920    }
921
922    #[test]
923    fn enum_put_restamps() {
924        let mut nt = NtEnum::new(0, vec!["A".to_string(), "B".to_string()]);
925        nt.time_stamp = OLD;
926        let mut rec = RecordInstance {
927            name: "E".to_string(),
928            record_type: RecordType::Mbbo,
929            common: DbCommonState::default(),
930            data: RecordData::NtEnum {
931                nt,
932                inp: None,
933                out: None,
934                omsl: OutputMode::Supervisory,
935            },
936            raw_fields: HashMap::new(),
937        };
938        let outcome = rec.apply_put(&DecodedValue::Int32(1), false);
939
940        assert!(outcome.value_changed);
941        let RecordData::NtEnum { nt, .. } = &rec.data else {
942            panic!("expected NtEnum");
943        };
944        assert!(nt.time_stamp.seconds_past_epoch > OLD.seconds_past_epoch);
945    }
946
947    fn ndarray_record() -> RecordInstance {
948        let nt = NtNdArray {
949            value: ScalarArrayValue::U8(vec![1, 2, 3, 4]),
950            codec: NdCodec {
951                name: "none".to_string(),
952                parameters: HashMap::new(),
953            },
954            compressed_size: 4,
955            uncompressed_size: 4,
956            dimension: vec![NdDimension {
957                size: 4,
958                offset: 0,
959                full_size: 4,
960                binning: 1,
961                reverse: false,
962            }],
963            unique_id: 1,
964            data_time_stamp: OLD,
965            attribute: vec![],
966            descriptor: Some("ndarray".to_string()),
967            alarm: None,
968            time_stamp: Some(OLD),
969            display: None,
970        };
971        RecordInstance {
972            name: "N".to_string(),
973            record_type: RecordType::NtNdArray,
974            common: DbCommonState::default(),
975            data: RecordData::NtNdArray {
976                nt,
977                inp: None,
978                out: None,
979                omsl: OutputMode::Supervisory,
980            },
981            raw_fields: HashMap::new(),
982        }
983    }
984
985    #[test]
986    fn ndarray_put_honours_client_supplied_data_time_stamp() {
987        // A gateway forwards {value, timeStamp: T_relay, dataTimeStamp:
988        // T_acquire}: the acquisition time must survive, not be overwritten
989        // by the relay's timeStamp (or by now()).
990        let mut rec = ndarray_record();
991        let body = DecodedValue::Structure(vec![
992            (
993                "value".to_string(),
994                DecodedValue::Array(vec![
995                    DecodedValue::UInt8(5),
996                    DecodedValue::UInt8(6),
997                    DecodedValue::UInt8(7),
998                    DecodedValue::UInt8(8),
999                ]),
1000            ),
1001            ("timeStamp".to_string(), ts_field(5_000, 42)),
1002            ("dataTimeStamp".to_string(), ts_field(9_000, 7)),
1003        ]);
1004        let outcome = rec.apply_put(&body, false);
1005
1006        assert!(outcome.value_changed);
1007        assert!(outcome.client_stamped);
1008        let RecordData::NtNdArray { nt, .. } = &rec.data else {
1009            panic!("expected NtNdArray");
1010        };
1011        assert_eq!(
1012            nt.time_stamp,
1013            Some(NtTimeStamp {
1014                seconds_past_epoch: 5_000,
1015                nanoseconds: 42,
1016                user_tag: 0,
1017            }),
1018            "timeStamp should be the relay's stamp"
1019        );
1020        assert_eq!(
1021            nt.data_time_stamp,
1022            NtTimeStamp {
1023                seconds_past_epoch: 9_000,
1024                nanoseconds: 7,
1025                user_tag: 0,
1026            },
1027            "dataTimeStamp should be the acquisition time, not overwritten by timeStamp"
1028        );
1029    }
1030
1031    #[test]
1032    fn ndarray_put_with_only_timestamp_fields_does_not_report_value_changed() {
1033        // A client-stamped PUT that touches only timeStamp/dataTimeStamp must
1034        // not be reported as a value change: apply_put owns stamping now, so
1035        // the field walk must not double-count timeStamp/dataTimeStamp as
1036        // data fields (spec rule 5: evaluate_links runs only when the value
1037        // actually changed).
1038        let mut rec = ndarray_record();
1039        let body = DecodedValue::Structure(vec![
1040            ("timeStamp".to_string(), ts_field(5_000, 42)),
1041            ("dataTimeStamp".to_string(), ts_field(9_000, 7)),
1042        ]);
1043        let outcome = rec.apply_put(&body, false);
1044
1045        assert!(!outcome.value_changed);
1046        assert!(outcome.client_stamped);
1047    }
1048
1049    #[test]
1050    fn table_put_with_only_timestamp_field_does_not_report_value_changed() {
1051        let mut nt = NtTable {
1052            labels: vec!["a".to_string()],
1053            columns: vec![spvirit_types::NtTableColumn {
1054                name: "a".to_string(),
1055                values: ScalarArrayValue::F64(vec![1.0]),
1056            }],
1057            descriptor: None,
1058            alarm: None,
1059            time_stamp: Some(OLD),
1060        };
1061        nt.time_stamp = Some(OLD);
1062        let mut rec = RecordInstance {
1063            name: "TBL".to_string(),
1064            record_type: RecordType::NtTable,
1065            common: DbCommonState::default(),
1066            data: RecordData::NtTable {
1067                nt,
1068                inp: None,
1069                out: None,
1070                omsl: OutputMode::Supervisory,
1071            },
1072            raw_fields: HashMap::new(),
1073        };
1074        let body = DecodedValue::Structure(vec![("timeStamp".to_string(), ts_field(5_000, 42))]);
1075        let outcome = rec.apply_put(&body, false);
1076
1077        assert!(!outcome.value_changed);
1078        assert!(outcome.client_stamped);
1079    }
1080
1081    #[test]
1082    fn ndarray_put_restamps_both_time_stamp_and_data_time_stamp() {
1083        let mut rec = ndarray_record();
1084        let body = DecodedValue::Structure(vec![(
1085            "value".to_string(),
1086            DecodedValue::Array(vec![
1087                DecodedValue::UInt8(5),
1088                DecodedValue::UInt8(6),
1089                DecodedValue::UInt8(7),
1090                DecodedValue::UInt8(8),
1091            ]),
1092        )]);
1093        let outcome = rec.apply_put(&body, false);
1094
1095        assert!(outcome.value_changed);
1096        let RecordData::NtNdArray { nt, .. } = &rec.data else {
1097            panic!("expected NtNdArray");
1098        };
1099        assert!(
1100            nt.time_stamp.as_ref().expect("stamped").seconds_past_epoch > OLD.seconds_past_epoch
1101        );
1102        assert!(nt.data_time_stamp.seconds_past_epoch > OLD.seconds_past_epoch);
1103    }
1104}