1use chrono::Utc;
8
9use crate::event::{AppEvent, InteractionKind, IpcResult};
10use crate::recording::RecordedSession;
11
12#[derive(Debug, Clone, Eq, PartialEq)]
14pub struct CodegenOptions {
15 pub test_name: String,
17 pub include_ipc_assertions: bool,
19 pub include_state_checks: bool,
21 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#[must_use]
41pub fn generate_test_default(session: &RecordedSession) -> String {
42 generate_test(session, &CodegenOptions::default())
43}
44
45#[must_use]
51pub fn generate_test(session: &RecordedSession, options: &CodegenOptions) -> String {
52 let mut out = String::with_capacity(2048);
53
54 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 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 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 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, .. } if options.include_state_checks => {
115 if caused_by.is_some() {
116 out.push_str(&format!(" // State changed: {key}\n"));
117 }
118 }
119
120 _ => {}
122 }
123 }
124
125 out.push_str("}\n");
126 out
127}
128
129fn 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
145enum ResolvedSelector {
151 ById(String),
153 ByText(String),
155 Raw(String),
157}
158
159fn resolve_selector(selector: &str) -> ResolvedSelector {
161 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 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
179fn 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 let sel = escape_rust_str(selector);
219 out.push_str(&format!(
220 " client.scroll_to(\"{sel}\", None, None).await.unwrap();\n"
221 ));
222 }
223 }
224}
225
226fn emit_resolved_call(
228 out: &mut String,
229 base_method: &str,
230 resolved: &ResolvedSelector,
231 extra_arg: Option<&str>,
232) {
233 let suffix = extra_arg.map_or_else(String::new, |v| format!(", \"{v}\""));
234 match resolved {
235 ResolvedSelector::ById(id) => {
236 let escaped = escape_rust_str(id);
237 out.push_str(&format!(
238 " client.{base_method}_by_id(\"{escaped}\"{suffix}).await.unwrap();\n"
239 ));
240 }
241 ResolvedSelector::ByText(text) => {
242 let escaped = escape_rust_str(text);
243 out.push_str(&format!(
244 " client.{base_method}_by_text(\"{escaped}\"{suffix}).await.unwrap();\n"
245 ));
246 }
247 ResolvedSelector::Raw(sel) => {
248 let escaped = escape_rust_str(sel);
249 out.push_str(&format!(
250 " client.{base_method}(\"{escaped}\"{suffix}).await.unwrap();\n"
251 ));
252 }
253 }
254}
255
256fn emit_resolved_select(out: &mut String, resolved: &ResolvedSelector, val: &str) {
258 match resolved {
259 ResolvedSelector::ById(id) => {
260 let escaped = escape_rust_str(id);
261 out.push_str(&format!(
262 " client.select_option_by_id(\"{escaped}\", &[\"{val}\"]).await.unwrap();\n"
263 ));
264 }
265 ResolvedSelector::ByText(text) => {
266 let escaped = escape_rust_str(text);
267 out.push_str(&format!(
268 " client.select_option_by_text(\"{escaped}\", &[\"{val}\"]).await.unwrap();\n"
269 ));
270 }
271 ResolvedSelector::Raw(sel) => {
272 let escaped = escape_rust_str(sel);
273 out.push_str(&format!(
274 " client.select_option(\"{escaped}\", &[\"{val}\"]).await.unwrap();\n"
275 ));
276 }
277 }
278}
279
280#[cfg(test)]
281mod tests {
282 use chrono::{Duration, Utc};
283
284 use super::*;
285 use crate::event::{AppEvent, InteractionKind, IpcCall, IpcResult};
286 use crate::recording::{RecordedEvent, RecordedSession};
287
288 fn make_session(events: Vec<RecordedEvent>) -> RecordedSession {
289 RecordedSession {
290 id: "test-session-001".to_string(),
291 started_at: Utc::now(),
292 events,
293 checkpoints: vec![],
294 }
295 }
296
297 fn interaction_event(
298 index: usize,
299 action: InteractionKind,
300 selector: &str,
301 value: Option<&str>,
302 offset_ms: i64,
303 ) -> RecordedEvent {
304 RecordedEvent {
305 index,
306 timestamp: Utc::now() + Duration::milliseconds(offset_ms),
307 event: AppEvent::DomInteraction {
308 action,
309 selector: selector.to_string(),
310 value: value.map(String::from),
311 timestamp: Utc::now() + Duration::milliseconds(offset_ms),
312 webview_label: "main".to_string(),
313 },
314 }
315 }
316
317 #[test]
318 fn empty_session_produces_valid_skeleton() {
319 let session = make_session(vec![]);
320 let code = generate_test_default(&session);
321
322 assert!(code.contains("use victauri_test::VictauriClient;"));
323 assert!(code.contains("#[tokio::test]"));
324 assert!(code.contains("async fn recorded_flow()"));
325 assert!(code.contains("VictauriClient::discover()"));
326 assert!(code.contains("Session: test-session-001"));
327 }
328
329 #[test]
330 fn click_by_id_generated_for_hash_selector() {
331 let session = make_session(vec![interaction_event(
332 0,
333 InteractionKind::Click,
334 "#submit-btn",
335 None,
336 0,
337 )]);
338 let code = generate_test_default(&session);
339
340 assert!(
341 code.contains("client.click_by_id(\"submit-btn\").await.unwrap();"),
342 "expected click_by_id for # selector, got:\n{code}"
343 );
344 }
345
346 #[test]
347 fn fill_generates_correct_call() {
348 let session = make_session(vec![interaction_event(
349 0,
350 InteractionKind::Fill,
351 "input[name=\"email\"]",
352 Some("user@example.com"),
353 0,
354 )]);
355 let code = generate_test_default(&session);
356
357 assert!(code.contains(
358 "client.fill(\"input[name=\\\"email\\\"]\", \"user@example.com\").await.unwrap();"
359 ));
360 }
361
362 #[test]
363 fn ipc_comment_included_when_enabled() {
364 let session = make_session(vec![RecordedEvent {
365 index: 0,
366 timestamp: Utc::now(),
367 event: AppEvent::Ipc(IpcCall {
368 id: "c1".to_string(),
369 command: "save_settings".to_string(),
370 timestamp: Utc::now(),
371 duration_ms: Some(10),
372 result: IpcResult::Ok(serde_json::json!(true)),
373 arg_size_bytes: 0,
374 webview_label: "main".to_string(),
375 }),
376 }]);
377 let code = generate_test_default(&session);
378
379 assert!(code.contains("// IPC: save_settings completed successfully"));
380 }
381
382 #[test]
383 fn internal_victauri_ipc_skipped() {
384 let session = make_session(vec![RecordedEvent {
385 index: 0,
386 timestamp: Utc::now(),
387 event: AppEvent::Ipc(IpcCall {
388 id: "c2".to_string(),
389 command: "plugin:victauri|get_snapshot".to_string(),
390 timestamp: Utc::now(),
391 duration_ms: Some(2),
392 result: IpcResult::Ok(serde_json::json!({})),
393 arg_size_bytes: 0,
394 webview_label: "main".to_string(),
395 }),
396 }]);
397 let code = generate_test_default(&session);
398
399 assert!(!code.contains("plugin:victauri"));
400 }
401
402 #[test]
403 fn ipc_omitted_when_disabled() {
404 let session = make_session(vec![RecordedEvent {
405 index: 0,
406 timestamp: Utc::now(),
407 event: AppEvent::Ipc(IpcCall {
408 id: "c1".to_string(),
409 command: "save_settings".to_string(),
410 timestamp: Utc::now(),
411 duration_ms: Some(10),
412 result: IpcResult::Ok(serde_json::json!(true)),
413 arg_size_bytes: 0,
414 webview_label: "main".to_string(),
415 }),
416 }]);
417 let opts = CodegenOptions {
418 include_ipc_assertions: false,
419 ..CodegenOptions::default()
420 };
421 let code = generate_test(&session, &opts);
422
423 assert!(!code.contains("IPC:"));
424 }
425
426 #[test]
427 fn state_change_comment_included() {
428 let session = make_session(vec![RecordedEvent {
429 index: 0,
430 timestamp: Utc::now(),
431 event: AppEvent::StateChange {
432 key: "user.theme".to_string(),
433 timestamp: Utc::now(),
434 caused_by: Some("toggle_theme".to_string()),
435 },
436 }]);
437 let code = generate_test_default(&session);
438
439 assert!(code.contains("// State changed: user.theme"));
440 }
441
442 #[test]
443 fn custom_test_name() {
444 let session = make_session(vec![]);
445 let opts = CodegenOptions {
446 test_name: "my_custom_test".to_string(),
447 ..CodegenOptions::default()
448 };
449 let code = generate_test(&session, &opts);
450
451 assert!(code.contains("async fn my_custom_test()"));
452 }
453
454 #[test]
455 fn special_chars_escaped() {
456 let session = make_session(vec![interaction_event(
457 0,
458 InteractionKind::Fill,
459 "input",
460 Some("line1\nline2\ttab\"quote\\back"),
461 0,
462 )]);
463 let code = generate_test_default(&session);
464
465 assert!(code.contains("\\n"));
466 assert!(code.contains("\\t"));
467 assert!(code.contains("\\\""));
468 assert!(code.contains("\\\\"));
469 }
470
471 #[test]
472 fn all_interaction_kinds_generate_code() {
473 let session = make_session(vec![
474 interaction_event(0, InteractionKind::Click, "[data-testid=\"a\"]", None, 0),
475 interaction_event(
476 1,
477 InteractionKind::DoubleClick,
478 "[data-testid=\"b\"]",
479 None,
480 10,
481 ),
482 interaction_event(
483 2,
484 InteractionKind::Fill,
485 "[data-testid=\"c\"]",
486 Some("val"),
487 20,
488 ),
489 interaction_event(3, InteractionKind::KeyPress, "#d", Some("Enter"), 30),
490 interaction_event(
491 4,
492 InteractionKind::Select,
493 "[data-testid=\"e\"]",
494 Some("opt1"),
495 40,
496 ),
497 interaction_event(5, InteractionKind::Navigate, "#f", Some("/page"), 50),
498 interaction_event(6, InteractionKind::Scroll, "#g", None, 60),
499 ]);
500 let code = generate_test(
501 &session,
502 &CodegenOptions {
503 include_timing_comments: false,
504 ..CodegenOptions::default()
505 },
506 );
507
508 assert!(code.contains("client.click(\"[data-testid=\\\"a\\\"]\")"));
509 assert!(code.contains("client.double_click(\"[data-testid=\\\"b\\\"]\")"));
510 assert!(code.contains("client.fill(\"[data-testid=\\\"c\\\"]\", \"val\")"));
511 assert!(code.contains("client.press_key(\"Enter\")"));
512 assert!(code.contains("client.select_option(\"[data-testid=\\\"e\\\"]\", &[\"opt1\"])"));
513 assert!(code.contains("client.navigate(\"/page\")"));
514 assert!(code.contains("client.scroll_to(\"#g\", None, None)"));
515 }
516
517 #[test]
518 fn dom_mutation_and_window_event_skipped() {
519 let ts = Utc::now();
520 let session = make_session(vec![
521 RecordedEvent {
522 index: 0,
523 timestamp: ts,
524 event: AppEvent::DomMutation {
525 webview_label: "main".to_string(),
526 timestamp: ts,
527 mutation_count: 5,
528 },
529 },
530 RecordedEvent {
531 index: 1,
532 timestamp: ts,
533 event: AppEvent::WindowEvent {
534 label: "main".to_string(),
535 event: "focus".to_string(),
536 timestamp: ts,
537 },
538 },
539 ]);
540 let code = generate_test_default(&session);
541
542 assert!(!code.contains("client."));
544 assert!(!code.contains("// IPC:"));
545 assert!(!code.contains("// State"));
546 }
547
548 #[test]
549 fn default_options_are_correct() {
550 let opts = CodegenOptions::default();
551 assert_eq!(opts.test_name, "recorded_flow");
552 assert!(opts.include_ipc_assertions);
553 assert!(opts.include_state_checks);
554 assert!(opts.include_timing_comments);
555 }
556
557 fn make_session_at(base: chrono::DateTime<Utc>, events: Vec<RecordedEvent>) -> RecordedSession {
560 RecordedSession {
561 id: "timing-session".to_string(),
562 started_at: base,
563 events,
564 checkpoints: vec![],
565 }
566 }
567
568 fn interaction_event_at(
569 base: chrono::DateTime<Utc>,
570 index: usize,
571 action: InteractionKind,
572 selector: &str,
573 value: Option<&str>,
574 offset_ms: i64,
575 ) -> RecordedEvent {
576 let ts = base + Duration::milliseconds(offset_ms);
577 RecordedEvent {
578 index,
579 timestamp: ts,
580 event: AppEvent::DomInteraction {
581 action,
582 selector: selector.to_string(),
583 value: value.map(String::from),
584 timestamp: ts,
585 webview_label: "main".to_string(),
586 },
587 }
588 }
589
590 #[test]
591 fn timing_comment_emitted_for_large_gap() {
592 let base = Utc::now();
593 let session = make_session_at(
594 base,
595 vec![
596 interaction_event_at(base, 0, InteractionKind::Click, ".btn-a", None, 0),
597 interaction_event_at(base, 1, InteractionKind::Click, ".btn-b", None, 1000),
598 ],
599 );
600 let opts = CodegenOptions {
601 include_timing_comments: true,
602 ..CodegenOptions::default()
603 };
604 let code = generate_test(&session, &opts);
605
606 assert!(
607 code.contains("// +1000ms"),
608 "expected timing comment for 1000ms gap, got:\n{code}"
609 );
610 }
611
612 #[test]
613 fn timing_comment_omitted_for_small_gap() {
614 let base = Utc::now();
615 let session = make_session_at(
616 base,
617 vec![
618 interaction_event_at(base, 0, InteractionKind::Click, ".btn-a", None, 0),
619 interaction_event_at(base, 1, InteractionKind::Click, ".btn-b", None, 200),
620 ],
621 );
622 let opts = CodegenOptions {
623 include_timing_comments: true,
624 ..CodegenOptions::default()
625 };
626 let code = generate_test(&session, &opts);
627
628 assert!(
629 !code.contains("// +"),
630 "expected no timing comment for 200ms gap, got:\n{code}"
631 );
632 }
633
634 #[test]
637 fn id_selector_emits_click_by_id() {
638 let session = make_session(vec![interaction_event(
639 0,
640 InteractionKind::Click,
641 "#my-id",
642 None,
643 0,
644 )]);
645 let code = generate_test_default(&session);
646
647 assert!(
648 code.contains("client.click_by_id(\"my-id\").await.unwrap();"),
649 "expected click_by_id, got:\n{code}"
650 );
651 }
652
653 #[test]
654 fn has_text_selector_emits_click_by_text() {
655 let session = make_session(vec![interaction_event(
656 0,
657 InteractionKind::Click,
658 "button:has-text(\"Submit\")",
659 None,
660 0,
661 )]);
662 let code = generate_test_default(&session);
663
664 assert!(
665 code.contains("client.click_by_text(\"Submit\").await.unwrap();"),
666 "expected click_by_text, got:\n{code}"
667 );
668 }
669
670 #[test]
671 fn role_has_text_selector_emits_click_by_text() {
672 let session = make_session(vec![interaction_event(
673 0,
674 InteractionKind::Click,
675 "[role=\"button\"]:has-text(\"Save\")",
676 None,
677 0,
678 )]);
679 let code = generate_test_default(&session);
680
681 assert!(
682 code.contains("client.click_by_text(\"Save\").await.unwrap();"),
683 "expected click_by_text for role selector, got:\n{code}"
684 );
685 }
686
687 #[test]
688 fn data_testid_selector_emits_raw_click() {
689 let session = make_session(vec![interaction_event(
690 0,
691 InteractionKind::Click,
692 "[data-testid=\"foo\"]",
693 None,
694 0,
695 )]);
696 let code = generate_test_default(&session);
697
698 assert!(
699 code.contains("client.click(\"[data-testid=\\\"foo\\\"]\").await.unwrap();"),
700 "expected raw click for data-testid selector, got:\n{code}"
701 );
702 }
703
704 #[test]
705 fn fill_with_id_selector_emits_fill_by_id() {
706 let session = make_session(vec![interaction_event(
707 0,
708 InteractionKind::Fill,
709 "#email",
710 Some("user@example.com"),
711 0,
712 )]);
713 let code = generate_test_default(&session);
714
715 assert!(
716 code.contains("client.fill_by_id(\"email\", \"user@example.com\").await.unwrap();"),
717 "expected fill_by_id, got:\n{code}"
718 );
719 }
720
721 #[test]
722 fn double_click_with_has_text_emits_by_text() {
723 let session = make_session(vec![interaction_event(
724 0,
725 InteractionKind::DoubleClick,
726 "span:has-text(\"Edit\")",
727 None,
728 0,
729 )]);
730 let code = generate_test_default(&session);
731
732 assert!(
733 code.contains("client.double_click_by_text(\"Edit\").await.unwrap();"),
734 "expected double_click_by_text, got:\n{code}"
735 );
736 }
737
738 #[test]
739 fn select_with_id_emits_select_option_by_id() {
740 let session = make_session(vec![interaction_event(
741 0,
742 InteractionKind::Select,
743 "#country",
744 Some("AU"),
745 0,
746 )]);
747 let code = generate_test_default(&session);
748
749 assert!(
750 code.contains("client.select_option_by_id(\"country\", &[\"AU\"]).await.unwrap();"),
751 "expected select_option_by_id, got:\n{code}"
752 );
753 }
754
755 #[test]
759 fn round_trip_realistic_session() {
760 let base = Utc::now();
761
762 let events = vec![
763 interaction_event_at(base, 0, InteractionKind::Click, "#submit-btn", None, 0),
765 interaction_event_at(
767 base,
768 1,
769 InteractionKind::Fill,
770 "input[name=email]",
771 Some("test@example.com"),
772 100,
773 ),
774 interaction_event_at(
776 base,
777 2,
778 InteractionKind::KeyPress,
779 "body",
780 Some("Enter"),
781 200,
782 ),
783 RecordedEvent {
785 index: 3,
786 timestamp: base + Duration::milliseconds(300),
787 event: AppEvent::Ipc(IpcCall {
788 id: "ipc-1".to_string(),
789 command: "save_draft".to_string(),
790 timestamp: base + Duration::milliseconds(300),
791 duration_ms: Some(15),
792 result: IpcResult::Ok(serde_json::json!({"saved": true})),
793 arg_size_bytes: 42,
794 webview_label: "main".to_string(),
795 }),
796 },
797 RecordedEvent {
799 index: 4,
800 timestamp: base + Duration::milliseconds(350),
801 event: AppEvent::StateChange {
802 key: "draft.status".to_string(),
803 timestamp: base + Duration::milliseconds(350),
804 caused_by: Some("save_draft".to_string()),
805 },
806 },
807 ];
808
809 let session = make_session_at(base, events);
810 let code = generate_test(&session, &CodegenOptions::default());
811
812 assert!(
814 code.contains("#[tokio::test]"),
815 "missing #[tokio::test] attribute:\n{code}"
816 );
817 assert!(
818 code.contains("async fn recorded_flow()"),
819 "missing async fn declaration:\n{code}"
820 );
821 assert!(
822 code.contains("VictauriClient::discover()"),
823 "missing VictauriClient::discover() call:\n{code}"
824 );
825 assert!(
826 code.ends_with("}\n"),
827 "missing closing brace at end of generated code:\n{code}"
828 );
829
830 assert!(
834 code.contains("client.click_by_id(\"submit-btn\").await.unwrap();"),
835 "expected click_by_id for #submit-btn:\n{code}"
836 );
837 assert!(
838 !code.contains("client.click(\"#submit-btn\")"),
839 "#submit-btn should NOT appear as raw client.click:\n{code}"
840 );
841
842 assert!(
844 code.contains(
845 "client.fill(\"input[name=email]\", \"test@example.com\").await.unwrap();"
846 ),
847 "expected raw client.fill for input[name=email]:\n{code}"
848 );
849 assert!(
850 !code.contains("client.fill_by_id(\"input[name=email]\""),
851 "input[name=email] should NOT resolve to fill_by_id:\n{code}"
852 );
853
854 assert!(
856 code.contains("client.press_key(\"Enter\").await.unwrap();"),
857 "expected press_key(\"Enter\"):\n{code}"
858 );
859
860 assert!(
862 code.contains("// IPC: save_draft completed successfully"),
863 "expected IPC comment for save_draft:\n{code}"
864 );
865
866 assert!(
868 code.contains("// State changed: draft.status"),
869 "expected state change comment for draft.status:\n{code}"
870 );
871
872 let open_braces = code.matches('{').count();
874 let close_braces = code.matches('}').count();
875 assert_eq!(
876 open_braces, close_braces,
877 "unbalanced braces: {open_braces} open vs {close_braces} close in:\n{code}"
878 );
879 }
880}