Skip to main content

executable_stories/
story.rs

1use std::collections::{HashMap, HashSet};
2
3use serde::Serialize;
4
5use crate::collector;
6use crate::doc_entry::DocEntry;
7use crate::types::{RawAttachment, RawStepEvent, RawTestCase, StoryMeta, StoryStep};
8
9/// Inline documentation for a step. Chain after a step method: `story.given("text").with_docs(vec![StepDoc::note("a note")])`.
10#[derive(Clone)]
11pub struct StepDoc(pub(crate) DocEntry);
12
13impl StepDoc {
14    #[must_use]
15    pub fn note(text: &str) -> Self {
16        StepDoc(DocEntry::note(text))
17    }
18    #[must_use]
19    pub fn tag(names: &[&str]) -> Self {
20        StepDoc(DocEntry::tag(names))
21    }
22    #[must_use]
23    pub fn kv(label: &str, value: serde_json::Value) -> Self {
24        StepDoc(DocEntry::kv(label, value))
25    }
26    #[must_use]
27    pub fn json_doc(label: &str, value: &impl Serialize) -> Self {
28        StepDoc(DocEntry::json_doc(label, value))
29    }
30    #[must_use]
31    pub fn code(label: &str, content: &str, lang: Option<&str>) -> Self {
32        StepDoc(DocEntry::code(label, content, lang))
33    }
34    #[must_use]
35    pub fn table(label: &str, columns: &[&str], rows: &[&[&str]]) -> Self {
36        StepDoc(DocEntry::table(label, columns, rows))
37    }
38    #[must_use]
39    pub fn link(label: &str, url: &str) -> Self {
40        StepDoc(DocEntry::link(label, url))
41    }
42    #[must_use]
43    pub fn section(title: &str, markdown: &str) -> Self {
44        StepDoc(DocEntry::section(title, markdown))
45    }
46    #[must_use]
47    pub fn mermaid(code: &str, title: Option<&str>) -> Self {
48        StepDoc(DocEntry::mermaid(code, title))
49    }
50    #[must_use]
51    pub fn screenshot(path: &str, alt: Option<&str>) -> Self {
52        StepDoc(DocEntry::screenshot(path, alt))
53    }
54    #[must_use]
55    pub fn video(path: &str, caption: Option<&str>, poster: Option<&str>) -> Self {
56        StepDoc(DocEntry::video(path, caption, poster))
57    }
58    #[must_use]
59    pub fn html(opts: crate::doc_entry::HtmlOptions) -> Self {
60        StepDoc(DocEntry::html(opts))
61    }
62    #[must_use]
63    pub fn state(label: Option<&str>, value: serde_json::Value) -> Self {
64        StepDoc(DocEntry::state(label, value))
65    }
66    #[must_use]
67    pub fn custom(type_name: &str, data: serde_json::Value) -> Self {
68        StepDoc(DocEntry::custom(type_name, data))
69    }
70    fn apply(self, step: &mut StoryStep) {
71        step.docs.get_or_insert_with(Vec::new).push(self.0);
72    }
73}
74
75struct TimerEntry {
76    start: std::time::Instant,
77    step_index: Option<usize>,
78    step_id: Option<String>,
79    consumed: bool,
80}
81
82/// A BDD story builder that captures steps and emits a `RawTestCase` on drop.
83///
84/// The status is detected on drop: a story dropped while the thread unwinds
85/// from a panic records `"fail"`, otherwise `"pass"`. Every failing assertion
86/// panics, so a passing test needs no extra call.
87///
88/// Tests that return `Result` are the exception. Returning `Err` fails the test
89/// without panicking, so call [`Story::fail`] on that path, or use
90/// [`Story::record_result`], which does it for you.
91pub struct Story {
92    scenario: String,
93    steps: Vec<StoryStep>,
94    tags: Option<Vec<String>>,
95    tickets: Option<Vec<crate::types::Ticket>>,
96    covers: Option<Vec<String>>,
97    meta: Option<serde_json::Value>,
98    trace_url_template: Option<String>,
99    docs: Vec<DocEntry>,
100    current_step_index: Option<usize>,
101    seen_primary: HashSet<String>,
102    source_order: Option<u32>,
103    start_time: std::time::Instant,
104    started_at_ms: f64,
105    passed: bool,
106    failed: bool,
107    planned: bool,
108    step_counter: usize,
109    attachments: Vec<RawAttachment>,
110    active_timers: HashMap<usize, TimerEntry>,
111    timer_counter: usize,
112    otel_spans: Option<Vec<serde_json::Value>>,
113    /// `pub(crate)` so a test in another file can prove `#[track_caller]`
114    /// records the caller's file and not this one.
115    pub(crate) source_file: Option<String>,
116}
117
118impl Story {
119    /// Create a new story with the given scenario description.
120    ///
121    /// `#[track_caller]` so the scenario records the test file it was written
122    /// in, the same path `declare_feature!` records through `file!()`. The
123    /// report groups by source file: without it a scenario lands under
124    /// "unknown" and its file's feature declaration never reaches it.
125    #[must_use]
126    #[track_caller]
127    pub fn new(scenario: &str) -> Self {
128        let mut story = Story {
129            scenario: scenario.to_string(),
130            steps: Vec::new(),
131            tags: None,
132            tickets: None,
133            covers: None,
134            meta: None,
135            trace_url_template: None,
136            docs: Vec::new(),
137            current_step_index: None,
138            seen_primary: HashSet::new(),
139            source_order: Some(collector::next_order()),
140            start_time: std::time::Instant::now(),
141            started_at_ms: collector::epoch_ms(),
142            passed: false,
143            failed: false,
144            planned: false,
145            step_counter: 0,
146            attachments: Vec::new(),
147            active_timers: HashMap::new(),
148            timer_counter: 0,
149            otel_spans: None,
150            source_file: Some(std::panic::Location::caller().file().to_string()),
151        };
152        collector::ensure_exit_hook();
153        story.bridge_otel();
154        story
155    }
156
157    /// Set tags on the story (consumes and returns self for chaining at creation).
158    #[must_use]
159    pub fn with_tags(mut self, tags: &[&str]) -> Self {
160        self.tags = Some(tags.iter().map(std::string::ToString::to_string).collect());
161        self
162    }
163
164    /// Set tickets on the story (consumes and returns self for chaining at creation).
165    #[must_use]
166    pub fn with_tickets(mut self, tickets: &[&str]) -> Self {
167        self.tickets = Some(
168            tickets
169                .iter()
170                .map(|t| crate::types::Ticket { id: (*t).to_string(), url: None })
171                .collect(),
172        );
173        self
174    }
175
176    /// Declare the product-code paths/globs this story exercises (consumes and returns self).
177    #[must_use]
178    pub fn with_covers(mut self, covers: &[&str]) -> Self {
179        self.covers = Some(covers.iter().map(std::string::ToString::to_string).collect());
180        self
181    }
182
183    /// Add a ticket with a URL to the story (consumes and returns self for chaining).
184    #[must_use]
185    pub fn with_ticket_url(mut self, id: &str, url: &str) -> Self {
186        let ticket = crate::types::Ticket { id: id.to_string(), url: Some(url.to_string()) };
187        match &mut self.tickets {
188            Some(tickets) => tickets.push(ticket),
189            None => self.tickets = Some(vec![ticket]),
190        }
191        self
192    }
193
194    /// Set the URL template for `OTel` trace links (uses {traceId} placeholder).
195    /// Also settable via `OTEL_TRACE_URL_TEMPLATE` env var.
196    #[must_use]
197    pub fn with_trace_url_template(mut self, template: &str) -> Self {
198        self.trace_url_template = Some(template.to_string());
199        self
200    }
201
202    /// `OTel` bridge: detect active span, flow data bidirectionally.
203    /// This is a no-op when the `otel` feature is not enabled.
204    fn bridge_otel(&mut self) {
205        #[cfg(feature = "otel")]
206        {
207            #[allow(unused_imports)]
208            use opentelemetry::trace::{Span, TraceContextExt};
209            let cx = opentelemetry::Context::current();
210            let span_ref = cx.span();
211            let span_ctx = span_ref.span_context();
212
213            if !span_ctx.trace_id().to_string().chars().any(|c| c != '0') {
214                return;
215            }
216
217            let trace_id = span_ctx.trace_id().to_string();
218            let span_id = span_ctx.span_id().to_string();
219
220            // OTel -> Story: capture traceId in structured meta
221            self.meta = Some(serde_json::json!({
222                "otel": { "traceId": &trace_id, "spanId": &span_id }
223            }));
224
225            // OTel -> Story: inject human-readable doc entries
226            self.docs.push(DocEntry::kv("Trace ID", serde_json::json!(trace_id.clone())));
227
228            let template = self
229                .trace_url_template
230                .clone()
231                .or_else(|| std::env::var("OTEL_TRACE_URL_TEMPLATE").ok());
232            if let Some(tmpl) = template {
233                let url = tmpl.replace("{traceId}", &trace_id);
234                self.docs.push(DocEntry::link("View Trace", &url));
235            }
236
237            // Story -> OTel: enrich active span with story attributes
238            let span = cx.span();
239            span.set_attribute(opentelemetry::KeyValue::new(
240                "story.scenario",
241                self.scenario.clone(),
242            ));
243            if let Some(ref tags) = self.tags {
244                span.set_attribute(opentelemetry::KeyValue::new("story.tags", format!("{tags:?}")));
245            }
246            if let Some(ref tickets) = self.tickets {
247                span.set_attribute(opentelemetry::KeyValue::new(
248                    "story.tickets",
249                    format!("{tickets:?}"),
250                ));
251            }
252        }
253    }
254
255    // --- BDD step methods ---
256
257    fn add_step(&mut self, keyword: &str, text: &str) -> &mut Self {
258        // Auto-And: repeated primary keywords render as "And"
259        let effective = match keyword {
260            "Given" | "When" | "Then" => {
261                if self.seen_primary.insert(keyword.to_string()) {
262                    keyword
263                } else {
264                    "And"
265                }
266            }
267            _ => keyword,
268        };
269        let id = format!("step-{}", self.step_counter);
270        self.step_counter += 1;
271        self.steps.push(StoryStep {
272            keyword: effective.to_string(),
273            text: text.to_string(),
274            id: Some(id),
275            mode: None,
276            wrapped: None,
277            assertions: None,
278            duration_ms: None,
279            docs: None,
280            started_at_ms: Some(collector::epoch_ms()),
281        });
282        self.current_step_index = Some(self.steps.len() - 1);
283        self
284    }
285
286    /// Add a Given step.
287    pub fn given(&mut self, text: &str) -> &mut Self {
288        self.add_step("Given", text)
289    }
290
291    /// Add a When step.
292    pub fn when(&mut self, text: &str) -> &mut Self {
293        self.add_step("When", text)
294    }
295
296    /// Add a Then step.
297    pub fn then(&mut self, text: &str) -> &mut Self {
298        self.add_step("Then", text)
299    }
300
301    /// Add an And step.
302    pub fn and(&mut self, text: &str) -> &mut Self {
303        self.add_step("And", text)
304    }
305
306    /// Add a But step.
307    pub fn but(&mut self, text: &str) -> &mut Self {
308        self.add_step("But", text)
309    }
310
311    // --- AAA pattern aliases ---
312
313    /// Add an Arrange step (alias for Given).
314    pub fn arrange(&mut self, text: &str) -> &mut Self {
315        self.add_step("Given", text)
316    }
317
318    /// Add an Act step (alias for When).
319    pub fn act(&mut self, text: &str) -> &mut Self {
320        self.add_step("When", text)
321    }
322
323    /// Add an Assert step (alias for Then). Uses `assert_that` because assert! is a macro.
324    pub fn assert_that(&mut self, text: &str) -> &mut Self {
325        self.add_step("Then", text)
326    }
327
328    // --- Additional aliases ---
329
330    /// Add a Setup step (alias for Given).
331    pub fn setup(&mut self, text: &str) -> &mut Self {
332        self.add_step("Given", text)
333    }
334
335    /// Add a Context step (alias for Given).
336    pub fn context(&mut self, text: &str) -> &mut Self {
337        self.add_step("Given", text)
338    }
339
340    /// Add an Execute step (alias for When).
341    pub fn execute(&mut self, text: &str) -> &mut Self {
342        self.add_step("When", text)
343    }
344
345    /// Add an Action step (alias for When).
346    pub fn action(&mut self, text: &str) -> &mut Self {
347        self.add_step("When", text)
348    }
349
350    /// Add a Verify step (alias for Then).
351    pub fn verify(&mut self, text: &str) -> &mut Self {
352        self.add_step("Then", text)
353    }
354
355    /// Attach inline documentation entries to the most recently added step.
356    pub fn with_docs(&mut self, docs: Vec<StepDoc>) -> &mut Self {
357        if let Some(idx) = self.current_step_index {
358            let step = &mut self.steps[idx];
359            for doc in docs {
360                doc.apply(step);
361            }
362        }
363        self
364    }
365
366    // --- Step timing ---
367
368    /// Start a high-resolution timer tied to the current step. Returns a token to pass to `end_timer()`.
369    pub fn start_timer(&mut self) -> usize {
370        let token = self.timer_counter;
371        self.timer_counter += 1;
372        let entry = TimerEntry {
373            start: std::time::Instant::now(),
374            step_index: self.current_step_index,
375            step_id: self.current_step_id(),
376            consumed: false,
377        };
378        self.active_timers.insert(token, entry);
379        token
380    }
381
382    /// Stop the timer and record `duration_ms` on the step that was active when `start_timer()` was called. Double-end is a no-op.
383    pub fn end_timer(&mut self, token: usize) {
384        let entry = match self.active_timers.get_mut(&token) {
385            Some(e) if !e.consumed => e,
386            _ => return,
387        };
388        entry.consumed = true;
389        let duration_ms = entry.start.elapsed().as_secs_f64() * 1000.0;
390        let step_index = entry.step_index;
391        let step_id = entry.step_id.clone();
392
393        if let Some(idx) = step_index {
394            if let Some(step) = self.steps.get_mut(idx) {
395                step.duration_ms = Some(duration_ms);
396            }
397        } else if let Some(id) = step_id {
398            if let Some(step) = self.steps.iter_mut().find(|s| s.id.as_deref() == Some(id.as_str()))
399            {
400                step.duration_ms = Some(duration_ms);
401            }
402        }
403    }
404
405    // --- Wrapped step execution ---
406
407    /// Wrap a closure as a BDD step with automatic timing.
408    /// Creates a step marked as `wrapped=true`, executes the body,
409    /// records `duration_ms`, and re-panics if the body panicked.
410    pub fn fn_step<F, T>(&mut self, keyword: &str, text: &str, body: F) -> T
411    where
412        F: FnOnce() -> T,
413    {
414        self.add_step(keyword, text);
415        let idx = self.steps.len() - 1;
416        self.steps[idx].wrapped = Some(true);
417        // Wrapping a claim is the only signal Rust can give that the step
418        // checked something: the body ran to completion. Setup steps arrange,
419        // so only a claim counts — tested against the keyword as written, since
420        // auto-And rewrites a repeated Then before the step is stored.
421        if keyword == "Then" {
422            self.steps[idx].assertions = Some(1);
423        }
424
425        let start = std::time::Instant::now();
426        self.steps[idx].started_at_ms = Some(collector::epoch_ms());
427        let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(body));
428        self.steps[idx].duration_ms = Some(start.elapsed().as_secs_f64() * 1000.0);
429
430        match result {
431            Ok(val) => val,
432            Err(panic) => std::panic::resume_unwind(panic),
433        }
434    }
435
436    /// Shorthand for `fn_step("Then", text, body)`.
437    pub fn expect_step<F, T>(&mut self, text: &str, body: F) -> T
438    where
439        F: FnOnce() -> T,
440    {
441        self.fn_step("Then", text, body)
442    }
443
444    // --- Doc attachment methods ---
445
446    /// Attach a doc entry either to the current step or to the story-level docs.
447    fn attach_doc(&mut self, entry: DocEntry) -> &mut Self {
448        if let Some(idx) = self.current_step_index {
449            let step = &mut self.steps[idx];
450            let docs = step.docs.get_or_insert_with(Vec::new);
451            docs.push(entry);
452        } else {
453            self.docs.push(entry);
454        }
455        self
456    }
457
458    /// Attach a text note to the current step or story.
459    pub fn note(&mut self, text: &str) -> &mut Self {
460        self.attach_doc(DocEntry::note(text))
461    }
462
463    /// Attach tag annotations to the current step or story.
464    pub fn tag(&mut self, names: &[&str]) -> &mut Self {
465        self.attach_doc(DocEntry::tag(names))
466    }
467
468    /// Attach a key-value pair to the current step or story.
469    pub fn kv(&mut self, label: &str, value: serde_json::Value) -> &mut Self {
470        self.attach_doc(DocEntry::kv(label, value))
471    }
472
473    /// Attach a JSON document (serialized as code with lang=json) to the current step or story.
474    pub fn json(&mut self, label: &str, value: &impl Serialize) -> &mut Self {
475        self.attach_doc(DocEntry::json_doc(label, value))
476    }
477
478    /// Attach a code block to the current step or story.
479    pub fn code(&mut self, label: &str, content: &str, lang: Option<&str>) -> &mut Self {
480        self.attach_doc(DocEntry::code(label, content, lang))
481    }
482
483    /// Attach a table to the current step or story.
484    pub fn table(&mut self, label: &str, columns: &[&str], rows: &[&[&str]]) -> &mut Self {
485        self.attach_doc(DocEntry::table(label, columns, rows))
486    }
487
488    /// Attach a link to the current step or story.
489    pub fn link(&mut self, label: &str, url: &str) -> &mut Self {
490        self.attach_doc(DocEntry::link(label, url))
491    }
492
493    /// Attach a markdown section to the current step or story.
494    pub fn section(&mut self, title: &str, markdown: &str) -> &mut Self {
495        self.attach_doc(DocEntry::section(title, markdown))
496    }
497
498    /// Attach a mermaid diagram to the current step or story.
499    pub fn mermaid(&mut self, code: &str, title: Option<&str>) -> &mut Self {
500        self.attach_doc(DocEntry::mermaid(code, title))
501    }
502
503    /// Attach a screenshot reference to the current step or story.
504    pub fn screenshot(&mut self, path: &str, alt: Option<&str>) -> &mut Self {
505        self.attach_doc(DocEntry::screenshot(path, alt))
506    }
507
508    /// Attach an embedded-HTML doc entry to the current step or story.
509    ///
510    /// Exactly one of `opts.path`, `opts.url`, or `opts.content` must be set.
511    pub fn html(&mut self, opts: crate::doc_entry::HtmlOptions) -> &mut Self {
512        self.attach_doc(DocEntry::html(opts))
513    }
514
515    /// Attach a state snapshot to the current step or story.
516    ///
517    /// `value` is a JSON-serializable snapshot of "what the world looks like"
518    /// at this step; consecutive states with the same label are diffed by the
519    /// report renderer. Pass `None` for an anonymous state lane (label omitted).
520    pub fn state(&mut self, label: Option<&str>, value: serde_json::Value) -> &mut Self {
521        self.attach_doc(DocEntry::state(label, value))
522    }
523
524    /// Attach a custom doc entry to the current step or story.
525    pub fn custom(&mut self, type_name: &str, data: serde_json::Value) -> &mut Self {
526        self.attach_doc(DocEntry::custom(type_name, data))
527    }
528
529    // --- Attachment methods ---
530
531    /// Return the ID of the current (last) step, if any.
532    fn current_step_id(&self) -> Option<String> {
533        self.steps.last().and_then(|s| s.id.clone())
534    }
535
536    /// Attach a file-based attachment to the story.
537    pub fn attach(&mut self, name: &str, media_type: &str, path: &str) -> &mut Self {
538        let mut a = RawAttachment {
539            name: name.to_string(),
540            media_type: media_type.to_string(),
541            path: if path.is_empty() { None } else { Some(path.to_string()) },
542            body: None,
543            encoding: None,
544            charset: None,
545            file_name: None,
546            byte_length: None,
547            step_index: None,
548            step_id: None,
549        };
550        if let Some(ref current) = self.current_step_id() {
551            a.step_index = Some(self.steps.len().saturating_sub(1));
552            a.step_id = Some(current.clone());
553        }
554        self.attachments.push(a);
555        self
556    }
557
558    /// Attach an inline (body-based) attachment to the story.
559    pub fn attach_inline(
560        &mut self,
561        name: &str,
562        media_type: &str,
563        body: &str,
564        encoding: &str,
565    ) -> &mut Self {
566        let mut a = RawAttachment {
567            name: name.to_string(),
568            media_type: media_type.to_string(),
569            path: None,
570            body: Some(body.to_string()),
571            encoding: Some(encoding.to_string()),
572            charset: None,
573            file_name: None,
574            byte_length: None,
575            step_index: None,
576            step_id: None,
577        };
578        if let Some(ref current) = self.current_step_id() {
579            a.step_index = Some(self.steps.len().saturating_sub(1));
580            a.step_id = Some(current.clone());
581        }
582        self.attachments.push(a);
583        self
584    }
585
586    /// Attach `OTel` spans for trace waterfall rendering in HTML reports.
587    pub fn attach_spans(&mut self, spans: Vec<serde_json::Value>) -> &mut Self {
588        self.otel_spans = Some(spans);
589        self
590    }
591
592    /// Mark the story as passed.
593    ///
594    /// Optional: a story dropped with no panic in flight already records
595    /// `"pass"`. Kept so existing tests still compile, and so a `Result`-returning
596    /// test can state its happy path.
597    pub fn pass(&mut self) {
598        self.passed = true;
599        self.failed = false;
600    }
601
602    /// Mark the story as failed.
603    ///
604    /// Needed only when a test fails without panicking, which in practice means
605    /// a `#[test]` returning `Err`. Panicking assertions are detected on drop.
606    pub fn fail(&mut self) {
607        self.failed = true;
608    }
609
610    /// Record the outcome of a `Result`, then hand it back for `?` to bubble up.
611    ///
612    /// # Errors
613    ///
614    /// Returns `result` untouched, so the caller sees the original error.
615    ///
616    /// ```
617    /// # use executable_stories::Story;
618    /// fn parses_a_price() -> Result<(), std::num::ParseIntError> {
619    ///     let mut story = Story::new("parses a price");
620    ///     story.given("the string 499");
621    ///     story.then("it parses to 499");
622    ///     let parsed = story.record_result("499".parse::<u32>())?;
623    ///     assert_eq!(parsed, 499);
624    ///     Ok(())
625    /// }
626    /// # parses_a_price().unwrap();
627    /// ```
628    pub fn record_result<T, E>(&mut self, result: Result<T, E>) -> Result<T, E> {
629        if result.is_err() {
630            self.fail();
631        }
632        result
633    }
634
635    /// Record a scenario that is specified but not built yet. It appears in the
636    /// report marked "planned" and stops being planned once someone writes it as
637    /// a real story.
638    ///
639    /// ```no_run
640    /// # use executable_stories::Story;
641    /// #[test]
642    /// fn checkout_blocks_suspended_account() {
643    ///     Story::planned("checkout is blocked for a suspended account");
644    /// }
645    /// ```
646    ///
647    /// `#[ignore]` means "do not run this now", which is a different claim from
648    /// "we have not built this yet", so this does not ignore the test for you.
649    ///
650    /// The scenario is recorded when this returns, so keep it the only statement
651    /// in the test: a panic afterwards cannot revise a record already written.
652    #[track_caller]
653    pub fn planned(scenario: &str) {
654        let mut story = Story::new(scenario);
655        story.planned = true;
656        // Drop records it: no steps, no pass() needed.
657    }
658}
659
660impl Drop for Story {
661    fn drop(&mut self) {
662        let status = if self.planned {
663            "todo"
664        } else if self.failed || (!self.passed && std::thread::panicking()) {
665            "fail"
666        } else {
667            "pass"
668        };
669        let duration = self.start_time.elapsed().as_secs_f64() * 1000.0;
670
671        let step_events: Vec<RawStepEvent> = self
672            .steps
673            .iter()
674            .enumerate()
675            .filter_map(|(i, step)| {
676                step.duration_ms.map(|d| RawStepEvent {
677                    index: Some(i),
678                    title: Some(step.text.clone()),
679                    status: None,
680                    duration_ms: Some(d),
681                })
682            })
683            .collect();
684
685        let tc = RawTestCase {
686            status: status.to_string(),
687            title: Some(self.scenario.clone()),
688            source_file: self.source_file.clone(),
689            story: Some(StoryMeta {
690                scenario: self.scenario.clone(),
691                steps: self.steps.clone(),
692                tags: self.tags.clone(),
693                tickets: self.tickets.clone(),
694                covers: self.covers.clone(),
695                meta: self.meta.clone(),
696                docs: if self.docs.is_empty() { None } else { Some(self.docs.clone()) },
697                source_order: self.source_order,
698                otel_spans: self.otel_spans.clone(),
699                // A planned scenario never ran, so it has no start time.
700                started_at_ms: (!self.planned).then_some(self.started_at_ms),
701            }),
702            duration_ms: Some(duration),
703            retry: 0,
704            retries: 0,
705            attachments: if self.attachments.is_empty() {
706                None
707            } else {
708                Some(self.attachments.clone())
709            },
710            step_events: if step_events.is_empty() { None } else { Some(step_events) },
711            ..Default::default()
712        };
713        collector::record(tc);
714    }
715}
716
717#[cfg(test)]
718mod tests {
719    use super::*;
720    use std::thread;
721    use std::time::Duration;
722
723    // Rust has no assertion counter to read, so a step's count is only known
724    // when the author routes the claim through a wrapped body. Bare markers
725    // stay unobserved: absent is honest, zero would be an accusation.
726    fn steps_for(scenario: &str) -> Vec<crate::types::StoryStep> {
727        crate::collector::get_all()
728            .into_iter()
729            .find(|tc| tc.story.as_ref().is_some_and(|m| m.scenario == scenario))
730            .expect("scenario was not recorded")
731            .story
732            .expect("story meta was not recorded")
733            .steps
734    }
735
736    #[test]
737    fn steps_and_scenario_record_start_times() {
738        let scenario = "timing: start times";
739        let mut s = Story::new(scenario);
740        s.given("a first step");
741        thread::sleep(Duration::from_millis(2));
742        s.expect_step("a later wrapped step", || {});
743        s.pass();
744        drop(s);
745
746        let meta = crate::collector::get_all()
747            .into_iter()
748            .find(|tc| tc.story.as_ref().is_some_and(|m| m.scenario == scenario))
749            .and_then(|tc| tc.story)
750            .unwrap();
751        let story_start = meta.started_at_ms.unwrap();
752        let first = meta.steps[0].started_at_ms.unwrap();
753        let second = meta.steps[1].started_at_ms.unwrap();
754        assert!(story_start > 1_000_000_000_000.0, "epoch ms, got {story_start}");
755        assert!(first >= story_start);
756        assert!(second >= first);
757
758        let json = serde_json::to_value(&meta).unwrap();
759        assert!(json["startedAtMs"].is_number());
760        assert!(json["steps"][0]["startedAtMs"].is_number());
761    }
762
763    #[test]
764    fn expect_step_records_an_assertion() {
765        let scenario = "assertions: a wrapped claim";
766        let mut s = Story::new(scenario);
767        s.given("two numbers 5 and 3");
768        s.expect_step("the result is 8", || {});
769        s.pass();
770        drop(s);
771
772        let steps = steps_for(scenario);
773        assert_eq!(steps[1].assertions, Some(1));
774    }
775
776    #[test]
777    fn marker_steps_stay_unobserved() {
778        let scenario = "assertions: an unwrapped claim";
779        let mut s = Story::new(scenario);
780        s.given("two numbers 5 and 3");
781        s.then("the result is 8");
782        s.pass();
783        drop(s);
784
785        for step in steps_for(scenario) {
786            assert_eq!(step.assertions, None);
787        }
788    }
789
790    #[test]
791    fn wrapped_setup_does_not_count_as_a_claim() {
792        let scenario = "assertions: wrapped setup";
793        let mut s = Story::new(scenario);
794        s.fn_step("Given", "an expensive fixture", || {});
795        s.pass();
796        drop(s);
797
798        assert_eq!(steps_for(scenario)[0].assertions, None);
799    }
800
801    #[test]
802    fn second_wrapped_claim_still_counts() {
803        // Auto-And rewrites a repeated Then before the step is stored.
804        let scenario = "assertions: two claims";
805        let mut s = Story::new(scenario);
806        s.given("two numbers 5 and 3");
807        s.expect_step("the result is 8", || {});
808        s.expect_step("the result is positive", || {});
809        s.pass();
810        drop(s);
811
812        let steps = steps_for(scenario);
813        assert_eq!(steps[2].keyword, "And");
814        assert_eq!(steps[2].assertions, Some(1));
815    }
816
817    // The collector is global and tests run in parallel, so this filters by a
818    // scenario name no other test uses rather than asserting on the total.
819    #[test]
820    fn planned_records_a_todo_scenario() {
821        Story::planned("planned: checkout is blocked for a suspended account");
822
823        let recorded = crate::collector::get_all();
824        let tc = recorded
825            .iter()
826            .find(|tc| {
827                tc.story.as_ref().is_some_and(|s| {
828                    s.scenario == "planned: checkout is blocked for a suspended account"
829                })
830            })
831            .expect("planned scenario was not recorded");
832
833        assert_eq!(tc.status, "todo");
834        assert!(tc.story.as_ref().unwrap().steps.is_empty());
835    }
836
837    fn find(scenario: &str) -> crate::types::RawTestCase {
838        crate::collector::get_all()
839            .into_iter()
840            .find(|tc| tc.story.as_ref().is_some_and(|s| s.scenario == scenario))
841            .unwrap_or_else(|| panic!("{scenario} was not recorded"))
842    }
843
844    #[test]
845    fn passing_test_records_pass_without_calling_pass() {
846        {
847            let mut story = Story::new("auto-status: no pass() call");
848            story.given("a story that never calls pass()");
849            story.then("it still records as passed");
850        }
851
852        assert_eq!(find("auto-status: no pass() call").status, "pass");
853    }
854
855    #[test]
856    fn panicking_test_records_fail() {
857        let outcome = std::panic::catch_unwind(|| {
858            let mut story = Story::new("auto-status: assertion panics");
859            story.given("a story whose assertion fails");
860            story.then("it records as failed");
861            panic!("assertion failed");
862        });
863        assert!(outcome.is_err());
864
865        assert_eq!(find("auto-status: assertion panics").status, "fail");
866    }
867
868    #[test]
869    fn record_result_marks_err_as_fail() {
870        {
871            let mut story = Story::new("auto-status: Result test returns Err");
872            story.given("a Result-returning test");
873            let _ = story.record_result("not a number".parse::<u32>());
874        }
875
876        assert_eq!(find("auto-status: Result test returns Err").status, "fail");
877    }
878
879    #[test]
880    fn auto_and_on_repeated_primary_keyword() {
881        let mut story = Story::new("auto-and across story");
882        story.given("first given");
883        story.when("a when in between");
884        story.given("second given");
885
886        assert_eq!(story.steps[0].keyword, "Given");
887        assert_eq!(story.steps[1].keyword, "When");
888        assert_eq!(story.steps[2].keyword, "And");
889    }
890
891    #[test]
892    fn aaa_aliases_produce_correct_keywords() {
893        let mut story = Story::new("AAA story");
894        story.arrange("precondition");
895        story.act("action");
896        story.assert_that("outcome");
897
898        assert_eq!(story.steps[0].keyword, "Given");
899        assert_eq!(story.steps[1].keyword, "When");
900        assert_eq!(story.steps[2].keyword, "Then");
901    }
902
903    #[test]
904    fn extra_aliases_produce_correct_keywords() {
905        let mut story = Story::new("Extra aliases");
906        story.setup("setup");
907        story.context("context");
908        story.execute("execute");
909        story.action("action");
910        story.verify("verify");
911
912        assert_eq!(story.steps[0].keyword, "Given");
913        assert_eq!(story.steps[1].keyword, "And");
914        assert_eq!(story.steps[2].keyword, "When");
915        assert_eq!(story.steps[3].keyword, "And");
916        assert_eq!(story.steps[4].keyword, "Then");
917    }
918
919    #[test]
920    fn with_docs_attaches_to_step() {
921        let mut story = Story::new("With docs");
922        story.given("a step").with_docs(vec![StepDoc::note("a note"), StepDoc::tag(&["smoke"])]);
923
924        assert_eq!(story.steps.len(), 1);
925        let docs = story.steps[0].docs.as_ref().unwrap();
926        assert_eq!(docs.len(), 2);
927        let note = serde_json::to_value(&docs[0]).unwrap();
928        assert_eq!(note["kind"], "note");
929        assert_eq!(note["text"], "a note");
930        let tag = serde_json::to_value(&docs[1]).unwrap();
931        assert_eq!(tag["kind"], "tag");
932    }
933
934    #[test]
935    fn state_attaches_to_current_step_else_story() {
936        let mut story = Story::new("State docs");
937
938        // Before any step: story-level
939        story.state(Some("Basket"), serde_json::json!({"items": []}));
940        assert_eq!(story.docs.len(), 1);
941        let story_doc = serde_json::to_value(&story.docs[0]).unwrap();
942        assert_eq!(story_doc["kind"], "state");
943
944        // After a step: attaches to that step
945        story.given("an item is added");
946        story.state(Some("Basket"), serde_json::json!({"items": [{"sku": "A1"}]}));
947        let docs = story.steps[0].docs.as_ref().unwrap();
948        assert_eq!(docs.len(), 1);
949        let step_doc = serde_json::to_value(&docs[0]).unwrap();
950        assert_eq!(step_doc["kind"], "state");
951        assert_eq!(step_doc["label"], "Basket");
952        assert_eq!(step_doc["value"]["items"][0]["sku"], "A1");
953        story.pass();
954    }
955
956    #[test]
957    fn step_timing() {
958        let mut story = Story::new("Timed step");
959        story.given("slow step");
960        let token = story.start_timer();
961        thread::sleep(Duration::from_millis(15));
962        story.end_timer(token);
963        story.pass();
964
965        assert_eq!(story.steps.len(), 1);
966        let d = story.steps[0].duration_ms.unwrap();
967        assert!(d >= 10.0, "duration_ms should be >= 10, got {d}");
968    }
969
970    #[test]
971    fn double_end_timer_is_noop() {
972        let mut story = Story::new("Double end");
973        story.given("step");
974        let token = story.start_timer();
975        story.end_timer(token);
976        story.end_timer(token); // no-op
977        story.pass();
978
979        assert_eq!(story.steps.len(), 1);
980        assert!(story.steps[0].duration_ms.is_some());
981    }
982
983    #[test]
984    fn fn_step_creates_wrapped_step() {
985        let mut story = Story::new("fn_step test");
986        let mut called = false;
987        story.fn_step("Given", "a wrapped precondition", || {
988            called = true;
989        });
990        story.pass();
991
992        assert!(called);
993        assert_eq!(story.steps.len(), 1);
994        assert_eq!(story.steps[0].keyword, "Given");
995        assert_eq!(story.steps[0].text, "a wrapped precondition");
996        assert_eq!(story.steps[0].wrapped, Some(true));
997        assert!(story.steps[0].duration_ms.is_some());
998    }
999
1000    #[test]
1001    fn fn_step_records_duration() {
1002        let mut story = Story::new("fn_step duration");
1003        story.fn_step("When", "I wait briefly", || {
1004            thread::sleep(Duration::from_millis(15));
1005        });
1006        story.pass();
1007
1008        let d = story.steps[0].duration_ms.unwrap();
1009        assert!(d >= 10.0, "duration_ms should be >= 10, got {d}");
1010    }
1011
1012    #[test]
1013    fn fn_step_returns_result() {
1014        let mut story = Story::new("fn_step return");
1015        let result = story.fn_step("When", "I compute", || 42);
1016        story.pass();
1017
1018        assert_eq!(result, 42);
1019    }
1020
1021    #[test]
1022    #[should_panic(expected = "boom")]
1023    fn fn_step_propagates_panics() {
1024        let mut story = Story::new("fn_step panic");
1025        story.fn_step("Then", "it panics", || {
1026            panic!("boom");
1027        });
1028        story.pass();
1029    }
1030
1031    #[test]
1032    fn fn_step_auto_and_conversion() {
1033        let mut story = Story::new("fn_step auto-and");
1034        story.given("a text-only step");
1035        story.fn_step("Given", "a wrapped step", || {});
1036        story.pass();
1037
1038        assert_eq!(story.steps[0].keyword, "Given");
1039        assert_eq!(story.steps[1].keyword, "And");
1040        assert_eq!(story.steps[1].wrapped, Some(true));
1041    }
1042
1043    #[test]
1044    fn fn_step_integration_with_markers() {
1045        let mut story = Story::new("fn_step + markers");
1046        story.given("a text-only precondition");
1047        story.fn_step("When", "I perform action", || {});
1048        story.then("a text-only assertion");
1049        story.fn_step("Then", "the wrapped assertion", || {});
1050        story.pass();
1051
1052        assert_eq!(story.steps.len(), 4);
1053        assert_eq!(story.steps[0].wrapped, None);
1054        assert_eq!(story.steps[1].wrapped, Some(true));
1055        assert_eq!(story.steps[2].wrapped, None);
1056        assert_eq!(story.steps[3].wrapped, Some(true));
1057    }
1058
1059    #[test]
1060    fn expect_step_creates_wrapped_then_step() {
1061        let mut story = Story::new("expect_step test");
1062        let mut called = false;
1063        story.expect_step("the result is correct", || {
1064            called = true;
1065        });
1066        story.pass();
1067
1068        assert!(called);
1069        assert_eq!(story.steps[0].keyword, "Then");
1070        assert_eq!(story.steps[0].text, "the result is correct");
1071        assert_eq!(story.steps[0].wrapped, Some(true));
1072        assert!(story.steps[0].duration_ms.is_some());
1073    }
1074
1075    #[test]
1076    fn expect_step_returns_result() {
1077        let mut story = Story::new("expect_step return");
1078        let result = story.expect_step("check value", || true);
1079        story.pass();
1080
1081        assert!(result);
1082    }
1083}