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 ) -> Result<Vec<GeneratedFile>> {
36 let lang = self.language_name();
37 let output_base = PathBuf::from(e2e_config.effective_output()).join(lang);
38
39 let mut files = Vec::new();
40
41 let dart_pkg = e2e_config.resolve_package("dart");
43 let pkg_name = dart_pkg
44 .as_ref()
45 .and_then(|p| p.name.as_ref())
46 .cloned()
47 .unwrap_or_else(|| config.dart_pubspec_name());
48 let pkg_path = dart_pkg
49 .as_ref()
50 .and_then(|p| p.path.as_ref())
51 .cloned()
52 .unwrap_or_else(|| "../../packages/dart".to_string());
53 let pkg_version = dart_pkg
54 .as_ref()
55 .and_then(|p| p.version.as_ref())
56 .cloned()
57 .or_else(|| config.resolved_version())
58 .unwrap_or_else(|| "0.1.0".to_string());
59
60 files.push(GeneratedFile {
62 path: output_base.join("pubspec.yaml"),
63 content: render_pubspec(&pkg_name, &pkg_path, &pkg_version, e2e_config.dep_mode),
64 generated_header: false,
65 });
66
67 files.push(GeneratedFile {
70 path: output_base.join("dart_test.yaml"),
71 content: concat!(
72 "# Generated by alef — DO NOT EDIT.\n",
73 "# Run test files sequentially to avoid overwhelming the mock server with\n",
74 "# concurrent keep-alive connections.\n",
75 "concurrency: 1\n",
76 )
77 .to_string(),
78 generated_header: false,
79 });
80
81 let test_base = output_base.join("test");
82
83 let bridge_class = config.dart_bridge_class_name();
85
86 let frb_module_name = config.name.replace('-', "_");
90
91 let dart_stub_methods: std::collections::HashSet<String> = config
96 .dart
97 .as_ref()
98 .map(|d| d.stub_methods.iter().cloned().collect())
99 .unwrap_or_default();
100
101 for group in groups {
102 let active: Vec<&Fixture> = group
103 .fixtures
104 .iter()
105 .filter(|f| super::should_include_fixture(f, lang, e2e_config))
106 .filter(|f| {
107 let call_config = e2e_config.resolve_call_for_fixture(f.call.as_deref(), &f.input);
108 let resolved_function = call_config
109 .overrides
110 .get(lang)
111 .and_then(|o| o.function.as_ref())
112 .cloned()
113 .unwrap_or_else(|| call_config.function.clone());
114 !dart_stub_methods.contains(&resolved_function)
115 })
116 .collect();
117
118 if active.is_empty() {
119 continue;
120 }
121
122 let filename = format!("{}_test.dart", sanitize_filename(&group.category));
123 let content = render_test_file(
124 &group.category,
125 &active,
126 e2e_config,
127 lang,
128 &pkg_name,
129 &frb_module_name,
130 &bridge_class,
131 );
132 files.push(GeneratedFile {
133 path: test_base.join(filename),
134 content,
135 generated_header: true,
136 });
137 }
138
139 Ok(files)
140 }
141
142 fn language_name(&self) -> &'static str {
143 "dart"
144 }
145}
146
147fn render_pubspec(
152 pkg_name: &str,
153 pkg_path: &str,
154 pkg_version: &str,
155 dep_mode: crate::config::DependencyMode,
156) -> String {
157 let test_ver = pub_dev::TEST_PACKAGE;
158 let http_ver = pub_dev::HTTP_PACKAGE;
159
160 let dep_block = match dep_mode {
161 crate::config::DependencyMode::Registry => {
162 format!(" {pkg_name}: ^{pkg_version}")
163 }
164 crate::config::DependencyMode::Local => {
165 format!(" {pkg_name}:\n path: {pkg_path}")
166 }
167 };
168
169 let sdk = alef_core::template_versions::toolchain::DART_SDK_CONSTRAINT;
170 format!(
171 r#"name: e2e_dart
172version: 0.1.0
173publish_to: none
174
175environment:
176 sdk: "{sdk}"
177
178dependencies:
179{dep_block}
180
181dev_dependencies:
182 test: {test_ver}
183 http: {http_ver}
184"#
185 )
186}
187
188fn render_test_file(
189 category: &str,
190 fixtures: &[&Fixture],
191 e2e_config: &E2eConfig,
192 lang: &str,
193 pkg_name: &str,
194 frb_module_name: &str,
195 bridge_class: &str,
196) -> String {
197 let mut out = String::new();
198 out.push_str(&hash::header(CommentStyle::DoubleSlash));
199
200 let field_resolver = FieldResolver::new(
205 &e2e_config.fields,
206 &e2e_config.fields_optional,
207 &e2e_config.result_fields,
208 &e2e_config.fields_array,
209 &e2e_config.fields_method_calls,
210 );
211
212 let has_http_fixtures = fixtures.iter().any(|f| f.is_http_test());
214
215 let has_batch_byte_items = fixtures.iter().any(|f| {
217 let call_config = e2e_config.resolve_call_for_fixture(f.call.as_deref(), &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 = e2e_config.resolve_call_for_fixture(f.call.as_deref(), &f.input);
231 call_config
232 .args
233 .iter()
234 .any(|a| a.arg_type == "file_path" || a.arg_type == "bytes")
235 });
236
237 let has_handle_args = fixtures.iter().any(|f| {
243 if f.is_http_test() {
244 return false;
245 }
246 let call_config = e2e_config.resolve_call_for_fixture(f.call.as_deref(), &f.input);
247 call_config
248 .args
249 .iter()
250 .any(|a| a.arg_type == "json_object" && super::resolve_field(&f.input, &a.field).is_array())
251 });
252
253 let _ = writeln!(out, "import 'package:test/test.dart';");
254 if has_http_fixtures || needs_chdir {
258 let _ = writeln!(out, "import 'dart:io';");
259 }
260 if has_batch_byte_items {
261 let _ = writeln!(out, "import 'dart:typed_data';");
262 }
263 let _ = writeln!(out, "import 'package:{pkg_name}/{pkg_name}.dart';");
264 let _ = writeln!(
270 out,
271 "import 'package:{pkg_name}/src/{frb_module_name}_bridge_generated/frb_generated.dart' show RustLib;"
272 );
273 if has_http_fixtures {
274 let _ = writeln!(out, "import 'dart:async';");
275 }
276 if has_http_fixtures || has_handle_args {
278 let _ = writeln!(out, "import 'dart:convert';");
279 }
280 let _ = writeln!(out);
281
282 if has_http_fixtures {
292 let _ = writeln!(out, "HttpClient _httpClient = HttpClient()..maxConnectionsPerHost = 1;");
293 let _ = writeln!(out);
294 let _ = writeln!(out, "var _lock = Future<void>.value();");
295 let _ = writeln!(out);
296 let _ = writeln!(out, "Future<T> _serialized<T>(Future<T> Function() fn) async {{");
297 let _ = writeln!(out, " final current = _lock;");
298 let _ = writeln!(out, " final next = Completer<void>();");
299 let _ = writeln!(out, " _lock = next.future;");
300 let _ = writeln!(out, " try {{");
301 let _ = writeln!(out, " await current;");
302 let _ = writeln!(out, " return await fn();");
303 let _ = writeln!(out, " }} finally {{");
304 let _ = writeln!(out, " next.complete();");
305 let _ = writeln!(out, " }}");
306 let _ = writeln!(out, "}}");
307 let _ = writeln!(out);
308 let _ = writeln!(out, "Future<T> _withRetry<T>(Future<T> Function() fn) async {{");
311 let _ = writeln!(out, " try {{");
312 let _ = writeln!(out, " return await fn();");
313 let _ = writeln!(out, " }} on SocketException {{");
314 let _ = writeln!(out, " _httpClient.close(force: true);");
315 let _ = writeln!(out, " _httpClient = HttpClient()..maxConnectionsPerHost = 1;");
316 let _ = writeln!(out, " return fn();");
317 let _ = writeln!(out, " }} on HttpException {{");
318 let _ = writeln!(out, " _httpClient.close(force: true);");
319 let _ = writeln!(out, " _httpClient = HttpClient()..maxConnectionsPerHost = 1;");
320 let _ = writeln!(out, " return fn();");
321 let _ = writeln!(out, " }}");
322 let _ = writeln!(out, "}}");
323 let _ = writeln!(out);
324 }
325
326 let _ = writeln!(out, "// E2e tests for category: {category}");
327 let _ = writeln!(out, "void main() {{");
328
329 let _ = writeln!(out, " setUpAll(() async {{");
336 let _ = writeln!(out, " await RustLib.init();");
337 if needs_chdir {
338 let test_docs_path = e2e_config.test_documents_relative_from(0);
339 let _ = writeln!(
340 out,
341 " final _testDocs = Platform.environment['FIXTURES_DIR'] ?? '{test_docs_path}';"
342 );
343 let _ = writeln!(out, " final _dir = Directory(_testDocs);");
344 let _ = writeln!(out, " if (_dir.existsSync()) Directory.current = _dir;");
345 }
346 let _ = writeln!(out, " }});");
347 let _ = writeln!(out);
348
349 if has_http_fixtures {
351 let _ = writeln!(out, " tearDownAll(() => _httpClient.close());");
352 let _ = writeln!(out);
353 }
354
355 for fixture in fixtures {
356 render_test_case(&mut out, fixture, e2e_config, lang, bridge_class, &field_resolver);
357 }
358
359 let _ = writeln!(out, "}}");
360 out
361}
362
363fn render_test_case(
364 out: &mut String,
365 fixture: &Fixture,
366 e2e_config: &E2eConfig,
367 lang: &str,
368 bridge_class: &str,
369 field_resolver: &FieldResolver,
370) {
371 if let Some(http) = &fixture.http {
373 render_http_test_case(out, fixture, http);
374 return;
375 }
376
377 let call_config = e2e_config.resolve_call_for_fixture(fixture.call.as_deref(), &fixture.input);
379 let call_overrides = call_config.overrides.get(lang);
380 let mut function_name = call_overrides
381 .and_then(|o| o.function.as_ref())
382 .cloned()
383 .unwrap_or_else(|| call_config.function.clone());
384 function_name = function_name
386 .split('_')
387 .enumerate()
388 .map(|(i, part)| {
389 if i == 0 {
390 part.to_string()
391 } else {
392 let mut chars = part.chars();
393 match chars.next() {
394 None => String::new(),
395 Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
396 }
397 }
398 })
399 .collect::<Vec<_>>()
400 .join("");
401 let result_var = &call_config.result_var;
402 let description = escape_dart(&fixture.description);
403 let fixture_id = &fixture.id;
404 let _is_async = call_overrides.and_then(|o| o.r#async).unwrap_or(call_config.r#async);
407
408 let expects_error = fixture.assertions.iter().any(|a| a.assertion_type == "error");
409 let is_streaming = crate::codegen::streaming_assertions::resolve_is_streaming(fixture, call_config.streaming);
410 let result_is_simple = call_overrides.is_some_and(|o| o.result_is_simple) || call_config.result_is_simple;
415
416 let options_type: Option<&str> = call_overrides.and_then(|o| o.options_type.as_deref());
423 let options_via: &str = call_overrides
424 .and_then(|o| o.options_via.as_deref())
425 .unwrap_or("kwargs");
426
427 let file_path_for_mime: Option<&str> = call_config
435 .args
436 .iter()
437 .find(|a| a.arg_type == "file_path")
438 .and_then(|a| resolve_field(&fixture.input, &a.field).as_str());
439
440 let has_file_path_arg = call_config.args.iter().any(|a| a.arg_type == "file_path");
447 let caller_supplied_override = call_overrides.and_then(|o| o.function.as_ref()).is_some();
450 if has_file_path_arg && !caller_supplied_override {
451 function_name = match function_name.as_str() {
452 "extractFile" => "extractBytes".to_string(),
453 "extractFileSync" => "extractBytesSync".to_string(),
454 other => other.to_string(),
455 };
456 }
457
458 let mut setup_lines: Vec<String> = Vec::new();
461 let mut args = Vec::new();
462
463 for arg_def in &call_config.args {
464 match arg_def.arg_type.as_str() {
465 "mock_url" => {
466 let name = arg_def.name.clone();
467 if fixture.has_host_root_route() {
468 let env_key = format!("MOCK_SERVER_{}", fixture_id.to_uppercase());
469 setup_lines.push(format!(
470 r#"final {name} = Platform.environment["{env_key}"] ?? (Platform.environment["MOCK_SERVER_URL"]! + "/fixtures/{fixture_id}");"#
471 ));
472 } else {
473 setup_lines.push(format!(
474 r#"final {name} = "${{Platform.environment["MOCK_SERVER_URL"] ?? "http://localhost:8080"}}/fixtures/{fixture_id}";"#
475 ));
476 }
477 args.push(name);
478 continue;
479 }
480 "handle" => {
481 let name = arg_def.name.clone();
482 let field = arg_def.field.strip_prefix("input.").unwrap_or(&arg_def.field);
483 let config_value = fixture.input.get(field).cloned().unwrap_or(serde_json::Value::Null);
484 let create_fn = {
486 let mut chars = name.chars();
487 let pascal = match chars.next() {
488 None => String::new(),
489 Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
490 };
491 format!("create{pascal}")
492 };
493 if config_value.is_null()
494 || config_value.is_object() && config_value.as_object().is_some_and(|o| o.is_empty())
495 {
496 setup_lines.push(format!("final {name} = await {bridge_class}.{create_fn}();"));
497 } else {
498 let json_str = serde_json::to_string(&config_value).unwrap_or_default();
499 let config_var = format!("{name}Config");
500 setup_lines.push(format!(
505 "final {config_var} = await createCrawlConfigFromJson(json: r'{json_str}');"
506 ));
507 setup_lines.push(format!(
509 "final {name} = await {bridge_class}.{create_fn}(config: {config_var});"
510 ));
511 }
512 args.push(name);
513 continue;
514 }
515 _ => {}
516 }
517
518 let arg_value = resolve_field(&fixture.input, &arg_def.field);
519 match arg_def.arg_type.as_str() {
520 "bytes" | "file_path" => {
521 if let serde_json::Value::String(file_path) = arg_value {
526 args.push(format!("File('{}').readAsBytesSync()", file_path));
527 }
528 }
529 "string" => {
530 let dart_param_name = snake_to_camel(&arg_def.name);
545 let mime_required_due_to_remap = has_file_path_arg
546 && arg_def.name == "mime_type"
547 && (function_name == "extractBytes" || function_name == "extractBytesSync");
548 let is_optional = arg_def.optional && !mime_required_due_to_remap;
549 match arg_value {
550 serde_json::Value::String(s) => {
551 let literal = format!("'{}'", escape_dart(s));
552 if is_optional {
553 args.push(format!("{dart_param_name}: {literal}"));
554 } else {
555 args.push(literal);
556 }
557 }
558 serde_json::Value::Null
559 if arg_def.optional
560 && arg_def.name == "mime_type" =>
563 {
564 let inferred = file_path_for_mime
565 .and_then(mime_from_extension)
566 .unwrap_or("application/octet-stream");
567 if is_optional {
568 args.push(format!("{dart_param_name}: '{inferred}'"));
569 } else {
570 args.push(format!("'{inferred}'"));
571 }
572 }
573 _ => {}
575 }
576 }
577 "json_object" => {
578 if let Some(elem_type) = &arg_def.element_type {
580 if (elem_type == "BatchBytesItem" || elem_type == "BatchFileItem") && arg_value.is_array() {
581 let dart_items = emit_dart_batch_item_array(arg_value, elem_type);
582 args.push(dart_items);
583 } else if elem_type == "String" && arg_value.is_array() {
584 let items: Vec<String> = arg_value
591 .as_array()
592 .unwrap()
593 .iter()
594 .filter_map(|v| v.as_str())
595 .map(|s| format!("'{}'", escape_dart(s)))
596 .collect();
597 args.push(format!("<String>[{}]", items.join(", ")));
598 }
599 } else if options_via == "from_json" {
600 if let Some(opts_type) = options_type {
610 if !arg_value.is_null() {
611 let json_str = serde_json::to_string(&arg_value).unwrap_or_default();
612 let escaped_json = escape_dart(&json_str);
615 let var_name = format!("_{}", arg_def.name);
616 let dart_fn = type_name_to_create_from_json_dart(opts_type);
617 setup_lines.push(format!("final {var_name} = await {dart_fn}(json: '{escaped_json}');"));
618 args.push(format!("req: {var_name}"));
621 }
622 }
623 } else if arg_def.name == "config" {
624 if let serde_json::Value::Object(map) = &arg_value {
625 if !map.is_empty() {
626 let explicit_options =
635 options_type.is_some_and(|t| t != "ExtractionConfig" && t != "FileExtractionConfig");
636 let has_non_scalar = map.values().any(|v| {
637 matches!(
638 v,
639 serde_json::Value::String(_)
640 | serde_json::Value::Object(_)
641 | serde_json::Value::Array(_)
642 )
643 });
644 if explicit_options || has_non_scalar {
645 let opts_type = options_type.unwrap_or("ExtractionConfig");
646 let json_str = serde_json::to_string(&arg_value).unwrap_or_default();
647 let escaped_json = escape_dart(&json_str);
648 let var_name = format!("_{}", arg_def.name);
649 let dart_fn = type_name_to_create_from_json_dart(opts_type);
650 setup_lines
651 .push(format!("final {var_name} = await {dart_fn}(json: '{escaped_json}');"));
652 args.push(var_name);
653 } else {
654 args.push(emit_extraction_config_dart(map));
660 }
661 }
662 }
663 } else if arg_value.is_array() {
665 let json_str = serde_json::to_string(&arg_value).unwrap_or_default();
668 let var_name = arg_def.name.clone();
669 setup_lines.push(format!(
670 "final {var_name} = (jsonDecode(r'{json_str}') as List<dynamic>).cast<String>();"
671 ));
672 args.push(var_name);
673 } else if let serde_json::Value::Object(map) = &arg_value {
674 if !map.is_empty() {
688 if let Some(opts_type) = options_type {
689 let json_str = serde_json::to_string(&arg_value).unwrap_or_default();
690 let escaped_json = escape_dart(&json_str);
691 let dart_param_name = snake_to_camel(&arg_def.name);
692 let var_name = format!("_{}", arg_def.name);
693 let dart_fn = type_name_to_create_from_json_dart(opts_type);
694 if fixture.visitor.is_some() {
695 setup_lines.push(format!(
696 "final {var_name} = await {dart_fn}WithVisitor(json: '{escaped_json}', visitor: _visitor);"
697 ));
698 } else {
699 setup_lines
700 .push(format!("final {var_name} = await {dart_fn}(json: '{escaped_json}');"));
701 }
702 if arg_def.optional {
703 args.push(format!("{dart_param_name}: {var_name}"));
704 } else {
705 args.push(var_name);
706 }
707 }
708 }
709 }
710 }
711 _ => {}
712 }
713 }
714
715 if let Some(visitor_spec) = &fixture.visitor {
730 let mut visitor_setup: Vec<String> = Vec::new();
731 let _ = super::dart_visitors::build_dart_visitor(&mut visitor_setup, visitor_spec);
732 for line in visitor_setup.into_iter().rev() {
735 setup_lines.insert(0, line);
736 }
737
738 let already_has_options = args.iter().any(|a| a.starts_with("options:") || a == "_options");
742 if !already_has_options {
743 if let Some(opts_type) = options_type {
744 let dart_fn = type_name_to_create_from_json_dart(opts_type);
745 setup_lines.push(format!(
746 "final _options = await {dart_fn}WithVisitor(json: '{{}}', visitor: _visitor);"
747 ));
748 args.push("options: _options".to_string());
749 }
750 }
751 }
752
753 let client_factory: Option<&str> = call_overrides.and_then(|o| o.client_factory.as_deref()).or_else(|| {
757 e2e_config
758 .call
759 .overrides
760 .get(lang)
761 .and_then(|o| o.client_factory.as_deref())
762 });
763
764 let client_factory_camel: Option<String> = client_factory.map(|f| {
766 f.split('_')
767 .enumerate()
768 .map(|(i, part)| {
769 if i == 0 {
770 part.to_string()
771 } else {
772 let mut chars = part.chars();
773 match chars.next() {
774 None => String::new(),
775 Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
776 }
777 }
778 })
779 .collect::<Vec<_>>()
780 .join("")
781 });
782
783 let _ = writeln!(out, " test('{description}', () async {{");
787
788 let args_str = args.join(", ");
789 let receiver_class = call_overrides
790 .and_then(|o| o.class.as_ref())
791 .cloned()
792 .unwrap_or_else(|| bridge_class.to_string());
793
794 let (receiver, extra_setup): (String, Option<String>) = if let Some(factory) = &client_factory_camel {
798 let has_mock_url = call_config.args.iter().any(|a| a.arg_type == "mock_url");
799 let mock_url_setup = if !has_mock_url {
800 if fixture.has_host_root_route() {
802 let env_key = format!("MOCK_SERVER_{}", fixture_id.to_uppercase());
803 Some(format!(
804 "final _mockUrl = Platform.environment[\"{env_key}\"] ?? (Platform.environment[\"MOCK_SERVER_URL\"]! + \"/fixtures/{fixture_id}\");"
805 ))
806 } else {
807 Some(format!(
808 r#"final _mockUrl = "${{Platform.environment["MOCK_SERVER_URL"] ?? "http://localhost:8080"}}/fixtures/{fixture_id}";"#
809 ))
810 }
811 } else {
812 None
813 };
814 let url_expr = if has_mock_url {
815 call_config
818 .args
819 .iter()
820 .find(|a| a.arg_type == "mock_url")
821 .map(|a| a.name.clone())
822 .unwrap_or_else(|| "_mockUrl".to_string())
823 } else {
824 "_mockUrl".to_string()
825 };
826 let create_line = format!("final _client = await {receiver_class}.{factory}('test-key', baseUrl: {url_expr});");
827 let full_setup = if let Some(url_line) = mock_url_setup {
828 Some(format!("{url_line}\n {create_line}"))
829 } else {
830 Some(create_line)
831 };
832 ("_client".to_string(), full_setup)
833 } else {
834 (receiver_class.clone(), None)
835 };
836
837 if expects_error && (!setup_lines.is_empty() || extra_setup.is_some()) {
838 let _ = writeln!(out, " await expectLater(() async {{");
842 for line in &setup_lines {
843 let _ = writeln!(out, " {line}");
844 }
845 if let Some(extra) = &extra_setup {
846 for line in extra.lines() {
847 let _ = writeln!(out, " {line}");
848 }
849 }
850 if is_streaming {
851 let _ = writeln!(out, " return {receiver}.{function_name}({args_str}).toList();");
852 } else {
853 let _ = writeln!(out, " return {receiver}.{function_name}({args_str});");
854 }
855 let _ = writeln!(out, " }}(), throwsA(anything));");
856 } else if expects_error {
857 if let Some(extra) = &extra_setup {
859 for line in extra.lines() {
860 let _ = writeln!(out, " {line}");
861 }
862 }
863 if is_streaming {
864 let _ = writeln!(
865 out,
866 " await expectLater({receiver}.{function_name}({args_str}).toList(), throwsA(anything));"
867 );
868 } else {
869 let _ = writeln!(
870 out,
871 " await expectLater({receiver}.{function_name}({args_str}), throwsA(anything));"
872 );
873 }
874 } else {
875 for line in &setup_lines {
876 let _ = writeln!(out, " {line}");
877 }
878 if let Some(extra) = &extra_setup {
879 for line in extra.lines() {
880 let _ = writeln!(out, " {line}");
881 }
882 }
883 if is_streaming {
884 let _ = writeln!(
885 out,
886 " final {result_var} = await {receiver}.{function_name}({args_str}).toList();"
887 );
888 } else {
889 let _ = writeln!(
890 out,
891 " final {result_var} = await {receiver}.{function_name}({args_str});"
892 );
893 }
894 for assertion in &fixture.assertions {
895 if is_streaming {
896 render_streaming_assertion_dart(out, assertion, result_var);
897 } else {
898 render_assertion_dart(out, assertion, result_var, result_is_simple, field_resolver);
899 }
900 }
901 }
902
903 let _ = writeln!(out, " }});");
904 let _ = writeln!(out);
905}
906
907fn dart_length_expr(field_accessor: &str, field: Option<&str>, field_resolver: &FieldResolver) -> String {
915 let is_optional = field
916 .map(|f| {
917 let resolved = field_resolver.resolve(f);
918 field_resolver.is_optional(f) || field_resolver.is_optional(resolved)
919 })
920 .unwrap_or(false);
921 if is_optional {
922 format!("{field_accessor}?.length ?? 0")
923 } else {
924 format!("{field_accessor}.length")
925 }
926}
927
928fn dart_format_value(val: &serde_json::Value) -> String {
929 match val {
930 serde_json::Value::String(s) => format!("'{}'", escape_dart(s)),
931 serde_json::Value::Bool(b) => b.to_string(),
932 serde_json::Value::Number(n) => n.to_string(),
933 serde_json::Value::Null => "null".to_string(),
934 other => format!("'{}'", escape_dart(&other.to_string())),
935 }
936}
937
938fn render_assertion_dart(
949 out: &mut String,
950 assertion: &Assertion,
951 result_var: &str,
952 result_is_simple: bool,
953 field_resolver: &FieldResolver,
954) {
955 if !result_is_simple {
959 if let Some(f) = assertion.field.as_deref() {
960 let head = f.split("[].").next().unwrap_or(f);
963 if !head.is_empty() && !field_resolver.is_valid_for_result(head) {
964 let _ = writeln!(out, " // skipped: field '{f}' not available on dart result type");
965 return;
966 }
967 }
968 }
969
970 if let Some(f) = assertion.field.as_deref() {
976 if !f.is_empty() && field_resolver.tagged_union_split(f).is_some() {
977 let _ = writeln!(
978 out,
979 " // skipped: field '{f}' crosses a tagged-union variant boundary (not expressible in Dart)"
980 );
981 return;
982 }
983 }
984
985 if let Some(f) = assertion.field.as_deref() {
987 if let Some(dot) = f.find("[].") {
988 let resolved_full = field_resolver.resolve(f);
993 let (array_part, elem_part) = match resolved_full.find("[].") {
994 Some(rdot) => (&resolved_full[..rdot], &resolved_full[rdot + 3..]),
995 None => (&f[..dot], &f[dot + 3..]),
998 };
999 let array_accessor = if array_part.is_empty() {
1000 result_var.to_string()
1001 } else {
1002 field_resolver.accessor(array_part, "dart", result_var)
1003 };
1004 let elem_accessor = field_to_dart_accessor(elem_part);
1005 match assertion.assertion_type.as_str() {
1006 "contains" => {
1007 if let Some(expected) = &assertion.value {
1008 let dart_val = dart_format_value(expected);
1009 let _ = writeln!(
1010 out,
1011 " expect({array_accessor}.any((e) => e.{elem_accessor}.toString().contains({dart_val})), isTrue);"
1012 );
1013 }
1014 }
1015 "contains_all" => {
1016 if let Some(values) = &assertion.values {
1017 for val in values {
1018 let dart_val = dart_format_value(val);
1019 let _ = writeln!(
1020 out,
1021 " expect({array_accessor}.any((e) => e.{elem_accessor}.toString().contains({dart_val})), isTrue);"
1022 );
1023 }
1024 }
1025 }
1026 "not_contains" => {
1027 if let Some(expected) = &assertion.value {
1028 let dart_val = dart_format_value(expected);
1029 let _ = writeln!(
1030 out,
1031 " expect({array_accessor}.any((e) => e.{elem_accessor}.toString().contains({dart_val})), isFalse);"
1032 );
1033 } else if let Some(values) = &assertion.values {
1034 for val in values {
1035 let dart_val = dart_format_value(val);
1036 let _ = writeln!(
1037 out,
1038 " expect({array_accessor}.any((e) => e.{elem_accessor}.toString().contains({dart_val})), isFalse);"
1039 );
1040 }
1041 }
1042 }
1043 "not_empty" => {
1044 let _ = writeln!(
1045 out,
1046 " expect({array_accessor}.any((e) => e.{elem_accessor}.toString().isNotEmpty), isTrue);"
1047 );
1048 }
1049 other => {
1050 let _ = writeln!(
1051 out,
1052 " // skipped: unsupported traversal assertion '{other}' on '{f}'"
1053 );
1054 }
1055 }
1056 return;
1057 }
1058 }
1059
1060 let field_accessor = if result_is_simple {
1061 result_var.to_string()
1065 } else {
1066 match assertion.field.as_deref() {
1067 Some(f) if !f.is_empty() => field_resolver.accessor(f, "dart", result_var),
1072 _ => result_var.to_string(),
1073 }
1074 };
1075
1076 let format_value = |val: &serde_json::Value| -> String { dart_format_value(val) };
1077
1078 match assertion.assertion_type.as_str() {
1079 "equals" | "field_equals" => {
1080 if let Some(expected) = &assertion.value {
1081 let dart_val = format_value(expected);
1082 if expected.is_string() {
1086 let _ = writeln!(
1087 out,
1088 " expect({field_accessor}.toString().trim(), equals({dart_val}.toString().trim()));"
1089 );
1090 } else {
1091 let _ = writeln!(out, " expect({field_accessor}, equals({dart_val}));");
1092 }
1093 } else {
1094 let _ = writeln!(
1095 out,
1096 " // skipped: '{}' assertion missing value",
1097 assertion.assertion_type
1098 );
1099 }
1100 }
1101 "not_equals" => {
1102 if let Some(expected) = &assertion.value {
1103 let dart_val = format_value(expected);
1104 if expected.is_string() {
1105 let _ = writeln!(
1106 out,
1107 " expect({field_accessor}.toString().trim(), isNot(equals({dart_val}.toString().trim())));"
1108 );
1109 } else {
1110 let _ = writeln!(out, " expect({field_accessor}, isNot(equals({dart_val})));");
1111 }
1112 }
1113 }
1114 "contains" => {
1115 if let Some(expected) = &assertion.value {
1116 let dart_val = format_value(expected);
1117 let _ = writeln!(out, " expect({field_accessor}, contains({dart_val}));");
1118 } else {
1119 let _ = writeln!(out, " // skipped: 'contains' assertion missing value");
1120 }
1121 }
1122 "contains_all" => {
1123 if let Some(values) = &assertion.values {
1124 for val in values {
1125 let dart_val = format_value(val);
1126 let _ = writeln!(out, " expect({field_accessor}, contains({dart_val}));");
1127 }
1128 }
1129 }
1130 "contains_any" => {
1131 if let Some(values) = &assertion.values {
1132 let checks: Vec<String> = values
1133 .iter()
1134 .map(|v| {
1135 let dart_val = format_value(v);
1136 format!("{field_accessor}.contains({dart_val})")
1137 })
1138 .collect();
1139 let joined = checks.join(" || ");
1140 let _ = writeln!(out, " expect({joined}, isTrue);");
1141 }
1142 }
1143 "not_contains" => {
1144 if let Some(expected) = &assertion.value {
1145 let dart_val = format_value(expected);
1146 let _ = writeln!(out, " expect({field_accessor}, isNot(contains({dart_val})));");
1147 } else if let Some(values) = &assertion.values {
1148 for val in values {
1149 let dart_val = format_value(val);
1150 let _ = writeln!(out, " expect({field_accessor}, isNot(contains({dart_val})));");
1151 }
1152 }
1153 }
1154 "not_empty" => {
1155 let is_collection = assertion.field.as_deref().is_some_and(|f| {
1160 let resolved = field_resolver.resolve(f);
1161 field_resolver.is_array(f) || field_resolver.is_array(resolved)
1162 });
1163 if is_collection {
1164 let _ = writeln!(out, " expect({field_accessor}, isNotEmpty);");
1165 } else {
1166 let _ = writeln!(out, " expect({field_accessor}, isNotNull);");
1167 }
1168 }
1169 "is_empty" => {
1170 let _ = writeln!(out, " expect({field_accessor}, anyOf(isNull, isEmpty));");
1174 }
1175 "starts_with" => {
1176 if let Some(expected) = &assertion.value {
1177 let dart_val = format_value(expected);
1178 let _ = writeln!(out, " expect({field_accessor}, startsWith({dart_val}));");
1179 }
1180 }
1181 "ends_with" => {
1182 if let Some(expected) = &assertion.value {
1183 let dart_val = format_value(expected);
1184 let _ = writeln!(out, " expect({field_accessor}, endsWith({dart_val}));");
1185 }
1186 }
1187 "min_length" => {
1188 if let Some(val) = &assertion.value {
1189 if let Some(n) = val.as_u64() {
1190 let length_expr = dart_length_expr(&field_accessor, assertion.field.as_deref(), field_resolver);
1191 let _ = writeln!(out, " expect({length_expr}, greaterThanOrEqualTo({n}));");
1192 }
1193 }
1194 }
1195 "max_length" => {
1196 if let Some(val) = &assertion.value {
1197 if let Some(n) = val.as_u64() {
1198 let length_expr = dart_length_expr(&field_accessor, assertion.field.as_deref(), field_resolver);
1199 let _ = writeln!(out, " expect({length_expr}, lessThanOrEqualTo({n}));");
1200 }
1201 }
1202 }
1203 "count_equals" => {
1204 if let Some(val) = &assertion.value {
1205 if let Some(n) = val.as_u64() {
1206 let length_expr = dart_length_expr(&field_accessor, assertion.field.as_deref(), field_resolver);
1207 let _ = writeln!(out, " expect({length_expr}, equals({n}));");
1208 }
1209 }
1210 }
1211 "count_min" => {
1212 if let Some(val) = &assertion.value {
1213 if let Some(n) = val.as_u64() {
1214 let length_expr = dart_length_expr(&field_accessor, assertion.field.as_deref(), field_resolver);
1215 let _ = writeln!(out, " expect({length_expr}, greaterThanOrEqualTo({n}));");
1216 }
1217 }
1218 }
1219 "matches_regex" => {
1220 if let Some(expected) = &assertion.value {
1221 let dart_val = format_value(expected);
1222 let _ = writeln!(out, " expect({field_accessor}, matches(RegExp({dart_val})));");
1223 }
1224 }
1225 "is_true" => {
1226 let _ = writeln!(out, " expect({field_accessor}, isTrue);");
1227 }
1228 "is_false" => {
1229 let _ = writeln!(out, " expect({field_accessor}, isFalse);");
1230 }
1231 "greater_than" => {
1232 if let Some(val) = &assertion.value {
1233 let dart_val = format_value(val);
1234 let _ = writeln!(out, " expect({field_accessor}, greaterThan({dart_val}));");
1235 }
1236 }
1237 "less_than" => {
1238 if let Some(val) = &assertion.value {
1239 let dart_val = format_value(val);
1240 let _ = writeln!(out, " expect({field_accessor}, lessThan({dart_val}));");
1241 }
1242 }
1243 "greater_than_or_equal" => {
1244 if let Some(val) = &assertion.value {
1245 let dart_val = format_value(val);
1246 let _ = writeln!(out, " expect({field_accessor}, greaterThanOrEqualTo({dart_val}));");
1247 }
1248 }
1249 "less_than_or_equal" => {
1250 if let Some(val) = &assertion.value {
1251 let dart_val = format_value(val);
1252 let _ = writeln!(out, " expect({field_accessor}, lessThanOrEqualTo({dart_val}));");
1253 }
1254 }
1255 "not_null" => {
1256 let _ = writeln!(out, " expect({field_accessor}, isNotNull);");
1257 }
1258 "not_error" => {
1259 }
1261 "error" => {
1262 }
1264 "method_result" => {
1265 if let Some(method) = &assertion.method {
1266 let dart_method = method.to_lower_camel_case();
1267 let check = assertion.check.as_deref().unwrap_or("not_null");
1268 let method_call = format!("{field_accessor}.{dart_method}()");
1269 match check {
1270 "equals" => {
1271 if let Some(expected) = &assertion.value {
1272 let dart_val = format_value(expected);
1273 let _ = writeln!(out, " expect({method_call}, equals({dart_val}));");
1274 }
1275 }
1276 "is_true" => {
1277 let _ = writeln!(out, " expect({method_call}, isTrue);");
1278 }
1279 "is_false" => {
1280 let _ = writeln!(out, " expect({method_call}, isFalse);");
1281 }
1282 "greater_than_or_equal" => {
1283 if let Some(val) = &assertion.value {
1284 let dart_val = format_value(val);
1285 let _ = writeln!(out, " expect({method_call}, greaterThanOrEqualTo({dart_val}));");
1286 }
1287 }
1288 "count_min" => {
1289 if let Some(val) = &assertion.value {
1290 if let Some(n) = val.as_u64() {
1291 let _ = writeln!(out, " expect({method_call}.length, greaterThanOrEqualTo({n}));");
1292 }
1293 }
1294 }
1295 _ => {
1296 let _ = writeln!(out, " expect({method_call}, isNotNull);");
1297 }
1298 }
1299 }
1300 }
1301 other => {
1302 let _ = writeln!(out, " // skipped: unknown assertion type '{other}'");
1303 }
1304 }
1305}
1306
1307fn render_streaming_assertion_dart(out: &mut String, assertion: &Assertion, result_var: &str) {
1317 match assertion.assertion_type.as_str() {
1318 "not_error" => {
1319 }
1321 "count_min" if assertion.field.as_deref() == Some("chunks") => {
1322 if let Some(serde_json::Value::Number(n)) = &assertion.value {
1323 let _ = writeln!(out, " expect({result_var}.length, greaterThanOrEqualTo({n}));");
1324 }
1325 }
1326 "equals" if assertion.field.as_deref() == Some("stream_content") => {
1327 if let Some(serde_json::Value::String(expected)) = &assertion.value {
1328 let escaped = escape_dart(expected);
1329 let _ = writeln!(
1330 out,
1331 " final _content = {result_var}.map((c) => c.choices.firstOrNull?.delta.content ?? '').join();"
1332 );
1333 let _ = writeln!(out, " expect(_content, equals('{escaped}'));");
1334 }
1335 }
1336 other => {
1337 let _ = writeln!(out, " // skipped streaming assertion: '{other}'");
1338 }
1339 }
1340}
1341
1342fn snake_to_camel(s: &str) -> String {
1344 let mut result = String::with_capacity(s.len());
1345 let mut next_upper = false;
1346 for ch in s.chars() {
1347 if ch == '_' {
1348 next_upper = true;
1349 } else if next_upper {
1350 result.extend(ch.to_uppercase());
1351 next_upper = false;
1352 } else {
1353 result.push(ch);
1354 }
1355 }
1356 result
1357}
1358
1359fn field_to_dart_accessor(path: &str) -> String {
1372 let mut result = String::with_capacity(path.len());
1373 for (i, segment) in path.split('.').enumerate() {
1374 if i > 0 {
1375 result.push('.');
1376 }
1377 if let Some(bracket_pos) = segment.find('[') {
1383 let name = &segment[..bracket_pos];
1384 let bracket = &segment[bracket_pos..];
1385 result.push_str(&name.to_lower_camel_case());
1386 result.push('!');
1387 result.push_str(bracket);
1388 } else {
1389 result.push_str(&segment.to_lower_camel_case());
1390 }
1391 }
1392 result
1393}
1394
1395fn emit_extraction_config_dart(overrides: &serde_json::Map<String, serde_json::Value>) -> String {
1401 let mut field_overrides: std::collections::HashMap<String, String> = std::collections::HashMap::new();
1403 for (key, val) in overrides {
1404 let camel = snake_to_camel(key);
1405 let dart_val = match val {
1406 serde_json::Value::Bool(b) => {
1407 if *b {
1408 "true".to_string()
1409 } else {
1410 "false".to_string()
1411 }
1412 }
1413 serde_json::Value::Number(n) => n.to_string(),
1414 serde_json::Value::String(s) => format!("'{s}'"),
1415 _ => continue, };
1417 field_overrides.insert(camel, dart_val);
1418 }
1419
1420 let use_cache = field_overrides.remove("useCache").unwrap_or_else(|| "true".to_string());
1421 let enable_quality_processing = field_overrides
1422 .remove("enableQualityProcessing")
1423 .unwrap_or_else(|| "true".to_string());
1424 let force_ocr = field_overrides
1425 .remove("forceOcr")
1426 .unwrap_or_else(|| "false".to_string());
1427 let disable_ocr = field_overrides
1428 .remove("disableOcr")
1429 .unwrap_or_else(|| "false".to_string());
1430 let include_document_structure = field_overrides
1431 .remove("includeDocumentStructure")
1432 .unwrap_or_else(|| "false".to_string());
1433 let use_layout_for_markdown = field_overrides
1434 .remove("useLayoutForMarkdown")
1435 .unwrap_or_else(|| "false".to_string());
1436 let max_archive_depth = field_overrides
1437 .remove("maxArchiveDepth")
1438 .unwrap_or_else(|| "3".to_string());
1439
1440 format!(
1441 "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})"
1442 )
1443}
1444
1445struct DartTestClientRenderer {
1461 in_skip: Cell<bool>,
1464 is_redirect: Cell<bool>,
1467}
1468
1469impl DartTestClientRenderer {
1470 fn new(is_redirect: bool) -> Self {
1471 Self {
1472 in_skip: Cell::new(false),
1473 is_redirect: Cell::new(is_redirect),
1474 }
1475 }
1476}
1477
1478impl client::TestClientRenderer for DartTestClientRenderer {
1479 fn language_name(&self) -> &'static str {
1480 "dart"
1481 }
1482
1483 fn render_test_open(&self, out: &mut String, _fn_name: &str, description: &str, skip_reason: Option<&str>) {
1492 let escaped_desc = escape_dart(description);
1493 if let Some(reason) = skip_reason {
1494 let escaped_reason = escape_dart(reason);
1495 let _ = writeln!(out, " test('{escaped_desc}', () {{");
1496 let _ = writeln!(out, " markTestSkipped('{escaped_reason}');");
1497 let _ = writeln!(out, " }});");
1498 let _ = writeln!(out);
1499 self.in_skip.set(true);
1500 } else {
1501 let _ = writeln!(
1502 out,
1503 " test('{escaped_desc}', () => _serialized(() => _withRetry(() async {{"
1504 );
1505 self.in_skip.set(false);
1506 }
1507 }
1508
1509 fn render_test_close(&self, out: &mut String) {
1514 if self.in_skip.get() {
1515 return;
1517 }
1518 let _ = writeln!(out, " }})));");
1519 let _ = writeln!(out);
1520 }
1521
1522 fn render_call(&self, out: &mut String, ctx: &client::CallCtx<'_>) {
1532 const DART_RESTRICTED_HEADERS: &[&str] = &["content-length", "host", "transfer-encoding"];
1534
1535 let method = ctx.method.to_uppercase();
1536 let escaped_method = escape_dart(&method);
1537
1538 let fixture_path = escape_dart(ctx.path);
1540
1541 let has_explicit_content_type = ctx.headers.keys().any(|k| k.to_lowercase() == "content-type");
1543 let effective_content_type = if has_explicit_content_type {
1544 ctx.headers
1545 .iter()
1546 .find(|(k, _)| k.to_lowercase() == "content-type")
1547 .map(|(_, v)| v.as_str())
1548 .unwrap_or("application/json")
1549 } else if ctx.body.is_some() {
1550 ctx.content_type.unwrap_or("application/json")
1551 } else {
1552 ""
1553 };
1554
1555 let _ = writeln!(
1556 out,
1557 " final baseUrl = Platform.environment['MOCK_SERVER_URL'] ?? 'http://localhost:8080';"
1558 );
1559 let _ = writeln!(out, " final uri = Uri.parse('$baseUrl{fixture_path}');");
1560 let _ = writeln!(
1561 out,
1562 " final ioReq = await _httpClient.openUrl('{escaped_method}', uri);"
1563 );
1564
1565 if self.is_redirect.get() {
1568 let _ = writeln!(out, " ioReq.followRedirects = false;");
1569 }
1570
1571 if !effective_content_type.is_empty() {
1573 let escaped_ct = escape_dart(effective_content_type);
1574 let _ = writeln!(out, " ioReq.headers.set('content-type', '{escaped_ct}');");
1575 }
1576
1577 let mut header_pairs: Vec<(&String, &String)> = ctx.headers.iter().collect();
1579 header_pairs.sort_by_key(|(k, _)| k.as_str());
1580 for (name, value) in &header_pairs {
1581 if DART_RESTRICTED_HEADERS.contains(&name.to_lowercase().as_str()) {
1582 continue;
1583 }
1584 if name.to_lowercase() == "content-type" {
1585 continue; }
1587 let escaped_name = escape_dart(&name.to_lowercase());
1588 let escaped_value = escape_dart(value);
1589 let _ = writeln!(out, " ioReq.headers.set('{escaped_name}', '{escaped_value}');");
1590 }
1591
1592 if !ctx.cookies.is_empty() {
1594 let mut cookie_pairs: Vec<(&String, &String)> = ctx.cookies.iter().collect();
1595 cookie_pairs.sort_by_key(|(k, _)| k.as_str());
1596 let cookie_str: Vec<String> = cookie_pairs.iter().map(|(k, v)| format!("{k}={v}")).collect();
1597 let cookie_header = escape_dart(&cookie_str.join("; "));
1598 let _ = writeln!(out, " ioReq.headers.set('cookie', '{cookie_header}');");
1599 }
1600
1601 if let Some(body) = ctx.body {
1603 let json_str = serde_json::to_string(body).unwrap_or_default();
1604 let escaped = escape_dart(&json_str);
1605 let _ = writeln!(out, " final bodyBytes = utf8.encode('{escaped}');");
1606 let _ = writeln!(out, " ioReq.add(bodyBytes);");
1607 }
1608
1609 let _ = writeln!(out, " final ioResp = await ioReq.close();");
1610 if !self.is_redirect.get() {
1614 let _ = writeln!(out, " final bodyStr = await ioResp.transform(utf8.decoder).join();");
1615 };
1616 }
1617
1618 fn render_assert_status(&self, out: &mut String, _response_var: &str, status: u16) {
1619 let _ = writeln!(
1620 out,
1621 " expect(ioResp.statusCode, equals({status}), reason: 'status code mismatch');"
1622 );
1623 }
1624
1625 fn render_assert_header(&self, out: &mut String, _response_var: &str, name: &str, expected: &str) {
1628 let escaped_name = escape_dart(&name.to_lowercase());
1629 match expected {
1630 "<<present>>" => {
1631 let _ = writeln!(
1632 out,
1633 " expect(ioResp.headers.value('{escaped_name}'), isNotNull, reason: 'header {escaped_name} should be present');"
1634 );
1635 }
1636 "<<absent>>" => {
1637 let _ = writeln!(
1638 out,
1639 " expect(ioResp.headers.value('{escaped_name}'), isNull, reason: 'header {escaped_name} should be absent');"
1640 );
1641 }
1642 "<<uuid>>" => {
1643 let _ = writeln!(
1644 out,
1645 " 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');"
1646 );
1647 }
1648 exact => {
1649 let escaped_value = escape_dart(exact);
1650 let _ = writeln!(
1651 out,
1652 " expect(ioResp.headers.value('{escaped_name}'), contains('{escaped_value}'), reason: 'header {escaped_name} mismatch');"
1653 );
1654 }
1655 }
1656 }
1657
1658 fn render_assert_json_body(&self, out: &mut String, _response_var: &str, expected: &serde_json::Value) {
1663 match expected {
1664 serde_json::Value::Object(_) | serde_json::Value::Array(_) => {
1665 let json_str = serde_json::to_string(expected).unwrap_or_default();
1666 let escaped = escape_dart(&json_str);
1667 let _ = writeln!(out, " final bodyJson = jsonDecode(bodyStr);");
1668 let _ = writeln!(out, " final expectedJson = jsonDecode('{escaped}');");
1669 let _ = writeln!(
1670 out,
1671 " expect(bodyJson, equals(expectedJson), reason: 'body mismatch');"
1672 );
1673 }
1674 serde_json::Value::String(s) => {
1675 let escaped = escape_dart(s);
1676 let _ = writeln!(
1677 out,
1678 " expect(bodyStr.trim(), equals('{escaped}'), reason: 'body mismatch');"
1679 );
1680 }
1681 other => {
1682 let escaped = escape_dart(&other.to_string());
1683 let _ = writeln!(
1684 out,
1685 " expect(bodyStr.trim(), equals('{escaped}'), reason: 'body mismatch');"
1686 );
1687 }
1688 }
1689 }
1690
1691 fn render_assert_partial_body(&self, out: &mut String, _response_var: &str, expected: &serde_json::Value) {
1694 let _ = writeln!(
1695 out,
1696 " final partialJson = jsonDecode(bodyStr) as Map<String, dynamic>;"
1697 );
1698 if let Some(obj) = expected.as_object() {
1699 for (idx, (key, val)) in obj.iter().enumerate() {
1700 let escaped_key = escape_dart(key);
1701 let json_val = serde_json::to_string(val).unwrap_or_default();
1702 let escaped_val = escape_dart(&json_val);
1703 let _ = writeln!(out, " final _expectedField{idx} = jsonDecode('{escaped_val}');");
1706 let _ = writeln!(
1707 out,
1708 " expect(partialJson['{escaped_key}'], equals(_expectedField{idx}), reason: 'partial body field \\'{escaped_key}\\' mismatch');"
1709 );
1710 }
1711 }
1712 }
1713
1714 fn render_assert_validation_errors(
1716 &self,
1717 out: &mut String,
1718 _response_var: &str,
1719 errors: &[ValidationErrorExpectation],
1720 ) {
1721 let _ = writeln!(out, " final errBody = jsonDecode(bodyStr) as Map<String, dynamic>;");
1722 let _ = writeln!(out, " final errList = (errBody['errors'] ?? []) as List<dynamic>;");
1723 for ve in errors {
1724 let loc_dart: Vec<String> = ve.loc.iter().map(|s| format!("'{}'", escape_dart(s))).collect();
1725 let loc_str = loc_dart.join(", ");
1726 let escaped_msg = escape_dart(&ve.msg);
1727 let _ = writeln!(
1728 out,
1729 " 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}');"
1730 );
1731 }
1732 }
1733}
1734
1735fn render_http_test_case(out: &mut String, fixture: &Fixture, http: &HttpFixture) {
1742 if http.expected_response.status_code == 101 {
1744 let description = escape_dart(&fixture.description);
1745 let _ = writeln!(out, " test('{description}', () {{");
1746 let _ = writeln!(
1747 out,
1748 " markTestSkipped('Skipped: Dart HttpClient cannot handle 101 Switching Protocols responses');"
1749 );
1750 let _ = writeln!(out, " }});");
1751 let _ = writeln!(out);
1752 return;
1753 }
1754
1755 let is_redirect = http.expected_response.status_code / 100 == 3;
1759 client::http_call::render_http_test(out, &DartTestClientRenderer::new(is_redirect), fixture);
1760}
1761
1762fn mime_from_extension(path: &str) -> Option<&'static str> {
1767 let ext = path.rsplit('.').next()?;
1768 match ext.to_lowercase().as_str() {
1769 "docx" => Some("application/vnd.openxmlformats-officedocument.wordprocessingml.document"),
1770 "xlsx" => Some("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"),
1771 "pptx" => Some("application/vnd.openxmlformats-officedocument.presentationml.presentation"),
1772 "pdf" => Some("application/pdf"),
1773 "txt" | "text" => Some("text/plain"),
1774 "html" | "htm" => Some("text/html"),
1775 "json" => Some("application/json"),
1776 "xml" => Some("application/xml"),
1777 "csv" => Some("text/csv"),
1778 "md" | "markdown" => Some("text/markdown"),
1779 "png" => Some("image/png"),
1780 "jpg" | "jpeg" => Some("image/jpeg"),
1781 "gif" => Some("image/gif"),
1782 "zip" => Some("application/zip"),
1783 "odt" => Some("application/vnd.oasis.opendocument.text"),
1784 "ods" => Some("application/vnd.oasis.opendocument.spreadsheet"),
1785 "odp" => Some("application/vnd.oasis.opendocument.presentation"),
1786 "rtf" => Some("application/rtf"),
1787 "epub" => Some("application/epub+zip"),
1788 "msg" => Some("application/vnd.ms-outlook"),
1789 "eml" => Some("message/rfc822"),
1790 _ => None,
1791 }
1792}
1793
1794fn emit_dart_batch_item_array(arr: &serde_json::Value, elem_type: &str) -> String {
1801 let items: Vec<String> = arr
1802 .as_array()
1803 .map(|a| a.as_slice())
1804 .unwrap_or_default()
1805 .iter()
1806 .filter_map(|item| {
1807 let obj = item.as_object()?;
1808 match elem_type {
1809 "BatchBytesItem" => {
1810 let content_bytes = obj
1811 .get("content")
1812 .and_then(|v| v.as_array())
1813 .map(|arr| {
1814 let nums: Vec<String> =
1815 arr.iter().filter_map(|v| v.as_u64().map(|n| n.to_string())).collect();
1816 format!("Uint8List.fromList([{}])", nums.join(", "))
1817 })
1818 .unwrap_or_else(|| "Uint8List(0)".to_string());
1819 let mime_type = obj
1820 .get("mime_type")
1821 .and_then(|v| v.as_str())
1822 .unwrap_or("application/octet-stream");
1823 Some(format!(
1824 "BatchBytesItem(content: {content_bytes}, mimeType: '{}')",
1825 escape_dart(mime_type)
1826 ))
1827 }
1828 "BatchFileItem" => {
1829 let path = obj.get("path").and_then(|v| v.as_str()).unwrap_or("");
1830 Some(format!("BatchFileItem(path: '{}')", escape_dart(path)))
1831 }
1832 _ => None,
1833 }
1834 })
1835 .collect();
1836 format!("[{}]", items.join(", "))
1837}
1838
1839pub(super) fn escape_dart(s: &str) -> String {
1841 s.replace('\\', "\\\\")
1842 .replace('\'', "\\'")
1843 .replace('\n', "\\n")
1844 .replace('\r', "\\r")
1845 .replace('\t', "\\t")
1846 .replace('$', "\\$")
1847}
1848
1849fn type_name_to_create_from_json_dart(type_name: &str) -> String {
1857 let mut snake = String::with_capacity(type_name.len() + 8);
1859 for (i, ch) in type_name.char_indices() {
1860 if ch.is_uppercase() {
1861 if i > 0 {
1862 snake.push('_');
1863 }
1864 snake.extend(ch.to_lowercase());
1865 } else {
1866 snake.push(ch);
1867 }
1868 }
1869 let rust_fn = format!("create_{snake}_from_json");
1872 rust_fn
1874 .split('_')
1875 .enumerate()
1876 .map(|(i, part)| {
1877 if i == 0 {
1878 part.to_string()
1879 } else {
1880 let mut chars = part.chars();
1881 match chars.next() {
1882 None => String::new(),
1883 Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
1884 }
1885 }
1886 })
1887 .collect::<Vec<_>>()
1888 .join("")
1889}