1use crate::codegen::resolve_field;
8use crate::config::E2eConfig;
9use crate::escape::sanitize_filename;
10use crate::fixture::{Assertion, Fixture, FixtureGroup, HttpFixture, ValidationErrorExpectation};
11use alef_core::backend::GeneratedFile;
12use alef_core::config::ResolvedCrateConfig;
13use alef_core::hash::{self, CommentStyle};
14use alef_core::template_versions::pub_dev;
15use anyhow::Result;
16use heck::ToLowerCamelCase;
17use std::cell::Cell;
18use std::fmt::Write as FmtWrite;
19use std::path::PathBuf;
20
21use super::E2eCodegen;
22use super::client;
23
24pub struct DartE2eCodegen;
26
27impl E2eCodegen for DartE2eCodegen {
28 fn generate(
29 &self,
30 groups: &[FixtureGroup],
31 e2e_config: &E2eConfig,
32 config: &ResolvedCrateConfig,
33 _type_defs: &[alef_core::ir::TypeDef],
34 ) -> Result<Vec<GeneratedFile>> {
35 let lang = self.language_name();
36 let output_base = PathBuf::from(e2e_config.effective_output()).join(lang);
37
38 let mut files = Vec::new();
39
40 let dart_pkg = e2e_config.resolve_package("dart");
42 let pkg_name = dart_pkg
43 .as_ref()
44 .and_then(|p| p.name.as_ref())
45 .cloned()
46 .unwrap_or_else(|| config.dart_pubspec_name());
47 let pkg_path = dart_pkg
48 .as_ref()
49 .and_then(|p| p.path.as_ref())
50 .cloned()
51 .unwrap_or_else(|| "../../packages/dart".to_string());
52 let pkg_version = dart_pkg
53 .as_ref()
54 .and_then(|p| p.version.as_ref())
55 .cloned()
56 .or_else(|| config.resolved_version())
57 .unwrap_or_else(|| "0.1.0".to_string());
58
59 files.push(GeneratedFile {
61 path: output_base.join("pubspec.yaml"),
62 content: render_pubspec(&pkg_name, &pkg_path, &pkg_version, e2e_config.dep_mode),
63 generated_header: false,
64 });
65
66 files.push(GeneratedFile {
69 path: output_base.join("dart_test.yaml"),
70 content: concat!(
71 "# Generated by alef — DO NOT EDIT.\n",
72 "# Run test files sequentially to avoid overwhelming the mock server with\n",
73 "# concurrent keep-alive connections.\n",
74 "concurrency: 1\n",
75 )
76 .to_string(),
77 generated_header: false,
78 });
79
80 let test_base = output_base.join("test");
81
82 let bridge_class = config.dart_bridge_class_name();
84
85 for group in groups {
86 let active: Vec<&Fixture> = group
87 .fixtures
88 .iter()
89 .filter(|f| super::should_include_fixture(f, lang, e2e_config))
90 .collect();
91
92 if active.is_empty() {
93 continue;
94 }
95
96 let filename = format!("{}_test.dart", sanitize_filename(&group.category));
97 let content = render_test_file(&group.category, &active, e2e_config, lang, &pkg_name, &bridge_class);
98 files.push(GeneratedFile {
99 path: test_base.join(filename),
100 content,
101 generated_header: true,
102 });
103 }
104
105 Ok(files)
106 }
107
108 fn language_name(&self) -> &'static str {
109 "dart"
110 }
111}
112
113fn render_pubspec(
118 pkg_name: &str,
119 pkg_path: &str,
120 pkg_version: &str,
121 dep_mode: crate::config::DependencyMode,
122) -> String {
123 let test_ver = pub_dev::TEST_PACKAGE;
124 let http_ver = pub_dev::HTTP_PACKAGE;
125
126 let dep_block = match dep_mode {
127 crate::config::DependencyMode::Registry => {
128 format!(" {pkg_name}: ^{pkg_version}")
129 }
130 crate::config::DependencyMode::Local => {
131 format!(" {pkg_name}:\n path: {pkg_path}")
132 }
133 };
134
135 let sdk = alef_core::template_versions::toolchain::DART_SDK_CONSTRAINT;
136 format!(
137 r#"name: e2e_dart
138version: 0.1.0
139publish_to: none
140
141environment:
142 sdk: "{sdk}"
143
144dependencies:
145{dep_block}
146
147dev_dependencies:
148 test: {test_ver}
149 http: {http_ver}
150"#
151 )
152}
153
154fn render_test_file(
155 category: &str,
156 fixtures: &[&Fixture],
157 e2e_config: &E2eConfig,
158 lang: &str,
159 pkg_name: &str,
160 bridge_class: &str,
161) -> String {
162 let mut out = String::new();
163 out.push_str(&hash::header(CommentStyle::DoubleSlash));
164
165 let has_http_fixtures = fixtures.iter().any(|f| f.is_http_test());
167
168 let has_batch_byte_items = fixtures.iter().any(|f| {
170 let call_config = e2e_config.resolve_call_for_fixture(f.call.as_deref(), &f.input);
171 call_config.args.iter().any(|a| {
172 a.element_type.as_deref() == Some("BatchBytesItem") && resolve_field(&f.input, &a.field).is_array()
173 })
174 });
175
176 let needs_chdir = fixtures.iter().any(|f| {
180 if f.is_http_test() {
181 return false;
182 }
183 let call_config = e2e_config.resolve_call_for_fixture(f.call.as_deref(), &f.input);
184 call_config
185 .args
186 .iter()
187 .any(|a| a.arg_type == "file_path" || a.arg_type == "bytes")
188 });
189
190 let has_handle_args = fixtures.iter().any(|f| {
193 if f.is_http_test() {
194 return false;
195 }
196 let call_config = e2e_config.resolve_call_for_fixture(f.call.as_deref(), &f.input);
197 call_config.args.iter().any(|a| a.arg_type == "handle")
198 });
199
200 let _ = writeln!(out, "import 'package:test/test.dart';");
201 let _ = writeln!(out, "import 'dart:io';");
202 if has_batch_byte_items {
203 let _ = writeln!(out, "import 'dart:typed_data';");
204 }
205 let _ = writeln!(out, "import 'package:{pkg_name}/{pkg_name}.dart';");
206 let _ = writeln!(
209 out,
210 "import 'package:{pkg_name}/src/{pkg_name}_bridge_generated/frb_generated.dart' show RustLib;"
211 );
212 if has_http_fixtures {
213 let _ = writeln!(out, "import 'dart:async';");
214 }
215 if has_http_fixtures || has_handle_args {
217 let _ = writeln!(out, "import 'dart:convert';");
218 }
219 let _ = writeln!(out);
220
221 if has_http_fixtures {
231 let _ = writeln!(out, "HttpClient _httpClient = HttpClient()..maxConnectionsPerHost = 1;");
232 let _ = writeln!(out);
233 let _ = writeln!(out, "var _lock = Future<void>.value();");
234 let _ = writeln!(out);
235 let _ = writeln!(out, "Future<T> _serialized<T>(Future<T> Function() fn) async {{");
236 let _ = writeln!(out, " final current = _lock;");
237 let _ = writeln!(out, " final next = Completer<void>();");
238 let _ = writeln!(out, " _lock = next.future;");
239 let _ = writeln!(out, " try {{");
240 let _ = writeln!(out, " await current;");
241 let _ = writeln!(out, " return await fn();");
242 let _ = writeln!(out, " }} finally {{");
243 let _ = writeln!(out, " next.complete();");
244 let _ = writeln!(out, " }}");
245 let _ = writeln!(out, "}}");
246 let _ = writeln!(out);
247 let _ = writeln!(out, "Future<T> _withRetry<T>(Future<T> Function() fn) async {{");
250 let _ = writeln!(out, " try {{");
251 let _ = writeln!(out, " return await fn();");
252 let _ = writeln!(out, " }} on SocketException {{");
253 let _ = writeln!(out, " _httpClient.close(force: true);");
254 let _ = writeln!(out, " _httpClient = HttpClient()..maxConnectionsPerHost = 1;");
255 let _ = writeln!(out, " return fn();");
256 let _ = writeln!(out, " }} on HttpException {{");
257 let _ = writeln!(out, " _httpClient.close(force: true);");
258 let _ = writeln!(out, " _httpClient = HttpClient()..maxConnectionsPerHost = 1;");
259 let _ = writeln!(out, " return fn();");
260 let _ = writeln!(out, " }}");
261 let _ = writeln!(out, "}}");
262 let _ = writeln!(out);
263 }
264
265 let _ = writeln!(out, "// E2e tests for category: {category}");
266 let _ = writeln!(out, "void main() {{");
267
268 let _ = writeln!(out, " setUpAll(() async {{");
275 let _ = writeln!(out, " await RustLib.init();");
276 if needs_chdir {
277 let test_docs_path = e2e_config.test_documents_relative_from(0);
278 let _ = writeln!(
279 out,
280 " final _testDocs = Platform.environment['FIXTURES_DIR'] ?? '{test_docs_path}';"
281 );
282 let _ = writeln!(out, " final _dir = Directory(_testDocs);");
283 let _ = writeln!(out, " if (_dir.existsSync()) Directory.current = _dir;");
284 }
285 let _ = writeln!(out, " }});");
286 let _ = writeln!(out);
287
288 if has_http_fixtures {
290 let _ = writeln!(out, " tearDownAll(() => _httpClient.close());");
291 let _ = writeln!(out);
292 }
293
294 for fixture in fixtures {
295 render_test_case(&mut out, fixture, e2e_config, lang, bridge_class);
296 }
297
298 let _ = writeln!(out, "}}");
299 out
300}
301
302fn render_test_case(out: &mut String, fixture: &Fixture, e2e_config: &E2eConfig, lang: &str, bridge_class: &str) {
303 if let Some(http) = &fixture.http {
305 render_http_test_case(out, fixture, http);
306 return;
307 }
308
309 let call_config = e2e_config.resolve_call_for_fixture(fixture.call.as_deref(), &fixture.input);
311 let call_overrides = call_config.overrides.get(lang);
312 let mut function_name = call_overrides
313 .and_then(|o| o.function.as_ref())
314 .cloned()
315 .unwrap_or_else(|| call_config.function.clone());
316 function_name = function_name
318 .split('_')
319 .enumerate()
320 .map(|(i, part)| {
321 if i == 0 {
322 part.to_string()
323 } else {
324 let mut chars = part.chars();
325 match chars.next() {
326 None => String::new(),
327 Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
328 }
329 }
330 })
331 .collect::<Vec<_>>()
332 .join("");
333 let result_var = &call_config.result_var;
334 let description = escape_dart(&fixture.description);
335 let fixture_id = &fixture.id;
336 let _is_async = call_overrides.and_then(|o| o.r#async).unwrap_or(call_config.r#async);
339
340 let expects_error = fixture.assertions.iter().any(|a| a.assertion_type == "error");
341 let is_streaming = fixture.is_streaming_mock();
342 let result_is_simple = call_overrides.is_some_and(|o| o.result_is_simple) || call_config.result_is_simple;
347
348 let options_type: Option<&str> = call_overrides.and_then(|o| o.options_type.as_deref());
355 let options_via: &str = call_overrides
356 .and_then(|o| o.options_via.as_deref())
357 .unwrap_or("kwargs");
358
359 let file_path_for_mime: Option<&str> = call_config
367 .args
368 .iter()
369 .find(|a| a.arg_type == "file_path")
370 .and_then(|a| resolve_field(&fixture.input, &a.field).as_str());
371
372 let has_file_path_arg = call_config.args.iter().any(|a| a.arg_type == "file_path");
379 let caller_supplied_override = call_overrides.and_then(|o| o.function.as_ref()).is_some();
382 if has_file_path_arg && !caller_supplied_override {
383 function_name = match function_name.as_str() {
384 "extractFile" => "extractBytes".to_string(),
385 "extractFileSync" => "extractBytesSync".to_string(),
386 other => other.to_string(),
387 };
388 }
389
390 let mut setup_lines: Vec<String> = Vec::new();
393 let mut args = Vec::new();
394
395 for arg_def in &call_config.args {
396 match arg_def.arg_type.as_str() {
397 "mock_url" => {
398 let name = arg_def.name.clone();
399 if fixture.has_host_root_route() {
400 let env_key = format!("MOCK_SERVER_{}", fixture_id.to_uppercase());
401 setup_lines.push(format!(
402 r#"final {name} = Platform.environment["{env_key}"] ?? (Platform.environment["MOCK_SERVER_URL"]! + "/fixtures/{fixture_id}");"#
403 ));
404 } else {
405 setup_lines.push(format!(
406 r#"final {name} = "${{Platform.environment["MOCK_SERVER_URL"] ?? "http://localhost:8080"}}/fixtures/{fixture_id}";"#
407 ));
408 }
409 args.push(name);
410 continue;
411 }
412 "handle" => {
413 let name = arg_def.name.clone();
414 let field = arg_def.field.strip_prefix("input.").unwrap_or(&arg_def.field);
415 let config_value = fixture.input.get(field).cloned().unwrap_or(serde_json::Value::Null);
416 let create_fn = {
418 let mut chars = name.chars();
419 let pascal = match chars.next() {
420 None => String::new(),
421 Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
422 };
423 format!("create{pascal}")
424 };
425 if config_value.is_null()
426 || config_value.is_object() && config_value.as_object().is_some_and(|o| o.is_empty())
427 {
428 setup_lines.push(format!("final {name} = await {bridge_class}.{create_fn}(null);"));
429 } else {
430 let json_str = serde_json::to_string(&config_value).unwrap_or_default();
431 let config_var = format!("{name}Config");
432 setup_lines.push(format!(
433 "final {config_var} = CrawlConfig.fromJson(jsonDecode(r'{json_str}') as Map<String, dynamic>);"
434 ));
435 setup_lines.push(format!(
436 "final {name} = await {bridge_class}.{create_fn}({config_var});"
437 ));
438 }
439 args.push(name);
440 continue;
441 }
442 _ => {}
443 }
444
445 let arg_value = resolve_field(&fixture.input, &arg_def.field);
446 match arg_def.arg_type.as_str() {
447 "bytes" | "file_path" => {
448 if let serde_json::Value::String(file_path) = arg_value {
453 args.push(format!("File('{}').readAsBytesSync()", file_path));
454 }
455 }
456 "string" => {
457 let dart_param_name = snake_to_camel(&arg_def.name);
461 match arg_value {
462 serde_json::Value::String(s) => {
463 args.push(format!("{dart_param_name}: '{}'", escape_dart(s)));
464 }
465 serde_json::Value::Null
466 if arg_def.optional
467 && arg_def.name == "mime_type" =>
470 {
471 let inferred = file_path_for_mime
472 .and_then(mime_from_extension)
473 .unwrap_or("application/octet-stream");
474 args.push(format!("{dart_param_name}: '{inferred}'"));
475 }
476 _ => {}
478 }
479 }
480 "json_object" => {
481 if let Some(elem_type) = &arg_def.element_type {
483 if (elem_type == "BatchBytesItem" || elem_type == "BatchFileItem") && arg_value.is_array() {
484 let dart_items = emit_dart_batch_item_array(arg_value, elem_type);
485 args.push(dart_items);
486 }
487 } else if options_via == "from_json" {
488 if let Some(opts_type) = options_type {
498 if !arg_value.is_null() {
499 let json_str = serde_json::to_string(&arg_value).unwrap_or_default();
500 let escaped_json = escape_dart(&json_str);
503 let var_name = format!("_{}", arg_def.name);
504 let dart_fn = type_name_to_create_from_json_dart(opts_type);
505 setup_lines.push(format!("final {var_name} = await {dart_fn}(json: '{escaped_json}');"));
506 args.push(format!("req: {var_name}"));
509 }
510 }
511 } else if arg_def.name == "config" {
512 if let serde_json::Value::Object(map) = &arg_value {
513 if !map.is_empty() {
517 args.push(emit_extraction_config_dart(map));
518 }
519 }
520 } else if arg_value.is_array() {
522 let json_str = serde_json::to_string(&arg_value).unwrap_or_default();
525 let var_name = arg_def.name.clone();
526 setup_lines.push(format!(
527 "final {var_name} = (jsonDecode(r'{json_str}') as List<dynamic>).cast<String>();"
528 ));
529 args.push(var_name);
530 }
531 }
532 _ => {}
533 }
534 }
535
536 let client_factory: Option<&str> = call_overrides.and_then(|o| o.client_factory.as_deref()).or_else(|| {
540 e2e_config
541 .call
542 .overrides
543 .get(lang)
544 .and_then(|o| o.client_factory.as_deref())
545 });
546
547 let client_factory_camel: Option<String> = client_factory.map(|f| {
549 f.split('_')
550 .enumerate()
551 .map(|(i, part)| {
552 if i == 0 {
553 part.to_string()
554 } else {
555 let mut chars = part.chars();
556 match chars.next() {
557 None => String::new(),
558 Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
559 }
560 }
561 })
562 .collect::<Vec<_>>()
563 .join("")
564 });
565
566 let _ = writeln!(out, " test('{description}', () async {{");
570
571 let args_str = args.join(", ");
572 let receiver_class = call_overrides
573 .and_then(|o| o.class.as_ref())
574 .cloned()
575 .unwrap_or_else(|| bridge_class.to_string());
576
577 let (receiver, extra_setup): (String, Option<String>) = if let Some(factory) = &client_factory_camel {
581 let has_mock_url = call_config.args.iter().any(|a| a.arg_type == "mock_url");
582 let mock_url_setup = if !has_mock_url {
583 if fixture.has_host_root_route() {
585 let env_key = format!("MOCK_SERVER_{}", fixture_id.to_uppercase());
586 Some(format!(
587 "final _mockUrl = Platform.environment[\"{env_key}\"] ?? (Platform.environment[\"MOCK_SERVER_URL\"]! + \"/fixtures/{fixture_id}\");"
588 ))
589 } else {
590 Some(format!(
591 r#"final _mockUrl = "${{Platform.environment["MOCK_SERVER_URL"] ?? "http://localhost:8080"}}/fixtures/{fixture_id}";"#
592 ))
593 }
594 } else {
595 None
596 };
597 let url_expr = if has_mock_url {
598 call_config
601 .args
602 .iter()
603 .find(|a| a.arg_type == "mock_url")
604 .map(|a| a.name.clone())
605 .unwrap_or_else(|| "_mockUrl".to_string())
606 } else {
607 "_mockUrl".to_string()
608 };
609 let create_line = format!("final _client = await {receiver_class}.{factory}('test-key', baseUrl: {url_expr});");
610 let full_setup = if let Some(url_line) = mock_url_setup {
611 Some(format!("{url_line}\n {create_line}"))
612 } else {
613 Some(create_line)
614 };
615 ("_client".to_string(), full_setup)
616 } else {
617 (receiver_class.clone(), None)
618 };
619
620 if expects_error && (!setup_lines.is_empty() || extra_setup.is_some()) {
621 let _ = writeln!(out, " await expectLater(() async {{");
625 for line in &setup_lines {
626 let _ = writeln!(out, " {line}");
627 }
628 if let Some(extra) = &extra_setup {
629 for line in extra.lines() {
630 let _ = writeln!(out, " {line}");
631 }
632 }
633 if is_streaming {
634 let _ = writeln!(out, " return {receiver}.{function_name}({args_str}).toList();");
635 } else {
636 let _ = writeln!(out, " return {receiver}.{function_name}({args_str});");
637 }
638 let _ = writeln!(out, " }}(), throwsA(anything));");
639 } else if expects_error {
640 if let Some(extra) = &extra_setup {
642 for line in extra.lines() {
643 let _ = writeln!(out, " {line}");
644 }
645 }
646 if is_streaming {
647 let _ = writeln!(
648 out,
649 " await expectLater({receiver}.{function_name}({args_str}).toList(), throwsA(anything));"
650 );
651 } else {
652 let _ = writeln!(
653 out,
654 " await expectLater({receiver}.{function_name}({args_str}), throwsA(anything));"
655 );
656 }
657 } else {
658 for line in &setup_lines {
659 let _ = writeln!(out, " {line}");
660 }
661 if let Some(extra) = &extra_setup {
662 for line in extra.lines() {
663 let _ = writeln!(out, " {line}");
664 }
665 }
666 if is_streaming {
667 let _ = writeln!(
668 out,
669 " final {result_var} = await {receiver}.{function_name}({args_str}).toList();"
670 );
671 } else {
672 let _ = writeln!(
673 out,
674 " final {result_var} = await {receiver}.{function_name}({args_str});"
675 );
676 }
677 for assertion in &fixture.assertions {
678 if is_streaming {
679 render_streaming_assertion_dart(out, assertion, result_var);
680 } else {
681 render_assertion_dart(out, assertion, result_var, result_is_simple);
682 }
683 }
684 }
685
686 let _ = writeln!(out, " }});");
687 let _ = writeln!(out);
688}
689
690fn dart_format_value(val: &serde_json::Value) -> String {
691 match val {
692 serde_json::Value::String(s) => format!("'{}'", escape_dart(s)),
693 serde_json::Value::Bool(b) => b.to_string(),
694 serde_json::Value::Number(n) => n.to_string(),
695 serde_json::Value::Null => "null".to_string(),
696 other => format!("'{}'", escape_dart(&other.to_string())),
697 }
698}
699
700fn render_assertion_dart(out: &mut String, assertion: &Assertion, result_var: &str, result_is_simple: bool) {
705 if let Some(f) = assertion.field.as_deref() {
707 if let Some(dot) = f.find("[].") {
708 let array_part = &f[..dot];
709 let elem_part = &f[dot + 3..];
710 let array_accessor = if array_part.is_empty() {
711 result_var.to_string()
712 } else {
713 format!("{result_var}.{}", field_to_dart_accessor(array_part))
714 };
715 let elem_accessor = field_to_dart_accessor(elem_part);
716 match assertion.assertion_type.as_str() {
717 "contains" => {
718 if let Some(expected) = &assertion.value {
719 let dart_val = dart_format_value(expected);
720 let _ = writeln!(
721 out,
722 " expect({array_accessor}.any((e) => e.{elem_accessor}.toString().contains({dart_val})), isTrue);"
723 );
724 }
725 }
726 "contains_all" => {
727 if let Some(values) = &assertion.values {
728 for val in values {
729 let dart_val = dart_format_value(val);
730 let _ = writeln!(
731 out,
732 " expect({array_accessor}.any((e) => e.{elem_accessor}.toString().contains({dart_val})), isTrue);"
733 );
734 }
735 }
736 }
737 "not_contains" => {
738 if let Some(expected) = &assertion.value {
739 let dart_val = dart_format_value(expected);
740 let _ = writeln!(
741 out,
742 " expect({array_accessor}.any((e) => e.{elem_accessor}.toString().contains({dart_val})), isFalse);"
743 );
744 } else if let Some(values) = &assertion.values {
745 for val in values {
746 let dart_val = dart_format_value(val);
747 let _ = writeln!(
748 out,
749 " expect({array_accessor}.any((e) => e.{elem_accessor}.toString().contains({dart_val})), isFalse);"
750 );
751 }
752 }
753 }
754 "not_empty" => {
755 let _ = writeln!(
756 out,
757 " expect({array_accessor}.any((e) => e.{elem_accessor}.toString().isNotEmpty), isTrue);"
758 );
759 }
760 other => {
761 let _ = writeln!(
762 out,
763 " // skipped: unsupported traversal assertion '{other}' on '{f}'"
764 );
765 }
766 }
767 return;
768 }
769 }
770
771 let field_accessor = if result_is_simple {
772 result_var.to_string()
776 } else {
777 match assertion.field.as_deref() {
778 Some(f) if !f.is_empty() => format!("{result_var}.{}", field_to_dart_accessor(f)),
779 _ => result_var.to_string(),
780 }
781 };
782
783 let format_value = |val: &serde_json::Value| -> String { dart_format_value(val) };
784
785 match assertion.assertion_type.as_str() {
786 "equals" | "field_equals" => {
787 if let Some(expected) = &assertion.value {
788 let dart_val = format_value(expected);
789 let _ = writeln!(out, " expect({field_accessor}, equals({dart_val}));");
790 } else {
791 let _ = writeln!(
792 out,
793 " // skipped: '{}' assertion missing value",
794 assertion.assertion_type
795 );
796 }
797 }
798 "not_equals" => {
799 if let Some(expected) = &assertion.value {
800 let dart_val = format_value(expected);
801 let _ = writeln!(out, " expect({field_accessor}, isNot(equals({dart_val})));");
802 }
803 }
804 "contains" => {
805 if let Some(expected) = &assertion.value {
806 let dart_val = format_value(expected);
807 let _ = writeln!(out, " expect({field_accessor}, contains({dart_val}));");
808 } else {
809 let _ = writeln!(out, " // skipped: 'contains' assertion missing value");
810 }
811 }
812 "contains_all" => {
813 if let Some(values) = &assertion.values {
814 for val in values {
815 let dart_val = format_value(val);
816 let _ = writeln!(out, " expect({field_accessor}, contains({dart_val}));");
817 }
818 }
819 }
820 "contains_any" => {
821 if let Some(values) = &assertion.values {
822 let checks: Vec<String> = values
823 .iter()
824 .map(|v| {
825 let dart_val = format_value(v);
826 format!("{field_accessor}.contains({dart_val})")
827 })
828 .collect();
829 let joined = checks.join(" || ");
830 let _ = writeln!(out, " expect({joined}, isTrue);");
831 }
832 }
833 "not_contains" => {
834 if let Some(expected) = &assertion.value {
835 let dart_val = format_value(expected);
836 let _ = writeln!(out, " expect({field_accessor}, isNot(contains({dart_val})));");
837 } else if let Some(values) = &assertion.values {
838 for val in values {
839 let dart_val = format_value(val);
840 let _ = writeln!(out, " expect({field_accessor}, isNot(contains({dart_val})));");
841 }
842 }
843 }
844 "not_empty" => {
845 let _ = writeln!(out, " expect({field_accessor}, isNotEmpty);");
846 }
847 "is_empty" => {
848 let _ = writeln!(out, " expect({field_accessor}, isEmpty);");
849 }
850 "starts_with" => {
851 if let Some(expected) = &assertion.value {
852 let dart_val = format_value(expected);
853 let _ = writeln!(out, " expect({field_accessor}, startsWith({dart_val}));");
854 }
855 }
856 "ends_with" => {
857 if let Some(expected) = &assertion.value {
858 let dart_val = format_value(expected);
859 let _ = writeln!(out, " expect({field_accessor}, endsWith({dart_val}));");
860 }
861 }
862 "min_length" => {
863 if let Some(val) = &assertion.value {
864 if let Some(n) = val.as_u64() {
865 let _ = writeln!(
869 out,
870 " expect({field_accessor}?.length ?? 0, greaterThanOrEqualTo({n}));"
871 );
872 }
873 }
874 }
875 "max_length" => {
876 if let Some(val) = &assertion.value {
877 if let Some(n) = val.as_u64() {
878 let _ = writeln!(
879 out,
880 " expect({field_accessor}?.length ?? 0, lessThanOrEqualTo({n}));"
881 );
882 }
883 }
884 }
885 "count_equals" => {
886 if let Some(val) = &assertion.value {
887 if let Some(n) = val.as_u64() {
888 let _ = writeln!(out, " expect({field_accessor}?.length ?? 0, equals({n}));");
889 }
890 }
891 }
892 "count_min" => {
893 if let Some(val) = &assertion.value {
894 if let Some(n) = val.as_u64() {
895 let _ = writeln!(
896 out,
897 " expect({field_accessor}?.length ?? 0, greaterThanOrEqualTo({n}));"
898 );
899 }
900 }
901 }
902 "matches_regex" => {
903 if let Some(expected) = &assertion.value {
904 let dart_val = format_value(expected);
905 let _ = writeln!(out, " expect({field_accessor}, matches(RegExp({dart_val})));");
906 }
907 }
908 "is_true" => {
909 let _ = writeln!(out, " expect({field_accessor}, isTrue);");
910 }
911 "is_false" => {
912 let _ = writeln!(out, " expect({field_accessor}, isFalse);");
913 }
914 "greater_than" => {
915 if let Some(val) = &assertion.value {
916 let dart_val = format_value(val);
917 let _ = writeln!(out, " expect({field_accessor}, greaterThan({dart_val}));");
918 }
919 }
920 "less_than" => {
921 if let Some(val) = &assertion.value {
922 let dart_val = format_value(val);
923 let _ = writeln!(out, " expect({field_accessor}, lessThan({dart_val}));");
924 }
925 }
926 "greater_than_or_equal" => {
927 if let Some(val) = &assertion.value {
928 let dart_val = format_value(val);
929 let _ = writeln!(out, " expect({field_accessor}, greaterThanOrEqualTo({dart_val}));");
930 }
931 }
932 "less_than_or_equal" => {
933 if let Some(val) = &assertion.value {
934 let dart_val = format_value(val);
935 let _ = writeln!(out, " expect({field_accessor}, lessThanOrEqualTo({dart_val}));");
936 }
937 }
938 "not_null" => {
939 let _ = writeln!(out, " expect({field_accessor}, isNotNull);");
940 }
941 "not_error" => {
942 }
944 "error" => {
945 }
947 "method_result" => {
948 if let Some(method) = &assertion.method {
949 let dart_method = method.to_lower_camel_case();
950 let check = assertion.check.as_deref().unwrap_or("not_null");
951 let method_call = format!("{field_accessor}.{dart_method}()");
952 match check {
953 "equals" => {
954 if let Some(expected) = &assertion.value {
955 let dart_val = format_value(expected);
956 let _ = writeln!(out, " expect({method_call}, equals({dart_val}));");
957 }
958 }
959 "is_true" => {
960 let _ = writeln!(out, " expect({method_call}, isTrue);");
961 }
962 "is_false" => {
963 let _ = writeln!(out, " expect({method_call}, isFalse);");
964 }
965 "greater_than_or_equal" => {
966 if let Some(val) = &assertion.value {
967 let dart_val = format_value(val);
968 let _ = writeln!(out, " expect({method_call}, greaterThanOrEqualTo({dart_val}));");
969 }
970 }
971 "count_min" => {
972 if let Some(val) = &assertion.value {
973 if let Some(n) = val.as_u64() {
974 let _ = writeln!(out, " expect({method_call}.length, greaterThanOrEqualTo({n}));");
975 }
976 }
977 }
978 _ => {
979 let _ = writeln!(out, " expect({method_call}, isNotNull);");
980 }
981 }
982 }
983 }
984 other => {
985 let _ = writeln!(out, " // skipped: unknown assertion type '{other}'");
986 }
987 }
988}
989
990fn render_streaming_assertion_dart(out: &mut String, assertion: &Assertion, result_var: &str) {
1000 match assertion.assertion_type.as_str() {
1001 "not_error" => {
1002 }
1004 "count_min" if assertion.field.as_deref() == Some("chunks") => {
1005 if let Some(serde_json::Value::Number(n)) = &assertion.value {
1006 let _ = writeln!(out, " expect({result_var}.length, greaterThanOrEqualTo({n}));");
1007 }
1008 }
1009 "equals" if assertion.field.as_deref() == Some("stream_content") => {
1010 if let Some(serde_json::Value::String(expected)) = &assertion.value {
1011 let escaped = escape_dart(expected);
1012 let _ = writeln!(
1013 out,
1014 " final _content = {result_var}.map((c) => c.choices.firstOrNull?.delta.content ?? '').join();"
1015 );
1016 let _ = writeln!(out, " expect(_content, equals('{escaped}'));");
1017 }
1018 }
1019 other => {
1020 let _ = writeln!(out, " // skipped streaming assertion: '{other}'");
1021 }
1022 }
1023}
1024
1025fn snake_to_camel(s: &str) -> String {
1027 let mut result = String::with_capacity(s.len());
1028 let mut next_upper = false;
1029 for ch in s.chars() {
1030 if ch == '_' {
1031 next_upper = true;
1032 } else if next_upper {
1033 result.extend(ch.to_uppercase());
1034 next_upper = false;
1035 } else {
1036 result.push(ch);
1037 }
1038 }
1039 result
1040}
1041
1042fn field_to_dart_accessor(path: &str) -> String {
1055 let mut result = String::with_capacity(path.len());
1056 for (i, segment) in path.split('.').enumerate() {
1057 if i > 0 {
1058 result.push('.');
1059 }
1060 if let Some(bracket_pos) = segment.find('[') {
1066 let name = &segment[..bracket_pos];
1067 let bracket = &segment[bracket_pos..];
1068 result.push_str(&name.to_lower_camel_case());
1069 result.push('!');
1070 result.push_str(bracket);
1071 } else {
1072 result.push_str(&segment.to_lower_camel_case());
1073 }
1074 }
1075 result
1076}
1077
1078fn emit_extraction_config_dart(overrides: &serde_json::Map<String, serde_json::Value>) -> String {
1084 let mut field_overrides: std::collections::HashMap<String, String> = std::collections::HashMap::new();
1086 for (key, val) in overrides {
1087 let camel = snake_to_camel(key);
1088 let dart_val = match val {
1089 serde_json::Value::Bool(b) => {
1090 if *b {
1091 "true".to_string()
1092 } else {
1093 "false".to_string()
1094 }
1095 }
1096 serde_json::Value::Number(n) => n.to_string(),
1097 serde_json::Value::String(s) => format!("'{s}'"),
1098 _ => continue, };
1100 field_overrides.insert(camel, dart_val);
1101 }
1102
1103 let use_cache = field_overrides.remove("useCache").unwrap_or_else(|| "true".to_string());
1104 let enable_quality_processing = field_overrides
1105 .remove("enableQualityProcessing")
1106 .unwrap_or_else(|| "true".to_string());
1107 let force_ocr = field_overrides
1108 .remove("forceOcr")
1109 .unwrap_or_else(|| "false".to_string());
1110 let disable_ocr = field_overrides
1111 .remove("disableOcr")
1112 .unwrap_or_else(|| "false".to_string());
1113 let include_document_structure = field_overrides
1114 .remove("includeDocumentStructure")
1115 .unwrap_or_else(|| "false".to_string());
1116 let max_archive_depth = field_overrides
1117 .remove("maxArchiveDepth")
1118 .unwrap_or_else(|| "3".to_string());
1119
1120 format!(
1121 "ExtractionConfig(useCache: {use_cache}, enableQualityProcessing: {enable_quality_processing}, forceOcr: {force_ocr}, disableOcr: {disable_ocr}, resultFormat: ResultFormat.unified, outputFormat: OutputFormat.plain(), includeDocumentStructure: {include_document_structure}, maxArchiveDepth: {max_archive_depth})"
1122 )
1123}
1124
1125struct DartTestClientRenderer {
1141 in_skip: Cell<bool>,
1144 is_redirect: Cell<bool>,
1147}
1148
1149impl DartTestClientRenderer {
1150 fn new(is_redirect: bool) -> Self {
1151 Self {
1152 in_skip: Cell::new(false),
1153 is_redirect: Cell::new(is_redirect),
1154 }
1155 }
1156}
1157
1158impl client::TestClientRenderer for DartTestClientRenderer {
1159 fn language_name(&self) -> &'static str {
1160 "dart"
1161 }
1162
1163 fn render_test_open(&self, out: &mut String, _fn_name: &str, description: &str, skip_reason: Option<&str>) {
1172 let escaped_desc = escape_dart(description);
1173 if let Some(reason) = skip_reason {
1174 let escaped_reason = escape_dart(reason);
1175 let _ = writeln!(out, " test('{escaped_desc}', () {{");
1176 let _ = writeln!(out, " markTestSkipped('{escaped_reason}');");
1177 let _ = writeln!(out, " }});");
1178 let _ = writeln!(out);
1179 self.in_skip.set(true);
1180 } else {
1181 let _ = writeln!(
1182 out,
1183 " test('{escaped_desc}', () => _serialized(() => _withRetry(() async {{"
1184 );
1185 self.in_skip.set(false);
1186 }
1187 }
1188
1189 fn render_test_close(&self, out: &mut String) {
1194 if self.in_skip.get() {
1195 return;
1197 }
1198 let _ = writeln!(out, " }})));");
1199 let _ = writeln!(out);
1200 }
1201
1202 fn render_call(&self, out: &mut String, ctx: &client::CallCtx<'_>) {
1212 const DART_RESTRICTED_HEADERS: &[&str] = &["content-length", "host", "transfer-encoding"];
1214
1215 let method = ctx.method.to_uppercase();
1216 let escaped_method = escape_dart(&method);
1217
1218 let fixture_path = escape_dart(ctx.path);
1220
1221 let has_explicit_content_type = ctx.headers.keys().any(|k| k.to_lowercase() == "content-type");
1223 let effective_content_type = if has_explicit_content_type {
1224 ctx.headers
1225 .iter()
1226 .find(|(k, _)| k.to_lowercase() == "content-type")
1227 .map(|(_, v)| v.as_str())
1228 .unwrap_or("application/json")
1229 } else if ctx.body.is_some() {
1230 ctx.content_type.unwrap_or("application/json")
1231 } else {
1232 ""
1233 };
1234
1235 let _ = writeln!(
1236 out,
1237 " final baseUrl = Platform.environment['MOCK_SERVER_URL'] ?? 'http://localhost:8080';"
1238 );
1239 let _ = writeln!(out, " final uri = Uri.parse('$baseUrl{fixture_path}');");
1240 let _ = writeln!(
1241 out,
1242 " final ioReq = await _httpClient.openUrl('{escaped_method}', uri);"
1243 );
1244
1245 if self.is_redirect.get() {
1248 let _ = writeln!(out, " ioReq.followRedirects = false;");
1249 }
1250
1251 if !effective_content_type.is_empty() {
1253 let escaped_ct = escape_dart(effective_content_type);
1254 let _ = writeln!(out, " ioReq.headers.set('content-type', '{escaped_ct}');");
1255 }
1256
1257 let mut header_pairs: Vec<(&String, &String)> = ctx.headers.iter().collect();
1259 header_pairs.sort_by_key(|(k, _)| k.as_str());
1260 for (name, value) in &header_pairs {
1261 if DART_RESTRICTED_HEADERS.contains(&name.to_lowercase().as_str()) {
1262 continue;
1263 }
1264 if name.to_lowercase() == "content-type" {
1265 continue; }
1267 let escaped_name = escape_dart(&name.to_lowercase());
1268 let escaped_value = escape_dart(value);
1269 let _ = writeln!(out, " ioReq.headers.set('{escaped_name}', '{escaped_value}');");
1270 }
1271
1272 if !ctx.cookies.is_empty() {
1274 let mut cookie_pairs: Vec<(&String, &String)> = ctx.cookies.iter().collect();
1275 cookie_pairs.sort_by_key(|(k, _)| k.as_str());
1276 let cookie_str: Vec<String> = cookie_pairs.iter().map(|(k, v)| format!("{k}={v}")).collect();
1277 let cookie_header = escape_dart(&cookie_str.join("; "));
1278 let _ = writeln!(out, " ioReq.headers.set('cookie', '{cookie_header}');");
1279 }
1280
1281 if let Some(body) = ctx.body {
1283 let json_str = serde_json::to_string(body).unwrap_or_default();
1284 let escaped = escape_dart(&json_str);
1285 let _ = writeln!(out, " final bodyBytes = utf8.encode('{escaped}');");
1286 let _ = writeln!(out, " ioReq.add(bodyBytes);");
1287 }
1288
1289 let _ = writeln!(out, " final ioResp = await ioReq.close();");
1290 if !self.is_redirect.get() {
1294 let _ = writeln!(out, " final bodyStr = await ioResp.transform(utf8.decoder).join();");
1295 };
1296 }
1297
1298 fn render_assert_status(&self, out: &mut String, _response_var: &str, status: u16) {
1299 let _ = writeln!(
1300 out,
1301 " expect(ioResp.statusCode, equals({status}), reason: 'status code mismatch');"
1302 );
1303 }
1304
1305 fn render_assert_header(&self, out: &mut String, _response_var: &str, name: &str, expected: &str) {
1308 let escaped_name = escape_dart(&name.to_lowercase());
1309 match expected {
1310 "<<present>>" => {
1311 let _ = writeln!(
1312 out,
1313 " expect(ioResp.headers.value('{escaped_name}'), isNotNull, reason: 'header {escaped_name} should be present');"
1314 );
1315 }
1316 "<<absent>>" => {
1317 let _ = writeln!(
1318 out,
1319 " expect(ioResp.headers.value('{escaped_name}'), isNull, reason: 'header {escaped_name} should be absent');"
1320 );
1321 }
1322 "<<uuid>>" => {
1323 let _ = writeln!(
1324 out,
1325 " 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');"
1326 );
1327 }
1328 exact => {
1329 let escaped_value = escape_dart(exact);
1330 let _ = writeln!(
1331 out,
1332 " expect(ioResp.headers.value('{escaped_name}'), contains('{escaped_value}'), reason: 'header {escaped_name} mismatch');"
1333 );
1334 }
1335 }
1336 }
1337
1338 fn render_assert_json_body(&self, out: &mut String, _response_var: &str, expected: &serde_json::Value) {
1343 match expected {
1344 serde_json::Value::Object(_) | serde_json::Value::Array(_) => {
1345 let json_str = serde_json::to_string(expected).unwrap_or_default();
1346 let escaped = escape_dart(&json_str);
1347 let _ = writeln!(out, " final bodyJson = jsonDecode(bodyStr);");
1348 let _ = writeln!(out, " final expectedJson = jsonDecode('{escaped}');");
1349 let _ = writeln!(
1350 out,
1351 " expect(bodyJson, equals(expectedJson), reason: 'body mismatch');"
1352 );
1353 }
1354 serde_json::Value::String(s) => {
1355 let escaped = escape_dart(s);
1356 let _ = writeln!(
1357 out,
1358 " expect(bodyStr.trim(), equals('{escaped}'), reason: 'body mismatch');"
1359 );
1360 }
1361 other => {
1362 let escaped = escape_dart(&other.to_string());
1363 let _ = writeln!(
1364 out,
1365 " expect(bodyStr.trim(), equals('{escaped}'), reason: 'body mismatch');"
1366 );
1367 }
1368 }
1369 }
1370
1371 fn render_assert_partial_body(&self, out: &mut String, _response_var: &str, expected: &serde_json::Value) {
1374 let _ = writeln!(
1375 out,
1376 " final partialJson = jsonDecode(bodyStr) as Map<String, dynamic>;"
1377 );
1378 if let Some(obj) = expected.as_object() {
1379 for (idx, (key, val)) in obj.iter().enumerate() {
1380 let escaped_key = escape_dart(key);
1381 let json_val = serde_json::to_string(val).unwrap_or_default();
1382 let escaped_val = escape_dart(&json_val);
1383 let _ = writeln!(out, " final _expectedField{idx} = jsonDecode('{escaped_val}');");
1386 let _ = writeln!(
1387 out,
1388 " expect(partialJson['{escaped_key}'], equals(_expectedField{idx}), reason: 'partial body field \\'{escaped_key}\\' mismatch');"
1389 );
1390 }
1391 }
1392 }
1393
1394 fn render_assert_validation_errors(
1396 &self,
1397 out: &mut String,
1398 _response_var: &str,
1399 errors: &[ValidationErrorExpectation],
1400 ) {
1401 let _ = writeln!(out, " final errBody = jsonDecode(bodyStr) as Map<String, dynamic>;");
1402 let _ = writeln!(out, " final errList = (errBody['errors'] ?? []) as List<dynamic>;");
1403 for ve in errors {
1404 let loc_dart: Vec<String> = ve.loc.iter().map(|s| format!("'{}'", escape_dart(s))).collect();
1405 let loc_str = loc_dart.join(", ");
1406 let escaped_msg = escape_dart(&ve.msg);
1407 let _ = writeln!(
1408 out,
1409 " 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}');"
1410 );
1411 }
1412 }
1413}
1414
1415fn render_http_test_case(out: &mut String, fixture: &Fixture, http: &HttpFixture) {
1422 if http.expected_response.status_code == 101 {
1424 let description = escape_dart(&fixture.description);
1425 let _ = writeln!(out, " test('{description}', () {{");
1426 let _ = writeln!(
1427 out,
1428 " markTestSkipped('Skipped: Dart HttpClient cannot handle 101 Switching Protocols responses');"
1429 );
1430 let _ = writeln!(out, " }});");
1431 let _ = writeln!(out);
1432 return;
1433 }
1434
1435 let is_redirect = http.expected_response.status_code / 100 == 3;
1439 client::http_call::render_http_test(out, &DartTestClientRenderer::new(is_redirect), fixture);
1440}
1441
1442fn mime_from_extension(path: &str) -> Option<&'static str> {
1447 let ext = path.rsplit('.').next()?;
1448 match ext.to_lowercase().as_str() {
1449 "docx" => Some("application/vnd.openxmlformats-officedocument.wordprocessingml.document"),
1450 "xlsx" => Some("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"),
1451 "pptx" => Some("application/vnd.openxmlformats-officedocument.presentationml.presentation"),
1452 "pdf" => Some("application/pdf"),
1453 "txt" | "text" => Some("text/plain"),
1454 "html" | "htm" => Some("text/html"),
1455 "json" => Some("application/json"),
1456 "xml" => Some("application/xml"),
1457 "csv" => Some("text/csv"),
1458 "md" | "markdown" => Some("text/markdown"),
1459 "png" => Some("image/png"),
1460 "jpg" | "jpeg" => Some("image/jpeg"),
1461 "gif" => Some("image/gif"),
1462 "zip" => Some("application/zip"),
1463 "odt" => Some("application/vnd.oasis.opendocument.text"),
1464 "ods" => Some("application/vnd.oasis.opendocument.spreadsheet"),
1465 "odp" => Some("application/vnd.oasis.opendocument.presentation"),
1466 "rtf" => Some("application/rtf"),
1467 "epub" => Some("application/epub+zip"),
1468 "msg" => Some("application/vnd.ms-outlook"),
1469 "eml" => Some("message/rfc822"),
1470 _ => None,
1471 }
1472}
1473
1474fn emit_dart_batch_item_array(arr: &serde_json::Value, elem_type: &str) -> String {
1481 let items: Vec<String> = arr
1482 .as_array()
1483 .map(|a| a.as_slice())
1484 .unwrap_or_default()
1485 .iter()
1486 .filter_map(|item| {
1487 let obj = item.as_object()?;
1488 match elem_type {
1489 "BatchBytesItem" => {
1490 let content_bytes = obj
1491 .get("content")
1492 .and_then(|v| v.as_array())
1493 .map(|arr| {
1494 let nums: Vec<String> =
1495 arr.iter().filter_map(|v| v.as_u64().map(|n| n.to_string())).collect();
1496 format!("Uint8List.fromList([{}])", nums.join(", "))
1497 })
1498 .unwrap_or_else(|| "Uint8List(0)".to_string());
1499 let mime_type = obj
1500 .get("mime_type")
1501 .and_then(|v| v.as_str())
1502 .unwrap_or("application/octet-stream");
1503 Some(format!(
1504 "BatchBytesItem(content: {content_bytes}, mimeType: '{}')",
1505 escape_dart(mime_type)
1506 ))
1507 }
1508 "BatchFileItem" => {
1509 let path = obj.get("path").and_then(|v| v.as_str()).unwrap_or("");
1510 Some(format!("BatchFileItem(path: '{}')", escape_dart(path)))
1511 }
1512 _ => None,
1513 }
1514 })
1515 .collect();
1516 format!("[{}]", items.join(", "))
1517}
1518
1519fn escape_dart(s: &str) -> String {
1521 s.replace('\\', "\\\\")
1522 .replace('\'', "\\'")
1523 .replace('\n', "\\n")
1524 .replace('\r', "\\r")
1525 .replace('\t', "\\t")
1526 .replace('$', "\\$")
1527}
1528
1529fn type_name_to_create_from_json_dart(type_name: &str) -> String {
1537 let mut snake = String::with_capacity(type_name.len() + 8);
1539 for (i, ch) in type_name.char_indices() {
1540 if ch.is_uppercase() {
1541 if i > 0 {
1542 snake.push('_');
1543 }
1544 snake.extend(ch.to_lowercase());
1545 } else {
1546 snake.push(ch);
1547 }
1548 }
1549 let rust_fn = format!("create_{snake}_from_json");
1552 rust_fn
1554 .split('_')
1555 .enumerate()
1556 .map(|(i, part)| {
1557 if i == 0 {
1558 part.to_string()
1559 } else {
1560 let mut chars = part.chars();
1561 match chars.next() {
1562 None => String::new(),
1563 Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
1564 }
1565 }
1566 })
1567 .collect::<Vec<_>>()
1568 .join("")
1569}