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/// The parameter-library assignments C `NDPluginCircularBuff::processCallbacks`
90/// makes while handling one frame (NDPluginCircularBuff.cpp:120-203).
91///
92/// A field is `Some` exactly when C calls `setIntegerParam`/`setStringParam` for
93/// it on this frame, and `None` when C leaves the parameter untouched — which is
94/// how C freezes `NDCircBuffCurrentImage` at the pre-buffer size for the whole
95/// flush (it is assigned only on the pre-trigger branch, `:151`).
96///
97/// Later assignments in the same frame overwrite earlier ones, exactly as
98/// repeated `setIntegerParam` calls do before C's single trailing
99/// `callParamCallbacks()`: on a frame that both flushes and completes, clients
100/// see only the final `NDCircBuffPostCount = 0`, never the intermediate count.
101///
102/// [`CircularBuffer::push`] — the state machine that decides the transitions —
103/// is the single owner of these values; the processor only maps them onto
104/// parameter indices. Reconstructing them from the post-push buffer state is
105/// what produced the divergences this type removes.
106#[derive(Debug, Default, Clone, PartialEq, Eq)]
107pub struct FrameParams {
108    /// `NDCircBuffTriggered` — C `:127`/`:133` (every frame that evaluates the
109    /// trigger) and `:192`/`:194` (cleared when the sequence ends).
110    pub triggered: Option<i32>,
111    /// `NDCircBuffCurrentImage` — C `:151`, the pre-buffer size, assigned ONLY
112    /// on a pre-trigger frame.
113    pub current_image: Option<i32>,
114    /// `NDCircBuffPostCount` — C `:168-169` per forwarded post-trigger frame,
115    /// and `:193` (reset to 0) when the sequence re-arms.
116    pub post_count: Option<i32>,
117    /// `NDCircBuffActualTriggerCount` — C `:179-180`, incremented when the
118    /// post-trigger count is reached, not when the trigger fires.
119    pub actual_trigger_count: Option<i32>,
120    /// `NDCircBuffSoftTrigger` — C `:191`, the soft-trigger latch cleared on
121    /// re-arm.
122    pub soft_trigger: Option<i32>,
123    /// `NDCircBuffControl` — C `:190` (re-arm, still 1) and `:197` (the preset
124    /// trigger count was reached: C turns acquisition off).
125    pub control: Option<i32>,
126    /// `NDCircBuffStatus` — C's `setStringParam` calls (`:152-153`, `:157`,
127    /// `:194-195`, `:198`).
128    pub status: Option<&'static str>,
129}
130
131/// Result of pushing a frame: which frames to forward downstream now, whether a
132/// capture sequence completed on this push, and the parameter assignments C
133/// makes for the frame.
134#[derive(Debug, Default)]
135pub struct PushResult {
136    /// Frames to forward downstream immediately, in order.
137    pub forward: Vec<Arc<NDArray>>,
138    /// True if the post-trigger count was reached on this push.
139    pub sequence_done: bool,
140    /// Trigger calc inputs/result when the calc was evaluated this frame
141    /// (C `calculateTrigger` path). `None` when already triggered or for a
142    /// non-calc trigger condition.
143    pub trigger_values: Option<TriggerValues>,
144    /// Parameters C assigns for this frame — see [`FrameParams`].
145    pub params: FrameParams,
146}
147
148/// Circular buffer state for pre/post-trigger capture.
149pub struct CircularBuffer {
150    /// C `NDCircBuffControl` (`scopeControl`) — the acquisition gate, and the
151    /// ONLY thing that admits a frame. C wraps the entire body of
152    /// `processCallbacks` in `if (scopeControl) { ... } else { /* nothing */ }`
153    /// (NDPluginCircularBuff.cpp:121-203): while it is off, an arriving frame is
154    /// not triggered on, not copied, not buffered, and does not advance the
155    /// completion test.
156    ///
157    /// It is a distinct thing from [`Self::status`], which is C's
158    /// `NDCircBuffStatus` — a display string. The port previously had no Control
159    /// state and inferred "running" from that string, which is what let a
160    /// stopped (or never-started) plugin record frames. Starts off: C's param
161    /// defaults to 0, so nothing is recorded before a `Control = 1` write.
162    control: bool,
163    pub(crate) pre_count: usize,
164    pub(crate) post_count: usize,
165    buffer: VecDeque<Arc<NDArray>>,
166    pub(crate) trigger_condition: TriggerCondition,
167    triggered: bool,
168    /// Number of post-trigger frames forwarded so far for the current trigger.
169    post_done: usize,
170    /// True once the pre-buffer has been flushed for the current trigger.
171    pre_flushed: bool,
172    /// Frames captured for the current sequence (pre + post), for callers
173    /// that want the batch via [`CircularBuffer::take_captured`].
174    captured: Vec<Arc<NDArray>>,
175    /// Maximum number of triggers before stopping (0 = unlimited).
176    preset_trigger_count: usize,
177    /// C `actualTriggerCount`: capture sequences *completed* so far — bumped
178    /// when the post-trigger count is reached (NDPluginCircularBuff.cpp:179),
179    /// not when the trigger fires.
180    trigger_count: usize,
181    /// C `NDCircBuffFlushOnSoftTrig` (epicsInt32), held raw. The flush decision
182    /// is C's `if (flushOn > 0)` (NDPluginCircularBuff.cpp:276) and lives in the
183    /// single owner [`CircularBuffer::flushes_on_soft_trigger`] — storing a
184    /// pre-digested `bool` cost the sign, and the `!= 0` that produced it made a
185    /// negative FlushOnSoftTrig flush on the port and not in C.
186    flush_on_soft_trigger: i32,
187    /// Current buffer status.
188    pub(crate) status: BufferStatus,
189}
190
191impl CircularBuffer {
192    pub fn new(pre_count: usize, post_count: usize, condition: TriggerCondition) -> Self {
193        Self {
194            // C's NDCircBuffControl param defaults to 0 — the plugin records
195            // nothing until a `Control = 1` write. Call `start()`.
196            control: false,
197            pre_count,
198            post_count,
199            buffer: VecDeque::with_capacity(pre_count + 1),
200            trigger_condition: condition,
201            triggered: false,
202            post_done: 0,
203            pre_flushed: false,
204            captured: Vec::new(),
205            preset_trigger_count: 0,
206            trigger_count: 0,
207            flush_on_soft_trigger: 0,
208            status: BufferStatus::Idle,
209        }
210    }
211
212    /// Set the preset trigger count (0 = unlimited).
213    pub fn set_preset_trigger_count(&mut self, count: usize) {
214        self.preset_trigger_count = count;
215    }
216
217    /// C `actualTriggerCount` — the number of *completed* capture sequences.
218    /// Reads one less than the number of triggers fired while a flush is still
219    /// in progress; C only increments it at the end of the sequence.
220    pub fn trigger_count(&self) -> usize {
221        self.trigger_count
222    }
223
224    /// Get the current buffer status.
225    pub fn status(&self) -> BufferStatus {
226        self.status
227    }
228
229    /// Store `NDCircBuffFlushOnSoftTrig` as written (C `setIntegerParam`); the
230    /// value is interpreted only by [`Self::flushes_on_soft_trigger`].
231    pub fn set_flush_on_soft_trigger(&mut self, flush_on: i32) {
232        self.flush_on_soft_trigger = flush_on;
233    }
234
235    /// C `NDPluginCircularBuff.cpp:276` — `if (flushOn > 0) flushPreBuffer()`.
236    /// The ONLY reader of `flush_on_soft_trigger`: negative and zero both mean
237    /// "do not flush", so a `caput FlushOnSoftTrig -1` leaves the pre-buffer to
238    /// drain lazily with the first post-trigger frame, as in C.
239    pub fn flushes_on_soft_trigger(&self) -> bool {
240        self.flush_on_soft_trigger > 0
241    }
242
243    /// C `writeInt32(NDCircBuffControl, 1)` (NDPluginCircularBuff.cpp:233-254):
244    /// rebuild the ring, drop the trigger state, zero the counters, and turn
245    /// acquisition on. The only way [`Self::push`] starts admitting frames.
246    pub fn start(&mut self) {
247        self.reset();
248        self.control = true;
249        self.status = BufferStatus::BufferFilling;
250    }
251
252    /// C `writeInt32(NDCircBuffControl, 0)` (NDPluginCircularBuff.cpp:255-260):
253    /// acquisition off. C clears the trigger latches and the displayed image
254    /// count but leaves the ring alone, so a restart is a fresh `start()`.
255    pub fn stop(&mut self) {
256        self.control = false;
257        self.triggered = false;
258        self.status = BufferStatus::Idle;
259    }
260
261    /// C `scopeControl` — is the plugin acquiring? The single gate on admitting
262    /// a frame, and the same test C's `writeInt32(NDCircBuffPreTrigger)` uses to
263    /// reject a pre-count change (:281-283).
264    pub fn is_running(&self) -> bool {
265        self.control
266    }
267
268    /// Push an array into the circular buffer.
269    ///
270    /// Mirrors C++ `NDPluginCircularBuff::processCallbacks`: on the frame that
271    /// triggers, the pre-buffer is flushed immediately and the triggering
272    /// frame is forwarded as the first post-trigger frame; each subsequent
273    /// post-trigger frame is forwarded individually. The returned
274    /// [`PushResult::forward`] holds the frames to send downstream this call.
275    pub fn push(&mut self, array: Arc<NDArray>) -> PushResult {
276        let mut result = PushResult::default();
277
278        // C `:121-203` — "Are we running?". EVERYTHING below (the trigger
279        // evaluation, the array copy, the pre-buffer add, the flush, the post
280        // count, and the completion test) sits inside `if (scopeControl)`, whose
281        // else arm is literally `// Currently do nothing`. So a frame arriving
282        // while acquisition is off changes no state and forwards nothing — and
283        // `Control` is the whole gate: it goes off on a user stop AND when the
284        // preset trigger count completes the last sequence (`:197`).
285        if !self.control {
286            return result;
287        }
288
289        // C `:123-134` settles `triggered` for this frame BEFORE the branch: a
290        // latched trigger (soft trigger, or a previous frame's) short-circuits
291        // the calc; otherwise the calc runs and its outcome is posted.
292        if !self.triggered {
293            let fired = self.evaluate_trigger(&array, &mut result);
294            // C `:132` posts the trigger flag on every frame that evaluated the
295            // trigger calc, whether it fired or not.
296            result.params.triggered = Some(i32::from(fired));
297            if fired {
298                // The trigger fires before this frame reaches the pre-buffer, so
299                // it becomes the first post-trigger frame — C's triggered branch
300                // has no `addToEnd`.
301                self.trigger();
302            }
303        }
304
305        if !self.triggered {
306            // C `:142-155` — no trigger yet: the frame joins the pre-trigger ring.
307            self.buffer.push_back(array);
308            if self.buffer.len() > self.pre_count {
309                self.buffer.pop_front();
310            }
311            // C `:151` posts the ring size — on this branch only, so the value
312            // stays frozen at the pre-trigger size for the whole flush.
313            result.params.current_image = Some(self.buffer.len() as i32);
314            // C `:152-153` only touches the status once the ring is at capacity.
315            if self.buffer.len() == self.pre_count {
316                result.params.status = Some(if self.pre_count > 0 {
317                    "Buffer Wrapping"
318                } else {
319                    "Dropping frames"
320                });
321            }
322        } else {
323            // C `:156-175` — post-trigger: flush the ring once (`:162-166`), then
324            // forward this frame and bump the post count (`:168-171`).
325            result.params.status = Some("Flushing");
326            if !self.pre_flushed {
327                result.forward.extend(self.flush_pre_buffer());
328            }
329            self.captured.push(Arc::clone(&array));
330            result.forward.push(array);
331            self.post_done += 1;
332            result.params.post_count = Some(self.post_done as i32);
333        }
334
335        // C `:177-197` tests `currentPostCount >= postCount` OUTSIDE the
336        // triggered/untriggered branches — on every frame the plugin records.
337        // The test therefore also runs on an untriggered frame, where
338        // currentPostCount is 0: with postCount == 0 it passes, so C completes a
339        // sequence (bumping ActualTriggerCount and re-arming) once per *running*
340        // frame, triggered or not. Evaluating it only after a post-trigger push
341        // is the R8-71 divergence.
342        if self.post_done >= self.post_count {
343            self.complete_sequence(&mut result);
344        }
345
346        result
347    }
348
349    /// C `:130-131` / `calculateTrigger` — does this frame fire the trigger?
350    /// Records the calc inputs and result in `result` as a side effect, exactly
351    /// as C posts TriggerAVal/BVal/CalcVal on every evaluated frame
352    /// (NDPluginCircularBuff.cpp:67-78), regardless of the outcome.
353    fn evaluate_trigger(&self, array: &NDArray, result: &mut PushResult) -> bool {
354        match &self.trigger_condition {
355            TriggerCondition::AttributeThreshold { name, threshold } => array
356                .attributes
357                .get(name)
358                .and_then(|a| a.value.as_f64())
359                .map(|v| v >= *threshold)
360                .unwrap_or(false),
361            TriggerCondition::External => false,
362            TriggerCondition::Calc {
363                attr_a,
364                attr_b,
365                expression,
366            } => {
367                let a = array
368                    .attributes
369                    .get(attr_a)
370                    .and_then(|a| a.value.as_f64())
371                    .unwrap_or(f64::NAN);
372                let b = array
373                    .attributes
374                    .get(attr_b)
375                    .and_then(|a| a.value.as_f64())
376                    .unwrap_or(f64::NAN);
377                // C++ passes: A=attrValueA, B=attrValueB, C=preTrigger,
378                // D=postTrigger, E=currentImage, F=triggered
379                let mut vars = [0.0f64; calc::CALC_NARGS];
380                vars[0] = a; // A
381                vars[1] = b; // B
382                vars[2] = self.pre_count as f64; // C
383                vars[3] = self.post_count as f64; // D
384                vars[4] = self.buffer.len() as f64; // E (currentImage)
385                vars[5] = if self.triggered { 1.0 } else { 0.0 }; // F
386                let calc = expression.evaluate_vars(&vars);
387                result.trigger_values = Some(TriggerValues { a, b, calc });
388                // C fires only when the result is a finite non-zero
389                // (NDPluginCircularBuff.cpp:77 `!isnan && !isinf && != 0`); a
390                // NaN/Inf result (e.g. a missing trigger attribute → epicsNAN,
391                // or an `A/B` with a zero denominator) must NOT trigger.
392                // `f64::is_finite` is exactly `!isnan && !isinf`.
393                calc.is_finite() && calc != 0.0
394            }
395        }
396    }
397
398    /// C `flushPreBuffer()` (NDPluginCircularBuff.cpp:208-218): drain the
399    /// pre-trigger ring and hand every frame downstream, in order.
400    ///
401    /// The single owner of the drain. Both callers go through it — the first
402    /// post-trigger frame (`:162-166`) and the soft-trigger write when
403    /// FlushOnSoftTrig > 0 (`:276-277`) — and whichever runs second finds the
404    /// ring already empty, exactly as C does.
405    fn flush_pre_buffer(&mut self) -> Vec<Arc<NDArray>> {
406        self.pre_flushed = true;
407        let pre: Vec<Arc<NDArray>> = self.buffer.drain(..).collect();
408        self.captured.extend(pre.iter().cloned());
409        pre
410    }
411
412    /// Finalize a completed post-trigger sequence (C++
413    /// `currentPostCount >= postCount` branch, NDPluginCircularBuff.cpp:178-197):
414    /// advance status / trigger bookkeeping and signal completion.
415    fn complete_sequence(&mut self, result: &mut PushResult) {
416        self.triggered = false;
417        self.pre_flushed = false;
418        self.post_done = 0;
419        // C increments actualTriggerCount HERE — when the post-trigger count is
420        // reached — not when the trigger fires (`:179-180`). During a flush the
421        // count still reads the number of *completed* sequences.
422        self.trigger_count += 1;
423        result.params.actual_trigger_count = Some(self.trigger_count as i32);
424        if self.preset_trigger_count > 0 && self.trigger_count >= self.preset_trigger_count {
425            // C `:194-198`: preset reached — clear the trigger and turn
426            // acquisition off (NDCircBuffControl = 0). Turning it off here is
427            // what stops the NEXT frame: the gate at the top of `push` is the
428            // same one a user stop clears, so completion needs no separate
429            // "already completed" branch.
430            self.control = false;
431            self.status = BufferStatus::AcquisitionCompleted;
432            result.params.triggered = Some(0);
433            result.params.control = Some(0);
434            result.params.status = Some("Acquisition Completed");
435        } else {
436            // C `:188-195`: re-arm for the next trigger — the soft-trigger
437            // latch and the post count are cleared, control stays on.
438            self.status = BufferStatus::BufferFilling;
439            result.params.control = Some(1);
440            result.params.soft_trigger = Some(0);
441            result.params.triggered = Some(0);
442            result.params.post_count = Some(0);
443            result.params.status = Some(if self.pre_count > 0 {
444                "Buffer filling"
445            } else {
446                "Dropping frames"
447            });
448        }
449        result.sequence_done = true;
450    }
451
452    /// External trigger.
453    pub fn trigger(&mut self) {
454        // The trigger only means anything while acquiring — C evaluates and
455        // latches `triggered` inside `if (scopeControl)`, so a soft trigger
456        // arriving with acquisition off (never started, user-stopped, or the
457        // preset count completed) cannot start a flush.
458        if !self.control {
459            return;
460        }
461
462        self.triggered = true;
463        self.post_done = 0;
464        self.pre_flushed = false;
465        self.status = BufferStatus::Flushing;
466        // The pre-buffer is flushed lazily on the first post-trigger push so
467        // the frames stream out in order with the post-trigger frames.
468        self.captured.clear();
469    }
470
471    /// Take the captured arrays (pre + post trigger).
472    pub fn take_captured(&mut self) -> Vec<Arc<NDArray>> {
473        std::mem::take(&mut self.captured)
474    }
475
476    pub fn is_triggered(&self) -> bool {
477        self.triggered
478    }
479
480    pub fn pre_buffer_len(&self) -> usize {
481        self.buffer.len()
482    }
483
484    /// Drop the ring and every counter. Leaves acquisition OFF — `start()` is
485    /// the one entry point that turns it on.
486    pub fn reset(&mut self) {
487        self.control = false;
488        self.buffer.clear();
489        self.captured.clear();
490        self.triggered = false;
491        self.post_done = 0;
492        self.pre_flushed = false;
493        self.trigger_count = 0;
494        self.status = BufferStatus::Idle;
495    }
496}
497
498// --- New CircularBuffProcessor (NDPluginProcess-based) ---
499
500/// CircularBuff processor: maintains ring buffer state, emits captured arrays on trigger.
501#[derive(Default)]
502struct CBParamIndices {
503    control: Option<usize>,
504    status: Option<usize>,
505    trigger_a: Option<usize>,
506    trigger_b: Option<usize>,
507    trigger_a_val: Option<usize>,
508    trigger_b_val: Option<usize>,
509    trigger_calc: Option<usize>,
510    trigger_calc_val: Option<usize>,
511    pre_trigger: Option<usize>,
512    post_trigger: Option<usize>,
513    current_image: Option<usize>,
514    post_count: Option<usize>,
515    soft_trigger: Option<usize>,
516    triggered: Option<usize>,
517    preset_trigger_count: Option<usize>,
518    actual_trigger_count: Option<usize>,
519    flush_on_soft_trigger: Option<usize>,
520}
521
522pub struct CircularBuffProcessor {
523    buffer: CircularBuffer,
524    params: CBParamIndices,
525    /// C `maxBuffers_` — the plugin's input NDArray queue size, passed to
526    /// `NDCircularBuffConfigure` as `queueSize`. Bounds the accepted pre-count:
527    /// C rejects `preCount > maxBuffers_ - 1` (NDPluginCircularBuff.cpp:284).
528    max_buffers: usize,
529    // cached trigger attribute names and calc expression
530    trigger_a_name: String,
531    trigger_b_name: String,
532    trigger_calc_expr: String,
533}
534
535impl CircularBuffProcessor {
536    pub fn new(
537        pre_count: usize,
538        post_count: usize,
539        condition: TriggerCondition,
540        max_buffers: usize,
541    ) -> Self {
542        Self {
543            buffer: CircularBuffer::new(pre_count, post_count, condition),
544            params: CBParamIndices::default(),
545            max_buffers,
546            trigger_a_name: String::new(),
547            trigger_b_name: String::new(),
548            trigger_calc_expr: String::new(),
549        }
550    }
551
552    pub fn trigger(&mut self) {
553        self.buffer.trigger();
554    }
555
556    /// Turn acquisition on, as a `Control = 1` write does. Until this is called
557    /// the plugin records nothing — C's `NDCircBuffControl` starts at 0 and
558    /// `processCallbacks` does nothing while it is off.
559    pub fn start(&mut self) {
560        self.buffer.start();
561    }
562
563    /// Turn acquisition off, as a `Control = 0` write does.
564    pub fn stop(&mut self) {
565        self.buffer.stop();
566    }
567
568    pub fn buffer(&self) -> &CircularBuffer {
569        &self.buffer
570    }
571
572    /// Rebuild the trigger condition from cached attribute names and calc expression.
573    fn rebuild_trigger_condition(&mut self) {
574        if !self.trigger_calc_expr.is_empty() {
575            if let Some(expr) = CalcExpression::parse(&self.trigger_calc_expr) {
576                self.buffer.trigger_condition = TriggerCondition::Calc {
577                    attr_a: self.trigger_a_name.clone(),
578                    attr_b: self.trigger_b_name.clone(),
579                    expression: expr,
580                };
581                return;
582            }
583        }
584        if !self.trigger_a_name.is_empty() {
585            self.buffer.trigger_condition = TriggerCondition::AttributeThreshold {
586                name: self.trigger_a_name.clone(),
587                threshold: 0.5,
588            };
589        } else {
590            self.buffer.trigger_condition = TriggerCondition::External;
591        }
592    }
593}
594
595impl NDPluginProcess for CircularBuffProcessor {
596    fn process_array(&mut self, array: &NDArray, _pool: &NDArrayPool) -> ProcessResult {
597        use ad_core_rs::plugin::runtime::ParamUpdate;
598
599        let push_result = self.buffer.push(Arc::new(array.clone()));
600
601        // The buffer reports exactly the parameters C assigns for this frame
602        // (see `FrameParams`); the processor only maps them onto indices. A
603        // `None` field is a parameter C leaves alone — emitting a value for it
604        // is what froze CurrentImage at 0 during a flush and posted
605        // ActualTriggerCount a whole sequence early.
606        let mut updates = Vec::new();
607        let p = &push_result.params;
608        if let (Some(idx), Some(s)) = (self.params.status, p.status) {
609            // C NDCircBuffStatus is asynOctet (NDPluginCircularBuff.cpp:411).
610            updates.push(ParamUpdate::octet(idx, s.to_string()));
611        }
612        for (index, value) in [
613            (self.params.triggered, p.triggered),
614            (self.params.current_image, p.current_image),
615            (self.params.post_count, p.post_count),
616            (self.params.actual_trigger_count, p.actual_trigger_count),
617            (self.params.soft_trigger, p.soft_trigger),
618            (self.params.control, p.control),
619        ] {
620            if let (Some(idx), Some(v)) = (index, value) {
621                updates.push(ParamUpdate::int32(idx, v));
622            }
623        }
624        // C posts the trigger calc inputs/result each evaluated frame
625        // (NDPluginCircularBuff.cpp:67-78).
626        if let Some(tv) = push_result.trigger_values {
627            if let Some(idx) = self.params.trigger_a_val {
628                updates.push(ParamUpdate::float64(idx, tv.a));
629            }
630            if let Some(idx) = self.params.trigger_b_val {
631                updates.push(ParamUpdate::float64(idx, tv.b));
632            }
633            if let Some(idx) = self.params.trigger_calc_val {
634                updates.push(ParamUpdate::float64(idx, tv.calc));
635            }
636        }
637
638        // Stream frames downstream as the C++ plugin does: pre-buffer frames
639        // are flushed at the trigger and each post-trigger frame is forwarded
640        // immediately, rather than being withheld until the sequence ends.
641        if push_result.forward.is_empty() {
642            ProcessResult::sink(updates)
643        } else {
644            let mut result = ProcessResult::arrays(push_result.forward);
645            result.param_updates = updates;
646            result
647        }
648    }
649
650    fn plugin_type(&self) -> &str {
651        "NDPluginCircularBuff"
652    }
653
654    fn register_params(
655        &mut self,
656        base: &mut asyn_rs::port::PortDriverBase,
657    ) -> asyn_rs::error::AsynResult<()> {
658        use asyn_rs::param::ParamType;
659        base.create_param("CIRC_BUFF_CONTROL", ParamType::Int32)?;
660        // C NDCircBuffStatus is asynParamOctet (NDPluginCircularBuff.cpp:411);
661        // the db binds it to a stringin/asynOctetRead record.
662        base.create_param("CIRC_BUFF_STATUS", ParamType::Octet)?;
663        base.create_param("CIRC_BUFF_TRIGGER_A", ParamType::Octet)?;
664        base.create_param("CIRC_BUFF_TRIGGER_B", ParamType::Octet)?;
665        base.create_param("CIRC_BUFF_TRIGGER_A_VAL", ParamType::Float64)?;
666        base.create_param("CIRC_BUFF_TRIGGER_B_VAL", ParamType::Float64)?;
667        base.create_param("CIRC_BUFF_TRIGGER_CALC", ParamType::Octet)?;
668        base.create_param("CIRC_BUFF_TRIGGER_CALC_VAL", ParamType::Float64)?;
669        base.create_param("CIRC_BUFF_PRE_TRIGGER", ParamType::Int32)?;
670        base.create_param("CIRC_BUFF_POST_TRIGGER", ParamType::Int32)?;
671        base.create_param("CIRC_BUFF_CURRENT_IMAGE", ParamType::Int32)?;
672        base.create_param("CIRC_BUFF_POST_COUNT", ParamType::Int32)?;
673        base.create_param("CIRC_BUFF_SOFT_TRIGGER", ParamType::Int32)?;
674        base.create_param("CIRC_BUFF_TRIGGERED", ParamType::Int32)?;
675        base.create_param("CIRC_BUFF_PRESET_TRIGGER_COUNT", ParamType::Int32)?;
676        base.create_param("CIRC_BUFF_ACTUAL_TRIGGER_COUNT", ParamType::Int32)?;
677        base.create_param("CIRC_BUFF_FLUSH_ON_SOFTTRIGGER", ParamType::Int32)?;
678
679        self.params.control = base.find_param("CIRC_BUFF_CONTROL");
680        self.params.status = base.find_param("CIRC_BUFF_STATUS");
681        self.params.trigger_a = base.find_param("CIRC_BUFF_TRIGGER_A");
682        self.params.trigger_b = base.find_param("CIRC_BUFF_TRIGGER_B");
683        self.params.trigger_a_val = base.find_param("CIRC_BUFF_TRIGGER_A_VAL");
684        self.params.trigger_b_val = base.find_param("CIRC_BUFF_TRIGGER_B_VAL");
685        self.params.trigger_calc = base.find_param("CIRC_BUFF_TRIGGER_CALC");
686        self.params.trigger_calc_val = base.find_param("CIRC_BUFF_TRIGGER_CALC_VAL");
687        self.params.pre_trigger = base.find_param("CIRC_BUFF_PRE_TRIGGER");
688        self.params.post_trigger = base.find_param("CIRC_BUFF_POST_TRIGGER");
689        self.params.current_image = base.find_param("CIRC_BUFF_CURRENT_IMAGE");
690        self.params.post_count = base.find_param("CIRC_BUFF_POST_COUNT");
691        self.params.soft_trigger = base.find_param("CIRC_BUFF_SOFT_TRIGGER");
692        self.params.triggered = base.find_param("CIRC_BUFF_TRIGGERED");
693        self.params.preset_trigger_count = base.find_param("CIRC_BUFF_PRESET_TRIGGER_COUNT");
694        self.params.actual_trigger_count = base.find_param("CIRC_BUFF_ACTUAL_TRIGGER_COUNT");
695        self.params.flush_on_soft_trigger = base.find_param("CIRC_BUFF_FLUSH_ON_SOFTTRIGGER");
696
697        // C sets NDCircBuffStatus to "Idle" in the constructor
698        // (NDPluginCircularBuff.cpp:432).
699        if let Some(idx) = self.params.status {
700            base.set_string_param(idx, 0, "Idle".into())?;
701        }
702        Ok(())
703    }
704
705    fn on_param_change(
706        &mut self,
707        reason: usize,
708        params: &ad_core_rs::plugin::runtime::PluginParamSnapshot,
709    ) -> ad_core_rs::plugin::runtime::ParamChangeResult {
710        use ad_core_rs::plugin::runtime::{ParamChangeResult, ParamChangeValue, ParamUpdate};
711
712        let mut updates = Vec::new();
713        if Some(reason) == self.params.control {
714            let v = params.value.as_i32();
715            if v == 1 {
716                // Start. C writeInt32(Control=1) rebuilds the ring and zeroes
717                // the whole runtime counter set before posting the status
718                // (NDPluginCircularBuff.cpp:249-254), and turns `scopeControl`
719                // on — which is what makes `push` admit frames at all.
720                self.buffer.start();
721                for (index, value) in [
722                    (self.params.soft_trigger, 0),
723                    (self.params.triggered, 0),
724                    (self.params.post_count, 0),
725                    (self.params.actual_trigger_count, 0),
726                ] {
727                    if let Some(idx) = index {
728                        updates.push(ParamUpdate::int32(idx, value));
729                    }
730                }
731                // C writeInt32(Control=1): "Buffer filling"/"Dropping frames"
732                // (NDPluginCircularBuff.cpp:255).
733                if let Some(idx) = self.params.status {
734                    let s = if self.buffer.pre_count > 0 {
735                        "Buffer filling"
736                    } else {
737                        "Dropping frames"
738                    };
739                    updates.push(ParamUpdate::octet(idx, s.to_string()));
740                }
741            } else {
742                // Stop. C writeInt32(Control=0) turns `scopeControl` off and
743                // clears the trigger latches and the displayed image count
744                // (NDPluginCircularBuff.cpp:255-260). From here `push` admits
745                // nothing until the next Control=1.
746                self.buffer.stop();
747                for (index, value) in [
748                    (self.params.soft_trigger, 0),
749                    (self.params.triggered, 0),
750                    (self.params.current_image, 0),
751                ] {
752                    if let Some(idx) = index {
753                        updates.push(ParamUpdate::int32(idx, value));
754                    }
755                }
756                // C writeInt32(Control=0): "Acquisition Stopped"
757                // (NDPluginCircularBuff.cpp:260).
758                if let Some(idx) = self.params.status {
759                    updates.push(ParamUpdate::octet(idx, "Acquisition Stopped".to_string()));
760                }
761            }
762        } else if Some(reason) == self.params.pre_trigger {
763            // C writeInt32(NDCircBuffPreTrigger) validates before committing
764            // (NDPluginCircularBuff.cpp:280-292), in this exact order: reject
765            // while running, then a pre-count above `maxBuffers_-1`, then a
766            // negative value (each leaves the param at its old value with an
767            // explanatory status string), otherwise commit.
768            let value = params.value.as_i32();
769            // C reads NDCircBuffControl for this test (`:281-282`), not the
770            // status string — the same gate `processCallbacks` runs on.
771            let reject_msg = if self.buffer.is_running() {
772                Some("Stop acquisition to set pre-count")
773            } else if value > self.max_buffers as i32 - 1 {
774                // The pre-trigger ring cannot exceed the input queue (C 284).
775                Some("Pre-count too high")
776            } else if value < 0 {
777                Some("Invalid pre-count value")
778            } else {
779                None
780            };
781            if let Some(msg) = reject_msg {
782                if let Some(idx) = self.params.status {
783                    updates.push(ParamUpdate::octet(idx, msg.to_string()));
784                }
785                // Revert the pre-committed param to the last accepted value
786                // (C never calls setIntegerParam on the reject paths).
787                if let Some(idx) = self.params.pre_trigger {
788                    updates.push(ParamUpdate::int32(idx, self.buffer.pre_count as i32));
789                }
790            } else {
791                self.buffer.pre_count = value as usize;
792            }
793        } else if Some(reason) == self.params.post_trigger {
794            self.buffer.post_count = params.value.as_i32().max(0) as usize;
795        } else if Some(reason) == self.params.preset_trigger_count {
796            self.buffer
797                .set_preset_trigger_count(params.value.as_i32().max(0) as usize);
798        } else if Some(reason) == self.params.flush_on_soft_trigger {
799            self.buffer.set_flush_on_soft_trigger(params.value.as_i32());
800        } else if Some(reason) == self.params.soft_trigger {
801            // The write's job, beyond storing the parameter, is to make the soft
802            // trigger take effect IMMEDIATELY — latch Triggered and flush the ring
803            // — instead of waiting for the next frame, which `process_array` would
804            // do anyway from the stored level (C `processCallbacks:123-125`:
805            // `if (softTrigger) triggered = 1;`, re-asserted every frame).
806            //
807            // DEVIATION from C, deliberate — CBUG-B11. C's writeInt32 arm
808            // (NDPluginCircularBuff.cpp:266-278) stores `value` (:268) and then
809            // never tests it: it latches NDCircBuffTriggered = 1 (:271) and
810            // flushes (:276-278) UNCONDITIONALLY. So `caput SoftTrigger 0` — the
811            // natural way to disarm between acquisitions, and what an
812            // autosave/PINI restore writes at boot — fires the trigger on C
813            // exactly like `caput SoftTrigger 1`. 0 unambiguously means "not
814            // triggered" in this plugin's own vocabulary: the Control on and
815            // Control off paths both disarm by writing SoftTrigger = 0 alongside
816            // Triggered = 0 (:248-249, :257-258), and processCallbacks only
817            // asserts the trigger `if (softTrigger)`.
818            //
819            // Writing 0 is therefore a no-op here, which is exactly "stops
820            // asserting the trigger" — the same level semantics processCallbacks
821            // has. It does not clear an already-latched Triggered: C clears that
822            // only on a Control transition, and a latch set by the ATTRIBUTE
823            // trigger condition is not this parameter's to cancel.
824            if params.value.as_i32() != 0 {
825                self.buffer.trigger();
826                if let Some(idx) = self.params.triggered {
827                    updates.push(ParamUpdate::int32(idx, 1));
828                }
829                // C `:273-277`: when FlushOnSoftTrig > 0 the pre-buffer is flushed
830                // from the write itself, not lazily on the next frame — the ring
831                // reaches the downstream plugins before any post-trigger frame.
832                if self.buffer.flushes_on_soft_trigger() {
833                    let flushed = self.buffer.flush_pre_buffer();
834                    if !flushed.is_empty() {
835                        return ParamChangeResult::combined(flushed, updates);
836                    }
837                }
838            }
839        } else if Some(reason) == self.params.trigger_a {
840            if let ParamChangeValue::Octet(s) = &params.value {
841                self.trigger_a_name = s.clone();
842                self.rebuild_trigger_condition();
843            }
844        } else if Some(reason) == self.params.trigger_b {
845            if let ParamChangeValue::Octet(s) = &params.value {
846                self.trigger_b_name = s.clone();
847                self.rebuild_trigger_condition();
848            }
849        } else if Some(reason) == self.params.trigger_calc {
850            if let ParamChangeValue::Octet(s) = &params.value {
851                self.trigger_calc_expr = s.clone();
852                self.rebuild_trigger_condition();
853            }
854        }
855
856        ParamChangeResult::updates(updates)
857    }
858}
859
860#[cfg(test)]
861mod tests {
862    use super::*;
863    use ad_core_rs::attributes::{NDAttrSource, NDAttrValue, NDAttribute};
864    use ad_core_rs::ndarray::{NDDataType, NDDimension};
865
866    fn make_array(id: i32) -> Arc<NDArray> {
867        let mut arr = NDArray::new(vec![NDDimension::new(4)], NDDataType::UInt8);
868        arr.unique_id = id;
869        Arc::new(arr)
870    }
871
872    fn make_array_with_attr(id: i32, attr_val: f64) -> Arc<NDArray> {
873        let mut arr = NDArray::new(vec![NDDimension::new(4)], NDDataType::UInt8);
874        arr.unique_id = id;
875        arr.attributes.add(NDAttribute::new_static(
876            "trigger",
877            "",
878            NDAttrSource::Driver,
879            NDAttrValue::Float64(attr_val),
880        ));
881        Arc::new(arr)
882    }
883
884    fn make_array_with_attrs(id: i32, a_val: f64, b_val: f64) -> Arc<NDArray> {
885        let mut arr = NDArray::new(vec![NDDimension::new(4)], NDDataType::UInt8);
886        arr.unique_id = id;
887        arr.attributes.add(NDAttribute::new_static(
888            "attr_a",
889            "",
890            NDAttrSource::Driver,
891            NDAttrValue::Float64(a_val),
892        ));
893        arr.attributes.add(NDAttribute::new_static(
894            "attr_b",
895            "",
896            NDAttrSource::Driver,
897            NDAttrValue::Float64(b_val),
898        ));
899        Arc::new(arr)
900    }
901
902    #[test]
903    fn test_pre_trigger_buffering() {
904        let mut cb = CircularBuffer::new(3, 2, TriggerCondition::External);
905        cb.start(); // C: NDCircBuffControl = 1 (the plugin only records while acquiring)
906
907        for i in 0..5 {
908            cb.push(make_array(i));
909        }
910        // Pre-buffer should hold last 3
911        assert_eq!(cb.pre_buffer_len(), 3);
912    }
913
914    #[test]
915    fn test_external_trigger() {
916        let mut cb = CircularBuffer::new(2, 2, TriggerCondition::External);
917        cb.start(); // C: NDCircBuffControl = 1 (the plugin only records while acquiring)
918
919        cb.push(make_array(1));
920        cb.push(make_array(2));
921        cb.push(make_array(3));
922        // Pre-buffer: [2, 3]
923
924        cb.trigger();
925        assert!(cb.is_triggered());
926
927        // First post-trigger push flushes the pre-buffer and forwards frame 4.
928        let r1 = cb.push(make_array(4));
929        assert!(!r1.sequence_done);
930        let ids1: Vec<_> = r1.forward.iter().map(|a| a.unique_id).collect();
931        assert_eq!(ids1, vec![2, 3, 4]); // 2 pre + frame 4
932
933        // Second post-trigger push forwards frame 5 and completes.
934        let r2 = cb.push(make_array(5));
935        assert!(r2.sequence_done);
936        let ids2: Vec<_> = r2.forward.iter().map(|a| a.unique_id).collect();
937        assert_eq!(ids2, vec![5]);
938
939        let captured = cb.take_captured();
940        assert_eq!(captured.len(), 4); // 2 pre + 2 post
941        assert_eq!(captured[0].unique_id, 2);
942        assert_eq!(captured[1].unique_id, 3);
943        assert_eq!(captured[2].unique_id, 4);
944        assert_eq!(captured[3].unique_id, 5);
945    }
946
947    #[test]
948    fn test_post_count_zero_no_underflow() {
949        // Regression: post_count == 0 must complete the sequence on the first
950        // post-trigger frame instead of underflowing the post counter.
951        let mut cb = CircularBuffer::new(2, 0, TriggerCondition::External);
952        cb.start(); // C: NDCircBuffControl = 1 (the plugin only records while acquiring)
953        cb.push(make_array(1));
954        cb.push(make_array(2));
955        cb.trigger();
956        assert!(cb.is_triggered());
957
958        // First frame after the trigger: pre-buffer flushed + this frame,
959        // and the sequence completes immediately (postCount == 0).
960        let r = cb.push(make_array(3));
961        assert!(r.sequence_done);
962        let ids: Vec<_> = r.forward.iter().map(|a| a.unique_id).collect();
963        assert_eq!(ids, vec![1, 2, 3]);
964        assert!(!cb.is_triggered());
965        assert_eq!(cb.status(), BufferStatus::BufferFilling);
966
967        // No panic / no 2^64 capture; further frames just fill the pre-buffer.
968        // They are not forwarded — but with postCount == 0 each of them still
969        // *completes* a sequence, because C evaluates `currentPostCount >=
970        // postCount` outside the triggered branches (NDPluginCircularBuff.cpp:178).
971        // The `!r2.sequence_done` this test used to assert was invented; C's
972        // value is `true` (R8-71, covered by
973        // `test_post_count_zero_completes_on_untriggered_frame`).
974        let r2 = cb.push(make_array(4));
975        assert!(r2.sequence_done);
976        assert!(r2.forward.is_empty());
977    }
978
979    #[test]
980    fn test_post_count_zero_completes_on_untriggered_frame() {
981        // R8-71. C tests `currentPostCount >= postCount` OUTSIDE the
982        // triggered/untriggered branches (NDPluginCircularBuff.cpp:178, inside
983        // `if (pArrayCpy)`), so with postCount == 0 the test passes on an
984        // *untriggered* frame too (currentPostCount is 0 there): C completes a
985        // sequence — bumping ActualTriggerCount and re-arming — once per running
986        // frame, without forwarding it.
987        let mut cb = CircularBuffer::new(2, 0, TriggerCondition::External);
988        cb.start(); // C: NDCircBuffControl = 1 (the plugin only records while acquiring)
989
990        for (n, id) in (1..=3).enumerate() {
991            let r = cb.push(make_array(id));
992            // Untriggered: the frame joins the pre-buffer, nothing is forwarded.
993            assert!(r.forward.is_empty(), "frame {id} must not be forwarded");
994            // ...yet the sequence completes (C `:178-191`).
995            assert!(r.sequence_done, "frame {id} must complete a sequence");
996            assert_eq!(r.params.actual_trigger_count, Some(n as i32 + 1));
997            assert_eq!(cb.trigger_count(), n + 1);
998            // C `:186-189` re-arms: control stays on, latches and post count clear.
999            assert_eq!(r.params.control, Some(1));
1000            assert_eq!(r.params.soft_trigger, Some(0));
1001            assert_eq!(r.params.triggered, Some(0));
1002            assert_eq!(r.params.post_count, Some(0));
1003            assert_eq!(r.params.status, Some("Buffer filling"));
1004        }
1005        // C never clears the ring on re-arm, so the pre-buffer keeps filling
1006        // across the completed sequences: 3 frames, preCount 2 → last two held.
1007        assert_eq!(cb.pre_buffer_len(), 2);
1008
1009        // Boundary: postCount > 0 — the same untriggered frame must NOT complete,
1010        // because currentPostCount (0) < postCount.
1011        let mut cb = CircularBuffer::new(2, 1, TriggerCondition::External);
1012        cb.start(); // C: NDCircBuffControl = 1 (the plugin only records while acquiring)
1013        let r = cb.push(make_array(1));
1014        assert!(!r.sequence_done);
1015        assert_eq!(r.params.actual_trigger_count, None);
1016        assert_eq!(cb.trigger_count(), 0);
1017    }
1018
1019    #[test]
1020    fn test_post_count_zero_untriggered_frames_reach_preset_trigger_count() {
1021        // R8-71, preset boundary: with postCount == 0 the per-frame completion
1022        // also drives ActualTriggerCount into PresetTriggerCount, so C stops
1023        // acquisition (Control = 0) after `preset` untriggered frames
1024        // (NDPluginCircularBuff.cpp:181-196).
1025        let mut cb = CircularBuffer::new(2, 0, TriggerCondition::External);
1026        cb.start(); // C: NDCircBuffControl = 1 (the plugin only records while acquiring)
1027        cb.set_preset_trigger_count(2);
1028
1029        let r1 = cb.push(make_array(1));
1030        assert!(r1.sequence_done);
1031        assert_eq!(r1.params.actual_trigger_count, Some(1));
1032        assert_eq!(cb.status(), BufferStatus::BufferFilling);
1033
1034        let r2 = cb.push(make_array(2));
1035        assert_eq!(r2.params.actual_trigger_count, Some(2));
1036        // Preset reached — C `:193-195`: Triggered = 0, Control = 0.
1037        assert_eq!(r2.params.control, Some(0));
1038        assert_eq!(r2.params.status, Some("Acquisition Completed"));
1039        assert_eq!(cb.status(), BufferStatus::AcquisitionCompleted);
1040    }
1041
1042    #[test]
1043    fn test_attribute_trigger_post_count_zero() {
1044        // post_count == 0 with an attribute trigger: the triggering frame is
1045        // forwarded and the sequence completes on the same push.
1046        let mut cb = CircularBuffer::new(
1047            1,
1048            0,
1049            TriggerCondition::AttributeThreshold {
1050                name: "trigger".into(),
1051                threshold: 5.0,
1052            },
1053        );
1054        cb.start(); // C: NDCircBuffControl = 1 (the plugin only records while acquiring)
1055        cb.push(make_array_with_attr(1, 1.0));
1056        let r = cb.push(make_array_with_attr(2, 9.0));
1057        assert!(r.sequence_done);
1058        let ids: Vec<_> = r.forward.iter().map(|a| a.unique_id).collect();
1059        assert_eq!(ids, vec![1, 2]); // 1 pre + triggering frame
1060        assert!(!cb.is_triggered());
1061    }
1062
1063    #[test]
1064    fn test_attribute_trigger() {
1065        let mut cb = CircularBuffer::new(
1066            1,
1067            2,
1068            TriggerCondition::AttributeThreshold {
1069                name: "trigger".into(),
1070                threshold: 5.0,
1071            },
1072        );
1073        cb.start(); // C: NDCircBuffControl = 1 (the plugin only records while acquiring)
1074
1075        cb.push(make_array_with_attr(1, 1.0));
1076        cb.push(make_array_with_attr(2, 2.0));
1077        assert!(!cb.is_triggered());
1078
1079        // This should trigger (attr >= 5.0); triggering frame is first post-trigger
1080        let r3 = cb.push(make_array_with_attr(3, 5.0));
1081        assert!(cb.is_triggered());
1082        // Pre-buffer (id=2) flushed + triggering frame (id=3) forwarded now.
1083        let ids3: Vec<_> = r3.forward.iter().map(|a| a.unique_id).collect();
1084        assert_eq!(ids3, vec![2, 3]);
1085
1086        let r4 = cb.push(make_array(4));
1087        assert!(r4.sequence_done);
1088
1089        let captured = cb.take_captured();
1090        // 1 pre (id=2) + 2 post (id=3 triggering frame + id=4)
1091        assert_eq!(captured.len(), 3);
1092        assert_eq!(captured[0].unique_id, 2);
1093        assert_eq!(captured[1].unique_id, 3);
1094        assert_eq!(captured[2].unique_id, 4);
1095    }
1096
1097    // --- New tests ---
1098
1099    #[test]
1100    fn test_calc_trigger() {
1101        // Expression: "A>5" — trigger when attribute A exceeds 5
1102        let expr = CalcExpression::parse("A>5").unwrap();
1103        let mut cb = CircularBuffer::new(
1104            1,
1105            2,
1106            TriggerCondition::Calc {
1107                attr_a: "attr_a".into(),
1108                attr_b: "attr_b".into(),
1109                expression: expr,
1110            },
1111        );
1112        cb.start(); // C: NDCircBuffControl = 1 (the plugin only records while acquiring)
1113
1114        // A=3, should not trigger
1115        cb.push(make_array_with_attrs(1, 3.0, 0.0));
1116        assert!(!cb.is_triggered());
1117
1118        // A=6, should trigger; triggering frame is first post-trigger
1119        cb.push(make_array_with_attrs(2, 6.0, 0.0));
1120        assert!(cb.is_triggered());
1121
1122        let done = cb.push(make_array(3));
1123        assert!(done.sequence_done);
1124
1125        let captured = cb.take_captured();
1126        // 1 pre (id=1) + 2 post (id=2 triggering frame + id=3)
1127        assert_eq!(captured.len(), 3);
1128        assert_eq!(captured[0].unique_id, 1);
1129        assert_eq!(captured[1].unique_id, 2);
1130        assert_eq!(captured[2].unique_id, 3);
1131    }
1132
1133    #[test]
1134    fn test_calc_trigger_values_surface() {
1135        // Regression for ADP-41: the Calc path must surface A, B, and the calc
1136        // result so the processor can post TriggerAVal/BVal/CalcVal.
1137        let expr = CalcExpression::parse("A+B").unwrap();
1138        // post_count=3 so the triggering frame does not finish the sequence and
1139        // frame 2 stays in the flushing branch.
1140        let mut cb = CircularBuffer::new(
1141            2,
1142            3,
1143            TriggerCondition::Calc {
1144                attr_a: "attr_a".into(),
1145                attr_b: "attr_b".into(),
1146                expression: expr,
1147            },
1148        );
1149        cb.start(); // C: NDCircBuffControl = 1 (the plugin only records while acquiring)
1150
1151        // Frame with A=3, B=4 → calc=7 (nonzero → triggers).
1152        let r = cb.push(make_array_with_attrs(1, 3.0, 4.0));
1153        let tv = r.trigger_values.expect("calc path surfaces trigger values");
1154        assert_eq!(tv.a, 3.0);
1155        assert_eq!(tv.b, 4.0);
1156        assert_eq!(tv.calc, 7.0);
1157
1158        // Once triggered, the calc is not re-evaluated (C calculateTrigger is
1159        // skipped while triggered), so no trigger values this frame.
1160        let r2 = cb.push(make_array(2));
1161        assert!(r2.trigger_values.is_none());
1162    }
1163
1164    #[test]
1165    fn test_calc_trigger_values_nan_when_attr_absent() {
1166        // C posts NaN for a missing trigger attribute (triggerCalcArgs_ default
1167        // epicsNAN); the calc of "A" with A absent is NaN.
1168        let expr = CalcExpression::parse("A").unwrap();
1169        let mut cb = CircularBuffer::new(
1170            2,
1171            1,
1172            TriggerCondition::Calc {
1173                attr_a: "missing_a".into(),
1174                attr_b: "missing_b".into(),
1175                expression: expr,
1176            },
1177        );
1178        cb.start(); // C: NDCircBuffControl = 1 (the plugin only records while acquiring)
1179        let r = cb.push(make_array(1));
1180        let tv = r.trigger_values.expect("calc path surfaces trigger values");
1181        assert!(tv.a.is_nan());
1182        assert!(tv.b.is_nan());
1183        assert!(tv.calc.is_nan());
1184    }
1185
1186    #[test]
1187    fn test_calc_trigger_skips_nan_and_inf_results() {
1188        // C fires only on a finite non-zero calc result
1189        // (NDPluginCircularBuff.cpp:77 `!isnan && !isinf && != 0`). A NaN or Inf
1190        // result must NOT trigger, even though `NaN != 0.0` and `Inf != 0.0` are
1191        // both true in Rust. Expression "A" surfaces the injected value directly.
1192        // post_count = 2 so a single triggering push does not immediately
1193        // complete the sequence and reset the triggered flag.
1194        let push_calc = |val: f64| {
1195            let expr = CalcExpression::parse("A").unwrap();
1196            let mut cb = CircularBuffer::new(
1197                2,
1198                2,
1199                TriggerCondition::Calc {
1200                    attr_a: "attr_a".into(),
1201                    attr_b: "attr_b".into(),
1202                    expression: expr,
1203                },
1204            );
1205            cb.start(); // C: NDCircBuffControl = 1 (the plugin only records while acquiring)
1206            cb.push(make_array_with_attrs(1, val, 0.0));
1207            cb.is_triggered()
1208        };
1209        // NaN and ±Inf results must not trigger.
1210        assert!(!push_calc(f64::NAN));
1211        assert!(!push_calc(f64::INFINITY));
1212        assert!(!push_calc(f64::NEG_INFINITY));
1213        // A finite non-zero result still triggers (the guard does not suppress
1214        // a valid trigger); a finite zero still does not.
1215        assert!(push_calc(1.0));
1216        assert!(!push_calc(0.0));
1217    }
1218
1219    #[test]
1220    fn test_calc_expression_parse() {
1221        // Simple comparison
1222        let expr = CalcExpression::parse("A>5").unwrap();
1223        assert_eq!(expr.evaluate(6.0, 0.0), 1.0);
1224        assert_eq!(expr.evaluate(4.0, 0.0), 0.0);
1225        assert_eq!(expr.evaluate(5.0, 0.0), 0.0); // not >=
1226
1227        // Greater-or-equal
1228        let expr = CalcExpression::parse("A>=5").unwrap();
1229        assert_eq!(expr.evaluate(5.0, 0.0), 1.0);
1230        assert_eq!(expr.evaluate(4.9, 0.0), 0.0);
1231
1232        // Logical AND with two variables
1233        let expr = CalcExpression::parse("A>3&&B<10").unwrap();
1234        assert_eq!(expr.evaluate(4.0, 5.0), 1.0);
1235        assert_eq!(expr.evaluate(2.0, 5.0), 0.0);
1236        assert_eq!(expr.evaluate(4.0, 15.0), 0.0);
1237
1238        // Parenthesized OR
1239        let expr = CalcExpression::parse("(A>10)||(B>10)").unwrap();
1240        assert_eq!(expr.evaluate(11.0, 0.0), 1.0);
1241        assert_eq!(expr.evaluate(0.0, 11.0), 1.0);
1242        assert_eq!(expr.evaluate(0.0, 0.0), 0.0);
1243
1244        // Not-equal
1245        let expr = CalcExpression::parse("A!=0").unwrap();
1246        assert_eq!(expr.evaluate(1.0, 0.0), 1.0);
1247        assert_eq!(expr.evaluate(0.0, 0.0), 0.0);
1248
1249        // Equality
1250        let expr = CalcExpression::parse("A==B").unwrap();
1251        assert_eq!(expr.evaluate(5.0, 5.0), 1.0);
1252        assert_eq!(expr.evaluate(5.0, 6.0), 0.0);
1253
1254        // Not operator
1255        let expr = CalcExpression::parse("!A").unwrap();
1256        assert_eq!(expr.evaluate(0.0, 0.0), 1.0);
1257        assert_eq!(expr.evaluate(1.0, 0.0), 0.0);
1258
1259        // The full EPICS calc engine treats single '=' as equality (like '==')
1260        // and single '&' as bitwise AND, so both are valid expressions.
1261        let expr = CalcExpression::parse("A=5").unwrap();
1262        assert_eq!(expr.evaluate(5.0, 0.0), 1.0);
1263        assert_eq!(expr.evaluate(4.0, 0.0), 0.0);
1264
1265        let expr = CalcExpression::parse("A&B").unwrap();
1266        // 3 & 1 = 1 (bitwise AND)
1267        assert_eq!(expr.evaluate(3.0, 1.0), 1.0);
1268
1269        // Test math functions supported by the full calc engine
1270        let expr = CalcExpression::parse("ABS(A)").unwrap();
1271        assert_eq!(expr.evaluate(-5.0, 0.0), 5.0);
1272
1273        let expr = CalcExpression::parse("SQRT(A)").unwrap();
1274        assert!((expr.evaluate(9.0, 0.0) - 3.0).abs() < 1e-10);
1275
1276        let expr = CalcExpression::parse("A+B").unwrap();
1277        assert_eq!(expr.evaluate(3.0, 4.0), 7.0);
1278
1279        let expr = CalcExpression::parse("A-B").unwrap();
1280        assert_eq!(expr.evaluate(10.0, 3.0), 7.0);
1281
1282        let expr = CalcExpression::parse("A*B").unwrap();
1283        assert_eq!(expr.evaluate(3.0, 4.0), 12.0);
1284
1285        let expr = CalcExpression::parse("A/B").unwrap();
1286        assert_eq!(expr.evaluate(12.0, 4.0), 3.0);
1287
1288        // Test variables C through F using evaluate_vars
1289        let expr = CalcExpression::parse("A>5&&C>0").unwrap();
1290        let mut vars = [0.0f64; calc::CALC_NARGS];
1291        vars[0] = 6.0; // A
1292        vars[2] = 1.0; // C
1293        assert_eq!(expr.evaluate_vars(&vars), 1.0);
1294        vars[2] = 0.0; // C=0 should fail the condition
1295        assert_eq!(expr.evaluate_vars(&vars), 0.0);
1296
1297        // Invalid expression returns None
1298        assert!(CalcExpression::parse("@@@").is_none());
1299    }
1300
1301    #[test]
1302    fn test_preset_trigger_count() {
1303        let mut cb = CircularBuffer::new(1, 1, TriggerCondition::External);
1304        cb.start(); // C: NDCircBuffControl = 1 (the plugin only records while acquiring)
1305        cb.set_preset_trigger_count(2);
1306
1307        // C's Control=1 write sets the status string to "Buffer filling"
1308        // straight away (NDPluginCircularBuff.cpp:253-254) — it does not wait
1309        // for a frame.
1310        assert_eq!(cb.status(), BufferStatus::BufferFilling);
1311
1312        cb.push(make_array(1));
1313        assert_eq!(cb.status(), BufferStatus::BufferFilling);
1314
1315        // First trigger. C's actualTriggerCount does not move until the
1316        // post-trigger count is reached (NDPluginCircularBuff.cpp:179).
1317        cb.trigger();
1318        assert_eq!(cb.trigger_count(), 0);
1319        assert_eq!(cb.status(), BufferStatus::Flushing);
1320
1321        let done = cb.push(make_array(2));
1322        assert!(done.sequence_done);
1323        assert_eq!(cb.trigger_count(), 1); // counted at completion
1324        assert_eq!(cb.status(), BufferStatus::BufferFilling); // back to filling after first capture
1325
1326        cb.take_captured();
1327
1328        // Refill buffer
1329        cb.push(make_array(3));
1330
1331        // Second trigger — completing it reaches the preset count
1332        cb.trigger();
1333        assert_eq!(cb.trigger_count(), 1);
1334        assert_eq!(cb.status(), BufferStatus::Flushing);
1335
1336        let done = cb.push(make_array(4));
1337        assert!(done.sequence_done);
1338        assert_eq!(cb.trigger_count(), 2);
1339        assert_eq!(cb.status(), BufferStatus::AcquisitionCompleted);
1340
1341        cb.take_captured();
1342
1343        // Further frames should be ignored
1344        let done = cb.push(make_array(5));
1345        assert!(!done.sequence_done);
1346        assert_eq!(cb.status(), BufferStatus::AcquisitionCompleted);
1347
1348        // Further triggers should be ignored
1349        cb.trigger();
1350        assert_eq!(cb.trigger_count(), 2); // unchanged
1351    }
1352
1353    #[test]
1354    fn test_stop_resets_current_image_and_status() {
1355        // Regression for ADP-43 (+ ADP-40 stop string): a Control=0 write posts
1356        // CURRENT_IMAGE=0 and STATUS="Acquisition Stopped".
1357        use ad_core_rs::plugin::runtime::{ParamChangeValue, ParamUpdate, PluginParamSnapshot};
1358
1359        let mut processor = CircularBuffProcessor::new(2, 1, TriggerCondition::External, 100);
1360        processor.params.control = Some(10);
1361        processor.params.current_image = Some(11);
1362        processor.params.status = Some(12);
1363
1364        let snapshot = PluginParamSnapshot {
1365            enable_callbacks: true,
1366            reason: 10,
1367            addr: 0,
1368            value: ParamChangeValue::Int32(0), // stop
1369        };
1370        let result = processor.on_param_change(10, &snapshot);
1371
1372        assert!(
1373            result.param_updates.iter().any(|u| matches!(
1374                u,
1375                ParamUpdate::Int32 {
1376                    reason: 11,
1377                    value: 0,
1378                    ..
1379                }
1380            )),
1381            "stop must post CURRENT_IMAGE=0"
1382        );
1383        assert!(
1384            result.param_updates.iter().any(|u| matches!(
1385                u,
1386                ParamUpdate::Octet { reason: 12, value, .. } if value == "Acquisition Stopped"
1387            )),
1388            "stop must post STATUS=Acquisition Stopped"
1389        );
1390    }
1391
1392    #[test]
1393    fn test_pre_count_validation() {
1394        // Regression for ADP-44: pre-count writes are rejected (status string +
1395        // param reverted) while running, above the maxBuffers-1 ceiling, and
1396        // for negative values, accepted otherwise (C NDPluginCircularBuff.cpp:
1397        // 280-292). maxBuffers_ is 10 here, so the ceiling is 9.
1398        use ad_core_rs::plugin::runtime::{ParamChangeValue, ParamUpdate, PluginParamSnapshot};
1399
1400        let make_proc = || {
1401            let mut p = CircularBuffProcessor::new(3, 1, TriggerCondition::External, 10);
1402            p.params.pre_trigger = Some(20);
1403            p.params.status = Some(12);
1404            p
1405        };
1406        let write = |p: &mut CircularBuffProcessor, v: i32| {
1407            let snap = PluginParamSnapshot {
1408                enable_callbacks: true,
1409                reason: 20,
1410                addr: 0,
1411                value: ParamChangeValue::Int32(v),
1412            };
1413            p.on_param_change(20, &snap)
1414        };
1415
1416        // Running → reject, status string, param reverted to old (3), unchanged.
1417        let mut p = make_proc();
1418        p.buffer.start();
1419        let r = write(&mut p, 7);
1420        assert_eq!(
1421            p.buffer.pre_count, 3,
1422            "reject while running, value unchanged"
1423        );
1424        assert!(r.param_updates.iter().any(|u| matches!(
1425            u,
1426            ParamUpdate::Octet { reason: 12, value, .. } if value == "Stop acquisition to set pre-count"
1427        )));
1428        assert!(r.param_updates.iter().any(|u| matches!(
1429            u,
1430            ParamUpdate::Int32 {
1431                reason: 20,
1432                value: 3,
1433                ..
1434            }
1435        )));
1436
1437        // Stopped + negative → reject with "Invalid pre-count value".
1438        let mut p = make_proc();
1439        p.buffer.stop();
1440        let r = write(&mut p, -1);
1441        assert_eq!(p.buffer.pre_count, 3, "negative rejected, value unchanged");
1442        assert!(r.param_updates.iter().any(|u| matches!(
1443            u,
1444            ParamUpdate::Octet { reason: 12, value, .. } if value == "Invalid pre-count value"
1445        )));
1446
1447        // Stopped + above maxBuffers-1 (9) → reject with "Pre-count too high".
1448        let mut p = make_proc();
1449        p.buffer.stop();
1450        let r = write(&mut p, 10);
1451        assert_eq!(p.buffer.pre_count, 3, "too-high rejected, value unchanged");
1452        assert!(r.param_updates.iter().any(|u| matches!(
1453            u,
1454            ParamUpdate::Octet { reason: 12, value, .. } if value == "Pre-count too high"
1455        )));
1456        assert!(r.param_updates.iter().any(|u| matches!(
1457            u,
1458            ParamUpdate::Int32 {
1459                reason: 20,
1460                value: 3,
1461                ..
1462            }
1463        )));
1464
1465        // Stopped + exactly maxBuffers-1 (9) → accept (boundary).
1466        let mut p = make_proc();
1467        p.buffer.stop();
1468        write(&mut p, 9);
1469        assert_eq!(p.buffer.pre_count, 9, "valid pre-count committed");
1470    }
1471
1472    #[test]
1473    fn test_frame_status_strings() {
1474        // ADP-40: NDCircBuffStatus is an Octet string and `push` now owns it —
1475        // the exact C strings, on exactly the frames C calls setStringParam.
1476        // Filling below capacity: C makes no setStringParam call at all.
1477        let mut cb = CircularBuffer::new(2, 2, TriggerCondition::External);
1478        cb.start(); // C: NDCircBuffControl = 1 (the plugin only records while acquiring)
1479        assert_eq!(cb.push(make_array(1)).params.status, None);
1480        // Ring reaches capacity → "Buffer Wrapping" on this and every later
1481        // filling frame.
1482        assert_eq!(
1483            cb.push(make_array(2)).params.status,
1484            Some("Buffer Wrapping")
1485        );
1486        assert_eq!(
1487            cb.push(make_array(3)).params.status,
1488            Some("Buffer Wrapping")
1489        );
1490        // Flushing frame (forwarded, sequence not done).
1491        cb.trigger();
1492        assert_eq!(cb.push(make_array(4)).params.status, Some("Flushing"));
1493        // Sequence completes with more triggers allowed → back to filling.
1494        assert_eq!(cb.push(make_array(5)).params.status, Some("Buffer filling"));
1495
1496        // preCount == 0: the ring is always "at capacity", so C reports dropped
1497        // frames both while filling and on completion.
1498        let mut cb = CircularBuffer::new(0, 1, TriggerCondition::External);
1499        cb.start(); // C: NDCircBuffControl = 1 (the plugin only records while acquiring)
1500        assert_eq!(
1501            cb.push(make_array(1)).params.status,
1502            Some("Dropping frames")
1503        );
1504        cb.trigger();
1505        assert_eq!(
1506            cb.push(make_array(2)).params.status,
1507            Some("Dropping frames")
1508        );
1509
1510        // Preset trigger count reached → "Acquisition Completed".
1511        let mut cb = CircularBuffer::new(2, 1, TriggerCondition::External);
1512        cb.start(); // C: NDCircBuffControl = 1 (the plugin only records while acquiring)
1513        cb.set_preset_trigger_count(1);
1514        cb.trigger();
1515        assert_eq!(
1516            cb.push(make_array(1)).params.status,
1517            Some("Acquisition Completed")
1518        );
1519    }
1520
1521    #[test]
1522    fn test_post_count_posted_per_flushed_frame() {
1523        // R8-65: C increments currentPostCount and posts NDCircBuffPostCount on
1524        // every forwarded post-trigger frame (NDPluginCircularBuff.cpp:168-169),
1525        // then resets it to 0 when the sequence re-arms (:193). The port cached
1526        // the param index but never emitted an update, so PostCount_RBV read 0
1527        // forever.
1528        let mut cb = CircularBuffer::new(2, 3, TriggerCondition::External);
1529        cb.start(); // C: NDCircBuffControl = 1 (the plugin only records while acquiring)
1530        // Pre-trigger frames touch neither the post count...
1531        assert_eq!(cb.push(make_array(1)).params.post_count, None);
1532        assert_eq!(cb.push(make_array(2)).params.post_count, None);
1533
1534        cb.trigger();
1535        assert_eq!(cb.push(make_array(3)).params.post_count, Some(1));
1536        assert_eq!(cb.push(make_array(4)).params.post_count, Some(2));
1537        // Third post-trigger frame completes the sequence: C posts the count (3)
1538        // and then 0 from the re-arm branch, so the client only ever sees 0.
1539        assert_eq!(cb.push(make_array(5)).params.post_count, Some(0));
1540
1541        // Re-armed: the next sequence counts from 1 again.
1542        cb.trigger();
1543        assert_eq!(cb.push(make_array(6)).params.post_count, Some(1));
1544    }
1545
1546    #[test]
1547    fn test_post_count_survives_acquisition_completed() {
1548        // On the "Acquisition Completed" branch C does NOT reset PostCount
1549        // (:194-198 has no setIntegerParam(NDCircBuffPostCount, 0)), so the
1550        // final count stays visible after the preset trigger count is reached.
1551        let mut cb = CircularBuffer::new(1, 2, TriggerCondition::External);
1552        cb.start(); // C: NDCircBuffControl = 1 (the plugin only records while acquiring)
1553        cb.set_preset_trigger_count(1);
1554        cb.trigger();
1555        assert_eq!(cb.push(make_array(1)).params.post_count, Some(1));
1556        let done = cb.push(make_array(2));
1557        assert_eq!(done.params.post_count, Some(2), "final count, not reset");
1558        assert_eq!(done.params.status, Some("Acquisition Completed"));
1559        assert_eq!(done.params.control, Some(0), "C turns acquisition off");
1560    }
1561
1562    #[test]
1563    fn test_current_image_frozen_during_flush() {
1564        // R8-65 sibling: C assigns NDCircBuffCurrentImage only on the
1565        // pre-trigger branch (`:151`), so during a flush the value stays frozen
1566        // at the pre-buffer size it had when the trigger fired. The port posted
1567        // `pre_buffer_len()` on every frame — and the flush drains the ring, so
1568        // it posted 0 for the whole capture.
1569        let mut cb = CircularBuffer::new(3, 2, TriggerCondition::External);
1570        cb.start(); // C: NDCircBuffControl = 1 (the plugin only records while acquiring)
1571        assert_eq!(cb.push(make_array(1)).params.current_image, Some(1));
1572        assert_eq!(cb.push(make_array(2)).params.current_image, Some(2));
1573
1574        cb.trigger();
1575        // Flushing frames leave the parameter alone — no update, so the record
1576        // holds the last pre-trigger size (2).
1577        let r1 = cb.push(make_array(3));
1578        assert_eq!(cb.pre_buffer_len(), 0, "the flush drained the ring");
1579        assert_eq!(r1.params.current_image, None);
1580        assert_eq!(cb.push(make_array(4)).params.current_image, None);
1581
1582        // Back to filling: the ring size is reported again, from 1.
1583        assert_eq!(cb.push(make_array(5)).params.current_image, Some(1));
1584    }
1585
1586    #[test]
1587    fn test_actual_trigger_count_increments_at_sequence_completion() {
1588        // R8-65 sibling: C increments actualTriggerCount when the post-trigger
1589        // count is reached (`:179-180`), not when the trigger fires. The port
1590        // bumped it inside trigger() and posted it every frame, so
1591        // ActualTriggerCount_RBV stepped a whole sequence early.
1592        let mut cb = CircularBuffer::new(1, 2, TriggerCondition::External);
1593        cb.start(); // C: NDCircBuffControl = 1 (the plugin only records while acquiring)
1594        cb.push(make_array(1));
1595        assert_eq!(cb.trigger_count(), 0);
1596
1597        cb.trigger();
1598        assert_eq!(cb.trigger_count(), 0, "the trigger alone completes nothing");
1599
1600        // First post-trigger frame: still mid-sequence, no count update.
1601        let r1 = cb.push(make_array(2));
1602        assert_eq!(r1.params.actual_trigger_count, None);
1603        assert_eq!(cb.trigger_count(), 0);
1604
1605        // Second (last) post-trigger frame: the sequence completes and the count
1606        // moves to 1, together with the re-arm parameters C writes.
1607        let r2 = cb.push(make_array(3));
1608        assert!(r2.sequence_done);
1609        assert_eq!(r2.params.actual_trigger_count, Some(1));
1610        assert_eq!(cb.trigger_count(), 1);
1611        assert_eq!(r2.params.soft_trigger, Some(0), "C clears the soft latch");
1612        assert_eq!(r2.params.triggered, Some(0));
1613        assert_eq!(r2.params.control, Some(1), "still acquiring");
1614    }
1615
1616    #[test]
1617    fn test_processor_emits_the_frame_params() {
1618        // The processor maps `FrameParams` onto the registered indices: a
1619        // flushing frame must emit POST_COUNT and leave CURRENT_IMAGE alone.
1620        use ad_core_rs::ndarray::{NDDataType, NDDimension};
1621        use ad_core_rs::plugin::runtime::ParamUpdate;
1622
1623        let mut p = CircularBuffProcessor::new(2, 2, TriggerCondition::External, 100);
1624        p.buffer.start(); // C: NDCircBuffControl = 1
1625        p.params.current_image = Some(11);
1626        p.params.post_count = Some(13);
1627        p.params.actual_trigger_count = Some(16);
1628        let pool = NDArrayPool::new(0);
1629        let frame = || NDArray::new(vec![NDDimension::new(4)], NDDataType::UInt8);
1630        let int32s = |r: &ProcessResult| -> Vec<(usize, i32)> {
1631            r.param_updates
1632                .iter()
1633                .filter_map(|u| match u {
1634                    ParamUpdate::Int32 { reason, value, .. } => Some((*reason, *value)),
1635                    _ => None,
1636                })
1637                .collect()
1638        };
1639
1640        // Pre-trigger frame: CURRENT_IMAGE=1, no POST_COUNT.
1641        let r = p.process_array(&frame(), &pool);
1642        assert!(int32s(&r).contains(&(11, 1)));
1643        assert!(!int32s(&r).iter().any(|(reason, _)| *reason == 13));
1644
1645        // Flushing frame: POST_COUNT=1 and NO CURRENT_IMAGE update (the pre-fix
1646        // processor posted CURRENT_IMAGE=0 here and never posted POST_COUNT).
1647        p.trigger();
1648        let r = p.process_array(&frame(), &pool);
1649        assert!(int32s(&r).contains(&(13, 1)), "POST_COUNT posted per frame");
1650        assert!(
1651            !int32s(&r).iter().any(|(reason, _)| *reason == 11),
1652            "CURRENT_IMAGE frozen during the flush"
1653        );
1654        assert!(
1655            !int32s(&r).iter().any(|(reason, _)| *reason == 16),
1656            "ActualTriggerCount only moves at completion"
1657        );
1658
1659        // Completing frame: ACTUAL_TRIGGER_COUNT=1, POST_COUNT reset to 0.
1660        let r = p.process_array(&frame(), &pool);
1661        assert!(int32s(&r).contains(&(16, 1)));
1662        assert!(int32s(&r).contains(&(13, 0)));
1663    }
1664
1665    /// CBUG-B11 — a NONZERO SoftTrigger write latches the trigger and (with
1666    /// FlushOnSoftTrig > 0) flushes the ring from the write itself
1667    /// (NDPluginCircularBuff.cpp:271, :276-277), rather than waiting for the next
1668    /// frame. Writing **0** does neither.
1669    ///
1670    /// This test used to be `test_soft_trigger_write_latches_for_any_value_and_flushes`
1671    /// and pinned C's bug: C stores `value` (:268) and then never tests it, so
1672    /// `caput SoftTrigger 0` — the natural way to disarm, and what an autosave/PINI
1673    /// restore writes at boot — armed the plugin and flushed the pre-trigger ring
1674    /// downstream. Both of the assertions below that now expect "no trigger, no
1675    /// flush" were written the other way round.
1676    #[test]
1677    fn test_soft_trigger_write_latches_only_for_a_nonzero_value() {
1678        use ad_core_rs::ndarray::{NDDataType, NDDimension};
1679        use ad_core_rs::plugin::runtime::{ParamChangeValue, ParamUpdate, PluginParamSnapshot};
1680
1681        let soft_trigger_write = |p: &mut CircularBuffProcessor, value: i32| {
1682            let reason = p.params.soft_trigger.unwrap();
1683            p.on_param_change(
1684                reason,
1685                &PluginParamSnapshot {
1686                    enable_callbacks: true,
1687                    reason,
1688                    addr: 0,
1689                    value: ParamChangeValue::Int32(value),
1690                },
1691            )
1692        };
1693        let processor = |flush_on_soft_trig: i32| {
1694            let mut p = CircularBuffProcessor::new(3, 2, TriggerCondition::External, 100);
1695            p.buffer.start(); // C: NDCircBuffControl = 1
1696            p.params.soft_trigger = Some(20);
1697            p.params.triggered = Some(21);
1698            p.buffer.set_flush_on_soft_trigger(flush_on_soft_trig);
1699            let pool = NDArrayPool::new(0);
1700            // Two frames into the pre-buffer.
1701            for id in 1..=2 {
1702                let mut a = NDArray::new(vec![NDDimension::new(4)], NDDataType::UInt8);
1703                a.unique_id = id;
1704                p.process_array(&a, &pool);
1705            }
1706            assert_eq!(p.buffer().pre_buffer_len(), 2);
1707            p
1708        };
1709
1710        let latched = |r: &ad_core_rs::plugin::runtime::ParamChangeResult| {
1711            r.param_updates.iter().any(|u| {
1712                matches!(
1713                    u,
1714                    ParamUpdate::Int32 {
1715                        reason: 21,
1716                        value: 1,
1717                        ..
1718                    }
1719                )
1720            })
1721        };
1722
1723        // Writing 0 with FlushOnSoftTrig = 1 — the disarm case, and the one C
1724        // gets wrong: no trigger, no flush, the ring stays intact.
1725        let mut p = processor(1);
1726        let r = soft_trigger_write(&mut p, 0);
1727        assert!(!p.buffer().is_triggered(), "SoftTrigger 0 must not arm");
1728        assert!(!latched(&r), "SoftTrigger 0 must not post Triggered=1");
1729        assert!(r.output_arrays.is_empty(), "SoftTrigger 0 must not flush");
1730        assert_eq!(p.buffer().pre_buffer_len(), 2);
1731
1732        // FlushOnSoftTrig = 0: a nonzero value latches, and the ring stays put
1733        // (it flushes lazily with the first post-trigger frame).
1734        let mut p = processor(0);
1735        let r = soft_trigger_write(&mut p, 1);
1736        assert!(p.buffer().is_triggered());
1737        assert!(latched(&r));
1738        assert!(
1739            r.output_arrays.is_empty(),
1740            "no flush when FlushOnSoftTrig = 0"
1741        );
1742        assert_eq!(p.buffer().pre_buffer_len(), 2);
1743
1744        // FlushOnSoftTrig = 1: the write itself flushes the pre-buffer, in order.
1745        let mut p = processor(1);
1746        let r = soft_trigger_write(&mut p, 1);
1747        assert!(p.buffer().is_triggered());
1748        let ids: Vec<_> = r.output_arrays.iter().map(|a| a.unique_id).collect();
1749        assert_eq!(ids, vec![1, 2], "pre-buffer flushed from the write");
1750        assert_eq!(p.buffer().pre_buffer_len(), 0);
1751        assert!(latched(&r));
1752
1753        // The next frame is the first post-trigger frame; the ring is already
1754        // drained, so C's second flushPreBuffer() (:165) is a no-op and only the
1755        // new frame is forwarded.
1756        let pool = NDArrayPool::new(0);
1757        let mut a = NDArray::new(vec![NDDimension::new(4)], NDDataType::UInt8);
1758        a.unique_id = 3;
1759        let r = p.process_array(&a, &pool);
1760        let ids: Vec<_> = r.output_arrays.iter().map(|a| a.unique_id).collect();
1761        assert_eq!(ids, vec![3], "pre-buffer already flushed, not re-emitted");
1762    }
1763
1764    /// R11-63: the soft-trigger flush is C's `if (flushOn > 0)`
1765    /// (NDPluginCircularBuff.cpp:276), not `flushOn != 0`. Boundary sweep of the
1766    /// written FlushOnSoftTrig value through the real param-write path: -1 and 0
1767    /// must NOT flush (the ring drains lazily on the first post-trigger frame),
1768    /// 1 must.
1769    #[test]
1770    fn r11_63_flush_on_soft_trig_requires_a_positive_value() {
1771        use ad_core_rs::ndarray::{NDDataType, NDDimension};
1772        use ad_core_rs::plugin::runtime::{ParamChangeValue, PluginParamSnapshot};
1773
1774        const FLUSH_ON: usize = 22;
1775        const SOFT_TRIG: usize = 20;
1776
1777        let write = |p: &mut CircularBuffProcessor, reason: usize, value: i32| {
1778            p.on_param_change(
1779                reason,
1780                &PluginParamSnapshot {
1781                    enable_callbacks: true,
1782                    reason,
1783                    addr: 0,
1784                    value: ParamChangeValue::Int32(value),
1785                },
1786            )
1787        };
1788
1789        for (flush_on, expect_flush) in [(-1, false), (0, false), (1, true)] {
1790            let mut p = CircularBuffProcessor::new(3, 2, TriggerCondition::External, 100);
1791            p.buffer.start();
1792            p.params.soft_trigger = Some(SOFT_TRIG);
1793            p.params.triggered = Some(21);
1794            p.params.flush_on_soft_trigger = Some(FLUSH_ON);
1795
1796            write(&mut p, FLUSH_ON, flush_on);
1797            assert_eq!(
1798                p.buffer().flushes_on_soft_trigger(),
1799                expect_flush,
1800                "FlushOnSoftTrig = {flush_on}: C flushes only when > 0"
1801            );
1802
1803            let pool = NDArrayPool::new(0);
1804            for id in 1..=2 {
1805                let mut a = NDArray::new(vec![NDDimension::new(4)], NDDataType::UInt8);
1806                a.unique_id = id;
1807                p.process_array(&a, &pool);
1808            }
1809            assert_eq!(p.buffer().pre_buffer_len(), 2);
1810
1811            let r = write(&mut p, SOFT_TRIG, 1);
1812            assert!(p.buffer().is_triggered());
1813            if expect_flush {
1814                let ids: Vec<_> = r.output_arrays.iter().map(|a| a.unique_id).collect();
1815                assert_eq!(ids, vec![1, 2], "FlushOnSoftTrig = {flush_on}: flushed");
1816                assert_eq!(p.buffer().pre_buffer_len(), 0);
1817            } else {
1818                assert!(
1819                    r.output_arrays.is_empty(),
1820                    "FlushOnSoftTrig = {flush_on}: C does not flush from the write"
1821                );
1822                assert_eq!(p.buffer().pre_buffer_len(), 2);
1823            }
1824        }
1825    }
1826
1827    #[test]
1828    fn test_buffer_status_transitions() {
1829        let mut cb = CircularBuffer::new(2, 1, TriggerCondition::External);
1830
1831        // Before Control=1 the plugin is idle and records nothing.
1832        assert_eq!(cb.status(), BufferStatus::Idle);
1833
1834        // C's Control=1 write posts "Buffer filling" itself
1835        // (NDPluginCircularBuff.cpp:253-254), before any frame arrives.
1836        cb.start();
1837        assert_eq!(cb.status(), BufferStatus::BufferFilling);
1838
1839        cb.push(make_array(1));
1840        assert_eq!(cb.status(), BufferStatus::BufferFilling);
1841
1842        cb.push(make_array(2));
1843        assert_eq!(cb.status(), BufferStatus::BufferFilling);
1844
1845        // Trigger -> Flushing
1846        cb.trigger();
1847        assert_eq!(cb.status(), BufferStatus::Flushing);
1848
1849        // Post-trigger capture completes -> back to BufferFilling
1850        let done = cb.push(make_array(3));
1851        assert!(done.sequence_done);
1852        assert_eq!(cb.status(), BufferStatus::BufferFilling);
1853
1854        // Reset -> Idle
1855        cb.reset();
1856        assert_eq!(cb.status(), BufferStatus::Idle);
1857        assert_eq!(cb.trigger_count(), 0);
1858    }
1859}