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 custom(type_name: &str, data: serde_json::Value) -> Self {
56        StepDoc(DocEntry::custom(type_name, data))
57    }
58    fn apply(self, step: &mut StoryStep) {
59        step.docs.get_or_insert_with(Vec::new).push(self.0);
60    }
61}
62
63struct TimerEntry {
64    start: std::time::Instant,
65    step_index: Option<usize>,
66    step_id: Option<String>,
67    consumed: bool,
68}
69
70/// A BDD story builder that captures steps and emits a `RawTestCase` on drop.
71///
72/// Call `.pass()` before the story goes out of scope to mark the test as passed.
73/// If `.pass()` is not called, the test case will be recorded with status `"fail"`.
74pub struct Story {
75    scenario: String,
76    steps: Vec<StoryStep>,
77    tags: Option<Vec<String>>,
78    tickets: Option<Vec<crate::types::Ticket>>,
79    meta: Option<serde_json::Value>,
80    trace_url_template: Option<String>,
81    docs: Vec<DocEntry>,
82    current_step_index: Option<usize>,
83    seen_primary: HashSet<String>,
84    source_order: Option<u32>,
85    start_time: std::time::Instant,
86    passed: bool,
87    step_counter: usize,
88    attachments: Vec<RawAttachment>,
89    active_timers: HashMap<usize, TimerEntry>,
90    timer_counter: usize,
91    otel_spans: Option<Vec<serde_json::Value>>,
92}
93
94impl Story {
95    /// Create a new story with the given scenario description.
96    #[must_use]
97    pub fn new(scenario: &str) -> Self {
98        let mut story = Story {
99            scenario: scenario.to_string(),
100            steps: Vec::new(),
101            tags: None,
102            tickets: None,
103            meta: None,
104            trace_url_template: None,
105            docs: Vec::new(),
106            current_step_index: None,
107            seen_primary: HashSet::new(),
108            source_order: Some(collector::next_order()),
109            start_time: std::time::Instant::now(),
110            passed: false,
111            step_counter: 0,
112            attachments: Vec::new(),
113            active_timers: HashMap::new(),
114            timer_counter: 0,
115            otel_spans: None,
116        };
117        story.bridge_otel();
118        story
119    }
120
121    /// Set tags on the story (consumes and returns self for chaining at creation).
122    #[must_use]
123    pub fn with_tags(mut self, tags: &[&str]) -> Self {
124        self.tags = Some(tags.iter().map(std::string::ToString::to_string).collect());
125        self
126    }
127
128    /// Set tickets on the story (consumes and returns self for chaining at creation).
129    #[must_use]
130    pub fn with_tickets(mut self, tickets: &[&str]) -> Self {
131        self.tickets = Some(tickets.iter().map(|t| crate::types::Ticket { id: (*t).to_string(), url: None }).collect());
132        self
133    }
134
135    /// Add a ticket with a URL to the story (consumes and returns self for chaining).
136    #[must_use]
137    pub fn with_ticket_url(mut self, id: &str, url: &str) -> Self {
138        let ticket = crate::types::Ticket { id: id.to_string(), url: Some(url.to_string()) };
139        match &mut self.tickets {
140            Some(tickets) => tickets.push(ticket),
141            None => self.tickets = Some(vec![ticket]),
142        }
143        self
144    }
145
146    /// Set the URL template for `OTel` trace links (uses {traceId} placeholder).
147    /// Also settable via `OTEL_TRACE_URL_TEMPLATE` env var.
148    #[must_use]
149    pub fn with_trace_url_template(mut self, template: &str) -> Self {
150        self.trace_url_template = Some(template.to_string());
151        self
152    }
153
154    /// `OTel` bridge: detect active span, flow data bidirectionally.
155    /// This is a no-op when the `otel` feature is not enabled.
156    fn bridge_otel(&mut self) {
157        #[cfg(feature = "otel")]
158        {
159            #[allow(unused_imports)]
160            use opentelemetry::trace::{Span, TraceContextExt};
161            let cx = opentelemetry::Context::current();
162            let span_ref = cx.span();
163            let span_ctx = span_ref.span_context();
164
165            if !span_ctx.trace_id().to_string().chars().any(|c| c != '0') {
166                return;
167            }
168
169            let trace_id = span_ctx.trace_id().to_string();
170            let span_id = span_ctx.span_id().to_string();
171
172            // OTel -> Story: capture traceId in structured meta
173            self.meta = Some(serde_json::json!({
174                "otel": { "traceId": &trace_id, "spanId": &span_id }
175            }));
176
177            // OTel -> Story: inject human-readable doc entries
178            self.docs.push(DocEntry::kv("Trace ID", serde_json::json!(trace_id.clone())));
179
180            let template = self
181                .trace_url_template
182                .clone()
183                .or_else(|| std::env::var("OTEL_TRACE_URL_TEMPLATE").ok());
184            if let Some(tmpl) = template {
185                let url = tmpl.replace("{traceId}", &trace_id);
186                self.docs.push(DocEntry::link("View Trace", &url));
187            }
188
189            // Story -> OTel: enrich active span with story attributes
190            let span = cx.span();
191            span.set_attribute(opentelemetry::KeyValue::new(
192                "story.scenario",
193                self.scenario.clone(),
194            ));
195            if let Some(ref tags) = self.tags {
196                span.set_attribute(opentelemetry::KeyValue::new("story.tags", format!("{tags:?}")));
197            }
198            if let Some(ref tickets) = self.tickets {
199                span.set_attribute(opentelemetry::KeyValue::new(
200                    "story.tickets",
201                    format!("{tickets:?}"),
202                ));
203            }
204        }
205    }
206
207    // --- BDD step methods ---
208
209    fn add_step(&mut self, keyword: &str, text: &str) -> &mut Self {
210        // Auto-And: repeated primary keywords render as "And"
211        let effective = match keyword {
212            "Given" | "When" | "Then" => {
213                if self.seen_primary.insert(keyword.to_string()) {
214                    keyword
215                } else {
216                    "And"
217                }
218            }
219            _ => keyword,
220        };
221        let id = format!("step-{}", self.step_counter);
222        self.step_counter += 1;
223        self.steps.push(StoryStep {
224            keyword: effective.to_string(),
225            text: text.to_string(),
226            id: Some(id),
227            mode: None,
228            wrapped: None,
229            duration_ms: None,
230            docs: None,
231        });
232        self.current_step_index = Some(self.steps.len() - 1);
233        self
234    }
235
236    /// Add a Given step.
237    pub fn given(&mut self, text: &str) -> &mut Self {
238        self.add_step("Given", text)
239    }
240
241    /// Add a When step.
242    pub fn when(&mut self, text: &str) -> &mut Self {
243        self.add_step("When", text)
244    }
245
246    /// Add a Then step.
247    pub fn then(&mut self, text: &str) -> &mut Self {
248        self.add_step("Then", text)
249    }
250
251    /// Add an And step.
252    pub fn and(&mut self, text: &str) -> &mut Self {
253        self.add_step("And", text)
254    }
255
256    /// Add a But step.
257    pub fn but(&mut self, text: &str) -> &mut Self {
258        self.add_step("But", text)
259    }
260
261    // --- AAA pattern aliases ---
262
263    /// Add an Arrange step (alias for Given).
264    pub fn arrange(&mut self, text: &str) -> &mut Self {
265        self.add_step("Given", text)
266    }
267
268    /// Add an Act step (alias for When).
269    pub fn act(&mut self, text: &str) -> &mut Self {
270        self.add_step("When", text)
271    }
272
273    /// Add an Assert step (alias for Then). Uses `assert_that` because assert! is a macro.
274    pub fn assert_that(&mut self, text: &str) -> &mut Self {
275        self.add_step("Then", text)
276    }
277
278    // --- Additional aliases ---
279
280    /// Add a Setup step (alias for Given).
281    pub fn setup(&mut self, text: &str) -> &mut Self {
282        self.add_step("Given", text)
283    }
284
285    /// Add a Context step (alias for Given).
286    pub fn context(&mut self, text: &str) -> &mut Self {
287        self.add_step("Given", text)
288    }
289
290    /// Add an Execute step (alias for When).
291    pub fn execute(&mut self, text: &str) -> &mut Self {
292        self.add_step("When", text)
293    }
294
295    /// Add an Action step (alias for When).
296    pub fn action(&mut self, text: &str) -> &mut Self {
297        self.add_step("When", text)
298    }
299
300    /// Add a Verify step (alias for Then).
301    pub fn verify(&mut self, text: &str) -> &mut Self {
302        self.add_step("Then", text)
303    }
304
305    /// Attach inline documentation entries to the most recently added step.
306    pub fn with_docs(&mut self, docs: Vec<StepDoc>) -> &mut Self {
307        if let Some(idx) = self.current_step_index {
308            let step = &mut self.steps[idx];
309            for doc in docs {
310                doc.apply(step);
311            }
312        }
313        self
314    }
315
316    // --- Step timing ---
317
318    /// Start a high-resolution timer tied to the current step. Returns a token to pass to `end_timer()`.
319    pub fn start_timer(&mut self) -> usize {
320        let token = self.timer_counter;
321        self.timer_counter += 1;
322        let entry = TimerEntry {
323            start: std::time::Instant::now(),
324            step_index: self.current_step_index,
325            step_id: self.current_step_id(),
326            consumed: false,
327        };
328        self.active_timers.insert(token, entry);
329        token
330    }
331
332    /// Stop the timer and record `duration_ms` on the step that was active when `start_timer()` was called. Double-end is a no-op.
333    pub fn end_timer(&mut self, token: usize) {
334        let entry = match self.active_timers.get_mut(&token) {
335            Some(e) if !e.consumed => e,
336            _ => return,
337        };
338        entry.consumed = true;
339        let duration_ms = entry.start.elapsed().as_secs_f64() * 1000.0;
340        let step_index = entry.step_index;
341        let step_id = entry.step_id.clone();
342
343        if let Some(idx) = step_index {
344            if let Some(step) = self.steps.get_mut(idx) {
345                step.duration_ms = Some(duration_ms);
346            }
347        } else if let Some(id) = step_id {
348            if let Some(step) = self.steps.iter_mut().find(|s| s.id.as_deref() == Some(id.as_str()))
349            {
350                step.duration_ms = Some(duration_ms);
351            }
352        }
353    }
354
355    // --- Wrapped step execution ---
356
357    /// Wrap a closure as a BDD step with automatic timing.
358    /// Creates a step marked as `wrapped=true`, executes the body,
359    /// records `duration_ms`, and re-panics if the body panicked.
360    pub fn fn_step<F, T>(&mut self, keyword: &str, text: &str, body: F) -> T
361    where
362        F: FnOnce() -> T,
363    {
364        self.add_step(keyword, text);
365        let idx = self.steps.len() - 1;
366        self.steps[idx].wrapped = Some(true);
367
368        let start = std::time::Instant::now();
369        let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(body));
370        self.steps[idx].duration_ms = Some(start.elapsed().as_secs_f64() * 1000.0);
371
372        match result {
373            Ok(val) => val,
374            Err(panic) => std::panic::resume_unwind(panic),
375        }
376    }
377
378    /// Shorthand for `fn_step("Then", text, body)`.
379    pub fn expect_step<F, T>(&mut self, text: &str, body: F) -> T
380    where
381        F: FnOnce() -> T,
382    {
383        self.fn_step("Then", text, body)
384    }
385
386    // --- Doc attachment methods ---
387
388    /// Attach a doc entry either to the current step or to the story-level docs.
389    fn attach_doc(&mut self, entry: DocEntry) -> &mut Self {
390        if let Some(idx) = self.current_step_index {
391            let step = &mut self.steps[idx];
392            let docs = step.docs.get_or_insert_with(Vec::new);
393            docs.push(entry);
394        } else {
395            self.docs.push(entry);
396        }
397        self
398    }
399
400    /// Attach a text note to the current step or story.
401    pub fn note(&mut self, text: &str) -> &mut Self {
402        self.attach_doc(DocEntry::note(text))
403    }
404
405    /// Attach tag annotations to the current step or story.
406    pub fn tag(&mut self, names: &[&str]) -> &mut Self {
407        self.attach_doc(DocEntry::tag(names))
408    }
409
410    /// Attach a key-value pair to the current step or story.
411    pub fn kv(&mut self, label: &str, value: serde_json::Value) -> &mut Self {
412        self.attach_doc(DocEntry::kv(label, value))
413    }
414
415    /// Attach a JSON document (serialized as code with lang=json) to the current step or story.
416    pub fn json(&mut self, label: &str, value: &impl Serialize) -> &mut Self {
417        self.attach_doc(DocEntry::json_doc(label, value))
418    }
419
420    /// Attach a code block to the current step or story.
421    pub fn code(&mut self, label: &str, content: &str, lang: Option<&str>) -> &mut Self {
422        self.attach_doc(DocEntry::code(label, content, lang))
423    }
424
425    /// Attach a table to the current step or story.
426    pub fn table(&mut self, label: &str, columns: &[&str], rows: &[&[&str]]) -> &mut Self {
427        self.attach_doc(DocEntry::table(label, columns, rows))
428    }
429
430    /// Attach a link to the current step or story.
431    pub fn link(&mut self, label: &str, url: &str) -> &mut Self {
432        self.attach_doc(DocEntry::link(label, url))
433    }
434
435    /// Attach a markdown section to the current step or story.
436    pub fn section(&mut self, title: &str, markdown: &str) -> &mut Self {
437        self.attach_doc(DocEntry::section(title, markdown))
438    }
439
440    /// Attach a mermaid diagram to the current step or story.
441    pub fn mermaid(&mut self, code: &str, title: Option<&str>) -> &mut Self {
442        self.attach_doc(DocEntry::mermaid(code, title))
443    }
444
445    /// Attach a screenshot reference to the current step or story.
446    pub fn screenshot(&mut self, path: &str, alt: Option<&str>) -> &mut Self {
447        self.attach_doc(DocEntry::screenshot(path, alt))
448    }
449
450    /// Attach a custom doc entry to the current step or story.
451    pub fn custom(&mut self, type_name: &str, data: serde_json::Value) -> &mut Self {
452        self.attach_doc(DocEntry::custom(type_name, data))
453    }
454
455    // --- Attachment methods ---
456
457    /// Return the ID of the current (last) step, if any.
458    fn current_step_id(&self) -> Option<String> {
459        self.steps.last().and_then(|s| s.id.clone())
460    }
461
462    /// Attach a file-based attachment to the story.
463    pub fn attach(&mut self, name: &str, media_type: &str, path: &str) -> &mut Self {
464        let mut a = RawAttachment {
465            name: name.to_string(),
466            media_type: media_type.to_string(),
467            path: if path.is_empty() { None } else { Some(path.to_string()) },
468            body: None,
469            encoding: None,
470            charset: None,
471            file_name: None,
472            byte_length: None,
473            step_index: None,
474            step_id: None,
475        };
476        if let Some(ref current) = self.current_step_id() {
477            a.step_index = Some(self.steps.len().saturating_sub(1));
478            a.step_id = Some(current.clone());
479        }
480        self.attachments.push(a);
481        self
482    }
483
484    /// Attach an inline (body-based) attachment to the story.
485    pub fn attach_inline(
486        &mut self,
487        name: &str,
488        media_type: &str,
489        body: &str,
490        encoding: &str,
491    ) -> &mut Self {
492        let mut a = RawAttachment {
493            name: name.to_string(),
494            media_type: media_type.to_string(),
495            path: None,
496            body: Some(body.to_string()),
497            encoding: Some(encoding.to_string()),
498            charset: None,
499            file_name: None,
500            byte_length: None,
501            step_index: None,
502            step_id: None,
503        };
504        if let Some(ref current) = self.current_step_id() {
505            a.step_index = Some(self.steps.len().saturating_sub(1));
506            a.step_id = Some(current.clone());
507        }
508        self.attachments.push(a);
509        self
510    }
511
512    /// Attach OTel spans for trace waterfall rendering in HTML reports.
513    pub fn attach_spans(&mut self, spans: Vec<serde_json::Value>) -> &mut Self {
514        self.otel_spans = Some(spans);
515        self
516    }
517
518    /// Mark the story as passed. Must be called before the story goes out of scope.
519    pub fn pass(&mut self) {
520        self.passed = true;
521    }
522}
523
524impl Drop for Story {
525    fn drop(&mut self) {
526        let status = if self.passed { "pass" } else { "fail" };
527        let duration = self.start_time.elapsed().as_secs_f64() * 1000.0;
528
529        let step_events: Vec<RawStepEvent> = self
530            .steps
531            .iter()
532            .enumerate()
533            .filter_map(|(i, step)| {
534                step.duration_ms.map(|d| RawStepEvent {
535                    index: Some(i),
536                    title: Some(step.text.clone()),
537                    status: None,
538                    duration_ms: Some(d),
539                })
540            })
541            .collect();
542
543        let tc = RawTestCase {
544            status: status.to_string(),
545            title: Some(self.scenario.clone()),
546            story: Some(StoryMeta {
547                scenario: self.scenario.clone(),
548                steps: self.steps.clone(),
549                tags: self.tags.clone(),
550                tickets: self.tickets.clone(),
551                meta: self.meta.clone(),
552                docs: if self.docs.is_empty() { None } else { Some(self.docs.clone()) },
553                source_order: self.source_order,
554                otel_spans: self.otel_spans.clone(),
555            }),
556            duration_ms: Some(duration),
557            retry: 0,
558            retries: 0,
559            attachments: if self.attachments.is_empty() {
560                None
561            } else {
562                Some(self.attachments.clone())
563            },
564            step_events: if step_events.is_empty() { None } else { Some(step_events) },
565            ..Default::default()
566        };
567        collector::record(tc);
568    }
569}
570
571#[cfg(test)]
572mod tests {
573    use super::*;
574    use std::thread;
575    use std::time::Duration;
576
577    #[test]
578    fn auto_and_on_repeated_primary_keyword() {
579        let mut story = Story::new("auto-and across story");
580        story.given("first given");
581        story.when("a when in between");
582        story.given("second given");
583
584        assert_eq!(story.steps[0].keyword, "Given");
585        assert_eq!(story.steps[1].keyword, "When");
586        assert_eq!(story.steps[2].keyword, "And");
587    }
588
589    #[test]
590    fn aaa_aliases_produce_correct_keywords() {
591        let mut story = Story::new("AAA story");
592        story.arrange("precondition");
593        story.act("action");
594        story.assert_that("outcome");
595
596        assert_eq!(story.steps[0].keyword, "Given");
597        assert_eq!(story.steps[1].keyword, "When");
598        assert_eq!(story.steps[2].keyword, "Then");
599    }
600
601    #[test]
602    fn extra_aliases_produce_correct_keywords() {
603        let mut story = Story::new("Extra aliases");
604        story.setup("setup");
605        story.context("context");
606        story.execute("execute");
607        story.action("action");
608        story.verify("verify");
609
610        assert_eq!(story.steps[0].keyword, "Given");
611        assert_eq!(story.steps[1].keyword, "And");
612        assert_eq!(story.steps[2].keyword, "When");
613        assert_eq!(story.steps[3].keyword, "And");
614        assert_eq!(story.steps[4].keyword, "Then");
615    }
616
617    #[test]
618    fn with_docs_attaches_to_step() {
619        let mut story = Story::new("With docs");
620        story.given("a step").with_docs(vec![StepDoc::note("a note"), StepDoc::tag(&["smoke"])]);
621
622        assert_eq!(story.steps.len(), 1);
623        let docs = story.steps[0].docs.as_ref().unwrap();
624        assert_eq!(docs.len(), 2);
625        let note = serde_json::to_value(&docs[0]).unwrap();
626        assert_eq!(note["kind"], "note");
627        assert_eq!(note["text"], "a note");
628        let tag = serde_json::to_value(&docs[1]).unwrap();
629        assert_eq!(tag["kind"], "tag");
630    }
631
632    #[test]
633    fn step_timing() {
634        let mut story = Story::new("Timed step");
635        story.given("slow step");
636        let token = story.start_timer();
637        thread::sleep(Duration::from_millis(15));
638        story.end_timer(token);
639        story.pass();
640
641        assert_eq!(story.steps.len(), 1);
642        let d = story.steps[0].duration_ms.unwrap();
643        assert!(d >= 10.0, "duration_ms should be >= 10, got {d}");
644    }
645
646    #[test]
647    fn double_end_timer_is_noop() {
648        let mut story = Story::new("Double end");
649        story.given("step");
650        let token = story.start_timer();
651        story.end_timer(token);
652        story.end_timer(token); // no-op
653        story.pass();
654
655        assert_eq!(story.steps.len(), 1);
656        assert!(story.steps[0].duration_ms.is_some());
657    }
658
659    #[test]
660    fn fn_step_creates_wrapped_step() {
661        let mut story = Story::new("fn_step test");
662        let mut called = false;
663        story.fn_step("Given", "a wrapped precondition", || {
664            called = true;
665        });
666        story.pass();
667
668        assert!(called);
669        assert_eq!(story.steps.len(), 1);
670        assert_eq!(story.steps[0].keyword, "Given");
671        assert_eq!(story.steps[0].text, "a wrapped precondition");
672        assert_eq!(story.steps[0].wrapped, Some(true));
673        assert!(story.steps[0].duration_ms.is_some());
674    }
675
676    #[test]
677    fn fn_step_records_duration() {
678        let mut story = Story::new("fn_step duration");
679        story.fn_step("When", "I wait briefly", || {
680            thread::sleep(Duration::from_millis(15));
681        });
682        story.pass();
683
684        let d = story.steps[0].duration_ms.unwrap();
685        assert!(d >= 10.0, "duration_ms should be >= 10, got {d}");
686    }
687
688    #[test]
689    fn fn_step_returns_result() {
690        let mut story = Story::new("fn_step return");
691        let result = story.fn_step("When", "I compute", || 42);
692        story.pass();
693
694        assert_eq!(result, 42);
695    }
696
697    #[test]
698    #[should_panic(expected = "boom")]
699    fn fn_step_propagates_panics() {
700        let mut story = Story::new("fn_step panic");
701        story.fn_step("Then", "it panics", || {
702            panic!("boom");
703        });
704        story.pass();
705    }
706
707    #[test]
708    fn fn_step_auto_and_conversion() {
709        let mut story = Story::new("fn_step auto-and");
710        story.given("a text-only step");
711        story.fn_step("Given", "a wrapped step", || {});
712        story.pass();
713
714        assert_eq!(story.steps[0].keyword, "Given");
715        assert_eq!(story.steps[1].keyword, "And");
716        assert_eq!(story.steps[1].wrapped, Some(true));
717    }
718
719    #[test]
720    fn fn_step_integration_with_markers() {
721        let mut story = Story::new("fn_step + markers");
722        story.given("a text-only precondition");
723        story.fn_step("When", "I perform action", || {});
724        story.then("a text-only assertion");
725        story.fn_step("Then", "the wrapped assertion", || {});
726        story.pass();
727
728        assert_eq!(story.steps.len(), 4);
729        assert_eq!(story.steps[0].wrapped, None);
730        assert_eq!(story.steps[1].wrapped, Some(true));
731        assert_eq!(story.steps[2].wrapped, None);
732        assert_eq!(story.steps[3].wrapped, Some(true));
733    }
734
735    #[test]
736    fn expect_step_creates_wrapped_then_step() {
737        let mut story = Story::new("expect_step test");
738        let mut called = false;
739        story.expect_step("the result is correct", || {
740            called = true;
741        });
742        story.pass();
743
744        assert!(called);
745        assert_eq!(story.steps[0].keyword, "Then");
746        assert_eq!(story.steps[0].text, "the result is correct");
747        assert_eq!(story.steps[0].wrapped, Some(true));
748        assert!(story.steps[0].duration_ms.is_some());
749    }
750
751    #[test]
752    fn expect_step_returns_result() {
753        let mut story = Story::new("expect_step return");
754        let result = story.expect_step("check value", || true);
755        story.pass();
756
757        assert!(result);
758    }
759}