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 format!(" {pkg_name}: ^{pkg_version}")
170 }
171 crate::config::DependencyMode::Local => {
172 format!(" {pkg_name}:\n path: {pkg_path}")
173 }
174 };
175
176 let sdk = alef_core::template_versions::toolchain::DART_SDK_CONSTRAINT;
177 format!(
178 r#"name: e2e_dart
179version: 0.1.0
180publish_to: none
181
182environment:
183 sdk: "{sdk}"
184
185dependencies:
186{dep_block}
187
188dev_dependencies:
189 test: {test_ver}
190 http: {http_ver}
191"#
192 )
193}
194
195fn render_test_file(
196 category: &str,
197 fixtures: &[&Fixture],
198 e2e_config: &E2eConfig,
199 lang: &str,
200 pkg_name: &str,
201 frb_module_name: &str,
202 bridge_class: &str,
203) -> String {
204 let mut out = String::new();
205 out.push_str(&hash::header(CommentStyle::DoubleSlash));
206 out.push_str("// ignore_for_file: unused_local_variable\n\n");
210
211 let has_http_fixtures = fixtures.iter().any(|f| f.is_http_test());
213
214 let has_batch_byte_items = fixtures.iter().any(|f| {
216 let call_config =
217 e2e_config.resolve_call_for_fixture(f.call.as_deref(), &f.id, &f.resolved_category(), &f.tags, &f.input);
218 call_config.args.iter().any(|a| {
219 a.element_type.as_deref() == Some("BatchBytesItem") && resolve_field(&f.input, &a.field).is_array()
220 })
221 });
222
223 let needs_chdir = fixtures.iter().any(|f| {
227 if f.is_http_test() {
228 return false;
229 }
230 let call_config =
231 e2e_config.resolve_call_for_fixture(f.call.as_deref(), &f.id, &f.resolved_category(), &f.tags, &f.input);
232 call_config
233 .args
234 .iter()
235 .any(|a| a.arg_type == "file_path" || a.arg_type == "bytes")
236 });
237
238 let has_handle_args = fixtures.iter().any(|f| {
244 if f.is_http_test() {
245 return false;
246 }
247 let call_config =
248 e2e_config.resolve_call_for_fixture(f.call.as_deref(), &f.id, &f.resolved_category(), &f.tags, &f.input);
249 call_config
250 .args
251 .iter()
252 .any(|a| a.arg_type == "json_object" && super::resolve_field(&f.input, &a.field).is_array())
253 });
254
255 let lang_client_factory = e2e_config
261 .call
262 .overrides
263 .get(lang)
264 .and_then(|o| o.client_factory.as_deref())
265 .is_some();
266 let has_mock_url_refs = lang_client_factory
267 || fixtures.iter().any(|f| {
268 if f.is_http_test() {
269 return false;
270 }
271 let call_config = e2e_config.resolve_call_for_fixture(
272 f.call.as_deref(),
273 &f.id,
274 &f.resolved_category(),
275 &f.tags,
276 &f.input,
277 );
278 if call_config.args.iter().any(|a| a.arg_type == "mock_url") {
279 return true;
280 }
281 call_config
282 .overrides
283 .get(lang)
284 .and_then(|o| o.client_factory.as_deref())
285 .is_some()
286 });
287
288 let _ = writeln!(out, "import 'package:test/test.dart';");
289 if has_http_fixtures || needs_chdir || has_mock_url_refs {
294 let _ = writeln!(out, "import 'dart:io';");
295 }
296 if has_batch_byte_items {
297 let _ = writeln!(out, "import 'dart:typed_data';");
298 }
299 let _ = writeln!(out, "import 'package:{pkg_name}/{pkg_name}.dart';");
300 let _ = writeln!(
306 out,
307 "import 'package:{pkg_name}/src/{frb_module_name}_bridge_generated/frb_generated.dart' show RustLib;"
308 );
309 if has_http_fixtures {
310 let _ = writeln!(out, "import 'dart:async';");
311 }
312 if has_http_fixtures || has_handle_args {
314 let _ = writeln!(out, "import 'dart:convert';");
315 }
316 let _ = writeln!(out);
317
318 if has_http_fixtures {
328 let _ = writeln!(out, "HttpClient _httpClient = HttpClient()..maxConnectionsPerHost = 1;");
329 let _ = writeln!(out);
330 let _ = writeln!(out, "var _lock = Future<void>.value();");
331 let _ = writeln!(out);
332 let _ = writeln!(out, "Future<T> _serialized<T>(Future<T> Function() fn) async {{");
333 let _ = writeln!(out, " final current = _lock;");
334 let _ = writeln!(out, " final next = Completer<void>();");
335 let _ = writeln!(out, " _lock = next.future;");
336 let _ = writeln!(out, " try {{");
337 let _ = writeln!(out, " await current;");
338 let _ = writeln!(out, " return await fn();");
339 let _ = writeln!(out, " }} finally {{");
340 let _ = writeln!(out, " next.complete();");
341 let _ = writeln!(out, " }}");
342 let _ = writeln!(out, "}}");
343 let _ = writeln!(out);
344 let _ = writeln!(out, "Future<T> _withRetry<T>(Future<T> Function() fn) async {{");
347 let _ = writeln!(out, " try {{");
348 let _ = writeln!(out, " return await fn();");
349 let _ = writeln!(out, " }} on SocketException {{");
350 let _ = writeln!(out, " _httpClient.close(force: true);");
351 let _ = writeln!(out, " _httpClient = HttpClient()..maxConnectionsPerHost = 1;");
352 let _ = writeln!(out, " return fn();");
353 let _ = writeln!(out, " }} on HttpException {{");
354 let _ = writeln!(out, " _httpClient.close(force: true);");
355 let _ = writeln!(out, " _httpClient = HttpClient()..maxConnectionsPerHost = 1;");
356 let _ = writeln!(out, " return fn();");
357 let _ = writeln!(out, " }}");
358 let _ = writeln!(out, "}}");
359 let _ = writeln!(out);
360 }
361
362 let _ = writeln!(out, "// E2e tests for category: {category}");
363 let _ = writeln!(out);
364
365 let _ = writeln!(out, "String _alefE2eText(Object? value) {{");
371 let _ = writeln!(out, " if (value == null) return '';");
372 let _ = writeln!(
373 out,
374 " // Check if it's an enum by examining its toString representation."
375 );
376 let _ = writeln!(out, " final str = value.toString();");
377 let _ = writeln!(out, " if (str.contains('.')) {{");
378 let _ = writeln!(
379 out,
380 " // Enum.toString() returns 'EnumName.variantName'. Extract the variant name."
381 );
382 let _ = writeln!(out, " final parts = str.split('.');");
383 let _ = writeln!(out, " if (parts.length == 2) {{");
384 let _ = writeln!(out, " final variantName = parts[1];");
385 let _ = writeln!(
386 out,
387 " // Convert camelCase variant names to snake_case for serde compatibility."
388 );
389 let _ = writeln!(out, " // E.g. 'toolCalls' -> 'tool_calls', 'stop' -> 'stop'.");
390 let _ = writeln!(out, " return _camelToSnake(variantName);");
391 let _ = writeln!(out, " }}");
392 let _ = writeln!(out, " }}");
393 let _ = writeln!(out, " return str;");
394 let _ = writeln!(out, "}}");
395 let _ = writeln!(out);
396
397 let _ = writeln!(out, "String _camelToSnake(String camel) {{");
399 let _ = writeln!(out, " final buffer = StringBuffer();");
400 let _ = writeln!(out, " for (int i = 0; i < camel.length; i++) {{");
401 let _ = writeln!(out, " final char = camel[i];");
402 let _ = writeln!(out, " if (char.contains(RegExp(r'[A-Z]'))) {{");
403 let _ = writeln!(out, " if (i > 0) buffer.write('_');");
404 let _ = writeln!(out, " buffer.write(char.toLowerCase());");
405 let _ = writeln!(out, " }} else {{");
406 let _ = writeln!(out, " buffer.write(char);");
407 let _ = writeln!(out, " }}");
408 let _ = writeln!(out, " }}");
409 let _ = writeln!(out, " return buffer.toString();");
410 let _ = writeln!(out, "}}");
411 let _ = writeln!(out);
412
413 let _ = writeln!(out, "void main() {{");
414
415 let _ = writeln!(out, " setUpAll(() async {{");
422 let _ = writeln!(out, " await RustLib.init();");
423 if needs_chdir {
424 let test_docs_path = e2e_config.test_documents_relative_from(0);
425 let _ = writeln!(
426 out,
427 " final _testDocs = Platform.environment['FIXTURES_DIR'] ?? '{test_docs_path}';"
428 );
429 let _ = writeln!(out, " final _dir = Directory(_testDocs);");
430 let _ = writeln!(out, " if (_dir.existsSync()) Directory.current = _dir;");
431 }
432 let _ = writeln!(out, " }});");
433 let _ = writeln!(out);
434
435 if has_http_fixtures {
437 let _ = writeln!(out, " tearDownAll(() => _httpClient.close());");
438 let _ = writeln!(out);
439 }
440
441 for fixture in fixtures {
442 render_test_case(&mut out, fixture, e2e_config, lang, bridge_class);
443 }
444
445 let _ = writeln!(out, "}}");
446 out
447}
448
449fn render_test_case(out: &mut String, fixture: &Fixture, e2e_config: &E2eConfig, lang: &str, bridge_class: &str) {
450 if let Some(http) = &fixture.http {
452 render_http_test_case(out, fixture, http);
453 return;
454 }
455
456 let call_config = e2e_config.resolve_call_for_fixture(
458 fixture.call.as_deref(),
459 &fixture.id,
460 &fixture.resolved_category(),
461 &fixture.tags,
462 &fixture.input,
463 );
464 let call_field_resolver = FieldResolver::new(
466 e2e_config.effective_fields(call_config),
467 e2e_config.effective_fields_optional(call_config),
468 e2e_config.effective_result_fields(call_config),
469 e2e_config.effective_fields_array(call_config),
470 e2e_config.effective_fields_method_calls(call_config),
471 );
472 let field_resolver = &call_field_resolver;
473 let enum_fields_base = e2e_config.effective_fields_enum(call_config);
474
475 let effective_enum_fields: std::collections::HashSet<String> = {
480 let dart_overrides = call_config.overrides.get("dart");
481 if let Some(overrides) = dart_overrides {
482 let mut merged = enum_fields_base.clone();
483 merged.extend(overrides.enum_fields.keys().cloned());
484 merged
485 } else {
486 enum_fields_base.clone()
487 }
488 };
489 let enum_fields = &effective_enum_fields;
490 let call_overrides = call_config.overrides.get(lang);
491 let mut function_name = call_overrides
492 .and_then(|o| o.function.as_ref())
493 .cloned()
494 .unwrap_or_else(|| call_config.function.clone());
495 function_name = function_name
497 .split('_')
498 .enumerate()
499 .map(|(i, part)| {
500 if i == 0 {
501 part.to_string()
502 } else {
503 let mut chars = part.chars();
504 match chars.next() {
505 None => String::new(),
506 Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
507 }
508 }
509 })
510 .collect::<Vec<_>>()
511 .join("");
512 let result_var = &call_config.result_var;
513 let description = escape_dart(&fixture.description);
514 let fixture_id = &fixture.id;
515 let _is_async = call_overrides.and_then(|o| o.r#async).unwrap_or(call_config.r#async);
518
519 let expects_error = fixture.assertions.iter().any(|a| a.assertion_type == "error");
520 let is_streaming = crate::codegen::streaming_assertions::resolve_is_streaming(fixture, call_config.streaming);
521 let result_is_simple = call_overrides.is_some_and(|o| o.result_is_simple) || call_config.result_is_simple;
526
527 let options_type: Option<&str> = call_overrides.and_then(|o| o.options_type.as_deref());
534 let options_via: &str = call_overrides
535 .and_then(|o| o.options_via.as_deref())
536 .unwrap_or("kwargs");
537
538 let file_path_for_mime: Option<&str> = call_config
546 .args
547 .iter()
548 .find(|a| a.arg_type == "file_path")
549 .and_then(|a| resolve_field(&fixture.input, &a.field).as_str());
550
551 let has_file_path_arg = call_config.args.iter().any(|a| a.arg_type == "file_path");
558 let caller_supplied_override = call_overrides.and_then(|o| o.function.as_ref()).is_some();
561 if has_file_path_arg && !caller_supplied_override {
562 function_name = match function_name.as_str() {
563 "extractFile" => "extractBytes".to_string(),
564 "extractFileSync" => "extractBytesSync".to_string(),
565 other => other.to_string(),
566 };
567 }
568
569 let mut setup_lines: Vec<String> = Vec::new();
572 let mut args = Vec::new();
573
574 for arg_def in &call_config.args {
575 match arg_def.arg_type.as_str() {
576 "mock_url" => {
577 let name = arg_def.name.clone();
578 if fixture.has_host_root_route() {
579 let env_key = format!("MOCK_SERVER_{}", fixture_id.to_uppercase());
580 setup_lines.push(format!(
581 r#"final {name} = Platform.environment["{env_key}"] ?? (Platform.environment["MOCK_SERVER_URL"]! + "/fixtures/{fixture_id}");"#
582 ));
583 } else {
584 setup_lines.push(format!(
585 r#"final {name} = "${{Platform.environment["MOCK_SERVER_URL"] ?? "http://localhost:8080"}}/fixtures/{fixture_id}";"#
586 ));
587 }
588 args.push(name);
589 continue;
590 }
591 "handle" => {
592 let name = arg_def.name.clone();
593 let field = arg_def.field.strip_prefix("input.").unwrap_or(&arg_def.field);
594 let config_value = fixture.input.get(field).cloned().unwrap_or(serde_json::Value::Null);
595 let create_fn = {
597 let mut chars = name.chars();
598 let pascal = match chars.next() {
599 None => String::new(),
600 Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
601 };
602 format!("create{pascal}")
603 };
604 if config_value.is_null()
605 || config_value.is_object() && config_value.as_object().is_some_and(|o| o.is_empty())
606 {
607 setup_lines.push(format!("final {name} = await {bridge_class}.{create_fn}();"));
608 } else {
609 let json_str = serde_json::to_string(&config_value).unwrap_or_default();
610 let config_var = format!("{name}Config");
611 setup_lines.push(format!(
616 "final {config_var} = await createCrawlConfigFromJson(json: r'{json_str}');"
617 ));
618 setup_lines.push(format!(
620 "final {name} = await {bridge_class}.{create_fn}(config: {config_var});"
621 ));
622 }
623 args.push(name);
624 continue;
625 }
626 _ => {}
627 }
628
629 let arg_value = resolve_field(&fixture.input, &arg_def.field);
630 match arg_def.arg_type.as_str() {
631 "bytes" | "file_path" => {
632 if let serde_json::Value::String(file_path) = arg_value {
637 args.push(format!("File('{}').readAsBytesSync()", file_path));
638 }
639 }
640 "string" => {
641 let dart_param_name = snake_to_camel(&arg_def.name);
656 match arg_value {
657 serde_json::Value::String(s) => {
658 let literal = format!("'{}'", escape_dart(s));
659 if arg_def.optional {
660 args.push(format!("{dart_param_name}: {literal}"));
661 } else {
662 args.push(literal);
663 }
664 }
665 serde_json::Value::Null
666 if arg_def.optional
667 && arg_def.name == "mime_type" =>
670 {
671 let inferred = file_path_for_mime
672 .and_then(mime_from_extension)
673 .unwrap_or("application/octet-stream");
674 args.push(format!("{dart_param_name}: '{inferred}'"));
675 }
676 _ => {}
678 }
679 }
680 "json_object" => {
681 if let Some(elem_type) = &arg_def.element_type {
683 if (elem_type == "BatchBytesItem" || elem_type == "BatchFileItem") && arg_value.is_array() {
684 let dart_items = emit_dart_batch_item_array(arg_value, elem_type);
685 args.push(dart_items);
686 } else if elem_type == "String" && arg_value.is_array() {
687 let items: Vec<String> = arg_value
694 .as_array()
695 .unwrap()
696 .iter()
697 .filter_map(|v| v.as_str())
698 .map(|s| format!("'{}'", escape_dart(s)))
699 .collect();
700 args.push(format!("<String>[{}]", items.join(", ")));
701 }
702 } else if options_via == "from_json" {
703 if let Some(opts_type) = options_type {
713 if !arg_value.is_null() {
714 let json_str = serde_json::to_string(&arg_value).unwrap_or_default();
715 let escaped_json = escape_dart(&json_str);
718 let var_name = format!("_{}", arg_def.name);
719 let dart_fn = type_name_to_create_from_json_dart(opts_type);
720 setup_lines.push(format!("final {var_name} = await {dart_fn}(json: '{escaped_json}');"));
721 args.push(format!("req: {var_name}"));
724 }
725 }
726 } else if arg_def.name == "config" {
727 if let serde_json::Value::Object(map) = &arg_value {
728 if !map.is_empty() {
729 let explicit_options =
738 options_type.is_some_and(|t| t != "ExtractionConfig" && t != "FileExtractionConfig");
739 let has_non_scalar = map.values().any(|v| {
740 matches!(
741 v,
742 serde_json::Value::String(_)
743 | serde_json::Value::Object(_)
744 | serde_json::Value::Array(_)
745 )
746 });
747 if explicit_options || has_non_scalar {
748 let opts_type = options_type.unwrap_or("ExtractionConfig");
749 let json_str = serde_json::to_string(&arg_value).unwrap_or_default();
750 let escaped_json = escape_dart(&json_str);
751 let var_name = format!("_{}", arg_def.name);
752 let dart_fn = type_name_to_create_from_json_dart(opts_type);
753 setup_lines
754 .push(format!("final {var_name} = await {dart_fn}(json: '{escaped_json}');"));
755 args.push(var_name);
756 } else {
757 args.push(emit_extraction_config_dart(map));
763 }
764 }
765 }
766 } else if arg_value.is_array() {
768 let json_str = serde_json::to_string(&arg_value).unwrap_or_default();
771 let var_name = arg_def.name.clone();
772 setup_lines.push(format!(
773 "final {var_name} = (jsonDecode(r'{json_str}') as List<dynamic>).cast<String>();"
774 ));
775 args.push(var_name);
776 } else if let serde_json::Value::Object(map) = &arg_value {
777 if !map.is_empty() {
791 if let Some(opts_type) = options_type {
792 let json_str = serde_json::to_string(&arg_value).unwrap_or_default();
793 let escaped_json = escape_dart(&json_str);
794 let dart_param_name = snake_to_camel(&arg_def.name);
795 let var_name = format!("_{}", arg_def.name);
796 let dart_fn = type_name_to_create_from_json_dart(opts_type);
797 if fixture.visitor.is_some() {
798 setup_lines.push(format!(
799 "final {var_name} = await {dart_fn}WithVisitor(json: '{escaped_json}', visitor: _visitor);"
800 ));
801 } else {
802 setup_lines
803 .push(format!("final {var_name} = await {dart_fn}(json: '{escaped_json}');"));
804 }
805 if arg_def.optional {
806 args.push(format!("{dart_param_name}: {var_name}"));
807 } else {
808 args.push(var_name);
809 }
810 }
811 }
812 }
813 }
814 _ => {}
815 }
816 }
817
818 if let Some(visitor_spec) = &fixture.visitor {
833 let mut visitor_setup: Vec<String> = Vec::new();
834 let _ = super::dart_visitors::build_dart_visitor(&mut visitor_setup, visitor_spec);
835 for line in visitor_setup.into_iter().rev() {
838 setup_lines.insert(0, line);
839 }
840
841 let already_has_options = args.iter().any(|a| a.starts_with("options:") || a == "_options");
845 if !already_has_options {
846 if let Some(opts_type) = options_type {
847 let dart_fn = type_name_to_create_from_json_dart(opts_type);
848 setup_lines.push(format!(
849 "final _options = await {dart_fn}WithVisitor(json: '{{}}', visitor: _visitor);"
850 ));
851 args.push("options: _options".to_string());
852 }
853 }
854 }
855
856 let client_factory: Option<&str> = call_overrides.and_then(|o| o.client_factory.as_deref()).or_else(|| {
860 e2e_config
861 .call
862 .overrides
863 .get(lang)
864 .and_then(|o| o.client_factory.as_deref())
865 });
866
867 let client_factory_camel: Option<String> = client_factory.map(|f| {
869 f.split('_')
870 .enumerate()
871 .map(|(i, part)| {
872 if i == 0 {
873 part.to_string()
874 } else {
875 let mut chars = part.chars();
876 match chars.next() {
877 None => String::new(),
878 Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
879 }
880 }
881 })
882 .collect::<Vec<_>>()
883 .join("")
884 });
885
886 let _ = writeln!(out, " test('{description}', () async {{");
890
891 let args_str = args.join(", ");
892 let receiver_class = call_overrides
893 .and_then(|o| o.class.as_ref())
894 .cloned()
895 .unwrap_or_else(|| bridge_class.to_string());
896
897 let (receiver, extra_setup): (String, Option<String>) = if let Some(factory) = &client_factory_camel {
901 let has_mock_url = call_config.args.iter().any(|a| a.arg_type == "mock_url");
902 let mock_url_setup = if !has_mock_url {
903 if fixture.has_host_root_route() {
905 let env_key = format!("MOCK_SERVER_{}", fixture_id.to_uppercase());
906 Some(format!(
907 "final _mockUrl = Platform.environment[\"{env_key}\"] ?? (Platform.environment[\"MOCK_SERVER_URL\"]! + \"/fixtures/{fixture_id}\");"
908 ))
909 } else {
910 Some(format!(
911 r#"final _mockUrl = "${{Platform.environment["MOCK_SERVER_URL"] ?? "http://localhost:8080"}}/fixtures/{fixture_id}";"#
912 ))
913 }
914 } else {
915 None
916 };
917 let url_expr = if has_mock_url {
918 call_config
921 .args
922 .iter()
923 .find(|a| a.arg_type == "mock_url")
924 .map(|a| a.name.clone())
925 .unwrap_or_else(|| "_mockUrl".to_string())
926 } else {
927 "_mockUrl".to_string()
928 };
929 let create_line = format!("final _client = await {receiver_class}.{factory}('test-key', baseUrl: {url_expr});");
930 let full_setup = if let Some(url_line) = mock_url_setup {
931 Some(format!("{url_line}\n {create_line}"))
932 } else {
933 Some(create_line)
934 };
935 ("_client".to_string(), full_setup)
936 } else {
937 (receiver_class.clone(), None)
938 };
939
940 if expects_error && (!setup_lines.is_empty() || extra_setup.is_some()) {
941 let _ = writeln!(out, " await expectLater(() async {{");
945 for line in &setup_lines {
946 let _ = writeln!(out, " {line}");
947 }
948 if let Some(extra) = &extra_setup {
949 for line in extra.lines() {
950 let _ = writeln!(out, " {line}");
951 }
952 }
953 if is_streaming {
954 let _ = writeln!(out, " return {receiver}.{function_name}({args_str}).toList();");
955 } else {
956 let _ = writeln!(out, " return {receiver}.{function_name}({args_str});");
957 }
958 let _ = writeln!(out, " }}(), throwsA(anything));");
959 } else if expects_error {
960 if let Some(extra) = &extra_setup {
962 for line in extra.lines() {
963 let _ = writeln!(out, " {line}");
964 }
965 }
966 if is_streaming {
967 let _ = writeln!(
968 out,
969 " await expectLater({receiver}.{function_name}({args_str}).toList(), throwsA(anything));"
970 );
971 } else {
972 let _ = writeln!(
973 out,
974 " await expectLater({receiver}.{function_name}({args_str}), throwsA(anything));"
975 );
976 }
977 } else {
978 for line in &setup_lines {
979 let _ = writeln!(out, " {line}");
980 }
981 if let Some(extra) = &extra_setup {
982 for line in extra.lines() {
983 let _ = writeln!(out, " {line}");
984 }
985 }
986 if is_streaming {
987 let _ = writeln!(
988 out,
989 " final {result_var} = await {receiver}.{function_name}({args_str}).toList();"
990 );
991 } else {
992 let _ = writeln!(
993 out,
994 " final {result_var} = await {receiver}.{function_name}({args_str});"
995 );
996 }
997 for assertion in &fixture.assertions {
998 if is_streaming {
999 render_streaming_assertion_dart(out, assertion, result_var);
1000 } else {
1001 render_assertion_dart(
1002 out,
1003 assertion,
1004 result_var,
1005 result_is_simple,
1006 field_resolver,
1007 enum_fields,
1008 );
1009 }
1010 }
1011 }
1012
1013 let _ = writeln!(out, " }});");
1014 let _ = writeln!(out);
1015}
1016
1017fn dart_length_expr(field_accessor: &str, field: Option<&str>, field_resolver: &FieldResolver) -> String {
1025 let is_optional = field
1026 .map(|f| {
1027 let resolved = field_resolver.resolve(f);
1028 field_resolver.is_optional(f) || field_resolver.is_optional(resolved)
1029 })
1030 .unwrap_or(false);
1031 if is_optional {
1032 format!("{field_accessor}?.length ?? 0")
1033 } else {
1034 format!("{field_accessor}.length")
1035 }
1036}
1037
1038fn dart_format_value(val: &serde_json::Value) -> String {
1039 match val {
1040 serde_json::Value::String(s) => format!("'{}'", escape_dart(s)),
1041 serde_json::Value::Bool(b) => b.to_string(),
1042 serde_json::Value::Number(n) => n.to_string(),
1043 serde_json::Value::Null => "null".to_string(),
1044 other => format!("'{}'", escape_dart(&other.to_string())),
1045 }
1046}
1047
1048fn render_assertion_dart(
1059 out: &mut String,
1060 assertion: &Assertion,
1061 result_var: &str,
1062 result_is_simple: bool,
1063 field_resolver: &FieldResolver,
1064 enum_fields: &std::collections::HashSet<String>,
1065) {
1066 if !result_is_simple {
1070 if let Some(f) = assertion.field.as_deref() {
1071 let head = f.split("[].").next().unwrap_or(f);
1074 if !head.is_empty() && !field_resolver.is_valid_for_result(head) {
1075 let _ = writeln!(out, " // skipped: field '{f}' not available on dart result type");
1076 return;
1077 }
1078 }
1079 }
1080
1081 if let Some(f) = assertion.field.as_deref() {
1087 if !f.is_empty() && field_resolver.tagged_union_split(f).is_some() {
1088 let _ = writeln!(
1089 out,
1090 " // skipped: field '{f}' crosses a tagged-union variant boundary (not expressible in Dart)"
1091 );
1092 return;
1093 }
1094 }
1095
1096 if let Some(f) = assertion.field.as_deref() {
1098 if let Some(dot) = f.find("[].") {
1099 let resolved_full = field_resolver.resolve(f);
1104 let (array_part, elem_part) = match resolved_full.find("[].") {
1105 Some(rdot) => (&resolved_full[..rdot], &resolved_full[rdot + 3..]),
1106 None => (&f[..dot], &f[dot + 3..]),
1109 };
1110 let array_accessor = if array_part.is_empty() {
1111 result_var.to_string()
1112 } else {
1113 field_resolver.accessor(array_part, "dart", result_var)
1114 };
1115 let elem_accessor = field_to_dart_accessor(elem_part);
1116 match assertion.assertion_type.as_str() {
1117 "contains" => {
1118 if let Some(expected) = &assertion.value {
1119 let dart_val = dart_format_value(expected);
1120 let _ = writeln!(
1121 out,
1122 " expect({array_accessor}.any((e) => e.{elem_accessor}.toString().contains({dart_val})), isTrue);"
1123 );
1124 }
1125 }
1126 "contains_all" => {
1127 if let Some(values) = &assertion.values {
1128 for val in values {
1129 let dart_val = dart_format_value(val);
1130 let _ = writeln!(
1131 out,
1132 " expect({array_accessor}.any((e) => e.{elem_accessor}.toString().contains({dart_val})), isTrue);"
1133 );
1134 }
1135 }
1136 }
1137 "not_contains" => {
1138 if let Some(expected) = &assertion.value {
1139 let dart_val = dart_format_value(expected);
1140 let _ = writeln!(
1141 out,
1142 " expect({array_accessor}.any((e) => e.{elem_accessor}.toString().contains({dart_val})), isFalse);"
1143 );
1144 } else if let Some(values) = &assertion.values {
1145 for val in values {
1146 let dart_val = dart_format_value(val);
1147 let _ = writeln!(
1148 out,
1149 " expect({array_accessor}.any((e) => e.{elem_accessor}.toString().contains({dart_val})), isFalse);"
1150 );
1151 }
1152 }
1153 }
1154 "not_empty" => {
1155 let _ = writeln!(
1156 out,
1157 " expect({array_accessor}.any((e) => e.{elem_accessor}.toString().isNotEmpty), isTrue);"
1158 );
1159 }
1160 other => {
1161 let _ = writeln!(
1162 out,
1163 " // skipped: unsupported traversal assertion '{other}' on '{f}'"
1164 );
1165 }
1166 }
1167 return;
1168 }
1169 }
1170
1171 let field_accessor = if result_is_simple {
1172 result_var.to_string()
1176 } else {
1177 match assertion.field.as_deref() {
1178 Some(f) if !f.is_empty() => field_resolver.accessor(f, "dart", result_var),
1183 _ => result_var.to_string(),
1184 }
1185 };
1186
1187 let format_value = |val: &serde_json::Value| -> String { dart_format_value(val) };
1188
1189 match assertion.assertion_type.as_str() {
1190 "equals" | "field_equals" => {
1191 if let Some(expected) = &assertion.value {
1192 let dart_val = format_value(expected);
1193 let is_enum_field = assertion
1196 .field
1197 .as_deref()
1198 .map(|f| {
1199 let resolved = field_resolver.resolve(f);
1200 enum_fields.contains(f) || enum_fields.contains(resolved)
1201 })
1202 .unwrap_or(false);
1203
1204 if expected.is_string() {
1208 if is_enum_field {
1209 let _ = writeln!(
1212 out,
1213 " expect(_alefE2eText({field_accessor}).trim(), equals({dart_val}.toString().trim()));"
1214 );
1215 } else {
1216 let safe_accessor = if result_is_simple && assertion.field.is_none() {
1219 format!("({field_accessor} ?? '').toString().trim()")
1220 } else {
1221 format!("{field_accessor}.toString().trim()")
1222 };
1223 let _ = writeln!(
1224 out,
1225 " expect({safe_accessor}, equals({dart_val}.toString().trim()));"
1226 );
1227 }
1228 } else {
1229 let _ = writeln!(out, " expect({field_accessor}, equals({dart_val}));");
1230 }
1231 } else {
1232 let _ = writeln!(
1233 out,
1234 " // skipped: '{}' assertion missing value",
1235 assertion.assertion_type
1236 );
1237 }
1238 }
1239 "not_equals" => {
1240 if let Some(expected) = &assertion.value {
1241 let dart_val = format_value(expected);
1242 let is_enum_field = assertion
1244 .field
1245 .as_deref()
1246 .map(|f| {
1247 let resolved = field_resolver.resolve(f);
1248 enum_fields.contains(f) || enum_fields.contains(resolved)
1249 })
1250 .unwrap_or(false);
1251
1252 if expected.is_string() {
1253 if is_enum_field {
1254 let _ = writeln!(
1255 out,
1256 " expect(_alefE2eText({field_accessor}).trim(), isNot(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}, isNot(equals({dart_val}.toString().trim())));"
1269 );
1270 }
1271 } else {
1272 let _ = writeln!(out, " expect({field_accessor}, isNot(equals({dart_val})));");
1273 }
1274 }
1275 }
1276 "contains" => {
1277 if let Some(expected) = &assertion.value {
1278 let dart_val = format_value(expected);
1279 let _ = writeln!(out, " expect({field_accessor}, contains({dart_val}));");
1280 } else {
1281 let _ = writeln!(out, " // skipped: 'contains' assertion missing value");
1282 }
1283 }
1284 "contains_all" => {
1285 if let Some(values) = &assertion.values {
1286 for val in values {
1287 let dart_val = format_value(val);
1288 let _ = writeln!(out, " expect({field_accessor}, contains({dart_val}));");
1289 }
1290 }
1291 }
1292 "contains_any" => {
1293 if let Some(values) = &assertion.values {
1294 let checks: Vec<String> = values
1295 .iter()
1296 .map(|v| {
1297 let dart_val = format_value(v);
1298 format!("{field_accessor}.contains({dart_val})")
1299 })
1300 .collect();
1301 let joined = checks.join(" || ");
1302 let _ = writeln!(out, " expect({joined}, isTrue);");
1303 }
1304 }
1305 "not_contains" => {
1306 if let Some(expected) = &assertion.value {
1307 let dart_val = format_value(expected);
1308 let _ = writeln!(out, " expect({field_accessor}, isNot(contains({dart_val})));");
1309 } else if let Some(values) = &assertion.values {
1310 for val in values {
1311 let dart_val = format_value(val);
1312 let _ = writeln!(out, " expect({field_accessor}, isNot(contains({dart_val})));");
1313 }
1314 }
1315 }
1316 "not_empty" => {
1317 let is_collection = assertion.field.as_deref().is_some_and(|f| {
1322 let resolved = field_resolver.resolve(f);
1323 field_resolver.is_array(f) || field_resolver.is_array(resolved)
1324 });
1325 if is_collection {
1326 let _ = writeln!(out, " expect({field_accessor}, isNotEmpty);");
1327 } else {
1328 let _ = writeln!(out, " expect({field_accessor}, isNotNull);");
1329 }
1330 }
1331 "is_empty" => {
1332 let _ = writeln!(out, " expect({field_accessor}, anyOf(isNull, isEmpty));");
1336 }
1337 "starts_with" => {
1338 if let Some(expected) = &assertion.value {
1339 let dart_val = format_value(expected);
1340 let _ = writeln!(out, " expect({field_accessor}, startsWith({dart_val}));");
1341 }
1342 }
1343 "ends_with" => {
1344 if let Some(expected) = &assertion.value {
1345 let dart_val = format_value(expected);
1346 let _ = writeln!(out, " expect({field_accessor}, endsWith({dart_val}));");
1347 }
1348 }
1349 "min_length" => {
1350 if let Some(val) = &assertion.value {
1351 if let Some(n) = val.as_u64() {
1352 let length_expr = dart_length_expr(&field_accessor, assertion.field.as_deref(), field_resolver);
1353 let _ = writeln!(out, " expect({length_expr}, greaterThanOrEqualTo({n}));");
1354 }
1355 }
1356 }
1357 "max_length" => {
1358 if let Some(val) = &assertion.value {
1359 if let Some(n) = val.as_u64() {
1360 let length_expr = dart_length_expr(&field_accessor, assertion.field.as_deref(), field_resolver);
1361 let _ = writeln!(out, " expect({length_expr}, lessThanOrEqualTo({n}));");
1362 }
1363 }
1364 }
1365 "count_equals" => {
1366 if let Some(val) = &assertion.value {
1367 if let Some(n) = val.as_u64() {
1368 let length_expr = dart_length_expr(&field_accessor, assertion.field.as_deref(), field_resolver);
1369 let _ = writeln!(out, " expect({length_expr}, equals({n}));");
1370 }
1371 }
1372 }
1373 "count_min" => {
1374 if let Some(val) = &assertion.value {
1375 if let Some(n) = val.as_u64() {
1376 let length_expr = dart_length_expr(&field_accessor, assertion.field.as_deref(), field_resolver);
1377 let _ = writeln!(out, " expect({length_expr}, greaterThanOrEqualTo({n}));");
1378 }
1379 }
1380 }
1381 "matches_regex" => {
1382 if let Some(expected) = &assertion.value {
1383 let dart_val = format_value(expected);
1384 let _ = writeln!(out, " expect({field_accessor}, matches(RegExp({dart_val})));");
1385 }
1386 }
1387 "is_true" => {
1388 let _ = writeln!(out, " expect({field_accessor}, isTrue);");
1389 }
1390 "is_false" => {
1391 let _ = writeln!(out, " expect({field_accessor}, isFalse);");
1392 }
1393 "greater_than" => {
1394 if let Some(val) = &assertion.value {
1395 let dart_val = format_value(val);
1396 let _ = writeln!(out, " expect({field_accessor}, greaterThan({dart_val}));");
1397 }
1398 }
1399 "less_than" => {
1400 if let Some(val) = &assertion.value {
1401 let dart_val = format_value(val);
1402 let _ = writeln!(out, " expect({field_accessor}, lessThan({dart_val}));");
1403 }
1404 }
1405 "greater_than_or_equal" => {
1406 if let Some(val) = &assertion.value {
1407 let dart_val = format_value(val);
1408 let _ = writeln!(out, " expect({field_accessor}, greaterThanOrEqualTo({dart_val}));");
1409 }
1410 }
1411 "less_than_or_equal" => {
1412 if let Some(val) = &assertion.value {
1413 let dart_val = format_value(val);
1414 let _ = writeln!(out, " expect({field_accessor}, lessThanOrEqualTo({dart_val}));");
1415 }
1416 }
1417 "not_null" => {
1418 let _ = writeln!(out, " expect({field_accessor}, isNotNull);");
1419 }
1420 "not_error" => {
1421 }
1428 "error" => {
1429 }
1431 "method_result" => {
1432 if let Some(method) = &assertion.method {
1433 let dart_method = method.to_lower_camel_case();
1434 let check = assertion.check.as_deref().unwrap_or("not_null");
1435 let method_call = format!("{field_accessor}.{dart_method}()");
1436 match check {
1437 "equals" => {
1438 if let Some(expected) = &assertion.value {
1439 let dart_val = format_value(expected);
1440 let _ = writeln!(out, " expect({method_call}, equals({dart_val}));");
1441 }
1442 }
1443 "is_true" => {
1444 let _ = writeln!(out, " expect({method_call}, isTrue);");
1445 }
1446 "is_false" => {
1447 let _ = writeln!(out, " expect({method_call}, isFalse);");
1448 }
1449 "greater_than_or_equal" => {
1450 if let Some(val) = &assertion.value {
1451 let dart_val = format_value(val);
1452 let _ = writeln!(out, " expect({method_call}, greaterThanOrEqualTo({dart_val}));");
1453 }
1454 }
1455 "count_min" => {
1456 if let Some(val) = &assertion.value {
1457 if let Some(n) = val.as_u64() {
1458 let _ = writeln!(out, " expect({method_call}.length, greaterThanOrEqualTo({n}));");
1459 }
1460 }
1461 }
1462 _ => {
1463 let _ = writeln!(out, " expect({method_call}, isNotNull);");
1464 }
1465 }
1466 }
1467 }
1468 other => {
1469 let _ = writeln!(out, " // skipped: unknown assertion type '{other}'");
1470 }
1471 }
1472}
1473
1474fn render_streaming_assertion_dart(out: &mut String, assertion: &Assertion, result_var: &str) {
1485 match assertion.assertion_type.as_str() {
1486 "not_error" => {
1487 let _ = writeln!(out, " expect({result_var}, isNotNull);");
1491 }
1492 "count_min" if assertion.field.as_deref() == Some("chunks") => {
1493 if let Some(serde_json::Value::Number(n)) = &assertion.value {
1494 let _ = writeln!(out, " expect({result_var}.length, greaterThanOrEqualTo({n}));");
1495 }
1496 }
1497 "equals" if assertion.field.as_deref() == Some("stream_content") => {
1498 if let Some(serde_json::Value::String(expected)) = &assertion.value {
1499 let escaped = escape_dart(expected);
1500 let _ = writeln!(
1501 out,
1502 " final _content = {result_var}.map((c) => c.choices.firstOrNull?.delta.content ?? '').join();"
1503 );
1504 let _ = writeln!(out, " expect(_content, equals('{escaped}'));");
1505 }
1506 }
1507 other => {
1508 let _ = writeln!(out, " // skipped streaming assertion: '{other}'");
1509 }
1510 }
1511}
1512
1513fn snake_to_camel(s: &str) -> String {
1515 let mut result = String::with_capacity(s.len());
1516 let mut next_upper = false;
1517 for ch in s.chars() {
1518 if ch == '_' {
1519 next_upper = true;
1520 } else if next_upper {
1521 result.extend(ch.to_uppercase());
1522 next_upper = false;
1523 } else {
1524 result.push(ch);
1525 }
1526 }
1527 result
1528}
1529
1530fn field_to_dart_accessor(path: &str) -> String {
1543 let mut result = String::with_capacity(path.len());
1544 for (i, segment) in path.split('.').enumerate() {
1545 if i > 0 {
1546 result.push('.');
1547 }
1548 if let Some(bracket_pos) = segment.find('[') {
1554 let name = &segment[..bracket_pos];
1555 let bracket = &segment[bracket_pos..];
1556 result.push_str(&name.to_lower_camel_case());
1557 result.push('!');
1558 result.push_str(bracket);
1559 } else {
1560 result.push_str(&segment.to_lower_camel_case());
1561 }
1562 }
1563 result
1564}
1565
1566fn emit_extraction_config_dart(overrides: &serde_json::Map<String, serde_json::Value>) -> String {
1572 let mut field_overrides: std::collections::HashMap<String, String> = std::collections::HashMap::new();
1574 for (key, val) in overrides {
1575 let camel = snake_to_camel(key);
1576 let dart_val = match val {
1577 serde_json::Value::Bool(b) => {
1578 if *b {
1579 "true".to_string()
1580 } else {
1581 "false".to_string()
1582 }
1583 }
1584 serde_json::Value::Number(n) => n.to_string(),
1585 serde_json::Value::String(s) => format!("'{s}'"),
1586 _ => continue, };
1588 field_overrides.insert(camel, dart_val);
1589 }
1590
1591 let use_cache = field_overrides.remove("useCache").unwrap_or_else(|| "true".to_string());
1592 let enable_quality_processing = field_overrides
1593 .remove("enableQualityProcessing")
1594 .unwrap_or_else(|| "true".to_string());
1595 let force_ocr = field_overrides
1596 .remove("forceOcr")
1597 .unwrap_or_else(|| "false".to_string());
1598 let disable_ocr = field_overrides
1599 .remove("disableOcr")
1600 .unwrap_or_else(|| "false".to_string());
1601 let include_document_structure = field_overrides
1602 .remove("includeDocumentStructure")
1603 .unwrap_or_else(|| "false".to_string());
1604 let use_layout_for_markdown = field_overrides
1605 .remove("useLayoutForMarkdown")
1606 .unwrap_or_else(|| "false".to_string());
1607 let max_archive_depth = field_overrides
1608 .remove("maxArchiveDepth")
1609 .unwrap_or_else(|| "3".to_string());
1610
1611 format!(
1612 "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})"
1613 )
1614}
1615
1616struct DartTestClientRenderer {
1632 in_skip: Cell<bool>,
1635 is_redirect: Cell<bool>,
1638}
1639
1640impl DartTestClientRenderer {
1641 fn new(is_redirect: bool) -> Self {
1642 Self {
1643 in_skip: Cell::new(false),
1644 is_redirect: Cell::new(is_redirect),
1645 }
1646 }
1647}
1648
1649impl client::TestClientRenderer for DartTestClientRenderer {
1650 fn language_name(&self) -> &'static str {
1651 "dart"
1652 }
1653
1654 fn render_test_open(&self, out: &mut String, _fn_name: &str, description: &str, skip_reason: Option<&str>) {
1663 let escaped_desc = escape_dart(description);
1664 if let Some(reason) = skip_reason {
1665 let escaped_reason = escape_dart(reason);
1666 let _ = writeln!(out, " test('{escaped_desc}', () {{");
1667 let _ = writeln!(out, " markTestSkipped('{escaped_reason}');");
1668 let _ = writeln!(out, " }});");
1669 let _ = writeln!(out);
1670 self.in_skip.set(true);
1671 } else {
1672 let _ = writeln!(
1673 out,
1674 " test('{escaped_desc}', () => _serialized(() => _withRetry(() async {{"
1675 );
1676 self.in_skip.set(false);
1677 }
1678 }
1679
1680 fn render_test_close(&self, out: &mut String) {
1685 if self.in_skip.get() {
1686 return;
1688 }
1689 let _ = writeln!(out, " }})));");
1690 let _ = writeln!(out);
1691 }
1692
1693 fn render_call(&self, out: &mut String, ctx: &client::CallCtx<'_>) {
1703 const DART_RESTRICTED_HEADERS: &[&str] = &["content-length", "host", "transfer-encoding"];
1705
1706 let method = ctx.method.to_uppercase();
1707 let escaped_method = escape_dart(&method);
1708
1709 let fixture_path = escape_dart(ctx.path);
1711
1712 let has_explicit_content_type = ctx.headers.keys().any(|k| k.to_lowercase() == "content-type");
1714 let effective_content_type = if has_explicit_content_type {
1715 ctx.headers
1716 .iter()
1717 .find(|(k, _)| k.to_lowercase() == "content-type")
1718 .map(|(_, v)| v.as_str())
1719 .unwrap_or("application/json")
1720 } else if ctx.body.is_some() {
1721 ctx.content_type.unwrap_or("application/json")
1722 } else {
1723 ""
1724 };
1725
1726 let _ = writeln!(
1727 out,
1728 " final baseUrl = Platform.environment['MOCK_SERVER_URL'] ?? 'http://localhost:8080';"
1729 );
1730 let _ = writeln!(out, " final uri = Uri.parse('$baseUrl{fixture_path}');");
1731 let _ = writeln!(
1732 out,
1733 " final ioReq = await _httpClient.openUrl('{escaped_method}', uri);"
1734 );
1735
1736 if self.is_redirect.get() {
1739 let _ = writeln!(out, " ioReq.followRedirects = false;");
1740 }
1741
1742 if !effective_content_type.is_empty() {
1744 let escaped_ct = escape_dart(effective_content_type);
1745 let _ = writeln!(out, " ioReq.headers.set('content-type', '{escaped_ct}');");
1746 }
1747
1748 let mut header_pairs: Vec<(&String, &String)> = ctx.headers.iter().collect();
1750 header_pairs.sort_by_key(|(k, _)| k.as_str());
1751 for (name, value) in &header_pairs {
1752 if DART_RESTRICTED_HEADERS.contains(&name.to_lowercase().as_str()) {
1753 continue;
1754 }
1755 if name.to_lowercase() == "content-type" {
1756 continue; }
1758 let escaped_name = escape_dart(&name.to_lowercase());
1759 let escaped_value = escape_dart(value);
1760 let _ = writeln!(out, " ioReq.headers.set('{escaped_name}', '{escaped_value}');");
1761 }
1762
1763 if !ctx.cookies.is_empty() {
1765 let mut cookie_pairs: Vec<(&String, &String)> = ctx.cookies.iter().collect();
1766 cookie_pairs.sort_by_key(|(k, _)| k.as_str());
1767 let cookie_str: Vec<String> = cookie_pairs.iter().map(|(k, v)| format!("{k}={v}")).collect();
1768 let cookie_header = escape_dart(&cookie_str.join("; "));
1769 let _ = writeln!(out, " ioReq.headers.set('cookie', '{cookie_header}');");
1770 }
1771
1772 if let Some(body) = ctx.body {
1774 let json_str = serde_json::to_string(body).unwrap_or_default();
1775 let escaped = escape_dart(&json_str);
1776 let _ = writeln!(out, " final bodyBytes = utf8.encode('{escaped}');");
1777 let _ = writeln!(out, " ioReq.add(bodyBytes);");
1778 }
1779
1780 let _ = writeln!(out, " final ioResp = await ioReq.close();");
1781 if !self.is_redirect.get() {
1785 let _ = writeln!(out, " final bodyStr = await ioResp.transform(utf8.decoder).join();");
1786 };
1787 }
1788
1789 fn render_assert_status(&self, out: &mut String, _response_var: &str, status: u16) {
1790 let _ = writeln!(
1791 out,
1792 " expect(ioResp.statusCode, equals({status}), reason: 'status code mismatch');"
1793 );
1794 }
1795
1796 fn render_assert_header(&self, out: &mut String, _response_var: &str, name: &str, expected: &str) {
1799 let escaped_name = escape_dart(&name.to_lowercase());
1800 match expected {
1801 "<<present>>" => {
1802 let _ = writeln!(
1803 out,
1804 " expect(ioResp.headers.value('{escaped_name}'), isNotNull, reason: 'header {escaped_name} should be present');"
1805 );
1806 }
1807 "<<absent>>" => {
1808 let _ = writeln!(
1809 out,
1810 " expect(ioResp.headers.value('{escaped_name}'), isNull, reason: 'header {escaped_name} should be absent');"
1811 );
1812 }
1813 "<<uuid>>" => {
1814 let _ = writeln!(
1815 out,
1816 " 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');"
1817 );
1818 }
1819 exact => {
1820 let escaped_value = escape_dart(exact);
1821 let _ = writeln!(
1822 out,
1823 " expect(ioResp.headers.value('{escaped_name}'), contains('{escaped_value}'), reason: 'header {escaped_name} mismatch');"
1824 );
1825 }
1826 }
1827 }
1828
1829 fn render_assert_json_body(&self, out: &mut String, _response_var: &str, expected: &serde_json::Value) {
1834 match expected {
1835 serde_json::Value::Object(_) | serde_json::Value::Array(_) => {
1836 let json_str = serde_json::to_string(expected).unwrap_or_default();
1837 let escaped = escape_dart(&json_str);
1838 let _ = writeln!(out, " final bodyJson = jsonDecode(bodyStr);");
1839 let _ = writeln!(out, " final expectedJson = jsonDecode('{escaped}');");
1840 let _ = writeln!(
1841 out,
1842 " expect(bodyJson, equals(expectedJson), reason: 'body mismatch');"
1843 );
1844 }
1845 serde_json::Value::String(s) => {
1846 let escaped = escape_dart(s);
1847 let _ = writeln!(
1848 out,
1849 " expect(bodyStr.trim(), equals('{escaped}'), reason: 'body mismatch');"
1850 );
1851 }
1852 other => {
1853 let escaped = escape_dart(&other.to_string());
1854 let _ = writeln!(
1855 out,
1856 " expect(bodyStr.trim(), equals('{escaped}'), reason: 'body mismatch');"
1857 );
1858 }
1859 }
1860 }
1861
1862 fn render_assert_partial_body(&self, out: &mut String, _response_var: &str, expected: &serde_json::Value) {
1865 let _ = writeln!(
1866 out,
1867 " final partialJson = jsonDecode(bodyStr) as Map<String, dynamic>;"
1868 );
1869 if let Some(obj) = expected.as_object() {
1870 for (idx, (key, val)) in obj.iter().enumerate() {
1871 let escaped_key = escape_dart(key);
1872 let json_val = serde_json::to_string(val).unwrap_or_default();
1873 let escaped_val = escape_dart(&json_val);
1874 let _ = writeln!(out, " final _expectedField{idx} = jsonDecode('{escaped_val}');");
1877 let _ = writeln!(
1878 out,
1879 " expect(partialJson['{escaped_key}'], equals(_expectedField{idx}), reason: 'partial body field \\'{escaped_key}\\' mismatch');"
1880 );
1881 }
1882 }
1883 }
1884
1885 fn render_assert_validation_errors(
1887 &self,
1888 out: &mut String,
1889 _response_var: &str,
1890 errors: &[ValidationErrorExpectation],
1891 ) {
1892 let _ = writeln!(out, " final errBody = jsonDecode(bodyStr) as Map<String, dynamic>;");
1893 let _ = writeln!(out, " final errList = (errBody['errors'] ?? []) as List<dynamic>;");
1894 for ve in errors {
1895 let loc_dart: Vec<String> = ve.loc.iter().map(|s| format!("'{}'", escape_dart(s))).collect();
1896 let loc_str = loc_dart.join(", ");
1897 let escaped_msg = escape_dart(&ve.msg);
1898 let _ = writeln!(
1899 out,
1900 " 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}');"
1901 );
1902 }
1903 }
1904}
1905
1906fn render_http_test_case(out: &mut String, fixture: &Fixture, http: &HttpFixture) {
1913 if http.expected_response.status_code == 101 {
1915 let description = escape_dart(&fixture.description);
1916 let _ = writeln!(out, " test('{description}', () {{");
1917 let _ = writeln!(
1918 out,
1919 " markTestSkipped('Skipped: Dart HttpClient cannot handle 101 Switching Protocols responses');"
1920 );
1921 let _ = writeln!(out, " }});");
1922 let _ = writeln!(out);
1923 return;
1924 }
1925
1926 let is_redirect = http.expected_response.status_code / 100 == 3;
1930 client::http_call::render_http_test(out, &DartTestClientRenderer::new(is_redirect), fixture);
1931}
1932
1933fn mime_from_extension(path: &str) -> Option<&'static str> {
1938 let ext = path.rsplit('.').next()?;
1939 match ext.to_lowercase().as_str() {
1940 "docx" => Some("application/vnd.openxmlformats-officedocument.wordprocessingml.document"),
1941 "xlsx" => Some("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"),
1942 "pptx" => Some("application/vnd.openxmlformats-officedocument.presentationml.presentation"),
1943 "pdf" => Some("application/pdf"),
1944 "txt" | "text" => Some("text/plain"),
1945 "html" | "htm" => Some("text/html"),
1946 "json" => Some("application/json"),
1947 "xml" => Some("application/xml"),
1948 "csv" => Some("text/csv"),
1949 "md" | "markdown" => Some("text/markdown"),
1950 "png" => Some("image/png"),
1951 "jpg" | "jpeg" => Some("image/jpeg"),
1952 "gif" => Some("image/gif"),
1953 "zip" => Some("application/zip"),
1954 "odt" => Some("application/vnd.oasis.opendocument.text"),
1955 "ods" => Some("application/vnd.oasis.opendocument.spreadsheet"),
1956 "odp" => Some("application/vnd.oasis.opendocument.presentation"),
1957 "rtf" => Some("application/rtf"),
1958 "epub" => Some("application/epub+zip"),
1959 "msg" => Some("application/vnd.ms-outlook"),
1960 "eml" => Some("message/rfc822"),
1961 _ => None,
1962 }
1963}
1964
1965fn emit_dart_batch_item_array(arr: &serde_json::Value, elem_type: &str) -> String {
1972 let items: Vec<String> = arr
1973 .as_array()
1974 .map(|a| a.as_slice())
1975 .unwrap_or_default()
1976 .iter()
1977 .filter_map(|item| {
1978 let obj = item.as_object()?;
1979 match elem_type {
1980 "BatchBytesItem" => {
1981 let content_bytes = obj
1982 .get("content")
1983 .and_then(|v| v.as_array())
1984 .map(|arr| {
1985 let nums: Vec<String> =
1986 arr.iter().filter_map(|v| v.as_u64().map(|n| n.to_string())).collect();
1987 format!("Uint8List.fromList([{}])", nums.join(", "))
1988 })
1989 .unwrap_or_else(|| "Uint8List(0)".to_string());
1990 let mime_type = obj
1991 .get("mime_type")
1992 .and_then(|v| v.as_str())
1993 .unwrap_or("application/octet-stream");
1994 Some(format!(
1995 "BatchBytesItem(content: {content_bytes}, mimeType: '{}')",
1996 escape_dart(mime_type)
1997 ))
1998 }
1999 "BatchFileItem" => {
2000 let path = obj.get("path").and_then(|v| v.as_str()).unwrap_or("");
2001 Some(format!("BatchFileItem(path: '{}')", escape_dart(path)))
2002 }
2003 _ => None,
2004 }
2005 })
2006 .collect();
2007 format!("[{}]", items.join(", "))
2008}
2009
2010pub(super) fn escape_dart(s: &str) -> String {
2012 s.replace('\\', "\\\\")
2013 .replace('\'', "\\'")
2014 .replace('\n', "\\n")
2015 .replace('\r', "\\r")
2016 .replace('\t', "\\t")
2017 .replace('$', "\\$")
2018}
2019
2020fn type_name_to_create_from_json_dart(type_name: &str) -> String {
2028 let mut snake = String::with_capacity(type_name.len() + 8);
2030 for (i, ch) in type_name.char_indices() {
2031 if ch.is_uppercase() {
2032 if i > 0 {
2033 snake.push('_');
2034 }
2035 snake.extend(ch.to_lowercase());
2036 } else {
2037 snake.push(ch);
2038 }
2039 }
2040 let rust_fn = format!("create_{snake}_from_json");
2043 rust_fn
2045 .split('_')
2046 .enumerate()
2047 .map(|(i, part)| {
2048 if i == 0 {
2049 part.to_string()
2050 } else {
2051 let mut chars = part.chars();
2052 match chars.next() {
2053 None => String::new(),
2054 Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
2055 }
2056 }
2057 })
2058 .collect::<Vec<_>>()
2059 .join("")
2060}