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#[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
82pub 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) source_file: Option<String>,
116}
117
118impl Story {
119 #[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 #[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 #[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 #[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 #[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 #[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 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 self.meta = Some(serde_json::json!({
222 "otel": { "traceId": &trace_id, "spanId": &span_id }
223 }));
224
225 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 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 fn add_step(&mut self, keyword: &str, text: &str) -> &mut Self {
258 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 pub fn given(&mut self, text: &str) -> &mut Self {
288 self.add_step("Given", text)
289 }
290
291 pub fn when(&mut self, text: &str) -> &mut Self {
293 self.add_step("When", text)
294 }
295
296 pub fn then(&mut self, text: &str) -> &mut Self {
298 self.add_step("Then", text)
299 }
300
301 pub fn and(&mut self, text: &str) -> &mut Self {
303 self.add_step("And", text)
304 }
305
306 pub fn but(&mut self, text: &str) -> &mut Self {
308 self.add_step("But", text)
309 }
310
311 pub fn arrange(&mut self, text: &str) -> &mut Self {
315 self.add_step("Given", text)
316 }
317
318 pub fn act(&mut self, text: &str) -> &mut Self {
320 self.add_step("When", text)
321 }
322
323 pub fn assert_that(&mut self, text: &str) -> &mut Self {
325 self.add_step("Then", text)
326 }
327
328 pub fn setup(&mut self, text: &str) -> &mut Self {
332 self.add_step("Given", text)
333 }
334
335 pub fn context(&mut self, text: &str) -> &mut Self {
337 self.add_step("Given", text)
338 }
339
340 pub fn execute(&mut self, text: &str) -> &mut Self {
342 self.add_step("When", text)
343 }
344
345 pub fn action(&mut self, text: &str) -> &mut Self {
347 self.add_step("When", text)
348 }
349
350 pub fn verify(&mut self, text: &str) -> &mut Self {
352 self.add_step("Then", text)
353 }
354
355 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 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 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 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 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 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 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 pub fn note(&mut self, text: &str) -> &mut Self {
460 self.attach_doc(DocEntry::note(text))
461 }
462
463 pub fn tag(&mut self, names: &[&str]) -> &mut Self {
465 self.attach_doc(DocEntry::tag(names))
466 }
467
468 pub fn kv(&mut self, label: &str, value: serde_json::Value) -> &mut Self {
470 self.attach_doc(DocEntry::kv(label, value))
471 }
472
473 pub fn json(&mut self, label: &str, value: &impl Serialize) -> &mut Self {
475 self.attach_doc(DocEntry::json_doc(label, value))
476 }
477
478 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 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 pub fn link(&mut self, label: &str, url: &str) -> &mut Self {
490 self.attach_doc(DocEntry::link(label, url))
491 }
492
493 pub fn section(&mut self, title: &str, markdown: &str) -> &mut Self {
495 self.attach_doc(DocEntry::section(title, markdown))
496 }
497
498 pub fn mermaid(&mut self, code: &str, title: Option<&str>) -> &mut Self {
500 self.attach_doc(DocEntry::mermaid(code, title))
501 }
502
503 pub fn screenshot(&mut self, path: &str, alt: Option<&str>) -> &mut Self {
505 self.attach_doc(DocEntry::screenshot(path, alt))
506 }
507
508 pub fn html(&mut self, opts: crate::doc_entry::HtmlOptions) -> &mut Self {
512 self.attach_doc(DocEntry::html(opts))
513 }
514
515 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 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 fn current_step_id(&self) -> Option<String> {
533 self.steps.last().and_then(|s| s.id.clone())
534 }
535
536 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 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 pub fn attach_spans(&mut self, spans: Vec<serde_json::Value>) -> &mut Self {
588 self.otel_spans = Some(spans);
589 self
590 }
591
592 pub fn pass(&mut self) {
598 self.passed = true;
599 self.failed = false;
600 }
601
602 pub fn fail(&mut self) {
607 self.failed = true;
608 }
609
610 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 #[track_caller]
653 pub fn planned(scenario: &str) {
654 let mut story = Story::new(scenario);
655 story.planned = true;
656 }
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 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 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 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 #[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 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 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); 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}