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::collections::HashMap;
20use std::fmt::Write as FmtWrite;
21use std::path::PathBuf;
22
23use super::E2eCodegen;
24use super::client;
25
26pub struct DartE2eCodegen;
28
29impl E2eCodegen for DartE2eCodegen {
30 fn generate(
31 &self,
32 groups: &[FixtureGroup],
33 e2e_config: &E2eConfig,
34 config: &ResolvedCrateConfig,
35 type_defs: &[alef_core::ir::TypeDef],
36 enums: &[alef_core::ir::EnumDef],
37 ) -> Result<Vec<GeneratedFile>> {
38 let lang = self.language_name();
39 let output_base = PathBuf::from(e2e_config.effective_output()).join(lang);
40
41 let mut files = Vec::new();
42
43 let dart_pkg = e2e_config.resolve_package("dart");
45 let pkg_name = dart_pkg
46 .as_ref()
47 .and_then(|p| p.name.as_ref())
48 .cloned()
49 .unwrap_or_else(|| config.dart_pubspec_name());
50 let pkg_path = dart_pkg
51 .as_ref()
52 .and_then(|p| p.path.as_ref())
53 .cloned()
54 .unwrap_or_else(|| "../../packages/dart".to_string());
55 let pkg_version = dart_pkg
56 .as_ref()
57 .and_then(|p| p.version.as_ref())
58 .cloned()
59 .or_else(|| config.resolved_version())
60 .unwrap_or_else(|| "0.1.0".to_string());
61
62 files.push(GeneratedFile {
64 path: output_base.join("pubspec.yaml"),
65 content: render_pubspec(&pkg_name, &pkg_path, &pkg_version, e2e_config.dep_mode),
66 generated_header: false,
67 });
68
69 files.push(GeneratedFile {
72 path: output_base.join("dart_test.yaml"),
73 content: concat!(
74 "# Generated by alef — DO NOT EDIT.\n",
75 "# Run test files sequentially to avoid overwhelming the mock server with\n",
76 "# concurrent keep-alive connections.\n",
77 "concurrency: 1\n",
78 )
79 .to_string(),
80 generated_header: false,
81 });
82
83 let test_base = output_base.join("test");
84
85 let bridge_class = config.dart_bridge_class_name();
87
88 let frb_module_name = config.name.replace('-', "_");
92
93 let dart_stub_methods: std::collections::HashSet<String> = config
98 .dart
99 .as_ref()
100 .map(|d| d.stub_methods.iter().cloned().collect())
101 .unwrap_or_default();
102
103 let dart_first_class_map = build_dart_first_class_map(type_defs, enums, e2e_config);
106
107 for group in groups {
108 let active: Vec<&Fixture> = group
109 .fixtures
110 .iter()
111 .filter(|f| super::should_include_fixture(f, lang, e2e_config))
112 .filter(|f| {
113 let call_config = e2e_config.resolve_call_for_fixture(
114 f.call.as_deref(),
115 &f.id,
116 &f.resolved_category(),
117 &f.tags,
118 &f.input,
119 );
120 let resolved_function = call_config
121 .overrides
122 .get(lang)
123 .and_then(|o| o.function.as_ref())
124 .cloned()
125 .unwrap_or_else(|| call_config.function.clone());
126 !dart_stub_methods.contains(&resolved_function)
127 })
128 .collect();
129
130 if active.is_empty() {
131 continue;
132 }
133
134 let filename = format!("{}_test.dart", sanitize_filename(&group.category));
135 let content = render_test_file(
136 &group.category,
137 &active,
138 e2e_config,
139 lang,
140 &pkg_name,
141 &frb_module_name,
142 &bridge_class,
143 &dart_first_class_map,
144 );
145 files.push(GeneratedFile {
146 path: test_base.join(filename),
147 content,
148 generated_header: true,
149 });
150 }
151
152 Ok(files)
153 }
154
155 fn language_name(&self) -> &'static str {
156 "dart"
157 }
158}
159
160fn render_pubspec(
165 pkg_name: &str,
166 pkg_path: &str,
167 pkg_version: &str,
168 dep_mode: crate::config::DependencyMode,
169) -> String {
170 let test_ver = pub_dev::TEST_PACKAGE;
171 let http_ver = pub_dev::HTTP_PACKAGE;
172
173 let dep_block = match dep_mode {
174 crate::config::DependencyMode::Registry => {
175 let constraint = if pkg_version.starts_with('^')
177 || pkg_version.starts_with('~')
178 || pkg_version.starts_with('>')
179 || pkg_version.starts_with('<')
180 || pkg_version.starts_with('=')
181 {
182 pkg_version.to_string()
183 } else {
184 format!("^{pkg_version}")
185 };
186 format!(" {pkg_name}: {constraint}")
187 }
188 crate::config::DependencyMode::Local => {
189 format!(" {pkg_name}:\n path: {pkg_path}")
190 }
191 };
192
193 let sdk = alef_core::template_versions::toolchain::DART_SDK_CONSTRAINT;
194 format!(
195 r#"name: e2e_dart
196version: 0.1.0
197publish_to: none
198
199environment:
200 sdk: "{sdk}"
201
202dependencies:
203{dep_block}
204
205dev_dependencies:
206 test: {test_ver}
207 http: {http_ver}
208"#
209 )
210}
211
212#[allow(clippy::too_many_arguments)]
213fn render_test_file(
214 category: &str,
215 fixtures: &[&Fixture],
216 e2e_config: &E2eConfig,
217 lang: &str,
218 pkg_name: &str,
219 frb_module_name: &str,
220 bridge_class: &str,
221 dart_first_class_map: &crate::field_access::DartFirstClassMap,
222) -> String {
223 let mut out = String::new();
224 out.push_str(&hash::header(CommentStyle::DoubleSlash));
225 out.push_str("// ignore_for_file: unused_local_variable\n\n");
229
230 let has_http_fixtures = fixtures.iter().any(|f| f.is_http_test());
232
233 let has_batch_byte_items = fixtures.iter().any(|f| {
235 let call_config =
236 e2e_config.resolve_call_for_fixture(f.call.as_deref(), &f.id, &f.resolved_category(), &f.tags, &f.input);
237 call_config.args.iter().any(|a| {
238 a.element_type.as_deref() == Some("BatchBytesItem") && resolve_field(&f.input, &a.field).is_array()
239 })
240 });
241
242 let needs_chdir = fixtures.iter().any(|f| {
246 if f.is_http_test() {
247 return false;
248 }
249 let call_config =
250 e2e_config.resolve_call_for_fixture(f.call.as_deref(), &f.id, &f.resolved_category(), &f.tags, &f.input);
251 call_config
252 .args
253 .iter()
254 .any(|a| a.arg_type == "file_path" || a.arg_type == "bytes")
255 });
256
257 let has_handle_args = fixtures.iter().any(|f| {
263 if f.is_http_test() {
264 return false;
265 }
266 let call_config =
267 e2e_config.resolve_call_for_fixture(f.call.as_deref(), &f.id, &f.resolved_category(), &f.tags, &f.input);
268 call_config
269 .args
270 .iter()
271 .any(|a| a.arg_type == "json_object" && super::resolve_field(&f.input, &a.field).is_array())
272 });
273
274 let lang_client_factory = e2e_config
280 .call
281 .overrides
282 .get(lang)
283 .and_then(|o| o.client_factory.as_deref())
284 .is_some();
285 let has_mock_url_refs = lang_client_factory
286 || fixtures.iter().any(|f| {
287 if f.is_http_test() {
288 return false;
289 }
290 let call_config = e2e_config.resolve_call_for_fixture(
291 f.call.as_deref(),
292 &f.id,
293 &f.resolved_category(),
294 &f.tags,
295 &f.input,
296 );
297 if call_config.args.iter().any(|a| a.arg_type == "mock_url") {
298 return true;
299 }
300 call_config
301 .overrides
302 .get(lang)
303 .and_then(|o| o.client_factory.as_deref())
304 .is_some()
305 });
306
307 let _ = writeln!(out, "import 'package:test/test.dart';");
308 if has_http_fixtures || needs_chdir || has_mock_url_refs {
313 let _ = writeln!(out, "import 'dart:io';");
314 }
315 if has_batch_byte_items {
316 let _ = writeln!(out, "import 'dart:typed_data';");
317 }
318 let _ = writeln!(out, "import 'package:{pkg_name}/{pkg_name}.dart';");
319 let _ = writeln!(
325 out,
326 "import 'package:{pkg_name}/src/{frb_module_name}_bridge_generated/frb_generated.dart' show RustLib;"
327 );
328 if has_http_fixtures {
329 let _ = writeln!(out, "import 'dart:async';");
330 }
331 if has_http_fixtures || has_handle_args {
333 let _ = writeln!(out, "import 'dart:convert';");
334 }
335 let _ = writeln!(out);
336
337 if has_http_fixtures {
347 let _ = writeln!(out, "HttpClient _httpClient = HttpClient()..maxConnectionsPerHost = 1;");
348 let _ = writeln!(out);
349 let _ = writeln!(out, "var _lock = Future<void>.value();");
350 let _ = writeln!(out);
351 let _ = writeln!(out, "Future<T> _serialized<T>(Future<T> Function() fn) async {{");
352 let _ = writeln!(out, " final current = _lock;");
353 let _ = writeln!(out, " final next = Completer<void>();");
354 let _ = writeln!(out, " _lock = next.future;");
355 let _ = writeln!(out, " try {{");
356 let _ = writeln!(out, " await current;");
357 let _ = writeln!(out, " return await fn();");
358 let _ = writeln!(out, " }} finally {{");
359 let _ = writeln!(out, " next.complete();");
360 let _ = writeln!(out, " }}");
361 let _ = writeln!(out, "}}");
362 let _ = writeln!(out);
363 let _ = writeln!(out, "Future<T> _withRetry<T>(Future<T> Function() fn) async {{");
366 let _ = writeln!(out, " try {{");
367 let _ = writeln!(out, " return await fn();");
368 let _ = writeln!(out, " }} on SocketException {{");
369 let _ = writeln!(out, " _httpClient.close(force: true);");
370 let _ = writeln!(out, " _httpClient = HttpClient()..maxConnectionsPerHost = 1;");
371 let _ = writeln!(out, " return fn();");
372 let _ = writeln!(out, " }} on HttpException {{");
373 let _ = writeln!(out, " _httpClient.close(force: true);");
374 let _ = writeln!(out, " _httpClient = HttpClient()..maxConnectionsPerHost = 1;");
375 let _ = writeln!(out, " return fn();");
376 let _ = writeln!(out, " }}");
377 let _ = writeln!(out, "}}");
378 let _ = writeln!(out);
379 }
380
381 let _ = writeln!(out, "// E2e tests for category: {category}");
382 let _ = writeln!(out);
383
384 let _ = writeln!(out, "String _alefE2eText(Object? value) {{");
390 let _ = writeln!(out, " if (value == null) return '';");
391 let _ = writeln!(
392 out,
393 " // Check if it's an enum by examining its toString representation."
394 );
395 let _ = writeln!(out, " final str = value.toString();");
396 let _ = writeln!(out, " if (str.contains('.')) {{");
397 let _ = writeln!(
398 out,
399 " // Enum.toString() returns 'EnumName.variantName'. Extract the variant name."
400 );
401 let _ = writeln!(out, " final parts = str.split('.');");
402 let _ = writeln!(out, " if (parts.length == 2) {{");
403 let _ = writeln!(out, " final variantName = parts[1];");
404 let _ = writeln!(
405 out,
406 " // Convert camelCase variant names to snake_case for serde compatibility."
407 );
408 let _ = writeln!(out, " // E.g. 'toolCalls' -> 'tool_calls', 'stop' -> 'stop'.");
409 let _ = writeln!(out, " return _camelToSnake(variantName);");
410 let _ = writeln!(out, " }}");
411 let _ = writeln!(out, " }}");
412 let _ = writeln!(out, " return str;");
413 let _ = writeln!(out, "}}");
414 let _ = writeln!(out);
415
416 let _ = writeln!(out, "String _camelToSnake(String camel) {{");
418 let _ = writeln!(out, " final buffer = StringBuffer();");
419 let _ = writeln!(out, " for (int i = 0; i < camel.length; i++) {{");
420 let _ = writeln!(out, " final char = camel[i];");
421 let _ = writeln!(out, " if (char.contains(RegExp(r'[A-Z]'))) {{");
422 let _ = writeln!(out, " if (i > 0) buffer.write('_');");
423 let _ = writeln!(out, " buffer.write(char.toLowerCase());");
424 let _ = writeln!(out, " }} else {{");
425 let _ = writeln!(out, " buffer.write(char);");
426 let _ = writeln!(out, " }}");
427 let _ = writeln!(out, " }}");
428 let _ = writeln!(out, " return buffer.toString();");
429 let _ = writeln!(out, "}}");
430 let _ = writeln!(out);
431
432 let _ = writeln!(out, "void main() {{");
433
434 let _ = writeln!(out, " setUpAll(() async {{");
441 let _ = writeln!(out, " await RustLib.init();");
442 if needs_chdir {
443 let test_docs_path = e2e_config.test_documents_relative_from(0);
444 let _ = writeln!(
445 out,
446 " final _testDocs = Platform.environment['FIXTURES_DIR'] ?? '{test_docs_path}';"
447 );
448 let _ = writeln!(out, " final _dir = Directory(_testDocs);");
449 let _ = writeln!(out, " if (_dir.existsSync()) Directory.current = _dir;");
450 }
451 let _ = writeln!(out, " }});");
452 let _ = writeln!(out);
453
454 if has_http_fixtures {
456 let _ = writeln!(out, " tearDownAll(() => _httpClient.close());");
457 let _ = writeln!(out);
458 }
459
460 for fixture in fixtures {
461 render_test_case(&mut out, fixture, e2e_config, lang, bridge_class, dart_first_class_map);
462 }
463
464 let _ = writeln!(out, "}}");
465 out
466}
467
468fn render_test_case(
469 out: &mut String,
470 fixture: &Fixture,
471 e2e_config: &E2eConfig,
472 lang: &str,
473 bridge_class: &str,
474 dart_first_class_map: &crate::field_access::DartFirstClassMap,
475) {
476 if let Some(http) = &fixture.http {
478 render_http_test_case(out, fixture, http);
479 return;
480 }
481
482 let call_config = e2e_config.resolve_call_for_fixture(
484 fixture.call.as_deref(),
485 &fixture.id,
486 &fixture.resolved_category(),
487 &fixture.tags,
488 &fixture.input,
489 );
490 let call_field_resolver = FieldResolver::new_with_dart_first_class(
492 e2e_config.effective_fields(call_config),
493 e2e_config.effective_fields_optional(call_config),
494 e2e_config.effective_result_fields(call_config),
495 e2e_config.effective_fields_array(call_config),
496 e2e_config.effective_fields_method_calls(call_config),
497 &HashMap::new(),
498 dart_first_class_map.clone(),
499 )
500 .with_dart_root_type(dart_call_result_type(call_config).or_else(|| dart_first_class_map.root_type.clone()));
501 let field_resolver = &call_field_resolver;
502 let enum_fields_base = e2e_config.effective_fields_enum(call_config);
503
504 let effective_enum_fields: std::collections::HashSet<String> = {
509 let dart_overrides = call_config.overrides.get("dart");
510 if let Some(overrides) = dart_overrides {
511 let mut merged = enum_fields_base.clone();
512 merged.extend(overrides.enum_fields.keys().cloned());
513 merged
514 } else {
515 enum_fields_base.clone()
516 }
517 };
518 let enum_fields = &effective_enum_fields;
519 let call_overrides = call_config.overrides.get(lang);
520 let mut function_name = call_overrides
521 .and_then(|o| o.function.as_ref())
522 .cloned()
523 .unwrap_or_else(|| call_config.function.clone());
524 function_name = function_name
526 .split('_')
527 .enumerate()
528 .map(|(i, part)| {
529 if i == 0 {
530 part.to_string()
531 } else {
532 let mut chars = part.chars();
533 match chars.next() {
534 None => String::new(),
535 Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
536 }
537 }
538 })
539 .collect::<Vec<_>>()
540 .join("");
541 let result_var = &call_config.result_var;
542 let description = escape_dart(&fixture.description);
543 let fixture_id = &fixture.id;
544 let _is_async = call_overrides.and_then(|o| o.r#async).unwrap_or(call_config.r#async);
547
548 let expects_error = fixture.assertions.iter().any(|a| a.assertion_type == "error");
549 let is_streaming = crate::codegen::streaming_assertions::resolve_is_streaming(fixture, call_config.streaming);
550 let result_is_simple = call_overrides.is_some_and(|o| o.result_is_simple) || call_config.result_is_simple;
555
556 let options_type: Option<&str> = call_overrides.and_then(|o| o.options_type.as_deref());
563 let options_via: &str = call_overrides
564 .and_then(|o| o.options_via.as_deref())
565 .unwrap_or("kwargs");
566
567 let file_path_for_mime: Option<&str> = call_config
575 .args
576 .iter()
577 .find(|a| a.arg_type == "file_path")
578 .and_then(|a| resolve_field(&fixture.input, &a.field).as_str());
579
580 let has_file_path_arg = call_config.args.iter().any(|a| a.arg_type == "file_path");
587 let caller_supplied_override = call_overrides.and_then(|o| o.function.as_ref()).is_some();
590 if has_file_path_arg && !caller_supplied_override {
591 function_name = match function_name.as_str() {
592 "extractFile" => "extractBytes".to_string(),
593 "extractFileSync" => "extractBytesSync".to_string(),
594 other => other.to_string(),
595 };
596 }
597
598 let client_factory_for_args: Option<&str> =
605 call_overrides.and_then(|o| o.client_factory.as_deref()).or_else(|| {
606 e2e_config
607 .call
608 .overrides
609 .get(lang)
610 .and_then(|o| o.client_factory.as_deref())
611 });
612
613 let mut setup_lines: Vec<String> = Vec::new();
616 let mut args = Vec::new();
617
618 for arg_def in &call_config.args {
619 match arg_def.arg_type.as_str() {
620 "mock_url" => {
621 let name = arg_def.name.clone();
622 if fixture.has_host_root_route() {
623 let env_key = format!("MOCK_SERVER_{}", fixture_id.to_uppercase());
624 setup_lines.push(format!(
625 r#"final {name} = Platform.environment["{env_key}"] ?? (Platform.environment["MOCK_SERVER_URL"]! + "/fixtures/{fixture_id}");"#
626 ));
627 } else {
628 setup_lines.push(format!(
629 r#"final {name} = "${{Platform.environment["MOCK_SERVER_URL"] ?? "http://localhost:8080"}}/fixtures/{fixture_id}";"#
630 ));
631 }
632 args.push(name);
633 continue;
634 }
635 "handle" => {
636 let name = arg_def.name.clone();
637 let field = arg_def.field.strip_prefix("input.").unwrap_or(&arg_def.field);
638 let config_value = fixture.input.get(field).cloned().unwrap_or(serde_json::Value::Null);
639 let create_fn = {
641 let mut chars = name.chars();
642 let pascal = match chars.next() {
643 None => String::new(),
644 Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
645 };
646 format!("create{pascal}")
647 };
648 if config_value.is_null()
649 || config_value.is_object() && config_value.as_object().is_some_and(|o| o.is_empty())
650 {
651 setup_lines.push(format!("final {name} = await {bridge_class}.{create_fn}();"));
652 } else {
653 let json_str = serde_json::to_string(&config_value).unwrap_or_default();
654 let config_var = format!("{name}Config");
655 setup_lines.push(format!(
660 "final {config_var} = await createCrawlConfigFromJson(json: r'{json_str}');"
661 ));
662 setup_lines.push(format!(
664 "final {name} = await {bridge_class}.{create_fn}(config: {config_var});"
665 ));
666 }
667 args.push(name);
668 continue;
669 }
670 "mock_url_list" => {
671 let env_key = format!("MOCK_SERVER_{}", fixture_id.to_uppercase());
676 let field = arg_def.field.strip_prefix("input.").unwrap_or(&arg_def.field);
677 let val = fixture.input.get(field).unwrap_or(&serde_json::Value::Null);
678
679 let paths: Vec<String> = if let Some(arr) = val.as_array() {
680 arr.iter()
681 .filter_map(|v| v.as_str())
682 .map(|s| format!("'{}'", escape_dart(s)))
683 .collect()
684 } else {
685 Vec::new()
686 };
687
688 let var_name = &arg_def.name;
689 let paths_literal = paths.join(", ");
690
691 setup_lines.push(format!(
692 r#"final {var_name}Base = Platform.environment["{env_key}"] ?? (Platform.environment["MOCK_SERVER_URL"] ?? "http://localhost:8080") + "/fixtures/{fixture_id}";"#
693 ));
694 setup_lines.push(format!(
695 r#"final {var_name} = <String>[{paths_literal}].map((p) => p.startsWith('http') ? p : {var_name}Base + p).toList();"#
696 ));
697
698 args.push(var_name.to_string());
699 continue;
700 }
701 _ => {}
702 }
703
704 let arg_value = resolve_field(&fixture.input, &arg_def.field);
705 match arg_def.arg_type.as_str() {
706 "bytes" | "file_path" => {
707 if let serde_json::Value::String(file_path) = arg_value {
712 args.push(format!("File('{}').readAsBytesSync()", file_path));
713 }
714 }
715 "int" | "integer" | "i64" => {
716 match arg_value {
718 serde_json::Value::Number(n) => {
719 args.push(n.to_string());
720 }
721 serde_json::Value::Null if arg_def.optional => {
722 }
724 _ => {
725 args.push("0".to_string());
727 }
728 }
729 }
730 "float" | "number" => {
731 match arg_value {
733 serde_json::Value::Number(n) => {
734 args.push(n.to_string());
735 }
736 serde_json::Value::Null if arg_def.optional => {
737 }
739 _ => {
740 args.push("0.0".to_string());
742 }
743 }
744 }
745 "bool" | "boolean" => {
746 match arg_value {
748 serde_json::Value::Bool(b) => {
749 args.push(if *b { "true" } else { "false" }.to_string());
750 }
751 serde_json::Value::Null if arg_def.optional => {
752 }
754 _ => {
755 args.push("false".to_string());
757 }
758 }
759 }
760 "string" => {
761 let dart_param_name = snake_to_camel(&arg_def.name);
776 let mime_type_is_positional = arg_def.name == "mime_type" && client_factory_for_args.is_none();
786 match arg_value {
787 serde_json::Value::String(s) => {
788 let literal = format!("'{}'", escape_dart(s));
789 if (arg_def.optional || client_factory_for_args.is_some()) && !mime_type_is_positional {
795 args.push(format!("{dart_param_name}: {literal}"));
796 } else {
797 args.push(literal);
798 }
799 }
800 serde_json::Value::Null
801 if arg_def.optional
802 && arg_def.name == "mime_type" =>
805 {
806 let inferred = file_path_for_mime
807 .and_then(mime_from_extension)
808 .unwrap_or("application/octet-stream");
809 if mime_type_is_positional {
812 args.push(format!("'{inferred}'"));
813 } else {
814 args.push(format!("{dart_param_name}: '{inferred}'"));
815 }
816 }
817 _ => {}
819 }
820 }
821 "json_object" => {
822 if let Some(elem_type) = &arg_def.element_type {
824 if (elem_type == "BatchBytesItem" || elem_type == "BatchFileItem") && arg_value.is_array() {
825 let dart_items = emit_dart_batch_item_array(arg_value, elem_type);
826 args.push(dart_items);
827 } else if elem_type == "String" && arg_value.is_array() {
828 let items: Vec<String> = arg_value
835 .as_array()
836 .unwrap()
837 .iter()
838 .filter_map(|v| v.as_str())
839 .map(|s| format!("'{}'", escape_dart(s)))
840 .collect();
841 args.push(format!("<String>[{}]", items.join(", ")));
842 }
843 } else if options_via == "from_json" {
844 if let Some(opts_type) = options_type {
854 if !arg_value.is_null() {
855 let json_str = serde_json::to_string(&arg_value).unwrap_or_default();
856 let escaped_json = escape_dart(&json_str);
859 let var_name = format!("_{}", arg_def.name);
860 let dart_fn = type_name_to_create_from_json_dart(opts_type);
861 setup_lines.push(format!("final {var_name} = await {dart_fn}(json: '{escaped_json}');"));
862 args.push(format!("req: {var_name}"));
865 }
866 }
867 } else if arg_def.name == "config" {
868 if let serde_json::Value::Object(map) = &arg_value {
869 if !map.is_empty() {
870 let explicit_options =
879 options_type.is_some_and(|t| t != "ExtractionConfig" && t != "FileExtractionConfig");
880 let has_non_scalar = map.values().any(|v| {
881 matches!(
882 v,
883 serde_json::Value::String(_)
884 | serde_json::Value::Object(_)
885 | serde_json::Value::Array(_)
886 )
887 });
888 if explicit_options || has_non_scalar {
889 let opts_type = options_type.unwrap_or("ExtractionConfig");
890 let json_str = serde_json::to_string(&arg_value).unwrap_or_default();
891 let escaped_json = escape_dart(&json_str);
892 let var_name = format!("_{}", arg_def.name);
893 let dart_fn = type_name_to_create_from_json_dart(opts_type);
894 setup_lines
895 .push(format!("final {var_name} = await {dart_fn}(json: '{escaped_json}');"));
896 args.push(var_name);
897 } else {
898 args.push(emit_extraction_config_dart(map));
904 }
905 } else {
906 if let Some(opts_type) = options_type {
912 let var_name = format!("_{}", arg_def.name);
913 let dart_fn = type_name_to_create_from_json_dart(opts_type);
914 setup_lines.push(format!("final {var_name} = await {dart_fn}(json: '{{}}');"));
915 args.push(var_name);
916 }
917 }
918 }
919 } else if arg_value.is_array() {
921 let json_str = serde_json::to_string(&arg_value).unwrap_or_default();
924 let var_name = arg_def.name.clone();
925 setup_lines.push(format!(
926 "final {var_name} = (jsonDecode(r'{json_str}') as List<dynamic>).cast<String>();"
927 ));
928 args.push(var_name);
929 } else if let serde_json::Value::Object(map) = &arg_value {
930 if !map.is_empty() {
944 if let Some(opts_type) = options_type {
945 let json_str = serde_json::to_string(&arg_value).unwrap_or_default();
946 let escaped_json = escape_dart(&json_str);
947 let dart_param_name = snake_to_camel(&arg_def.name);
948 let var_name = format!("_{}", arg_def.name);
949 let dart_fn = type_name_to_create_from_json_dart(opts_type);
950 if fixture.visitor.is_some() {
951 setup_lines.push(format!(
952 "final {var_name} = await {dart_fn}WithVisitor(json: '{escaped_json}', visitor: _visitor);"
953 ));
954 } else {
955 setup_lines
956 .push(format!("final {var_name} = await {dart_fn}(json: '{escaped_json}');"));
957 }
958 if arg_def.optional {
959 args.push(format!("{dart_param_name}: {var_name}"));
960 } else {
961 args.push(var_name);
962 }
963 }
964 }
965 }
966 }
967 _ => {}
968 }
969 }
970
971 if let Some(visitor_spec) = &fixture.visitor {
986 let mut visitor_setup: Vec<String> = Vec::new();
987 let _ = super::dart_visitors::build_dart_visitor(&mut visitor_setup, visitor_spec);
988 for line in visitor_setup.into_iter().rev() {
991 setup_lines.insert(0, line);
992 }
993
994 let already_has_options = args.iter().any(|a| a.starts_with("options:") || a == "_options");
998 if !already_has_options {
999 if let Some(opts_type) = options_type {
1000 let dart_fn = type_name_to_create_from_json_dart(opts_type);
1001 setup_lines.push(format!(
1002 "final _options = await {dart_fn}WithVisitor(json: '{{}}', visitor: _visitor);"
1003 ));
1004 args.push("options: _options".to_string());
1005 }
1006 }
1007 }
1008
1009 let client_factory: Option<&str> = call_overrides.and_then(|o| o.client_factory.as_deref()).or_else(|| {
1013 e2e_config
1014 .call
1015 .overrides
1016 .get(lang)
1017 .and_then(|o| o.client_factory.as_deref())
1018 });
1019
1020 let client_factory_camel: Option<String> = client_factory.map(|f| {
1022 f.split('_')
1023 .enumerate()
1024 .map(|(i, part)| {
1025 if i == 0 {
1026 part.to_string()
1027 } else {
1028 let mut chars = part.chars();
1029 match chars.next() {
1030 None => String::new(),
1031 Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
1032 }
1033 }
1034 })
1035 .collect::<Vec<_>>()
1036 .join("")
1037 });
1038
1039 let _ = writeln!(out, " test('{description}', () async {{");
1043
1044 let args_str = args.join(", ");
1045 let receiver_class = call_overrides
1046 .and_then(|o| o.class.as_ref())
1047 .cloned()
1048 .unwrap_or_else(|| bridge_class.to_string());
1049
1050 let (receiver, extra_setup): (String, Option<String>) = if let Some(factory) = &client_factory_camel {
1054 let has_mock_url = call_config.args.iter().any(|a| a.arg_type == "mock_url");
1055 let mock_url_setup = if !has_mock_url {
1056 if fixture.has_host_root_route() {
1058 let env_key = format!("MOCK_SERVER_{}", fixture_id.to_uppercase());
1059 Some(format!(
1060 "final _mockUrl = Platform.environment[\"{env_key}\"] ?? (Platform.environment[\"MOCK_SERVER_URL\"]! + \"/fixtures/{fixture_id}\");"
1061 ))
1062 } else {
1063 Some(format!(
1064 r#"final _mockUrl = "${{Platform.environment["MOCK_SERVER_URL"] ?? "http://localhost:8080"}}/fixtures/{fixture_id}";"#
1065 ))
1066 }
1067 } else {
1068 None
1069 };
1070 let url_expr = if has_mock_url {
1071 call_config
1074 .args
1075 .iter()
1076 .find(|a| a.arg_type == "mock_url")
1077 .map(|a| a.name.clone())
1078 .unwrap_or_else(|| "_mockUrl".to_string())
1079 } else {
1080 "_mockUrl".to_string()
1081 };
1082 let create_line = format!("final _client = await {receiver_class}.{factory}('test-key', baseUrl: {url_expr});");
1083 let full_setup = if let Some(url_line) = mock_url_setup {
1084 Some(format!("{url_line}\n {create_line}"))
1085 } else {
1086 Some(create_line)
1087 };
1088 ("_client".to_string(), full_setup)
1089 } else {
1090 (receiver_class.clone(), None)
1091 };
1092
1093 if expects_error && (!setup_lines.is_empty() || extra_setup.is_some()) {
1094 let _ = writeln!(out, " await expectLater(() async {{");
1098 for line in &setup_lines {
1099 let _ = writeln!(out, " {line}");
1100 }
1101 if let Some(extra) = &extra_setup {
1102 for line in extra.lines() {
1103 let _ = writeln!(out, " {line}");
1104 }
1105 }
1106 if is_streaming {
1107 let _ = writeln!(out, " return {receiver}.{function_name}({args_str}).toList();");
1108 } else {
1109 let _ = writeln!(out, " return {receiver}.{function_name}({args_str});");
1110 }
1111 let _ = writeln!(out, " }}(), throwsA(anything));");
1112 } else if expects_error {
1113 if let Some(extra) = &extra_setup {
1115 for line in extra.lines() {
1116 let _ = writeln!(out, " {line}");
1117 }
1118 }
1119 if is_streaming {
1120 let _ = writeln!(
1121 out,
1122 " await expectLater({receiver}.{function_name}({args_str}).toList(), throwsA(anything));"
1123 );
1124 } else {
1125 let _ = writeln!(
1126 out,
1127 " await expectLater({receiver}.{function_name}({args_str}), throwsA(anything));"
1128 );
1129 }
1130 } else {
1131 for line in &setup_lines {
1132 let _ = writeln!(out, " {line}");
1133 }
1134 if let Some(extra) = &extra_setup {
1135 for line in extra.lines() {
1136 let _ = writeln!(out, " {line}");
1137 }
1138 }
1139 if is_streaming {
1140 let _ = writeln!(
1141 out,
1142 " final {result_var} = await {receiver}.{function_name}({args_str}).toList();"
1143 );
1144 } else {
1145 let _ = writeln!(
1146 out,
1147 " final {result_var} = await {receiver}.{function_name}({args_str});"
1148 );
1149 }
1150 for assertion in &fixture.assertions {
1151 if is_streaming {
1152 render_streaming_assertion_dart(out, assertion, result_var);
1153 } else {
1154 render_assertion_dart(
1155 out,
1156 assertion,
1157 result_var,
1158 result_is_simple,
1159 field_resolver,
1160 enum_fields,
1161 );
1162 }
1163 }
1164 }
1165
1166 let _ = writeln!(out, " }});");
1167 let _ = writeln!(out);
1168}
1169
1170fn dart_length_expr(field_accessor: &str, field: Option<&str>, field_resolver: &FieldResolver) -> String {
1178 let is_optional = field
1179 .map(|f| {
1180 let resolved = field_resolver.resolve(f);
1181 field_resolver.is_optional(f) || field_resolver.is_optional(resolved)
1182 })
1183 .unwrap_or(false);
1184 if is_optional {
1185 format!("{field_accessor}?.length ?? 0")
1186 } else {
1187 format!("{field_accessor}.length")
1188 }
1189}
1190
1191fn dart_format_value(val: &serde_json::Value) -> String {
1192 match val {
1193 serde_json::Value::String(s) => format!("'{}'", escape_dart(s)),
1194 serde_json::Value::Bool(b) => b.to_string(),
1195 serde_json::Value::Number(n) => n.to_string(),
1196 serde_json::Value::Null => "null".to_string(),
1197 other => format!("'{}'", escape_dart(&other.to_string())),
1198 }
1199}
1200
1201fn render_assertion_dart(
1212 out: &mut String,
1213 assertion: &Assertion,
1214 result_var: &str,
1215 result_is_simple: bool,
1216 field_resolver: &FieldResolver,
1217 enum_fields: &std::collections::HashSet<String>,
1218) {
1219 if !result_is_simple {
1223 if let Some(f) = assertion.field.as_deref() {
1224 let head = f.split("[].").next().unwrap_or(f);
1227 if !head.is_empty() && !field_resolver.is_valid_for_result(head) {
1228 let _ = writeln!(out, " // skipped: field '{f}' not available on dart result type");
1229 return;
1230 }
1231 }
1232 }
1233
1234 if let Some(f) = assertion.field.as_deref() {
1240 if !f.is_empty() && field_resolver.tagged_union_split(f).is_some() {
1241 let _ = writeln!(
1242 out,
1243 " // skipped: field '{f}' crosses a tagged-union variant boundary (not expressible in Dart)"
1244 );
1245 return;
1246 }
1247 }
1248
1249 if let Some(f) = assertion.field.as_deref() {
1251 if let Some(dot) = f.find("[].") {
1252 let resolved_full = field_resolver.resolve(f);
1257 let (array_part, elem_part) = match resolved_full.find("[].") {
1258 Some(rdot) => (&resolved_full[..rdot], &resolved_full[rdot + 3..]),
1259 None => (&f[..dot], &f[dot + 3..]),
1262 };
1263 let array_accessor = if array_part.is_empty() {
1264 result_var.to_string()
1265 } else {
1266 field_resolver.accessor(array_part, "dart", result_var)
1267 };
1268 let elem_accessor = field_to_dart_accessor(elem_part);
1269 match assertion.assertion_type.as_str() {
1270 "contains" => {
1271 if let Some(expected) = &assertion.value {
1272 let dart_val = dart_format_value(expected);
1273 let _ = writeln!(
1274 out,
1275 " expect({array_accessor}.any((e) => e.{elem_accessor}.toString().contains({dart_val})), isTrue);"
1276 );
1277 }
1278 }
1279 "contains_all" => {
1280 if let Some(values) = &assertion.values {
1281 for val in values {
1282 let dart_val = dart_format_value(val);
1283 let _ = writeln!(
1284 out,
1285 " expect({array_accessor}.any((e) => e.{elem_accessor}.toString().contains({dart_val})), isTrue);"
1286 );
1287 }
1288 }
1289 }
1290 "not_contains" => {
1291 if let Some(expected) = &assertion.value {
1292 let dart_val = dart_format_value(expected);
1293 let _ = writeln!(
1294 out,
1295 " expect({array_accessor}.any((e) => e.{elem_accessor}.toString().contains({dart_val})), isFalse);"
1296 );
1297 } else if let Some(values) = &assertion.values {
1298 for val in values {
1299 let dart_val = dart_format_value(val);
1300 let _ = writeln!(
1301 out,
1302 " expect({array_accessor}.any((e) => e.{elem_accessor}.toString().contains({dart_val})), isFalse);"
1303 );
1304 }
1305 }
1306 }
1307 "not_empty" => {
1308 let _ = writeln!(
1309 out,
1310 " expect({array_accessor}.any((e) => e.{elem_accessor}.toString().isNotEmpty), isTrue);"
1311 );
1312 }
1313 other => {
1314 let _ = writeln!(
1315 out,
1316 " // skipped: unsupported traversal assertion '{other}' on '{f}'"
1317 );
1318 }
1319 }
1320 return;
1321 }
1322 }
1323
1324 let field_accessor = if result_is_simple {
1325 result_var.to_string()
1329 } else {
1330 match assertion.field.as_deref() {
1331 Some(f) if !f.is_empty() => field_resolver.accessor(f, "dart", result_var),
1336 _ => result_var.to_string(),
1337 }
1338 };
1339
1340 let format_value = |val: &serde_json::Value| -> String { dart_format_value(val) };
1341
1342 match assertion.assertion_type.as_str() {
1343 "equals" | "field_equals" => {
1344 if let Some(expected) = &assertion.value {
1345 let dart_val = format_value(expected);
1346 let is_enum_field = assertion
1349 .field
1350 .as_deref()
1351 .map(|f| {
1352 let resolved = field_resolver.resolve(f);
1353 enum_fields.contains(f) || enum_fields.contains(resolved)
1354 })
1355 .unwrap_or(false);
1356
1357 if expected.is_string() {
1361 if is_enum_field {
1362 let _ = writeln!(
1365 out,
1366 " expect(_alefE2eText({field_accessor}).trim(), equals({dart_val}.toString().trim()));"
1367 );
1368 } else {
1369 let safe_accessor = if result_is_simple && assertion.field.is_none() {
1372 format!("({field_accessor} ?? '').toString().trim()")
1373 } else {
1374 format!("{field_accessor}.toString().trim()")
1375 };
1376 let _ = writeln!(
1377 out,
1378 " expect({safe_accessor}, equals({dart_val}.toString().trim()));"
1379 );
1380 }
1381 } else {
1382 let _ = writeln!(out, " expect({field_accessor}, equals({dart_val}));");
1383 }
1384 } else {
1385 let _ = writeln!(
1386 out,
1387 " // skipped: '{}' assertion missing value",
1388 assertion.assertion_type
1389 );
1390 }
1391 }
1392 "not_equals" => {
1393 if let Some(expected) = &assertion.value {
1394 let dart_val = format_value(expected);
1395 let is_enum_field = assertion
1397 .field
1398 .as_deref()
1399 .map(|f| {
1400 let resolved = field_resolver.resolve(f);
1401 enum_fields.contains(f) || enum_fields.contains(resolved)
1402 })
1403 .unwrap_or(false);
1404
1405 if expected.is_string() {
1406 if is_enum_field {
1407 let _ = writeln!(
1408 out,
1409 " expect(_alefE2eText({field_accessor}).trim(), isNot(equals({dart_val}.toString().trim())));"
1410 );
1411 } else {
1412 let safe_accessor = if result_is_simple && assertion.field.is_none() {
1415 format!("({field_accessor} ?? '').toString().trim()")
1416 } else {
1417 format!("{field_accessor}.toString().trim()")
1418 };
1419 let _ = writeln!(
1420 out,
1421 " expect({safe_accessor}, isNot(equals({dart_val}.toString().trim())));"
1422 );
1423 }
1424 } else {
1425 let _ = writeln!(out, " expect({field_accessor}, isNot(equals({dart_val})));");
1426 }
1427 }
1428 }
1429 "contains" => {
1430 if let Some(expected) = &assertion.value {
1431 let dart_val = format_value(expected);
1432 let aggregator = dart_stringy_aggregator_contains_assert(
1437 assertion.field.as_deref(),
1438 result_var,
1439 field_resolver,
1440 &dart_val,
1441 );
1442 if let Some(line) = aggregator {
1443 let _ = writeln!(out, "{line}");
1444 } else {
1445 let _ = writeln!(out, " expect({field_accessor}, contains({dart_val}));");
1446 }
1447 } else {
1448 let _ = writeln!(out, " // skipped: 'contains' assertion missing value");
1449 }
1450 }
1451 "contains_all" => {
1452 if let Some(values) = &assertion.values {
1453 for val in values {
1454 let dart_val = format_value(val);
1455 let _ = writeln!(out, " expect({field_accessor}, contains({dart_val}));");
1456 }
1457 }
1458 }
1459 "contains_any" => {
1460 if let Some(values) = &assertion.values {
1461 let checks: Vec<String> = values
1462 .iter()
1463 .map(|v| {
1464 let dart_val = format_value(v);
1465 format!("{field_accessor}.contains({dart_val})")
1466 })
1467 .collect();
1468 let joined = checks.join(" || ");
1469 let _ = writeln!(out, " expect({joined}, isTrue);");
1470 }
1471 }
1472 "not_contains" => {
1473 if let Some(expected) = &assertion.value {
1474 let dart_val = format_value(expected);
1475 let _ = writeln!(out, " expect({field_accessor}, isNot(contains({dart_val})));");
1476 } else if let Some(values) = &assertion.values {
1477 for val in values {
1478 let dart_val = format_value(val);
1479 let _ = writeln!(out, " expect({field_accessor}, isNot(contains({dart_val})));");
1480 }
1481 }
1482 }
1483 "not_empty" => {
1484 let is_collection = assertion.field.as_deref().is_some_and(|f| {
1489 let resolved = field_resolver.resolve(f);
1490 field_resolver.is_array(f) || field_resolver.is_array(resolved)
1491 });
1492 if is_collection {
1493 let _ = writeln!(out, " expect({field_accessor}, isNotEmpty);");
1494 } else {
1495 let _ = writeln!(out, " expect({field_accessor}, isNotNull);");
1496 }
1497 }
1498 "is_empty" => {
1499 let _ = writeln!(out, " expect({field_accessor}, anyOf(isNull, isEmpty));");
1503 }
1504 "starts_with" => {
1505 if let Some(expected) = &assertion.value {
1506 let dart_val = format_value(expected);
1507 let _ = writeln!(out, " expect({field_accessor}, startsWith({dart_val}));");
1508 }
1509 }
1510 "ends_with" => {
1511 if let Some(expected) = &assertion.value {
1512 let dart_val = format_value(expected);
1513 let _ = writeln!(out, " expect({field_accessor}, endsWith({dart_val}));");
1514 }
1515 }
1516 "min_length" => {
1517 if let Some(val) = &assertion.value {
1518 if let Some(n) = val.as_u64() {
1519 let length_expr = dart_length_expr(&field_accessor, assertion.field.as_deref(), field_resolver);
1520 let _ = writeln!(out, " expect({length_expr}, greaterThanOrEqualTo({n}));");
1521 }
1522 }
1523 }
1524 "max_length" => {
1525 if let Some(val) = &assertion.value {
1526 if let Some(n) = val.as_u64() {
1527 let length_expr = dart_length_expr(&field_accessor, assertion.field.as_deref(), field_resolver);
1528 let _ = writeln!(out, " expect({length_expr}, lessThanOrEqualTo({n}));");
1529 }
1530 }
1531 }
1532 "count_equals" => {
1533 if let Some(val) = &assertion.value {
1534 if let Some(n) = val.as_u64() {
1535 let length_expr = dart_length_expr(&field_accessor, assertion.field.as_deref(), field_resolver);
1536 let _ = writeln!(out, " expect({length_expr}, equals({n}));");
1537 }
1538 }
1539 }
1540 "count_min" => {
1541 if let Some(val) = &assertion.value {
1542 if let Some(n) = val.as_u64() {
1543 let length_expr = dart_length_expr(&field_accessor, assertion.field.as_deref(), field_resolver);
1544 let _ = writeln!(out, " expect({length_expr}, greaterThanOrEqualTo({n}));");
1545 }
1546 }
1547 }
1548 "matches_regex" => {
1549 if let Some(expected) = &assertion.value {
1550 let dart_val = format_value(expected);
1551 let _ = writeln!(out, " expect({field_accessor}, matches(RegExp({dart_val})));");
1552 }
1553 }
1554 "is_true" => {
1555 let _ = writeln!(out, " expect({field_accessor}, isTrue);");
1556 }
1557 "is_false" => {
1558 let _ = writeln!(out, " expect({field_accessor}, isFalse);");
1559 }
1560 "greater_than" => {
1561 if let Some(val) = &assertion.value {
1562 let dart_val = format_value(val);
1563 let _ = writeln!(out, " expect({field_accessor}, greaterThan({dart_val}));");
1564 }
1565 }
1566 "less_than" => {
1567 if let Some(val) = &assertion.value {
1568 let dart_val = format_value(val);
1569 let _ = writeln!(out, " expect({field_accessor}, lessThan({dart_val}));");
1570 }
1571 }
1572 "greater_than_or_equal" => {
1573 if let Some(val) = &assertion.value {
1574 let dart_val = format_value(val);
1575 let _ = writeln!(out, " expect({field_accessor}, greaterThanOrEqualTo({dart_val}));");
1576 }
1577 }
1578 "less_than_or_equal" => {
1579 if let Some(val) = &assertion.value {
1580 let dart_val = format_value(val);
1581 let _ = writeln!(out, " expect({field_accessor}, lessThanOrEqualTo({dart_val}));");
1582 }
1583 }
1584 "not_null" => {
1585 let _ = writeln!(out, " expect({field_accessor}, isNotNull);");
1586 }
1587 "not_error" => {
1588 }
1595 "error" => {
1596 }
1598 "method_result" => {
1599 if let Some(method) = &assertion.method {
1600 let dart_method = method.to_lower_camel_case();
1601 let check = assertion.check.as_deref().unwrap_or("not_null");
1602 let method_call = format!("{field_accessor}.{dart_method}()");
1603 match check {
1604 "equals" => {
1605 if let Some(expected) = &assertion.value {
1606 let dart_val = format_value(expected);
1607 let _ = writeln!(out, " expect({method_call}, equals({dart_val}));");
1608 }
1609 }
1610 "is_true" => {
1611 let _ = writeln!(out, " expect({method_call}, isTrue);");
1612 }
1613 "is_false" => {
1614 let _ = writeln!(out, " expect({method_call}, isFalse);");
1615 }
1616 "greater_than_or_equal" => {
1617 if let Some(val) = &assertion.value {
1618 let dart_val = format_value(val);
1619 let _ = writeln!(out, " expect({method_call}, greaterThanOrEqualTo({dart_val}));");
1620 }
1621 }
1622 "count_min" => {
1623 if let Some(val) = &assertion.value {
1624 if let Some(n) = val.as_u64() {
1625 let _ = writeln!(out, " expect({method_call}.length, greaterThanOrEqualTo({n}));");
1626 }
1627 }
1628 }
1629 _ => {
1630 let _ = writeln!(out, " expect({method_call}, isNotNull);");
1631 }
1632 }
1633 }
1634 }
1635 other => {
1636 let _ = writeln!(out, " // skipped: unknown assertion type '{other}'");
1637 }
1638 }
1639}
1640
1641fn render_streaming_assertion_dart(out: &mut String, assertion: &Assertion, result_var: &str) {
1652 match assertion.assertion_type.as_str() {
1653 "not_error" => {
1654 let _ = writeln!(out, " expect({result_var}, isNotNull);");
1658 }
1659 "count_min" if assertion.field.as_deref() == Some("chunks") => {
1660 if let Some(serde_json::Value::Number(n)) = &assertion.value {
1661 let _ = writeln!(out, " expect({result_var}.length, greaterThanOrEqualTo({n}));");
1662 }
1663 }
1664 "equals" if assertion.field.as_deref() == Some("stream_content") => {
1665 if let Some(serde_json::Value::String(expected)) = &assertion.value {
1666 let escaped = escape_dart(expected);
1667 let _ = writeln!(
1668 out,
1669 " final _content = {result_var}.map((c) => c.choices.firstOrNull?.delta.content ?? '').join();"
1670 );
1671 let _ = writeln!(out, " expect(_content, equals('{escaped}'));");
1672 }
1673 }
1674 other => {
1675 let _ = writeln!(out, " // skipped streaming assertion: '{other}'");
1676 }
1677 }
1678}
1679
1680fn snake_to_camel(s: &str) -> String {
1682 let mut result = String::with_capacity(s.len());
1683 let mut next_upper = false;
1684 for ch in s.chars() {
1685 if ch == '_' {
1686 next_upper = true;
1687 } else if next_upper {
1688 result.extend(ch.to_uppercase());
1689 next_upper = false;
1690 } else {
1691 result.push(ch);
1692 }
1693 }
1694 result
1695}
1696
1697fn field_to_dart_accessor(path: &str) -> String {
1710 let mut result = String::with_capacity(path.len());
1711 for (i, segment) in path.split('.').enumerate() {
1712 if i > 0 {
1713 result.push('.');
1714 }
1715 if let Some(bracket_pos) = segment.find('[') {
1721 let name = &segment[..bracket_pos];
1722 let bracket = &segment[bracket_pos..];
1723 result.push_str(&name.to_lower_camel_case());
1724 result.push('!');
1725 result.push_str(bracket);
1726 } else {
1727 result.push_str(&segment.to_lower_camel_case());
1728 }
1729 }
1730 result
1731}
1732
1733fn emit_extraction_config_dart(overrides: &serde_json::Map<String, serde_json::Value>) -> String {
1739 let mut field_overrides: std::collections::HashMap<String, String> = std::collections::HashMap::new();
1741 for (key, val) in overrides {
1742 let camel = snake_to_camel(key);
1743 let dart_val = match val {
1744 serde_json::Value::Bool(b) => {
1745 if *b {
1746 "true".to_string()
1747 } else {
1748 "false".to_string()
1749 }
1750 }
1751 serde_json::Value::Number(n) => n.to_string(),
1752 serde_json::Value::String(s) => format!("'{s}'"),
1753 _ => continue, };
1755 field_overrides.insert(camel, dart_val);
1756 }
1757
1758 let use_cache = field_overrides.remove("useCache").unwrap_or_else(|| "true".to_string());
1759 let enable_quality_processing = field_overrides
1760 .remove("enableQualityProcessing")
1761 .unwrap_or_else(|| "true".to_string());
1762 let force_ocr = field_overrides
1763 .remove("forceOcr")
1764 .unwrap_or_else(|| "false".to_string());
1765 let disable_ocr = field_overrides
1766 .remove("disableOcr")
1767 .unwrap_or_else(|| "false".to_string());
1768 let include_document_structure = field_overrides
1769 .remove("includeDocumentStructure")
1770 .unwrap_or_else(|| "false".to_string());
1771 let use_layout_for_markdown = field_overrides
1772 .remove("useLayoutForMarkdown")
1773 .unwrap_or_else(|| "false".to_string());
1774 let max_archive_depth = field_overrides
1775 .remove("maxArchiveDepth")
1776 .unwrap_or_else(|| "3".to_string());
1777
1778 format!(
1779 "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})"
1780 )
1781}
1782
1783struct DartTestClientRenderer {
1799 in_skip: Cell<bool>,
1802 is_redirect: Cell<bool>,
1805}
1806
1807impl DartTestClientRenderer {
1808 fn new(is_redirect: bool) -> Self {
1809 Self {
1810 in_skip: Cell::new(false),
1811 is_redirect: Cell::new(is_redirect),
1812 }
1813 }
1814}
1815
1816impl client::TestClientRenderer for DartTestClientRenderer {
1817 fn language_name(&self) -> &'static str {
1818 "dart"
1819 }
1820
1821 fn render_test_open(&self, out: &mut String, _fn_name: &str, description: &str, skip_reason: Option<&str>) {
1830 let escaped_desc = escape_dart(description);
1831 if let Some(reason) = skip_reason {
1832 let escaped_reason = escape_dart(reason);
1833 let _ = writeln!(out, " test('{escaped_desc}', () {{");
1834 let _ = writeln!(out, " markTestSkipped('{escaped_reason}');");
1835 let _ = writeln!(out, " }});");
1836 let _ = writeln!(out);
1837 self.in_skip.set(true);
1838 } else {
1839 let _ = writeln!(
1840 out,
1841 " test('{escaped_desc}', () => _serialized(() => _withRetry(() async {{"
1842 );
1843 self.in_skip.set(false);
1844 }
1845 }
1846
1847 fn render_test_close(&self, out: &mut String) {
1852 if self.in_skip.get() {
1853 return;
1855 }
1856 let _ = writeln!(out, " }})));");
1857 let _ = writeln!(out);
1858 }
1859
1860 fn render_call(&self, out: &mut String, ctx: &client::CallCtx<'_>) {
1870 const DART_RESTRICTED_HEADERS: &[&str] = &["content-length", "host", "transfer-encoding"];
1872
1873 let method = ctx.method.to_uppercase();
1874 let escaped_method = escape_dart(&method);
1875
1876 let fixture_path = escape_dart(ctx.path);
1878
1879 let has_explicit_content_type = ctx.headers.keys().any(|k| k.to_lowercase() == "content-type");
1881 let effective_content_type = if has_explicit_content_type {
1882 ctx.headers
1883 .iter()
1884 .find(|(k, _)| k.to_lowercase() == "content-type")
1885 .map(|(_, v)| v.as_str())
1886 .unwrap_or("application/json")
1887 } else if ctx.body.is_some() {
1888 ctx.content_type.unwrap_or("application/json")
1889 } else {
1890 ""
1891 };
1892
1893 let _ = writeln!(
1894 out,
1895 " final baseUrl = Platform.environment['MOCK_SERVER_URL'] ?? 'http://localhost:8080';"
1896 );
1897 let _ = writeln!(out, " final uri = Uri.parse('$baseUrl{fixture_path}');");
1898 let _ = writeln!(
1899 out,
1900 " final ioReq = await _httpClient.openUrl('{escaped_method}', uri);"
1901 );
1902
1903 if self.is_redirect.get() {
1906 let _ = writeln!(out, " ioReq.followRedirects = false;");
1907 }
1908
1909 if !effective_content_type.is_empty() {
1911 let escaped_ct = escape_dart(effective_content_type);
1912 let _ = writeln!(out, " ioReq.headers.set('content-type', '{escaped_ct}');");
1913 }
1914
1915 let mut header_pairs: Vec<(&String, &String)> = ctx.headers.iter().collect();
1917 header_pairs.sort_by_key(|(k, _)| k.as_str());
1918 for (name, value) in &header_pairs {
1919 if DART_RESTRICTED_HEADERS.contains(&name.to_lowercase().as_str()) {
1920 continue;
1921 }
1922 if name.to_lowercase() == "content-type" {
1923 continue; }
1925 let escaped_name = escape_dart(&name.to_lowercase());
1926 let escaped_value = escape_dart(value);
1927 let _ = writeln!(out, " ioReq.headers.set('{escaped_name}', '{escaped_value}');");
1928 }
1929
1930 if !ctx.cookies.is_empty() {
1932 let mut cookie_pairs: Vec<(&String, &String)> = ctx.cookies.iter().collect();
1933 cookie_pairs.sort_by_key(|(k, _)| k.as_str());
1934 let cookie_str: Vec<String> = cookie_pairs.iter().map(|(k, v)| format!("{k}={v}")).collect();
1935 let cookie_header = escape_dart(&cookie_str.join("; "));
1936 let _ = writeln!(out, " ioReq.headers.set('cookie', '{cookie_header}');");
1937 }
1938
1939 if let Some(body) = ctx.body {
1941 let json_str = serde_json::to_string(body).unwrap_or_default();
1942 let escaped = escape_dart(&json_str);
1943 let _ = writeln!(out, " final bodyBytes = utf8.encode('{escaped}');");
1944 let _ = writeln!(out, " ioReq.add(bodyBytes);");
1945 }
1946
1947 let _ = writeln!(out, " final ioResp = await ioReq.close();");
1948 if !self.is_redirect.get() {
1952 let _ = writeln!(out, " final bodyStr = await ioResp.transform(utf8.decoder).join();");
1953 };
1954 }
1955
1956 fn render_assert_status(&self, out: &mut String, _response_var: &str, status: u16) {
1957 let _ = writeln!(
1958 out,
1959 " expect(ioResp.statusCode, equals({status}), reason: 'status code mismatch');"
1960 );
1961 }
1962
1963 fn render_assert_header(&self, out: &mut String, _response_var: &str, name: &str, expected: &str) {
1966 let escaped_name = escape_dart(&name.to_lowercase());
1967 match expected {
1968 "<<present>>" => {
1969 let _ = writeln!(
1970 out,
1971 " expect(ioResp.headers.value('{escaped_name}'), isNotNull, reason: 'header {escaped_name} should be present');"
1972 );
1973 }
1974 "<<absent>>" => {
1975 let _ = writeln!(
1976 out,
1977 " expect(ioResp.headers.value('{escaped_name}'), isNull, reason: 'header {escaped_name} should be absent');"
1978 );
1979 }
1980 "<<uuid>>" => {
1981 let _ = writeln!(
1982 out,
1983 " 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');"
1984 );
1985 }
1986 exact => {
1987 let escaped_value = escape_dart(exact);
1988 let _ = writeln!(
1989 out,
1990 " expect(ioResp.headers.value('{escaped_name}'), contains('{escaped_value}'), reason: 'header {escaped_name} mismatch');"
1991 );
1992 }
1993 }
1994 }
1995
1996 fn render_assert_json_body(&self, out: &mut String, _response_var: &str, expected: &serde_json::Value) {
2001 match expected {
2002 serde_json::Value::Object(_) | serde_json::Value::Array(_) => {
2003 let json_str = serde_json::to_string(expected).unwrap_or_default();
2004 let escaped = escape_dart(&json_str);
2005 let _ = writeln!(out, " final bodyJson = jsonDecode(bodyStr);");
2006 let _ = writeln!(out, " final expectedJson = jsonDecode('{escaped}');");
2007 let _ = writeln!(
2008 out,
2009 " expect(bodyJson, equals(expectedJson), reason: 'body mismatch');"
2010 );
2011 }
2012 serde_json::Value::String(s) => {
2013 let escaped = escape_dart(s);
2014 let _ = writeln!(
2015 out,
2016 " expect(bodyStr.trim(), equals('{escaped}'), reason: 'body mismatch');"
2017 );
2018 }
2019 other => {
2020 let escaped = escape_dart(&other.to_string());
2021 let _ = writeln!(
2022 out,
2023 " expect(bodyStr.trim(), equals('{escaped}'), reason: 'body mismatch');"
2024 );
2025 }
2026 }
2027 }
2028
2029 fn render_assert_partial_body(&self, out: &mut String, _response_var: &str, expected: &serde_json::Value) {
2032 let _ = writeln!(
2033 out,
2034 " final partialJson = jsonDecode(bodyStr) as Map<String, dynamic>;"
2035 );
2036 if let Some(obj) = expected.as_object() {
2037 for (idx, (key, val)) in obj.iter().enumerate() {
2038 let escaped_key = escape_dart(key);
2039 let json_val = serde_json::to_string(val).unwrap_or_default();
2040 let escaped_val = escape_dart(&json_val);
2041 let _ = writeln!(out, " final _expectedField{idx} = jsonDecode('{escaped_val}');");
2044 let _ = writeln!(
2045 out,
2046 " expect(partialJson['{escaped_key}'], equals(_expectedField{idx}), reason: 'partial body field \\'{escaped_key}\\' mismatch');"
2047 );
2048 }
2049 }
2050 }
2051
2052 fn render_assert_validation_errors(
2054 &self,
2055 out: &mut String,
2056 _response_var: &str,
2057 errors: &[ValidationErrorExpectation],
2058 ) {
2059 let _ = writeln!(out, " final errBody = jsonDecode(bodyStr) as Map<String, dynamic>;");
2060 let _ = writeln!(out, " final errList = (errBody['errors'] ?? []) as List<dynamic>;");
2061 for ve in errors {
2062 let loc_dart: Vec<String> = ve.loc.iter().map(|s| format!("'{}'", escape_dart(s))).collect();
2063 let loc_str = loc_dart.join(", ");
2064 let escaped_msg = escape_dart(&ve.msg);
2065 let _ = writeln!(
2066 out,
2067 " 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}');"
2068 );
2069 }
2070 }
2071}
2072
2073fn render_http_test_case(out: &mut String, fixture: &Fixture, http: &HttpFixture) {
2080 if http.expected_response.status_code == 101 {
2082 let description = escape_dart(&fixture.description);
2083 let _ = writeln!(out, " test('{description}', () {{");
2084 let _ = writeln!(
2085 out,
2086 " markTestSkipped('Skipped: Dart HttpClient cannot handle 101 Switching Protocols responses');"
2087 );
2088 let _ = writeln!(out, " }});");
2089 let _ = writeln!(out);
2090 return;
2091 }
2092
2093 let is_redirect = http.expected_response.status_code / 100 == 3;
2097 client::http_call::render_http_test(out, &DartTestClientRenderer::new(is_redirect), fixture);
2098}
2099
2100fn mime_from_extension(path: &str) -> Option<&'static str> {
2105 let ext = path.rsplit('.').next()?;
2106 match ext.to_lowercase().as_str() {
2107 "docx" => Some("application/vnd.openxmlformats-officedocument.wordprocessingml.document"),
2108 "xlsx" => Some("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"),
2109 "pptx" => Some("application/vnd.openxmlformats-officedocument.presentationml.presentation"),
2110 "pdf" => Some("application/pdf"),
2111 "txt" | "text" => Some("text/plain"),
2112 "html" | "htm" => Some("text/html"),
2113 "json" => Some("application/json"),
2114 "xml" => Some("application/xml"),
2115 "csv" => Some("text/csv"),
2116 "md" | "markdown" => Some("text/markdown"),
2117 "png" => Some("image/png"),
2118 "jpg" | "jpeg" => Some("image/jpeg"),
2119 "gif" => Some("image/gif"),
2120 "zip" => Some("application/zip"),
2121 "odt" => Some("application/vnd.oasis.opendocument.text"),
2122 "ods" => Some("application/vnd.oasis.opendocument.spreadsheet"),
2123 "odp" => Some("application/vnd.oasis.opendocument.presentation"),
2124 "rtf" => Some("application/rtf"),
2125 "epub" => Some("application/epub+zip"),
2126 "msg" => Some("application/vnd.ms-outlook"),
2127 "eml" => Some("message/rfc822"),
2128 _ => None,
2129 }
2130}
2131
2132fn emit_dart_batch_item_array(arr: &serde_json::Value, elem_type: &str) -> String {
2139 let items: Vec<String> = arr
2140 .as_array()
2141 .map(|a| a.as_slice())
2142 .unwrap_or_default()
2143 .iter()
2144 .filter_map(|item| {
2145 let obj = item.as_object()?;
2146 match elem_type {
2147 "BatchBytesItem" => {
2148 let content_bytes = obj
2149 .get("content")
2150 .and_then(|v| v.as_array())
2151 .map(|arr| {
2152 let nums: Vec<String> =
2153 arr.iter().filter_map(|v| v.as_u64().map(|n| n.to_string())).collect();
2154 format!("Uint8List.fromList([{}])", nums.join(", "))
2155 })
2156 .unwrap_or_else(|| "Uint8List(0)".to_string());
2157 let mime_type = obj
2158 .get("mime_type")
2159 .and_then(|v| v.as_str())
2160 .unwrap_or("application/octet-stream");
2161 Some(format!(
2162 "BatchBytesItem(content: {content_bytes}, mimeType: '{}')",
2163 escape_dart(mime_type)
2164 ))
2165 }
2166 "BatchFileItem" => {
2167 let path = obj.get("path").and_then(|v| v.as_str()).unwrap_or("");
2168 Some(format!("BatchFileItem(path: '{}')", escape_dart(path)))
2169 }
2170 _ => None,
2171 }
2172 })
2173 .collect();
2174 format!("[{}]", items.join(", "))
2175}
2176
2177fn dart_stringy_aggregator_contains_assert(
2201 field: Option<&str>,
2202 result_var: &str,
2203 field_resolver: &crate::field_access::FieldResolver,
2204 dart_val: &str,
2205) -> Option<String> {
2206 use crate::field_access::StringyFieldKind;
2207 let field = field?;
2208 let resolved = field_resolver.resolve(field);
2209
2210 if resolved.contains('.') || resolved.contains('[') {
2212 return None;
2213 }
2214
2215 if !field_resolver.is_array(field) && !field_resolver.is_array(resolved) {
2218 return None;
2219 }
2220
2221 let array_accessor = field_resolver.accessor(field, "dart", result_var);
2222
2223 let root_type = field_resolver.dart_root_type().cloned();
2226 if let Some(elem_type) = field_resolver.dart_advance(root_type.as_deref(), resolved) {
2227 if let Some(stringy) = field_resolver.dart_stringy_fields(&elem_type) {
2228 if stringy.len() >= 2 {
2231 let mut texts_lines: Vec<String> = Vec::new();
2237 for sf in stringy {
2238 let call = sf.name.to_lower_camel_case();
2239 match sf.kind {
2240 StringyFieldKind::Plain => {
2241 texts_lines.push(format!(" texts.add(item.{call}.toString());"));
2242 }
2243 StringyFieldKind::Optional => {
2244 texts_lines.push(format!(
2245 " final v_{call} = item.{call};\n if (v_{call} != null) texts.add(v_{call}.toString());"
2246 ));
2247 }
2248 StringyFieldKind::Vec => {
2249 texts_lines.push(format!(
2250 " texts.addAll(item.{call}.map((e) => e.toString()));"
2251 ));
2252 }
2253 }
2254 }
2255 let texts_block = texts_lines.join("\n");
2256 return Some(format!(
2260 " expect({array_accessor}.where((item) {{\n final texts = <String>[];\n{texts_block}\n return texts.any((t) => t.toLowerCase().contains(({dart_val}).toString().toLowerCase()));\n }}).isEmpty, isFalse);"
2261 ));
2262 }
2263 }
2264 }
2265
2266 Some(format!(
2271 " expect({array_accessor}.where((item) => item.toString().toLowerCase().contains(({dart_val}).toString().toLowerCase())).isEmpty, isFalse);"
2272 ))
2273}
2274
2275fn dart_call_result_type(call_config: &alef_core::config::e2e::CallConfig) -> Option<String> {
2283 const LOOKUP_LANGS: &[&str] = &["c", "csharp", "java", "kotlin", "go", "php"];
2284 for lang in LOOKUP_LANGS {
2285 if let Some(o) = call_config.overrides.get(*lang)
2286 && let Some(rt) = o.result_type.as_deref()
2287 && !rt.is_empty()
2288 {
2289 return Some(rt.to_string());
2290 }
2291 }
2292 None
2293}
2294
2295pub(super) fn escape_dart(s: &str) -> String {
2297 s.replace('\\', "\\\\")
2298 .replace('\'', "\\'")
2299 .replace('\n', "\\n")
2300 .replace('\r', "\\r")
2301 .replace('\t', "\\t")
2302 .replace('$', "\\$")
2303}
2304
2305fn type_name_to_create_from_json_dart(type_name: &str) -> String {
2313 let mut snake = String::with_capacity(type_name.len() + 8);
2315 for (i, ch) in type_name.char_indices() {
2316 if ch.is_uppercase() {
2317 if i > 0 {
2318 snake.push('_');
2319 }
2320 snake.extend(ch.to_lowercase());
2321 } else {
2322 snake.push(ch);
2323 }
2324 }
2325 let rust_fn = format!("create_{snake}_from_json");
2328 rust_fn
2330 .split('_')
2331 .enumerate()
2332 .map(|(i, part)| {
2333 if i == 0 {
2334 part.to_string()
2335 } else {
2336 let mut chars = part.chars();
2337 match chars.next() {
2338 None => String::new(),
2339 Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
2340 }
2341 }
2342 })
2343 .collect::<Vec<_>>()
2344 .join("")
2345}
2346
2347fn build_dart_first_class_map(
2352 type_defs: &[alef_core::ir::TypeDef],
2353 enum_defs: &[alef_core::ir::EnumDef],
2354 e2e_config: &crate::config::E2eConfig,
2355) -> crate::field_access::DartFirstClassMap {
2356 use crate::field_access::{StringyField, StringyFieldKind};
2357 use alef_core::ir::TypeRef;
2358
2359 let mut field_types: std::collections::HashMap<String, std::collections::HashMap<String, String>> =
2360 std::collections::HashMap::new();
2361
2362 fn inner_named(ty: &TypeRef) -> Option<String> {
2363 match ty {
2364 TypeRef::Named(n) => Some(n.clone()),
2365 TypeRef::Optional(inner) | TypeRef::Vec(inner) => inner_named(inner),
2366 _ => None,
2367 }
2368 }
2369
2370 let enum_names: std::collections::HashSet<&str> = enum_defs.iter().map(|e| e.name.as_str()).collect();
2371 let classify_stringy = |ty: &TypeRef, field_optional: bool| -> Option<StringyFieldKind> {
2372 match ty {
2373 TypeRef::String => Some(if field_optional {
2374 StringyFieldKind::Optional
2375 } else {
2376 StringyFieldKind::Plain
2377 }),
2378 TypeRef::Named(name) if enum_names.contains(name.as_str()) => Some(if field_optional {
2379 StringyFieldKind::Optional
2380 } else {
2381 StringyFieldKind::Plain
2382 }),
2383 TypeRef::Optional(inner) => match inner.as_ref() {
2384 TypeRef::String => Some(StringyFieldKind::Optional),
2385 TypeRef::Named(name) if enum_names.contains(name.as_str()) => Some(StringyFieldKind::Optional),
2386 _ => None,
2387 },
2388 TypeRef::Vec(inner) => match inner.as_ref() {
2389 TypeRef::String => Some(StringyFieldKind::Vec),
2390 TypeRef::Named(name) if enum_names.contains(name.as_str()) => Some(StringyFieldKind::Vec),
2391 _ => None,
2392 },
2393 _ => None,
2394 }
2395 };
2396
2397 let mut stringy_fields_by_type: std::collections::HashMap<String, Vec<StringyField>> =
2398 std::collections::HashMap::new();
2399 for td in type_defs {
2400 let mut td_field_types: std::collections::HashMap<String, String> = std::collections::HashMap::new();
2401 let mut td_stringy: Vec<StringyField> = Vec::new();
2402 for f in &td.fields {
2403 if let Some(named) = inner_named(&f.ty) {
2404 td_field_types.insert(f.name.clone(), named);
2405 }
2406 if f.binding_excluded {
2407 continue;
2408 }
2409 if let Some(kind) = classify_stringy(&f.ty, f.optional) {
2410 td_stringy.push(StringyField {
2411 name: f.name.clone(),
2412 kind,
2413 });
2414 }
2415 }
2416 if !td_field_types.is_empty() {
2417 field_types.insert(td.name.clone(), td_field_types);
2418 }
2419 if !td_stringy.is_empty() {
2420 stringy_fields_by_type.insert(td.name.clone(), td_stringy);
2421 }
2422 }
2423
2424 let root_type = if e2e_config.result_fields.is_empty() {
2427 None
2428 } else {
2429 let matches: Vec<&alef_core::ir::TypeDef> = type_defs
2430 .iter()
2431 .filter(|td| {
2432 let names: std::collections::HashSet<&str> = td.fields.iter().map(|f| f.name.as_str()).collect();
2433 e2e_config.result_fields.iter().all(|rf| names.contains(rf.as_str()))
2434 })
2435 .collect();
2436 if matches.len() == 1 {
2437 Some(matches[0].name.clone())
2438 } else {
2439 None
2440 }
2441 };
2442
2443 crate::field_access::DartFirstClassMap {
2444 field_types,
2445 root_type,
2446 stringy_fields_by_type,
2447 }
2448}