1use crate::codegen::resolve_field;
8use crate::config::E2eConfig;
9use crate::escape::sanitize_filename;
10use crate::field_access::FieldResolver;
11use crate::fixture::{Assertion, Fixture, FixtureGroup, HttpFixture, ValidationErrorExpectation};
12use alef_core::backend::GeneratedFile;
13use alef_core::config::ResolvedCrateConfig;
14use alef_core::hash::{self, CommentStyle};
15use alef_core::template_versions::pub_dev;
16use anyhow::Result;
17use heck::ToLowerCamelCase;
18use std::cell::Cell;
19use std::fmt::Write as FmtWrite;
20use std::path::PathBuf;
21
22use super::E2eCodegen;
23use super::client;
24
25pub struct DartE2eCodegen;
27
28impl E2eCodegen for DartE2eCodegen {
29 fn generate(
30 &self,
31 groups: &[FixtureGroup],
32 e2e_config: &E2eConfig,
33 config: &ResolvedCrateConfig,
34 _type_defs: &[alef_core::ir::TypeDef],
35 _enums: &[alef_core::ir::EnumDef],
36 ) -> Result<Vec<GeneratedFile>> {
37 let lang = self.language_name();
38 let output_base = PathBuf::from(e2e_config.effective_output()).join(lang);
39
40 let mut files = Vec::new();
41
42 let dart_pkg = e2e_config.resolve_package("dart");
44 let pkg_name = dart_pkg
45 .as_ref()
46 .and_then(|p| p.name.as_ref())
47 .cloned()
48 .unwrap_or_else(|| config.dart_pubspec_name());
49 let pkg_path = dart_pkg
50 .as_ref()
51 .and_then(|p| p.path.as_ref())
52 .cloned()
53 .unwrap_or_else(|| "../../packages/dart".to_string());
54 let pkg_version = dart_pkg
55 .as_ref()
56 .and_then(|p| p.version.as_ref())
57 .cloned()
58 .or_else(|| config.resolved_version())
59 .unwrap_or_else(|| "0.1.0".to_string());
60
61 files.push(GeneratedFile {
63 path: output_base.join("pubspec.yaml"),
64 content: render_pubspec(&pkg_name, &pkg_path, &pkg_version, e2e_config.dep_mode),
65 generated_header: false,
66 });
67
68 files.push(GeneratedFile {
71 path: output_base.join("dart_test.yaml"),
72 content: concat!(
73 "# Generated by alef — DO NOT EDIT.\n",
74 "# Run test files sequentially to avoid overwhelming the mock server with\n",
75 "# concurrent keep-alive connections.\n",
76 "concurrency: 1\n",
77 )
78 .to_string(),
79 generated_header: false,
80 });
81
82 let test_base = output_base.join("test");
83
84 let bridge_class = config.dart_bridge_class_name();
86
87 let frb_module_name = config.name.replace('-', "_");
91
92 let dart_stub_methods: std::collections::HashSet<String> = config
97 .dart
98 .as_ref()
99 .map(|d| d.stub_methods.iter().cloned().collect())
100 .unwrap_or_default();
101
102 for group in groups {
103 let active: Vec<&Fixture> = group
104 .fixtures
105 .iter()
106 .filter(|f| super::should_include_fixture(f, lang, e2e_config))
107 .filter(|f| {
108 let call_config = e2e_config.resolve_call_for_fixture(
109 f.call.as_deref(),
110 &f.id,
111 &f.resolved_category(),
112 &f.tags,
113 &f.input,
114 );
115 let resolved_function = call_config
116 .overrides
117 .get(lang)
118 .and_then(|o| o.function.as_ref())
119 .cloned()
120 .unwrap_or_else(|| call_config.function.clone());
121 !dart_stub_methods.contains(&resolved_function)
122 })
123 .collect();
124
125 if active.is_empty() {
126 continue;
127 }
128
129 let filename = format!("{}_test.dart", sanitize_filename(&group.category));
130 let content = render_test_file(
131 &group.category,
132 &active,
133 e2e_config,
134 lang,
135 &pkg_name,
136 &frb_module_name,
137 &bridge_class,
138 );
139 files.push(GeneratedFile {
140 path: test_base.join(filename),
141 content,
142 generated_header: true,
143 });
144 }
145
146 Ok(files)
147 }
148
149 fn language_name(&self) -> &'static str {
150 "dart"
151 }
152}
153
154fn render_pubspec(
159 pkg_name: &str,
160 pkg_path: &str,
161 pkg_version: &str,
162 dep_mode: crate::config::DependencyMode,
163) -> String {
164 let test_ver = pub_dev::TEST_PACKAGE;
165 let http_ver = pub_dev::HTTP_PACKAGE;
166
167 let dep_block = match dep_mode {
168 crate::config::DependencyMode::Registry => {
169 let constraint = if pkg_version.starts_with('^')
171 || pkg_version.starts_with('~')
172 || pkg_version.starts_with('>')
173 || pkg_version.starts_with('<')
174 || pkg_version.starts_with('=')
175 {
176 pkg_version.to_string()
177 } else {
178 format!("^{pkg_version}")
179 };
180 format!(" {pkg_name}: {constraint}")
181 }
182 crate::config::DependencyMode::Local => {
183 format!(" {pkg_name}:\n path: {pkg_path}")
184 }
185 };
186
187 let sdk = alef_core::template_versions::toolchain::DART_SDK_CONSTRAINT;
188 format!(
189 r#"name: e2e_dart
190version: 0.1.0
191publish_to: none
192
193environment:
194 sdk: "{sdk}"
195
196dependencies:
197{dep_block}
198
199dev_dependencies:
200 test: {test_ver}
201 http: {http_ver}
202"#
203 )
204}
205
206fn render_test_file(
207 category: &str,
208 fixtures: &[&Fixture],
209 e2e_config: &E2eConfig,
210 lang: &str,
211 pkg_name: &str,
212 frb_module_name: &str,
213 bridge_class: &str,
214) -> String {
215 let mut out = String::new();
216 out.push_str(&hash::header(CommentStyle::DoubleSlash));
217 out.push_str("// ignore_for_file: unused_local_variable\n\n");
221
222 let has_http_fixtures = fixtures.iter().any(|f| f.is_http_test());
224
225 let has_batch_byte_items = fixtures.iter().any(|f| {
227 let call_config =
228 e2e_config.resolve_call_for_fixture(f.call.as_deref(), &f.id, &f.resolved_category(), &f.tags, &f.input);
229 call_config.args.iter().any(|a| {
230 a.element_type.as_deref() == Some("BatchBytesItem") && resolve_field(&f.input, &a.field).is_array()
231 })
232 });
233
234 let needs_chdir = fixtures.iter().any(|f| {
238 if f.is_http_test() {
239 return false;
240 }
241 let call_config =
242 e2e_config.resolve_call_for_fixture(f.call.as_deref(), &f.id, &f.resolved_category(), &f.tags, &f.input);
243 call_config
244 .args
245 .iter()
246 .any(|a| a.arg_type == "file_path" || a.arg_type == "bytes")
247 });
248
249 let has_handle_args = fixtures.iter().any(|f| {
255 if f.is_http_test() {
256 return false;
257 }
258 let call_config =
259 e2e_config.resolve_call_for_fixture(f.call.as_deref(), &f.id, &f.resolved_category(), &f.tags, &f.input);
260 call_config
261 .args
262 .iter()
263 .any(|a| a.arg_type == "json_object" && super::resolve_field(&f.input, &a.field).is_array())
264 });
265
266 let lang_client_factory = e2e_config
272 .call
273 .overrides
274 .get(lang)
275 .and_then(|o| o.client_factory.as_deref())
276 .is_some();
277 let has_mock_url_refs = lang_client_factory
278 || fixtures.iter().any(|f| {
279 if f.is_http_test() {
280 return false;
281 }
282 let call_config = e2e_config.resolve_call_for_fixture(
283 f.call.as_deref(),
284 &f.id,
285 &f.resolved_category(),
286 &f.tags,
287 &f.input,
288 );
289 if call_config.args.iter().any(|a| a.arg_type == "mock_url") {
290 return true;
291 }
292 call_config
293 .overrides
294 .get(lang)
295 .and_then(|o| o.client_factory.as_deref())
296 .is_some()
297 });
298
299 let _ = writeln!(out, "import 'package:test/test.dart';");
300 if has_http_fixtures || needs_chdir || has_mock_url_refs {
305 let _ = writeln!(out, "import 'dart:io';");
306 }
307 if has_batch_byte_items {
308 let _ = writeln!(out, "import 'dart:typed_data';");
309 }
310 let _ = writeln!(out, "import 'package:{pkg_name}/{pkg_name}.dart';");
311 let _ = writeln!(
317 out,
318 "import 'package:{pkg_name}/src/{frb_module_name}_bridge_generated/frb_generated.dart' show RustLib;"
319 );
320 if has_http_fixtures {
321 let _ = writeln!(out, "import 'dart:async';");
322 }
323 if has_http_fixtures || has_handle_args {
325 let _ = writeln!(out, "import 'dart:convert';");
326 }
327 let _ = writeln!(out);
328
329 if has_http_fixtures {
339 let _ = writeln!(out, "HttpClient _httpClient = HttpClient()..maxConnectionsPerHost = 1;");
340 let _ = writeln!(out);
341 let _ = writeln!(out, "var _lock = Future<void>.value();");
342 let _ = writeln!(out);
343 let _ = writeln!(out, "Future<T> _serialized<T>(Future<T> Function() fn) async {{");
344 let _ = writeln!(out, " final current = _lock;");
345 let _ = writeln!(out, " final next = Completer<void>();");
346 let _ = writeln!(out, " _lock = next.future;");
347 let _ = writeln!(out, " try {{");
348 let _ = writeln!(out, " await current;");
349 let _ = writeln!(out, " return await fn();");
350 let _ = writeln!(out, " }} finally {{");
351 let _ = writeln!(out, " next.complete();");
352 let _ = writeln!(out, " }}");
353 let _ = writeln!(out, "}}");
354 let _ = writeln!(out);
355 let _ = writeln!(out, "Future<T> _withRetry<T>(Future<T> Function() fn) async {{");
358 let _ = writeln!(out, " try {{");
359 let _ = writeln!(out, " return await fn();");
360 let _ = writeln!(out, " }} on SocketException {{");
361 let _ = writeln!(out, " _httpClient.close(force: true);");
362 let _ = writeln!(out, " _httpClient = HttpClient()..maxConnectionsPerHost = 1;");
363 let _ = writeln!(out, " return fn();");
364 let _ = writeln!(out, " }} on HttpException {{");
365 let _ = writeln!(out, " _httpClient.close(force: true);");
366 let _ = writeln!(out, " _httpClient = HttpClient()..maxConnectionsPerHost = 1;");
367 let _ = writeln!(out, " return fn();");
368 let _ = writeln!(out, " }}");
369 let _ = writeln!(out, "}}");
370 let _ = writeln!(out);
371 }
372
373 let _ = writeln!(out, "// E2e tests for category: {category}");
374 let _ = writeln!(out);
375
376 let _ = writeln!(out, "String _alefE2eText(Object? value) {{");
382 let _ = writeln!(out, " if (value == null) return '';");
383 let _ = writeln!(
384 out,
385 " // Check if it's an enum by examining its toString representation."
386 );
387 let _ = writeln!(out, " final str = value.toString();");
388 let _ = writeln!(out, " if (str.contains('.')) {{");
389 let _ = writeln!(
390 out,
391 " // Enum.toString() returns 'EnumName.variantName'. Extract the variant name."
392 );
393 let _ = writeln!(out, " final parts = str.split('.');");
394 let _ = writeln!(out, " if (parts.length == 2) {{");
395 let _ = writeln!(out, " final variantName = parts[1];");
396 let _ = writeln!(
397 out,
398 " // Convert camelCase variant names to snake_case for serde compatibility."
399 );
400 let _ = writeln!(out, " // E.g. 'toolCalls' -> 'tool_calls', 'stop' -> 'stop'.");
401 let _ = writeln!(out, " return _camelToSnake(variantName);");
402 let _ = writeln!(out, " }}");
403 let _ = writeln!(out, " }}");
404 let _ = writeln!(out, " return str;");
405 let _ = writeln!(out, "}}");
406 let _ = writeln!(out);
407
408 let _ = writeln!(out, "String _camelToSnake(String camel) {{");
410 let _ = writeln!(out, " final buffer = StringBuffer();");
411 let _ = writeln!(out, " for (int i = 0; i < camel.length; i++) {{");
412 let _ = writeln!(out, " final char = camel[i];");
413 let _ = writeln!(out, " if (char.contains(RegExp(r'[A-Z]'))) {{");
414 let _ = writeln!(out, " if (i > 0) buffer.write('_');");
415 let _ = writeln!(out, " buffer.write(char.toLowerCase());");
416 let _ = writeln!(out, " }} else {{");
417 let _ = writeln!(out, " buffer.write(char);");
418 let _ = writeln!(out, " }}");
419 let _ = writeln!(out, " }}");
420 let _ = writeln!(out, " return buffer.toString();");
421 let _ = writeln!(out, "}}");
422 let _ = writeln!(out);
423
424 let _ = writeln!(out, "void main() {{");
425
426 let _ = writeln!(out, " setUpAll(() async {{");
433 let _ = writeln!(out, " await RustLib.init();");
434 if needs_chdir {
435 let test_docs_path = e2e_config.test_documents_relative_from(0);
436 let _ = writeln!(
437 out,
438 " final _testDocs = Platform.environment['FIXTURES_DIR'] ?? '{test_docs_path}';"
439 );
440 let _ = writeln!(out, " final _dir = Directory(_testDocs);");
441 let _ = writeln!(out, " if (_dir.existsSync()) Directory.current = _dir;");
442 }
443 let _ = writeln!(out, " }});");
444 let _ = writeln!(out);
445
446 if has_http_fixtures {
448 let _ = writeln!(out, " tearDownAll(() => _httpClient.close());");
449 let _ = writeln!(out);
450 }
451
452 for fixture in fixtures {
453 render_test_case(&mut out, fixture, e2e_config, lang, bridge_class);
454 }
455
456 let _ = writeln!(out, "}}");
457 out
458}
459
460fn render_test_case(out: &mut String, fixture: &Fixture, e2e_config: &E2eConfig, lang: &str, bridge_class: &str) {
461 if let Some(http) = &fixture.http {
463 render_http_test_case(out, fixture, http);
464 return;
465 }
466
467 let call_config = e2e_config.resolve_call_for_fixture(
469 fixture.call.as_deref(),
470 &fixture.id,
471 &fixture.resolved_category(),
472 &fixture.tags,
473 &fixture.input,
474 );
475 let call_field_resolver = FieldResolver::new(
477 e2e_config.effective_fields(call_config),
478 e2e_config.effective_fields_optional(call_config),
479 e2e_config.effective_result_fields(call_config),
480 e2e_config.effective_fields_array(call_config),
481 e2e_config.effective_fields_method_calls(call_config),
482 );
483 let field_resolver = &call_field_resolver;
484 let enum_fields_base = e2e_config.effective_fields_enum(call_config);
485
486 let effective_enum_fields: std::collections::HashSet<String> = {
491 let dart_overrides = call_config.overrides.get("dart");
492 if let Some(overrides) = dart_overrides {
493 let mut merged = enum_fields_base.clone();
494 merged.extend(overrides.enum_fields.keys().cloned());
495 merged
496 } else {
497 enum_fields_base.clone()
498 }
499 };
500 let enum_fields = &effective_enum_fields;
501 let call_overrides = call_config.overrides.get(lang);
502 let mut function_name = call_overrides
503 .and_then(|o| o.function.as_ref())
504 .cloned()
505 .unwrap_or_else(|| call_config.function.clone());
506 function_name = function_name
508 .split('_')
509 .enumerate()
510 .map(|(i, part)| {
511 if i == 0 {
512 part.to_string()
513 } else {
514 let mut chars = part.chars();
515 match chars.next() {
516 None => String::new(),
517 Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
518 }
519 }
520 })
521 .collect::<Vec<_>>()
522 .join("");
523 let result_var = &call_config.result_var;
524 let description = escape_dart(&fixture.description);
525 let fixture_id = &fixture.id;
526 let _is_async = call_overrides.and_then(|o| o.r#async).unwrap_or(call_config.r#async);
529
530 let expects_error = fixture.assertions.iter().any(|a| a.assertion_type == "error");
531 let is_streaming = crate::codegen::streaming_assertions::resolve_is_streaming(fixture, call_config.streaming);
532 let result_is_simple = call_overrides.is_some_and(|o| o.result_is_simple) || call_config.result_is_simple;
537
538 let options_type: Option<&str> = call_overrides.and_then(|o| o.options_type.as_deref());
545 let options_via: &str = call_overrides
546 .and_then(|o| o.options_via.as_deref())
547 .unwrap_or("kwargs");
548
549 let file_path_for_mime: Option<&str> = call_config
557 .args
558 .iter()
559 .find(|a| a.arg_type == "file_path")
560 .and_then(|a| resolve_field(&fixture.input, &a.field).as_str());
561
562 let has_file_path_arg = call_config.args.iter().any(|a| a.arg_type == "file_path");
569 let caller_supplied_override = call_overrides.and_then(|o| o.function.as_ref()).is_some();
572 if has_file_path_arg && !caller_supplied_override {
573 function_name = match function_name.as_str() {
574 "extractFile" => "extractBytes".to_string(),
575 "extractFileSync" => "extractBytesSync".to_string(),
576 other => other.to_string(),
577 };
578 }
579
580 let client_factory_for_args: Option<&str> =
587 call_overrides.and_then(|o| o.client_factory.as_deref()).or_else(|| {
588 e2e_config
589 .call
590 .overrides
591 .get(lang)
592 .and_then(|o| o.client_factory.as_deref())
593 });
594
595 let mut setup_lines: Vec<String> = Vec::new();
598 let mut args = Vec::new();
599
600 for arg_def in &call_config.args {
601 match arg_def.arg_type.as_str() {
602 "mock_url" => {
603 let name = arg_def.name.clone();
604 if fixture.has_host_root_route() {
605 let env_key = format!("MOCK_SERVER_{}", fixture_id.to_uppercase());
606 setup_lines.push(format!(
607 r#"final {name} = Platform.environment["{env_key}"] ?? (Platform.environment["MOCK_SERVER_URL"]! + "/fixtures/{fixture_id}");"#
608 ));
609 } else {
610 setup_lines.push(format!(
611 r#"final {name} = "${{Platform.environment["MOCK_SERVER_URL"] ?? "http://localhost:8080"}}/fixtures/{fixture_id}";"#
612 ));
613 }
614 args.push(name);
615 continue;
616 }
617 "handle" => {
618 let name = arg_def.name.clone();
619 let field = arg_def.field.strip_prefix("input.").unwrap_or(&arg_def.field);
620 let config_value = fixture.input.get(field).cloned().unwrap_or(serde_json::Value::Null);
621 let create_fn = {
623 let mut chars = name.chars();
624 let pascal = match chars.next() {
625 None => String::new(),
626 Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
627 };
628 format!("create{pascal}")
629 };
630 if config_value.is_null()
631 || config_value.is_object() && config_value.as_object().is_some_and(|o| o.is_empty())
632 {
633 setup_lines.push(format!("final {name} = await {bridge_class}.{create_fn}();"));
634 } else {
635 let json_str = serde_json::to_string(&config_value).unwrap_or_default();
636 let config_var = format!("{name}Config");
637 setup_lines.push(format!(
642 "final {config_var} = await createCrawlConfigFromJson(json: r'{json_str}');"
643 ));
644 setup_lines.push(format!(
646 "final {name} = await {bridge_class}.{create_fn}(config: {config_var});"
647 ));
648 }
649 args.push(name);
650 continue;
651 }
652 _ => {}
653 }
654
655 let arg_value = resolve_field(&fixture.input, &arg_def.field);
656 match arg_def.arg_type.as_str() {
657 "bytes" | "file_path" => {
658 if let serde_json::Value::String(file_path) = arg_value {
663 args.push(format!("File('{}').readAsBytesSync()", file_path));
664 }
665 }
666 "string" => {
667 let dart_param_name = snake_to_camel(&arg_def.name);
682 match arg_value {
683 serde_json::Value::String(s) => {
684 let literal = format!("'{}'", escape_dart(s));
685 if arg_def.optional || client_factory_for_args.is_some() {
691 args.push(format!("{dart_param_name}: {literal}"));
692 } else {
693 args.push(literal);
694 }
695 }
696 serde_json::Value::Null
697 if arg_def.optional
698 && arg_def.name == "mime_type" =>
701 {
702 let inferred = file_path_for_mime
703 .and_then(mime_from_extension)
704 .unwrap_or("application/octet-stream");
705 args.push(format!("{dart_param_name}: '{inferred}'"));
706 }
707 _ => {}
709 }
710 }
711 "json_object" => {
712 if let Some(elem_type) = &arg_def.element_type {
714 if (elem_type == "BatchBytesItem" || elem_type == "BatchFileItem") && arg_value.is_array() {
715 let dart_items = emit_dart_batch_item_array(arg_value, elem_type);
716 args.push(dart_items);
717 } else if elem_type == "String" && arg_value.is_array() {
718 let items: Vec<String> = arg_value
725 .as_array()
726 .unwrap()
727 .iter()
728 .filter_map(|v| v.as_str())
729 .map(|s| format!("'{}'", escape_dart(s)))
730 .collect();
731 args.push(format!("<String>[{}]", items.join(", ")));
732 }
733 } else if options_via == "from_json" {
734 if let Some(opts_type) = options_type {
744 if !arg_value.is_null() {
745 let json_str = serde_json::to_string(&arg_value).unwrap_or_default();
746 let escaped_json = escape_dart(&json_str);
749 let var_name = format!("_{}", arg_def.name);
750 let dart_fn = type_name_to_create_from_json_dart(opts_type);
751 setup_lines.push(format!("final {var_name} = await {dart_fn}(json: '{escaped_json}');"));
752 args.push(format!("req: {var_name}"));
755 }
756 }
757 } else if arg_def.name == "config" {
758 if let serde_json::Value::Object(map) = &arg_value {
759 if !map.is_empty() {
760 let explicit_options =
769 options_type.is_some_and(|t| t != "ExtractionConfig" && t != "FileExtractionConfig");
770 let has_non_scalar = map.values().any(|v| {
771 matches!(
772 v,
773 serde_json::Value::String(_)
774 | serde_json::Value::Object(_)
775 | serde_json::Value::Array(_)
776 )
777 });
778 if explicit_options || has_non_scalar {
779 let opts_type = options_type.unwrap_or("ExtractionConfig");
780 let json_str = serde_json::to_string(&arg_value).unwrap_or_default();
781 let escaped_json = escape_dart(&json_str);
782 let var_name = format!("_{}", arg_def.name);
783 let dart_fn = type_name_to_create_from_json_dart(opts_type);
784 setup_lines
785 .push(format!("final {var_name} = await {dart_fn}(json: '{escaped_json}');"));
786 args.push(var_name);
787 } else {
788 args.push(emit_extraction_config_dart(map));
794 }
795 } else {
796 if let Some(opts_type) = options_type {
802 let var_name = format!("_{}", arg_def.name);
803 let dart_fn = type_name_to_create_from_json_dart(opts_type);
804 setup_lines.push(format!("final {var_name} = await {dart_fn}(json: '{{}}');"));
805 args.push(var_name);
806 }
807 }
808 }
809 } else if arg_value.is_array() {
811 let json_str = serde_json::to_string(&arg_value).unwrap_or_default();
814 let var_name = arg_def.name.clone();
815 setup_lines.push(format!(
816 "final {var_name} = (jsonDecode(r'{json_str}') as List<dynamic>).cast<String>();"
817 ));
818 args.push(var_name);
819 } else if let serde_json::Value::Object(map) = &arg_value {
820 if !map.is_empty() {
834 if let Some(opts_type) = options_type {
835 let json_str = serde_json::to_string(&arg_value).unwrap_or_default();
836 let escaped_json = escape_dart(&json_str);
837 let dart_param_name = snake_to_camel(&arg_def.name);
838 let var_name = format!("_{}", arg_def.name);
839 let dart_fn = type_name_to_create_from_json_dart(opts_type);
840 if fixture.visitor.is_some() {
841 setup_lines.push(format!(
842 "final {var_name} = await {dart_fn}WithVisitor(json: '{escaped_json}', visitor: _visitor);"
843 ));
844 } else {
845 setup_lines
846 .push(format!("final {var_name} = await {dart_fn}(json: '{escaped_json}');"));
847 }
848 if arg_def.optional {
849 args.push(format!("{dart_param_name}: {var_name}"));
850 } else {
851 args.push(var_name);
852 }
853 }
854 }
855 }
856 }
857 _ => {}
858 }
859 }
860
861 if let Some(visitor_spec) = &fixture.visitor {
876 let mut visitor_setup: Vec<String> = Vec::new();
877 let _ = super::dart_visitors::build_dart_visitor(&mut visitor_setup, visitor_spec);
878 for line in visitor_setup.into_iter().rev() {
881 setup_lines.insert(0, line);
882 }
883
884 let already_has_options = args.iter().any(|a| a.starts_with("options:") || a == "_options");
888 if !already_has_options {
889 if let Some(opts_type) = options_type {
890 let dart_fn = type_name_to_create_from_json_dart(opts_type);
891 setup_lines.push(format!(
892 "final _options = await {dart_fn}WithVisitor(json: '{{}}', visitor: _visitor);"
893 ));
894 args.push("options: _options".to_string());
895 }
896 }
897 }
898
899 let client_factory: Option<&str> = call_overrides.and_then(|o| o.client_factory.as_deref()).or_else(|| {
903 e2e_config
904 .call
905 .overrides
906 .get(lang)
907 .and_then(|o| o.client_factory.as_deref())
908 });
909
910 let client_factory_camel: Option<String> = client_factory.map(|f| {
912 f.split('_')
913 .enumerate()
914 .map(|(i, part)| {
915 if i == 0 {
916 part.to_string()
917 } else {
918 let mut chars = part.chars();
919 match chars.next() {
920 None => String::new(),
921 Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
922 }
923 }
924 })
925 .collect::<Vec<_>>()
926 .join("")
927 });
928
929 let _ = writeln!(out, " test('{description}', () async {{");
933
934 let args_str = args.join(", ");
935 let receiver_class = call_overrides
936 .and_then(|o| o.class.as_ref())
937 .cloned()
938 .unwrap_or_else(|| bridge_class.to_string());
939
940 let (receiver, extra_setup): (String, Option<String>) = if let Some(factory) = &client_factory_camel {
944 let has_mock_url = call_config.args.iter().any(|a| a.arg_type == "mock_url");
945 let mock_url_setup = if !has_mock_url {
946 if fixture.has_host_root_route() {
948 let env_key = format!("MOCK_SERVER_{}", fixture_id.to_uppercase());
949 Some(format!(
950 "final _mockUrl = Platform.environment[\"{env_key}\"] ?? (Platform.environment[\"MOCK_SERVER_URL\"]! + \"/fixtures/{fixture_id}\");"
951 ))
952 } else {
953 Some(format!(
954 r#"final _mockUrl = "${{Platform.environment["MOCK_SERVER_URL"] ?? "http://localhost:8080"}}/fixtures/{fixture_id}";"#
955 ))
956 }
957 } else {
958 None
959 };
960 let url_expr = if has_mock_url {
961 call_config
964 .args
965 .iter()
966 .find(|a| a.arg_type == "mock_url")
967 .map(|a| a.name.clone())
968 .unwrap_or_else(|| "_mockUrl".to_string())
969 } else {
970 "_mockUrl".to_string()
971 };
972 let create_line = format!("final _client = await {receiver_class}.{factory}('test-key', baseUrl: {url_expr});");
973 let full_setup = if let Some(url_line) = mock_url_setup {
974 Some(format!("{url_line}\n {create_line}"))
975 } else {
976 Some(create_line)
977 };
978 ("_client".to_string(), full_setup)
979 } else {
980 (receiver_class.clone(), None)
981 };
982
983 if expects_error && (!setup_lines.is_empty() || extra_setup.is_some()) {
984 let _ = writeln!(out, " await expectLater(() async {{");
988 for line in &setup_lines {
989 let _ = writeln!(out, " {line}");
990 }
991 if let Some(extra) = &extra_setup {
992 for line in extra.lines() {
993 let _ = writeln!(out, " {line}");
994 }
995 }
996 if is_streaming {
997 let _ = writeln!(out, " return {receiver}.{function_name}({args_str}).toList();");
998 } else {
999 let _ = writeln!(out, " return {receiver}.{function_name}({args_str});");
1000 }
1001 let _ = writeln!(out, " }}(), throwsA(anything));");
1002 } else if expects_error {
1003 if let Some(extra) = &extra_setup {
1005 for line in extra.lines() {
1006 let _ = writeln!(out, " {line}");
1007 }
1008 }
1009 if is_streaming {
1010 let _ = writeln!(
1011 out,
1012 " await expectLater({receiver}.{function_name}({args_str}).toList(), throwsA(anything));"
1013 );
1014 } else {
1015 let _ = writeln!(
1016 out,
1017 " await expectLater({receiver}.{function_name}({args_str}), throwsA(anything));"
1018 );
1019 }
1020 } else {
1021 for line in &setup_lines {
1022 let _ = writeln!(out, " {line}");
1023 }
1024 if let Some(extra) = &extra_setup {
1025 for line in extra.lines() {
1026 let _ = writeln!(out, " {line}");
1027 }
1028 }
1029 if is_streaming {
1030 let _ = writeln!(
1031 out,
1032 " final {result_var} = await {receiver}.{function_name}({args_str}).toList();"
1033 );
1034 } else {
1035 let _ = writeln!(
1036 out,
1037 " final {result_var} = await {receiver}.{function_name}({args_str});"
1038 );
1039 }
1040 for assertion in &fixture.assertions {
1041 if is_streaming {
1042 render_streaming_assertion_dart(out, assertion, result_var);
1043 } else {
1044 render_assertion_dart(
1045 out,
1046 assertion,
1047 result_var,
1048 result_is_simple,
1049 field_resolver,
1050 enum_fields,
1051 );
1052 }
1053 }
1054 }
1055
1056 let _ = writeln!(out, " }});");
1057 let _ = writeln!(out);
1058}
1059
1060fn dart_length_expr(field_accessor: &str, field: Option<&str>, field_resolver: &FieldResolver) -> String {
1068 let is_optional = field
1069 .map(|f| {
1070 let resolved = field_resolver.resolve(f);
1071 field_resolver.is_optional(f) || field_resolver.is_optional(resolved)
1072 })
1073 .unwrap_or(false);
1074 if is_optional {
1075 format!("{field_accessor}?.length ?? 0")
1076 } else {
1077 format!("{field_accessor}.length")
1078 }
1079}
1080
1081fn dart_format_value(val: &serde_json::Value) -> String {
1082 match val {
1083 serde_json::Value::String(s) => format!("'{}'", escape_dart(s)),
1084 serde_json::Value::Bool(b) => b.to_string(),
1085 serde_json::Value::Number(n) => n.to_string(),
1086 serde_json::Value::Null => "null".to_string(),
1087 other => format!("'{}'", escape_dart(&other.to_string())),
1088 }
1089}
1090
1091fn render_assertion_dart(
1102 out: &mut String,
1103 assertion: &Assertion,
1104 result_var: &str,
1105 result_is_simple: bool,
1106 field_resolver: &FieldResolver,
1107 enum_fields: &std::collections::HashSet<String>,
1108) {
1109 if !result_is_simple {
1113 if let Some(f) = assertion.field.as_deref() {
1114 let head = f.split("[].").next().unwrap_or(f);
1117 if !head.is_empty() && !field_resolver.is_valid_for_result(head) {
1118 let _ = writeln!(out, " // skipped: field '{f}' not available on dart result type");
1119 return;
1120 }
1121 }
1122 }
1123
1124 if let Some(f) = assertion.field.as_deref() {
1130 if !f.is_empty() && field_resolver.tagged_union_split(f).is_some() {
1131 let _ = writeln!(
1132 out,
1133 " // skipped: field '{f}' crosses a tagged-union variant boundary (not expressible in Dart)"
1134 );
1135 return;
1136 }
1137 }
1138
1139 if let Some(f) = assertion.field.as_deref() {
1141 if let Some(dot) = f.find("[].") {
1142 let resolved_full = field_resolver.resolve(f);
1147 let (array_part, elem_part) = match resolved_full.find("[].") {
1148 Some(rdot) => (&resolved_full[..rdot], &resolved_full[rdot + 3..]),
1149 None => (&f[..dot], &f[dot + 3..]),
1152 };
1153 let array_accessor = if array_part.is_empty() {
1154 result_var.to_string()
1155 } else {
1156 field_resolver.accessor(array_part, "dart", result_var)
1157 };
1158 let elem_accessor = field_to_dart_accessor(elem_part);
1159 match assertion.assertion_type.as_str() {
1160 "contains" => {
1161 if let Some(expected) = &assertion.value {
1162 let dart_val = dart_format_value(expected);
1163 let _ = writeln!(
1164 out,
1165 " expect({array_accessor}.any((e) => e.{elem_accessor}.toString().contains({dart_val})), isTrue);"
1166 );
1167 }
1168 }
1169 "contains_all" => {
1170 if let Some(values) = &assertion.values {
1171 for val in values {
1172 let dart_val = dart_format_value(val);
1173 let _ = writeln!(
1174 out,
1175 " expect({array_accessor}.any((e) => e.{elem_accessor}.toString().contains({dart_val})), isTrue);"
1176 );
1177 }
1178 }
1179 }
1180 "not_contains" => {
1181 if let Some(expected) = &assertion.value {
1182 let dart_val = dart_format_value(expected);
1183 let _ = writeln!(
1184 out,
1185 " expect({array_accessor}.any((e) => e.{elem_accessor}.toString().contains({dart_val})), isFalse);"
1186 );
1187 } else if let Some(values) = &assertion.values {
1188 for val in values {
1189 let dart_val = dart_format_value(val);
1190 let _ = writeln!(
1191 out,
1192 " expect({array_accessor}.any((e) => e.{elem_accessor}.toString().contains({dart_val})), isFalse);"
1193 );
1194 }
1195 }
1196 }
1197 "not_empty" => {
1198 let _ = writeln!(
1199 out,
1200 " expect({array_accessor}.any((e) => e.{elem_accessor}.toString().isNotEmpty), isTrue);"
1201 );
1202 }
1203 other => {
1204 let _ = writeln!(
1205 out,
1206 " // skipped: unsupported traversal assertion '{other}' on '{f}'"
1207 );
1208 }
1209 }
1210 return;
1211 }
1212 }
1213
1214 let field_accessor = if result_is_simple {
1215 result_var.to_string()
1219 } else {
1220 match assertion.field.as_deref() {
1221 Some(f) if !f.is_empty() => field_resolver.accessor(f, "dart", result_var),
1226 _ => result_var.to_string(),
1227 }
1228 };
1229
1230 let format_value = |val: &serde_json::Value| -> String { dart_format_value(val) };
1231
1232 match assertion.assertion_type.as_str() {
1233 "equals" | "field_equals" => {
1234 if let Some(expected) = &assertion.value {
1235 let dart_val = format_value(expected);
1236 let is_enum_field = assertion
1239 .field
1240 .as_deref()
1241 .map(|f| {
1242 let resolved = field_resolver.resolve(f);
1243 enum_fields.contains(f) || enum_fields.contains(resolved)
1244 })
1245 .unwrap_or(false);
1246
1247 if expected.is_string() {
1251 if is_enum_field {
1252 let _ = writeln!(
1255 out,
1256 " expect(_alefE2eText({field_accessor}).trim(), equals({dart_val}.toString().trim()));"
1257 );
1258 } else {
1259 let safe_accessor = if result_is_simple && assertion.field.is_none() {
1262 format!("({field_accessor} ?? '').toString().trim()")
1263 } else {
1264 format!("{field_accessor}.toString().trim()")
1265 };
1266 let _ = writeln!(
1267 out,
1268 " expect({safe_accessor}, equals({dart_val}.toString().trim()));"
1269 );
1270 }
1271 } else {
1272 let _ = writeln!(out, " expect({field_accessor}, equals({dart_val}));");
1273 }
1274 } else {
1275 let _ = writeln!(
1276 out,
1277 " // skipped: '{}' assertion missing value",
1278 assertion.assertion_type
1279 );
1280 }
1281 }
1282 "not_equals" => {
1283 if let Some(expected) = &assertion.value {
1284 let dart_val = format_value(expected);
1285 let is_enum_field = assertion
1287 .field
1288 .as_deref()
1289 .map(|f| {
1290 let resolved = field_resolver.resolve(f);
1291 enum_fields.contains(f) || enum_fields.contains(resolved)
1292 })
1293 .unwrap_or(false);
1294
1295 if expected.is_string() {
1296 if is_enum_field {
1297 let _ = writeln!(
1298 out,
1299 " expect(_alefE2eText({field_accessor}).trim(), isNot(equals({dart_val}.toString().trim())));"
1300 );
1301 } else {
1302 let safe_accessor = if result_is_simple && assertion.field.is_none() {
1305 format!("({field_accessor} ?? '').toString().trim()")
1306 } else {
1307 format!("{field_accessor}.toString().trim()")
1308 };
1309 let _ = writeln!(
1310 out,
1311 " expect({safe_accessor}, isNot(equals({dart_val}.toString().trim())));"
1312 );
1313 }
1314 } else {
1315 let _ = writeln!(out, " expect({field_accessor}, isNot(equals({dart_val})));");
1316 }
1317 }
1318 }
1319 "contains" => {
1320 if let Some(expected) = &assertion.value {
1321 let dart_val = format_value(expected);
1322 let _ = writeln!(out, " expect({field_accessor}, contains({dart_val}));");
1323 } else {
1324 let _ = writeln!(out, " // skipped: 'contains' assertion missing value");
1325 }
1326 }
1327 "contains_all" => {
1328 if let Some(values) = &assertion.values {
1329 for val in values {
1330 let dart_val = format_value(val);
1331 let _ = writeln!(out, " expect({field_accessor}, contains({dart_val}));");
1332 }
1333 }
1334 }
1335 "contains_any" => {
1336 if let Some(values) = &assertion.values {
1337 let checks: Vec<String> = values
1338 .iter()
1339 .map(|v| {
1340 let dart_val = format_value(v);
1341 format!("{field_accessor}.contains({dart_val})")
1342 })
1343 .collect();
1344 let joined = checks.join(" || ");
1345 let _ = writeln!(out, " expect({joined}, isTrue);");
1346 }
1347 }
1348 "not_contains" => {
1349 if let Some(expected) = &assertion.value {
1350 let dart_val = format_value(expected);
1351 let _ = writeln!(out, " expect({field_accessor}, isNot(contains({dart_val})));");
1352 } else if let Some(values) = &assertion.values {
1353 for val in values {
1354 let dart_val = format_value(val);
1355 let _ = writeln!(out, " expect({field_accessor}, isNot(contains({dart_val})));");
1356 }
1357 }
1358 }
1359 "not_empty" => {
1360 let is_collection = assertion.field.as_deref().is_some_and(|f| {
1365 let resolved = field_resolver.resolve(f);
1366 field_resolver.is_array(f) || field_resolver.is_array(resolved)
1367 });
1368 if is_collection {
1369 let _ = writeln!(out, " expect({field_accessor}, isNotEmpty);");
1370 } else {
1371 let _ = writeln!(out, " expect({field_accessor}, isNotNull);");
1372 }
1373 }
1374 "is_empty" => {
1375 let _ = writeln!(out, " expect({field_accessor}, anyOf(isNull, isEmpty));");
1379 }
1380 "starts_with" => {
1381 if let Some(expected) = &assertion.value {
1382 let dart_val = format_value(expected);
1383 let _ = writeln!(out, " expect({field_accessor}, startsWith({dart_val}));");
1384 }
1385 }
1386 "ends_with" => {
1387 if let Some(expected) = &assertion.value {
1388 let dart_val = format_value(expected);
1389 let _ = writeln!(out, " expect({field_accessor}, endsWith({dart_val}));");
1390 }
1391 }
1392 "min_length" => {
1393 if let Some(val) = &assertion.value {
1394 if let Some(n) = val.as_u64() {
1395 let length_expr = dart_length_expr(&field_accessor, assertion.field.as_deref(), field_resolver);
1396 let _ = writeln!(out, " expect({length_expr}, greaterThanOrEqualTo({n}));");
1397 }
1398 }
1399 }
1400 "max_length" => {
1401 if let Some(val) = &assertion.value {
1402 if let Some(n) = val.as_u64() {
1403 let length_expr = dart_length_expr(&field_accessor, assertion.field.as_deref(), field_resolver);
1404 let _ = writeln!(out, " expect({length_expr}, lessThanOrEqualTo({n}));");
1405 }
1406 }
1407 }
1408 "count_equals" => {
1409 if let Some(val) = &assertion.value {
1410 if let Some(n) = val.as_u64() {
1411 let length_expr = dart_length_expr(&field_accessor, assertion.field.as_deref(), field_resolver);
1412 let _ = writeln!(out, " expect({length_expr}, equals({n}));");
1413 }
1414 }
1415 }
1416 "count_min" => {
1417 if let Some(val) = &assertion.value {
1418 if let Some(n) = val.as_u64() {
1419 let length_expr = dart_length_expr(&field_accessor, assertion.field.as_deref(), field_resolver);
1420 let _ = writeln!(out, " expect({length_expr}, greaterThanOrEqualTo({n}));");
1421 }
1422 }
1423 }
1424 "matches_regex" => {
1425 if let Some(expected) = &assertion.value {
1426 let dart_val = format_value(expected);
1427 let _ = writeln!(out, " expect({field_accessor}, matches(RegExp({dart_val})));");
1428 }
1429 }
1430 "is_true" => {
1431 let _ = writeln!(out, " expect({field_accessor}, isTrue);");
1432 }
1433 "is_false" => {
1434 let _ = writeln!(out, " expect({field_accessor}, isFalse);");
1435 }
1436 "greater_than" => {
1437 if let Some(val) = &assertion.value {
1438 let dart_val = format_value(val);
1439 let _ = writeln!(out, " expect({field_accessor}, greaterThan({dart_val}));");
1440 }
1441 }
1442 "less_than" => {
1443 if let Some(val) = &assertion.value {
1444 let dart_val = format_value(val);
1445 let _ = writeln!(out, " expect({field_accessor}, lessThan({dart_val}));");
1446 }
1447 }
1448 "greater_than_or_equal" => {
1449 if let Some(val) = &assertion.value {
1450 let dart_val = format_value(val);
1451 let _ = writeln!(out, " expect({field_accessor}, greaterThanOrEqualTo({dart_val}));");
1452 }
1453 }
1454 "less_than_or_equal" => {
1455 if let Some(val) = &assertion.value {
1456 let dart_val = format_value(val);
1457 let _ = writeln!(out, " expect({field_accessor}, lessThanOrEqualTo({dart_val}));");
1458 }
1459 }
1460 "not_null" => {
1461 let _ = writeln!(out, " expect({field_accessor}, isNotNull);");
1462 }
1463 "not_error" => {
1464 }
1471 "error" => {
1472 }
1474 "method_result" => {
1475 if let Some(method) = &assertion.method {
1476 let dart_method = method.to_lower_camel_case();
1477 let check = assertion.check.as_deref().unwrap_or("not_null");
1478 let method_call = format!("{field_accessor}.{dart_method}()");
1479 match check {
1480 "equals" => {
1481 if let Some(expected) = &assertion.value {
1482 let dart_val = format_value(expected);
1483 let _ = writeln!(out, " expect({method_call}, equals({dart_val}));");
1484 }
1485 }
1486 "is_true" => {
1487 let _ = writeln!(out, " expect({method_call}, isTrue);");
1488 }
1489 "is_false" => {
1490 let _ = writeln!(out, " expect({method_call}, isFalse);");
1491 }
1492 "greater_than_or_equal" => {
1493 if let Some(val) = &assertion.value {
1494 let dart_val = format_value(val);
1495 let _ = writeln!(out, " expect({method_call}, greaterThanOrEqualTo({dart_val}));");
1496 }
1497 }
1498 "count_min" => {
1499 if let Some(val) = &assertion.value {
1500 if let Some(n) = val.as_u64() {
1501 let _ = writeln!(out, " expect({method_call}.length, greaterThanOrEqualTo({n}));");
1502 }
1503 }
1504 }
1505 _ => {
1506 let _ = writeln!(out, " expect({method_call}, isNotNull);");
1507 }
1508 }
1509 }
1510 }
1511 other => {
1512 let _ = writeln!(out, " // skipped: unknown assertion type '{other}'");
1513 }
1514 }
1515}
1516
1517fn render_streaming_assertion_dart(out: &mut String, assertion: &Assertion, result_var: &str) {
1528 match assertion.assertion_type.as_str() {
1529 "not_error" => {
1530 let _ = writeln!(out, " expect({result_var}, isNotNull);");
1534 }
1535 "count_min" if assertion.field.as_deref() == Some("chunks") => {
1536 if let Some(serde_json::Value::Number(n)) = &assertion.value {
1537 let _ = writeln!(out, " expect({result_var}.length, greaterThanOrEqualTo({n}));");
1538 }
1539 }
1540 "equals" if assertion.field.as_deref() == Some("stream_content") => {
1541 if let Some(serde_json::Value::String(expected)) = &assertion.value {
1542 let escaped = escape_dart(expected);
1543 let _ = writeln!(
1544 out,
1545 " final _content = {result_var}.map((c) => c.choices.firstOrNull?.delta.content ?? '').join();"
1546 );
1547 let _ = writeln!(out, " expect(_content, equals('{escaped}'));");
1548 }
1549 }
1550 other => {
1551 let _ = writeln!(out, " // skipped streaming assertion: '{other}'");
1552 }
1553 }
1554}
1555
1556fn snake_to_camel(s: &str) -> String {
1558 let mut result = String::with_capacity(s.len());
1559 let mut next_upper = false;
1560 for ch in s.chars() {
1561 if ch == '_' {
1562 next_upper = true;
1563 } else if next_upper {
1564 result.extend(ch.to_uppercase());
1565 next_upper = false;
1566 } else {
1567 result.push(ch);
1568 }
1569 }
1570 result
1571}
1572
1573fn field_to_dart_accessor(path: &str) -> String {
1586 let mut result = String::with_capacity(path.len());
1587 for (i, segment) in path.split('.').enumerate() {
1588 if i > 0 {
1589 result.push('.');
1590 }
1591 if let Some(bracket_pos) = segment.find('[') {
1597 let name = &segment[..bracket_pos];
1598 let bracket = &segment[bracket_pos..];
1599 result.push_str(&name.to_lower_camel_case());
1600 result.push('!');
1601 result.push_str(bracket);
1602 } else {
1603 result.push_str(&segment.to_lower_camel_case());
1604 }
1605 }
1606 result
1607}
1608
1609fn emit_extraction_config_dart(overrides: &serde_json::Map<String, serde_json::Value>) -> String {
1615 let mut field_overrides: std::collections::HashMap<String, String> = std::collections::HashMap::new();
1617 for (key, val) in overrides {
1618 let camel = snake_to_camel(key);
1619 let dart_val = match val {
1620 serde_json::Value::Bool(b) => {
1621 if *b {
1622 "true".to_string()
1623 } else {
1624 "false".to_string()
1625 }
1626 }
1627 serde_json::Value::Number(n) => n.to_string(),
1628 serde_json::Value::String(s) => format!("'{s}'"),
1629 _ => continue, };
1631 field_overrides.insert(camel, dart_val);
1632 }
1633
1634 let use_cache = field_overrides.remove("useCache").unwrap_or_else(|| "true".to_string());
1635 let enable_quality_processing = field_overrides
1636 .remove("enableQualityProcessing")
1637 .unwrap_or_else(|| "true".to_string());
1638 let force_ocr = field_overrides
1639 .remove("forceOcr")
1640 .unwrap_or_else(|| "false".to_string());
1641 let disable_ocr = field_overrides
1642 .remove("disableOcr")
1643 .unwrap_or_else(|| "false".to_string());
1644 let include_document_structure = field_overrides
1645 .remove("includeDocumentStructure")
1646 .unwrap_or_else(|| "false".to_string());
1647 let use_layout_for_markdown = field_overrides
1648 .remove("useLayoutForMarkdown")
1649 .unwrap_or_else(|| "false".to_string());
1650 let max_archive_depth = field_overrides
1651 .remove("maxArchiveDepth")
1652 .unwrap_or_else(|| "3".to_string());
1653
1654 format!(
1655 "ExtractionConfig(useCache: {use_cache}, enableQualityProcessing: {enable_quality_processing}, forceOcr: {force_ocr}, disableOcr: {disable_ocr}, resultFormat: ResultFormat.unified, outputFormat: OutputFormat.plain(), includeDocumentStructure: {include_document_structure}, useLayoutForMarkdown: {use_layout_for_markdown}, maxArchiveDepth: {max_archive_depth})"
1656 )
1657}
1658
1659struct DartTestClientRenderer {
1675 in_skip: Cell<bool>,
1678 is_redirect: Cell<bool>,
1681}
1682
1683impl DartTestClientRenderer {
1684 fn new(is_redirect: bool) -> Self {
1685 Self {
1686 in_skip: Cell::new(false),
1687 is_redirect: Cell::new(is_redirect),
1688 }
1689 }
1690}
1691
1692impl client::TestClientRenderer for DartTestClientRenderer {
1693 fn language_name(&self) -> &'static str {
1694 "dart"
1695 }
1696
1697 fn render_test_open(&self, out: &mut String, _fn_name: &str, description: &str, skip_reason: Option<&str>) {
1706 let escaped_desc = escape_dart(description);
1707 if let Some(reason) = skip_reason {
1708 let escaped_reason = escape_dart(reason);
1709 let _ = writeln!(out, " test('{escaped_desc}', () {{");
1710 let _ = writeln!(out, " markTestSkipped('{escaped_reason}');");
1711 let _ = writeln!(out, " }});");
1712 let _ = writeln!(out);
1713 self.in_skip.set(true);
1714 } else {
1715 let _ = writeln!(
1716 out,
1717 " test('{escaped_desc}', () => _serialized(() => _withRetry(() async {{"
1718 );
1719 self.in_skip.set(false);
1720 }
1721 }
1722
1723 fn render_test_close(&self, out: &mut String) {
1728 if self.in_skip.get() {
1729 return;
1731 }
1732 let _ = writeln!(out, " }})));");
1733 let _ = writeln!(out);
1734 }
1735
1736 fn render_call(&self, out: &mut String, ctx: &client::CallCtx<'_>) {
1746 const DART_RESTRICTED_HEADERS: &[&str] = &["content-length", "host", "transfer-encoding"];
1748
1749 let method = ctx.method.to_uppercase();
1750 let escaped_method = escape_dart(&method);
1751
1752 let fixture_path = escape_dart(ctx.path);
1754
1755 let has_explicit_content_type = ctx.headers.keys().any(|k| k.to_lowercase() == "content-type");
1757 let effective_content_type = if has_explicit_content_type {
1758 ctx.headers
1759 .iter()
1760 .find(|(k, _)| k.to_lowercase() == "content-type")
1761 .map(|(_, v)| v.as_str())
1762 .unwrap_or("application/json")
1763 } else if ctx.body.is_some() {
1764 ctx.content_type.unwrap_or("application/json")
1765 } else {
1766 ""
1767 };
1768
1769 let _ = writeln!(
1770 out,
1771 " final baseUrl = Platform.environment['MOCK_SERVER_URL'] ?? 'http://localhost:8080';"
1772 );
1773 let _ = writeln!(out, " final uri = Uri.parse('$baseUrl{fixture_path}');");
1774 let _ = writeln!(
1775 out,
1776 " final ioReq = await _httpClient.openUrl('{escaped_method}', uri);"
1777 );
1778
1779 if self.is_redirect.get() {
1782 let _ = writeln!(out, " ioReq.followRedirects = false;");
1783 }
1784
1785 if !effective_content_type.is_empty() {
1787 let escaped_ct = escape_dart(effective_content_type);
1788 let _ = writeln!(out, " ioReq.headers.set('content-type', '{escaped_ct}');");
1789 }
1790
1791 let mut header_pairs: Vec<(&String, &String)> = ctx.headers.iter().collect();
1793 header_pairs.sort_by_key(|(k, _)| k.as_str());
1794 for (name, value) in &header_pairs {
1795 if DART_RESTRICTED_HEADERS.contains(&name.to_lowercase().as_str()) {
1796 continue;
1797 }
1798 if name.to_lowercase() == "content-type" {
1799 continue; }
1801 let escaped_name = escape_dart(&name.to_lowercase());
1802 let escaped_value = escape_dart(value);
1803 let _ = writeln!(out, " ioReq.headers.set('{escaped_name}', '{escaped_value}');");
1804 }
1805
1806 if !ctx.cookies.is_empty() {
1808 let mut cookie_pairs: Vec<(&String, &String)> = ctx.cookies.iter().collect();
1809 cookie_pairs.sort_by_key(|(k, _)| k.as_str());
1810 let cookie_str: Vec<String> = cookie_pairs.iter().map(|(k, v)| format!("{k}={v}")).collect();
1811 let cookie_header = escape_dart(&cookie_str.join("; "));
1812 let _ = writeln!(out, " ioReq.headers.set('cookie', '{cookie_header}');");
1813 }
1814
1815 if let Some(body) = ctx.body {
1817 let json_str = serde_json::to_string(body).unwrap_or_default();
1818 let escaped = escape_dart(&json_str);
1819 let _ = writeln!(out, " final bodyBytes = utf8.encode('{escaped}');");
1820 let _ = writeln!(out, " ioReq.add(bodyBytes);");
1821 }
1822
1823 let _ = writeln!(out, " final ioResp = await ioReq.close();");
1824 if !self.is_redirect.get() {
1828 let _ = writeln!(out, " final bodyStr = await ioResp.transform(utf8.decoder).join();");
1829 };
1830 }
1831
1832 fn render_assert_status(&self, out: &mut String, _response_var: &str, status: u16) {
1833 let _ = writeln!(
1834 out,
1835 " expect(ioResp.statusCode, equals({status}), reason: 'status code mismatch');"
1836 );
1837 }
1838
1839 fn render_assert_header(&self, out: &mut String, _response_var: &str, name: &str, expected: &str) {
1842 let escaped_name = escape_dart(&name.to_lowercase());
1843 match expected {
1844 "<<present>>" => {
1845 let _ = writeln!(
1846 out,
1847 " expect(ioResp.headers.value('{escaped_name}'), isNotNull, reason: 'header {escaped_name} should be present');"
1848 );
1849 }
1850 "<<absent>>" => {
1851 let _ = writeln!(
1852 out,
1853 " expect(ioResp.headers.value('{escaped_name}'), isNull, reason: 'header {escaped_name} should be absent');"
1854 );
1855 }
1856 "<<uuid>>" => {
1857 let _ = writeln!(
1858 out,
1859 " expect(ioResp.headers.value('{escaped_name}'), matches(RegExp(r'^[0-9a-f]{{8}}-[0-9a-f]{{4}}-[0-9a-f]{{4}}-[0-9a-f]{{4}}-[0-9a-f]{{12}}$')), reason: 'header {escaped_name} should be a UUID');"
1860 );
1861 }
1862 exact => {
1863 let escaped_value = escape_dart(exact);
1864 let _ = writeln!(
1865 out,
1866 " expect(ioResp.headers.value('{escaped_name}'), contains('{escaped_value}'), reason: 'header {escaped_name} mismatch');"
1867 );
1868 }
1869 }
1870 }
1871
1872 fn render_assert_json_body(&self, out: &mut String, _response_var: &str, expected: &serde_json::Value) {
1877 match expected {
1878 serde_json::Value::Object(_) | serde_json::Value::Array(_) => {
1879 let json_str = serde_json::to_string(expected).unwrap_or_default();
1880 let escaped = escape_dart(&json_str);
1881 let _ = writeln!(out, " final bodyJson = jsonDecode(bodyStr);");
1882 let _ = writeln!(out, " final expectedJson = jsonDecode('{escaped}');");
1883 let _ = writeln!(
1884 out,
1885 " expect(bodyJson, equals(expectedJson), reason: 'body mismatch');"
1886 );
1887 }
1888 serde_json::Value::String(s) => {
1889 let escaped = escape_dart(s);
1890 let _ = writeln!(
1891 out,
1892 " expect(bodyStr.trim(), equals('{escaped}'), reason: 'body mismatch');"
1893 );
1894 }
1895 other => {
1896 let escaped = escape_dart(&other.to_string());
1897 let _ = writeln!(
1898 out,
1899 " expect(bodyStr.trim(), equals('{escaped}'), reason: 'body mismatch');"
1900 );
1901 }
1902 }
1903 }
1904
1905 fn render_assert_partial_body(&self, out: &mut String, _response_var: &str, expected: &serde_json::Value) {
1908 let _ = writeln!(
1909 out,
1910 " final partialJson = jsonDecode(bodyStr) as Map<String, dynamic>;"
1911 );
1912 if let Some(obj) = expected.as_object() {
1913 for (idx, (key, val)) in obj.iter().enumerate() {
1914 let escaped_key = escape_dart(key);
1915 let json_val = serde_json::to_string(val).unwrap_or_default();
1916 let escaped_val = escape_dart(&json_val);
1917 let _ = writeln!(out, " final _expectedField{idx} = jsonDecode('{escaped_val}');");
1920 let _ = writeln!(
1921 out,
1922 " expect(partialJson['{escaped_key}'], equals(_expectedField{idx}), reason: 'partial body field \\'{escaped_key}\\' mismatch');"
1923 );
1924 }
1925 }
1926 }
1927
1928 fn render_assert_validation_errors(
1930 &self,
1931 out: &mut String,
1932 _response_var: &str,
1933 errors: &[ValidationErrorExpectation],
1934 ) {
1935 let _ = writeln!(out, " final errBody = jsonDecode(bodyStr) as Map<String, dynamic>;");
1936 let _ = writeln!(out, " final errList = (errBody['errors'] ?? []) as List<dynamic>;");
1937 for ve in errors {
1938 let loc_dart: Vec<String> = ve.loc.iter().map(|s| format!("'{}'", escape_dart(s))).collect();
1939 let loc_str = loc_dart.join(", ");
1940 let escaped_msg = escape_dart(&ve.msg);
1941 let _ = writeln!(
1942 out,
1943 " expect(errList.any((e) => e is Map && (e['loc'] as List?)?.join(',') == [{loc_str}].join(',') && (e['msg'] as String? ?? '').contains('{escaped_msg}')), isTrue, reason: 'validation error not found: {escaped_msg}');"
1944 );
1945 }
1946 }
1947}
1948
1949fn render_http_test_case(out: &mut String, fixture: &Fixture, http: &HttpFixture) {
1956 if http.expected_response.status_code == 101 {
1958 let description = escape_dart(&fixture.description);
1959 let _ = writeln!(out, " test('{description}', () {{");
1960 let _ = writeln!(
1961 out,
1962 " markTestSkipped('Skipped: Dart HttpClient cannot handle 101 Switching Protocols responses');"
1963 );
1964 let _ = writeln!(out, " }});");
1965 let _ = writeln!(out);
1966 return;
1967 }
1968
1969 let is_redirect = http.expected_response.status_code / 100 == 3;
1973 client::http_call::render_http_test(out, &DartTestClientRenderer::new(is_redirect), fixture);
1974}
1975
1976fn mime_from_extension(path: &str) -> Option<&'static str> {
1981 let ext = path.rsplit('.').next()?;
1982 match ext.to_lowercase().as_str() {
1983 "docx" => Some("application/vnd.openxmlformats-officedocument.wordprocessingml.document"),
1984 "xlsx" => Some("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"),
1985 "pptx" => Some("application/vnd.openxmlformats-officedocument.presentationml.presentation"),
1986 "pdf" => Some("application/pdf"),
1987 "txt" | "text" => Some("text/plain"),
1988 "html" | "htm" => Some("text/html"),
1989 "json" => Some("application/json"),
1990 "xml" => Some("application/xml"),
1991 "csv" => Some("text/csv"),
1992 "md" | "markdown" => Some("text/markdown"),
1993 "png" => Some("image/png"),
1994 "jpg" | "jpeg" => Some("image/jpeg"),
1995 "gif" => Some("image/gif"),
1996 "zip" => Some("application/zip"),
1997 "odt" => Some("application/vnd.oasis.opendocument.text"),
1998 "ods" => Some("application/vnd.oasis.opendocument.spreadsheet"),
1999 "odp" => Some("application/vnd.oasis.opendocument.presentation"),
2000 "rtf" => Some("application/rtf"),
2001 "epub" => Some("application/epub+zip"),
2002 "msg" => Some("application/vnd.ms-outlook"),
2003 "eml" => Some("message/rfc822"),
2004 _ => None,
2005 }
2006}
2007
2008fn emit_dart_batch_item_array(arr: &serde_json::Value, elem_type: &str) -> String {
2015 let items: Vec<String> = arr
2016 .as_array()
2017 .map(|a| a.as_slice())
2018 .unwrap_or_default()
2019 .iter()
2020 .filter_map(|item| {
2021 let obj = item.as_object()?;
2022 match elem_type {
2023 "BatchBytesItem" => {
2024 let content_bytes = obj
2025 .get("content")
2026 .and_then(|v| v.as_array())
2027 .map(|arr| {
2028 let nums: Vec<String> =
2029 arr.iter().filter_map(|v| v.as_u64().map(|n| n.to_string())).collect();
2030 format!("Uint8List.fromList([{}])", nums.join(", "))
2031 })
2032 .unwrap_or_else(|| "Uint8List(0)".to_string());
2033 let mime_type = obj
2034 .get("mime_type")
2035 .and_then(|v| v.as_str())
2036 .unwrap_or("application/octet-stream");
2037 Some(format!(
2038 "BatchBytesItem(content: {content_bytes}, mimeType: '{}')",
2039 escape_dart(mime_type)
2040 ))
2041 }
2042 "BatchFileItem" => {
2043 let path = obj.get("path").and_then(|v| v.as_str()).unwrap_or("");
2044 Some(format!("BatchFileItem(path: '{}')", escape_dart(path)))
2045 }
2046 _ => None,
2047 }
2048 })
2049 .collect();
2050 format!("[{}]", items.join(", "))
2051}
2052
2053pub(super) fn escape_dart(s: &str) -> String {
2055 s.replace('\\', "\\\\")
2056 .replace('\'', "\\'")
2057 .replace('\n', "\\n")
2058 .replace('\r', "\\r")
2059 .replace('\t', "\\t")
2060 .replace('$', "\\$")
2061}
2062
2063fn type_name_to_create_from_json_dart(type_name: &str) -> String {
2071 let mut snake = String::with_capacity(type_name.len() + 8);
2073 for (i, ch) in type_name.char_indices() {
2074 if ch.is_uppercase() {
2075 if i > 0 {
2076 snake.push('_');
2077 }
2078 snake.extend(ch.to_lowercase());
2079 } else {
2080 snake.push(ch);
2081 }
2082 }
2083 let rust_fn = format!("create_{snake}_from_json");
2086 rust_fn
2088 .split('_')
2089 .enumerate()
2090 .map(|(i, part)| {
2091 if i == 0 {
2092 part.to_string()
2093 } else {
2094 let mut chars = part.chars();
2095 match chars.next() {
2096 None => String::new(),
2097 Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
2098 }
2099 }
2100 })
2101 .collect::<Vec<_>>()
2102 .join("")
2103}