Skip to main content

ad_plugins_rs/
circular_buff.rs

1use std::collections::VecDeque;
2use std::sync::Arc;
3
4use ad_core_rs::ndarray::NDArray;
5use ad_core_rs::ndarray_pool::NDArrayPool;
6use ad_core_rs::plugin::runtime::{NDPluginProcess, ProcessResult};
7use epics_base_rs::calc;
8
9/// Compiled EPICS calc expression wrapper.
10///
11/// Uses the full epics-base-rs calc engine which supports variables A-L (indices 0-11)
12/// plus arithmetic, math functions (ABS, SQRT, LOG, LN, EXP, SIN, COS, MIN, MAX, etc.),
13/// comparison, logical, and bitwise operators -- matching the C++ EPICS calc engine.
14///
15/// For trigger calculations the C++ passes:
16///   A=attrValueA, B=attrValueB, C=preTrigger, D=postTrigger, E=currentImage, F=triggered
17#[derive(Debug, Clone)]
18pub struct CalcExpression {
19    compiled: calc::CompiledExpr,
20}
21
22impl CalcExpression {
23    /// Compile an infix expression string.
24    ///
25    /// Returns `None` if the expression is invalid.
26    pub fn parse(expr: &str) -> Option<CalcExpression> {
27        calc::compile(expr)
28            .ok()
29            .map(|compiled| CalcExpression { compiled })
30    }
31
32    /// Evaluate with variables A and B only (legacy 2-variable interface).
33    /// Returns the numeric result; nonzero means true for trigger purposes.
34    pub fn evaluate(&self, a: f64, b: f64) -> f64 {
35        let mut inputs = calc::NumericInputs::new();
36        inputs.vars[0] = a; // A
37        inputs.vars[1] = b; // B
38        calc::eval(&self.compiled, &mut inputs).unwrap_or(0.0)
39    }
40
41    /// Evaluate with the full variable set (A through U).
42    ///
43    /// `vars` is indexed 0=A, 1=B, 2=C, ... up to `CALC_NARGS - 1` = U.
44    pub fn evaluate_vars(&self, vars: &[f64; calc::CALC_NARGS]) -> f64 {
45        let mut inputs = calc::NumericInputs::with_vars(*vars);
46        calc::eval(&self.compiled, &mut inputs).unwrap_or(0.0)
47    }
48}
49
50/// Trigger condition for circular buffer.
51#[derive(Debug, Clone)]
52pub enum TriggerCondition {
53    /// Trigger on an attribute value exceeding threshold.
54    AttributeThreshold { name: String, threshold: f64 },
55    /// External trigger (manual).
56    External,
57    /// Calculated trigger based on attribute values and an expression.
58    ///
59    /// The C++ calc engine passes: A=attrValueA, B=attrValueB, C=preTrigger,
60    /// D=postTrigger, E=currentImage, F=triggered.
61    Calc {
62        attr_a: String,
63        attr_b: String,
64        expression: CalcExpression,
65    },
66}
67
68/// Status of the circular buffer.
69#[derive(Debug, Clone, Copy, PartialEq, Eq)]
70pub enum BufferStatus {
71    Idle,
72    BufferFilling,
73    Flushing,
74    AcquisitionCompleted,
75}
76
77/// Trigger calc inputs and result for one frame, mirroring C's
78/// `triggerCalcArgs_[0]`/`[1]` and `calcResult` (NDPluginCircularBuff.cpp:67-78).
79#[derive(Debug, Clone, Copy)]
80pub struct TriggerValues {
81    /// Value of the TriggerA attribute (NaN if absent).
82    pub a: f64,
83    /// Value of the TriggerB attribute (NaN if absent).
84    pub b: f64,
85    /// Result of the trigger calc expression.
86    pub calc: f64,
87}
88
89/// Result of pushing a frame: which frames to forward downstream now, and
90/// whether a capture sequence completed on this push.
91#[derive(Debug, Default)]
92pub struct PushResult {
93    /// Frames to forward downstream immediately, in order.
94    pub forward: Vec<Arc<NDArray>>,
95    /// True if the post-trigger count was reached on this push.
96    pub sequence_done: bool,
97    /// Trigger calc inputs/result when the calc was evaluated this frame
98    /// (C `calculateTrigger` path). `None` when already triggered or for a
99    /// non-calc trigger condition.
100    pub trigger_values: Option<TriggerValues>,
101}
102
103/// Circular buffer state for pre/post-trigger capture.
104pub struct CircularBuffer {
105    pub(crate) pre_count: usize,
106    pub(crate) post_count: usize,
107    buffer: VecDeque<Arc<NDArray>>,
108    pub(crate) trigger_condition: TriggerCondition,
109    triggered: bool,
110    /// Number of post-trigger frames forwarded so far for the current trigger.
111    post_done: usize,
112    /// True once the pre-buffer has been flushed for the current trigger.
113    pre_flushed: bool,
114    /// Frames captured for the current sequence (pre + post), for callers
115    /// that want the batch via [`CircularBuffer::take_captured`].
116    captured: Vec<Arc<NDArray>>,
117    /// Maximum number of triggers before stopping (0 = unlimited).
118    preset_trigger_count: usize,
119    /// Number of triggers fired so far.
120    trigger_count: usize,
121    /// If true, flush buffer immediately on soft trigger.
122    flush_on_soft_trigger: bool,
123    /// Current buffer status.
124    pub(crate) status: BufferStatus,
125}
126
127impl CircularBuffer {
128    pub fn new(pre_count: usize, post_count: usize, condition: TriggerCondition) -> Self {
129        Self {
130            pre_count,
131            post_count,
132            buffer: VecDeque::with_capacity(pre_count + 1),
133            trigger_condition: condition,
134            triggered: false,
135            post_done: 0,
136            pre_flushed: false,
137            captured: Vec::new(),
138            preset_trigger_count: 0,
139            trigger_count: 0,
140            flush_on_soft_trigger: false,
141            status: BufferStatus::Idle,
142        }
143    }
144
145    /// Set the preset trigger count (0 = unlimited).
146    pub fn set_preset_trigger_count(&mut self, count: usize) {
147        self.preset_trigger_count = count;
148    }
149
150    /// Get the current trigger count.
151    pub fn trigger_count(&self) -> usize {
152        self.trigger_count
153    }
154
155    /// Get the current buffer status.
156    pub fn status(&self) -> BufferStatus {
157        self.status
158    }
159
160    /// Set flush_on_soft_trigger flag.
161    pub fn set_flush_on_soft_trigger(&mut self, flush: bool) {
162        self.flush_on_soft_trigger = flush;
163    }
164
165    /// Push an array into the circular buffer.
166    ///
167    /// Mirrors C++ `NDPluginCircularBuff::processCallbacks`: on the frame that
168    /// triggers, the pre-buffer is flushed immediately and the triggering
169    /// frame is forwarded as the first post-trigger frame; each subsequent
170    /// post-trigger frame is forwarded individually. The returned
171    /// [`PushResult::forward`] holds the frames to send downstream this call.
172    pub fn push(&mut self, array: Arc<NDArray>) -> PushResult {
173        let mut result = PushResult::default();
174
175        // If acquisition is completed, ignore new frames
176        if self.status == BufferStatus::AcquisitionCompleted {
177            return result;
178        }
179
180        // Transition from Idle to BufferFilling on first push
181        if self.status == BufferStatus::Idle {
182            self.status = BufferStatus::BufferFilling;
183        }
184
185        if self.triggered {
186            // Post-trigger capture (Flushing state).
187            // Flush the pre-buffer once, before the first post-trigger frame.
188            if !self.pre_flushed {
189                self.pre_flushed = true;
190                let pre: Vec<_> = self.buffer.drain(..).collect();
191                self.captured.extend(pre.iter().cloned());
192                result.forward.extend(pre);
193            }
194            // C++ increments currentPostCount, forwards the frame, then tests
195            // `currentPostCount >= postCount`.
196            self.captured.push(Arc::clone(&array));
197            result.forward.push(array);
198            self.post_done += 1;
199            if self.post_done >= self.post_count {
200                self.complete_sequence(&mut result);
201            }
202            return result;
203        }
204
205        // Check trigger condition BEFORE adding to pre-buffer,
206        // so the triggering frame becomes the first post-trigger frame.
207        let trigger = match &self.trigger_condition {
208            TriggerCondition::AttributeThreshold { name, threshold } => array
209                .attributes
210                .get(name)
211                .and_then(|a| a.value.as_f64())
212                .map(|v| v >= *threshold)
213                .unwrap_or(false),
214            TriggerCondition::External => false,
215            TriggerCondition::Calc {
216                attr_a,
217                attr_b,
218                expression,
219            } => {
220                let a = array
221                    .attributes
222                    .get(attr_a)
223                    .and_then(|a| a.value.as_f64())
224                    .unwrap_or(f64::NAN);
225                let b = array
226                    .attributes
227                    .get(attr_b)
228                    .and_then(|a| a.value.as_f64())
229                    .unwrap_or(f64::NAN);
230                // C++ passes: A=attrValueA, B=attrValueB, C=preTrigger,
231                // D=postTrigger, E=currentImage, F=triggered
232                let mut vars = [0.0f64; calc::CALC_NARGS];
233                vars[0] = a; // A
234                vars[1] = b; // B
235                vars[2] = self.pre_count as f64; // C
236                vars[3] = self.post_count as f64; // D
237                vars[4] = self.buffer.len() as f64; // E (currentImage)
238                vars[5] = if self.triggered { 1.0 } else { 0.0 }; // F
239                let calc = expression.evaluate_vars(&vars);
240                // C posts TriggerAVal/BVal/CalcVal every evaluated frame
241                // (NDPluginCircularBuff.cpp:67-78), regardless of the outcome.
242                result.trigger_values = Some(TriggerValues { a, b, calc });
243                // C fires only when the result is a finite non-zero
244                // (NDPluginCircularBuff.cpp:77 `!isnan && !isinf && != 0`); a
245                // NaN/Inf result (e.g. a missing trigger attribute → epicsNAN,
246                // or an `A/B` with a zero denominator) must NOT trigger.
247                // `f64::is_finite` is exactly `!isnan && !isinf`.
248                calc.is_finite() && calc != 0.0
249            }
250        };
251
252        if trigger {
253            // Trigger fires before adding this frame to the pre-buffer,
254            // so the triggering frame will be the first post-trigger frame.
255            self.trigger();
256            // Flush the pre-buffer immediately, then forward the triggering
257            // frame as the first post-trigger frame (C++ flushPreBuffer +
258            // doCallbacksGenericPointer of the trigger frame).
259            self.pre_flushed = true;
260            let pre: Vec<_> = self.buffer.drain(..).collect();
261            self.captured.extend(pre.iter().cloned());
262            result.forward.extend(pre);
263            self.captured.push(Arc::clone(&array));
264            result.forward.push(array);
265            self.post_done += 1;
266            if self.post_done >= self.post_count {
267                self.complete_sequence(&mut result);
268            }
269            return result;
270        }
271
272        // Maintain pre-trigger ring buffer
273        self.buffer.push_back(array);
274        if self.buffer.len() > self.pre_count {
275            self.buffer.pop_front();
276        }
277
278        result
279    }
280
281    /// Finalize a completed post-trigger sequence (C++
282    /// `currentPostCount >= postCount` branch): advance status / trigger
283    /// bookkeeping and signal completion.
284    fn complete_sequence(&mut self, result: &mut PushResult) {
285        self.triggered = false;
286        self.pre_flushed = false;
287        self.post_done = 0;
288        if self.preset_trigger_count > 0 && self.trigger_count >= self.preset_trigger_count {
289            self.status = BufferStatus::AcquisitionCompleted;
290        } else {
291            self.status = BufferStatus::BufferFilling;
292        }
293        result.sequence_done = true;
294    }
295
296    /// External trigger.
297    pub fn trigger(&mut self) {
298        // Don't trigger if acquisition already completed
299        if self.status == BufferStatus::AcquisitionCompleted {
300            return;
301        }
302
303        self.triggered = true;
304        self.post_done = 0;
305        self.pre_flushed = false;
306        self.trigger_count += 1;
307        self.status = BufferStatus::Flushing;
308        // The pre-buffer is flushed lazily on the first post-trigger push so
309        // the frames stream out in order with the post-trigger frames.
310        self.captured.clear();
311    }
312
313    /// Take the captured arrays (pre + post trigger).
314    pub fn take_captured(&mut self) -> Vec<Arc<NDArray>> {
315        std::mem::take(&mut self.captured)
316    }
317
318    pub fn is_triggered(&self) -> bool {
319        self.triggered
320    }
321
322    pub fn pre_buffer_len(&self) -> usize {
323        self.buffer.len()
324    }
325
326    pub fn reset(&mut self) {
327        self.buffer.clear();
328        self.captured.clear();
329        self.triggered = false;
330        self.post_done = 0;
331        self.pre_flushed = false;
332        self.trigger_count = 0;
333        self.status = BufferStatus::Idle;
334    }
335}
336
337// --- New CircularBuffProcessor (NDPluginProcess-based) ---
338
339/// The status string C `NDPluginCircularBuff::processCallbacks` leaves in
340/// `NDCircBuffStatus` (asynOctet) after one frame, given the post-push state.
341///
342/// Mirrors NDPluginCircularBuff.cpp:153-195: on a flushing frame the status is
343/// "Flushing"; once the post-trigger count is reached it becomes
344/// "Buffer filling"/"Dropping frames" (more triggers allowed) or
345/// "Acquisition Completed" (preset reached); while filling it only changes when
346/// the pre-buffer reaches its capacity ("Buffer Wrapping"/"Dropping frames").
347/// `None` means C makes no `setStringParam` call this frame (status unchanged).
348fn flush_path_status(
349    sequence_done: bool,
350    acquisition_completed: bool,
351    forwarded: bool,
352    pre_buffer_full: bool,
353    pre_count: usize,
354) -> Option<&'static str> {
355    if sequence_done {
356        Some(if acquisition_completed {
357            "Acquisition Completed"
358        } else if pre_count > 0 {
359            "Buffer filling"
360        } else {
361            "Dropping frames"
362        })
363    } else if forwarded {
364        Some("Flushing")
365    } else if pre_buffer_full {
366        Some(if pre_count > 0 {
367            "Buffer Wrapping"
368        } else {
369            "Dropping frames"
370        })
371    } else {
372        None
373    }
374}
375
376/// CircularBuff processor: maintains ring buffer state, emits captured arrays on trigger.
377#[derive(Default)]
378struct CBParamIndices {
379    control: Option<usize>,
380    status: Option<usize>,
381    trigger_a: Option<usize>,
382    trigger_b: Option<usize>,
383    trigger_a_val: Option<usize>,
384    trigger_b_val: Option<usize>,
385    trigger_calc: Option<usize>,
386    trigger_calc_val: Option<usize>,
387    pre_trigger: Option<usize>,
388    post_trigger: Option<usize>,
389    current_image: Option<usize>,
390    post_count: Option<usize>,
391    soft_trigger: Option<usize>,
392    triggered: Option<usize>,
393    preset_trigger_count: Option<usize>,
394    actual_trigger_count: Option<usize>,
395    flush_on_soft_trigger: Option<usize>,
396}
397
398pub struct CircularBuffProcessor {
399    buffer: CircularBuffer,
400    params: CBParamIndices,
401    /// C `maxBuffers_` — the plugin's input NDArray queue size, passed to
402    /// `NDCircularBuffConfigure` as `queueSize`. Bounds the accepted pre-count:
403    /// C rejects `preCount > maxBuffers_ - 1` (NDPluginCircularBuff.cpp:284).
404    max_buffers: usize,
405    // cached trigger attribute names and calc expression
406    trigger_a_name: String,
407    trigger_b_name: String,
408    trigger_calc_expr: String,
409}
410
411impl CircularBuffProcessor {
412    pub fn new(
413        pre_count: usize,
414        post_count: usize,
415        condition: TriggerCondition,
416        max_buffers: usize,
417    ) -> Self {
418        Self {
419            buffer: CircularBuffer::new(pre_count, post_count, condition),
420            params: CBParamIndices::default(),
421            max_buffers,
422            trigger_a_name: String::new(),
423            trigger_b_name: String::new(),
424            trigger_calc_expr: String::new(),
425        }
426    }
427
428    pub fn trigger(&mut self) {
429        self.buffer.trigger();
430    }
431
432    pub fn buffer(&self) -> &CircularBuffer {
433        &self.buffer
434    }
435
436    /// Rebuild the trigger condition from cached attribute names and calc expression.
437    fn rebuild_trigger_condition(&mut self) {
438        if !self.trigger_calc_expr.is_empty() {
439            if let Some(expr) = CalcExpression::parse(&self.trigger_calc_expr) {
440                self.buffer.trigger_condition = TriggerCondition::Calc {
441                    attr_a: self.trigger_a_name.clone(),
442                    attr_b: self.trigger_b_name.clone(),
443                    expression: expr,
444                };
445                return;
446            }
447        }
448        if !self.trigger_a_name.is_empty() {
449            self.buffer.trigger_condition = TriggerCondition::AttributeThreshold {
450                name: self.trigger_a_name.clone(),
451                threshold: 0.5,
452            };
453        } else {
454            self.buffer.trigger_condition = TriggerCondition::External;
455        }
456    }
457}
458
459impl NDPluginProcess for CircularBuffProcessor {
460    fn process_array(&mut self, array: &NDArray, _pool: &NDArrayPool) -> ProcessResult {
461        use ad_core_rs::plugin::runtime::ParamUpdate;
462
463        let push_result = self.buffer.push(Arc::new(array.clone()));
464
465        let mut updates = Vec::new();
466        if let Some(idx) = self.params.status {
467            // C NDCircBuffStatus is asynOctet: emit the status string C leaves
468            // after this frame (NDPluginCircularBuff.cpp:153-195), or nothing
469            // when C makes no setStringParam call (still filling below capacity).
470            let pre_count = self.buffer.pre_count;
471            if let Some(s) = flush_path_status(
472                push_result.sequence_done,
473                self.buffer.status() == BufferStatus::AcquisitionCompleted,
474                !push_result.forward.is_empty(),
475                self.buffer.pre_buffer_len() == pre_count,
476                pre_count,
477            ) {
478                updates.push(ParamUpdate::octet(idx, s.to_string()));
479            }
480        }
481        if let Some(idx) = self.params.current_image {
482            updates.push(ParamUpdate::int32(idx, self.buffer.pre_buffer_len() as i32));
483        }
484        if let Some(idx) = self.params.triggered {
485            updates.push(ParamUpdate::int32(
486                idx,
487                if self.buffer.is_triggered() { 1 } else { 0 },
488            ));
489        }
490        if let Some(idx) = self.params.actual_trigger_count {
491            updates.push(ParamUpdate::int32(idx, self.buffer.trigger_count() as i32));
492        }
493        // C posts the trigger calc inputs/result each evaluated frame
494        // (NDPluginCircularBuff.cpp:67-78).
495        if let Some(tv) = push_result.trigger_values {
496            if let Some(idx) = self.params.trigger_a_val {
497                updates.push(ParamUpdate::float64(idx, tv.a));
498            }
499            if let Some(idx) = self.params.trigger_b_val {
500                updates.push(ParamUpdate::float64(idx, tv.b));
501            }
502            if let Some(idx) = self.params.trigger_calc_val {
503                updates.push(ParamUpdate::float64(idx, tv.calc));
504            }
505        }
506
507        // Stream frames downstream as the C++ plugin does: pre-buffer frames
508        // are flushed at the trigger and each post-trigger frame is forwarded
509        // immediately, rather than being withheld until the sequence ends.
510        if push_result.forward.is_empty() {
511            ProcessResult::sink(updates)
512        } else {
513            let mut result = ProcessResult::arrays(push_result.forward);
514            result.param_updates = updates;
515            result
516        }
517    }
518
519    fn plugin_type(&self) -> &str {
520        "NDPluginCircularBuff"
521    }
522
523    fn register_params(
524        &mut self,
525        base: &mut asyn_rs::port::PortDriverBase,
526    ) -> asyn_rs::error::AsynResult<()> {
527        use asyn_rs::param::ParamType;
528        base.create_param("CIRC_BUFF_CONTROL", ParamType::Int32)?;
529        // C NDCircBuffStatus is asynParamOctet (NDPluginCircularBuff.cpp:411);
530        // the db binds it to a stringin/asynOctetRead record.
531        base.create_param("CIRC_BUFF_STATUS", ParamType::Octet)?;
532        base.create_param("CIRC_BUFF_TRIGGER_A", ParamType::Octet)?;
533        base.create_param("CIRC_BUFF_TRIGGER_B", ParamType::Octet)?;
534        base.create_param("CIRC_BUFF_TRIGGER_A_VAL", ParamType::Float64)?;
535        base.create_param("CIRC_BUFF_TRIGGER_B_VAL", ParamType::Float64)?;
536        base.create_param("CIRC_BUFF_TRIGGER_CALC", ParamType::Octet)?;
537        base.create_param("CIRC_BUFF_TRIGGER_CALC_VAL", ParamType::Float64)?;
538        base.create_param("CIRC_BUFF_PRE_TRIGGER", ParamType::Int32)?;
539        base.create_param("CIRC_BUFF_POST_TRIGGER", ParamType::Int32)?;
540        base.create_param("CIRC_BUFF_CURRENT_IMAGE", ParamType::Int32)?;
541        base.create_param("CIRC_BUFF_POST_COUNT", ParamType::Int32)?;
542        base.create_param("CIRC_BUFF_SOFT_TRIGGER", ParamType::Int32)?;
543        base.create_param("CIRC_BUFF_TRIGGERED", ParamType::Int32)?;
544        base.create_param("CIRC_BUFF_PRESET_TRIGGER_COUNT", ParamType::Int32)?;
545        base.create_param("CIRC_BUFF_ACTUAL_TRIGGER_COUNT", ParamType::Int32)?;
546        base.create_param("CIRC_BUFF_FLUSH_ON_SOFTTRIGGER", ParamType::Int32)?;
547
548        self.params.control = base.find_param("CIRC_BUFF_CONTROL");
549        self.params.status = base.find_param("CIRC_BUFF_STATUS");
550        self.params.trigger_a = base.find_param("CIRC_BUFF_TRIGGER_A");
551        self.params.trigger_b = base.find_param("CIRC_BUFF_TRIGGER_B");
552        self.params.trigger_a_val = base.find_param("CIRC_BUFF_TRIGGER_A_VAL");
553        self.params.trigger_b_val = base.find_param("CIRC_BUFF_TRIGGER_B_VAL");
554        self.params.trigger_calc = base.find_param("CIRC_BUFF_TRIGGER_CALC");
555        self.params.trigger_calc_val = base.find_param("CIRC_BUFF_TRIGGER_CALC_VAL");
556        self.params.pre_trigger = base.find_param("CIRC_BUFF_PRE_TRIGGER");
557        self.params.post_trigger = base.find_param("CIRC_BUFF_POST_TRIGGER");
558        self.params.current_image = base.find_param("CIRC_BUFF_CURRENT_IMAGE");
559        self.params.post_count = base.find_param("CIRC_BUFF_POST_COUNT");
560        self.params.soft_trigger = base.find_param("CIRC_BUFF_SOFT_TRIGGER");
561        self.params.triggered = base.find_param("CIRC_BUFF_TRIGGERED");
562        self.params.preset_trigger_count = base.find_param("CIRC_BUFF_PRESET_TRIGGER_COUNT");
563        self.params.actual_trigger_count = base.find_param("CIRC_BUFF_ACTUAL_TRIGGER_COUNT");
564        self.params.flush_on_soft_trigger = base.find_param("CIRC_BUFF_FLUSH_ON_SOFTTRIGGER");
565
566        // C sets NDCircBuffStatus to "Idle" in the constructor
567        // (NDPluginCircularBuff.cpp:432).
568        if let Some(idx) = self.params.status {
569            base.set_string_param(idx, 0, "Idle".into())?;
570        }
571        Ok(())
572    }
573
574    fn on_param_change(
575        &mut self,
576        reason: usize,
577        params: &ad_core_rs::plugin::runtime::PluginParamSnapshot,
578    ) -> ad_core_rs::plugin::runtime::ParamChangeResult {
579        use ad_core_rs::plugin::runtime::{ParamChangeResult, ParamChangeValue, ParamUpdate};
580
581        let mut updates = Vec::new();
582        if Some(reason) == self.params.control {
583            let v = params.value.as_i32();
584            if v == 1 {
585                // Start
586                self.buffer.reset();
587                self.buffer.status = BufferStatus::BufferFilling;
588                // C writeInt32(Control=1): "Buffer filling"/"Dropping frames"
589                // (NDPluginCircularBuff.cpp:252-253).
590                if let Some(idx) = self.params.status {
591                    let s = if self.buffer.pre_count > 0 {
592                        "Buffer filling"
593                    } else {
594                        "Dropping frames"
595                    };
596                    updates.push(ParamUpdate::octet(idx, s.to_string()));
597                }
598            } else {
599                // Stop
600                self.buffer.status = BufferStatus::Idle;
601                // C writeInt32(Control=0) resets the displayed image count to 0
602                // (NDPluginCircularBuff.cpp:259).
603                if let Some(idx) = self.params.current_image {
604                    updates.push(ParamUpdate::int32(idx, 0));
605                }
606                // C writeInt32(Control=0): "Acquisition Stopped"
607                // (NDPluginCircularBuff.cpp:260).
608                if let Some(idx) = self.params.status {
609                    updates.push(ParamUpdate::octet(idx, "Acquisition Stopped".to_string()));
610                }
611            }
612        } else if Some(reason) == self.params.pre_trigger {
613            // C writeInt32(NDCircBuffPreTrigger) validates before committing
614            // (NDPluginCircularBuff.cpp:280-292), in this exact order: reject
615            // while running, then a pre-count above `maxBuffers_-1`, then a
616            // negative value (each leaves the param at its old value with an
617            // explanatory status string), otherwise commit.
618            let value = params.value.as_i32();
619            let running = matches!(
620                self.buffer.status(),
621                BufferStatus::BufferFilling | BufferStatus::Flushing
622            );
623            let reject_msg = if running {
624                Some("Stop acquisition to set pre-count")
625            } else if value > self.max_buffers as i32 - 1 {
626                // The pre-trigger ring cannot exceed the input queue (C 284).
627                Some("Pre-count too high")
628            } else if value < 0 {
629                Some("Invalid pre-count value")
630            } else {
631                None
632            };
633            if let Some(msg) = reject_msg {
634                if let Some(idx) = self.params.status {
635                    updates.push(ParamUpdate::octet(idx, msg.to_string()));
636                }
637                // Revert the pre-committed param to the last accepted value
638                // (C never calls setIntegerParam on the reject paths).
639                if let Some(idx) = self.params.pre_trigger {
640                    updates.push(ParamUpdate::int32(idx, self.buffer.pre_count as i32));
641                }
642            } else {
643                self.buffer.pre_count = value as usize;
644            }
645        } else if Some(reason) == self.params.post_trigger {
646            self.buffer.post_count = params.value.as_i32().max(0) as usize;
647        } else if Some(reason) == self.params.preset_trigger_count {
648            self.buffer
649                .set_preset_trigger_count(params.value.as_i32().max(0) as usize);
650        } else if Some(reason) == self.params.flush_on_soft_trigger {
651            self.buffer
652                .set_flush_on_soft_trigger(params.value.as_i32() != 0);
653        } else if Some(reason) == self.params.soft_trigger {
654            if params.value.as_i32() != 0 {
655                self.buffer.trigger();
656            }
657        } else if Some(reason) == self.params.trigger_a {
658            if let ParamChangeValue::Octet(s) = &params.value {
659                self.trigger_a_name = s.clone();
660                self.rebuild_trigger_condition();
661            }
662        } else if Some(reason) == self.params.trigger_b {
663            if let ParamChangeValue::Octet(s) = &params.value {
664                self.trigger_b_name = s.clone();
665                self.rebuild_trigger_condition();
666            }
667        } else if Some(reason) == self.params.trigger_calc {
668            if let ParamChangeValue::Octet(s) = &params.value {
669                self.trigger_calc_expr = s.clone();
670                self.rebuild_trigger_condition();
671            }
672        }
673
674        ParamChangeResult::updates(updates)
675    }
676}
677
678#[cfg(test)]
679mod tests {
680    use super::*;
681    use ad_core_rs::attributes::{NDAttrSource, NDAttrValue, NDAttribute};
682    use ad_core_rs::ndarray::{NDDataType, NDDimension};
683
684    fn make_array(id: i32) -> Arc<NDArray> {
685        let mut arr = NDArray::new(vec![NDDimension::new(4)], NDDataType::UInt8);
686        arr.unique_id = id;
687        Arc::new(arr)
688    }
689
690    fn make_array_with_attr(id: i32, attr_val: f64) -> Arc<NDArray> {
691        let mut arr = NDArray::new(vec![NDDimension::new(4)], NDDataType::UInt8);
692        arr.unique_id = id;
693        arr.attributes.add(NDAttribute::new_static(
694            "trigger",
695            "",
696            NDAttrSource::Driver,
697            NDAttrValue::Float64(attr_val),
698        ));
699        Arc::new(arr)
700    }
701
702    fn make_array_with_attrs(id: i32, a_val: f64, b_val: f64) -> Arc<NDArray> {
703        let mut arr = NDArray::new(vec![NDDimension::new(4)], NDDataType::UInt8);
704        arr.unique_id = id;
705        arr.attributes.add(NDAttribute::new_static(
706            "attr_a",
707            "",
708            NDAttrSource::Driver,
709            NDAttrValue::Float64(a_val),
710        ));
711        arr.attributes.add(NDAttribute::new_static(
712            "attr_b",
713            "",
714            NDAttrSource::Driver,
715            NDAttrValue::Float64(b_val),
716        ));
717        Arc::new(arr)
718    }
719
720    #[test]
721    fn test_pre_trigger_buffering() {
722        let mut cb = CircularBuffer::new(3, 2, TriggerCondition::External);
723
724        for i in 0..5 {
725            cb.push(make_array(i));
726        }
727        // Pre-buffer should hold last 3
728        assert_eq!(cb.pre_buffer_len(), 3);
729    }
730
731    #[test]
732    fn test_external_trigger() {
733        let mut cb = CircularBuffer::new(2, 2, TriggerCondition::External);
734
735        cb.push(make_array(1));
736        cb.push(make_array(2));
737        cb.push(make_array(3));
738        // Pre-buffer: [2, 3]
739
740        cb.trigger();
741        assert!(cb.is_triggered());
742
743        // First post-trigger push flushes the pre-buffer and forwards frame 4.
744        let r1 = cb.push(make_array(4));
745        assert!(!r1.sequence_done);
746        let ids1: Vec<_> = r1.forward.iter().map(|a| a.unique_id).collect();
747        assert_eq!(ids1, vec![2, 3, 4]); // 2 pre + frame 4
748
749        // Second post-trigger push forwards frame 5 and completes.
750        let r2 = cb.push(make_array(5));
751        assert!(r2.sequence_done);
752        let ids2: Vec<_> = r2.forward.iter().map(|a| a.unique_id).collect();
753        assert_eq!(ids2, vec![5]);
754
755        let captured = cb.take_captured();
756        assert_eq!(captured.len(), 4); // 2 pre + 2 post
757        assert_eq!(captured[0].unique_id, 2);
758        assert_eq!(captured[1].unique_id, 3);
759        assert_eq!(captured[2].unique_id, 4);
760        assert_eq!(captured[3].unique_id, 5);
761    }
762
763    #[test]
764    fn test_post_count_zero_no_underflow() {
765        // Regression: post_count == 0 must complete the sequence on the first
766        // post-trigger frame instead of underflowing the post counter.
767        let mut cb = CircularBuffer::new(2, 0, TriggerCondition::External);
768        cb.push(make_array(1));
769        cb.push(make_array(2));
770        cb.trigger();
771        assert!(cb.is_triggered());
772
773        // First frame after the trigger: pre-buffer flushed + this frame,
774        // and the sequence completes immediately (postCount == 0).
775        let r = cb.push(make_array(3));
776        assert!(r.sequence_done);
777        let ids: Vec<_> = r.forward.iter().map(|a| a.unique_id).collect();
778        assert_eq!(ids, vec![1, 2, 3]);
779        assert!(!cb.is_triggered());
780        assert_eq!(cb.status(), BufferStatus::BufferFilling);
781
782        // No panic / no 2^64 capture; further frames just fill the pre-buffer.
783        let r2 = cb.push(make_array(4));
784        assert!(!r2.sequence_done);
785        assert!(r2.forward.is_empty());
786    }
787
788    #[test]
789    fn test_attribute_trigger_post_count_zero() {
790        // post_count == 0 with an attribute trigger: the triggering frame is
791        // forwarded and the sequence completes on the same push.
792        let mut cb = CircularBuffer::new(
793            1,
794            0,
795            TriggerCondition::AttributeThreshold {
796                name: "trigger".into(),
797                threshold: 5.0,
798            },
799        );
800        cb.push(make_array_with_attr(1, 1.0));
801        let r = cb.push(make_array_with_attr(2, 9.0));
802        assert!(r.sequence_done);
803        let ids: Vec<_> = r.forward.iter().map(|a| a.unique_id).collect();
804        assert_eq!(ids, vec![1, 2]); // 1 pre + triggering frame
805        assert!(!cb.is_triggered());
806    }
807
808    #[test]
809    fn test_attribute_trigger() {
810        let mut cb = CircularBuffer::new(
811            1,
812            2,
813            TriggerCondition::AttributeThreshold {
814                name: "trigger".into(),
815                threshold: 5.0,
816            },
817        );
818
819        cb.push(make_array_with_attr(1, 1.0));
820        cb.push(make_array_with_attr(2, 2.0));
821        assert!(!cb.is_triggered());
822
823        // This should trigger (attr >= 5.0); triggering frame is first post-trigger
824        let r3 = cb.push(make_array_with_attr(3, 5.0));
825        assert!(cb.is_triggered());
826        // Pre-buffer (id=2) flushed + triggering frame (id=3) forwarded now.
827        let ids3: Vec<_> = r3.forward.iter().map(|a| a.unique_id).collect();
828        assert_eq!(ids3, vec![2, 3]);
829
830        let r4 = cb.push(make_array(4));
831        assert!(r4.sequence_done);
832
833        let captured = cb.take_captured();
834        // 1 pre (id=2) + 2 post (id=3 triggering frame + id=4)
835        assert_eq!(captured.len(), 3);
836        assert_eq!(captured[0].unique_id, 2);
837        assert_eq!(captured[1].unique_id, 3);
838        assert_eq!(captured[2].unique_id, 4);
839    }
840
841    // --- New tests ---
842
843    #[test]
844    fn test_calc_trigger() {
845        // Expression: "A>5" — trigger when attribute A exceeds 5
846        let expr = CalcExpression::parse("A>5").unwrap();
847        let mut cb = CircularBuffer::new(
848            1,
849            2,
850            TriggerCondition::Calc {
851                attr_a: "attr_a".into(),
852                attr_b: "attr_b".into(),
853                expression: expr,
854            },
855        );
856
857        // A=3, should not trigger
858        cb.push(make_array_with_attrs(1, 3.0, 0.0));
859        assert!(!cb.is_triggered());
860
861        // A=6, should trigger; triggering frame is first post-trigger
862        cb.push(make_array_with_attrs(2, 6.0, 0.0));
863        assert!(cb.is_triggered());
864
865        let done = cb.push(make_array(3));
866        assert!(done.sequence_done);
867
868        let captured = cb.take_captured();
869        // 1 pre (id=1) + 2 post (id=2 triggering frame + id=3)
870        assert_eq!(captured.len(), 3);
871        assert_eq!(captured[0].unique_id, 1);
872        assert_eq!(captured[1].unique_id, 2);
873        assert_eq!(captured[2].unique_id, 3);
874    }
875
876    #[test]
877    fn test_calc_trigger_values_surface() {
878        // Regression for ADP-41: the Calc path must surface A, B, and the calc
879        // result so the processor can post TriggerAVal/BVal/CalcVal.
880        let expr = CalcExpression::parse("A+B").unwrap();
881        // post_count=3 so the triggering frame does not finish the sequence and
882        // frame 2 stays in the flushing branch.
883        let mut cb = CircularBuffer::new(
884            2,
885            3,
886            TriggerCondition::Calc {
887                attr_a: "attr_a".into(),
888                attr_b: "attr_b".into(),
889                expression: expr,
890            },
891        );
892
893        // Frame with A=3, B=4 → calc=7 (nonzero → triggers).
894        let r = cb.push(make_array_with_attrs(1, 3.0, 4.0));
895        let tv = r.trigger_values.expect("calc path surfaces trigger values");
896        assert_eq!(tv.a, 3.0);
897        assert_eq!(tv.b, 4.0);
898        assert_eq!(tv.calc, 7.0);
899
900        // Once triggered, the calc is not re-evaluated (C calculateTrigger is
901        // skipped while triggered), so no trigger values this frame.
902        let r2 = cb.push(make_array(2));
903        assert!(r2.trigger_values.is_none());
904    }
905
906    #[test]
907    fn test_calc_trigger_values_nan_when_attr_absent() {
908        // C posts NaN for a missing trigger attribute (triggerCalcArgs_ default
909        // epicsNAN); the calc of "A" with A absent is NaN.
910        let expr = CalcExpression::parse("A").unwrap();
911        let mut cb = CircularBuffer::new(
912            2,
913            1,
914            TriggerCondition::Calc {
915                attr_a: "missing_a".into(),
916                attr_b: "missing_b".into(),
917                expression: expr,
918            },
919        );
920        let r = cb.push(make_array(1));
921        let tv = r.trigger_values.expect("calc path surfaces trigger values");
922        assert!(tv.a.is_nan());
923        assert!(tv.b.is_nan());
924        assert!(tv.calc.is_nan());
925    }
926
927    #[test]
928    fn test_calc_trigger_skips_nan_and_inf_results() {
929        // C fires only on a finite non-zero calc result
930        // (NDPluginCircularBuff.cpp:77 `!isnan && !isinf && != 0`). A NaN or Inf
931        // result must NOT trigger, even though `NaN != 0.0` and `Inf != 0.0` are
932        // both true in Rust. Expression "A" surfaces the injected value directly.
933        // post_count = 2 so a single triggering push does not immediately
934        // complete the sequence and reset the triggered flag.
935        let push_calc = |val: f64| {
936            let expr = CalcExpression::parse("A").unwrap();
937            let mut cb = CircularBuffer::new(
938                2,
939                2,
940                TriggerCondition::Calc {
941                    attr_a: "attr_a".into(),
942                    attr_b: "attr_b".into(),
943                    expression: expr,
944                },
945            );
946            cb.push(make_array_with_attrs(1, val, 0.0));
947            cb.is_triggered()
948        };
949        // NaN and ±Inf results must not trigger.
950        assert!(!push_calc(f64::NAN));
951        assert!(!push_calc(f64::INFINITY));
952        assert!(!push_calc(f64::NEG_INFINITY));
953        // A finite non-zero result still triggers (the guard does not suppress
954        // a valid trigger); a finite zero still does not.
955        assert!(push_calc(1.0));
956        assert!(!push_calc(0.0));
957    }
958
959    #[test]
960    fn test_calc_expression_parse() {
961        // Simple comparison
962        let expr = CalcExpression::parse("A>5").unwrap();
963        assert_eq!(expr.evaluate(6.0, 0.0), 1.0);
964        assert_eq!(expr.evaluate(4.0, 0.0), 0.0);
965        assert_eq!(expr.evaluate(5.0, 0.0), 0.0); // not >=
966
967        // Greater-or-equal
968        let expr = CalcExpression::parse("A>=5").unwrap();
969        assert_eq!(expr.evaluate(5.0, 0.0), 1.0);
970        assert_eq!(expr.evaluate(4.9, 0.0), 0.0);
971
972        // Logical AND with two variables
973        let expr = CalcExpression::parse("A>3&&B<10").unwrap();
974        assert_eq!(expr.evaluate(4.0, 5.0), 1.0);
975        assert_eq!(expr.evaluate(2.0, 5.0), 0.0);
976        assert_eq!(expr.evaluate(4.0, 15.0), 0.0);
977
978        // Parenthesized OR
979        let expr = CalcExpression::parse("(A>10)||(B>10)").unwrap();
980        assert_eq!(expr.evaluate(11.0, 0.0), 1.0);
981        assert_eq!(expr.evaluate(0.0, 11.0), 1.0);
982        assert_eq!(expr.evaluate(0.0, 0.0), 0.0);
983
984        // Not-equal
985        let expr = CalcExpression::parse("A!=0").unwrap();
986        assert_eq!(expr.evaluate(1.0, 0.0), 1.0);
987        assert_eq!(expr.evaluate(0.0, 0.0), 0.0);
988
989        // Equality
990        let expr = CalcExpression::parse("A==B").unwrap();
991        assert_eq!(expr.evaluate(5.0, 5.0), 1.0);
992        assert_eq!(expr.evaluate(5.0, 6.0), 0.0);
993
994        // Not operator
995        let expr = CalcExpression::parse("!A").unwrap();
996        assert_eq!(expr.evaluate(0.0, 0.0), 1.0);
997        assert_eq!(expr.evaluate(1.0, 0.0), 0.0);
998
999        // The full EPICS calc engine treats single '=' as equality (like '==')
1000        // and single '&' as bitwise AND, so both are valid expressions.
1001        let expr = CalcExpression::parse("A=5").unwrap();
1002        assert_eq!(expr.evaluate(5.0, 0.0), 1.0);
1003        assert_eq!(expr.evaluate(4.0, 0.0), 0.0);
1004
1005        let expr = CalcExpression::parse("A&B").unwrap();
1006        // 3 & 1 = 1 (bitwise AND)
1007        assert_eq!(expr.evaluate(3.0, 1.0), 1.0);
1008
1009        // Test math functions supported by the full calc engine
1010        let expr = CalcExpression::parse("ABS(A)").unwrap();
1011        assert_eq!(expr.evaluate(-5.0, 0.0), 5.0);
1012
1013        let expr = CalcExpression::parse("SQRT(A)").unwrap();
1014        assert!((expr.evaluate(9.0, 0.0) - 3.0).abs() < 1e-10);
1015
1016        let expr = CalcExpression::parse("A+B").unwrap();
1017        assert_eq!(expr.evaluate(3.0, 4.0), 7.0);
1018
1019        let expr = CalcExpression::parse("A-B").unwrap();
1020        assert_eq!(expr.evaluate(10.0, 3.0), 7.0);
1021
1022        let expr = CalcExpression::parse("A*B").unwrap();
1023        assert_eq!(expr.evaluate(3.0, 4.0), 12.0);
1024
1025        let expr = CalcExpression::parse("A/B").unwrap();
1026        assert_eq!(expr.evaluate(12.0, 4.0), 3.0);
1027
1028        // Test variables C through F using evaluate_vars
1029        let expr = CalcExpression::parse("A>5&&C>0").unwrap();
1030        let mut vars = [0.0f64; calc::CALC_NARGS];
1031        vars[0] = 6.0; // A
1032        vars[2] = 1.0; // C
1033        assert_eq!(expr.evaluate_vars(&vars), 1.0);
1034        vars[2] = 0.0; // C=0 should fail the condition
1035        assert_eq!(expr.evaluate_vars(&vars), 0.0);
1036
1037        // Invalid expression returns None
1038        assert!(CalcExpression::parse("@@@").is_none());
1039    }
1040
1041    #[test]
1042    fn test_preset_trigger_count() {
1043        let mut cb = CircularBuffer::new(1, 1, TriggerCondition::External);
1044        cb.set_preset_trigger_count(2);
1045
1046        assert_eq!(cb.status(), BufferStatus::Idle);
1047
1048        // First push transitions to BufferFilling
1049        cb.push(make_array(1));
1050        assert_eq!(cb.status(), BufferStatus::BufferFilling);
1051
1052        // First trigger
1053        cb.trigger();
1054        assert_eq!(cb.trigger_count(), 1);
1055        assert_eq!(cb.status(), BufferStatus::Flushing);
1056
1057        let done = cb.push(make_array(2));
1058        assert!(done.sequence_done);
1059        assert_eq!(cb.status(), BufferStatus::BufferFilling); // back to filling after first capture
1060
1061        cb.take_captured();
1062
1063        // Refill buffer
1064        cb.push(make_array(3));
1065
1066        // Second trigger — should reach preset count
1067        cb.trigger();
1068        assert_eq!(cb.trigger_count(), 2);
1069        assert_eq!(cb.status(), BufferStatus::Flushing);
1070
1071        let done = cb.push(make_array(4));
1072        assert!(done.sequence_done);
1073        assert_eq!(cb.status(), BufferStatus::AcquisitionCompleted);
1074
1075        cb.take_captured();
1076
1077        // Further frames should be ignored
1078        let done = cb.push(make_array(5));
1079        assert!(!done.sequence_done);
1080        assert_eq!(cb.status(), BufferStatus::AcquisitionCompleted);
1081
1082        // Further triggers should be ignored
1083        cb.trigger();
1084        assert_eq!(cb.trigger_count(), 2); // unchanged
1085    }
1086
1087    #[test]
1088    fn test_stop_resets_current_image_and_status() {
1089        // Regression for ADP-43 (+ ADP-40 stop string): a Control=0 write posts
1090        // CURRENT_IMAGE=0 and STATUS="Acquisition Stopped".
1091        use ad_core_rs::plugin::runtime::{ParamChangeValue, ParamUpdate, PluginParamSnapshot};
1092
1093        let mut processor = CircularBuffProcessor::new(2, 1, TriggerCondition::External, 100);
1094        processor.params.control = Some(10);
1095        processor.params.current_image = Some(11);
1096        processor.params.status = Some(12);
1097
1098        let snapshot = PluginParamSnapshot {
1099            enable_callbacks: true,
1100            reason: 10,
1101            addr: 0,
1102            value: ParamChangeValue::Int32(0), // stop
1103        };
1104        let result = processor.on_param_change(10, &snapshot);
1105
1106        assert!(
1107            result.param_updates.iter().any(|u| matches!(
1108                u,
1109                ParamUpdate::Int32 {
1110                    reason: 11,
1111                    value: 0,
1112                    ..
1113                }
1114            )),
1115            "stop must post CURRENT_IMAGE=0"
1116        );
1117        assert!(
1118            result.param_updates.iter().any(|u| matches!(
1119                u,
1120                ParamUpdate::Octet { reason: 12, value, .. } if value == "Acquisition Stopped"
1121            )),
1122            "stop must post STATUS=Acquisition Stopped"
1123        );
1124    }
1125
1126    #[test]
1127    fn test_pre_count_validation() {
1128        // Regression for ADP-44: pre-count writes are rejected (status string +
1129        // param reverted) while running, above the maxBuffers-1 ceiling, and
1130        // for negative values, accepted otherwise (C NDPluginCircularBuff.cpp:
1131        // 280-292). maxBuffers_ is 10 here, so the ceiling is 9.
1132        use ad_core_rs::plugin::runtime::{ParamChangeValue, ParamUpdate, PluginParamSnapshot};
1133
1134        let make_proc = || {
1135            let mut p = CircularBuffProcessor::new(3, 1, TriggerCondition::External, 10);
1136            p.params.pre_trigger = Some(20);
1137            p.params.status = Some(12);
1138            p
1139        };
1140        let write = |p: &mut CircularBuffProcessor, v: i32| {
1141            let snap = PluginParamSnapshot {
1142                enable_callbacks: true,
1143                reason: 20,
1144                addr: 0,
1145                value: ParamChangeValue::Int32(v),
1146            };
1147            p.on_param_change(20, &snap)
1148        };
1149
1150        // Running → reject, status string, param reverted to old (3), unchanged.
1151        let mut p = make_proc();
1152        p.buffer.status = BufferStatus::BufferFilling;
1153        let r = write(&mut p, 7);
1154        assert_eq!(
1155            p.buffer.pre_count, 3,
1156            "reject while running, value unchanged"
1157        );
1158        assert!(r.param_updates.iter().any(|u| matches!(
1159            u,
1160            ParamUpdate::Octet { reason: 12, value, .. } if value == "Stop acquisition to set pre-count"
1161        )));
1162        assert!(r.param_updates.iter().any(|u| matches!(
1163            u,
1164            ParamUpdate::Int32 {
1165                reason: 20,
1166                value: 3,
1167                ..
1168            }
1169        )));
1170
1171        // Stopped + negative → reject with "Invalid pre-count value".
1172        let mut p = make_proc();
1173        p.buffer.status = BufferStatus::Idle;
1174        let r = write(&mut p, -1);
1175        assert_eq!(p.buffer.pre_count, 3, "negative rejected, value unchanged");
1176        assert!(r.param_updates.iter().any(|u| matches!(
1177            u,
1178            ParamUpdate::Octet { reason: 12, value, .. } if value == "Invalid pre-count value"
1179        )));
1180
1181        // Stopped + above maxBuffers-1 (9) → reject with "Pre-count too high".
1182        let mut p = make_proc();
1183        p.buffer.status = BufferStatus::Idle;
1184        let r = write(&mut p, 10);
1185        assert_eq!(p.buffer.pre_count, 3, "too-high rejected, value unchanged");
1186        assert!(r.param_updates.iter().any(|u| matches!(
1187            u,
1188            ParamUpdate::Octet { reason: 12, value, .. } if value == "Pre-count too high"
1189        )));
1190        assert!(r.param_updates.iter().any(|u| matches!(
1191            u,
1192            ParamUpdate::Int32 {
1193                reason: 20,
1194                value: 3,
1195                ..
1196            }
1197        )));
1198
1199        // Stopped + exactly maxBuffers-1 (9) → accept (boundary).
1200        let mut p = make_proc();
1201        p.buffer.status = BufferStatus::Idle;
1202        write(&mut p, 9);
1203        assert_eq!(p.buffer.pre_count, 9, "valid pre-count committed");
1204    }
1205
1206    #[test]
1207    fn test_flush_path_status_strings() {
1208        // Regression for ADP-40: NDCircBuffStatus is an Octet string; the
1209        // processCallbacks path must leave the exact C strings.
1210        // Flushing frame (a frame was forwarded, sequence not done).
1211        assert_eq!(
1212            flush_path_status(false, false, true, false, 5),
1213            Some("Flushing")
1214        );
1215        // Sequence completes, more triggers allowed, preCount>0.
1216        assert_eq!(
1217            flush_path_status(true, false, true, false, 5),
1218            Some("Buffer filling")
1219        );
1220        // Sequence completes, more triggers allowed, preCount==0.
1221        assert_eq!(
1222            flush_path_status(true, false, true, false, 0),
1223            Some("Dropping frames")
1224        );
1225        // Sequence completes, preset trigger count reached.
1226        assert_eq!(
1227            flush_path_status(true, true, true, false, 5),
1228            Some("Acquisition Completed")
1229        );
1230        // Filling, pre-buffer just reached capacity, preCount>0.
1231        assert_eq!(
1232            flush_path_status(false, false, false, true, 5),
1233            Some("Buffer Wrapping")
1234        );
1235        // Filling at capacity with preCount==0 → frames are dropped.
1236        assert_eq!(
1237            flush_path_status(false, false, false, true, 0),
1238            Some("Dropping frames")
1239        );
1240        // Still filling below capacity → C makes no setStringParam call.
1241        assert_eq!(flush_path_status(false, false, false, false, 5), None);
1242    }
1243
1244    #[test]
1245    fn test_buffer_status_transitions() {
1246        let mut cb = CircularBuffer::new(2, 1, TriggerCondition::External);
1247
1248        // Initial state
1249        assert_eq!(cb.status(), BufferStatus::Idle);
1250
1251        // First push -> BufferFilling
1252        cb.push(make_array(1));
1253        assert_eq!(cb.status(), BufferStatus::BufferFilling);
1254
1255        cb.push(make_array(2));
1256        assert_eq!(cb.status(), BufferStatus::BufferFilling);
1257
1258        // Trigger -> Flushing
1259        cb.trigger();
1260        assert_eq!(cb.status(), BufferStatus::Flushing);
1261
1262        // Post-trigger capture completes -> back to BufferFilling
1263        let done = cb.push(make_array(3));
1264        assert!(done.sequence_done);
1265        assert_eq!(cb.status(), BufferStatus::BufferFilling);
1266
1267        // Reset -> Idle
1268        cb.reset();
1269        assert_eq!(cb.status(), BufferStatus::Idle);
1270        assert_eq!(cb.trigger_count(), 0);
1271    }
1272}