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