Skip to main content

bhtune_driver/
replay.rs

1//! `ReplayDriver`: feeds a previously captured golden-master trace back through the
2//! [`Driver`] trait, tick by tick, instead of a live OPC DA connection or the in-process
3//! FOPDT simulator.
4//!
5//! `crates/bhtune-core/tests/golden_replay.rs` already proves the
6//! *pure* `MrftEngine` reproduces the legacy C# application's behavior exactly, by feeding a
7//! fixture's recorded ticks directly into `engine.step(Tick { time, pv })`. That test cannot
8//! exercise anything in this crate at all -- `bhtune-core` cannot depend on `bhtune-driver`,
9//! which depends on it -- so it says nothing about whether the *real* async `Driver`
10//! abstraction a live run actually goes through (tag-based indirection, `TagValue`/
11//! `TagWrite` conversions, `Quality`, `Send`-across-`.await` dispatch behind `Box<dyn
12//! Driver>`/`Arc<dyn Driver>`) introduces its own bugs on top of a provably-correct engine.
13//! `ReplayDriver` closes that gap: it serves the exact same recorded trace through the
14//! genuine trait boundary, so a validation test can drive a real `MrftEngine` through it and
15//! confirm the same golden trace still reaches the same answer -- see this module's own
16//! `mrft_engine_replays_the_golden_trace_through_the_real_driver_trait` test.
17
18use std::sync::Mutex;
19
20use async_trait::async_trait;
21use chrono::{DateTime, Utc};
22use serde::Deserialize;
23
24use crate::{
25    driver::Driver,
26    error::{DriverError, DriverResult, poisoned_lock},
27    types::{
28        BrowsePage, BrowsePageRequest, DriverCapabilities, Quality, SearchEvent, SearchRequest,
29        TagId, TagValue, TagWrite, WriteOutcome,
30    },
31};
32
33/// One recorded `(time, PV)` sample from a captured trace -- the two fields
34/// [`ReplayDriver`] actually needs to serve a PV read.
35///
36/// Deliberately not `bhtune-core`'s `Tick`: this crate stays free of a `bhtune-core`
37/// dependency in production code because reading and writing named string tags has no domain
38/// meaning by itself. Also deliberately not
39/// the full golden-fixture JSON schema `crates/bhtune-core/tests/golden_replay.rs` owns
40/// (`config`, `direction`, `initial`, `pv_range`, `template_name`, each tick's `expected`
41/// block, `expected_final`) -- none of that is needed to *serve* a replay, only to
42/// *validate* one, which stays the calling test's job.
43#[derive(Debug, Clone, Copy, PartialEq)]
44pub struct ReplaySample {
45    pub time: DateTime<Utc>,
46    pub pv: f32,
47}
48
49/// A single MV write [`ReplayDriver`] observed, in the order [`Driver::write`] was called
50/// -- what a validation test inspects afterward (via [`ReplayDriver::writes`]) to see
51/// exactly what an engine driven through the real `Driver` trait chose to write, without
52/// needing its own separate bookkeeping alongside the driver's.
53#[derive(Debug, Clone, PartialEq)]
54pub struct RecordedWrite {
55    pub tag: TagId,
56    pub value: f32,
57}
58
59/// The subset of a golden-master fixture's JSON shape [`ReplayDriver::from_fixture_json`]
60/// parses -- every other top-level or per-tick field is silently ignored by `serde`'s
61/// default "unknown fields are fine" behavior, since none of it is needed to serve PV
62/// samples. See [`ReplaySample`]'s doc comment for why this is a deliberate subset rather
63/// than reusing `bhtune-core`'s test-only `Fixture` type.
64#[derive(Debug, Deserialize)]
65struct FixtureFile {
66    ticks: Vec<FixtureTick>,
67}
68
69#[derive(Debug, Deserialize)]
70struct FixtureTick {
71    time: DateTime<Utc>,
72    pv: f32,
73}
74
75/// The error [`Driver::read`] wraps in [`DriverError::Operation`] when the configured PV
76/// tag is read after every recorded sample has already been consumed.
77///
78/// A correctly captured trace paired with a correctly behaving engine should never reach
79/// this: `MrftEngine::step` is a documented no-op once it has returned `Action::Complete`
80/// once (see `crates/bhtune-core/tests/golden_replay.rs`'s own note on this), so a caller
81/// that stops polling as soon as completion is observed -- exactly the shape that test and
82/// this module's own end-to-end test both use -- never triggers it. Seeing this in practice
83/// means either the trace's tick count doesn't actually cover a real MRFT completion, or the
84/// engine driving this driver has a genuine regression that fails to complete in time.
85#[derive(Debug, Clone, Copy, PartialEq, Eq)]
86pub struct ReplayTraceExhausted {
87    /// How many samples this driver was constructed with.
88    pub recorded: usize,
89    /// The 1-based read attempt number that failed (`recorded + 1`, `recorded + 2`, ...).
90    pub attempted: usize,
91}
92
93impl std::fmt::Display for ReplayTraceExhausted {
94    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
95        write!(
96            f,
97            "replay trace exhausted: only {} sample(s) recorded, but PV read attempt #{} was \
98             made -- the driving engine never reported completion within the recorded trace",
99            self.recorded, self.attempted
100        )
101    }
102}
103
104impl std::error::Error for ReplayTraceExhausted {}
105
106#[derive(Debug)]
107struct ReplayState {
108    next_index: usize,
109    last_mv: f32,
110    writes: Vec<RecordedWrite>,
111}
112
113/// Serves a captured `(time, PV)` trace through the real [`Driver`] trait, for validating
114/// that a live `MrftEngine` run reproduces a golden-master trace's result when driven
115/// through the actual async abstraction -- not just when fed the trace directly, as the
116/// pure-engine golden-replay test does.
117///
118/// Reading the configured PV tag returns the next unconsumed sample's PV value, with its
119/// *real* recorded time in `TagValue.timestamp`. This is the one [`Driver`] implementation
120/// in this crate where that field is genuinely meaningful rather than a diagnostic-only
121/// extra: [`crate::types::TagValue`]'s own doc comment is clear that a live driver's
122/// timestamp must never become "the tick time the tuning engine itself runs on, which comes
123/// from the caller's own polling clock instead" -- true and load-bearing for
124/// `OpcDaDriver`/`SimulatorDriver`, whose reported (or absent) timestamps cannot be
125/// trusted to reconstruct a control loop's real tick cadence. `ReplayDriver` is a
126/// deliberate, narrow exception to that rule, not a violation of it: it is not a live
127/// driver at all, its entire purpose is exact historical replay, and the recorded time
128/// *is* the tick time a validation test needs -- reading it straight back out of the trait
129/// boundary is simpler and less redundant than threading the same value through some
130/// separate side channel the test would otherwise have to keep in lockstep with this
131/// driver's own internal cursor.
132///
133/// Reading the configured MV tag returns the last written value (or the seeded initial MV
134/// before any write), matching [`crate::SimulatorDriver`]'s convention exactly. Running a
135/// PV read past the last recorded sample is [`DriverError::Operation`] (wrapping
136/// [`ReplayTraceExhausted`]) rather than panicking or silently repeating/holding the last
137/// value, since either of those would let a real regression (the engine failing to
138/// complete) masquerade as a passing test.
139///
140/// Uses `std::sync::Mutex`, matching [`crate::SimulatorDriver`]: every operation here is
141/// synchronous index/vec bookkeeping with no `.await` point in the critical section.
142#[derive(Debug)]
143pub struct ReplayDriver {
144    pv_tag: TagId,
145    mv_tag: TagId,
146    samples: Vec<ReplaySample>,
147    state: Mutex<ReplayState>,
148}
149
150impl ReplayDriver {
151    /// Builds a replay driver from an already-parsed sample sequence. `initial_mv` is what
152    /// the MV tag reads as before the first write -- mirroring
153    /// [`crate::SimulatorDriver::new`]'s `initial_mv` parameter, and matching the fact that
154    /// a real trace's MV convention (see `crates/bhtune-core/tests/golden_replay.rs`'s
155    /// `FixtureInitial::mv_ini`) is likewise supplied out of band from the tick sequence
156    /// itself.
157    pub fn new(
158        pv_tag: impl Into<TagId>,
159        mv_tag: impl Into<TagId>,
160        samples: Vec<ReplaySample>,
161        initial_mv: f32,
162    ) -> ReplayDriver {
163        ReplayDriver {
164            pv_tag: pv_tag.into(),
165            mv_tag: mv_tag.into(),
166            samples,
167            state: Mutex::new(ReplayState {
168                next_index: 0,
169                last_mv: initial_mv,
170                writes: Vec::new(),
171            }),
172        }
173    }
174
175    /// Parses a golden-master fixture JSON document's `ticks[].time`/`ticks[].pv` fields
176    /// into the sample sequence [`ReplayDriver::new`] expects, so a
177    /// validation test can point this driver directly at the same fixture file
178    /// the pure-engine golden-replay test already validates against
179    /// (`tests/golden/fixtures/*.json`)
180    /// rather than hand-transcribing the tick sequence a second time. A parse failure is
181    /// [`DriverError::Operation`], since parsing the fixture is part of this driver's work.
182    pub fn from_fixture_json(
183        pv_tag: impl Into<TagId>,
184        mv_tag: impl Into<TagId>,
185        json: &str,
186        initial_mv: f32,
187    ) -> DriverResult<ReplayDriver> {
188        let file: FixtureFile =
189            serde_json::from_str(json).map_err(|e| DriverError::Operation(Box::new(e)))?;
190        let samples = file
191            .ticks
192            .into_iter()
193            .map(|t| ReplaySample {
194                time: t.time,
195                pv: t.pv,
196            })
197            .collect();
198        Ok(ReplayDriver::new(pv_tag, mv_tag, samples, initial_mv))
199    }
200
201    /// Every MV write observed so far, in call order -- for a validation test to compare
202    /// against a golden fixture's own expected per-tick MV sequence.
203    pub fn writes(&self) -> DriverResult<Vec<RecordedWrite>> {
204        Ok(poisoned_lock(self.state.lock())?.writes.clone())
205    }
206
207    /// How many configured samples have not yet been consumed by a PV read.
208    pub fn remaining(&self) -> DriverResult<usize> {
209        let state = poisoned_lock(self.state.lock())?;
210        Ok(self.samples.len() - state.next_index)
211    }
212}
213
214#[async_trait]
215impl Driver for ReplayDriver {
216    async fn read(&self, tags: &[TagId]) -> DriverResult<Vec<TagValue>> {
217        let mut state = poisoned_lock(self.state.lock())?;
218        tags.iter()
219            .map(|tag| {
220                if *tag == self.pv_tag {
221                    let index = state.next_index;
222                    let sample = self.samples.get(index).ok_or_else(|| {
223                        DriverError::Operation(Box::new(ReplayTraceExhausted {
224                            recorded: self.samples.len(),
225                            attempted: index + 1,
226                        }))
227                    })?;
228                    state.next_index += 1;
229                    Ok(TagValue {
230                        tag: tag.clone(),
231                        value: sample.pv.to_string(),
232                        quality: Quality::Good,
233                        timestamp: Some(sample.time),
234                    })
235                } else if *tag == self.mv_tag {
236                    Ok(TagValue {
237                        tag: tag.clone(),
238                        value: state.last_mv.to_string(),
239                        quality: Quality::Good,
240                        timestamp: None,
241                    })
242                } else {
243                    Err(DriverError::InvalidTagValue {
244                        tag: tag.clone(),
245                        message: "ReplayDriver only knows its configured PV/MV tags".to_string(),
246                    })
247                }
248            })
249            .collect()
250    }
251
252    async fn write(&self, tag: &TagId, value: TagWrite) -> DriverResult<WriteOutcome> {
253        if *tag != self.mv_tag {
254            return Err(DriverError::InvalidTagValue {
255                tag: tag.clone(),
256                message: "ReplayDriver only accepts writes to its configured MV tag".to_string(),
257            });
258        }
259        let mv = match value {
260            TagWrite::Float(f) => f,
261            TagWrite::Raw(s) => match s.parse::<f32>() {
262                Ok(f) => f,
263                Err(_) => {
264                    return Ok(WriteOutcome::failure(format!(
265                        "'{s}' is not a valid numeric MV value"
266                    )));
267                }
268            },
269        };
270        let mut state = poisoned_lock(self.state.lock())?;
271        state.last_mv = mv;
272        state.writes.push(RecordedWrite {
273            tag: tag.clone(),
274            value: mv,
275        });
276        Ok(WriteOutcome::success())
277    }
278
279    async fn capabilities(&self) -> DriverResult<DriverCapabilities> {
280        Err(DriverError::Unsupported {
281            operation: "capabilities",
282        })
283    }
284
285    async fn browse(&self, _request: BrowsePageRequest) -> DriverResult<BrowsePage> {
286        Err(DriverError::Unsupported {
287            operation: "browse",
288        })
289    }
290
291    async fn close_browse_session(&self, _session_id: &str) -> DriverResult<()> {
292        Err(DriverError::Unsupported {
293            operation: "browse-session close",
294        })
295    }
296
297    async fn search(&self, _request: SearchRequest) -> DriverResult<Vec<SearchEvent>> {
298        Err(DriverError::Unsupported {
299            operation: "search",
300        })
301    }
302}
303
304#[cfg(test)]
305mod tests {
306    use super::*;
307    use chrono::TimeZone;
308
309    fn t(secs: i64) -> DateTime<Utc> {
310        Utc.with_ymd_and_hms(2024, 1, 1, 0, 0, 0).unwrap() + chrono::Duration::seconds(secs)
311    }
312
313    fn samples() -> Vec<ReplaySample> {
314        vec![
315            ReplaySample {
316                time: t(0),
317                pv: 10.0,
318            },
319            ReplaySample {
320                time: t(1),
321                pv: 11.0,
322            },
323            ReplaySample {
324                time: t(2),
325                pv: 12.0,
326            },
327        ]
328    }
329
330    fn expect_trace_exhaustion(error: DriverError) -> Box<ReplayTraceExhausted> {
331        match error {
332            DriverError::Operation(source) => source.downcast::<ReplayTraceExhausted>().unwrap(),
333            other => panic!("expected DriverError::Operation, got {other:?}"),
334        }
335    }
336
337    #[tokio::test]
338    async fn unsupported_namespace_operations_are_reported() {
339        let driver = ReplayDriver::new("PV", "MV", samples(), 0.0);
340        assert!(matches!(
341            driver.capabilities().await,
342            Err(DriverError::Unsupported {
343                operation: "capabilities"
344            })
345        ));
346        assert!(matches!(
347            driver.browse(BrowsePageRequest::root(1)).await,
348            Err(DriverError::Unsupported {
349                operation: "browse"
350            })
351        ));
352        assert!(matches!(
353            driver.close_browse_session("s").await,
354            Err(DriverError::Unsupported {
355                operation: "browse-session close"
356            })
357        ));
358        assert!(matches!(
359            driver
360                .search(SearchRequest {
361                    query: "PV".into(),
362                    match_mode: crate::types::SearchMatchMode::Exact,
363                    session_id: None,
364                    scope_node_key: None,
365                    max_results: 1,
366                    include_branches: false,
367                    refresh: false,
368                })
369                .await,
370            Err(DriverError::Unsupported {
371                operation: "search"
372            })
373        ));
374    }
375
376    // --- construction / basic read-back ---------------------------------------------------
377
378    #[tokio::test]
379    async fn reads_pv_samples_in_order_with_their_recorded_timestamps() {
380        let driver = ReplayDriver::new("PV", "MV", samples(), 50.0);
381
382        for (i, expected) in samples().iter().enumerate() {
383            let read = driver.read(&["PV".to_string()]).await.unwrap();
384            assert_eq!(read.len(), 1, "tick {i}");
385            assert_eq!(read[0].tag, "PV");
386            assert_eq!(read[0].value, expected.pv.to_string(), "tick {i}");
387            assert_eq!(read[0].quality, Quality::Good, "tick {i}");
388            assert_eq!(read[0].timestamp, Some(expected.time), "tick {i}");
389        }
390    }
391
392    #[tokio::test]
393    async fn mv_read_before_any_write_returns_the_seeded_initial_value() {
394        let driver = ReplayDriver::new("PV", "MV", samples(), 42.5);
395        let read = driver.read(&["MV".to_string()]).await.unwrap();
396        assert_eq!(read[0].value, "42.5");
397        assert_eq!(read[0].timestamp, None, "MV reads have no recorded time");
398    }
399
400    #[tokio::test]
401    async fn mv_read_reflects_the_most_recent_write() {
402        let driver = ReplayDriver::new("PV", "MV", samples(), 0.0);
403        driver
404            .write(&"MV".to_string(), TagWrite::Float(37.0))
405            .await
406            .unwrap();
407        let read = driver.read(&["MV".to_string()]).await.unwrap();
408        assert_eq!(read[0].value, "37");
409    }
410
411    #[tokio::test]
412    async fn reading_pv_does_not_advance_a_subsequent_mv_read_and_vice_versa() {
413        let driver = ReplayDriver::new("PV", "MV", samples(), 0.0);
414        driver.read(&["MV".to_string()]).await.unwrap();
415        driver.read(&["MV".to_string()]).await.unwrap();
416        assert_eq!(
417            driver.remaining().unwrap(),
418            3,
419            "MV reads must not consume PV samples"
420        );
421        driver.read(&["PV".to_string()]).await.unwrap();
422        assert_eq!(driver.remaining().unwrap(), 2);
423    }
424
425    #[tokio::test]
426    async fn reading_multiple_tags_in_one_call_resolves_each_independently() {
427        let driver = ReplayDriver::new("PV", "MV", samples(), 5.0);
428        let read = driver
429            .read(&["PV".to_string(), "MV".to_string()])
430            .await
431            .unwrap();
432        assert_eq!(read[0].tag, "PV");
433        assert_eq!(read[0].value, "10");
434        assert_eq!(read[1].tag, "MV");
435        assert_eq!(read[1].value, "5");
436        assert_eq!(
437            driver.remaining().unwrap(),
438            2,
439            "the one PV tag in the batch consumed one sample"
440        );
441    }
442
443    // --- writes / recording ----------------------------------------------------------------
444
445    #[tokio::test]
446    async fn writes_are_recorded_in_call_order() {
447        let driver = ReplayDriver::new("PV", "MV", samples(), 0.0);
448        driver
449            .write(&"MV".to_string(), TagWrite::Float(1.0))
450            .await
451            .unwrap();
452        driver
453            .write(&"MV".to_string(), TagWrite::Float(2.0))
454            .await
455            .unwrap();
456        driver
457            .write(&"MV".to_string(), TagWrite::Raw("3".to_string()))
458            .await
459            .unwrap();
460        let writes = driver.writes().unwrap();
461        assert_eq!(
462            writes,
463            vec![
464                RecordedWrite {
465                    tag: "MV".to_string(),
466                    value: 1.0
467                },
468                RecordedWrite {
469                    tag: "MV".to_string(),
470                    value: 2.0
471                },
472                RecordedWrite {
473                    tag: "MV".to_string(),
474                    value: 3.0
475                },
476            ]
477        );
478    }
479
480    #[tokio::test]
481    async fn raw_write_with_unparseable_value_is_a_rejected_outcome_not_an_error() {
482        let driver = ReplayDriver::new("PV", "MV", samples(), 0.0);
483        let outcome = driver
484            .write(&"MV".to_string(), TagWrite::Raw("not-a-number".to_string()))
485            .await
486            .unwrap();
487        assert!(!outcome.success);
488        assert!(outcome.error_message.unwrap().contains("not-a-number"));
489        assert!(
490            driver.writes().unwrap().is_empty(),
491            "a rejected write must not be recorded"
492        );
493    }
494
495    // --- error paths -------------------------------------------------------------------------
496
497    #[tokio::test]
498    async fn reading_an_unknown_tag_is_invalid_tag_value() {
499        let driver = ReplayDriver::new("PV", "MV", samples(), 0.0);
500        let err = driver
501            .read(&["SomeOtherTag".to_string()])
502            .await
503            .unwrap_err();
504        assert!(matches!(
505            err,
506            DriverError::InvalidTagValue { tag, .. } if tag == "SomeOtherTag"
507        ));
508    }
509
510    #[tokio::test]
511    async fn writing_an_unknown_tag_is_invalid_tag_value() {
512        let driver = ReplayDriver::new("PV", "MV", samples(), 0.0);
513        let err = driver
514            .write(&"SomeOtherTag".to_string(), TagWrite::Float(1.0))
515            .await
516            .unwrap_err();
517        assert!(matches!(err, DriverError::InvalidTagValue { .. }));
518    }
519
520    #[tokio::test]
521    async fn browse_is_unsupported() {
522        let driver = ReplayDriver::new("PV", "MV", samples(), 0.0);
523        let err = driver
524            .browse(BrowsePageRequest::root(20))
525            .await
526            .unwrap_err();
527        assert!(matches!(
528            err,
529            DriverError::Unsupported {
530                operation: "browse"
531            }
532        ));
533    }
534
535    #[tokio::test]
536    async fn reading_pv_past_the_last_sample_is_operation_error_not_a_panic() {
537        let driver = ReplayDriver::new("PV", "MV", samples(), 0.0);
538        for _ in 0..3 {
539            driver.read(&["PV".to_string()]).await.unwrap();
540        }
541        assert_eq!(driver.remaining().unwrap(), 0);
542        let err = driver.read(&["PV".to_string()]).await.unwrap_err();
543        let exhausted = expect_trace_exhaustion(err);
544        assert_eq!(exhausted.recorded, 3);
545        assert_eq!(exhausted.attempted, 4);
546        assert!(exhausted.to_string().contains("exhausted"));
547    }
548
549    #[tokio::test]
550    async fn an_empty_trace_reports_exhaustion_on_the_very_first_read() {
551        let driver = ReplayDriver::new("PV", "MV", Vec::new(), 0.0);
552        let err = driver.read(&["PV".to_string()]).await.unwrap_err();
553        let exhausted = expect_trace_exhaustion(err);
554        assert_eq!(exhausted.recorded, 0);
555        assert_eq!(exhausted.attempted, 1);
556    }
557
558    #[test]
559    fn trace_exhaustion_assertion_fails_clearly_for_a_non_operation_error() {
560        let panic = std::panic::catch_unwind(|| {
561            expect_trace_exhaustion(DriverError::Unsupported {
562                operation: "browse",
563            })
564        })
565        .unwrap_err();
566        assert!(
567            panic
568                .downcast_ref::<String>()
569                .is_some_and(|message| message.contains("DriverError::Operation"))
570        );
571    }
572
573    // --- from_fixture_json -------------------------------------------------------------------
574
575    #[test]
576    fn from_fixture_json_parses_ticks_and_ignores_every_other_field() {
577        let json = r#"{
578            "name": "example",
579            "description": "irrelevant prose",
580            "source": { "static_log": "x", "dynamic_log": "y", "captured": "2026-01-01" },
581            "config": { "process_type": "flow", "controller_type": "pi" },
582            "direction": "reverse",
583            "initial": { "pv_ini": 1.0 },
584            "pv_range": { "high": 100.0, "low": 0.0 },
585            "template_name": "Yokogawa CentumVP",
586            "ticks": [
587                { "time": "2024-01-01T00:00:00Z", "pv": 40.0, "expected": { "hysteresis": 0.1 } },
588                { "time": "2024-01-01T00:00:01Z", "pv": 41.5, "expected": { "hysteresis": 0.2 } }
589            ]
590        }"#;
591
592        let driver = ReplayDriver::from_fixture_json("PV", "MV", json, 40.0).unwrap();
593        assert_eq!(driver.remaining().unwrap(), 2);
594    }
595
596    #[test]
597    fn from_fixture_json_rejects_malformed_json_as_operation_error() {
598        let err = ReplayDriver::from_fixture_json("PV", "MV", "not json", 0.0).unwrap_err();
599        assert!(matches!(err, DriverError::Operation(_)));
600    }
601
602    #[test]
603    fn from_fixture_json_rejects_a_document_with_no_ticks_field() {
604        let err = ReplayDriver::from_fixture_json("PV", "MV", "{}", 0.0).unwrap_err();
605        assert!(matches!(err, DriverError::Operation(_)));
606    }
607
608    #[test]
609    fn trace_exhaustion_error_reports_the_recorded_and_attempted_counts() {
610        let error = ReplayTraceExhausted {
611            recorded: 3,
612            attempted: 4,
613        };
614        assert!(error.to_string().contains("3 sample(s)"));
615        assert!(error.to_string().contains("#4"));
616        let _: Box<dyn std::error::Error> = Box::new(error);
617    }
618
619    // --- object safety -----------------------------------------------------------------------
620
621    #[tokio::test]
622    async fn is_usable_as_a_boxed_dyn_driver() {
623        let driver: Box<dyn Driver> = Box::new(ReplayDriver::new("PV", "MV", samples(), 0.0));
624        let read = driver.read(&["PV".to_string()]).await.unwrap();
625        assert_eq!(read[0].value, "10");
626    }
627
628    /// End-to-end: a real `MrftEngine` (from `bhtune-core`) drives `ReplayDriver` through
629    /// the actual `Driver` trait, fed from the *same* captured golden-master fixture
630    /// `crates/bhtune-core/tests/golden_replay.rs` already validates at the pure-engine
631    /// level, and reaches the same final tuning result. This is
632    /// deliberately not a re-run of that test's exhaustive per-tick assertions (hysteresis,
633    /// `mv_sign_next_step`, cycle counters, ...) at every tick -- that would just duplicate
634    /// already-proven engine correctness. What this test adds is proof that the *real* async
635    /// `Driver` abstraction this trace is now served through -- tag lookup, `TagValue`/
636    /// `TagWrite` conversions, the `timestamp` field carrying the tick time -- introduces no
637    /// bugs of its own on top of an already-correct engine.
638    #[tokio::test]
639    async fn mrft_engine_replays_the_golden_trace_through_the_real_driver_trait() {
640        use std::{fs, path::Path};
641
642        use bhtune_core::{
643            Action, ControllerDirection, ControllerType, InitialReadings, LoopConfig, MrftEngine,
644            ProcessType, PvRange, ResponseLevel, Tick, built_in_templates, calculate_all, lookup,
645        };
646
647        let fixture_path = Path::new(env!("CARGO_MANIFEST_DIR"))
648            .join("../../tests/golden/fixtures/flow_pi_direct.json");
649        let json = fs::read_to_string(&fixture_path)
650            .unwrap_or_else(|e| panic!("failed to read {}: {e}", fixture_path.display()));
651
652        let pv_tag = "Loop.PV".to_string();
653        let mv_tag = "Loop.MV".to_string();
654        // This fixture's own `initial.mv_ini` (see `crates/bhtune-core/tests/
655        // golden_replay.rs`'s `FixtureInitial`) -- kept as a literal here since this test
656        // deliberately only parses `ticks[].time`/`ticks[].pv` via `from_fixture_json`, not
657        // the fixture's other fields.
658        let initial_mv = 40.0;
659        let driver =
660            ReplayDriver::from_fixture_json(pv_tag.clone(), mv_tag.clone(), &json, initial_mv)
661                .expect("flow_pi_direct.json should parse");
662        let total_samples = driver.remaining().unwrap();
663
664        // This fixture's own config/direction/initial-readings/pv-range, matching
665        // `golden_replay.rs`'s hardcoded transcription of the same fixture exactly (that
666        // test asserts the fixture's `config`/`direction` enums decode to these values, so
667        // duplicating the literals here rather than re-parsing them is a deliberate,
668        // already-covered redundancy, not a risk of silent drift).
669        let config = LoopConfig {
670            process_type: ProcessType::Flow,
671            controller_type: ControllerType::Pi,
672            relay_amp_percent: 2.0,
673            num_cycles_skip: 1,
674            num_cycles_count: 2,
675            noise_protection_secs: 3,
676            mrft_delay_secs: 0,
677        };
678        config.validate().expect("fixture config must be valid");
679        let direction = ControllerDirection::Reverse;
680        let initial = InitialReadings {
681            pv_ini: 40.00012,
682            mv_ini: initial_mv,
683            mv_range_low: 0.0,
684            mv_range_high: 100.0,
685        };
686        let pv_range = PvRange {
687            high: 100.0,
688            low: 0.0,
689        };
690        let beta = lookup(
691            config.process_type,
692            config.controller_type,
693            ResponseLevel::Aggressive,
694        )
695        .beta;
696        let template = built_in_templates()
697            .into_iter()
698            .find(|t| t.name == "Yokogawa CentumVP")
699            .expect("built-in template must exist");
700
701        // The first read's timestamp is this trace's own start time -- read here (rather
702        // than hardcoded) purely to seed `MrftEngine::new`, which needs a start time before
703        // the first `step` call.
704        let first = driver.read(std::slice::from_ref(&pv_tag)).await.unwrap();
705        let start_time = first[0]
706            .timestamp
707            .expect("ReplayDriver always sets a timestamp on PV reads");
708        let mut pending_tick = Some(Tick {
709            time: start_time,
710            pv: first[0].value.parse().unwrap(),
711        });
712
713        let mut engine = MrftEngine::new(config, direction, beta, initial, start_time);
714
715        let mut completion = None;
716        for _ in 0..total_samples {
717            let tick = match pending_tick.take() {
718                Some(tick) => tick,
719                None => {
720                    let read = driver.read(std::slice::from_ref(&pv_tag)).await.unwrap();
721                    let time = read[0]
722                        .timestamp
723                        .expect("ReplayDriver always sets a timestamp on PV reads");
724                    Tick {
725                        time,
726                        pv: read[0].value.parse().unwrap(),
727                    }
728                }
729            };
730
731            for action in engine.step(tick) {
732                match action {
733                    Action::WriteMv(mv) => {
734                        driver.write(&mv_tag, TagWrite::Float(mv)).await.unwrap();
735                    }
736                    Action::Complete {
737                        peaks,
738                        troughs,
739                        switch_times,
740                        mv_sign_init,
741                    } => {
742                        completion = Some((peaks, troughs, switch_times, mv_sign_init));
743                    }
744                }
745            }
746            if completion.is_some() {
747                break;
748            }
749        }
750
751        let (peaks, troughs, switch_times, mv_sign_init) =
752            completion.expect("engine should complete within the recorded trace");
753
754        let results = calculate_all(
755            &peaks,
756            &troughs,
757            &switch_times,
758            mv_sign_init,
759            direction,
760            config,
761            pv_range,
762            &template,
763        );
764
765        // The aggressive-response proportional band, in the fixture's own DCS units,
766        // matches the independently checked control-theory calculation for this capture.
767        // Same tolerance shape `golden_replay.rs` uses for its own final numbers; see that
768        // test for the rationale around the captured fixture's floating-point values.
769        let expected_aggressive_pb = 157.7088_f32;
770        let (_tuning, pid) = results
771            .iter()
772            .find(|(r, _)| r.response_level == ResponseLevel::Aggressive)
773            .expect("aggressive result must be present");
774        let tolerance = 1e-3 + expected_aggressive_pb.abs() * 1e-2;
775        assert!(
776            (pid.proportional - expected_aggressive_pb).abs() <= tolerance,
777            "aggressive proportional band: expected ~{expected_aggressive_pb}, got {} \
778             (tolerance {tolerance})",
779            pid.proportional
780        );
781
782        assert!(
783            !driver.writes().unwrap().is_empty(),
784            "the engine should have written at least one relay step through the real \
785             Driver trait"
786        );
787        // The engine stops driving reads the instant completion is observed (matching
788        // the pure-engine golden-replay test's "any remaining fixture ticks are exactly this
789        // harmless trailing data and are not replayed" behavior -- see that test's comment),
790        // so this
791        // trace's trailing padding ticks are expected to remain unconsumed; the meaningful
792        // assertion is that real consumption happened at all, not that every recorded tick
793        // was read.
794        assert!(
795            driver.remaining().unwrap() < total_samples,
796            "expected at least one sample to be consumed before completion"
797        );
798    }
799}