1use crate::codegen::resolve_field;
8use crate::config::E2eConfig;
9use crate::escape::sanitize_filename;
10use crate::field_access::FieldResolver;
11use crate::fixture::{Assertion, Fixture, FixtureGroup, HttpFixture, ValidationErrorExpectation};
12use alef_core::backend::GeneratedFile;
13use alef_core::config::ResolvedCrateConfig;
14use alef_core::hash::{self, CommentStyle};
15use alef_core::template_versions::pub_dev;
16use anyhow::Result;
17use heck::ToLowerCamelCase;
18use std::cell::Cell;
19use std::fmt::Write as FmtWrite;
20use std::path::PathBuf;
21
22use super::E2eCodegen;
23use super::client;
24
25pub struct DartE2eCodegen;
27
28impl E2eCodegen for DartE2eCodegen {
29 fn generate(
30 &self,
31 groups: &[FixtureGroup],
32 e2e_config: &E2eConfig,
33 config: &ResolvedCrateConfig,
34 _type_defs: &[alef_core::ir::TypeDef],
35 _enums: &[alef_core::ir::EnumDef],
36 ) -> Result<Vec<GeneratedFile>> {
37 let lang = self.language_name();
38 let output_base = PathBuf::from(e2e_config.effective_output()).join(lang);
39
40 let mut files = Vec::new();
41
42 let dart_pkg = e2e_config.resolve_package("dart");
44 let pkg_name = dart_pkg
45 .as_ref()
46 .and_then(|p| p.name.as_ref())
47 .cloned()
48 .unwrap_or_else(|| config.dart_pubspec_name());
49 let pkg_path = dart_pkg
50 .as_ref()
51 .and_then(|p| p.path.as_ref())
52 .cloned()
53 .unwrap_or_else(|| "../../packages/dart".to_string());
54 let pkg_version = dart_pkg
55 .as_ref()
56 .and_then(|p| p.version.as_ref())
57 .cloned()
58 .or_else(|| config.resolved_version())
59 .unwrap_or_else(|| "0.1.0".to_string());
60
61 files.push(GeneratedFile {
63 path: output_base.join("pubspec.yaml"),
64 content: render_pubspec(&pkg_name, &pkg_path, &pkg_version, e2e_config.dep_mode),
65 generated_header: false,
66 });
67
68 files.push(GeneratedFile {
71 path: output_base.join("dart_test.yaml"),
72 content: concat!(
73 "# Generated by alef — DO NOT EDIT.\n",
74 "# Run test files sequentially to avoid overwhelming the mock server with\n",
75 "# concurrent keep-alive connections.\n",
76 "concurrency: 1\n",
77 )
78 .to_string(),
79 generated_header: false,
80 });
81
82 let test_base = output_base.join("test");
83
84 let bridge_class = config.dart_bridge_class_name();
86
87 let frb_module_name = config.name.replace('-', "_");
91
92 let dart_stub_methods: std::collections::HashSet<String> = config
97 .dart
98 .as_ref()
99 .map(|d| d.stub_methods.iter().cloned().collect())
100 .unwrap_or_default();
101
102 for group in groups {
103 let active: Vec<&Fixture> = group
104 .fixtures
105 .iter()
106 .filter(|f| super::should_include_fixture(f, lang, e2e_config))
107 .filter(|f| {
108 let call_config = e2e_config.resolve_call_for_fixture(
109 f.call.as_deref(),
110 &f.id,
111 &f.resolved_category(),
112 &f.tags,
113 &f.input,
114 );
115 let resolved_function = call_config
116 .overrides
117 .get(lang)
118 .and_then(|o| o.function.as_ref())
119 .cloned()
120 .unwrap_or_else(|| call_config.function.clone());
121 !dart_stub_methods.contains(&resolved_function)
122 })
123 .collect();
124
125 if active.is_empty() {
126 continue;
127 }
128
129 let filename = format!("{}_test.dart", sanitize_filename(&group.category));
130 let content = render_test_file(
131 &group.category,
132 &active,
133 e2e_config,
134 lang,
135 &pkg_name,
136 &frb_module_name,
137 &bridge_class,
138 );
139 files.push(GeneratedFile {
140 path: test_base.join(filename),
141 content,
142 generated_header: true,
143 });
144 }
145
146 Ok(files)
147 }
148
149 fn language_name(&self) -> &'static str {
150 "dart"
151 }
152}
153
154fn render_pubspec(
159 pkg_name: &str,
160 pkg_path: &str,
161 pkg_version: &str,
162 dep_mode: crate::config::DependencyMode,
163) -> String {
164 let test_ver = pub_dev::TEST_PACKAGE;
165 let http_ver = pub_dev::HTTP_PACKAGE;
166
167 let dep_block = match dep_mode {
168 crate::config::DependencyMode::Registry => {
169 format!(" {pkg_name}: ^{pkg_version}")
170 }
171 crate::config::DependencyMode::Local => {
172 format!(" {pkg_name}:\n path: {pkg_path}")
173 }
174 };
175
176 let sdk = alef_core::template_versions::toolchain::DART_SDK_CONSTRAINT;
177 format!(
178 r#"name: e2e_dart
179version: 0.1.0
180publish_to: none
181
182environment:
183 sdk: "{sdk}"
184
185dependencies:
186{dep_block}
187
188dev_dependencies:
189 test: {test_ver}
190 http: {http_ver}
191"#
192 )
193}
194
195fn render_test_file(
196 category: &str,
197 fixtures: &[&Fixture],
198 e2e_config: &E2eConfig,
199 lang: &str,
200 pkg_name: &str,
201 frb_module_name: &str,
202 bridge_class: &str,
203) -> String {
204 let mut out = String::new();
205 out.push_str(&hash::header(CommentStyle::DoubleSlash));
206 out.push_str("// ignore_for_file: unused_local_variable\n\n");
210
211 let has_http_fixtures = fixtures.iter().any(|f| f.is_http_test());
213
214 let has_batch_byte_items = fixtures.iter().any(|f| {
216 let call_config =
217 e2e_config.resolve_call_for_fixture(f.call.as_deref(), &f.id, &f.resolved_category(), &f.tags, &f.input);
218 call_config.args.iter().any(|a| {
219 a.element_type.as_deref() == Some("BatchBytesItem") && resolve_field(&f.input, &a.field).is_array()
220 })
221 });
222
223 let needs_chdir = fixtures.iter().any(|f| {
227 if f.is_http_test() {
228 return false;
229 }
230 let call_config =
231 e2e_config.resolve_call_for_fixture(f.call.as_deref(), &f.id, &f.resolved_category(), &f.tags, &f.input);
232 call_config
233 .args
234 .iter()
235 .any(|a| a.arg_type == "file_path" || a.arg_type == "bytes")
236 });
237
238 let has_handle_args = fixtures.iter().any(|f| {
244 if f.is_http_test() {
245 return false;
246 }
247 let call_config =
248 e2e_config.resolve_call_for_fixture(f.call.as_deref(), &f.id, &f.resolved_category(), &f.tags, &f.input);
249 call_config
250 .args
251 .iter()
252 .any(|a| a.arg_type == "json_object" && super::resolve_field(&f.input, &a.field).is_array())
253 });
254
255 let lang_client_factory = e2e_config
261 .call
262 .overrides
263 .get(lang)
264 .and_then(|o| o.client_factory.as_deref())
265 .is_some();
266 let has_mock_url_refs = lang_client_factory
267 || fixtures.iter().any(|f| {
268 if f.is_http_test() {
269 return false;
270 }
271 let call_config = e2e_config.resolve_call_for_fixture(
272 f.call.as_deref(),
273 &f.id,
274 &f.resolved_category(),
275 &f.tags,
276 &f.input,
277 );
278 if call_config.args.iter().any(|a| a.arg_type == "mock_url") {
279 return true;
280 }
281 call_config
282 .overrides
283 .get(lang)
284 .and_then(|o| o.client_factory.as_deref())
285 .is_some()
286 });
287
288 let _ = writeln!(out, "import 'package:test/test.dart';");
289 if has_http_fixtures || needs_chdir || has_mock_url_refs {
294 let _ = writeln!(out, "import 'dart:io';");
295 }
296 if has_batch_byte_items {
297 let _ = writeln!(out, "import 'dart:typed_data';");
298 }
299 let _ = writeln!(out, "import 'package:{pkg_name}/{pkg_name}.dart';");
300 let _ = writeln!(
306 out,
307 "import 'package:{pkg_name}/src/{frb_module_name}_bridge_generated/frb_generated.dart' show RustLib;"
308 );
309 if has_http_fixtures {
310 let _ = writeln!(out, "import 'dart:async';");
311 }
312 if has_http_fixtures || has_handle_args {
314 let _ = writeln!(out, "import 'dart:convert';");
315 }
316 let _ = writeln!(out);
317
318 if has_http_fixtures {
328 let _ = writeln!(out, "HttpClient _httpClient = HttpClient()..maxConnectionsPerHost = 1;");
329 let _ = writeln!(out);
330 let _ = writeln!(out, "var _lock = Future<void>.value();");
331 let _ = writeln!(out);
332 let _ = writeln!(out, "Future<T> _serialized<T>(Future<T> Function() fn) async {{");
333 let _ = writeln!(out, " final current = _lock;");
334 let _ = writeln!(out, " final next = Completer<void>();");
335 let _ = writeln!(out, " _lock = next.future;");
336 let _ = writeln!(out, " try {{");
337 let _ = writeln!(out, " await current;");
338 let _ = writeln!(out, " return await fn();");
339 let _ = writeln!(out, " }} finally {{");
340 let _ = writeln!(out, " next.complete();");
341 let _ = writeln!(out, " }}");
342 let _ = writeln!(out, "}}");
343 let _ = writeln!(out);
344 let _ = writeln!(out, "Future<T> _withRetry<T>(Future<T> Function() fn) async {{");
347 let _ = writeln!(out, " try {{");
348 let _ = writeln!(out, " return await fn();");
349 let _ = writeln!(out, " }} on SocketException {{");
350 let _ = writeln!(out, " _httpClient.close(force: true);");
351 let _ = writeln!(out, " _httpClient = HttpClient()..maxConnectionsPerHost = 1;");
352 let _ = writeln!(out, " return fn();");
353 let _ = writeln!(out, " }} on HttpException {{");
354 let _ = writeln!(out, " _httpClient.close(force: true);");
355 let _ = writeln!(out, " _httpClient = HttpClient()..maxConnectionsPerHost = 1;");
356 let _ = writeln!(out, " return fn();");
357 let _ = writeln!(out, " }}");
358 let _ = writeln!(out, "}}");
359 let _ = writeln!(out);
360 }
361
362 let _ = writeln!(out, "// E2e tests for category: {category}");
363 let _ = writeln!(out, "void main() {{");
364
365 let _ = writeln!(out, " setUpAll(() async {{");
372 let _ = writeln!(out, " await RustLib.init();");
373 if needs_chdir {
374 let test_docs_path = e2e_config.test_documents_relative_from(0);
375 let _ = writeln!(
376 out,
377 " final _testDocs = Platform.environment['FIXTURES_DIR'] ?? '{test_docs_path}';"
378 );
379 let _ = writeln!(out, " final _dir = Directory(_testDocs);");
380 let _ = writeln!(out, " if (_dir.existsSync()) Directory.current = _dir;");
381 }
382 let _ = writeln!(out, " }});");
383 let _ = writeln!(out);
384
385 if has_http_fixtures {
387 let _ = writeln!(out, " tearDownAll(() => _httpClient.close());");
388 let _ = writeln!(out);
389 }
390
391 for fixture in fixtures {
392 render_test_case(&mut out, fixture, e2e_config, lang, bridge_class);
393 }
394
395 let _ = writeln!(out, "}}");
396 out
397}
398
399fn render_test_case(out: &mut String, fixture: &Fixture, e2e_config: &E2eConfig, lang: &str, bridge_class: &str) {
400 if let Some(http) = &fixture.http {
402 render_http_test_case(out, fixture, http);
403 return;
404 }
405
406 let call_config = e2e_config.resolve_call_for_fixture(
408 fixture.call.as_deref(),
409 &fixture.id,
410 &fixture.resolved_category(),
411 &fixture.tags,
412 &fixture.input,
413 );
414 let call_field_resolver = FieldResolver::new(
416 e2e_config.effective_fields(call_config),
417 e2e_config.effective_fields_optional(call_config),
418 e2e_config.effective_result_fields(call_config),
419 e2e_config.effective_fields_array(call_config),
420 e2e_config.effective_fields_method_calls(call_config),
421 );
422 let field_resolver = &call_field_resolver;
423 let call_overrides = call_config.overrides.get(lang);
424 let mut function_name = call_overrides
425 .and_then(|o| o.function.as_ref())
426 .cloned()
427 .unwrap_or_else(|| call_config.function.clone());
428 function_name = function_name
430 .split('_')
431 .enumerate()
432 .map(|(i, part)| {
433 if i == 0 {
434 part.to_string()
435 } else {
436 let mut chars = part.chars();
437 match chars.next() {
438 None => String::new(),
439 Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
440 }
441 }
442 })
443 .collect::<Vec<_>>()
444 .join("");
445 let result_var = &call_config.result_var;
446 let description = escape_dart(&fixture.description);
447 let fixture_id = &fixture.id;
448 let _is_async = call_overrides.and_then(|o| o.r#async).unwrap_or(call_config.r#async);
451
452 let expects_error = fixture.assertions.iter().any(|a| a.assertion_type == "error");
453 let is_streaming = crate::codegen::streaming_assertions::resolve_is_streaming(fixture, call_config.streaming);
454 let result_is_simple = call_overrides.is_some_and(|o| o.result_is_simple) || call_config.result_is_simple;
459
460 let options_type: Option<&str> = call_overrides.and_then(|o| o.options_type.as_deref());
467 let options_via: &str = call_overrides
468 .and_then(|o| o.options_via.as_deref())
469 .unwrap_or("kwargs");
470
471 let file_path_for_mime: Option<&str> = call_config
479 .args
480 .iter()
481 .find(|a| a.arg_type == "file_path")
482 .and_then(|a| resolve_field(&fixture.input, &a.field).as_str());
483
484 let has_file_path_arg = call_config.args.iter().any(|a| a.arg_type == "file_path");
491 let caller_supplied_override = call_overrides.and_then(|o| o.function.as_ref()).is_some();
494 if has_file_path_arg && !caller_supplied_override {
495 function_name = match function_name.as_str() {
496 "extractFile" => "extractBytes".to_string(),
497 "extractFileSync" => "extractBytesSync".to_string(),
498 other => other.to_string(),
499 };
500 }
501
502 let mut setup_lines: Vec<String> = Vec::new();
505 let mut args = Vec::new();
506
507 for arg_def in &call_config.args {
508 match arg_def.arg_type.as_str() {
509 "mock_url" => {
510 let name = arg_def.name.clone();
511 if fixture.has_host_root_route() {
512 let env_key = format!("MOCK_SERVER_{}", fixture_id.to_uppercase());
513 setup_lines.push(format!(
514 r#"final {name} = Platform.environment["{env_key}"] ?? (Platform.environment["MOCK_SERVER_URL"]! + "/fixtures/{fixture_id}");"#
515 ));
516 } else {
517 setup_lines.push(format!(
518 r#"final {name} = "${{Platform.environment["MOCK_SERVER_URL"] ?? "http://localhost:8080"}}/fixtures/{fixture_id}";"#
519 ));
520 }
521 args.push(name);
522 continue;
523 }
524 "handle" => {
525 let name = arg_def.name.clone();
526 let field = arg_def.field.strip_prefix("input.").unwrap_or(&arg_def.field);
527 let config_value = fixture.input.get(field).cloned().unwrap_or(serde_json::Value::Null);
528 let create_fn = {
530 let mut chars = name.chars();
531 let pascal = match chars.next() {
532 None => String::new(),
533 Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
534 };
535 format!("create{pascal}")
536 };
537 if config_value.is_null()
538 || config_value.is_object() && config_value.as_object().is_some_and(|o| o.is_empty())
539 {
540 setup_lines.push(format!("final {name} = await {bridge_class}.{create_fn}();"));
541 } else {
542 let json_str = serde_json::to_string(&config_value).unwrap_or_default();
543 let config_var = format!("{name}Config");
544 setup_lines.push(format!(
549 "final {config_var} = await createCrawlConfigFromJson(json: r'{json_str}');"
550 ));
551 setup_lines.push(format!(
553 "final {name} = await {bridge_class}.{create_fn}(config: {config_var});"
554 ));
555 }
556 args.push(name);
557 continue;
558 }
559 _ => {}
560 }
561
562 let arg_value = resolve_field(&fixture.input, &arg_def.field);
563 match arg_def.arg_type.as_str() {
564 "bytes" | "file_path" => {
565 if let serde_json::Value::String(file_path) = arg_value {
570 args.push(format!("File('{}').readAsBytesSync()", file_path));
571 }
572 }
573 "string" => {
574 let dart_param_name = snake_to_camel(&arg_def.name);
585 let mime_required_due_to_remap = has_file_path_arg
586 && arg_def.name == "mime_type"
587 && (function_name == "extractBytes" || function_name == "extractBytesSync");
588 let use_positional = mime_required_due_to_remap || !arg_def.optional;
589 match arg_value {
590 serde_json::Value::String(s) => {
591 let literal = format!("'{}'", escape_dart(s));
592 if use_positional {
593 args.push(literal);
594 } else {
595 args.push(format!("{dart_param_name}: {literal}"));
596 }
597 }
598 serde_json::Value::Null
599 if arg_def.optional
600 && arg_def.name == "mime_type" =>
603 {
604 let inferred = file_path_for_mime
605 .and_then(mime_from_extension)
606 .unwrap_or("application/octet-stream");
607 if use_positional {
608 args.push(format!("'{inferred}'"));
609 } else {
610 args.push(format!("{dart_param_name}: '{inferred}'"));
611 }
612 }
613 _ => {}
615 }
616 }
617 "json_object" => {
618 if let Some(elem_type) = &arg_def.element_type {
620 if (elem_type == "BatchBytesItem" || elem_type == "BatchFileItem") && arg_value.is_array() {
621 let dart_items = emit_dart_batch_item_array(arg_value, elem_type);
622 args.push(dart_items);
623 } else if elem_type == "String" && arg_value.is_array() {
624 let items: Vec<String> = arg_value
631 .as_array()
632 .unwrap()
633 .iter()
634 .filter_map(|v| v.as_str())
635 .map(|s| format!("'{}'", escape_dart(s)))
636 .collect();
637 args.push(format!("<String>[{}]", items.join(", ")));
638 }
639 } else if options_via == "from_json" {
640 if let Some(opts_type) = options_type {
650 if !arg_value.is_null() {
651 let json_str = serde_json::to_string(&arg_value).unwrap_or_default();
652 let escaped_json = escape_dart(&json_str);
655 let var_name = format!("_{}", arg_def.name);
656 let dart_fn = type_name_to_create_from_json_dart(opts_type);
657 setup_lines.push(format!("final {var_name} = await {dart_fn}(json: '{escaped_json}');"));
658 args.push(format!("req: {var_name}"));
661 }
662 }
663 } else if arg_def.name == "config" {
664 if let serde_json::Value::Object(map) = &arg_value {
665 if !map.is_empty() {
666 let explicit_options =
675 options_type.is_some_and(|t| t != "ExtractionConfig" && t != "FileExtractionConfig");
676 let has_non_scalar = map.values().any(|v| {
677 matches!(
678 v,
679 serde_json::Value::String(_)
680 | serde_json::Value::Object(_)
681 | serde_json::Value::Array(_)
682 )
683 });
684 if explicit_options || has_non_scalar {
685 let opts_type = options_type.unwrap_or("ExtractionConfig");
686 let json_str = serde_json::to_string(&arg_value).unwrap_or_default();
687 let escaped_json = escape_dart(&json_str);
688 let var_name = format!("_{}", arg_def.name);
689 let dart_fn = type_name_to_create_from_json_dart(opts_type);
690 setup_lines
691 .push(format!("final {var_name} = await {dart_fn}(json: '{escaped_json}');"));
692 args.push(var_name);
693 } else {
694 args.push(emit_extraction_config_dart(map));
700 }
701 }
702 }
703 } else if arg_value.is_array() {
705 let json_str = serde_json::to_string(&arg_value).unwrap_or_default();
708 let var_name = arg_def.name.clone();
709 setup_lines.push(format!(
710 "final {var_name} = (jsonDecode(r'{json_str}') as List<dynamic>).cast<String>();"
711 ));
712 args.push(var_name);
713 } else if let serde_json::Value::Object(map) = &arg_value {
714 if !map.is_empty() {
728 if let Some(opts_type) = options_type {
729 let json_str = serde_json::to_string(&arg_value).unwrap_or_default();
730 let escaped_json = escape_dart(&json_str);
731 let dart_param_name = snake_to_camel(&arg_def.name);
732 let var_name = format!("_{}", arg_def.name);
733 let dart_fn = type_name_to_create_from_json_dart(opts_type);
734 if fixture.visitor.is_some() {
735 setup_lines.push(format!(
736 "final {var_name} = await {dart_fn}WithVisitor(json: '{escaped_json}', visitor: _visitor);"
737 ));
738 } else {
739 setup_lines
740 .push(format!("final {var_name} = await {dart_fn}(json: '{escaped_json}');"));
741 }
742 if arg_def.optional {
743 args.push(format!("{dart_param_name}: {var_name}"));
744 } else {
745 args.push(var_name);
746 }
747 }
748 }
749 }
750 }
751 _ => {}
752 }
753 }
754
755 if let Some(visitor_spec) = &fixture.visitor {
770 let mut visitor_setup: Vec<String> = Vec::new();
771 let _ = super::dart_visitors::build_dart_visitor(&mut visitor_setup, visitor_spec);
772 for line in visitor_setup.into_iter().rev() {
775 setup_lines.insert(0, line);
776 }
777
778 let already_has_options = args.iter().any(|a| a.starts_with("options:") || a == "_options");
782 if !already_has_options {
783 if let Some(opts_type) = options_type {
784 let dart_fn = type_name_to_create_from_json_dart(opts_type);
785 setup_lines.push(format!(
786 "final _options = await {dart_fn}WithVisitor(json: '{{}}', visitor: _visitor);"
787 ));
788 args.push("options: _options".to_string());
789 }
790 }
791 }
792
793 let client_factory: Option<&str> = call_overrides.and_then(|o| o.client_factory.as_deref()).or_else(|| {
797 e2e_config
798 .call
799 .overrides
800 .get(lang)
801 .and_then(|o| o.client_factory.as_deref())
802 });
803
804 let client_factory_camel: Option<String> = client_factory.map(|f| {
806 f.split('_')
807 .enumerate()
808 .map(|(i, part)| {
809 if i == 0 {
810 part.to_string()
811 } else {
812 let mut chars = part.chars();
813 match chars.next() {
814 None => String::new(),
815 Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
816 }
817 }
818 })
819 .collect::<Vec<_>>()
820 .join("")
821 });
822
823 let _ = writeln!(out, " test('{description}', () async {{");
827
828 let args_str = args.join(", ");
829 let receiver_class = call_overrides
830 .and_then(|o| o.class.as_ref())
831 .cloned()
832 .unwrap_or_else(|| bridge_class.to_string());
833
834 let (receiver, extra_setup): (String, Option<String>) = if let Some(factory) = &client_factory_camel {
838 let has_mock_url = call_config.args.iter().any(|a| a.arg_type == "mock_url");
839 let mock_url_setup = if !has_mock_url {
840 if fixture.has_host_root_route() {
842 let env_key = format!("MOCK_SERVER_{}", fixture_id.to_uppercase());
843 Some(format!(
844 "final _mockUrl = Platform.environment[\"{env_key}\"] ?? (Platform.environment[\"MOCK_SERVER_URL\"]! + \"/fixtures/{fixture_id}\");"
845 ))
846 } else {
847 Some(format!(
848 r#"final _mockUrl = "${{Platform.environment["MOCK_SERVER_URL"] ?? "http://localhost:8080"}}/fixtures/{fixture_id}";"#
849 ))
850 }
851 } else {
852 None
853 };
854 let url_expr = if has_mock_url {
855 call_config
858 .args
859 .iter()
860 .find(|a| a.arg_type == "mock_url")
861 .map(|a| a.name.clone())
862 .unwrap_or_else(|| "_mockUrl".to_string())
863 } else {
864 "_mockUrl".to_string()
865 };
866 let create_line = format!("final _client = await {receiver_class}.{factory}('test-key', baseUrl: {url_expr});");
867 let full_setup = if let Some(url_line) = mock_url_setup {
868 Some(format!("{url_line}\n {create_line}"))
869 } else {
870 Some(create_line)
871 };
872 ("_client".to_string(), full_setup)
873 } else {
874 (receiver_class.clone(), None)
875 };
876
877 if expects_error && (!setup_lines.is_empty() || extra_setup.is_some()) {
878 let _ = writeln!(out, " await expectLater(() async {{");
882 for line in &setup_lines {
883 let _ = writeln!(out, " {line}");
884 }
885 if let Some(extra) = &extra_setup {
886 for line in extra.lines() {
887 let _ = writeln!(out, " {line}");
888 }
889 }
890 if is_streaming {
891 let _ = writeln!(out, " return {receiver}.{function_name}({args_str}).toList();");
892 } else {
893 let _ = writeln!(out, " return {receiver}.{function_name}({args_str});");
894 }
895 let _ = writeln!(out, " }}(), throwsA(anything));");
896 } else if expects_error {
897 if let Some(extra) = &extra_setup {
899 for line in extra.lines() {
900 let _ = writeln!(out, " {line}");
901 }
902 }
903 if is_streaming {
904 let _ = writeln!(
905 out,
906 " await expectLater({receiver}.{function_name}({args_str}).toList(), throwsA(anything));"
907 );
908 } else {
909 let _ = writeln!(
910 out,
911 " await expectLater({receiver}.{function_name}({args_str}), throwsA(anything));"
912 );
913 }
914 } else {
915 for line in &setup_lines {
916 let _ = writeln!(out, " {line}");
917 }
918 if let Some(extra) = &extra_setup {
919 for line in extra.lines() {
920 let _ = writeln!(out, " {line}");
921 }
922 }
923 if is_streaming {
924 let _ = writeln!(
925 out,
926 " final {result_var} = await {receiver}.{function_name}({args_str}).toList();"
927 );
928 } else {
929 let _ = writeln!(
930 out,
931 " final {result_var} = await {receiver}.{function_name}({args_str});"
932 );
933 }
934 for assertion in &fixture.assertions {
935 if is_streaming {
936 render_streaming_assertion_dart(out, assertion, result_var);
937 } else {
938 render_assertion_dart(out, assertion, result_var, result_is_simple, field_resolver);
939 }
940 }
941 }
942
943 let _ = writeln!(out, " }});");
944 let _ = writeln!(out);
945}
946
947fn dart_length_expr(field_accessor: &str, field: Option<&str>, field_resolver: &FieldResolver) -> String {
955 let is_optional = field
956 .map(|f| {
957 let resolved = field_resolver.resolve(f);
958 field_resolver.is_optional(f) || field_resolver.is_optional(resolved)
959 })
960 .unwrap_or(false);
961 if is_optional {
962 format!("{field_accessor}?.length ?? 0")
963 } else {
964 format!("{field_accessor}.length")
965 }
966}
967
968fn dart_format_value(val: &serde_json::Value) -> String {
969 match val {
970 serde_json::Value::String(s) => format!("'{}'", escape_dart(s)),
971 serde_json::Value::Bool(b) => b.to_string(),
972 serde_json::Value::Number(n) => n.to_string(),
973 serde_json::Value::Null => "null".to_string(),
974 other => format!("'{}'", escape_dart(&other.to_string())),
975 }
976}
977
978fn render_assertion_dart(
989 out: &mut String,
990 assertion: &Assertion,
991 result_var: &str,
992 result_is_simple: bool,
993 field_resolver: &FieldResolver,
994) {
995 if !result_is_simple {
999 if let Some(f) = assertion.field.as_deref() {
1000 let head = f.split("[].").next().unwrap_or(f);
1003 if !head.is_empty() && !field_resolver.is_valid_for_result(head) {
1004 let _ = writeln!(out, " // skipped: field '{f}' not available on dart result type");
1005 return;
1006 }
1007 }
1008 }
1009
1010 if let Some(f) = assertion.field.as_deref() {
1016 if !f.is_empty() && field_resolver.tagged_union_split(f).is_some() {
1017 let _ = writeln!(
1018 out,
1019 " // skipped: field '{f}' crosses a tagged-union variant boundary (not expressible in Dart)"
1020 );
1021 return;
1022 }
1023 }
1024
1025 if let Some(f) = assertion.field.as_deref() {
1027 if let Some(dot) = f.find("[].") {
1028 let resolved_full = field_resolver.resolve(f);
1033 let (array_part, elem_part) = match resolved_full.find("[].") {
1034 Some(rdot) => (&resolved_full[..rdot], &resolved_full[rdot + 3..]),
1035 None => (&f[..dot], &f[dot + 3..]),
1038 };
1039 let array_accessor = if array_part.is_empty() {
1040 result_var.to_string()
1041 } else {
1042 field_resolver.accessor(array_part, "dart", result_var)
1043 };
1044 let elem_accessor = field_to_dart_accessor(elem_part);
1045 match assertion.assertion_type.as_str() {
1046 "contains" => {
1047 if let Some(expected) = &assertion.value {
1048 let dart_val = dart_format_value(expected);
1049 let _ = writeln!(
1050 out,
1051 " expect({array_accessor}.any((e) => e.{elem_accessor}.toString().contains({dart_val})), isTrue);"
1052 );
1053 }
1054 }
1055 "contains_all" => {
1056 if let Some(values) = &assertion.values {
1057 for val in values {
1058 let dart_val = dart_format_value(val);
1059 let _ = writeln!(
1060 out,
1061 " expect({array_accessor}.any((e) => e.{elem_accessor}.toString().contains({dart_val})), isTrue);"
1062 );
1063 }
1064 }
1065 }
1066 "not_contains" => {
1067 if let Some(expected) = &assertion.value {
1068 let dart_val = dart_format_value(expected);
1069 let _ = writeln!(
1070 out,
1071 " expect({array_accessor}.any((e) => e.{elem_accessor}.toString().contains({dart_val})), isFalse);"
1072 );
1073 } else if let Some(values) = &assertion.values {
1074 for val in values {
1075 let dart_val = dart_format_value(val);
1076 let _ = writeln!(
1077 out,
1078 " expect({array_accessor}.any((e) => e.{elem_accessor}.toString().contains({dart_val})), isFalse);"
1079 );
1080 }
1081 }
1082 }
1083 "not_empty" => {
1084 let _ = writeln!(
1085 out,
1086 " expect({array_accessor}.any((e) => e.{elem_accessor}.toString().isNotEmpty), isTrue);"
1087 );
1088 }
1089 other => {
1090 let _ = writeln!(
1091 out,
1092 " // skipped: unsupported traversal assertion '{other}' on '{f}'"
1093 );
1094 }
1095 }
1096 return;
1097 }
1098 }
1099
1100 let field_accessor = if result_is_simple {
1101 result_var.to_string()
1105 } else {
1106 match assertion.field.as_deref() {
1107 Some(f) if !f.is_empty() => field_resolver.accessor(f, "dart", result_var),
1112 _ => result_var.to_string(),
1113 }
1114 };
1115
1116 let format_value = |val: &serde_json::Value| -> String { dart_format_value(val) };
1117
1118 match assertion.assertion_type.as_str() {
1119 "equals" | "field_equals" => {
1120 if let Some(expected) = &assertion.value {
1121 let dart_val = format_value(expected);
1122 if expected.is_string() {
1126 let _ = writeln!(
1127 out,
1128 " expect({field_accessor}.toString().trim(), equals({dart_val}.toString().trim()));"
1129 );
1130 } else {
1131 let _ = writeln!(out, " expect({field_accessor}, equals({dart_val}));");
1132 }
1133 } else {
1134 let _ = writeln!(
1135 out,
1136 " // skipped: '{}' assertion missing value",
1137 assertion.assertion_type
1138 );
1139 }
1140 }
1141 "not_equals" => {
1142 if let Some(expected) = &assertion.value {
1143 let dart_val = format_value(expected);
1144 if expected.is_string() {
1145 let _ = writeln!(
1146 out,
1147 " expect({field_accessor}.toString().trim(), isNot(equals({dart_val}.toString().trim())));"
1148 );
1149 } else {
1150 let _ = writeln!(out, " expect({field_accessor}, isNot(equals({dart_val})));");
1151 }
1152 }
1153 }
1154 "contains" => {
1155 if let Some(expected) = &assertion.value {
1156 let dart_val = format_value(expected);
1157 let _ = writeln!(out, " expect({field_accessor}, contains({dart_val}));");
1158 } else {
1159 let _ = writeln!(out, " // skipped: 'contains' assertion missing value");
1160 }
1161 }
1162 "contains_all" => {
1163 if let Some(values) = &assertion.values {
1164 for val in values {
1165 let dart_val = format_value(val);
1166 let _ = writeln!(out, " expect({field_accessor}, contains({dart_val}));");
1167 }
1168 }
1169 }
1170 "contains_any" => {
1171 if let Some(values) = &assertion.values {
1172 let checks: Vec<String> = values
1173 .iter()
1174 .map(|v| {
1175 let dart_val = format_value(v);
1176 format!("{field_accessor}.contains({dart_val})")
1177 })
1178 .collect();
1179 let joined = checks.join(" || ");
1180 let _ = writeln!(out, " expect({joined}, isTrue);");
1181 }
1182 }
1183 "not_contains" => {
1184 if let Some(expected) = &assertion.value {
1185 let dart_val = format_value(expected);
1186 let _ = writeln!(out, " expect({field_accessor}, isNot(contains({dart_val})));");
1187 } else if let Some(values) = &assertion.values {
1188 for val in values {
1189 let dart_val = format_value(val);
1190 let _ = writeln!(out, " expect({field_accessor}, isNot(contains({dart_val})));");
1191 }
1192 }
1193 }
1194 "not_empty" => {
1195 let is_collection = assertion.field.as_deref().is_some_and(|f| {
1200 let resolved = field_resolver.resolve(f);
1201 field_resolver.is_array(f) || field_resolver.is_array(resolved)
1202 });
1203 if is_collection {
1204 let _ = writeln!(out, " expect({field_accessor}, isNotEmpty);");
1205 } else {
1206 let _ = writeln!(out, " expect({field_accessor}, isNotNull);");
1207 }
1208 }
1209 "is_empty" => {
1210 let _ = writeln!(out, " expect({field_accessor}, anyOf(isNull, isEmpty));");
1214 }
1215 "starts_with" => {
1216 if let Some(expected) = &assertion.value {
1217 let dart_val = format_value(expected);
1218 let _ = writeln!(out, " expect({field_accessor}, startsWith({dart_val}));");
1219 }
1220 }
1221 "ends_with" => {
1222 if let Some(expected) = &assertion.value {
1223 let dart_val = format_value(expected);
1224 let _ = writeln!(out, " expect({field_accessor}, endsWith({dart_val}));");
1225 }
1226 }
1227 "min_length" => {
1228 if let Some(val) = &assertion.value {
1229 if let Some(n) = val.as_u64() {
1230 let length_expr = dart_length_expr(&field_accessor, assertion.field.as_deref(), field_resolver);
1231 let _ = writeln!(out, " expect({length_expr}, greaterThanOrEqualTo({n}));");
1232 }
1233 }
1234 }
1235 "max_length" => {
1236 if let Some(val) = &assertion.value {
1237 if let Some(n) = val.as_u64() {
1238 let length_expr = dart_length_expr(&field_accessor, assertion.field.as_deref(), field_resolver);
1239 let _ = writeln!(out, " expect({length_expr}, lessThanOrEqualTo({n}));");
1240 }
1241 }
1242 }
1243 "count_equals" => {
1244 if let Some(val) = &assertion.value {
1245 if let Some(n) = val.as_u64() {
1246 let length_expr = dart_length_expr(&field_accessor, assertion.field.as_deref(), field_resolver);
1247 let _ = writeln!(out, " expect({length_expr}, equals({n}));");
1248 }
1249 }
1250 }
1251 "count_min" => {
1252 if let Some(val) = &assertion.value {
1253 if let Some(n) = val.as_u64() {
1254 let length_expr = dart_length_expr(&field_accessor, assertion.field.as_deref(), field_resolver);
1255 let _ = writeln!(out, " expect({length_expr}, greaterThanOrEqualTo({n}));");
1256 }
1257 }
1258 }
1259 "matches_regex" => {
1260 if let Some(expected) = &assertion.value {
1261 let dart_val = format_value(expected);
1262 let _ = writeln!(out, " expect({field_accessor}, matches(RegExp({dart_val})));");
1263 }
1264 }
1265 "is_true" => {
1266 let _ = writeln!(out, " expect({field_accessor}, isTrue);");
1267 }
1268 "is_false" => {
1269 let _ = writeln!(out, " expect({field_accessor}, isFalse);");
1270 }
1271 "greater_than" => {
1272 if let Some(val) = &assertion.value {
1273 let dart_val = format_value(val);
1274 let _ = writeln!(out, " expect({field_accessor}, greaterThan({dart_val}));");
1275 }
1276 }
1277 "less_than" => {
1278 if let Some(val) = &assertion.value {
1279 let dart_val = format_value(val);
1280 let _ = writeln!(out, " expect({field_accessor}, lessThan({dart_val}));");
1281 }
1282 }
1283 "greater_than_or_equal" => {
1284 if let Some(val) = &assertion.value {
1285 let dart_val = format_value(val);
1286 let _ = writeln!(out, " expect({field_accessor}, greaterThanOrEqualTo({dart_val}));");
1287 }
1288 }
1289 "less_than_or_equal" => {
1290 if let Some(val) = &assertion.value {
1291 let dart_val = format_value(val);
1292 let _ = writeln!(out, " expect({field_accessor}, lessThanOrEqualTo({dart_val}));");
1293 }
1294 }
1295 "not_null" => {
1296 let _ = writeln!(out, " expect({field_accessor}, isNotNull);");
1297 }
1298 "not_error" => {
1299 }
1306 "error" => {
1307 }
1309 "method_result" => {
1310 if let Some(method) = &assertion.method {
1311 let dart_method = method.to_lower_camel_case();
1312 let check = assertion.check.as_deref().unwrap_or("not_null");
1313 let method_call = format!("{field_accessor}.{dart_method}()");
1314 match check {
1315 "equals" => {
1316 if let Some(expected) = &assertion.value {
1317 let dart_val = format_value(expected);
1318 let _ = writeln!(out, " expect({method_call}, equals({dart_val}));");
1319 }
1320 }
1321 "is_true" => {
1322 let _ = writeln!(out, " expect({method_call}, isTrue);");
1323 }
1324 "is_false" => {
1325 let _ = writeln!(out, " expect({method_call}, isFalse);");
1326 }
1327 "greater_than_or_equal" => {
1328 if let Some(val) = &assertion.value {
1329 let dart_val = format_value(val);
1330 let _ = writeln!(out, " expect({method_call}, greaterThanOrEqualTo({dart_val}));");
1331 }
1332 }
1333 "count_min" => {
1334 if let Some(val) = &assertion.value {
1335 if let Some(n) = val.as_u64() {
1336 let _ = writeln!(out, " expect({method_call}.length, greaterThanOrEqualTo({n}));");
1337 }
1338 }
1339 }
1340 _ => {
1341 let _ = writeln!(out, " expect({method_call}, isNotNull);");
1342 }
1343 }
1344 }
1345 }
1346 other => {
1347 let _ = writeln!(out, " // skipped: unknown assertion type '{other}'");
1348 }
1349 }
1350}
1351
1352fn render_streaming_assertion_dart(out: &mut String, assertion: &Assertion, result_var: &str) {
1363 match assertion.assertion_type.as_str() {
1364 "not_error" => {
1365 let _ = writeln!(out, " expect({result_var}, isNotNull);");
1369 }
1370 "count_min" if assertion.field.as_deref() == Some("chunks") => {
1371 if let Some(serde_json::Value::Number(n)) = &assertion.value {
1372 let _ = writeln!(out, " expect({result_var}.length, greaterThanOrEqualTo({n}));");
1373 }
1374 }
1375 "equals" if assertion.field.as_deref() == Some("stream_content") => {
1376 if let Some(serde_json::Value::String(expected)) = &assertion.value {
1377 let escaped = escape_dart(expected);
1378 let _ = writeln!(
1379 out,
1380 " final _content = {result_var}.map((c) => c.choices.firstOrNull?.delta.content ?? '').join();"
1381 );
1382 let _ = writeln!(out, " expect(_content, equals('{escaped}'));");
1383 }
1384 }
1385 other => {
1386 let _ = writeln!(out, " // skipped streaming assertion: '{other}'");
1387 }
1388 }
1389}
1390
1391fn snake_to_camel(s: &str) -> String {
1393 let mut result = String::with_capacity(s.len());
1394 let mut next_upper = false;
1395 for ch in s.chars() {
1396 if ch == '_' {
1397 next_upper = true;
1398 } else if next_upper {
1399 result.extend(ch.to_uppercase());
1400 next_upper = false;
1401 } else {
1402 result.push(ch);
1403 }
1404 }
1405 result
1406}
1407
1408fn field_to_dart_accessor(path: &str) -> String {
1421 let mut result = String::with_capacity(path.len());
1422 for (i, segment) in path.split('.').enumerate() {
1423 if i > 0 {
1424 result.push('.');
1425 }
1426 if let Some(bracket_pos) = segment.find('[') {
1432 let name = &segment[..bracket_pos];
1433 let bracket = &segment[bracket_pos..];
1434 result.push_str(&name.to_lower_camel_case());
1435 result.push('!');
1436 result.push_str(bracket);
1437 } else {
1438 result.push_str(&segment.to_lower_camel_case());
1439 }
1440 }
1441 result
1442}
1443
1444fn emit_extraction_config_dart(overrides: &serde_json::Map<String, serde_json::Value>) -> String {
1450 let mut field_overrides: std::collections::HashMap<String, String> = std::collections::HashMap::new();
1452 for (key, val) in overrides {
1453 let camel = snake_to_camel(key);
1454 let dart_val = match val {
1455 serde_json::Value::Bool(b) => {
1456 if *b {
1457 "true".to_string()
1458 } else {
1459 "false".to_string()
1460 }
1461 }
1462 serde_json::Value::Number(n) => n.to_string(),
1463 serde_json::Value::String(s) => format!("'{s}'"),
1464 _ => continue, };
1466 field_overrides.insert(camel, dart_val);
1467 }
1468
1469 let use_cache = field_overrides.remove("useCache").unwrap_or_else(|| "true".to_string());
1470 let enable_quality_processing = field_overrides
1471 .remove("enableQualityProcessing")
1472 .unwrap_or_else(|| "true".to_string());
1473 let force_ocr = field_overrides
1474 .remove("forceOcr")
1475 .unwrap_or_else(|| "false".to_string());
1476 let disable_ocr = field_overrides
1477 .remove("disableOcr")
1478 .unwrap_or_else(|| "false".to_string());
1479 let include_document_structure = field_overrides
1480 .remove("includeDocumentStructure")
1481 .unwrap_or_else(|| "false".to_string());
1482 let use_layout_for_markdown = field_overrides
1483 .remove("useLayoutForMarkdown")
1484 .unwrap_or_else(|| "false".to_string());
1485 let max_archive_depth = field_overrides
1486 .remove("maxArchiveDepth")
1487 .unwrap_or_else(|| "3".to_string());
1488
1489 format!(
1490 "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})"
1491 )
1492}
1493
1494struct DartTestClientRenderer {
1510 in_skip: Cell<bool>,
1513 is_redirect: Cell<bool>,
1516}
1517
1518impl DartTestClientRenderer {
1519 fn new(is_redirect: bool) -> Self {
1520 Self {
1521 in_skip: Cell::new(false),
1522 is_redirect: Cell::new(is_redirect),
1523 }
1524 }
1525}
1526
1527impl client::TestClientRenderer for DartTestClientRenderer {
1528 fn language_name(&self) -> &'static str {
1529 "dart"
1530 }
1531
1532 fn render_test_open(&self, out: &mut String, _fn_name: &str, description: &str, skip_reason: Option<&str>) {
1541 let escaped_desc = escape_dart(description);
1542 if let Some(reason) = skip_reason {
1543 let escaped_reason = escape_dart(reason);
1544 let _ = writeln!(out, " test('{escaped_desc}', () {{");
1545 let _ = writeln!(out, " markTestSkipped('{escaped_reason}');");
1546 let _ = writeln!(out, " }});");
1547 let _ = writeln!(out);
1548 self.in_skip.set(true);
1549 } else {
1550 let _ = writeln!(
1551 out,
1552 " test('{escaped_desc}', () => _serialized(() => _withRetry(() async {{"
1553 );
1554 self.in_skip.set(false);
1555 }
1556 }
1557
1558 fn render_test_close(&self, out: &mut String) {
1563 if self.in_skip.get() {
1564 return;
1566 }
1567 let _ = writeln!(out, " }})));");
1568 let _ = writeln!(out);
1569 }
1570
1571 fn render_call(&self, out: &mut String, ctx: &client::CallCtx<'_>) {
1581 const DART_RESTRICTED_HEADERS: &[&str] = &["content-length", "host", "transfer-encoding"];
1583
1584 let method = ctx.method.to_uppercase();
1585 let escaped_method = escape_dart(&method);
1586
1587 let fixture_path = escape_dart(ctx.path);
1589
1590 let has_explicit_content_type = ctx.headers.keys().any(|k| k.to_lowercase() == "content-type");
1592 let effective_content_type = if has_explicit_content_type {
1593 ctx.headers
1594 .iter()
1595 .find(|(k, _)| k.to_lowercase() == "content-type")
1596 .map(|(_, v)| v.as_str())
1597 .unwrap_or("application/json")
1598 } else if ctx.body.is_some() {
1599 ctx.content_type.unwrap_or("application/json")
1600 } else {
1601 ""
1602 };
1603
1604 let _ = writeln!(
1605 out,
1606 " final baseUrl = Platform.environment['MOCK_SERVER_URL'] ?? 'http://localhost:8080';"
1607 );
1608 let _ = writeln!(out, " final uri = Uri.parse('$baseUrl{fixture_path}');");
1609 let _ = writeln!(
1610 out,
1611 " final ioReq = await _httpClient.openUrl('{escaped_method}', uri);"
1612 );
1613
1614 if self.is_redirect.get() {
1617 let _ = writeln!(out, " ioReq.followRedirects = false;");
1618 }
1619
1620 if !effective_content_type.is_empty() {
1622 let escaped_ct = escape_dart(effective_content_type);
1623 let _ = writeln!(out, " ioReq.headers.set('content-type', '{escaped_ct}');");
1624 }
1625
1626 let mut header_pairs: Vec<(&String, &String)> = ctx.headers.iter().collect();
1628 header_pairs.sort_by_key(|(k, _)| k.as_str());
1629 for (name, value) in &header_pairs {
1630 if DART_RESTRICTED_HEADERS.contains(&name.to_lowercase().as_str()) {
1631 continue;
1632 }
1633 if name.to_lowercase() == "content-type" {
1634 continue; }
1636 let escaped_name = escape_dart(&name.to_lowercase());
1637 let escaped_value = escape_dart(value);
1638 let _ = writeln!(out, " ioReq.headers.set('{escaped_name}', '{escaped_value}');");
1639 }
1640
1641 if !ctx.cookies.is_empty() {
1643 let mut cookie_pairs: Vec<(&String, &String)> = ctx.cookies.iter().collect();
1644 cookie_pairs.sort_by_key(|(k, _)| k.as_str());
1645 let cookie_str: Vec<String> = cookie_pairs.iter().map(|(k, v)| format!("{k}={v}")).collect();
1646 let cookie_header = escape_dart(&cookie_str.join("; "));
1647 let _ = writeln!(out, " ioReq.headers.set('cookie', '{cookie_header}');");
1648 }
1649
1650 if let Some(body) = ctx.body {
1652 let json_str = serde_json::to_string(body).unwrap_or_default();
1653 let escaped = escape_dart(&json_str);
1654 let _ = writeln!(out, " final bodyBytes = utf8.encode('{escaped}');");
1655 let _ = writeln!(out, " ioReq.add(bodyBytes);");
1656 }
1657
1658 let _ = writeln!(out, " final ioResp = await ioReq.close();");
1659 if !self.is_redirect.get() {
1663 let _ = writeln!(out, " final bodyStr = await ioResp.transform(utf8.decoder).join();");
1664 };
1665 }
1666
1667 fn render_assert_status(&self, out: &mut String, _response_var: &str, status: u16) {
1668 let _ = writeln!(
1669 out,
1670 " expect(ioResp.statusCode, equals({status}), reason: 'status code mismatch');"
1671 );
1672 }
1673
1674 fn render_assert_header(&self, out: &mut String, _response_var: &str, name: &str, expected: &str) {
1677 let escaped_name = escape_dart(&name.to_lowercase());
1678 match expected {
1679 "<<present>>" => {
1680 let _ = writeln!(
1681 out,
1682 " expect(ioResp.headers.value('{escaped_name}'), isNotNull, reason: 'header {escaped_name} should be present');"
1683 );
1684 }
1685 "<<absent>>" => {
1686 let _ = writeln!(
1687 out,
1688 " expect(ioResp.headers.value('{escaped_name}'), isNull, reason: 'header {escaped_name} should be absent');"
1689 );
1690 }
1691 "<<uuid>>" => {
1692 let _ = writeln!(
1693 out,
1694 " 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');"
1695 );
1696 }
1697 exact => {
1698 let escaped_value = escape_dart(exact);
1699 let _ = writeln!(
1700 out,
1701 " expect(ioResp.headers.value('{escaped_name}'), contains('{escaped_value}'), reason: 'header {escaped_name} mismatch');"
1702 );
1703 }
1704 }
1705 }
1706
1707 fn render_assert_json_body(&self, out: &mut String, _response_var: &str, expected: &serde_json::Value) {
1712 match expected {
1713 serde_json::Value::Object(_) | serde_json::Value::Array(_) => {
1714 let json_str = serde_json::to_string(expected).unwrap_or_default();
1715 let escaped = escape_dart(&json_str);
1716 let _ = writeln!(out, " final bodyJson = jsonDecode(bodyStr);");
1717 let _ = writeln!(out, " final expectedJson = jsonDecode('{escaped}');");
1718 let _ = writeln!(
1719 out,
1720 " expect(bodyJson, equals(expectedJson), reason: 'body mismatch');"
1721 );
1722 }
1723 serde_json::Value::String(s) => {
1724 let escaped = escape_dart(s);
1725 let _ = writeln!(
1726 out,
1727 " expect(bodyStr.trim(), equals('{escaped}'), reason: 'body mismatch');"
1728 );
1729 }
1730 other => {
1731 let escaped = escape_dart(&other.to_string());
1732 let _ = writeln!(
1733 out,
1734 " expect(bodyStr.trim(), equals('{escaped}'), reason: 'body mismatch');"
1735 );
1736 }
1737 }
1738 }
1739
1740 fn render_assert_partial_body(&self, out: &mut String, _response_var: &str, expected: &serde_json::Value) {
1743 let _ = writeln!(
1744 out,
1745 " final partialJson = jsonDecode(bodyStr) as Map<String, dynamic>;"
1746 );
1747 if let Some(obj) = expected.as_object() {
1748 for (idx, (key, val)) in obj.iter().enumerate() {
1749 let escaped_key = escape_dart(key);
1750 let json_val = serde_json::to_string(val).unwrap_or_default();
1751 let escaped_val = escape_dart(&json_val);
1752 let _ = writeln!(out, " final _expectedField{idx} = jsonDecode('{escaped_val}');");
1755 let _ = writeln!(
1756 out,
1757 " expect(partialJson['{escaped_key}'], equals(_expectedField{idx}), reason: 'partial body field \\'{escaped_key}\\' mismatch');"
1758 );
1759 }
1760 }
1761 }
1762
1763 fn render_assert_validation_errors(
1765 &self,
1766 out: &mut String,
1767 _response_var: &str,
1768 errors: &[ValidationErrorExpectation],
1769 ) {
1770 let _ = writeln!(out, " final errBody = jsonDecode(bodyStr) as Map<String, dynamic>;");
1771 let _ = writeln!(out, " final errList = (errBody['errors'] ?? []) as List<dynamic>;");
1772 for ve in errors {
1773 let loc_dart: Vec<String> = ve.loc.iter().map(|s| format!("'{}'", escape_dart(s))).collect();
1774 let loc_str = loc_dart.join(", ");
1775 let escaped_msg = escape_dart(&ve.msg);
1776 let _ = writeln!(
1777 out,
1778 " 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}');"
1779 );
1780 }
1781 }
1782}
1783
1784fn render_http_test_case(out: &mut String, fixture: &Fixture, http: &HttpFixture) {
1791 if http.expected_response.status_code == 101 {
1793 let description = escape_dart(&fixture.description);
1794 let _ = writeln!(out, " test('{description}', () {{");
1795 let _ = writeln!(
1796 out,
1797 " markTestSkipped('Skipped: Dart HttpClient cannot handle 101 Switching Protocols responses');"
1798 );
1799 let _ = writeln!(out, " }});");
1800 let _ = writeln!(out);
1801 return;
1802 }
1803
1804 let is_redirect = http.expected_response.status_code / 100 == 3;
1808 client::http_call::render_http_test(out, &DartTestClientRenderer::new(is_redirect), fixture);
1809}
1810
1811fn mime_from_extension(path: &str) -> Option<&'static str> {
1816 let ext = path.rsplit('.').next()?;
1817 match ext.to_lowercase().as_str() {
1818 "docx" => Some("application/vnd.openxmlformats-officedocument.wordprocessingml.document"),
1819 "xlsx" => Some("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"),
1820 "pptx" => Some("application/vnd.openxmlformats-officedocument.presentationml.presentation"),
1821 "pdf" => Some("application/pdf"),
1822 "txt" | "text" => Some("text/plain"),
1823 "html" | "htm" => Some("text/html"),
1824 "json" => Some("application/json"),
1825 "xml" => Some("application/xml"),
1826 "csv" => Some("text/csv"),
1827 "md" | "markdown" => Some("text/markdown"),
1828 "png" => Some("image/png"),
1829 "jpg" | "jpeg" => Some("image/jpeg"),
1830 "gif" => Some("image/gif"),
1831 "zip" => Some("application/zip"),
1832 "odt" => Some("application/vnd.oasis.opendocument.text"),
1833 "ods" => Some("application/vnd.oasis.opendocument.spreadsheet"),
1834 "odp" => Some("application/vnd.oasis.opendocument.presentation"),
1835 "rtf" => Some("application/rtf"),
1836 "epub" => Some("application/epub+zip"),
1837 "msg" => Some("application/vnd.ms-outlook"),
1838 "eml" => Some("message/rfc822"),
1839 _ => None,
1840 }
1841}
1842
1843fn emit_dart_batch_item_array(arr: &serde_json::Value, elem_type: &str) -> String {
1850 let items: Vec<String> = arr
1851 .as_array()
1852 .map(|a| a.as_slice())
1853 .unwrap_or_default()
1854 .iter()
1855 .filter_map(|item| {
1856 let obj = item.as_object()?;
1857 match elem_type {
1858 "BatchBytesItem" => {
1859 let content_bytes = obj
1860 .get("content")
1861 .and_then(|v| v.as_array())
1862 .map(|arr| {
1863 let nums: Vec<String> =
1864 arr.iter().filter_map(|v| v.as_u64().map(|n| n.to_string())).collect();
1865 format!("Uint8List.fromList([{}])", nums.join(", "))
1866 })
1867 .unwrap_or_else(|| "Uint8List(0)".to_string());
1868 let mime_type = obj
1869 .get("mime_type")
1870 .and_then(|v| v.as_str())
1871 .unwrap_or("application/octet-stream");
1872 Some(format!(
1873 "BatchBytesItem(content: {content_bytes}, mimeType: '{}')",
1874 escape_dart(mime_type)
1875 ))
1876 }
1877 "BatchFileItem" => {
1878 let path = obj.get("path").and_then(|v| v.as_str()).unwrap_or("");
1879 Some(format!("BatchFileItem(path: '{}')", escape_dart(path)))
1880 }
1881 _ => None,
1882 }
1883 })
1884 .collect();
1885 format!("[{}]", items.join(", "))
1886}
1887
1888pub(super) fn escape_dart(s: &str) -> String {
1890 s.replace('\\', "\\\\")
1891 .replace('\'', "\\'")
1892 .replace('\n', "\\n")
1893 .replace('\r', "\\r")
1894 .replace('\t', "\\t")
1895 .replace('$', "\\$")
1896}
1897
1898fn type_name_to_create_from_json_dart(type_name: &str) -> String {
1906 let mut snake = String::with_capacity(type_name.len() + 8);
1908 for (i, ch) in type_name.char_indices() {
1909 if ch.is_uppercase() {
1910 if i > 0 {
1911 snake.push('_');
1912 }
1913 snake.extend(ch.to_lowercase());
1914 } else {
1915 snake.push(ch);
1916 }
1917 }
1918 let rust_fn = format!("create_{snake}_from_json");
1921 rust_fn
1923 .split('_')
1924 .enumerate()
1925 .map(|(i, part)| {
1926 if i == 0 {
1927 part.to_string()
1928 } else {
1929 let mut chars = part.chars();
1930 match chars.next() {
1931 None => String::new(),
1932 Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
1933 }
1934 }
1935 })
1936 .collect::<Vec<_>>()
1937 .join("")
1938}