Skip to main content

victauri_core/
codegen.rs

1//! Test code generation from recorded sessions.
2//!
3//! Converts a [`RecordedSession`] into a self-contained Rust test file that
4//! replays DOM interactions through the `VictauriClient` API and optionally
5//! asserts on IPC outcomes and state transitions.
6
7use chrono::Utc;
8
9use crate::event::{AppEvent, InteractionKind, IpcResult};
10use crate::recording::RecordedSession;
11
12/// Options for controlling test code generation.
13#[derive(Debug, Clone, Eq, PartialEq)]
14pub struct CodegenOptions {
15    /// Name of the generated test function.
16    pub test_name: String,
17    /// Whether to include IPC assertions after interactions.
18    pub include_ipc_assertions: bool,
19    /// Whether to include state verification assertions.
20    pub include_state_checks: bool,
21    /// Whether to add timing comments showing relative timestamps.
22    pub include_timing_comments: bool,
23}
24
25impl Default for CodegenOptions {
26    fn default() -> Self {
27        Self {
28            test_name: "recorded_flow".to_string(),
29            include_ipc_assertions: true,
30            include_state_checks: true,
31            include_timing_comments: true,
32        }
33    }
34}
35
36/// Generates a Rust test file from a recorded session using default options.
37///
38/// This is a convenience wrapper around [`generate_test`] with
39/// [`CodegenOptions::default()`].
40#[must_use]
41pub fn generate_test_default(session: &RecordedSession) -> String {
42    generate_test(session, &CodegenOptions::default())
43}
44
45/// Generates a Rust test file from a recorded session.
46///
47/// Converts DOM interactions into `VictauriClient` API calls and IPC events
48/// into verification assertions. The generated test is self-contained and
49/// can be run with `cargo test`.
50#[must_use]
51pub fn generate_test(session: &RecordedSession, options: &CodegenOptions) -> String {
52    let mut out = String::with_capacity(2048);
53
54    // File header
55    let date = Utc::now().format("%Y-%m-%d");
56    out.push_str(&format!("// Generated by victauri record -- {date}\n"));
57    out.push_str(&format!("// Session: {}\n", session.id));
58    out.push_str("\nuse victauri_test::VictauriClient;\n\n");
59
60    // Test function
61    out.push_str("#[tokio::test]\n");
62    out.push_str(&format!("async fn {}() {{\n", options.test_name));
63    out.push_str(
64        "    let mut client = VictauriClient::discover().await.expect(\"connect to Tauri app\");\n",
65    );
66
67    let session_start = session.started_at;
68
69    for (i, recorded) in session.events.iter().enumerate() {
70        // Timing comment: elapsed since session start, only when gap > 500ms
71        if options.include_timing_comments {
72            let elapsed_ms = recorded
73                .timestamp
74                .signed_duration_since(session_start)
75                .num_milliseconds();
76
77            let show_timing = if i == 0 {
78                elapsed_ms > 500
79            } else {
80                let prev_ts = session.events[i - 1].timestamp;
81                let gap_ms = recorded
82                    .timestamp
83                    .signed_duration_since(prev_ts)
84                    .num_milliseconds();
85                gap_ms > 500
86            };
87
88            if show_timing {
89                out.push_str(&format!("\n    // +{elapsed_ms}ms\n"));
90            }
91        }
92
93        match &recorded.event {
94            AppEvent::DomInteraction {
95                action,
96                selector,
97                value,
98                ..
99            } => {
100                emit_interaction(&mut out, action, selector, value.as_deref());
101            }
102
103            AppEvent::Ipc(call) if options.include_ipc_assertions => {
104                // Skip internal victauri plugin commands
105                if call.command.starts_with("plugin:victauri|") {
106                    continue;
107                }
108                if matches!(call.result, IpcResult::Ok(_)) {
109                    let cmd = &call.command;
110                    out.push_str(&format!("    // IPC: {cmd} completed successfully\n"));
111                }
112            }
113
114            AppEvent::StateChange { key, caused_by, .. }
115                if options.include_state_checks && caused_by.is_some() =>
116            {
117                out.push_str(&format!("    // State changed: {key}\n"));
118            }
119
120            // DomMutation, WindowEvent, and disabled variants are skipped
121            _ => {}
122        }
123    }
124
125    out.push_str("}\n");
126    out
127}
128
129/// Escapes a string for embedding in a Rust string literal.
130fn escape_rust_str(s: &str) -> String {
131    let mut escaped = String::with_capacity(s.len());
132    for ch in s.chars() {
133        match ch {
134            '\\' => escaped.push_str("\\\\"),
135            '"' => escaped.push_str("\\\""),
136            '\n' => escaped.push_str("\\n"),
137            '\r' => escaped.push_str("\\r"),
138            '\t' => escaped.push_str("\\t"),
139            other => escaped.push(other),
140        }
141    }
142    escaped
143}
144
145/// Resolved selector form for emitting idiomatic `VictauriClient` calls.
146///
147/// The JS observer produces raw CSS selectors; this classification maps them
148/// to the high-level convenience methods on `VictauriClient` (`click_by_id`,
149/// `click_by_text`, etc.) so generated tests read naturally.
150enum ResolvedSelector {
151    /// Selector started with `#` — strip the hash and use `*_by_id`.
152    ById(String),
153    /// Selector contained `:has-text("...")` — extract the text and use `*_by_text`.
154    ByText(String),
155    /// Everything else — pass the raw selector through.
156    Raw(String),
157}
158
159/// Classifies a raw CSS selector into the most idiomatic `VictauriClient` form.
160fn resolve_selector(selector: &str) -> ResolvedSelector {
161    // Pattern 2: contains `:has-text("...")` — extract the quoted text.
162    if let Some(start) = selector.find(":has-text(\"") {
163        let text_start = start + ":has-text(\"".len();
164        if let Some(end) = selector[text_start..].find("\")") {
165            let text = &selector[text_start..text_start + end];
166            return ResolvedSelector::ByText(text.to_string());
167        }
168    }
169
170    // Pattern 1: starts with `#` (simple ID selector, no combinators).
171    if selector.starts_with('#') && !selector[1..].contains(' ') {
172        let id = &selector[1..];
173        return ResolvedSelector::ById(id.to_string());
174    }
175
176    ResolvedSelector::Raw(selector.to_string())
177}
178
179/// Emits a single DOM interaction as a `VictauriClient` method call.
180///
181/// Selector-aware: emits `*_by_id` for `#id` selectors, `*_by_text` for
182/// `:has-text("...")` selectors, and `*_by_selector` for raw CSS selectors
183/// so the client resolves them to ref handles before interacting.
184fn emit_interaction(
185    out: &mut String,
186    action: &InteractionKind,
187    selector: &str,
188    value: Option<&str>,
189) {
190    let resolved = resolve_selector(selector);
191
192    match action {
193        InteractionKind::Click => {
194            emit_resolved_call(out, "click", &resolved, None);
195        }
196        InteractionKind::DoubleClick => {
197            emit_resolved_call(out, "double_click", &resolved, None);
198        }
199        InteractionKind::Fill => {
200            let val = value.map_or_else(String::new, escape_rust_str);
201            emit_resolved_call(out, "fill", &resolved, Some(&val));
202        }
203        InteractionKind::KeyPress => {
204            let val = value.map_or_else(String::new, escape_rust_str);
205            out.push_str(&format!(
206                "    client.press_key(\"{val}\").await.unwrap();\n"
207            ));
208        }
209        InteractionKind::Select => {
210            let val = value.map_or_else(String::new, escape_rust_str);
211            emit_resolved_select(out, &resolved, &val);
212        }
213        InteractionKind::Navigate => {
214            let val = value.map_or_else(String::new, escape_rust_str);
215            out.push_str(&format!("    client.navigate(\"{val}\").await.unwrap();\n"));
216        }
217        InteractionKind::Scroll => {
218            emit_resolved_scroll(out, &resolved);
219        }
220    }
221}
222
223/// Emits a resolved call for click-like and fill-like methods.
224///
225/// For `ById` selectors, emits `*_by_id`. For `ByText`, emits `*_by_text`.
226/// For `Raw` CSS selectors, emits `*_by_selector` which resolves the selector
227/// to a ref handle before interacting.
228fn emit_resolved_call(
229    out: &mut String,
230    base_method: &str,
231    resolved: &ResolvedSelector,
232    extra_arg: Option<&str>,
233) {
234    let suffix = extra_arg.map_or_else(String::new, |v| format!(", \"{v}\""));
235    match resolved {
236        ResolvedSelector::ById(id) => {
237            let escaped = escape_rust_str(id);
238            out.push_str(&format!(
239                "    client.{base_method}_by_id(\"{escaped}\"{suffix}).await.unwrap();\n"
240            ));
241        }
242        ResolvedSelector::ByText(text) => {
243            let escaped = escape_rust_str(text);
244            out.push_str(&format!(
245                "    client.{base_method}_by_text(\"{escaped}\"{suffix}).await.unwrap();\n"
246            ));
247        }
248        ResolvedSelector::Raw(sel) => {
249            let escaped = escape_rust_str(sel);
250            out.push_str(&format!(
251                "    client.{base_method}_by_selector(\"{escaped}\"{suffix}).await.unwrap();\n"
252            ));
253        }
254    }
255}
256
257/// Emits a resolved `select_option` call.
258///
259/// For `Raw` selectors, emits `select_option_by_selector` which resolves the
260/// CSS selector to a ref handle before selecting.
261fn emit_resolved_select(out: &mut String, resolved: &ResolvedSelector, val: &str) {
262    match resolved {
263        ResolvedSelector::ById(id) => {
264            let escaped = escape_rust_str(id);
265            out.push_str(&format!(
266                "    client.select_option_by_id(\"{escaped}\", &[\"{val}\"]).await.unwrap();\n"
267            ));
268        }
269        ResolvedSelector::ByText(text) => {
270            let escaped = escape_rust_str(text);
271            out.push_str(&format!(
272                "    client.select_option_by_text(\"{escaped}\", &[\"{val}\"]).await.unwrap();\n"
273            ));
274        }
275        ResolvedSelector::Raw(sel) => {
276            let escaped = escape_rust_str(sel);
277            out.push_str(&format!(
278                "    client.select_option_by_selector(\"{escaped}\", &[\"{val}\"]).await.unwrap();\n"
279            ));
280        }
281    }
282}
283
284/// Emits a resolved `scroll_to` call.
285///
286/// For `Raw` selectors, emits `scroll_to_by_selector`. For `ById`, emits
287/// `scroll_to_by_id`. For `ByText`, falls back to `scroll_to_by_selector`
288/// with the original selector text (scroll has no text variant).
289fn emit_resolved_scroll(out: &mut String, resolved: &ResolvedSelector) {
290    match resolved {
291        ResolvedSelector::ById(id) => {
292            let escaped = escape_rust_str(id);
293            out.push_str(&format!(
294                "    client.scroll_to_by_id(\"{escaped}\").await.unwrap();\n"
295            ));
296        }
297        ResolvedSelector::ByText(_) | ResolvedSelector::Raw(_) => {
298            let sel = match resolved {
299                ResolvedSelector::ByText(t) => escape_rust_str(t),
300                ResolvedSelector::Raw(s) => escape_rust_str(s),
301                _ => unreachable!(),
302            };
303            out.push_str(&format!(
304                "    client.scroll_to_by_selector(\"{sel}\").await.unwrap();\n"
305            ));
306        }
307    }
308}
309
310#[cfg(test)]
311mod tests {
312    use chrono::{Duration, Utc};
313
314    use super::*;
315    use crate::event::{AppEvent, InteractionKind, IpcCall, IpcResult};
316    use crate::recording::{RecordedEvent, RecordedSession};
317
318    fn make_session(events: Vec<RecordedEvent>) -> RecordedSession {
319        RecordedSession {
320            id: "test-session-001".to_string(),
321            started_at: Utc::now(),
322            events,
323            checkpoints: vec![],
324        }
325    }
326
327    fn interaction_event(
328        index: usize,
329        action: InteractionKind,
330        selector: &str,
331        value: Option<&str>,
332        offset_ms: i64,
333    ) -> RecordedEvent {
334        RecordedEvent {
335            index,
336            timestamp: Utc::now() + Duration::milliseconds(offset_ms),
337            event: AppEvent::DomInteraction {
338                action,
339                selector: selector.to_string(),
340                value: value.map(String::from),
341                timestamp: Utc::now() + Duration::milliseconds(offset_ms),
342                webview_label: "main".to_string(),
343            },
344        }
345    }
346
347    #[test]
348    fn empty_session_produces_valid_skeleton() {
349        let session = make_session(vec![]);
350        let code = generate_test_default(&session);
351
352        assert!(code.contains("use victauri_test::VictauriClient;"));
353        assert!(code.contains("#[tokio::test]"));
354        assert!(code.contains("async fn recorded_flow()"));
355        assert!(code.contains("VictauriClient::discover()"));
356        assert!(code.contains("Session: test-session-001"));
357    }
358
359    #[test]
360    fn click_by_id_generated_for_hash_selector() {
361        let session = make_session(vec![interaction_event(
362            0,
363            InteractionKind::Click,
364            "#submit-btn",
365            None,
366            0,
367        )]);
368        let code = generate_test_default(&session);
369
370        assert!(
371            code.contains("client.click_by_id(\"submit-btn\").await.unwrap();"),
372            "expected click_by_id for # selector, got:\n{code}"
373        );
374    }
375
376    #[test]
377    fn fill_generates_correct_call() {
378        let session = make_session(vec![interaction_event(
379            0,
380            InteractionKind::Fill,
381            "input[name=\"email\"]",
382            Some("user@example.com"),
383            0,
384        )]);
385        let code = generate_test_default(&session);
386
387        assert!(code.contains(
388            "client.fill_by_selector(\"input[name=\\\"email\\\"]\", \"user@example.com\").await.unwrap();"
389        ));
390    }
391
392    #[test]
393    fn ipc_comment_included_when_enabled() {
394        let session = make_session(vec![RecordedEvent {
395            index: 0,
396            timestamp: Utc::now(),
397            event: AppEvent::Ipc(IpcCall {
398                id: "c1".to_string(),
399                command: "save_settings".to_string(),
400                timestamp: Utc::now(),
401                duration_ms: Some(10),
402                result: IpcResult::Ok(serde_json::json!(true)),
403                arg_size_bytes: 0,
404                webview_label: "main".to_string(),
405            }),
406        }]);
407        let code = generate_test_default(&session);
408
409        assert!(code.contains("// IPC: save_settings completed successfully"));
410    }
411
412    #[test]
413    fn internal_victauri_ipc_skipped() {
414        let session = make_session(vec![RecordedEvent {
415            index: 0,
416            timestamp: Utc::now(),
417            event: AppEvent::Ipc(IpcCall {
418                id: "c2".to_string(),
419                command: "plugin:victauri|get_snapshot".to_string(),
420                timestamp: Utc::now(),
421                duration_ms: Some(2),
422                result: IpcResult::Ok(serde_json::json!({})),
423                arg_size_bytes: 0,
424                webview_label: "main".to_string(),
425            }),
426        }]);
427        let code = generate_test_default(&session);
428
429        assert!(!code.contains("plugin:victauri"));
430    }
431
432    #[test]
433    fn ipc_omitted_when_disabled() {
434        let session = make_session(vec![RecordedEvent {
435            index: 0,
436            timestamp: Utc::now(),
437            event: AppEvent::Ipc(IpcCall {
438                id: "c1".to_string(),
439                command: "save_settings".to_string(),
440                timestamp: Utc::now(),
441                duration_ms: Some(10),
442                result: IpcResult::Ok(serde_json::json!(true)),
443                arg_size_bytes: 0,
444                webview_label: "main".to_string(),
445            }),
446        }]);
447        let opts = CodegenOptions {
448            include_ipc_assertions: false,
449            ..CodegenOptions::default()
450        };
451        let code = generate_test(&session, &opts);
452
453        assert!(!code.contains("IPC:"));
454    }
455
456    #[test]
457    fn state_change_comment_included() {
458        let session = make_session(vec![RecordedEvent {
459            index: 0,
460            timestamp: Utc::now(),
461            event: AppEvent::StateChange {
462                key: "user.theme".to_string(),
463                timestamp: Utc::now(),
464                caused_by: Some("toggle_theme".to_string()),
465            },
466        }]);
467        let code = generate_test_default(&session);
468
469        assert!(code.contains("// State changed: user.theme"));
470    }
471
472    #[test]
473    fn custom_test_name() {
474        let session = make_session(vec![]);
475        let opts = CodegenOptions {
476            test_name: "my_custom_test".to_string(),
477            ..CodegenOptions::default()
478        };
479        let code = generate_test(&session, &opts);
480
481        assert!(code.contains("async fn my_custom_test()"));
482    }
483
484    #[test]
485    fn special_chars_escaped() {
486        let session = make_session(vec![interaction_event(
487            0,
488            InteractionKind::Fill,
489            "input",
490            Some("line1\nline2\ttab\"quote\\back"),
491            0,
492        )]);
493        let code = generate_test_default(&session);
494
495        assert!(code.contains("\\n"));
496        assert!(code.contains("\\t"));
497        assert!(code.contains("\\\""));
498        assert!(code.contains("\\\\"));
499    }
500
501    #[test]
502    fn all_interaction_kinds_generate_code() {
503        let session = make_session(vec![
504            interaction_event(0, InteractionKind::Click, "[data-testid=\"a\"]", None, 0),
505            interaction_event(
506                1,
507                InteractionKind::DoubleClick,
508                "[data-testid=\"b\"]",
509                None,
510                10,
511            ),
512            interaction_event(
513                2,
514                InteractionKind::Fill,
515                "[data-testid=\"c\"]",
516                Some("val"),
517                20,
518            ),
519            interaction_event(3, InteractionKind::KeyPress, "#d", Some("Enter"), 30),
520            interaction_event(
521                4,
522                InteractionKind::Select,
523                "[data-testid=\"e\"]",
524                Some("opt1"),
525                40,
526            ),
527            interaction_event(5, InteractionKind::Navigate, "#f", Some("/page"), 50),
528            interaction_event(6, InteractionKind::Scroll, "#g", None, 60),
529        ]);
530        let code = generate_test(
531            &session,
532            &CodegenOptions {
533                include_timing_comments: false,
534                ..CodegenOptions::default()
535            },
536        );
537
538        assert!(code.contains("client.click_by_selector(\"[data-testid=\\\"a\\\"]\")"));
539        assert!(code.contains("client.double_click_by_selector(\"[data-testid=\\\"b\\\"]\")"));
540        assert!(code.contains("client.fill_by_selector(\"[data-testid=\\\"c\\\"]\", \"val\")"));
541        assert!(code.contains("client.press_key(\"Enter\")"));
542        assert!(code.contains(
543            "client.select_option_by_selector(\"[data-testid=\\\"e\\\"]\", &[\"opt1\"])"
544        ));
545        assert!(code.contains("client.navigate(\"/page\")"));
546        assert!(code.contains("client.scroll_to_by_id(\"g\")"));
547    }
548
549    #[test]
550    fn dom_mutation_and_window_event_skipped() {
551        let ts = Utc::now();
552        let session = make_session(vec![
553            RecordedEvent {
554                index: 0,
555                timestamp: ts,
556                event: AppEvent::DomMutation {
557                    webview_label: "main".to_string(),
558                    timestamp: ts,
559                    mutation_count: 5,
560                },
561            },
562            RecordedEvent {
563                index: 1,
564                timestamp: ts,
565                event: AppEvent::WindowEvent {
566                    label: "main".to_string(),
567                    event: "focus".to_string(),
568                    timestamp: ts,
569                },
570            },
571        ]);
572        let code = generate_test_default(&session);
573
574        // Should only have the skeleton, no interaction or assertion lines
575        assert!(!code.contains("client."));
576        assert!(!code.contains("// IPC:"));
577        assert!(!code.contains("// State"));
578    }
579
580    #[test]
581    fn default_options_are_correct() {
582        let opts = CodegenOptions::default();
583        assert_eq!(opts.test_name, "recorded_flow");
584        assert!(opts.include_ipc_assertions);
585        assert!(opts.include_state_checks);
586        assert!(opts.include_timing_comments);
587    }
588
589    // --- Timing comment tests ---
590
591    fn make_session_at(base: chrono::DateTime<Utc>, events: Vec<RecordedEvent>) -> RecordedSession {
592        RecordedSession {
593            id: "timing-session".to_string(),
594            started_at: base,
595            events,
596            checkpoints: vec![],
597        }
598    }
599
600    fn interaction_event_at(
601        base: chrono::DateTime<Utc>,
602        index: usize,
603        action: InteractionKind,
604        selector: &str,
605        value: Option<&str>,
606        offset_ms: i64,
607    ) -> RecordedEvent {
608        let ts = base + Duration::milliseconds(offset_ms);
609        RecordedEvent {
610            index,
611            timestamp: ts,
612            event: AppEvent::DomInteraction {
613                action,
614                selector: selector.to_string(),
615                value: value.map(String::from),
616                timestamp: ts,
617                webview_label: "main".to_string(),
618            },
619        }
620    }
621
622    #[test]
623    fn timing_comment_emitted_for_large_gap() {
624        let base = Utc::now();
625        let session = make_session_at(
626            base,
627            vec![
628                interaction_event_at(base, 0, InteractionKind::Click, ".btn-a", None, 0),
629                interaction_event_at(base, 1, InteractionKind::Click, ".btn-b", None, 1000),
630            ],
631        );
632        let opts = CodegenOptions {
633            include_timing_comments: true,
634            ..CodegenOptions::default()
635        };
636        let code = generate_test(&session, &opts);
637
638        assert!(
639            code.contains("// +1000ms"),
640            "expected timing comment for 1000ms gap, got:\n{code}"
641        );
642    }
643
644    #[test]
645    fn timing_comment_omitted_for_small_gap() {
646        let base = Utc::now();
647        let session = make_session_at(
648            base,
649            vec![
650                interaction_event_at(base, 0, InteractionKind::Click, ".btn-a", None, 0),
651                interaction_event_at(base, 1, InteractionKind::Click, ".btn-b", None, 200),
652            ],
653        );
654        let opts = CodegenOptions {
655            include_timing_comments: true,
656            ..CodegenOptions::default()
657        };
658        let code = generate_test(&session, &opts);
659
660        assert!(
661            !code.contains("// +"),
662            "expected no timing comment for 200ms gap, got:\n{code}"
663        );
664    }
665
666    // --- Selector resolution tests ---
667
668    #[test]
669    fn id_selector_emits_click_by_id() {
670        let session = make_session(vec![interaction_event(
671            0,
672            InteractionKind::Click,
673            "#my-id",
674            None,
675            0,
676        )]);
677        let code = generate_test_default(&session);
678
679        assert!(
680            code.contains("client.click_by_id(\"my-id\").await.unwrap();"),
681            "expected click_by_id, got:\n{code}"
682        );
683    }
684
685    #[test]
686    fn has_text_selector_emits_click_by_text() {
687        let session = make_session(vec![interaction_event(
688            0,
689            InteractionKind::Click,
690            "button:has-text(\"Submit\")",
691            None,
692            0,
693        )]);
694        let code = generate_test_default(&session);
695
696        assert!(
697            code.contains("client.click_by_text(\"Submit\").await.unwrap();"),
698            "expected click_by_text, got:\n{code}"
699        );
700    }
701
702    #[test]
703    fn role_has_text_selector_emits_click_by_text() {
704        let session = make_session(vec![interaction_event(
705            0,
706            InteractionKind::Click,
707            "[role=\"button\"]:has-text(\"Save\")",
708            None,
709            0,
710        )]);
711        let code = generate_test_default(&session);
712
713        assert!(
714            code.contains("client.click_by_text(\"Save\").await.unwrap();"),
715            "expected click_by_text for role selector, got:\n{code}"
716        );
717    }
718
719    #[test]
720    fn data_testid_selector_emits_click_by_selector() {
721        let session = make_session(vec![interaction_event(
722            0,
723            InteractionKind::Click,
724            "[data-testid=\"foo\"]",
725            None,
726            0,
727        )]);
728        let code = generate_test_default(&session);
729
730        assert!(
731            code.contains(
732                "client.click_by_selector(\"[data-testid=\\\"foo\\\"]\").await.unwrap();"
733            ),
734            "expected click_by_selector for data-testid selector, got:\n{code}"
735        );
736    }
737
738    #[test]
739    fn fill_with_id_selector_emits_fill_by_id() {
740        let session = make_session(vec![interaction_event(
741            0,
742            InteractionKind::Fill,
743            "#email",
744            Some("user@example.com"),
745            0,
746        )]);
747        let code = generate_test_default(&session);
748
749        assert!(
750            code.contains("client.fill_by_id(\"email\", \"user@example.com\").await.unwrap();"),
751            "expected fill_by_id, got:\n{code}"
752        );
753    }
754
755    #[test]
756    fn double_click_with_has_text_emits_by_text() {
757        let session = make_session(vec![interaction_event(
758            0,
759            InteractionKind::DoubleClick,
760            "span:has-text(\"Edit\")",
761            None,
762            0,
763        )]);
764        let code = generate_test_default(&session);
765
766        assert!(
767            code.contains("client.double_click_by_text(\"Edit\").await.unwrap();"),
768            "expected double_click_by_text, got:\n{code}"
769        );
770    }
771
772    #[test]
773    fn select_with_id_emits_select_option_by_id() {
774        let session = make_session(vec![interaction_event(
775            0,
776            InteractionKind::Select,
777            "#country",
778            Some("AU"),
779            0,
780        )]);
781        let code = generate_test_default(&session);
782
783        assert!(
784            code.contains("client.select_option_by_id(\"country\", &[\"AU\"]).await.unwrap();"),
785            "expected select_option_by_id, got:\n{code}"
786        );
787    }
788
789    /// Round-trip test: builds a realistic multi-event session and verifies
790    /// the generated Rust source contains valid structural elements for every
791    /// event kind — click, fill, key-press, IPC, and state-change.
792    #[test]
793    fn round_trip_realistic_session() {
794        let base = Utc::now();
795
796        let events = vec![
797            // 0: Click on #submit-btn  (should resolve to click_by_id)
798            interaction_event_at(base, 0, InteractionKind::Click, "#submit-btn", None, 0),
799            // 1: Fill on input[name=email]  (raw selector — should use client.fill_by_selector)
800            interaction_event_at(
801                base,
802                1,
803                InteractionKind::Fill,
804                "input[name=email]",
805                Some("test@example.com"),
806                100,
807            ),
808            // 2: KeyPress "Enter"
809            interaction_event_at(
810                base,
811                2,
812                InteractionKind::KeyPress,
813                "body",
814                Some("Enter"),
815                200,
816            ),
817            // 3: IPC call — save_draft completed successfully
818            RecordedEvent {
819                index: 3,
820                timestamp: base + Duration::milliseconds(300),
821                event: AppEvent::Ipc(IpcCall {
822                    id: "ipc-1".to_string(),
823                    command: "save_draft".to_string(),
824                    timestamp: base + Duration::milliseconds(300),
825                    duration_ms: Some(15),
826                    result: IpcResult::Ok(serde_json::json!({"saved": true})),
827                    arg_size_bytes: 42,
828                    webview_label: "main".to_string(),
829                }),
830            },
831            // 4: StateChange caused by save_draft
832            RecordedEvent {
833                index: 4,
834                timestamp: base + Duration::milliseconds(350),
835                event: AppEvent::StateChange {
836                    key: "draft.status".to_string(),
837                    timestamp: base + Duration::milliseconds(350),
838                    caused_by: Some("save_draft".to_string()),
839                },
840            },
841        ];
842
843        let session = make_session_at(base, events);
844        let code = generate_test(&session, &CodegenOptions::default());
845
846        // --- Structural validity: the generated code is a well-formed async test ---
847        assert!(
848            code.contains("#[tokio::test]"),
849            "missing #[tokio::test] attribute:\n{code}"
850        );
851        assert!(
852            code.contains("async fn recorded_flow()"),
853            "missing async fn declaration:\n{code}"
854        );
855        assert!(
856            code.contains("VictauriClient::discover()"),
857            "missing VictauriClient::discover() call:\n{code}"
858        );
859        assert!(
860            code.ends_with("}\n"),
861            "missing closing brace at end of generated code:\n{code}"
862        );
863
864        // --- Event-specific assertions ---
865
866        // Click on #submit-btn should resolve to click_by_id (not raw click with "#submit-btn")
867        assert!(
868            code.contains("client.click_by_id(\"submit-btn\").await.unwrap();"),
869            "expected click_by_id for #submit-btn:\n{code}"
870        );
871        assert!(
872            !code.contains("client.click(\"#submit-btn\")"),
873            "#submit-btn should NOT appear as raw client.click:\n{code}"
874        );
875
876        // Fill on input[name=email] — raw selector, should use fill_by_selector
877        assert!(
878            code.contains(
879                "client.fill_by_selector(\"input[name=email]\", \"test@example.com\").await.unwrap();"
880            ),
881            "expected fill_by_selector for input[name=email]:\n{code}"
882        );
883        assert!(
884            !code.contains("client.fill_by_id(\"input[name=email]\""),
885            "input[name=email] should NOT resolve to fill_by_id:\n{code}"
886        );
887
888        // KeyPress "Enter"
889        assert!(
890            code.contains("client.press_key(\"Enter\").await.unwrap();"),
891            "expected press_key(\"Enter\"):\n{code}"
892        );
893
894        // IPC comment for save_draft
895        assert!(
896            code.contains("// IPC: save_draft completed successfully"),
897            "expected IPC comment for save_draft:\n{code}"
898        );
899
900        // State change comment for draft.status
901        assert!(
902            code.contains("// State changed: draft.status"),
903            "expected state change comment for draft.status:\n{code}"
904        );
905
906        // --- Brace matching: count opening and closing braces ---
907        let open_braces = code.matches('{').count();
908        let close_braces = code.matches('}').count();
909        assert_eq!(
910            open_braces, close_braces,
911            "unbalanced braces: {open_braces} open vs {close_braces} close in:\n{code}"
912        );
913    }
914}