Skip to main content

spvirit_server/
simple_store.rs

1//! A simple in-memory [`Source`] implementation backed by `RecordInstance`.
2//!
3//! Used by [`PvaServer`](crate::pva_server::PvaServer) to serve PVs without
4//! requiring an external database.
5
6use std::collections::{HashMap, HashSet};
7use std::sync::Arc;
8
9use tokio::sync::{RwLock, mpsc};
10use tracing::debug;
11
12use std::future::Future;
13use std::pin::Pin;
14
15use spvirit_codec::spvd_decode::{DecodedValue, FieldDesc, FieldType, StructureDesc, TypeCode};
16use spvirit_types::{NtPayload, ScalarArrayValue, ScalarValue};
17
18use crate::monitor::MonitorRegistry;
19use crate::pvstore::{PvInfo, Source};
20use crate::types::{RecordData, RecordInstance};
21
22/// Callback invoked after a PUT value is applied to a record.
23pub type OnPutCallback = Arc<dyn Fn(&str, &DecodedValue) + Send + Sync>;
24
25/// Callback invoked by the scan scheduler; returns the new value for the PV.
26pub type ScanCallback = Arc<dyn Fn(&str) -> ScalarValue + Send + Sync>;
27
28/// Callback that computes a derived PV value from its input values.
29pub type LinkCallback = Arc<dyn Fn(&[ScalarValue]) -> ScalarValue + Send + Sync>;
30
31/// Pre-apply PUT validator: `Err(msg)` rejects the PUT (error on the wire).
32pub(crate) type PutValidator = Arc<dyn Fn(&str, &DecodedValue) -> Result<(), String> + Send + Sync>;
33
34/// A link from one or more input PVs to a computed output PV.
35pub(crate) struct LinkDef {
36    pub output: String,
37    pub inputs: Vec<String>,
38    pub compute: LinkCallback,
39}
40
41struct PvEntry {
42    record: RecordInstance,
43    subscribers: Vec<mpsc::Sender<NtPayload>>,
44    /// Value carried by the last update posted to subscribers/monitors —
45    /// the reference point for the MDEL monitor deadband. `None` until the
46    /// first post (so the first change always posts).
47    last_posted: Option<f64>,
48}
49
50/// A simple in-memory PV store.
51pub struct SimplePvStore {
52    pvs: RwLock<HashMap<String, PvEntry>>,
53    on_put: HashMap<String, OnPutCallback>,
54    links: Vec<LinkDef>,
55    compute_alarms: bool,
56    registry: RwLock<Option<Arc<MonitorRegistry>>>,
57    validators: RwLock<HashMap<String, PutValidator>>,
58}
59
60impl SimplePvStore {
61    pub(crate) fn new(
62        records: HashMap<String, RecordInstance>,
63        on_put: HashMap<String, OnPutCallback>,
64        links: Vec<LinkDef>,
65        compute_alarms: bool,
66    ) -> Self {
67        let pvs = records
68            .into_iter()
69            .map(|(name, mut record)| {
70                record.stamp_missing_timestamps();
71                let last_posted = initial_posted(&record);
72                (
73                    name,
74                    PvEntry {
75                        record,
76                        subscribers: Vec::new(),
77                        last_posted,
78                    },
79                )
80            })
81            .collect();
82        Self {
83            pvs: RwLock::new(pvs),
84            on_put,
85            links,
86            compute_alarms,
87            registry: RwLock::new(None),
88            validators: RwLock::new(HashMap::new()),
89        }
90    }
91
92    /// Attach the [`MonitorRegistry`] so that `set_value` can push updates
93    /// to PVAccess monitor clients.  Called automatically by [`PvaServer::run`].
94    pub async fn set_registry(&self, registry: Arc<MonitorRegistry>) {
95        *self.registry.write().await = Some(registry);
96    }
97
98    /// Register a pre-apply PUT validator for a PV.
99    pub(crate) async fn set_validator(&self, name: String, v: PutValidator) {
100        self.validators.write().await.insert(name, v);
101    }
102
103    /// Insert or replace a PV record at runtime.
104    pub async fn insert(&self, name: String, mut record: RecordInstance) {
105        record.stamp_missing_timestamps();
106        let mut pvs = self.pvs.write().await;
107        let last_posted = initial_posted(&record);
108        pvs.insert(
109            name,
110            PvEntry {
111                record,
112                subscribers: Vec::new(),
113                last_posted,
114            },
115        );
116    }
117
118    /// Remove a PV record at runtime. Returns `true` if a record was removed,
119    /// `false` if no record with that name existed. Dropping the entry drops
120    /// its subscriber senders, which closes any active monitor channels for
121    /// that PV.
122    pub async fn remove(&self, name: &str) -> bool {
123        self.pvs.write().await.remove(name).is_some()
124    }
125
126    /// Read the current [`ScalarValue`] of a PV.
127    pub async fn get_value(&self, name: &str) -> Option<ScalarValue> {
128        let pvs = self.pvs.read().await;
129        pvs.get(name).map(|e| e.record.current_value())
130    }
131
132    /// Read a clone of the full [`RecordInstance`] backing a PV.
133    ///
134    /// Used by [`RecordFieldSource`](crate::record_fields::RecordFieldSource)
135    /// to serve `<name>.<FIELD>` channels.
136    pub async fn get_record(&self, name: &str) -> Option<RecordInstance> {
137        let pvs = self.pvs.read().await;
138        pvs.get(name).map(|e| e.record.clone())
139    }
140
141    /// Read the full [`NtPayload`] of a PV.
142    pub async fn get_nt(&self, name: &str) -> Option<NtPayload> {
143        let pvs = self.pvs.read().await;
144        pvs.get(name).map(|e| e.record.to_ntpayload())
145    }
146
147    /// Write a [`ScalarValue`] to a PV (bypasses on_put).
148    pub async fn set_value(&self, name: &str, value: ScalarValue) -> bool {
149        if self.set_value_inner(name, value).await {
150            self.evaluate_links(name).await;
151            true
152        } else {
153            false
154        }
155    }
156
157    /// Write a [`ScalarArrayValue`] to an array PV (bypasses on_put).
158    pub async fn set_array_value(&self, name: &str, value: ScalarArrayValue) -> bool {
159        if self.set_array_value_inner(name, value).await {
160            self.evaluate_links(name).await;
161            true
162        } else {
163            false
164        }
165    }
166
167    /// Write a full [`NtPayload`] to a PV (bypasses on_put).
168    pub async fn put_nt(&self, name: &str, payload: NtPayload) -> bool {
169        if self.put_nt_inner(name, payload).await {
170            self.evaluate_links(name).await;
171            true
172        } else {
173            false
174        }
175    }
176
177    /// Explicitly set a record's alarm fields (severity/status/message),
178    /// independent of its value. Unlike [`SimplePvStore::set_value`], alarm
179    /// transitions always post — there is no MDEL deadband gating and no
180    /// link evaluation (alarm changes don't propagate links). Returns
181    /// `false` if the alarm state is unchanged (idempotent) or the record
182    /// doesn't support alarm fields (`Table`/`NdArray`/`Generic`) or doesn't
183    /// exist.
184    pub async fn set_alarm(&self, name: &str, severity: i32, status: i32, message: &str) -> bool {
185        let payload = {
186            let mut pvs = self.pvs.write().await;
187            let Some(entry) = pvs.get_mut(name) else {
188                return false;
189            };
190            let alarm = if let Some(nt) = entry.record.nt_scalar_mut() {
191                (
192                    &mut nt.alarm_severity,
193                    &mut nt.alarm_status,
194                    &mut nt.alarm_message,
195                )
196            } else {
197                match &mut entry.record.data {
198                    RecordData::NtEnum { nt, .. } => (
199                        &mut nt.alarm.severity,
200                        &mut nt.alarm.status,
201                        &mut nt.alarm.message,
202                    ),
203                    RecordData::Waveform { nt, .. }
204                    | RecordData::Aai { nt, .. }
205                    | RecordData::Aao { nt, .. }
206                    | RecordData::SubArray { nt, .. } => (
207                        &mut nt.alarm.severity,
208                        &mut nt.alarm.status,
209                        &mut nt.alarm.message,
210                    ),
211                    _ => return false,
212                }
213            };
214            let (sev, sta, msg) = alarm;
215            let changed = *sev != severity || *sta != status || msg.as_str() != message;
216            if !changed {
217                return false;
218            }
219            *sev = severity;
220            *sta = status;
221            *msg = message.to_string();
222
223            let payload = entry.record.to_ntpayload();
224            entry
225                .subscribers
226                .retain(|tx| tx.try_send(payload.clone()).is_ok());
227            payload
228        };
229
230        let reg = self.registry.read().await;
231        if let Some(registry) = reg.as_ref() {
232            registry.notify_monitors(name, &payload).await;
233        }
234        true
235    }
236
237    /// Core write logic — updates the value, notifies subscribers and monitors,
238    /// but does **not** trigger link evaluation (to avoid recursion).
239    async fn set_value_inner(&self, name: &str, value: ScalarValue) -> bool {
240        let payload = {
241            let mut pvs = self.pvs.write().await;
242            if let Some(entry) = pvs.get_mut(name) {
243                let prev_severity = entry.record.to_ntscalar().alarm_severity;
244                let changed = entry.record.set_scalar_value(value, self.compute_alarms);
245                if changed {
246                    if !should_post_update(entry, prev_severity) {
247                        // Changed, but within the MDEL monitor deadband:
248                        // the record holds the new value (GETs see it), just
249                        // no update is posted to subscribers/monitors.
250                        return true;
251                    }
252                    let payload = entry.record.to_ntpayload();
253                    entry
254                        .subscribers
255                        .retain(|tx| tx.try_send(payload.clone()).is_ok());
256                    Some(payload)
257                } else {
258                    None
259                }
260            } else {
261                return false;
262            }
263        };
264
265        if let Some(payload) = payload {
266            // Notify PVAccess monitor clients (if the registry is attached).
267            let reg = self.registry.read().await;
268            if let Some(registry) = reg.as_ref() {
269                registry.notify_monitors(name, &payload).await;
270            }
271            true
272        } else {
273            false
274        }
275    }
276
277    /// Core array write logic — updates the value, notifies subscribers and monitors,
278    /// but does **not** trigger link evaluation (to avoid recursion).
279    async fn set_array_value_inner(&self, name: &str, value: ScalarArrayValue) -> bool {
280        let payload = {
281            let mut pvs = self.pvs.write().await;
282            if let Some(entry) = pvs.get_mut(name) {
283                let changed = entry.record.set_array_value(value);
284                if changed {
285                    let payload = entry.record.to_ntpayload();
286                    entry
287                        .subscribers
288                        .retain(|tx| tx.try_send(payload.clone()).is_ok());
289                    Some(payload)
290                } else {
291                    None
292                }
293            } else {
294                return false;
295            }
296        };
297
298        if let Some(payload) = payload {
299            // Notify PVAccess monitor clients (if the registry is attached).
300            let reg = self.registry.read().await;
301            if let Some(registry) = reg.as_ref() {
302                registry.notify_monitors(name, &payload).await;
303            }
304            true
305        } else {
306            false
307        }
308    }
309
310    /// Core NtPayload write logic — updates the payload, notifies subscribers
311    /// and monitors, but does **not** trigger link evaluation.
312    async fn put_nt_inner(&self, name: &str, payload: NtPayload) -> bool {
313        let payload = {
314            let mut pvs = self.pvs.write().await;
315            if let Some(entry) = pvs.get_mut(name) {
316                let changed = entry.record.set_nt_payload(payload);
317                if changed {
318                    let payload = entry.record.to_ntpayload();
319                    entry
320                        .subscribers
321                        .retain(|tx| tx.try_send(payload.clone()).is_ok());
322                    Some(payload)
323                } else {
324                    None
325                }
326            } else {
327                return false;
328            }
329        };
330
331        if let Some(payload) = payload {
332            // Notify PVAccess monitor clients (if the registry is attached).
333            let reg = self.registry.read().await;
334            if let Some(registry) = reg.as_ref() {
335                registry.notify_monitors(name, &payload).await;
336            }
337            true
338        } else {
339            false
340        }
341    }
342
343    /// Walk every link whose inputs include `changed_pv`, compute the output,
344    /// and propagate (BFS with cycle detection).
345    async fn evaluate_links(&self, changed_pv: &str) {
346        if self.links.is_empty() {
347            return;
348        }
349        let mut queue = vec![changed_pv.to_string()];
350        let mut visited = HashSet::new();
351
352        while let Some(pv) = queue.pop() {
353            if !visited.insert(pv.clone()) {
354                debug!("Circular link detected for PV '{}', skipping", pv);
355                continue;
356            }
357            for link in &self.links {
358                if !link.inputs.iter().any(|i| i == &pv) {
359                    continue;
360                }
361                // Gather current values of all inputs.
362                let values = {
363                    let pvs = self.pvs.read().await;
364                    link.inputs
365                        .iter()
366                        .map(|n| {
367                            pvs.get(n)
368                                .map(|e| e.record.current_value())
369                                .unwrap_or(ScalarValue::F64(0.0))
370                        })
371                        .collect::<Vec<_>>()
372                };
373                let new_val = (link.compute)(&values);
374                if self.set_value_inner(&link.output, new_val).await {
375                    queue.push(link.output.clone());
376                }
377            }
378        }
379    }
380
381    /// List all PV names.
382    pub async fn pv_names(&self) -> Vec<String> {
383        let pvs = self.pvs.read().await;
384        pvs.keys().cloned().collect()
385    }
386}
387
388impl Source for SimplePvStore {
389    fn claim(&self, name: &str) -> Pin<Box<dyn Future<Output = Option<PvInfo>> + Send + '_>> {
390        let name = name.to_string();
391        Box::pin(async move {
392            let pvs = self.pvs.read().await;
393            let entry = pvs.get(&name)?;
394            let descriptor = descriptor_for_payload(&entry.record.to_ntpayload());
395            Some(PvInfo {
396                descriptor,
397                writable: entry.record.writable(),
398            })
399        })
400    }
401
402    fn get(&self, name: &str) -> Pin<Box<dyn Future<Output = Option<NtPayload>> + Send + '_>> {
403        let name = name.to_string();
404        Box::pin(async move {
405            let pvs = self.pvs.read().await;
406            pvs.get(&name).map(|e| e.record.to_ntpayload())
407        })
408    }
409
410    fn put(
411        &self,
412        name: &str,
413        value: &DecodedValue,
414    ) -> Pin<Box<dyn Future<Output = Result<Vec<(String, NtPayload)>, String>> + Send + '_>> {
415        let name = name.to_string();
416        let value = value.clone();
417        Box::pin(async move {
418            // Clone the validator out inside a tight scope so the read guard
419            // drops before the user callback runs — otherwise temporary
420            // lifetime extension holds the lock across the call, blocking
421            // concurrent set_validator for the duration of every PUT.
422            let validator = {
423                let guard = self.validators.read().await;
424                guard.get(&name).cloned()
425            };
426            if let Some(v) = validator {
427                v(&name, &value)?;
428            }
429
430            let result = {
431                let mut pvs = self.pvs.write().await;
432                let entry = pvs
433                    .get_mut(&name)
434                    .ok_or_else(|| format!("PV '{}' not found", name))?;
435
436                if !entry.record.writable() {
437                    return Err(format!("PV '{}' is not writable", name));
438                }
439
440                let prev_severity = entry.record.to_ntscalar().alarm_severity;
441                let outcome = entry.record.apply_put(&value, self.compute_alarms);
442
443                // A client-supplied timeStamp is new information even when the
444                // value is identical, so it posts. A server-generated stamp on
445                // an unchanged value updates the record silently — GETs and the
446                // next real post carry it. should_post_update is only consulted
447                // on a value change, so the MDEL reference point is untouched
448                // by timestamp-only updates.
449                let post = if outcome.value_changed {
450                    should_post_update(entry, prev_severity)
451                } else {
452                    outcome.client_stamped
453                };
454
455                if post {
456                    let payload = entry.record.to_ntpayload();
457                    entry
458                        .subscribers
459                        .retain(|tx| tx.try_send(payload.clone()).is_ok());
460                    (Some((name.clone(), payload)), outcome)
461                } else {
462                    (None, outcome)
463                }
464            }; // pvs lock dropped
465            let (result, outcome) = result;
466
467            // EPICS runs the forward link on every record process, so on_put
468            // fires for every accepted PUT — including one that did not change
469            // the value.
470            if let Some(cb) = self.on_put.get(&name) {
471                let cb = cb.clone();
472                let n = name.clone();
473                let v = value.clone();
474                tokio::spawn(async move { cb(&n, &v) });
475            }
476
477            // Links are change-driven here, not post-driven: a PUT suppressed
478            // by the MDEL deadband still propagates, matching set_value.
479            if outcome.value_changed {
480                self.evaluate_links(&name).await;
481            }
482
483            Ok(result.into_iter().collect())
484        })
485    }
486
487    fn subscribe(
488        &self,
489        name: &str,
490    ) -> Pin<Box<dyn Future<Output = Option<mpsc::Receiver<NtPayload>>> + Send + '_>> {
491        let name = name.to_string();
492        Box::pin(async move {
493            let mut pvs = self.pvs.write().await;
494            let entry = pvs.get_mut(&name)?;
495            let (tx, rx) = mpsc::channel(64);
496            entry.subscribers.push(tx);
497            Some(rx)
498        })
499    }
500
501    fn names(&self) -> Pin<Box<dyn Future<Output = Vec<String>> + Send + '_>> {
502        Box::pin(async move {
503            let pvs = self.pvs.read().await;
504            pvs.keys().cloned().collect()
505        })
506    }
507}
508
509// ── Helpers ──────────────────────────────────────────────────────────────
510
511/// Numeric view of a scalar value, for deadband arithmetic.
512fn scalar_as_f64(v: &ScalarValue) -> Option<f64> {
513    Some(match v {
514        ScalarValue::I8(x) => *x as f64,
515        ScalarValue::I16(x) => *x as f64,
516        ScalarValue::I32(x) => *x as f64,
517        ScalarValue::I64(x) => *x as f64,
518        ScalarValue::U8(x) => *x as f64,
519        ScalarValue::U16(x) => *x as f64,
520        ScalarValue::U32(x) => *x as f64,
521        ScalarValue::U64(x) => *x as f64,
522        ScalarValue::F32(x) => *x as f64,
523        ScalarValue::F64(x) => *x,
524        ScalarValue::Bool(_) | ScalarValue::Str(_) => return None,
525    })
526}
527
528/// Initial MDEL deadband reference: the record's starting value (EPICS
529/// initialises MLST from the initial VAL, so the first small change is
530/// already subject to the deadband).
531fn initial_posted(record: &RecordInstance) -> Option<f64> {
532    match record.to_ntpayload() {
533        NtPayload::Scalar(nt) => scalar_as_f64(&nt.value),
534        _ => None,
535    }
536}
537
538/// MDEL monitor-deadband gate, called after a record changed.
539///
540/// Returns `true` when the update must be posted to subscribers/monitors
541/// (and records it as the new deadband reference point). An update is
542/// suppressed only when the record is a numeric scalar with MDEL > 0, the
543/// alarm severity did not change, and the value moved less than MDEL from
544/// the last *posted* value — EPICS monitor-deadband semantics.
545fn should_post_update(entry: &mut PvEntry, prev_severity: i32) -> bool {
546    let new_f = match entry.record.to_ntpayload() {
547        NtPayload::Scalar(nt) => match scalar_as_f64(&nt.value) {
548            Some(f) => f,
549            None => return true,
550        },
551        _ => return true,
552    };
553    let mdel = crate::record_fields::mdel_of(&entry.record);
554    let severity_changed = entry.record.to_ntscalar().alarm_severity != prev_severity;
555    let within_deadband = mdel > 0.0
556        && !severity_changed
557        && entry
558            .last_posted
559            .is_some_and(|last| (new_f - last).abs() < mdel);
560    if within_deadband {
561        return false;
562    }
563    entry.last_posted = Some(new_f);
564    true
565}
566
567// ── NtPayload → StructureDesc ────────────────────────────────────────────
568
569pub fn descriptor_for_payload(payload: &NtPayload) -> StructureDesc {
570    match payload {
571        NtPayload::Scalar(nt) => nt_scalar_desc(&nt.value),
572        NtPayload::ScalarArray(arr) => nt_scalar_array_desc(&arr.value),
573        // Table / NdArray / Enum / Generic: the codec knows how to build the
574        // full structure descriptor. An empty descriptor here makes GET and
575        // MONITOR init hand clients an empty structure, so every read of
576        // these PV types decodes as {}.
577        _ => spvirit_codec::spvd_encode::nt_payload_desc(payload),
578    }
579}
580
581fn value_type_code(sv: &ScalarValue) -> TypeCode {
582    match sv {
583        ScalarValue::Bool(_) => TypeCode::Boolean,
584        ScalarValue::I8(_) => TypeCode::Int8,
585        ScalarValue::I16(_) => TypeCode::Int16,
586        ScalarValue::I32(_) => TypeCode::Int32,
587        ScalarValue::I64(_) => TypeCode::Int64,
588        ScalarValue::U8(_) => TypeCode::UInt8,
589        ScalarValue::U16(_) => TypeCode::UInt16,
590        ScalarValue::U32(_) => TypeCode::UInt32,
591        ScalarValue::U64(_) => TypeCode::UInt64,
592        ScalarValue::F32(_) => TypeCode::Float32,
593        ScalarValue::F64(_) => TypeCode::Float64,
594        ScalarValue::Str(_) => TypeCode::String,
595    }
596}
597
598fn array_type_code(sav: &ScalarArrayValue) -> TypeCode {
599    match sav {
600        ScalarArrayValue::Bool(_) => TypeCode::Boolean,
601        ScalarArrayValue::I8(_) => TypeCode::Int8,
602        ScalarArrayValue::I16(_) => TypeCode::Int16,
603        ScalarArrayValue::I32(_) => TypeCode::Int32,
604        ScalarArrayValue::I64(_) => TypeCode::Int64,
605        ScalarArrayValue::U8(_) => TypeCode::UInt8,
606        ScalarArrayValue::U16(_) => TypeCode::UInt16,
607        ScalarArrayValue::U32(_) => TypeCode::UInt32,
608        ScalarArrayValue::U64(_) => TypeCode::UInt64,
609        ScalarArrayValue::F32(_) => TypeCode::Float32,
610        ScalarArrayValue::F64(_) => TypeCode::Float64,
611        ScalarArrayValue::Str(_) => TypeCode::String,
612    }
613}
614
615fn nt_scalar_desc(sv: &ScalarValue) -> StructureDesc {
616    let tc = value_type_code(sv);
617    StructureDesc {
618        struct_id: Some("epics:nt/NTScalar:1.0".to_string()),
619        fields: vec![
620            FieldDesc {
621                name: "value".to_string(),
622                field_type: FieldType::Scalar(tc),
623            },
624            FieldDesc {
625                name: "alarm".to_string(),
626                field_type: FieldType::Structure(alarm_desc()),
627            },
628            FieldDesc {
629                name: "timeStamp".to_string(),
630                field_type: FieldType::Structure(timestamp_desc()),
631            },
632            FieldDesc {
633                name: "display".to_string(),
634                field_type: FieldType::Structure(display_desc()),
635            },
636            FieldDesc {
637                name: "control".to_string(),
638                field_type: FieldType::Structure(control_desc()),
639            },
640            FieldDesc {
641                name: "valueAlarm".to_string(),
642                field_type: FieldType::Structure(value_alarm_desc()),
643            },
644        ],
645    }
646}
647
648fn nt_scalar_array_desc(sav: &ScalarArrayValue) -> StructureDesc {
649    let tc = array_type_code(sav);
650    StructureDesc {
651        struct_id: Some("epics:nt/NTScalarArray:1.0".to_string()),
652        fields: vec![
653            FieldDesc {
654                name: "value".to_string(),
655                field_type: FieldType::ScalarArray(tc),
656            },
657            FieldDesc {
658                name: "alarm".to_string(),
659                field_type: FieldType::Structure(alarm_desc()),
660            },
661            FieldDesc {
662                name: "timeStamp".to_string(),
663                field_type: FieldType::Structure(timestamp_desc()),
664            },
665            FieldDesc {
666                name: "display".to_string(),
667                field_type: FieldType::Structure(display_desc()),
668            },
669            FieldDesc {
670                name: "control".to_string(),
671                field_type: FieldType::Structure(control_desc()),
672            },
673        ],
674    }
675}
676
677fn alarm_desc() -> StructureDesc {
678    StructureDesc {
679        struct_id: Some("alarm_t".to_string()),
680        fields: vec![
681            FieldDesc {
682                name: "severity".to_string(),
683                field_type: FieldType::Scalar(TypeCode::Int32),
684            },
685            FieldDesc {
686                name: "status".to_string(),
687                field_type: FieldType::Scalar(TypeCode::Int32),
688            },
689            FieldDesc {
690                name: "message".to_string(),
691                field_type: FieldType::String,
692            },
693        ],
694    }
695}
696
697fn timestamp_desc() -> StructureDesc {
698    StructureDesc {
699        struct_id: Some("time_t".to_string()),
700        fields: vec![
701            FieldDesc {
702                name: "secondsPastEpoch".to_string(),
703                field_type: FieldType::Scalar(TypeCode::Int64),
704            },
705            FieldDesc {
706                name: "nanoseconds".to_string(),
707                field_type: FieldType::Scalar(TypeCode::Int32),
708            },
709            FieldDesc {
710                name: "userTag".to_string(),
711                field_type: FieldType::Scalar(TypeCode::Int32),
712            },
713        ],
714    }
715}
716
717fn display_desc() -> StructureDesc {
718    StructureDesc {
719        struct_id: Some("display_t".to_string()),
720        fields: vec![
721            FieldDesc {
722                name: "limitLow".to_string(),
723                field_type: FieldType::Scalar(TypeCode::Float64),
724            },
725            FieldDesc {
726                name: "limitHigh".to_string(),
727                field_type: FieldType::Scalar(TypeCode::Float64),
728            },
729            FieldDesc {
730                name: "description".to_string(),
731                field_type: FieldType::String,
732            },
733            FieldDesc {
734                name: "units".to_string(),
735                field_type: FieldType::String,
736            },
737            FieldDesc {
738                name: "precision".to_string(),
739                field_type: FieldType::Scalar(TypeCode::Int32),
740            },
741            FieldDesc {
742                name: "form".to_string(),
743                field_type: FieldType::Structure(StructureDesc {
744                    struct_id: Some("enum_t".to_string()),
745                    fields: vec![
746                        FieldDesc {
747                            name: "index".to_string(),
748                            field_type: FieldType::Scalar(TypeCode::Int32),
749                        },
750                        FieldDesc {
751                            name: "choices".to_string(),
752                            field_type: FieldType::StringArray,
753                        },
754                    ],
755                }),
756            },
757        ],
758    }
759}
760
761fn control_desc() -> StructureDesc {
762    StructureDesc {
763        struct_id: Some("control_t".to_string()),
764        fields: vec![
765            FieldDesc {
766                name: "limitLow".to_string(),
767                field_type: FieldType::Scalar(TypeCode::Float64),
768            },
769            FieldDesc {
770                name: "limitHigh".to_string(),
771                field_type: FieldType::Scalar(TypeCode::Float64),
772            },
773            FieldDesc {
774                name: "minStep".to_string(),
775                field_type: FieldType::Scalar(TypeCode::Float64),
776            },
777        ],
778    }
779}
780
781fn value_alarm_desc() -> StructureDesc {
782    StructureDesc {
783        struct_id: Some("valueAlarm_t".to_string()),
784        fields: vec![
785            FieldDesc {
786                name: "active".to_string(),
787                field_type: FieldType::Scalar(TypeCode::Boolean),
788            },
789            FieldDesc {
790                name: "lowAlarmLimit".to_string(),
791                field_type: FieldType::Scalar(TypeCode::Float64),
792            },
793            FieldDesc {
794                name: "lowWarningLimit".to_string(),
795                field_type: FieldType::Scalar(TypeCode::Float64),
796            },
797            FieldDesc {
798                name: "highWarningLimit".to_string(),
799                field_type: FieldType::Scalar(TypeCode::Float64),
800            },
801            FieldDesc {
802                name: "highAlarmLimit".to_string(),
803                field_type: FieldType::Scalar(TypeCode::Float64),
804            },
805            FieldDesc {
806                name: "lowAlarmSeverity".to_string(),
807                field_type: FieldType::Scalar(TypeCode::Int32),
808            },
809            FieldDesc {
810                name: "lowWarningSeverity".to_string(),
811                field_type: FieldType::Scalar(TypeCode::Int32),
812            },
813            FieldDesc {
814                name: "highWarningSeverity".to_string(),
815                field_type: FieldType::Scalar(TypeCode::Int32),
816            },
817            FieldDesc {
818                name: "highAlarmSeverity".to_string(),
819                field_type: FieldType::Scalar(TypeCode::Int32),
820            },
821            FieldDesc {
822                name: "hysteresis".to_string(),
823                field_type: FieldType::Scalar(TypeCode::UInt8),
824            },
825        ],
826    }
827}
828
829#[cfg(test)]
830mod tests {
831    use super::*;
832    use crate::types::{DbCommonState, RecordType};
833    use spvirit_types::{
834        NdCodec, NdDimension, NtNdArray, NtPayload, NtScalar, NtScalarArray, NtTable,
835        NtTableColumn, ScalarArrayValue, ScalarValue,
836    };
837
838    fn make_ai(name: &str, val: f64) -> RecordInstance {
839        RecordInstance {
840            name: name.to_string(),
841            record_type: RecordType::Ai,
842            common: DbCommonState::default(),
843            data: RecordData::Ai {
844                nt: NtScalar::from_value(ScalarValue::F64(val)),
845                inp: None,
846                siml: None,
847                siol: None,
848                simm: false,
849            },
850            raw_fields: HashMap::new(),
851        }
852    }
853
854    fn make_mbbo(name: &str, choices: Vec<String>, initial: i32) -> RecordInstance {
855        RecordInstance {
856            name: name.to_string(),
857            record_type: RecordType::Mbbo,
858            common: DbCommonState::default(),
859            data: RecordData::NtEnum {
860                nt: spvirit_types::NtEnum::new(initial, choices),
861                inp: None,
862                out: None,
863                omsl: crate::types::OutputMode::Supervisory,
864            },
865            raw_fields: HashMap::new(),
866        }
867    }
868
869    fn make_ao(name: &str, val: f64) -> RecordInstance {
870        RecordInstance {
871            name: name.to_string(),
872            record_type: RecordType::Ao,
873            common: DbCommonState::default(),
874            data: RecordData::Ao {
875                nt: NtScalar::from_value(ScalarValue::F64(val)),
876                out: None,
877                dol: None,
878                omsl: crate::types::OutputMode::Supervisory,
879                drvl: None,
880                drvh: None,
881                oroc: None,
882                siml: None,
883                siol: None,
884                simm: false,
885            },
886            raw_fields: HashMap::new(),
887        }
888    }
889
890    #[tokio::test]
891    async fn mdel_deadband_suppresses_small_monitor_updates() {
892        let recs = crate::db::parse_db(
893            r#"
894record(ao, "DB:AO") {
895    field(VAL, "0.0")
896    field(MDEL, "0.5")
897}"#,
898        )
899        .expect("parse");
900        let store = SimplePvStore::new(recs, HashMap::new(), Vec::new(), false);
901        let mut rx = Source::subscribe(&store, "DB:AO")
902            .await
903            .expect("subscribed");
904
905        // |Δ| = 0.2 < MDEL 0.5 → value updates but no monitor post.
906        assert!(store.set_value("DB:AO", ScalarValue::F64(0.2)).await);
907        // |Δ| = 0.9 ≥ MDEL 0.5 → posted.
908        assert!(store.set_value("DB:AO", ScalarValue::F64(0.9)).await);
909
910        match rx.recv().await.expect("posted update") {
911            NtPayload::Scalar(nt) => assert_eq!(nt.value, ScalarValue::F64(0.9)),
912            other => panic!("expected scalar, got {other:?}"),
913        }
914        // The suppressed 0.2 update must not be queued behind it.
915        assert!(rx.try_recv().is_err());
916        // GETs always see the latest value regardless of the deadband.
917        assert_eq!(store.get_value("DB:AO").await, Some(ScalarValue::F64(0.9)));
918    }
919
920    fn make_waveform(name: &str, value: ScalarArrayValue) -> RecordInstance {
921        let nelm = value.len();
922        RecordInstance {
923            name: name.to_string(),
924            record_type: RecordType::Waveform,
925            common: DbCommonState::default(),
926            data: RecordData::Waveform {
927                nt: NtScalarArray::from_value(value),
928                inp: None,
929                ftvl: "DOUBLE".to_string(),
930                nelm,
931                nord: nelm,
932            },
933            raw_fields: HashMap::new(),
934        }
935    }
936
937    fn make_nt_table(name: &str) -> RecordInstance {
938        RecordInstance {
939            name: name.to_string(),
940            record_type: RecordType::NtTable,
941            common: DbCommonState::default(),
942            data: RecordData::NtTable {
943                nt: NtTable {
944                    labels: vec!["X".to_string(), "Y".to_string()],
945                    columns: vec![
946                        NtTableColumn {
947                            name: "x".to_string(),
948                            values: ScalarArrayValue::F64(vec![1.0, 2.0]),
949                        },
950                        NtTableColumn {
951                            name: "y".to_string(),
952                            values: ScalarArrayValue::F64(vec![10.0, 20.0]),
953                        },
954                    ],
955                    descriptor: Some("table".to_string()),
956                    alarm: None,
957                    time_stamp: None,
958                },
959                inp: None,
960                out: None,
961                omsl: crate::types::OutputMode::Supervisory,
962            },
963            raw_fields: HashMap::new(),
964        }
965    }
966
967    fn make_nt_ndarray(name: &str) -> RecordInstance {
968        RecordInstance {
969            name: name.to_string(),
970            record_type: RecordType::NtNdArray,
971            common: DbCommonState::default(),
972            data: RecordData::NtNdArray {
973                nt: NtNdArray {
974                    value: ScalarArrayValue::U8(vec![0; 4]),
975                    codec: NdCodec {
976                        name: "none".to_string(),
977                        parameters: HashMap::new(),
978                    },
979                    compressed_size: 4,
980                    uncompressed_size: 4,
981                    dimension: vec![NdDimension {
982                        size: 2,
983                        offset: 0,
984                        full_size: 2,
985                        binning: 1,
986                        reverse: false,
987                    }],
988                    unique_id: 1,
989                    data_time_stamp: Default::default(),
990                    attribute: vec![],
991                    descriptor: Some("ndarray".to_string()),
992                    alarm: None,
993                    time_stamp: None,
994                    display: None,
995                },
996                inp: None,
997                out: None,
998                omsl: crate::types::OutputMode::Supervisory,
999            },
1000            raw_fields: HashMap::new(),
1001        }
1002    }
1003
1004    #[tokio::test]
1005    async fn store_stamps_initial_timestamps_on_static_records() {
1006        // Records that are never updated after creation (e.g. a static
1007        // NTTable) must still carry a valid timestamp — clients like the
1008        // EPICS Archiver Appliance reject epoch-0 events.
1009        let mut records = HashMap::new();
1010        records.insert("TEST:TBL".into(), make_nt_table("TEST:TBL"));
1011        records.insert("TEST:NDA".into(), make_nt_ndarray("TEST:NDA"));
1012        records.insert(
1013            "TEST:ENUM".into(),
1014            make_mbbo("TEST:ENUM", vec!["A".into(), "B".into()], 0),
1015        );
1016        records.insert(
1017            "TEST:WF".into(),
1018            make_waveform("TEST:WF", ScalarArrayValue::F64(vec![0.0])),
1019        );
1020        records.insert("TEST:AI".into(), make_ai("TEST:AI", 1.0));
1021        let store = SimplePvStore::new(records, HashMap::new(), vec![], false);
1022
1023        match store.get_nt("TEST:TBL").await.unwrap() {
1024            NtPayload::Table(nt) => {
1025                assert!(nt.time_stamp.expect("table stamped").seconds_past_epoch > 0)
1026            }
1027            _ => panic!("expected table"),
1028        }
1029        match store.get_nt("TEST:NDA").await.unwrap() {
1030            NtPayload::NdArray(nt) => {
1031                assert!(nt.time_stamp.expect("ndarray stamped").seconds_past_epoch > 0);
1032                assert!(nt.data_time_stamp.seconds_past_epoch > 0);
1033            }
1034            _ => panic!("expected ndarray"),
1035        }
1036        match store.get_nt("TEST:ENUM").await.unwrap() {
1037            NtPayload::Enum(nt) => assert!(nt.time_stamp.seconds_past_epoch > 0),
1038            _ => panic!("expected enum"),
1039        }
1040        match store.get_nt("TEST:WF").await.unwrap() {
1041            NtPayload::ScalarArray(nt) => assert!(nt.time_stamp.seconds_past_epoch > 0),
1042            _ => panic!("expected scalar array"),
1043        }
1044        match store.get_nt("TEST:AI").await.unwrap() {
1045            NtPayload::Scalar(nt) => {
1046                assert!(nt.time_stamp.expect("scalar stamped").seconds_past_epoch > 0)
1047            }
1048            _ => panic!("expected scalar"),
1049        }
1050    }
1051
1052    #[tokio::test]
1053    async fn has_pv_returns_true_for_existing() {
1054        let mut records = HashMap::new();
1055        records.insert("TEST:AI".into(), make_ai("TEST:AI", 1.0));
1056        let store = SimplePvStore::new(records, HashMap::new(), vec![], false);
1057        assert!(store.claim("TEST:AI").await.is_some());
1058        assert!(store.claim("MISSING").await.is_none());
1059    }
1060
1061    #[tokio::test]
1062    async fn get_snapshot_returns_payload() {
1063        let mut records = HashMap::new();
1064        records.insert("TEST:AI".into(), make_ai("TEST:AI", 42.0));
1065        let store = SimplePvStore::new(records, HashMap::new(), vec![], false);
1066        let snap = store.get("TEST:AI").await.unwrap();
1067        match snap {
1068            NtPayload::Scalar(nt) => assert_eq!(nt.value, ScalarValue::F64(42.0)),
1069            _ => panic!("expected scalar"),
1070        }
1071    }
1072
1073    #[tokio::test]
1074    async fn put_value_updates_writable_record() {
1075        let mut records = HashMap::new();
1076        records.insert("TEST:AO".into(), make_ao("TEST:AO", 0.0));
1077        let store = SimplePvStore::new(records, HashMap::new(), vec![], false);
1078
1079        let val = DecodedValue::Structure(vec![("value".to_string(), DecodedValue::Float64(99.5))]);
1080        let result = store.put("TEST:AO", &val).await.unwrap();
1081        assert_eq!(result.len(), 1);
1082        assert_eq!(result[0].0, "TEST:AO");
1083
1084        let snap = store.get("TEST:AO").await.unwrap();
1085        match snap {
1086            NtPayload::Scalar(nt) => assert_eq!(nt.value, ScalarValue::F64(99.5)),
1087            _ => panic!("expected scalar"),
1088        }
1089    }
1090
1091    #[tokio::test]
1092    async fn put_wire_rejects_out_of_range_enum_index() {
1093        let mut records = HashMap::new();
1094        records.insert(
1095            "E".into(),
1096            make_mbbo("E", vec!["A".into(), "B".into(), "C".into()], 0),
1097        );
1098        let store = SimplePvStore::new(records, HashMap::new(), vec![], false);
1099
1100        // Out-of-range index — must be a no-op (Ok, no changed PVs), index unchanged.
1101        let result = Source::put(&store, "E", &DecodedValue::Int32(7))
1102            .await
1103            .unwrap();
1104        assert!(result.is_empty());
1105        assert_eq!(store.get_value("E").await.unwrap(), ScalarValue::I32(0));
1106
1107        // In-range index — applied.
1108        let result = Source::put(&store, "E", &DecodedValue::Int32(2))
1109            .await
1110            .unwrap();
1111        assert_eq!(result.len(), 1);
1112        assert_eq!(store.get_value("E").await.unwrap(), ScalarValue::I32(2));
1113    }
1114
1115    #[tokio::test]
1116    async fn put_value_rejects_readonly() {
1117        let mut records = HashMap::new();
1118        records.insert("TEST:AI".into(), make_ai("TEST:AI", 1.0));
1119        let store = SimplePvStore::new(records, HashMap::new(), vec![], false);
1120
1121        let val = DecodedValue::Float64(5.0);
1122        let err = store.put("TEST:AI", &val).await.unwrap_err();
1123        assert!(err.contains("not writable"));
1124    }
1125
1126    #[tokio::test]
1127    async fn set_value_bypasses_writable_check() {
1128        let mut records = HashMap::new();
1129        records.insert("TEST:AI".into(), make_ai("TEST:AI", 1.0));
1130        let store = SimplePvStore::new(records, HashMap::new(), vec![], false);
1131        assert!(store.set_value("TEST:AI", ScalarValue::F64(10.0)).await);
1132        let val = store.get_value("TEST:AI").await.unwrap();
1133        assert_eq!(val, ScalarValue::F64(10.0));
1134    }
1135
1136    #[tokio::test]
1137    async fn set_array_value_updates_all_scalar_array_types() {
1138        let cases: Vec<ScalarArrayValue> = vec![
1139            ScalarArrayValue::Bool(vec![false, true]),
1140            ScalarArrayValue::I8(vec![1, 2]),
1141            ScalarArrayValue::I16(vec![1, 2]),
1142            ScalarArrayValue::I32(vec![1, 2]),
1143            ScalarArrayValue::I64(vec![1, 2]),
1144            ScalarArrayValue::U8(vec![1, 2]),
1145            ScalarArrayValue::U16(vec![1, 2]),
1146            ScalarArrayValue::U32(vec![1, 2]),
1147            ScalarArrayValue::U64(vec![1, 2]),
1148            ScalarArrayValue::F32(vec![1.0, 2.0]),
1149            ScalarArrayValue::F64(vec![1.0, 2.0]),
1150            ScalarArrayValue::Str(vec!["a".to_string(), "b".to_string()]),
1151        ];
1152
1153        for (idx, updated) in cases.into_iter().enumerate() {
1154            let pv = format!("TEST:WF:{idx}");
1155            let mut records = HashMap::new();
1156            records.insert(pv.clone(), make_waveform(&pv, updated.clone()));
1157            let store = SimplePvStore::new(records, HashMap::new(), vec![], false);
1158
1159            assert!(!store.set_array_value(&pv, updated.clone()).await);
1160
1161            let second = match updated {
1162                ScalarArrayValue::Bool(_) => ScalarArrayValue::Bool(vec![true, false]),
1163                ScalarArrayValue::I8(_) => ScalarArrayValue::I8(vec![3, 4]),
1164                ScalarArrayValue::I16(_) => ScalarArrayValue::I16(vec![3, 4]),
1165                ScalarArrayValue::I32(_) => ScalarArrayValue::I32(vec![3, 4]),
1166                ScalarArrayValue::I64(_) => ScalarArrayValue::I64(vec![3, 4]),
1167                ScalarArrayValue::U8(_) => ScalarArrayValue::U8(vec![3, 4]),
1168                ScalarArrayValue::U16(_) => ScalarArrayValue::U16(vec![3, 4]),
1169                ScalarArrayValue::U32(_) => ScalarArrayValue::U32(vec![3, 4]),
1170                ScalarArrayValue::U64(_) => ScalarArrayValue::U64(vec![3, 4]),
1171                ScalarArrayValue::F32(_) => ScalarArrayValue::F32(vec![3.0, 4.0]),
1172                ScalarArrayValue::F64(_) => ScalarArrayValue::F64(vec![3.0, 4.0]),
1173                ScalarArrayValue::Str(_) => {
1174                    ScalarArrayValue::Str(vec!["x".to_string(), "y".to_string()])
1175                }
1176            };
1177
1178            assert!(store.set_array_value(&pv, second.clone()).await);
1179            let snap = store.get(&pv).await.unwrap();
1180            match snap {
1181                NtPayload::ScalarArray(nt) => assert_eq!(nt.value, second),
1182                _ => panic!("expected scalar array"),
1183            }
1184        }
1185    }
1186
1187    #[tokio::test]
1188    async fn get_nt_returns_full_payload() {
1189        let mut records = HashMap::new();
1190        records.insert("TEST:AI".into(), make_ai("TEST:AI", 12.5));
1191        let store = SimplePvStore::new(records, HashMap::new(), vec![], false);
1192
1193        let nt = store.get_nt("TEST:AI").await.unwrap();
1194        match nt {
1195            NtPayload::Scalar(nt) => assert_eq!(nt.value, ScalarValue::F64(12.5)),
1196            _ => panic!("expected scalar payload"),
1197        }
1198    }
1199
1200    #[tokio::test]
1201    async fn put_nt_updates_scalar_array_table_and_ndarray() {
1202        let mut records = HashMap::new();
1203        records.insert("TEST:AI".into(), make_ai("TEST:AI", 1.0));
1204        records.insert(
1205            "TEST:WF".into(),
1206            make_waveform("TEST:WF", ScalarArrayValue::F64(vec![0.0, 0.0])),
1207        );
1208        records.insert("TEST:TBL".into(), make_nt_table("TEST:TBL"));
1209        records.insert("TEST:NDA".into(), make_nt_ndarray("TEST:NDA"));
1210        let store = SimplePvStore::new(records, HashMap::new(), vec![], false);
1211
1212        assert!(
1213            store
1214                .put_nt(
1215                    "TEST:AI",
1216                    NtPayload::Scalar(NtScalar::from_value(ScalarValue::F64(5.0))),
1217                )
1218                .await
1219        );
1220        assert!(
1221            store
1222                .put_nt(
1223                    "TEST:WF",
1224                    NtPayload::ScalarArray(NtScalarArray::from_value(ScalarArrayValue::F64(vec![
1225                        3.0, 4.0
1226                    ],))),
1227                )
1228                .await
1229        );
1230
1231        let table = NtTable {
1232            labels: vec!["X".to_string(), "Y".to_string()],
1233            columns: vec![
1234                NtTableColumn {
1235                    name: "x".to_string(),
1236                    values: ScalarArrayValue::F64(vec![2.0, 3.0]),
1237                },
1238                NtTableColumn {
1239                    name: "y".to_string(),
1240                    values: ScalarArrayValue::F64(vec![20.0, 30.0]),
1241                },
1242            ],
1243            descriptor: Some("updated table".to_string()),
1244            alarm: None,
1245            time_stamp: None,
1246        };
1247        assert!(
1248            store
1249                .put_nt("TEST:TBL", NtPayload::Table(table.clone()))
1250                .await
1251        );
1252
1253        let ndarray = NtNdArray {
1254            value: ScalarArrayValue::U8(vec![1, 2, 3, 4]),
1255            codec: NdCodec {
1256                name: "none".to_string(),
1257                parameters: HashMap::new(),
1258            },
1259            compressed_size: 4,
1260            uncompressed_size: 4,
1261            dimension: vec![NdDimension {
1262                size: 4,
1263                offset: 0,
1264                full_size: 4,
1265                binning: 1,
1266                reverse: false,
1267            }],
1268            unique_id: 2,
1269            data_time_stamp: Default::default(),
1270            attribute: vec![],
1271            descriptor: Some("updated ndarray".to_string()),
1272            alarm: None,
1273            time_stamp: None,
1274            display: None,
1275        };
1276        assert!(
1277            store
1278                .put_nt("TEST:NDA", NtPayload::NdArray(ndarray.clone()))
1279                .await
1280        );
1281
1282        assert!(
1283            !store
1284                .put_nt(
1285                    "TEST:AI",
1286                    NtPayload::ScalarArray(NtScalarArray::from_value(ScalarArrayValue::F64(vec![
1287                        1.0
1288                    ]))),
1289                )
1290                .await
1291        );
1292
1293        // The caller supplied no timestamps, so the store stamps the update
1294        // time — compare everything else verbatim.
1295        match store.get_nt("TEST:TBL").await.unwrap() {
1296            NtPayload::Table(mut nt) => {
1297                let ts = nt.time_stamp.take().expect("table put must be stamped");
1298                assert!(ts.seconds_past_epoch > 0);
1299                assert_eq!(nt, table);
1300            }
1301            _ => panic!("expected table payload"),
1302        }
1303        match store.get_nt("TEST:NDA").await.unwrap() {
1304            NtPayload::NdArray(mut nt) => {
1305                let ts = nt.time_stamp.take().expect("ndarray put must be stamped");
1306                assert!(ts.seconds_past_epoch > 0);
1307                assert!(nt.data_time_stamp.seconds_past_epoch > 0);
1308                nt.data_time_stamp = Default::default();
1309                assert_eq!(nt, ndarray);
1310            }
1311            _ => panic!("expected ndarray payload"),
1312        }
1313    }
1314
1315    #[tokio::test]
1316    async fn descriptor_matches_value_type() {
1317        let mut records = HashMap::new();
1318        records.insert("TEST:AI".into(), make_ai("TEST:AI", 0.0));
1319        let store = SimplePvStore::new(records, HashMap::new(), vec![], false);
1320        let info = store.claim("TEST:AI").await.unwrap();
1321        assert_eq!(
1322            info.descriptor.struct_id.as_deref(),
1323            Some("epics:nt/NTScalar:1.0")
1324        );
1325        let desc = info.descriptor;
1326        let value_field = desc.field("value").unwrap();
1327        assert!(matches!(
1328            value_field.field_type,
1329            FieldType::Scalar(TypeCode::Float64)
1330        ));
1331    }
1332
1333    #[tokio::test]
1334    async fn subscribe_receives_updates() {
1335        let mut records = HashMap::new();
1336        records.insert("TEST:AO".into(), make_ao("TEST:AO", 0.0));
1337        let store = SimplePvStore::new(records, HashMap::new(), vec![], false);
1338
1339        let mut rx = Source::subscribe(&store, "TEST:AO").await.unwrap();
1340
1341        let val = DecodedValue::Structure(vec![("value".to_string(), DecodedValue::Float64(7.7))]);
1342        store.put("TEST:AO", &val).await.unwrap();
1343
1344        let update = rx.recv().await.unwrap();
1345        match update {
1346            NtPayload::Scalar(nt) => assert_eq!(nt.value, ScalarValue::F64(7.7)),
1347            _ => panic!("expected scalar"),
1348        }
1349    }
1350
1351    #[tokio::test]
1352    async fn on_put_callback_is_invoked() {
1353        use std::sync::atomic::{AtomicBool, Ordering};
1354
1355        let called = Arc::new(AtomicBool::new(false));
1356        let called2 = called.clone();
1357
1358        let mut records = HashMap::new();
1359        records.insert("CB:AO".into(), make_ao("CB:AO", 0.0));
1360
1361        let mut on_put = HashMap::new();
1362        let cb: OnPutCallback = Arc::new(move |_name, _val| {
1363            called2.store(true, Ordering::SeqCst);
1364        });
1365        on_put.insert("CB:AO".into(), cb);
1366
1367        let store = SimplePvStore::new(records, on_put, vec![], false);
1368        let val = DecodedValue::Structure(vec![("value".to_string(), DecodedValue::Float64(1.0))]);
1369        store.put("CB:AO", &val).await.unwrap();
1370
1371        // Give the spawned task time to run.
1372        tokio::task::yield_now().await;
1373        tokio::task::yield_now().await;
1374
1375        assert!(called.load(Ordering::SeqCst));
1376    }
1377
1378    #[tokio::test]
1379    async fn validator_rejects_put_before_apply() {
1380        let mut records = std::collections::HashMap::new();
1381        records.insert(
1382            "V".to_string(),
1383            crate::pva_server::make_output_record(
1384                "V",
1385                crate::types::RecordType::Ao,
1386                ScalarValue::F64(1.0),
1387            ),
1388        );
1389        let store =
1390            SimplePvStore::new(records, std::collections::HashMap::new(), Vec::new(), false);
1391        store
1392            .set_validator(
1393                "V".to_string(),
1394                std::sync::Arc::new(|_name, _val| Err("nope".to_string())),
1395            )
1396            .await;
1397
1398        let dv = DecodedValue::Float64(2.0);
1399        let res = Source::put(&store, "V", &dv).await;
1400        assert_eq!(res, Err("nope".to_string()));
1401        // value unchanged — validator ran BEFORE apply
1402        assert_eq!(store.get_value("V").await, Some(ScalarValue::F64(1.0)));
1403    }
1404
1405    #[tokio::test]
1406    async fn remove_deletes_record_and_is_idempotent() {
1407        let mut records = std::collections::HashMap::new();
1408        records.insert(
1409            "T:GONE".to_string(),
1410            crate::pva_server::make_scalar_record("T:GONE", RecordType::Ai, ScalarValue::F64(1.0)),
1411        );
1412        let store = SimplePvStore::new(records, Default::default(), Vec::new(), false);
1413
1414        assert!(store.get_value("T:GONE").await.is_some());
1415        assert!(store.remove("T:GONE").await, "first remove returns true");
1416        assert!(store.get_value("T:GONE").await.is_none(), "record is gone");
1417        assert!(!store.remove("T:GONE").await, "second remove returns false");
1418        assert!(store.claim("T:GONE").await.is_none(), "claim no longer matches");
1419    }
1420
1421    #[tokio::test]
1422    async fn put_advances_the_timestamp() {
1423        let mut records = HashMap::new();
1424        records.insert("TEST:AO".into(), make_ao("TEST:AO", 1.0));
1425        let store = SimplePvStore::new(records, HashMap::new(), vec![], false);
1426
1427        let first = match store.get_nt("TEST:AO").await.unwrap() {
1428            NtPayload::Scalar(nt) => nt.time_stamp.unwrap(),
1429            other => panic!("expected scalar, got {other:?}"),
1430        };
1431
1432        store
1433            .put("TEST:AO", &DecodedValue::Float64(2.0))
1434            .await
1435            .unwrap();
1436
1437        let second = match store.get_nt("TEST:AO").await.unwrap() {
1438            NtPayload::Scalar(nt) => nt.time_stamp.unwrap(),
1439            other => panic!("expected scalar, got {other:?}"),
1440        };
1441        assert!(
1442            (second.seconds_past_epoch, second.nanoseconds)
1443                > (first.seconds_past_epoch, first.nanoseconds),
1444            "timestamp did not advance: {first:?} -> {second:?}"
1445        );
1446    }
1447
1448    #[tokio::test]
1449    async fn put_of_identical_value_restamps_without_posting() {
1450        let mut records = HashMap::new();
1451        records.insert("TEST:AO".into(), make_ao("TEST:AO", 1.0));
1452        let store = SimplePvStore::new(records, HashMap::new(), vec![], false);
1453        let mut rx = Source::subscribe(&store, "TEST:AO").await.unwrap();
1454
1455        let before = match store.get_nt("TEST:AO").await.unwrap() {
1456            NtPayload::Scalar(nt) => nt.time_stamp.unwrap(),
1457            other => panic!("expected scalar, got {other:?}"),
1458        };
1459
1460        let posted = store
1461            .put("TEST:AO", &DecodedValue::Float64(1.0))
1462            .await
1463            .unwrap();
1464
1465        assert!(posted.is_empty(), "no-op PUT must not report a change");
1466        assert!(rx.try_recv().is_err(), "no-op PUT must not post to monitors");
1467
1468        let after = match store.get_nt("TEST:AO").await.unwrap() {
1469            NtPayload::Scalar(nt) => nt.time_stamp.unwrap(),
1470            other => panic!("expected scalar, got {other:?}"),
1471        };
1472        assert!(
1473            (after.seconds_past_epoch, after.nanoseconds)
1474                > (before.seconds_past_epoch, before.nanoseconds),
1475            "record was not restamped: {before:?} -> {after:?}"
1476        );
1477    }
1478
1479    #[tokio::test]
1480    async fn client_supplied_timestamp_posts_even_when_value_is_unchanged() {
1481        let mut records = HashMap::new();
1482        records.insert("TEST:AO".into(), make_ao("TEST:AO", 1.0));
1483        let store = SimplePvStore::new(records, HashMap::new(), vec![], false);
1484        let mut rx = Source::subscribe(&store, "TEST:AO").await.unwrap();
1485
1486        let body = DecodedValue::Structure(vec![
1487            ("value".to_string(), DecodedValue::Float64(1.0)),
1488            (
1489                "timeStamp".to_string(),
1490                DecodedValue::Structure(vec![
1491                    (
1492                        "secondsPastEpoch".to_string(),
1493                        DecodedValue::Int64(9_000),
1494                    ),
1495                    ("nanoseconds".to_string(), DecodedValue::Int32(0)),
1496                    ("userTag".to_string(), DecodedValue::Int32(0)),
1497                ]),
1498            ),
1499        ]);
1500        let posted = store.put("TEST:AO", &body).await.unwrap();
1501
1502        assert_eq!(posted.len(), 1, "client-stamped PUT must post");
1503        assert!(rx.try_recv().is_ok(), "subscriber must receive the update");
1504        match store.get_nt("TEST:AO").await.unwrap() {
1505            NtPayload::Scalar(nt) => {
1506                assert_eq!(nt.time_stamp.unwrap().seconds_past_epoch, 9_000)
1507            }
1508            other => panic!("expected scalar, got {other:?}"),
1509        }
1510    }
1511
1512    #[tokio::test]
1513    async fn on_put_fires_for_a_value_unchanged_put() {
1514        use std::sync::atomic::{AtomicUsize, Ordering};
1515        let calls = Arc::new(AtomicUsize::new(0));
1516        let seen = calls.clone();
1517
1518        let mut records = HashMap::new();
1519        records.insert("TEST:AO".into(), make_ao("TEST:AO", 1.0));
1520        let mut on_put: HashMap<String, OnPutCallback> = HashMap::new();
1521        on_put.insert(
1522            "TEST:AO".into(),
1523            Arc::new(move |_name, _val| {
1524                seen.fetch_add(1, Ordering::SeqCst);
1525            }),
1526        );
1527        let store = SimplePvStore::new(records, on_put, vec![], false);
1528
1529        store
1530            .put("TEST:AO", &DecodedValue::Float64(1.0))
1531            .await
1532            .unwrap();
1533
1534        // on_put is spawned; give the task a chance to run.
1535        tokio::task::yield_now().await;
1536        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
1537        assert_eq!(calls.load(Ordering::SeqCst), 1);
1538    }
1539
1540    #[tokio::test]
1541    async fn validator_allows_structure_wrapped_put_through() {
1542        // Real puts to scalar records arrive wrapped as a Structure with a
1543        // "value" field (see apply_put_to_record's bare-scalar-wrapping).
1544        // The validator itself only sees the raw DecodedValue as given to
1545        // `put`; this test documents that a validator returning Ok lets a
1546        // structure-wrapped put proceed and apply normally.
1547        let mut records = std::collections::HashMap::new();
1548        records.insert("W".to_string(), make_ao("W", 1.0));
1549        let store =
1550            SimplePvStore::new(records, std::collections::HashMap::new(), Vec::new(), false);
1551        store
1552            .set_validator("W".to_string(), std::sync::Arc::new(|_name, _val| Ok(())))
1553            .await;
1554
1555        let dv = DecodedValue::Structure(vec![("value".to_string(), DecodedValue::Float64(5.0))]);
1556        let res = Source::put(&store, "W", &dv).await;
1557        assert!(res.is_ok());
1558        assert_eq!(store.get_value("W").await, Some(ScalarValue::F64(5.0)));
1559    }
1560}