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