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
343 let options_type: Option<&str> = call_overrides.and_then(|o| o.options_type.as_deref());
350 let options_via: &str = call_overrides
351 .and_then(|o| o.options_via.as_deref())
352 .unwrap_or("kwargs");
353
354 let file_path_for_mime: Option<&str> = call_config
362 .args
363 .iter()
364 .find(|a| a.arg_type == "file_path")
365 .and_then(|a| resolve_field(&fixture.input, &a.field).as_str());
366
367 let has_file_path_arg = call_config.args.iter().any(|a| a.arg_type == "file_path");
374 let caller_supplied_override = call_overrides.and_then(|o| o.function.as_ref()).is_some();
377 if has_file_path_arg && !caller_supplied_override {
378 function_name = match function_name.as_str() {
379 "extractFile" => "extractBytes".to_string(),
380 "extractFileSync" => "extractBytesSync".to_string(),
381 other => other.to_string(),
382 };
383 }
384
385 let mut setup_lines: Vec<String> = Vec::new();
388 let mut args = Vec::new();
389
390 for arg_def in &call_config.args {
391 match arg_def.arg_type.as_str() {
392 "mock_url" => {
393 let name = arg_def.name.clone();
394 if fixture.has_host_root_route() {
395 let env_key = format!("MOCK_SERVER_{}", fixture_id.to_uppercase());
396 setup_lines.push(format!(
397 r#"final {name} = Platform.environment["{env_key}"] ?? (Platform.environment["MOCK_SERVER_URL"]! + "/fixtures/{fixture_id}");"#
398 ));
399 } else {
400 setup_lines.push(format!(
401 r#"final {name} = "${{Platform.environment["MOCK_SERVER_URL"] ?? "http://localhost:8080"}}/fixtures/{fixture_id}";"#
402 ));
403 }
404 args.push(name);
405 continue;
406 }
407 "handle" => {
408 let name = arg_def.name.clone();
409 let field = arg_def.field.strip_prefix("input.").unwrap_or(&arg_def.field);
410 let config_value = fixture.input.get(field).cloned().unwrap_or(serde_json::Value::Null);
411 let create_fn = {
413 let mut chars = name.chars();
414 let pascal = match chars.next() {
415 None => String::new(),
416 Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
417 };
418 format!("create{pascal}")
419 };
420 if config_value.is_null()
421 || config_value.is_object() && config_value.as_object().is_some_and(|o| o.is_empty())
422 {
423 setup_lines.push(format!("final {name} = await {bridge_class}.{create_fn}(null);"));
424 } else {
425 let json_str = serde_json::to_string(&config_value).unwrap_or_default();
426 let config_var = format!("{name}Config");
427 setup_lines.push(format!(
428 "final {config_var} = CrawlConfig.fromJson(jsonDecode(r'{json_str}') as Map<String, dynamic>);"
429 ));
430 setup_lines.push(format!(
431 "final {name} = await {bridge_class}.{create_fn}({config_var});"
432 ));
433 }
434 args.push(name);
435 continue;
436 }
437 _ => {}
438 }
439
440 let arg_value = resolve_field(&fixture.input, &arg_def.field);
441 match arg_def.arg_type.as_str() {
442 "bytes" | "file_path" => {
443 if let serde_json::Value::String(file_path) = arg_value {
448 args.push(format!("File('{}').readAsBytesSync()", file_path));
449 }
450 }
451 "string" => {
452 let dart_param_name = snake_to_camel(&arg_def.name);
456 match arg_value {
457 serde_json::Value::String(s) => {
458 args.push(format!("{dart_param_name}: '{}'", escape_dart(s)));
459 }
460 serde_json::Value::Null
461 if arg_def.optional
462 && arg_def.name == "mime_type" =>
465 {
466 let inferred = file_path_for_mime
467 .and_then(mime_from_extension)
468 .unwrap_or("application/octet-stream");
469 args.push(format!("{dart_param_name}: '{inferred}'"));
470 }
471 _ => {}
473 }
474 }
475 "json_object" => {
476 if let Some(elem_type) = &arg_def.element_type {
478 if (elem_type == "BatchBytesItem" || elem_type == "BatchFileItem") && arg_value.is_array() {
479 let dart_items = emit_dart_batch_item_array(arg_value, elem_type);
480 args.push(dart_items);
481 }
482 } else if options_via == "from_json" {
483 if let Some(opts_type) = options_type {
493 if !arg_value.is_null() {
494 let json_str = serde_json::to_string(&arg_value).unwrap_or_default();
495 let escaped_json = escape_dart(&json_str);
498 let var_name = format!("_{}", arg_def.name);
499 let dart_fn = type_name_to_create_from_json_dart(opts_type);
500 setup_lines.push(format!("final {var_name} = await {dart_fn}(json: '{escaped_json}');"));
501 args.push(format!("req: {var_name}"));
504 }
505 }
506 } else if arg_def.name == "config" {
507 if let serde_json::Value::Object(map) = &arg_value {
508 if !map.is_empty() {
512 args.push(emit_extraction_config_dart(map));
513 }
514 }
515 } else if arg_value.is_array() {
517 let json_str = serde_json::to_string(&arg_value).unwrap_or_default();
520 let var_name = arg_def.name.clone();
521 setup_lines.push(format!(
522 "final {var_name} = (jsonDecode(r'{json_str}') as List<dynamic>).cast<String>();"
523 ));
524 args.push(var_name);
525 }
526 }
527 _ => {}
528 }
529 }
530
531 let client_factory: Option<&str> = call_overrides.and_then(|o| o.client_factory.as_deref()).or_else(|| {
535 e2e_config
536 .call
537 .overrides
538 .get(lang)
539 .and_then(|o| o.client_factory.as_deref())
540 });
541
542 let client_factory_camel: Option<String> = client_factory.map(|f| {
544 f.split('_')
545 .enumerate()
546 .map(|(i, part)| {
547 if i == 0 {
548 part.to_string()
549 } else {
550 let mut chars = part.chars();
551 match chars.next() {
552 None => String::new(),
553 Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
554 }
555 }
556 })
557 .collect::<Vec<_>>()
558 .join("")
559 });
560
561 let _ = writeln!(out, " test('{description}', () async {{");
565
566 let args_str = args.join(", ");
567 let receiver_class = call_overrides
568 .and_then(|o| o.class.as_ref())
569 .cloned()
570 .unwrap_or_else(|| bridge_class.to_string());
571
572 let (receiver, extra_setup): (String, Option<String>) = if let Some(factory) = &client_factory_camel {
576 let has_mock_url = call_config.args.iter().any(|a| a.arg_type == "mock_url");
577 let mock_url_setup = if !has_mock_url {
578 if fixture.has_host_root_route() {
580 let env_key = format!("MOCK_SERVER_{}", fixture_id.to_uppercase());
581 Some(format!(
582 "final _mockUrl = Platform.environment[\"{env_key}\"] ?? (Platform.environment[\"MOCK_SERVER_URL\"]! + \"/fixtures/{fixture_id}\");"
583 ))
584 } else {
585 Some(format!(
586 r#"final _mockUrl = "${{Platform.environment["MOCK_SERVER_URL"] ?? "http://localhost:8080"}}/fixtures/{fixture_id}";"#
587 ))
588 }
589 } else {
590 None
591 };
592 let url_expr = if has_mock_url {
593 call_config
596 .args
597 .iter()
598 .find(|a| a.arg_type == "mock_url")
599 .map(|a| a.name.clone())
600 .unwrap_or_else(|| "_mockUrl".to_string())
601 } else {
602 "_mockUrl".to_string()
603 };
604 let create_line = format!("final _client = await {receiver_class}.{factory}('test-key', baseUrl: {url_expr});");
605 let full_setup = if let Some(url_line) = mock_url_setup {
606 Some(format!("{url_line}\n {create_line}"))
607 } else {
608 Some(create_line)
609 };
610 ("_client".to_string(), full_setup)
611 } else {
612 (receiver_class.clone(), None)
613 };
614
615 if expects_error && (!setup_lines.is_empty() || extra_setup.is_some()) {
616 let _ = writeln!(out, " await expectLater(() async {{");
620 for line in &setup_lines {
621 let _ = writeln!(out, " {line}");
622 }
623 if let Some(extra) = &extra_setup {
624 for line in extra.lines() {
625 let _ = writeln!(out, " {line}");
626 }
627 }
628 if is_streaming {
629 let _ = writeln!(out, " return {receiver}.{function_name}({args_str}).toList();");
630 } else {
631 let _ = writeln!(out, " return {receiver}.{function_name}({args_str});");
632 }
633 let _ = writeln!(out, " }}(), throwsA(anything));");
634 } else if expects_error {
635 if let Some(extra) = &extra_setup {
637 for line in extra.lines() {
638 let _ = writeln!(out, " {line}");
639 }
640 }
641 if is_streaming {
642 let _ = writeln!(
643 out,
644 " await expectLater({receiver}.{function_name}({args_str}).toList(), throwsA(anything));"
645 );
646 } else {
647 let _ = writeln!(
648 out,
649 " await expectLater({receiver}.{function_name}({args_str}), throwsA(anything));"
650 );
651 }
652 } else {
653 for line in &setup_lines {
654 let _ = writeln!(out, " {line}");
655 }
656 if let Some(extra) = &extra_setup {
657 for line in extra.lines() {
658 let _ = writeln!(out, " {line}");
659 }
660 }
661 if is_streaming {
662 let _ = writeln!(
663 out,
664 " final {result_var} = await {receiver}.{function_name}({args_str}).toList();"
665 );
666 } else {
667 let _ = writeln!(
668 out,
669 " final {result_var} = await {receiver}.{function_name}({args_str});"
670 );
671 }
672 for assertion in &fixture.assertions {
673 if is_streaming {
674 render_streaming_assertion_dart(out, assertion, result_var);
675 } else {
676 render_assertion_dart(out, assertion, result_var);
677 }
678 }
679 }
680
681 let _ = writeln!(out, " }});");
682 let _ = writeln!(out);
683}
684
685fn render_assertion_dart(out: &mut String, assertion: &Assertion, result_var: &str) {
690 let field_accessor = match assertion.field.as_deref() {
691 Some(f) if !f.is_empty() => format!("{result_var}.{}", field_to_dart_accessor(f)),
692 _ => result_var.to_string(),
693 };
694
695 let format_value = |val: &serde_json::Value| -> String {
696 match val {
697 serde_json::Value::String(s) => format!("'{}'", escape_dart(s)),
698 serde_json::Value::Bool(b) => b.to_string(),
699 serde_json::Value::Number(n) => n.to_string(),
700 serde_json::Value::Null => "null".to_string(),
701 other => format!("'{}'", escape_dart(&other.to_string())),
702 }
703 };
704
705 match assertion.assertion_type.as_str() {
706 "equals" | "field_equals" => {
707 if let Some(expected) = &assertion.value {
708 let dart_val = format_value(expected);
709 let _ = writeln!(out, " expect({field_accessor}, equals({dart_val}));");
710 } else {
711 let _ = writeln!(
712 out,
713 " // skipped: '{}' assertion missing value",
714 assertion.assertion_type
715 );
716 }
717 }
718 "not_equals" => {
719 if let Some(expected) = &assertion.value {
720 let dart_val = format_value(expected);
721 let _ = writeln!(out, " expect({field_accessor}, isNot(equals({dart_val})));");
722 }
723 }
724 "contains" => {
725 if let Some(expected) = &assertion.value {
726 let dart_val = format_value(expected);
727 let _ = writeln!(out, " expect({field_accessor}, contains({dart_val}));");
728 } else {
729 let _ = writeln!(out, " // skipped: 'contains' assertion missing value");
730 }
731 }
732 "contains_all" => {
733 if let Some(values) = &assertion.values {
734 for val in values {
735 let dart_val = format_value(val);
736 let _ = writeln!(out, " expect({field_accessor}, contains({dart_val}));");
737 }
738 }
739 }
740 "contains_any" => {
741 if let Some(values) = &assertion.values {
742 let checks: Vec<String> = values
743 .iter()
744 .map(|v| {
745 let dart_val = format_value(v);
746 format!("{field_accessor}.contains({dart_val})")
747 })
748 .collect();
749 let joined = checks.join(" || ");
750 let _ = writeln!(out, " expect({joined}, isTrue);");
751 }
752 }
753 "not_contains" => {
754 if let Some(expected) = &assertion.value {
755 let dart_val = format_value(expected);
756 let _ = writeln!(out, " expect({field_accessor}, isNot(contains({dart_val})));");
757 } else if let Some(values) = &assertion.values {
758 for val in values {
759 let dart_val = format_value(val);
760 let _ = writeln!(out, " expect({field_accessor}, isNot(contains({dart_val})));");
761 }
762 }
763 }
764 "not_empty" => {
765 let _ = writeln!(out, " expect({field_accessor}, isNotEmpty);");
766 }
767 "is_empty" => {
768 let _ = writeln!(out, " expect({field_accessor}, isEmpty);");
769 }
770 "starts_with" => {
771 if let Some(expected) = &assertion.value {
772 let dart_val = format_value(expected);
773 let _ = writeln!(out, " expect({field_accessor}, startsWith({dart_val}));");
774 }
775 }
776 "ends_with" => {
777 if let Some(expected) = &assertion.value {
778 let dart_val = format_value(expected);
779 let _ = writeln!(out, " expect({field_accessor}, endsWith({dart_val}));");
780 }
781 }
782 "min_length" => {
783 if let Some(val) = &assertion.value {
784 if let Some(n) = val.as_u64() {
785 let _ = writeln!(out, " expect({field_accessor}.length, greaterThanOrEqualTo({n}));");
786 }
787 }
788 }
789 "max_length" => {
790 if let Some(val) = &assertion.value {
791 if let Some(n) = val.as_u64() {
792 let _ = writeln!(out, " expect({field_accessor}.length, lessThanOrEqualTo({n}));");
793 }
794 }
795 }
796 "count_equals" => {
797 if let Some(val) = &assertion.value {
798 if let Some(n) = val.as_u64() {
799 let _ = writeln!(out, " expect({field_accessor}.length, equals({n}));");
800 }
801 }
802 }
803 "count_min" => {
804 if let Some(val) = &assertion.value {
805 if let Some(n) = val.as_u64() {
806 let _ = writeln!(out, " expect({field_accessor}.length, greaterThanOrEqualTo({n}));");
807 }
808 }
809 }
810 "matches_regex" => {
811 if let Some(expected) = &assertion.value {
812 let dart_val = format_value(expected);
813 let _ = writeln!(out, " expect({field_accessor}, matches(RegExp({dart_val})));");
814 }
815 }
816 "is_true" => {
817 let _ = writeln!(out, " expect({field_accessor}, isTrue);");
818 }
819 "is_false" => {
820 let _ = writeln!(out, " expect({field_accessor}, isFalse);");
821 }
822 "greater_than" => {
823 if let Some(val) = &assertion.value {
824 let dart_val = format_value(val);
825 let _ = writeln!(out, " expect({field_accessor}, greaterThan({dart_val}));");
826 }
827 }
828 "less_than" => {
829 if let Some(val) = &assertion.value {
830 let dart_val = format_value(val);
831 let _ = writeln!(out, " expect({field_accessor}, lessThan({dart_val}));");
832 }
833 }
834 "greater_than_or_equal" => {
835 if let Some(val) = &assertion.value {
836 let dart_val = format_value(val);
837 let _ = writeln!(out, " expect({field_accessor}, greaterThanOrEqualTo({dart_val}));");
838 }
839 }
840 "less_than_or_equal" => {
841 if let Some(val) = &assertion.value {
842 let dart_val = format_value(val);
843 let _ = writeln!(out, " expect({field_accessor}, lessThanOrEqualTo({dart_val}));");
844 }
845 }
846 "not_null" => {
847 let _ = writeln!(out, " expect({field_accessor}, isNotNull);");
848 }
849 "not_error" => {
850 }
852 "error" => {
853 }
855 "method_result" => {
856 if let Some(method) = &assertion.method {
857 let dart_method = method.to_lower_camel_case();
858 let check = assertion.check.as_deref().unwrap_or("not_null");
859 let method_call = format!("{field_accessor}.{dart_method}()");
860 match check {
861 "equals" => {
862 if let Some(expected) = &assertion.value {
863 let dart_val = format_value(expected);
864 let _ = writeln!(out, " expect({method_call}, equals({dart_val}));");
865 }
866 }
867 "is_true" => {
868 let _ = writeln!(out, " expect({method_call}, isTrue);");
869 }
870 "is_false" => {
871 let _ = writeln!(out, " expect({method_call}, isFalse);");
872 }
873 "greater_than_or_equal" => {
874 if let Some(val) = &assertion.value {
875 let dart_val = format_value(val);
876 let _ = writeln!(out, " expect({method_call}, greaterThanOrEqualTo({dart_val}));");
877 }
878 }
879 "count_min" => {
880 if let Some(val) = &assertion.value {
881 if let Some(n) = val.as_u64() {
882 let _ = writeln!(out, " expect({method_call}.length, greaterThanOrEqualTo({n}));");
883 }
884 }
885 }
886 _ => {
887 let _ = writeln!(out, " expect({method_call}, isNotNull);");
888 }
889 }
890 }
891 }
892 other => {
893 let _ = writeln!(out, " // skipped: unknown assertion type '{other}'");
894 }
895 }
896}
897
898fn render_streaming_assertion_dart(out: &mut String, assertion: &Assertion, result_var: &str) {
907 match assertion.assertion_type.as_str() {
908 "not_error" => {
909 }
911 "count_min" if assertion.field.as_deref() == Some("chunks") => {
912 if let Some(serde_json::Value::Number(n)) = &assertion.value {
913 let _ = writeln!(out, " expect({result_var}.length, greaterThanOrEqualTo({n}));");
914 }
915 }
916 "equals" if assertion.field.as_deref() == Some("stream_content") => {
917 if let Some(serde_json::Value::String(expected)) = &assertion.value {
918 let escaped = escape_dart(expected);
919 let _ = writeln!(
920 out,
921 " final _content = {result_var}.map((c) => c.choices.firstOrNull?.delta.content ?? '').join();"
922 );
923 let _ = writeln!(out, " expect(_content, equals('{escaped}'));");
924 }
925 }
926 other => {
927 let _ = writeln!(out, " // skipped streaming assertion: '{other}'");
928 }
929 }
930}
931
932fn snake_to_camel(s: &str) -> String {
934 let mut result = String::with_capacity(s.len());
935 let mut next_upper = false;
936 for ch in s.chars() {
937 if ch == '_' {
938 next_upper = true;
939 } else if next_upper {
940 result.extend(ch.to_uppercase());
941 next_upper = false;
942 } else {
943 result.push(ch);
944 }
945 }
946 result
947}
948
949fn field_to_dart_accessor(path: &str) -> String {
962 let mut result = String::with_capacity(path.len());
963 for (i, segment) in path.split('.').enumerate() {
964 if i > 0 {
965 result.push('.');
966 }
967 if let Some(bracket_pos) = segment.find('[') {
970 let name = &segment[..bracket_pos];
971 let bracket = &segment[bracket_pos..];
972 result.push_str(&name.to_lower_camel_case());
973 result.push_str(bracket);
974 } else {
975 result.push_str(&segment.to_lower_camel_case());
976 }
977 }
978 result
979}
980
981fn emit_extraction_config_dart(overrides: &serde_json::Map<String, serde_json::Value>) -> String {
987 let mut field_overrides: std::collections::HashMap<String, String> = std::collections::HashMap::new();
989 for (key, val) in overrides {
990 let camel = snake_to_camel(key);
991 let dart_val = match val {
992 serde_json::Value::Bool(b) => {
993 if *b {
994 "true".to_string()
995 } else {
996 "false".to_string()
997 }
998 }
999 serde_json::Value::Number(n) => n.to_string(),
1000 serde_json::Value::String(s) => format!("'{s}'"),
1001 _ => continue, };
1003 field_overrides.insert(camel, dart_val);
1004 }
1005
1006 let use_cache = field_overrides.remove("useCache").unwrap_or_else(|| "true".to_string());
1007 let enable_quality_processing = field_overrides
1008 .remove("enableQualityProcessing")
1009 .unwrap_or_else(|| "true".to_string());
1010 let force_ocr = field_overrides
1011 .remove("forceOcr")
1012 .unwrap_or_else(|| "false".to_string());
1013 let disable_ocr = field_overrides
1014 .remove("disableOcr")
1015 .unwrap_or_else(|| "false".to_string());
1016 let include_document_structure = field_overrides
1017 .remove("includeDocumentStructure")
1018 .unwrap_or_else(|| "false".to_string());
1019 let max_archive_depth = field_overrides
1020 .remove("maxArchiveDepth")
1021 .unwrap_or_else(|| "3".to_string());
1022
1023 format!(
1024 "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})"
1025 )
1026}
1027
1028struct DartTestClientRenderer {
1044 in_skip: Cell<bool>,
1047 is_redirect: Cell<bool>,
1050}
1051
1052impl DartTestClientRenderer {
1053 fn new(is_redirect: bool) -> Self {
1054 Self {
1055 in_skip: Cell::new(false),
1056 is_redirect: Cell::new(is_redirect),
1057 }
1058 }
1059}
1060
1061impl client::TestClientRenderer for DartTestClientRenderer {
1062 fn language_name(&self) -> &'static str {
1063 "dart"
1064 }
1065
1066 fn render_test_open(&self, out: &mut String, _fn_name: &str, description: &str, skip_reason: Option<&str>) {
1075 let escaped_desc = escape_dart(description);
1076 if let Some(reason) = skip_reason {
1077 let escaped_reason = escape_dart(reason);
1078 let _ = writeln!(out, " test('{escaped_desc}', () {{");
1079 let _ = writeln!(out, " markTestSkipped('{escaped_reason}');");
1080 let _ = writeln!(out, " }});");
1081 let _ = writeln!(out);
1082 self.in_skip.set(true);
1083 } else {
1084 let _ = writeln!(
1085 out,
1086 " test('{escaped_desc}', () => _serialized(() => _withRetry(() async {{"
1087 );
1088 self.in_skip.set(false);
1089 }
1090 }
1091
1092 fn render_test_close(&self, out: &mut String) {
1097 if self.in_skip.get() {
1098 return;
1100 }
1101 let _ = writeln!(out, " }})));");
1102 let _ = writeln!(out);
1103 }
1104
1105 fn render_call(&self, out: &mut String, ctx: &client::CallCtx<'_>) {
1115 const DART_RESTRICTED_HEADERS: &[&str] = &["content-length", "host", "transfer-encoding"];
1117
1118 let method = ctx.method.to_uppercase();
1119 let escaped_method = escape_dart(&method);
1120
1121 let fixture_path = escape_dart(ctx.path);
1123
1124 let has_explicit_content_type = ctx.headers.keys().any(|k| k.to_lowercase() == "content-type");
1126 let effective_content_type = if has_explicit_content_type {
1127 ctx.headers
1128 .iter()
1129 .find(|(k, _)| k.to_lowercase() == "content-type")
1130 .map(|(_, v)| v.as_str())
1131 .unwrap_or("application/json")
1132 } else if ctx.body.is_some() {
1133 ctx.content_type.unwrap_or("application/json")
1134 } else {
1135 ""
1136 };
1137
1138 let _ = writeln!(
1139 out,
1140 " final baseUrl = Platform.environment['MOCK_SERVER_URL'] ?? 'http://localhost:8080';"
1141 );
1142 let _ = writeln!(out, " final uri = Uri.parse('$baseUrl{fixture_path}');");
1143 let _ = writeln!(
1144 out,
1145 " final ioReq = await _httpClient.openUrl('{escaped_method}', uri);"
1146 );
1147
1148 if self.is_redirect.get() {
1151 let _ = writeln!(out, " ioReq.followRedirects = false;");
1152 }
1153
1154 if !effective_content_type.is_empty() {
1156 let escaped_ct = escape_dart(effective_content_type);
1157 let _ = writeln!(out, " ioReq.headers.set('content-type', '{escaped_ct}');");
1158 }
1159
1160 let mut header_pairs: Vec<(&String, &String)> = ctx.headers.iter().collect();
1162 header_pairs.sort_by_key(|(k, _)| k.as_str());
1163 for (name, value) in &header_pairs {
1164 if DART_RESTRICTED_HEADERS.contains(&name.to_lowercase().as_str()) {
1165 continue;
1166 }
1167 if name.to_lowercase() == "content-type" {
1168 continue; }
1170 let escaped_name = escape_dart(&name.to_lowercase());
1171 let escaped_value = escape_dart(value);
1172 let _ = writeln!(out, " ioReq.headers.set('{escaped_name}', '{escaped_value}');");
1173 }
1174
1175 if !ctx.cookies.is_empty() {
1177 let mut cookie_pairs: Vec<(&String, &String)> = ctx.cookies.iter().collect();
1178 cookie_pairs.sort_by_key(|(k, _)| k.as_str());
1179 let cookie_str: Vec<String> = cookie_pairs.iter().map(|(k, v)| format!("{k}={v}")).collect();
1180 let cookie_header = escape_dart(&cookie_str.join("; "));
1181 let _ = writeln!(out, " ioReq.headers.set('cookie', '{cookie_header}');");
1182 }
1183
1184 if let Some(body) = ctx.body {
1186 let json_str = serde_json::to_string(body).unwrap_or_default();
1187 let escaped = escape_dart(&json_str);
1188 let _ = writeln!(out, " final bodyBytes = utf8.encode('{escaped}');");
1189 let _ = writeln!(out, " ioReq.add(bodyBytes);");
1190 }
1191
1192 let _ = writeln!(out, " final ioResp = await ioReq.close();");
1193 if !self.is_redirect.get() {
1197 let _ = writeln!(out, " final bodyStr = await ioResp.transform(utf8.decoder).join();");
1198 };
1199 }
1200
1201 fn render_assert_status(&self, out: &mut String, _response_var: &str, status: u16) {
1202 let _ = writeln!(
1203 out,
1204 " expect(ioResp.statusCode, equals({status}), reason: 'status code mismatch');"
1205 );
1206 }
1207
1208 fn render_assert_header(&self, out: &mut String, _response_var: &str, name: &str, expected: &str) {
1211 let escaped_name = escape_dart(&name.to_lowercase());
1212 match expected {
1213 "<<present>>" => {
1214 let _ = writeln!(
1215 out,
1216 " expect(ioResp.headers.value('{escaped_name}'), isNotNull, reason: 'header {escaped_name} should be present');"
1217 );
1218 }
1219 "<<absent>>" => {
1220 let _ = writeln!(
1221 out,
1222 " expect(ioResp.headers.value('{escaped_name}'), isNull, reason: 'header {escaped_name} should be absent');"
1223 );
1224 }
1225 "<<uuid>>" => {
1226 let _ = writeln!(
1227 out,
1228 " 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');"
1229 );
1230 }
1231 exact => {
1232 let escaped_value = escape_dart(exact);
1233 let _ = writeln!(
1234 out,
1235 " expect(ioResp.headers.value('{escaped_name}'), contains('{escaped_value}'), reason: 'header {escaped_name} mismatch');"
1236 );
1237 }
1238 }
1239 }
1240
1241 fn render_assert_json_body(&self, out: &mut String, _response_var: &str, expected: &serde_json::Value) {
1246 match expected {
1247 serde_json::Value::Object(_) | serde_json::Value::Array(_) => {
1248 let json_str = serde_json::to_string(expected).unwrap_or_default();
1249 let escaped = escape_dart(&json_str);
1250 let _ = writeln!(out, " final bodyJson = jsonDecode(bodyStr);");
1251 let _ = writeln!(out, " final expectedJson = jsonDecode('{escaped}');");
1252 let _ = writeln!(
1253 out,
1254 " expect(bodyJson, equals(expectedJson), reason: 'body mismatch');"
1255 );
1256 }
1257 serde_json::Value::String(s) => {
1258 let escaped = escape_dart(s);
1259 let _ = writeln!(
1260 out,
1261 " expect(bodyStr.trim(), equals('{escaped}'), reason: 'body mismatch');"
1262 );
1263 }
1264 other => {
1265 let escaped = escape_dart(&other.to_string());
1266 let _ = writeln!(
1267 out,
1268 " expect(bodyStr.trim(), equals('{escaped}'), reason: 'body mismatch');"
1269 );
1270 }
1271 }
1272 }
1273
1274 fn render_assert_partial_body(&self, out: &mut String, _response_var: &str, expected: &serde_json::Value) {
1277 let _ = writeln!(
1278 out,
1279 " final partialJson = jsonDecode(bodyStr) as Map<String, dynamic>;"
1280 );
1281 if let Some(obj) = expected.as_object() {
1282 for (idx, (key, val)) in obj.iter().enumerate() {
1283 let escaped_key = escape_dart(key);
1284 let json_val = serde_json::to_string(val).unwrap_or_default();
1285 let escaped_val = escape_dart(&json_val);
1286 let _ = writeln!(out, " final _expectedField{idx} = jsonDecode('{escaped_val}');");
1289 let _ = writeln!(
1290 out,
1291 " expect(partialJson['{escaped_key}'], equals(_expectedField{idx}), reason: 'partial body field \\'{escaped_key}\\' mismatch');"
1292 );
1293 }
1294 }
1295 }
1296
1297 fn render_assert_validation_errors(
1299 &self,
1300 out: &mut String,
1301 _response_var: &str,
1302 errors: &[ValidationErrorExpectation],
1303 ) {
1304 let _ = writeln!(out, " final errBody = jsonDecode(bodyStr) as Map<String, dynamic>;");
1305 let _ = writeln!(out, " final errList = (errBody['errors'] ?? []) as List<dynamic>;");
1306 for ve in errors {
1307 let loc_dart: Vec<String> = ve.loc.iter().map(|s| format!("'{}'", escape_dart(s))).collect();
1308 let loc_str = loc_dart.join(", ");
1309 let escaped_msg = escape_dart(&ve.msg);
1310 let _ = writeln!(
1311 out,
1312 " 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}');"
1313 );
1314 }
1315 }
1316}
1317
1318fn render_http_test_case(out: &mut String, fixture: &Fixture, http: &HttpFixture) {
1325 if http.expected_response.status_code == 101 {
1327 let description = escape_dart(&fixture.description);
1328 let _ = writeln!(out, " test('{description}', () {{");
1329 let _ = writeln!(
1330 out,
1331 " markTestSkipped('Skipped: Dart HttpClient cannot handle 101 Switching Protocols responses');"
1332 );
1333 let _ = writeln!(out, " }});");
1334 let _ = writeln!(out);
1335 return;
1336 }
1337
1338 let is_redirect = http.expected_response.status_code / 100 == 3;
1342 client::http_call::render_http_test(out, &DartTestClientRenderer::new(is_redirect), fixture);
1343}
1344
1345fn mime_from_extension(path: &str) -> Option<&'static str> {
1350 let ext = path.rsplit('.').next()?;
1351 match ext.to_lowercase().as_str() {
1352 "docx" => Some("application/vnd.openxmlformats-officedocument.wordprocessingml.document"),
1353 "xlsx" => Some("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"),
1354 "pptx" => Some("application/vnd.openxmlformats-officedocument.presentationml.presentation"),
1355 "pdf" => Some("application/pdf"),
1356 "txt" | "text" => Some("text/plain"),
1357 "html" | "htm" => Some("text/html"),
1358 "json" => Some("application/json"),
1359 "xml" => Some("application/xml"),
1360 "csv" => Some("text/csv"),
1361 "md" | "markdown" => Some("text/markdown"),
1362 "png" => Some("image/png"),
1363 "jpg" | "jpeg" => Some("image/jpeg"),
1364 "gif" => Some("image/gif"),
1365 "zip" => Some("application/zip"),
1366 "odt" => Some("application/vnd.oasis.opendocument.text"),
1367 "ods" => Some("application/vnd.oasis.opendocument.spreadsheet"),
1368 "odp" => Some("application/vnd.oasis.opendocument.presentation"),
1369 "rtf" => Some("application/rtf"),
1370 "epub" => Some("application/epub+zip"),
1371 "msg" => Some("application/vnd.ms-outlook"),
1372 "eml" => Some("message/rfc822"),
1373 _ => None,
1374 }
1375}
1376
1377fn emit_dart_batch_item_array(arr: &serde_json::Value, elem_type: &str) -> String {
1384 let items: Vec<String> = arr
1385 .as_array()
1386 .map(|a| a.as_slice())
1387 .unwrap_or_default()
1388 .iter()
1389 .filter_map(|item| {
1390 let obj = item.as_object()?;
1391 match elem_type {
1392 "BatchBytesItem" => {
1393 let content_bytes = obj
1394 .get("content")
1395 .and_then(|v| v.as_array())
1396 .map(|arr| {
1397 let nums: Vec<String> =
1398 arr.iter().filter_map(|v| v.as_u64().map(|n| n.to_string())).collect();
1399 format!("Uint8List.fromList([{}])", nums.join(", "))
1400 })
1401 .unwrap_or_else(|| "Uint8List(0)".to_string());
1402 let mime_type = obj
1403 .get("mime_type")
1404 .and_then(|v| v.as_str())
1405 .unwrap_or("application/octet-stream");
1406 Some(format!(
1407 "BatchBytesItem(content: {content_bytes}, mimeType: '{}')",
1408 escape_dart(mime_type)
1409 ))
1410 }
1411 "BatchFileItem" => {
1412 let path = obj.get("path").and_then(|v| v.as_str()).unwrap_or("");
1413 Some(format!("BatchFileItem(path: '{}')", escape_dart(path)))
1414 }
1415 _ => None,
1416 }
1417 })
1418 .collect();
1419 format!("[{}]", items.join(", "))
1420}
1421
1422fn escape_dart(s: &str) -> String {
1424 s.replace('\\', "\\\\")
1425 .replace('\'', "\\'")
1426 .replace('\n', "\\n")
1427 .replace('\r', "\\r")
1428 .replace('\t', "\\t")
1429 .replace('$', "\\$")
1430}
1431
1432fn type_name_to_create_from_json_dart(type_name: &str) -> String {
1440 let mut snake = String::with_capacity(type_name.len() + 8);
1442 for (i, ch) in type_name.char_indices() {
1443 if ch.is_uppercase() {
1444 if i > 0 {
1445 snake.push('_');
1446 }
1447 snake.extend(ch.to_lowercase());
1448 } else {
1449 snake.push(ch);
1450 }
1451 }
1452 let rust_fn = format!("create_{snake}_from_json");
1455 rust_fn
1457 .split('_')
1458 .enumerate()
1459 .map(|(i, part)| {
1460 if i == 0 {
1461 part.to_string()
1462 } else {
1463 let mut chars = part.chars();
1464 match chars.next() {
1465 None => String::new(),
1466 Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
1467 }
1468 }
1469 })
1470 .collect::<Vec<_>>()
1471 .join("")
1472}