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