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