rite_runtime/test_support.rs
1//! Test-only helpers for action implementors.
2//!
3//! Provides a [`ReporterHarness`] that owns the channels and an
4//! [`InMemorySink`](crate::InMemorySink) needed to construct a
5//! [`Reporter`] in unit and integration tests, so downstream crates can
6//! exercise their actions without dealing with channel plumbing.
7
8use std::sync::Arc;
9
10use chrono::{DateTime, Utc};
11use crossbeam_channel::{Receiver, Sender, unbounded};
12use rite_model::StepId;
13
14use crate::clock::{Clock, SystemClock};
15use crate::protocol::{ExecEvent, PromptId, Response, UiCommand};
16use crate::reporter::Reporter;
17use crate::transcript_sink::{InMemorySink, JsonlFileSink, TranscriptSink};
18use rite_model::{Sha256Digest, StepFact, TranscriptHeader};
19
20/// Owns the channels and sink needed to build a [`Reporter`] for tests.
21///
22/// The matching event receiver and command sender are kept alive for the
23/// harness's lifetime, so a reporter built from [`Self::reporter`] never
24/// sees a spurious disconnect while emitting facts.
25pub struct ReporterHarness {
26 sink: InMemorySink,
27 event_tx: Sender<ExecEvent>,
28 _event_rx: Receiver<ExecEvent>,
29 cmd_tx: Sender<UiCommand>,
30 cmd_rx: Receiver<UiCommand>,
31 next_response_id: u64,
32}
33
34impl ReporterHarness {
35 /// Build a harness with empty channels and an empty sink.
36 #[must_use]
37 pub fn new() -> Self {
38 let (event_tx, event_rx) = unbounded();
39 let (cmd_tx, cmd_rx) = unbounded();
40 Self {
41 sink: begun_sink(),
42 event_tx,
43 _event_rx: event_rx,
44 cmd_tx,
45 cmd_rx,
46 next_response_id: 0,
47 }
48 }
49
50 /// Pre-queue a response to the next prompt the action under test will issue.
51 ///
52 /// Prompt ids start at 0 and increase by one per prompt, so queued
53 /// responses are matched in issue order: the first call answers the first
54 /// prompt, the second answers the second, and so on. The reporter reads
55 /// commands from an unbounded channel, so queuing before `execute` is
56 /// enough and no second thread is needed.
57 ///
58 /// Assumes a single reporter built from this harness (the id sequence is
59 /// not reset by [`Self::reporter`]); that matches every current test.
60 pub fn enqueue_response(&mut self, response: Response) {
61 let prompt_id = PromptId::new(self.next_response_id);
62 self.next_response_id = self.next_response_id.wrapping_add(1);
63 // The receiver is held by the harness for its lifetime, so this send
64 // cannot disconnect.
65 let _ = self.cmd_tx.send(UiCommand::PromptResponse {
66 prompt_id,
67 response,
68 });
69 }
70
71 /// Build a reporter scoped to the given step. The reporter borrows
72 /// the harness for its lifetime.
73 ///
74 /// The entropy source is seeded with a fixed test seed, mirroring the
75 /// runner, so actions that draw values (serials, nonces) work out of the
76 /// box. Tests that need a specific seed can call
77 /// [`Reporter::seed_entropy`] again.
78 pub fn reporter(&mut self, step: StepId) -> Reporter<'_> {
79 let mut reporter = Reporter::new(
80 &self.event_tx,
81 &self.cmd_rx,
82 &mut self.sink,
83 Arc::new(SystemClock),
84 );
85 reporter.set_current_step(Some(step));
86 reporter.seed_entropy(b"rite-test-harness-seed");
87 reporter
88 }
89
90 /// Facts recorded by the harness's transcript sink, in order.
91 #[must_use]
92 pub fn facts(&self) -> &[StepFact] {
93 self.sink.facts()
94 }
95}
96
97impl Default for ReporterHarness {
98 fn default() -> Self {
99 Self::new()
100 }
101}
102
103/// A header for tests: a fixed run id, not a dry run.
104#[must_use]
105pub fn test_header() -> TranscriptHeader {
106 TranscriptHeader::new("rite test", &"0".repeat(32), false)
107}
108
109/// An in-memory sink with [`test_header`] already written, ready to record
110/// facts.
111///
112/// # Panics
113///
114/// Never in practice: writing the first header to a fresh in-memory sink
115/// cannot fail.
116#[must_use]
117#[allow(clippy::expect_used)]
118pub fn begun_sink() -> InMemorySink {
119 let mut sink = InMemorySink::new();
120 sink.begin(&test_header())
121 .expect("a fresh in-memory sink takes a header");
122 sink
123}
124
125/// Write `transcript.jsonl` in `dir`: [`test_header`], then each fact at its
126/// default level and [`fixed_test_time`]. Returns the fingerprint.
127///
128/// # Errors
129///
130/// Returns the I/O error if the file cannot be created or written.
131pub fn write_transcript(
132 dir: &std::path::Path,
133 facts: &[StepFact],
134) -> std::io::Result<Sha256Digest> {
135 let mut sink = JsonlFileSink::create(dir)?;
136 sink.begin(&test_header())?;
137 for fact in facts {
138 sink.record(fixed_test_time(), fact.default_level(), fact)?;
139 }
140 sink.finalize()
141}
142
143/// A `CeremonyStarted` fact for tests, over a fixed template digest.
144#[must_use]
145pub fn ceremony_started(name: &str) -> StepFact {
146 StepFact::CeremonyStarted {
147 name: name.to_string(),
148 template: Sha256Digest::of(b"test ceremony"),
149 }
150}
151
152/// A fixed instant for tests that need a deterministic event time. Arbitrary
153/// but stable, so recorded `at` values and snapshots stay reproducible.
154#[must_use]
155pub fn fixed_test_time() -> DateTime<Utc> {
156 // `from_timestamp_nanos` is infallible, unlike the seconds-based
157 // constructor, so this needs no `expect` in non-test library code.
158 DateTime::from_timestamp_nanos(1_700_000_000_000_000_000)
159}
160
161/// Wrap a fact into an [`ExecEvent::Fact`] stamped with [`fixed_test_time`],
162/// for frontend tests that feed synthetic events into a driver.
163#[must_use]
164pub fn fact_event(fact: StepFact) -> ExecEvent {
165 ExecEvent::Fact {
166 at: fixed_test_time(),
167 fact,
168 }
169}
170
171/// A [`Clock`](crate::Clock) frozen at a caller-chosen instant, for asserting
172/// that event times come from the injected clock rather than the wall clock.
173pub struct FixedClock(pub DateTime<Utc>);
174
175impl Clock for FixedClock {
176 fn now(&self) -> DateTime<Utc> {
177 self.0
178 }
179}