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(f.call.as_deref(), &f.input);
109 let resolved_function = call_config
110 .overrides
111 .get(lang)
112 .and_then(|o| o.function.as_ref())
113 .cloned()
114 .unwrap_or_else(|| call_config.function.clone());
115 !dart_stub_methods.contains(&resolved_function)
116 })
117 .collect();
118
119 if active.is_empty() {
120 continue;
121 }
122
123 let filename = format!("{}_test.dart", sanitize_filename(&group.category));
124 let content = render_test_file(
125 &group.category,
126 &active,
127 e2e_config,
128 lang,
129 &pkg_name,
130 &frb_module_name,
131 &bridge_class,
132 );
133 files.push(GeneratedFile {
134 path: test_base.join(filename),
135 content,
136 generated_header: true,
137 });
138 }
139
140 Ok(files)
141 }
142
143 fn language_name(&self) -> &'static str {
144 "dart"
145 }
146}
147
148fn render_pubspec(
153 pkg_name: &str,
154 pkg_path: &str,
155 pkg_version: &str,
156 dep_mode: crate::config::DependencyMode,
157) -> String {
158 let test_ver = pub_dev::TEST_PACKAGE;
159 let http_ver = pub_dev::HTTP_PACKAGE;
160
161 let dep_block = match dep_mode {
162 crate::config::DependencyMode::Registry => {
163 format!(" {pkg_name}: ^{pkg_version}")
164 }
165 crate::config::DependencyMode::Local => {
166 format!(" {pkg_name}:\n path: {pkg_path}")
167 }
168 };
169
170 let sdk = alef_core::template_versions::toolchain::DART_SDK_CONSTRAINT;
171 format!(
172 r#"name: e2e_dart
173version: 0.1.0
174publish_to: none
175
176environment:
177 sdk: "{sdk}"
178
179dependencies:
180{dep_block}
181
182dev_dependencies:
183 test: {test_ver}
184 http: {http_ver}
185"#
186 )
187}
188
189fn render_test_file(
190 category: &str,
191 fixtures: &[&Fixture],
192 e2e_config: &E2eConfig,
193 lang: &str,
194 pkg_name: &str,
195 frb_module_name: &str,
196 bridge_class: &str,
197) -> String {
198 let mut out = String::new();
199 out.push_str(&hash::header(CommentStyle::DoubleSlash));
200 out.push_str("// ignore_for_file: unused_local_variable\n\n");
204
205 let field_resolver = FieldResolver::new(
210 &e2e_config.fields,
211 &e2e_config.fields_optional,
212 &e2e_config.result_fields,
213 &e2e_config.fields_array,
214 &e2e_config.fields_method_calls,
215 );
216
217 let has_http_fixtures = fixtures.iter().any(|f| f.is_http_test());
219
220 let has_batch_byte_items = fixtures.iter().any(|f| {
222 let call_config = e2e_config.resolve_call_for_fixture(f.call.as_deref(), &f.input);
223 call_config.args.iter().any(|a| {
224 a.element_type.as_deref() == Some("BatchBytesItem") && resolve_field(&f.input, &a.field).is_array()
225 })
226 });
227
228 let needs_chdir = fixtures.iter().any(|f| {
232 if f.is_http_test() {
233 return false;
234 }
235 let call_config = e2e_config.resolve_call_for_fixture(f.call.as_deref(), &f.input);
236 call_config
237 .args
238 .iter()
239 .any(|a| a.arg_type == "file_path" || a.arg_type == "bytes")
240 });
241
242 let has_handle_args = fixtures.iter().any(|f| {
248 if f.is_http_test() {
249 return false;
250 }
251 let call_config = e2e_config.resolve_call_for_fixture(f.call.as_deref(), &f.input);
252 call_config
253 .args
254 .iter()
255 .any(|a| a.arg_type == "json_object" && super::resolve_field(&f.input, &a.field).is_array())
256 });
257
258 let lang_client_factory = e2e_config
264 .call
265 .overrides
266 .get(lang)
267 .and_then(|o| o.client_factory.as_deref())
268 .is_some();
269 let has_mock_url_refs = lang_client_factory
270 || fixtures.iter().any(|f| {
271 if f.is_http_test() {
272 return false;
273 }
274 let call_config = e2e_config.resolve_call_for_fixture(f.call.as_deref(), &f.input);
275 if call_config.args.iter().any(|a| a.arg_type == "mock_url") {
276 return true;
277 }
278 call_config
279 .overrides
280 .get(lang)
281 .and_then(|o| o.client_factory.as_deref())
282 .is_some()
283 });
284
285 let _ = writeln!(out, "import 'package:test/test.dart';");
286 if has_http_fixtures || needs_chdir || has_mock_url_refs {
291 let _ = writeln!(out, "import 'dart:io';");
292 }
293 if has_batch_byte_items {
294 let _ = writeln!(out, "import 'dart:typed_data';");
295 }
296 let _ = writeln!(out, "import 'package:{pkg_name}/{pkg_name}.dart';");
297 let _ = writeln!(
303 out,
304 "import 'package:{pkg_name}/src/{frb_module_name}_bridge_generated/frb_generated.dart' show RustLib;"
305 );
306 if has_http_fixtures {
307 let _ = writeln!(out, "import 'dart:async';");
308 }
309 if has_http_fixtures || has_handle_args {
311 let _ = writeln!(out, "import 'dart:convert';");
312 }
313 let _ = writeln!(out);
314
315 if has_http_fixtures {
325 let _ = writeln!(out, "HttpClient _httpClient = HttpClient()..maxConnectionsPerHost = 1;");
326 let _ = writeln!(out);
327 let _ = writeln!(out, "var _lock = Future<void>.value();");
328 let _ = writeln!(out);
329 let _ = writeln!(out, "Future<T> _serialized<T>(Future<T> Function() fn) async {{");
330 let _ = writeln!(out, " final current = _lock;");
331 let _ = writeln!(out, " final next = Completer<void>();");
332 let _ = writeln!(out, " _lock = next.future;");
333 let _ = writeln!(out, " try {{");
334 let _ = writeln!(out, " await current;");
335 let _ = writeln!(out, " return await fn();");
336 let _ = writeln!(out, " }} finally {{");
337 let _ = writeln!(out, " next.complete();");
338 let _ = writeln!(out, " }}");
339 let _ = writeln!(out, "}}");
340 let _ = writeln!(out);
341 let _ = writeln!(out, "Future<T> _withRetry<T>(Future<T> Function() fn) async {{");
344 let _ = writeln!(out, " try {{");
345 let _ = writeln!(out, " return await fn();");
346 let _ = writeln!(out, " }} on SocketException {{");
347 let _ = writeln!(out, " _httpClient.close(force: true);");
348 let _ = writeln!(out, " _httpClient = HttpClient()..maxConnectionsPerHost = 1;");
349 let _ = writeln!(out, " return fn();");
350 let _ = writeln!(out, " }} on HttpException {{");
351 let _ = writeln!(out, " _httpClient.close(force: true);");
352 let _ = writeln!(out, " _httpClient = HttpClient()..maxConnectionsPerHost = 1;");
353 let _ = writeln!(out, " return fn();");
354 let _ = writeln!(out, " }}");
355 let _ = writeln!(out, "}}");
356 let _ = writeln!(out);
357 }
358
359 let _ = writeln!(out, "// E2e tests for category: {category}");
360 let _ = writeln!(out, "void main() {{");
361
362 let _ = writeln!(out, " setUpAll(() async {{");
369 let _ = writeln!(out, " await RustLib.init();");
370 if needs_chdir {
371 let test_docs_path = e2e_config.test_documents_relative_from(0);
372 let _ = writeln!(
373 out,
374 " final _testDocs = Platform.environment['FIXTURES_DIR'] ?? '{test_docs_path}';"
375 );
376 let _ = writeln!(out, " final _dir = Directory(_testDocs);");
377 let _ = writeln!(out, " if (_dir.existsSync()) Directory.current = _dir;");
378 }
379 let _ = writeln!(out, " }});");
380 let _ = writeln!(out);
381
382 if has_http_fixtures {
384 let _ = writeln!(out, " tearDownAll(() => _httpClient.close());");
385 let _ = writeln!(out);
386 }
387
388 for fixture in fixtures {
389 render_test_case(&mut out, fixture, e2e_config, lang, bridge_class, &field_resolver);
390 }
391
392 let _ = writeln!(out, "}}");
393 out
394}
395
396fn render_test_case(
397 out: &mut String,
398 fixture: &Fixture,
399 e2e_config: &E2eConfig,
400 lang: &str,
401 bridge_class: &str,
402 field_resolver: &FieldResolver,
403) {
404 if let Some(http) = &fixture.http {
406 render_http_test_case(out, fixture, http);
407 return;
408 }
409
410 let call_config = e2e_config.resolve_call_for_fixture(fixture.call.as_deref(), &fixture.input);
412 let call_overrides = call_config.overrides.get(lang);
413 let mut function_name = call_overrides
414 .and_then(|o| o.function.as_ref())
415 .cloned()
416 .unwrap_or_else(|| call_config.function.clone());
417 function_name = function_name
419 .split('_')
420 .enumerate()
421 .map(|(i, part)| {
422 if i == 0 {
423 part.to_string()
424 } else {
425 let mut chars = part.chars();
426 match chars.next() {
427 None => String::new(),
428 Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
429 }
430 }
431 })
432 .collect::<Vec<_>>()
433 .join("");
434 let result_var = &call_config.result_var;
435 let description = escape_dart(&fixture.description);
436 let fixture_id = &fixture.id;
437 let _is_async = call_overrides.and_then(|o| o.r#async).unwrap_or(call_config.r#async);
440
441 let expects_error = fixture.assertions.iter().any(|a| a.assertion_type == "error");
442 let is_streaming = crate::codegen::streaming_assertions::resolve_is_streaming(fixture, call_config.streaming);
443 let result_is_simple = call_overrides.is_some_and(|o| o.result_is_simple) || call_config.result_is_simple;
448
449 let options_type: Option<&str> = call_overrides.and_then(|o| o.options_type.as_deref());
456 let options_via: &str = call_overrides
457 .and_then(|o| o.options_via.as_deref())
458 .unwrap_or("kwargs");
459
460 let file_path_for_mime: Option<&str> = call_config
468 .args
469 .iter()
470 .find(|a| a.arg_type == "file_path")
471 .and_then(|a| resolve_field(&fixture.input, &a.field).as_str());
472
473 let has_file_path_arg = call_config.args.iter().any(|a| a.arg_type == "file_path");
480 let caller_supplied_override = call_overrides.and_then(|o| o.function.as_ref()).is_some();
483 if has_file_path_arg && !caller_supplied_override {
484 function_name = match function_name.as_str() {
485 "extractFile" => "extractBytes".to_string(),
486 "extractFileSync" => "extractBytesSync".to_string(),
487 other => other.to_string(),
488 };
489 }
490
491 let mut setup_lines: Vec<String> = Vec::new();
494 let mut args = Vec::new();
495
496 for arg_def in &call_config.args {
497 match arg_def.arg_type.as_str() {
498 "mock_url" => {
499 let name = arg_def.name.clone();
500 if fixture.has_host_root_route() {
501 let env_key = format!("MOCK_SERVER_{}", fixture_id.to_uppercase());
502 setup_lines.push(format!(
503 r#"final {name} = Platform.environment["{env_key}"] ?? (Platform.environment["MOCK_SERVER_URL"]! + "/fixtures/{fixture_id}");"#
504 ));
505 } else {
506 setup_lines.push(format!(
507 r#"final {name} = "${{Platform.environment["MOCK_SERVER_URL"] ?? "http://localhost:8080"}}/fixtures/{fixture_id}";"#
508 ));
509 }
510 args.push(name);
511 continue;
512 }
513 "handle" => {
514 let name = arg_def.name.clone();
515 let field = arg_def.field.strip_prefix("input.").unwrap_or(&arg_def.field);
516 let config_value = fixture.input.get(field).cloned().unwrap_or(serde_json::Value::Null);
517 let create_fn = {
519 let mut chars = name.chars();
520 let pascal = match chars.next() {
521 None => String::new(),
522 Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
523 };
524 format!("create{pascal}")
525 };
526 if config_value.is_null()
527 || config_value.is_object() && config_value.as_object().is_some_and(|o| o.is_empty())
528 {
529 setup_lines.push(format!("final {name} = await {bridge_class}.{create_fn}();"));
530 } else {
531 let json_str = serde_json::to_string(&config_value).unwrap_or_default();
532 let config_var = format!("{name}Config");
533 setup_lines.push(format!(
538 "final {config_var} = await createCrawlConfigFromJson(json: r'{json_str}');"
539 ));
540 setup_lines.push(format!(
542 "final {name} = await {bridge_class}.{create_fn}(config: {config_var});"
543 ));
544 }
545 args.push(name);
546 continue;
547 }
548 _ => {}
549 }
550
551 let arg_value = resolve_field(&fixture.input, &arg_def.field);
552 match arg_def.arg_type.as_str() {
553 "bytes" | "file_path" => {
554 if let serde_json::Value::String(file_path) = arg_value {
559 args.push(format!("File('{}').readAsBytesSync()", file_path));
560 }
561 }
562 "string" => {
563 let dart_param_name = snake_to_camel(&arg_def.name);
574 let mime_required_due_to_remap = has_file_path_arg
575 && arg_def.name == "mime_type"
576 && (function_name == "extractBytes" || function_name == "extractBytesSync");
577 let use_positional = mime_required_due_to_remap || !arg_def.optional;
578 match arg_value {
579 serde_json::Value::String(s) => {
580 let literal = format!("'{}'", escape_dart(s));
581 if use_positional {
582 args.push(literal);
583 } else {
584 args.push(format!("{dart_param_name}: {literal}"));
585 }
586 }
587 serde_json::Value::Null
588 if arg_def.optional
589 && arg_def.name == "mime_type" =>
592 {
593 let inferred = file_path_for_mime
594 .and_then(mime_from_extension)
595 .unwrap_or("application/octet-stream");
596 if use_positional {
597 args.push(format!("'{inferred}'"));
598 } else {
599 args.push(format!("{dart_param_name}: '{inferred}'"));
600 }
601 }
602 _ => {}
604 }
605 }
606 "json_object" => {
607 if let Some(elem_type) = &arg_def.element_type {
609 if (elem_type == "BatchBytesItem" || elem_type == "BatchFileItem") && arg_value.is_array() {
610 let dart_items = emit_dart_batch_item_array(arg_value, elem_type);
611 args.push(dart_items);
612 } else if elem_type == "String" && arg_value.is_array() {
613 let items: Vec<String> = arg_value
620 .as_array()
621 .unwrap()
622 .iter()
623 .filter_map(|v| v.as_str())
624 .map(|s| format!("'{}'", escape_dart(s)))
625 .collect();
626 args.push(format!("<String>[{}]", items.join(", ")));
627 }
628 } else if options_via == "from_json" {
629 if let Some(opts_type) = options_type {
639 if !arg_value.is_null() {
640 let json_str = serde_json::to_string(&arg_value).unwrap_or_default();
641 let escaped_json = escape_dart(&json_str);
644 let var_name = format!("_{}", arg_def.name);
645 let dart_fn = type_name_to_create_from_json_dart(opts_type);
646 setup_lines.push(format!("final {var_name} = await {dart_fn}(json: '{escaped_json}');"));
647 args.push(format!("req: {var_name}"));
650 }
651 }
652 } else if arg_def.name == "config" {
653 if let serde_json::Value::Object(map) = &arg_value {
654 if !map.is_empty() {
655 let explicit_options =
664 options_type.is_some_and(|t| t != "ExtractionConfig" && t != "FileExtractionConfig");
665 let has_non_scalar = map.values().any(|v| {
666 matches!(
667 v,
668 serde_json::Value::String(_)
669 | serde_json::Value::Object(_)
670 | serde_json::Value::Array(_)
671 )
672 });
673 if explicit_options || has_non_scalar {
674 let opts_type = options_type.unwrap_or("ExtractionConfig");
675 let json_str = serde_json::to_string(&arg_value).unwrap_or_default();
676 let escaped_json = escape_dart(&json_str);
677 let var_name = format!("_{}", arg_def.name);
678 let dart_fn = type_name_to_create_from_json_dart(opts_type);
679 setup_lines
680 .push(format!("final {var_name} = await {dart_fn}(json: '{escaped_json}');"));
681 args.push(var_name);
682 } else {
683 args.push(emit_extraction_config_dart(map));
689 }
690 }
691 }
692 } else if arg_value.is_array() {
694 let json_str = serde_json::to_string(&arg_value).unwrap_or_default();
697 let var_name = arg_def.name.clone();
698 setup_lines.push(format!(
699 "final {var_name} = (jsonDecode(r'{json_str}') as List<dynamic>).cast<String>();"
700 ));
701 args.push(var_name);
702 } else if let serde_json::Value::Object(map) = &arg_value {
703 if !map.is_empty() {
717 if let Some(opts_type) = options_type {
718 let json_str = serde_json::to_string(&arg_value).unwrap_or_default();
719 let escaped_json = escape_dart(&json_str);
720 let dart_param_name = snake_to_camel(&arg_def.name);
721 let var_name = format!("_{}", arg_def.name);
722 let dart_fn = type_name_to_create_from_json_dart(opts_type);
723 if fixture.visitor.is_some() {
724 setup_lines.push(format!(
725 "final {var_name} = await {dart_fn}WithVisitor(json: '{escaped_json}', visitor: _visitor);"
726 ));
727 } else {
728 setup_lines
729 .push(format!("final {var_name} = await {dart_fn}(json: '{escaped_json}');"));
730 }
731 if arg_def.optional {
732 args.push(format!("{dart_param_name}: {var_name}"));
733 } else {
734 args.push(var_name);
735 }
736 }
737 }
738 }
739 }
740 _ => {}
741 }
742 }
743
744 if let Some(visitor_spec) = &fixture.visitor {
759 let mut visitor_setup: Vec<String> = Vec::new();
760 let _ = super::dart_visitors::build_dart_visitor(&mut visitor_setup, visitor_spec);
761 for line in visitor_setup.into_iter().rev() {
764 setup_lines.insert(0, line);
765 }
766
767 let already_has_options = args.iter().any(|a| a.starts_with("options:") || a == "_options");
771 if !already_has_options {
772 if let Some(opts_type) = options_type {
773 let dart_fn = type_name_to_create_from_json_dart(opts_type);
774 setup_lines.push(format!(
775 "final _options = await {dart_fn}WithVisitor(json: '{{}}', visitor: _visitor);"
776 ));
777 args.push("options: _options".to_string());
778 }
779 }
780 }
781
782 let client_factory: Option<&str> = call_overrides.and_then(|o| o.client_factory.as_deref()).or_else(|| {
786 e2e_config
787 .call
788 .overrides
789 .get(lang)
790 .and_then(|o| o.client_factory.as_deref())
791 });
792
793 let client_factory_camel: Option<String> = client_factory.map(|f| {
795 f.split('_')
796 .enumerate()
797 .map(|(i, part)| {
798 if i == 0 {
799 part.to_string()
800 } else {
801 let mut chars = part.chars();
802 match chars.next() {
803 None => String::new(),
804 Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
805 }
806 }
807 })
808 .collect::<Vec<_>>()
809 .join("")
810 });
811
812 let _ = writeln!(out, " test('{description}', () async {{");
816
817 let args_str = args.join(", ");
818 let receiver_class = call_overrides
819 .and_then(|o| o.class.as_ref())
820 .cloned()
821 .unwrap_or_else(|| bridge_class.to_string());
822
823 let (receiver, extra_setup): (String, Option<String>) = if let Some(factory) = &client_factory_camel {
827 let has_mock_url = call_config.args.iter().any(|a| a.arg_type == "mock_url");
828 let mock_url_setup = if !has_mock_url {
829 if fixture.has_host_root_route() {
831 let env_key = format!("MOCK_SERVER_{}", fixture_id.to_uppercase());
832 Some(format!(
833 "final _mockUrl = Platform.environment[\"{env_key}\"] ?? (Platform.environment[\"MOCK_SERVER_URL\"]! + \"/fixtures/{fixture_id}\");"
834 ))
835 } else {
836 Some(format!(
837 r#"final _mockUrl = "${{Platform.environment["MOCK_SERVER_URL"] ?? "http://localhost:8080"}}/fixtures/{fixture_id}";"#
838 ))
839 }
840 } else {
841 None
842 };
843 let url_expr = if has_mock_url {
844 call_config
847 .args
848 .iter()
849 .find(|a| a.arg_type == "mock_url")
850 .map(|a| a.name.clone())
851 .unwrap_or_else(|| "_mockUrl".to_string())
852 } else {
853 "_mockUrl".to_string()
854 };
855 let create_line = format!("final _client = await {receiver_class}.{factory}('test-key', baseUrl: {url_expr});");
856 let full_setup = if let Some(url_line) = mock_url_setup {
857 Some(format!("{url_line}\n {create_line}"))
858 } else {
859 Some(create_line)
860 };
861 ("_client".to_string(), full_setup)
862 } else {
863 (receiver_class.clone(), None)
864 };
865
866 if expects_error && (!setup_lines.is_empty() || extra_setup.is_some()) {
867 let _ = writeln!(out, " await expectLater(() async {{");
871 for line in &setup_lines {
872 let _ = writeln!(out, " {line}");
873 }
874 if let Some(extra) = &extra_setup {
875 for line in extra.lines() {
876 let _ = writeln!(out, " {line}");
877 }
878 }
879 if is_streaming {
880 let _ = writeln!(out, " return {receiver}.{function_name}({args_str}).toList();");
881 } else {
882 let _ = writeln!(out, " return {receiver}.{function_name}({args_str});");
883 }
884 let _ = writeln!(out, " }}(), throwsA(anything));");
885 } else if expects_error {
886 if let Some(extra) = &extra_setup {
888 for line in extra.lines() {
889 let _ = writeln!(out, " {line}");
890 }
891 }
892 if is_streaming {
893 let _ = writeln!(
894 out,
895 " await expectLater({receiver}.{function_name}({args_str}).toList(), throwsA(anything));"
896 );
897 } else {
898 let _ = writeln!(
899 out,
900 " await expectLater({receiver}.{function_name}({args_str}), throwsA(anything));"
901 );
902 }
903 } else {
904 for line in &setup_lines {
905 let _ = writeln!(out, " {line}");
906 }
907 if let Some(extra) = &extra_setup {
908 for line in extra.lines() {
909 let _ = writeln!(out, " {line}");
910 }
911 }
912 if is_streaming {
913 let _ = writeln!(
914 out,
915 " final {result_var} = await {receiver}.{function_name}({args_str}).toList();"
916 );
917 } else {
918 let _ = writeln!(
919 out,
920 " final {result_var} = await {receiver}.{function_name}({args_str});"
921 );
922 }
923 for assertion in &fixture.assertions {
924 if is_streaming {
925 render_streaming_assertion_dart(out, assertion, result_var);
926 } else {
927 render_assertion_dart(out, assertion, result_var, result_is_simple, field_resolver);
928 }
929 }
930 }
931
932 let _ = writeln!(out, " }});");
933 let _ = writeln!(out);
934}
935
936fn dart_length_expr(field_accessor: &str, field: Option<&str>, field_resolver: &FieldResolver) -> String {
944 let is_optional = field
945 .map(|f| {
946 let resolved = field_resolver.resolve(f);
947 field_resolver.is_optional(f) || field_resolver.is_optional(resolved)
948 })
949 .unwrap_or(false);
950 if is_optional {
951 format!("{field_accessor}?.length ?? 0")
952 } else {
953 format!("{field_accessor}.length")
954 }
955}
956
957fn dart_format_value(val: &serde_json::Value) -> String {
958 match val {
959 serde_json::Value::String(s) => format!("'{}'", escape_dart(s)),
960 serde_json::Value::Bool(b) => b.to_string(),
961 serde_json::Value::Number(n) => n.to_string(),
962 serde_json::Value::Null => "null".to_string(),
963 other => format!("'{}'", escape_dart(&other.to_string())),
964 }
965}
966
967fn render_assertion_dart(
978 out: &mut String,
979 assertion: &Assertion,
980 result_var: &str,
981 result_is_simple: bool,
982 field_resolver: &FieldResolver,
983) {
984 if !result_is_simple {
988 if let Some(f) = assertion.field.as_deref() {
989 let head = f.split("[].").next().unwrap_or(f);
992 if !head.is_empty() && !field_resolver.is_valid_for_result(head) {
993 let _ = writeln!(out, " // skipped: field '{f}' not available on dart result type");
994 return;
995 }
996 }
997 }
998
999 if let Some(f) = assertion.field.as_deref() {
1005 if !f.is_empty() && field_resolver.tagged_union_split(f).is_some() {
1006 let _ = writeln!(
1007 out,
1008 " // skipped: field '{f}' crosses a tagged-union variant boundary (not expressible in Dart)"
1009 );
1010 return;
1011 }
1012 }
1013
1014 if let Some(f) = assertion.field.as_deref() {
1016 if let Some(dot) = f.find("[].") {
1017 let resolved_full = field_resolver.resolve(f);
1022 let (array_part, elem_part) = match resolved_full.find("[].") {
1023 Some(rdot) => (&resolved_full[..rdot], &resolved_full[rdot + 3..]),
1024 None => (&f[..dot], &f[dot + 3..]),
1027 };
1028 let array_accessor = if array_part.is_empty() {
1029 result_var.to_string()
1030 } else {
1031 field_resolver.accessor(array_part, "dart", result_var)
1032 };
1033 let elem_accessor = field_to_dart_accessor(elem_part);
1034 match assertion.assertion_type.as_str() {
1035 "contains" => {
1036 if let Some(expected) = &assertion.value {
1037 let dart_val = dart_format_value(expected);
1038 let _ = writeln!(
1039 out,
1040 " expect({array_accessor}.any((e) => e.{elem_accessor}.toString().contains({dart_val})), isTrue);"
1041 );
1042 }
1043 }
1044 "contains_all" => {
1045 if let Some(values) = &assertion.values {
1046 for val in values {
1047 let dart_val = dart_format_value(val);
1048 let _ = writeln!(
1049 out,
1050 " expect({array_accessor}.any((e) => e.{elem_accessor}.toString().contains({dart_val})), isTrue);"
1051 );
1052 }
1053 }
1054 }
1055 "not_contains" => {
1056 if let Some(expected) = &assertion.value {
1057 let dart_val = dart_format_value(expected);
1058 let _ = writeln!(
1059 out,
1060 " expect({array_accessor}.any((e) => e.{elem_accessor}.toString().contains({dart_val})), isFalse);"
1061 );
1062 } else if let Some(values) = &assertion.values {
1063 for val in values {
1064 let dart_val = dart_format_value(val);
1065 let _ = writeln!(
1066 out,
1067 " expect({array_accessor}.any((e) => e.{elem_accessor}.toString().contains({dart_val})), isFalse);"
1068 );
1069 }
1070 }
1071 }
1072 "not_empty" => {
1073 let _ = writeln!(
1074 out,
1075 " expect({array_accessor}.any((e) => e.{elem_accessor}.toString().isNotEmpty), isTrue);"
1076 );
1077 }
1078 other => {
1079 let _ = writeln!(
1080 out,
1081 " // skipped: unsupported traversal assertion '{other}' on '{f}'"
1082 );
1083 }
1084 }
1085 return;
1086 }
1087 }
1088
1089 let field_accessor = if result_is_simple {
1090 result_var.to_string()
1094 } else {
1095 match assertion.field.as_deref() {
1096 Some(f) if !f.is_empty() => field_resolver.accessor(f, "dart", result_var),
1101 _ => result_var.to_string(),
1102 }
1103 };
1104
1105 let format_value = |val: &serde_json::Value| -> String { dart_format_value(val) };
1106
1107 match assertion.assertion_type.as_str() {
1108 "equals" | "field_equals" => {
1109 if let Some(expected) = &assertion.value {
1110 let dart_val = format_value(expected);
1111 if expected.is_string() {
1115 let _ = writeln!(
1116 out,
1117 " expect({field_accessor}.toString().trim(), equals({dart_val}.toString().trim()));"
1118 );
1119 } else {
1120 let _ = writeln!(out, " expect({field_accessor}, equals({dart_val}));");
1121 }
1122 } else {
1123 let _ = writeln!(
1124 out,
1125 " // skipped: '{}' assertion missing value",
1126 assertion.assertion_type
1127 );
1128 }
1129 }
1130 "not_equals" => {
1131 if let Some(expected) = &assertion.value {
1132 let dart_val = format_value(expected);
1133 if expected.is_string() {
1134 let _ = writeln!(
1135 out,
1136 " expect({field_accessor}.toString().trim(), isNot(equals({dart_val}.toString().trim())));"
1137 );
1138 } else {
1139 let _ = writeln!(out, " expect({field_accessor}, isNot(equals({dart_val})));");
1140 }
1141 }
1142 }
1143 "contains" => {
1144 if let Some(expected) = &assertion.value {
1145 let dart_val = format_value(expected);
1146 let _ = writeln!(out, " expect({field_accessor}, contains({dart_val}));");
1147 } else {
1148 let _ = writeln!(out, " // skipped: 'contains' assertion missing value");
1149 }
1150 }
1151 "contains_all" => {
1152 if let Some(values) = &assertion.values {
1153 for val in values {
1154 let dart_val = format_value(val);
1155 let _ = writeln!(out, " expect({field_accessor}, contains({dart_val}));");
1156 }
1157 }
1158 }
1159 "contains_any" => {
1160 if let Some(values) = &assertion.values {
1161 let checks: Vec<String> = values
1162 .iter()
1163 .map(|v| {
1164 let dart_val = format_value(v);
1165 format!("{field_accessor}.contains({dart_val})")
1166 })
1167 .collect();
1168 let joined = checks.join(" || ");
1169 let _ = writeln!(out, " expect({joined}, isTrue);");
1170 }
1171 }
1172 "not_contains" => {
1173 if let Some(expected) = &assertion.value {
1174 let dart_val = format_value(expected);
1175 let _ = writeln!(out, " expect({field_accessor}, isNot(contains({dart_val})));");
1176 } else if let Some(values) = &assertion.values {
1177 for val in values {
1178 let dart_val = format_value(val);
1179 let _ = writeln!(out, " expect({field_accessor}, isNot(contains({dart_val})));");
1180 }
1181 }
1182 }
1183 "not_empty" => {
1184 let is_collection = assertion.field.as_deref().is_some_and(|f| {
1189 let resolved = field_resolver.resolve(f);
1190 field_resolver.is_array(f) || field_resolver.is_array(resolved)
1191 });
1192 if is_collection {
1193 let _ = writeln!(out, " expect({field_accessor}, isNotEmpty);");
1194 } else {
1195 let _ = writeln!(out, " expect({field_accessor}, isNotNull);");
1196 }
1197 }
1198 "is_empty" => {
1199 let _ = writeln!(out, " expect({field_accessor}, anyOf(isNull, isEmpty));");
1203 }
1204 "starts_with" => {
1205 if let Some(expected) = &assertion.value {
1206 let dart_val = format_value(expected);
1207 let _ = writeln!(out, " expect({field_accessor}, startsWith({dart_val}));");
1208 }
1209 }
1210 "ends_with" => {
1211 if let Some(expected) = &assertion.value {
1212 let dart_val = format_value(expected);
1213 let _ = writeln!(out, " expect({field_accessor}, endsWith({dart_val}));");
1214 }
1215 }
1216 "min_length" => {
1217 if let Some(val) = &assertion.value {
1218 if let Some(n) = val.as_u64() {
1219 let length_expr = dart_length_expr(&field_accessor, assertion.field.as_deref(), field_resolver);
1220 let _ = writeln!(out, " expect({length_expr}, greaterThanOrEqualTo({n}));");
1221 }
1222 }
1223 }
1224 "max_length" => {
1225 if let Some(val) = &assertion.value {
1226 if let Some(n) = val.as_u64() {
1227 let length_expr = dart_length_expr(&field_accessor, assertion.field.as_deref(), field_resolver);
1228 let _ = writeln!(out, " expect({length_expr}, lessThanOrEqualTo({n}));");
1229 }
1230 }
1231 }
1232 "count_equals" => {
1233 if let Some(val) = &assertion.value {
1234 if let Some(n) = val.as_u64() {
1235 let length_expr = dart_length_expr(&field_accessor, assertion.field.as_deref(), field_resolver);
1236 let _ = writeln!(out, " expect({length_expr}, equals({n}));");
1237 }
1238 }
1239 }
1240 "count_min" => {
1241 if let Some(val) = &assertion.value {
1242 if let Some(n) = val.as_u64() {
1243 let length_expr = dart_length_expr(&field_accessor, assertion.field.as_deref(), field_resolver);
1244 let _ = writeln!(out, " expect({length_expr}, greaterThanOrEqualTo({n}));");
1245 }
1246 }
1247 }
1248 "matches_regex" => {
1249 if let Some(expected) = &assertion.value {
1250 let dart_val = format_value(expected);
1251 let _ = writeln!(out, " expect({field_accessor}, matches(RegExp({dart_val})));");
1252 }
1253 }
1254 "is_true" => {
1255 let _ = writeln!(out, " expect({field_accessor}, isTrue);");
1256 }
1257 "is_false" => {
1258 let _ = writeln!(out, " expect({field_accessor}, isFalse);");
1259 }
1260 "greater_than" => {
1261 if let Some(val) = &assertion.value {
1262 let dart_val = format_value(val);
1263 let _ = writeln!(out, " expect({field_accessor}, greaterThan({dart_val}));");
1264 }
1265 }
1266 "less_than" => {
1267 if let Some(val) = &assertion.value {
1268 let dart_val = format_value(val);
1269 let _ = writeln!(out, " expect({field_accessor}, lessThan({dart_val}));");
1270 }
1271 }
1272 "greater_than_or_equal" => {
1273 if let Some(val) = &assertion.value {
1274 let dart_val = format_value(val);
1275 let _ = writeln!(out, " expect({field_accessor}, greaterThanOrEqualTo({dart_val}));");
1276 }
1277 }
1278 "less_than_or_equal" => {
1279 if let Some(val) = &assertion.value {
1280 let dart_val = format_value(val);
1281 let _ = writeln!(out, " expect({field_accessor}, lessThanOrEqualTo({dart_val}));");
1282 }
1283 }
1284 "not_null" => {
1285 let _ = writeln!(out, " expect({field_accessor}, isNotNull);");
1286 }
1287 "not_error" => {
1288 }
1290 "error" => {
1291 }
1293 "method_result" => {
1294 if let Some(method) = &assertion.method {
1295 let dart_method = method.to_lower_camel_case();
1296 let check = assertion.check.as_deref().unwrap_or("not_null");
1297 let method_call = format!("{field_accessor}.{dart_method}()");
1298 match check {
1299 "equals" => {
1300 if let Some(expected) = &assertion.value {
1301 let dart_val = format_value(expected);
1302 let _ = writeln!(out, " expect({method_call}, equals({dart_val}));");
1303 }
1304 }
1305 "is_true" => {
1306 let _ = writeln!(out, " expect({method_call}, isTrue);");
1307 }
1308 "is_false" => {
1309 let _ = writeln!(out, " expect({method_call}, isFalse);");
1310 }
1311 "greater_than_or_equal" => {
1312 if let Some(val) = &assertion.value {
1313 let dart_val = format_value(val);
1314 let _ = writeln!(out, " expect({method_call}, greaterThanOrEqualTo({dart_val}));");
1315 }
1316 }
1317 "count_min" => {
1318 if let Some(val) = &assertion.value {
1319 if let Some(n) = val.as_u64() {
1320 let _ = writeln!(out, " expect({method_call}.length, greaterThanOrEqualTo({n}));");
1321 }
1322 }
1323 }
1324 _ => {
1325 let _ = writeln!(out, " expect({method_call}, isNotNull);");
1326 }
1327 }
1328 }
1329 }
1330 other => {
1331 let _ = writeln!(out, " // skipped: unknown assertion type '{other}'");
1332 }
1333 }
1334}
1335
1336fn render_streaming_assertion_dart(out: &mut String, assertion: &Assertion, result_var: &str) {
1346 match assertion.assertion_type.as_str() {
1347 "not_error" => {
1348 }
1350 "count_min" if assertion.field.as_deref() == Some("chunks") => {
1351 if let Some(serde_json::Value::Number(n)) = &assertion.value {
1352 let _ = writeln!(out, " expect({result_var}.length, greaterThanOrEqualTo({n}));");
1353 }
1354 }
1355 "equals" if assertion.field.as_deref() == Some("stream_content") => {
1356 if let Some(serde_json::Value::String(expected)) = &assertion.value {
1357 let escaped = escape_dart(expected);
1358 let _ = writeln!(
1359 out,
1360 " final _content = {result_var}.map((c) => c.choices.firstOrNull?.delta.content ?? '').join();"
1361 );
1362 let _ = writeln!(out, " expect(_content, equals('{escaped}'));");
1363 }
1364 }
1365 other => {
1366 let _ = writeln!(out, " // skipped streaming assertion: '{other}'");
1367 }
1368 }
1369}
1370
1371fn snake_to_camel(s: &str) -> String {
1373 let mut result = String::with_capacity(s.len());
1374 let mut next_upper = false;
1375 for ch in s.chars() {
1376 if ch == '_' {
1377 next_upper = true;
1378 } else if next_upper {
1379 result.extend(ch.to_uppercase());
1380 next_upper = false;
1381 } else {
1382 result.push(ch);
1383 }
1384 }
1385 result
1386}
1387
1388fn field_to_dart_accessor(path: &str) -> String {
1401 let mut result = String::with_capacity(path.len());
1402 for (i, segment) in path.split('.').enumerate() {
1403 if i > 0 {
1404 result.push('.');
1405 }
1406 if let Some(bracket_pos) = segment.find('[') {
1412 let name = &segment[..bracket_pos];
1413 let bracket = &segment[bracket_pos..];
1414 result.push_str(&name.to_lower_camel_case());
1415 result.push('!');
1416 result.push_str(bracket);
1417 } else {
1418 result.push_str(&segment.to_lower_camel_case());
1419 }
1420 }
1421 result
1422}
1423
1424fn emit_extraction_config_dart(overrides: &serde_json::Map<String, serde_json::Value>) -> String {
1430 let mut field_overrides: std::collections::HashMap<String, String> = std::collections::HashMap::new();
1432 for (key, val) in overrides {
1433 let camel = snake_to_camel(key);
1434 let dart_val = match val {
1435 serde_json::Value::Bool(b) => {
1436 if *b {
1437 "true".to_string()
1438 } else {
1439 "false".to_string()
1440 }
1441 }
1442 serde_json::Value::Number(n) => n.to_string(),
1443 serde_json::Value::String(s) => format!("'{s}'"),
1444 _ => continue, };
1446 field_overrides.insert(camel, dart_val);
1447 }
1448
1449 let use_cache = field_overrides.remove("useCache").unwrap_or_else(|| "true".to_string());
1450 let enable_quality_processing = field_overrides
1451 .remove("enableQualityProcessing")
1452 .unwrap_or_else(|| "true".to_string());
1453 let force_ocr = field_overrides
1454 .remove("forceOcr")
1455 .unwrap_or_else(|| "false".to_string());
1456 let disable_ocr = field_overrides
1457 .remove("disableOcr")
1458 .unwrap_or_else(|| "false".to_string());
1459 let include_document_structure = field_overrides
1460 .remove("includeDocumentStructure")
1461 .unwrap_or_else(|| "false".to_string());
1462 let use_layout_for_markdown = field_overrides
1463 .remove("useLayoutForMarkdown")
1464 .unwrap_or_else(|| "false".to_string());
1465 let max_archive_depth = field_overrides
1466 .remove("maxArchiveDepth")
1467 .unwrap_or_else(|| "3".to_string());
1468
1469 format!(
1470 "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})"
1471 )
1472}
1473
1474struct DartTestClientRenderer {
1490 in_skip: Cell<bool>,
1493 is_redirect: Cell<bool>,
1496}
1497
1498impl DartTestClientRenderer {
1499 fn new(is_redirect: bool) -> Self {
1500 Self {
1501 in_skip: Cell::new(false),
1502 is_redirect: Cell::new(is_redirect),
1503 }
1504 }
1505}
1506
1507impl client::TestClientRenderer for DartTestClientRenderer {
1508 fn language_name(&self) -> &'static str {
1509 "dart"
1510 }
1511
1512 fn render_test_open(&self, out: &mut String, _fn_name: &str, description: &str, skip_reason: Option<&str>) {
1521 let escaped_desc = escape_dart(description);
1522 if let Some(reason) = skip_reason {
1523 let escaped_reason = escape_dart(reason);
1524 let _ = writeln!(out, " test('{escaped_desc}', () {{");
1525 let _ = writeln!(out, " markTestSkipped('{escaped_reason}');");
1526 let _ = writeln!(out, " }});");
1527 let _ = writeln!(out);
1528 self.in_skip.set(true);
1529 } else {
1530 let _ = writeln!(
1531 out,
1532 " test('{escaped_desc}', () => _serialized(() => _withRetry(() async {{"
1533 );
1534 self.in_skip.set(false);
1535 }
1536 }
1537
1538 fn render_test_close(&self, out: &mut String) {
1543 if self.in_skip.get() {
1544 return;
1546 }
1547 let _ = writeln!(out, " }})));");
1548 let _ = writeln!(out);
1549 }
1550
1551 fn render_call(&self, out: &mut String, ctx: &client::CallCtx<'_>) {
1561 const DART_RESTRICTED_HEADERS: &[&str] = &["content-length", "host", "transfer-encoding"];
1563
1564 let method = ctx.method.to_uppercase();
1565 let escaped_method = escape_dart(&method);
1566
1567 let fixture_path = escape_dart(ctx.path);
1569
1570 let has_explicit_content_type = ctx.headers.keys().any(|k| k.to_lowercase() == "content-type");
1572 let effective_content_type = if has_explicit_content_type {
1573 ctx.headers
1574 .iter()
1575 .find(|(k, _)| k.to_lowercase() == "content-type")
1576 .map(|(_, v)| v.as_str())
1577 .unwrap_or("application/json")
1578 } else if ctx.body.is_some() {
1579 ctx.content_type.unwrap_or("application/json")
1580 } else {
1581 ""
1582 };
1583
1584 let _ = writeln!(
1585 out,
1586 " final baseUrl = Platform.environment['MOCK_SERVER_URL'] ?? 'http://localhost:8080';"
1587 );
1588 let _ = writeln!(out, " final uri = Uri.parse('$baseUrl{fixture_path}');");
1589 let _ = writeln!(
1590 out,
1591 " final ioReq = await _httpClient.openUrl('{escaped_method}', uri);"
1592 );
1593
1594 if self.is_redirect.get() {
1597 let _ = writeln!(out, " ioReq.followRedirects = false;");
1598 }
1599
1600 if !effective_content_type.is_empty() {
1602 let escaped_ct = escape_dart(effective_content_type);
1603 let _ = writeln!(out, " ioReq.headers.set('content-type', '{escaped_ct}');");
1604 }
1605
1606 let mut header_pairs: Vec<(&String, &String)> = ctx.headers.iter().collect();
1608 header_pairs.sort_by_key(|(k, _)| k.as_str());
1609 for (name, value) in &header_pairs {
1610 if DART_RESTRICTED_HEADERS.contains(&name.to_lowercase().as_str()) {
1611 continue;
1612 }
1613 if name.to_lowercase() == "content-type" {
1614 continue; }
1616 let escaped_name = escape_dart(&name.to_lowercase());
1617 let escaped_value = escape_dart(value);
1618 let _ = writeln!(out, " ioReq.headers.set('{escaped_name}', '{escaped_value}');");
1619 }
1620
1621 if !ctx.cookies.is_empty() {
1623 let mut cookie_pairs: Vec<(&String, &String)> = ctx.cookies.iter().collect();
1624 cookie_pairs.sort_by_key(|(k, _)| k.as_str());
1625 let cookie_str: Vec<String> = cookie_pairs.iter().map(|(k, v)| format!("{k}={v}")).collect();
1626 let cookie_header = escape_dart(&cookie_str.join("; "));
1627 let _ = writeln!(out, " ioReq.headers.set('cookie', '{cookie_header}');");
1628 }
1629
1630 if let Some(body) = ctx.body {
1632 let json_str = serde_json::to_string(body).unwrap_or_default();
1633 let escaped = escape_dart(&json_str);
1634 let _ = writeln!(out, " final bodyBytes = utf8.encode('{escaped}');");
1635 let _ = writeln!(out, " ioReq.add(bodyBytes);");
1636 }
1637
1638 let _ = writeln!(out, " final ioResp = await ioReq.close();");
1639 if !self.is_redirect.get() {
1643 let _ = writeln!(out, " final bodyStr = await ioResp.transform(utf8.decoder).join();");
1644 };
1645 }
1646
1647 fn render_assert_status(&self, out: &mut String, _response_var: &str, status: u16) {
1648 let _ = writeln!(
1649 out,
1650 " expect(ioResp.statusCode, equals({status}), reason: 'status code mismatch');"
1651 );
1652 }
1653
1654 fn render_assert_header(&self, out: &mut String, _response_var: &str, name: &str, expected: &str) {
1657 let escaped_name = escape_dart(&name.to_lowercase());
1658 match expected {
1659 "<<present>>" => {
1660 let _ = writeln!(
1661 out,
1662 " expect(ioResp.headers.value('{escaped_name}'), isNotNull, reason: 'header {escaped_name} should be present');"
1663 );
1664 }
1665 "<<absent>>" => {
1666 let _ = writeln!(
1667 out,
1668 " expect(ioResp.headers.value('{escaped_name}'), isNull, reason: 'header {escaped_name} should be absent');"
1669 );
1670 }
1671 "<<uuid>>" => {
1672 let _ = writeln!(
1673 out,
1674 " 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');"
1675 );
1676 }
1677 exact => {
1678 let escaped_value = escape_dart(exact);
1679 let _ = writeln!(
1680 out,
1681 " expect(ioResp.headers.value('{escaped_name}'), contains('{escaped_value}'), reason: 'header {escaped_name} mismatch');"
1682 );
1683 }
1684 }
1685 }
1686
1687 fn render_assert_json_body(&self, out: &mut String, _response_var: &str, expected: &serde_json::Value) {
1692 match expected {
1693 serde_json::Value::Object(_) | serde_json::Value::Array(_) => {
1694 let json_str = serde_json::to_string(expected).unwrap_or_default();
1695 let escaped = escape_dart(&json_str);
1696 let _ = writeln!(out, " final bodyJson = jsonDecode(bodyStr);");
1697 let _ = writeln!(out, " final expectedJson = jsonDecode('{escaped}');");
1698 let _ = writeln!(
1699 out,
1700 " expect(bodyJson, equals(expectedJson), reason: 'body mismatch');"
1701 );
1702 }
1703 serde_json::Value::String(s) => {
1704 let escaped = escape_dart(s);
1705 let _ = writeln!(
1706 out,
1707 " expect(bodyStr.trim(), equals('{escaped}'), reason: 'body mismatch');"
1708 );
1709 }
1710 other => {
1711 let escaped = escape_dart(&other.to_string());
1712 let _ = writeln!(
1713 out,
1714 " expect(bodyStr.trim(), equals('{escaped}'), reason: 'body mismatch');"
1715 );
1716 }
1717 }
1718 }
1719
1720 fn render_assert_partial_body(&self, out: &mut String, _response_var: &str, expected: &serde_json::Value) {
1723 let _ = writeln!(
1724 out,
1725 " final partialJson = jsonDecode(bodyStr) as Map<String, dynamic>;"
1726 );
1727 if let Some(obj) = expected.as_object() {
1728 for (idx, (key, val)) in obj.iter().enumerate() {
1729 let escaped_key = escape_dart(key);
1730 let json_val = serde_json::to_string(val).unwrap_or_default();
1731 let escaped_val = escape_dart(&json_val);
1732 let _ = writeln!(out, " final _expectedField{idx} = jsonDecode('{escaped_val}');");
1735 let _ = writeln!(
1736 out,
1737 " expect(partialJson['{escaped_key}'], equals(_expectedField{idx}), reason: 'partial body field \\'{escaped_key}\\' mismatch');"
1738 );
1739 }
1740 }
1741 }
1742
1743 fn render_assert_validation_errors(
1745 &self,
1746 out: &mut String,
1747 _response_var: &str,
1748 errors: &[ValidationErrorExpectation],
1749 ) {
1750 let _ = writeln!(out, " final errBody = jsonDecode(bodyStr) as Map<String, dynamic>;");
1751 let _ = writeln!(out, " final errList = (errBody['errors'] ?? []) as List<dynamic>;");
1752 for ve in errors {
1753 let loc_dart: Vec<String> = ve.loc.iter().map(|s| format!("'{}'", escape_dart(s))).collect();
1754 let loc_str = loc_dart.join(", ");
1755 let escaped_msg = escape_dart(&ve.msg);
1756 let _ = writeln!(
1757 out,
1758 " 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}');"
1759 );
1760 }
1761 }
1762}
1763
1764fn render_http_test_case(out: &mut String, fixture: &Fixture, http: &HttpFixture) {
1771 if http.expected_response.status_code == 101 {
1773 let description = escape_dart(&fixture.description);
1774 let _ = writeln!(out, " test('{description}', () {{");
1775 let _ = writeln!(
1776 out,
1777 " markTestSkipped('Skipped: Dart HttpClient cannot handle 101 Switching Protocols responses');"
1778 );
1779 let _ = writeln!(out, " }});");
1780 let _ = writeln!(out);
1781 return;
1782 }
1783
1784 let is_redirect = http.expected_response.status_code / 100 == 3;
1788 client::http_call::render_http_test(out, &DartTestClientRenderer::new(is_redirect), fixture);
1789}
1790
1791fn mime_from_extension(path: &str) -> Option<&'static str> {
1796 let ext = path.rsplit('.').next()?;
1797 match ext.to_lowercase().as_str() {
1798 "docx" => Some("application/vnd.openxmlformats-officedocument.wordprocessingml.document"),
1799 "xlsx" => Some("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"),
1800 "pptx" => Some("application/vnd.openxmlformats-officedocument.presentationml.presentation"),
1801 "pdf" => Some("application/pdf"),
1802 "txt" | "text" => Some("text/plain"),
1803 "html" | "htm" => Some("text/html"),
1804 "json" => Some("application/json"),
1805 "xml" => Some("application/xml"),
1806 "csv" => Some("text/csv"),
1807 "md" | "markdown" => Some("text/markdown"),
1808 "png" => Some("image/png"),
1809 "jpg" | "jpeg" => Some("image/jpeg"),
1810 "gif" => Some("image/gif"),
1811 "zip" => Some("application/zip"),
1812 "odt" => Some("application/vnd.oasis.opendocument.text"),
1813 "ods" => Some("application/vnd.oasis.opendocument.spreadsheet"),
1814 "odp" => Some("application/vnd.oasis.opendocument.presentation"),
1815 "rtf" => Some("application/rtf"),
1816 "epub" => Some("application/epub+zip"),
1817 "msg" => Some("application/vnd.ms-outlook"),
1818 "eml" => Some("message/rfc822"),
1819 _ => None,
1820 }
1821}
1822
1823fn emit_dart_batch_item_array(arr: &serde_json::Value, elem_type: &str) -> String {
1830 let items: Vec<String> = arr
1831 .as_array()
1832 .map(|a| a.as_slice())
1833 .unwrap_or_default()
1834 .iter()
1835 .filter_map(|item| {
1836 let obj = item.as_object()?;
1837 match elem_type {
1838 "BatchBytesItem" => {
1839 let content_bytes = obj
1840 .get("content")
1841 .and_then(|v| v.as_array())
1842 .map(|arr| {
1843 let nums: Vec<String> =
1844 arr.iter().filter_map(|v| v.as_u64().map(|n| n.to_string())).collect();
1845 format!("Uint8List.fromList([{}])", nums.join(", "))
1846 })
1847 .unwrap_or_else(|| "Uint8List(0)".to_string());
1848 let mime_type = obj
1849 .get("mime_type")
1850 .and_then(|v| v.as_str())
1851 .unwrap_or("application/octet-stream");
1852 Some(format!(
1853 "BatchBytesItem(content: {content_bytes}, mimeType: '{}')",
1854 escape_dart(mime_type)
1855 ))
1856 }
1857 "BatchFileItem" => {
1858 let path = obj.get("path").and_then(|v| v.as_str()).unwrap_or("");
1859 Some(format!("BatchFileItem(path: '{}')", escape_dart(path)))
1860 }
1861 _ => None,
1862 }
1863 })
1864 .collect();
1865 format!("[{}]", items.join(", "))
1866}
1867
1868pub(super) fn escape_dart(s: &str) -> String {
1870 s.replace('\\', "\\\\")
1871 .replace('\'', "\\'")
1872 .replace('\n', "\\n")
1873 .replace('\r', "\\r")
1874 .replace('\t', "\\t")
1875 .replace('$', "\\$")
1876}
1877
1878fn type_name_to_create_from_json_dart(type_name: &str) -> String {
1886 let mut snake = String::with_capacity(type_name.len() + 8);
1888 for (i, ch) in type_name.char_indices() {
1889 if ch.is_uppercase() {
1890 if i > 0 {
1891 snake.push('_');
1892 }
1893 snake.extend(ch.to_lowercase());
1894 } else {
1895 snake.push(ch);
1896 }
1897 }
1898 let rust_fn = format!("create_{snake}_from_json");
1901 rust_fn
1903 .split('_')
1904 .enumerate()
1905 .map(|(i, part)| {
1906 if i == 0 {
1907 part.to_string()
1908 } else {
1909 let mut chars = part.chars();
1910 match chars.next() {
1911 None => String::new(),
1912 Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
1913 }
1914 }
1915 })
1916 .collect::<Vec<_>>()
1917 .join("")
1918}