1use crate::codegen::resolve_field;
8use crate::config::E2eConfig;
9use crate::escape::sanitize_filename;
10use crate::fixture::{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 std::cell::Cell;
17use std::fmt::Write as FmtWrite;
18use std::path::PathBuf;
19
20use super::E2eCodegen;
21use super::client;
22
23pub struct DartE2eCodegen;
25
26impl E2eCodegen for DartE2eCodegen {
27 fn generate(
28 &self,
29 groups: &[FixtureGroup],
30 e2e_config: &E2eConfig,
31 config: &ResolvedCrateConfig,
32 _type_defs: &[alef_core::ir::TypeDef],
33 ) -> Result<Vec<GeneratedFile>> {
34 let lang = self.language_name();
35 let output_base = PathBuf::from(e2e_config.effective_output()).join(lang);
36
37 let mut files = Vec::new();
38
39 let dart_pkg = e2e_config.resolve_package("dart");
41 let pkg_name = dart_pkg
42 .as_ref()
43 .and_then(|p| p.name.as_ref())
44 .cloned()
45 .unwrap_or_else(|| config.dart_pubspec_name());
46 let pkg_path = dart_pkg
47 .as_ref()
48 .and_then(|p| p.path.as_ref())
49 .cloned()
50 .unwrap_or_else(|| "../../packages/dart".to_string());
51 let pkg_version = dart_pkg
52 .as_ref()
53 .and_then(|p| p.version.as_ref())
54 .cloned()
55 .or_else(|| config.resolved_version())
56 .unwrap_or_else(|| "0.1.0".to_string());
57
58 files.push(GeneratedFile {
60 path: output_base.join("pubspec.yaml"),
61 content: render_pubspec(&pkg_name, &pkg_path, &pkg_version, e2e_config.dep_mode),
62 generated_header: false,
63 });
64
65 files.push(GeneratedFile {
68 path: output_base.join("dart_test.yaml"),
69 content: concat!(
70 "# Generated by alef — DO NOT EDIT.\n",
71 "# Run test files sequentially to avoid overwhelming the mock server with\n",
72 "# concurrent keep-alive connections.\n",
73 "concurrency: 1\n",
74 )
75 .to_string(),
76 generated_header: false,
77 });
78
79 let test_base = output_base.join("test");
80
81 let bridge_class = config.dart_bridge_class_name();
83
84 for group in groups {
85 let active: Vec<&Fixture> = group
86 .fixtures
87 .iter()
88 .filter(|f| super::should_include_fixture(f, lang, e2e_config))
89 .collect();
90
91 if active.is_empty() {
92 continue;
93 }
94
95 let filename = format!("{}_test.dart", sanitize_filename(&group.category));
96 let content = render_test_file(&group.category, &active, e2e_config, lang, &pkg_name, &bridge_class);
97 files.push(GeneratedFile {
98 path: test_base.join(filename),
99 content,
100 generated_header: true,
101 });
102 }
103
104 Ok(files)
105 }
106
107 fn language_name(&self) -> &'static str {
108 "dart"
109 }
110}
111
112fn render_pubspec(
117 pkg_name: &str,
118 pkg_path: &str,
119 pkg_version: &str,
120 dep_mode: crate::config::DependencyMode,
121) -> String {
122 let test_ver = pub_dev::TEST_PACKAGE;
123 let http_ver = pub_dev::HTTP_PACKAGE;
124
125 let dep_block = match dep_mode {
126 crate::config::DependencyMode::Registry => {
127 format!(" {pkg_name}: ^{pkg_version}")
128 }
129 crate::config::DependencyMode::Local => {
130 format!(" {pkg_name}:\n path: {pkg_path}")
131 }
132 };
133
134 format!(
135 r#"name: e2e_dart
136version: 0.1.0
137publish_to: none
138
139environment:
140 sdk: ">=3.0.0 <4.0.0"
141
142dependencies:
143{dep_block}
144
145dev_dependencies:
146 test: {test_ver}
147 http: {http_ver}
148"#
149 )
150}
151
152fn render_test_file(
153 category: &str,
154 fixtures: &[&Fixture],
155 e2e_config: &E2eConfig,
156 lang: &str,
157 pkg_name: &str,
158 bridge_class: &str,
159) -> String {
160 let mut out = String::new();
161 out.push_str(&hash::header(CommentStyle::DoubleSlash));
162
163 let has_http_fixtures = fixtures.iter().any(|f| f.is_http_test());
165
166 let has_batch_byte_items = fixtures.iter().any(|f| {
168 let call_config = e2e_config.resolve_call_for_fixture(f.call.as_deref(), &f.input);
169 call_config.args.iter().any(|a| {
170 a.element_type.as_deref() == Some("BatchBytesItem") && resolve_field(&f.input, &a.field).is_array()
171 })
172 });
173
174 let needs_chdir = fixtures.iter().any(|f| {
178 if f.is_http_test() {
179 return false;
180 }
181 let call_config = e2e_config.resolve_call_for_fixture(f.call.as_deref(), &f.input);
182 call_config
183 .args
184 .iter()
185 .any(|a| a.arg_type == "file_path" || a.arg_type == "bytes")
186 });
187
188 let has_handle_args = fixtures.iter().any(|f| {
191 if f.is_http_test() {
192 return false;
193 }
194 let call_config = e2e_config.resolve_call_for_fixture(f.call.as_deref(), &f.input);
195 call_config.args.iter().any(|a| a.arg_type == "handle")
196 });
197
198 let _ = writeln!(out, "import 'package:test/test.dart';");
199 let _ = writeln!(out, "import 'dart:io';");
200 if has_batch_byte_items {
201 let _ = writeln!(out, "import 'dart:typed_data';");
202 }
203 let _ = writeln!(out, "import 'package:{pkg_name}/{pkg_name}.dart';");
204 let _ = writeln!(
207 out,
208 "import 'package:{pkg_name}/src/{pkg_name}_bridge_generated/frb_generated.dart' show RustLib;"
209 );
210 if has_http_fixtures {
211 let _ = writeln!(out, "import 'dart:async';");
212 }
213 if has_http_fixtures || has_handle_args {
215 let _ = writeln!(out, "import 'dart:convert';");
216 }
217 let _ = writeln!(out);
218
219 if has_http_fixtures {
229 let _ = writeln!(out, "HttpClient _httpClient = HttpClient()..maxConnectionsPerHost = 1;");
230 let _ = writeln!(out);
231 let _ = writeln!(out, "var _lock = Future<void>.value();");
232 let _ = writeln!(out);
233 let _ = writeln!(out, "Future<T> _serialized<T>(Future<T> Function() fn) async {{");
234 let _ = writeln!(out, " final current = _lock;");
235 let _ = writeln!(out, " final next = Completer<void>();");
236 let _ = writeln!(out, " _lock = next.future;");
237 let _ = writeln!(out, " try {{");
238 let _ = writeln!(out, " await current;");
239 let _ = writeln!(out, " return await fn();");
240 let _ = writeln!(out, " }} finally {{");
241 let _ = writeln!(out, " next.complete();");
242 let _ = writeln!(out, " }}");
243 let _ = writeln!(out, "}}");
244 let _ = writeln!(out);
245 let _ = writeln!(out, "Future<T> _withRetry<T>(Future<T> Function() fn) async {{");
248 let _ = writeln!(out, " try {{");
249 let _ = writeln!(out, " return await fn();");
250 let _ = writeln!(out, " }} on SocketException {{");
251 let _ = writeln!(out, " _httpClient.close(force: true);");
252 let _ = writeln!(out, " _httpClient = HttpClient()..maxConnectionsPerHost = 1;");
253 let _ = writeln!(out, " return fn();");
254 let _ = writeln!(out, " }} on HttpException {{");
255 let _ = writeln!(out, " _httpClient.close(force: true);");
256 let _ = writeln!(out, " _httpClient = HttpClient()..maxConnectionsPerHost = 1;");
257 let _ = writeln!(out, " return fn();");
258 let _ = writeln!(out, " }}");
259 let _ = writeln!(out, "}}");
260 let _ = writeln!(out);
261 }
262
263 let _ = writeln!(out, "// E2e tests for category: {category}");
264 let _ = writeln!(out, "void main() {{");
265
266 let _ = writeln!(out, " setUpAll(() async {{");
273 let _ = writeln!(out, " await RustLib.init();");
274 if needs_chdir {
275 let test_docs_path = e2e_config.test_documents_relative_from(0);
276 let _ = writeln!(
277 out,
278 " final _testDocs = Platform.environment['FIXTURES_DIR'] ?? '{test_docs_path}';"
279 );
280 let _ = writeln!(out, " final _dir = Directory(_testDocs);");
281 let _ = writeln!(out, " if (_dir.existsSync()) Directory.current = _dir;");
282 }
283 let _ = writeln!(out, " }});");
284 let _ = writeln!(out);
285
286 if has_http_fixtures {
288 let _ = writeln!(out, " tearDownAll(() => _httpClient.close());");
289 let _ = writeln!(out);
290 }
291
292 for fixture in fixtures {
293 render_test_case(&mut out, fixture, e2e_config, lang, bridge_class);
294 }
295
296 let _ = writeln!(out, "}}");
297 out
298}
299
300fn render_test_case(out: &mut String, fixture: &Fixture, e2e_config: &E2eConfig, lang: &str, bridge_class: &str) {
301 if let Some(http) = &fixture.http {
303 render_http_test_case(out, fixture, http);
304 return;
305 }
306
307 let call_config = e2e_config.resolve_call_for_fixture(fixture.call.as_deref(), &fixture.input);
309 let call_overrides = call_config.overrides.get(lang);
310 let mut function_name = call_overrides
311 .and_then(|o| o.function.as_ref())
312 .cloned()
313 .unwrap_or_else(|| call_config.function.clone());
314 function_name = function_name
316 .split('_')
317 .enumerate()
318 .map(|(i, part)| {
319 if i == 0 {
320 part.to_string()
321 } else {
322 let mut chars = part.chars();
323 match chars.next() {
324 None => String::new(),
325 Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
326 }
327 }
328 })
329 .collect::<Vec<_>>()
330 .join("");
331 let result_var = &call_config.result_var;
332 let description = escape_dart(&fixture.description);
333 let fixture_id = &fixture.id;
334 let _is_async = call_overrides.and_then(|o| o.r#async).unwrap_or(call_config.r#async);
337
338 let expects_error = fixture.assertions.iter().any(|a| a.assertion_type == "error");
339
340 let file_path_for_mime: Option<&str> = call_config
348 .args
349 .iter()
350 .find(|a| a.arg_type == "file_path")
351 .and_then(|a| resolve_field(&fixture.input, &a.field).as_str());
352
353 let has_file_path_arg = call_config.args.iter().any(|a| a.arg_type == "file_path");
360 let caller_supplied_override = call_overrides.and_then(|o| o.function.as_ref()).is_some();
363 if has_file_path_arg && !caller_supplied_override {
364 function_name = match function_name.as_str() {
365 "extractFile" => "extractBytes".to_string(),
366 "extractFileSync" => "extractBytesSync".to_string(),
367 other => other.to_string(),
368 };
369 }
370
371 let mut setup_lines: Vec<String> = Vec::new();
374 let mut args = Vec::new();
375
376 for arg_def in &call_config.args {
377 match arg_def.arg_type.as_str() {
378 "mock_url" => {
379 let name = arg_def.name.clone();
380 if fixture.has_host_root_route() {
381 let env_key = format!("MOCK_SERVER_{}", fixture_id.to_uppercase());
382 setup_lines.push(format!(
383 r#"final {name} = Platform.environment["{env_key}"] ?? (Platform.environment["MOCK_SERVER_URL"]! + "/fixtures/{fixture_id}");"#
384 ));
385 } else {
386 setup_lines.push(format!(
387 r#"final {name} = "${{Platform.environment["MOCK_SERVER_URL"] ?? "http://localhost:8080"}}/fixtures/{fixture_id}";"#
388 ));
389 }
390 args.push(name);
391 continue;
392 }
393 "handle" => {
394 let name = arg_def.name.clone();
395 let field = arg_def.field.strip_prefix("input.").unwrap_or(&arg_def.field);
396 let config_value = fixture.input.get(field).cloned().unwrap_or(serde_json::Value::Null);
397 let create_fn = {
399 let mut chars = name.chars();
400 let pascal = match chars.next() {
401 None => String::new(),
402 Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
403 };
404 format!("create{pascal}")
405 };
406 if config_value.is_null()
407 || config_value.is_object() && config_value.as_object().is_some_and(|o| o.is_empty())
408 {
409 setup_lines.push(format!("final {name} = await {bridge_class}.{create_fn}(null);"));
410 } else {
411 let json_str = serde_json::to_string(&config_value).unwrap_or_default();
412 let config_var = format!("{name}Config");
413 setup_lines.push(format!(
414 "final {config_var} = CrawlConfig.fromJson(jsonDecode(r'{json_str}') as Map<String, dynamic>);"
415 ));
416 setup_lines.push(format!(
417 "final {name} = await {bridge_class}.{create_fn}({config_var});"
418 ));
419 }
420 args.push(name);
421 continue;
422 }
423 _ => {}
424 }
425
426 let arg_value = resolve_field(&fixture.input, &arg_def.field);
427 match arg_def.arg_type.as_str() {
428 "bytes" | "file_path" => {
429 if let serde_json::Value::String(file_path) = arg_value {
434 args.push(format!("File('{}').readAsBytesSync()", file_path));
435 }
436 }
437 "string" => {
438 match arg_value {
439 serde_json::Value::String(s) => {
440 args.push(format!("'{}'", escape_dart(s)));
441 }
442 serde_json::Value::Null
443 if arg_def.optional
444 && arg_def.name == "mime_type" =>
447 {
448 let inferred = file_path_for_mime
449 .and_then(mime_from_extension)
450 .unwrap_or("application/octet-stream");
451 args.push(format!("'{inferred}'"));
452 }
453 _ => {}
455 }
456 }
457 "json_object" => {
458 if let Some(elem_type) = &arg_def.element_type {
460 if (elem_type == "BatchBytesItem" || elem_type == "BatchFileItem") && arg_value.is_array() {
461 let dart_items = emit_dart_batch_item_array(arg_value, elem_type);
462 args.push(dart_items);
463 }
464 } else if arg_def.name == "config" {
465 if let serde_json::Value::Object(map) = &arg_value {
466 if !map.is_empty() {
470 args.push(emit_extraction_config_dart(map));
471 }
472 }
473 } else if arg_value.is_array() {
475 let json_str = serde_json::to_string(&arg_value).unwrap_or_default();
478 let var_name = arg_def.name.clone();
479 setup_lines.push(format!(
480 "final {var_name} = (jsonDecode(r'{json_str}') as List<dynamic>).cast<String>();"
481 ));
482 args.push(var_name);
483 }
484 }
485 _ => {}
486 }
487 }
488
489 let _ = writeln!(out, " test('{description}', () async {{");
493
494 let args_str = args.join(", ");
495 let receiver_class = call_overrides
496 .and_then(|o| o.class.as_ref())
497 .cloned()
498 .unwrap_or_else(|| bridge_class.to_string());
499
500 if expects_error && !setup_lines.is_empty() {
501 let _ = writeln!(out, " await expectLater(() async {{");
505 for line in &setup_lines {
506 let _ = writeln!(out, " {line}");
507 }
508 let _ = writeln!(out, " return {receiver_class}.{function_name}({args_str});");
509 let _ = writeln!(out, " }}(), throwsA(anything));");
510 } else if expects_error {
511 let _ = writeln!(
513 out,
514 " await expectLater({receiver_class}.{function_name}({args_str}), throwsA(anything));"
515 );
516 } else {
517 for line in &setup_lines {
518 let _ = writeln!(out, " {line}");
519 }
520 let _ = writeln!(
521 out,
522 " final {result_var} = await {receiver_class}.{function_name}({args_str});"
523 );
524 }
525
526 let _ = writeln!(out, " }});");
527 let _ = writeln!(out);
528}
529
530fn snake_to_camel(s: &str) -> String {
532 let mut result = String::with_capacity(s.len());
533 let mut next_upper = false;
534 for ch in s.chars() {
535 if ch == '_' {
536 next_upper = true;
537 } else if next_upper {
538 result.extend(ch.to_uppercase());
539 next_upper = false;
540 } else {
541 result.push(ch);
542 }
543 }
544 result
545}
546
547fn emit_extraction_config_dart(overrides: &serde_json::Map<String, serde_json::Value>) -> String {
553 let mut field_overrides: std::collections::HashMap<String, String> = std::collections::HashMap::new();
555 for (key, val) in overrides {
556 let camel = snake_to_camel(key);
557 let dart_val = match val {
558 serde_json::Value::Bool(b) => {
559 if *b {
560 "true".to_string()
561 } else {
562 "false".to_string()
563 }
564 }
565 serde_json::Value::Number(n) => n.to_string(),
566 serde_json::Value::String(s) => format!("'{s}'"),
567 _ => continue, };
569 field_overrides.insert(camel, dart_val);
570 }
571
572 let use_cache = field_overrides.remove("useCache").unwrap_or_else(|| "true".to_string());
573 let enable_quality_processing = field_overrides
574 .remove("enableQualityProcessing")
575 .unwrap_or_else(|| "true".to_string());
576 let force_ocr = field_overrides
577 .remove("forceOcr")
578 .unwrap_or_else(|| "false".to_string());
579 let disable_ocr = field_overrides
580 .remove("disableOcr")
581 .unwrap_or_else(|| "false".to_string());
582 let include_document_structure = field_overrides
583 .remove("includeDocumentStructure")
584 .unwrap_or_else(|| "false".to_string());
585 let max_archive_depth = field_overrides
586 .remove("maxArchiveDepth")
587 .unwrap_or_else(|| "3".to_string());
588
589 format!(
590 "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})"
591 )
592}
593
594struct DartTestClientRenderer {
610 in_skip: Cell<bool>,
613 is_redirect: Cell<bool>,
616}
617
618impl DartTestClientRenderer {
619 fn new(is_redirect: bool) -> Self {
620 Self {
621 in_skip: Cell::new(false),
622 is_redirect: Cell::new(is_redirect),
623 }
624 }
625}
626
627impl client::TestClientRenderer for DartTestClientRenderer {
628 fn language_name(&self) -> &'static str {
629 "dart"
630 }
631
632 fn render_test_open(&self, out: &mut String, _fn_name: &str, description: &str, skip_reason: Option<&str>) {
641 let escaped_desc = escape_dart(description);
642 if let Some(reason) = skip_reason {
643 let escaped_reason = escape_dart(reason);
644 let _ = writeln!(out, " test('{escaped_desc}', () {{");
645 let _ = writeln!(out, " markTestSkipped('{escaped_reason}');");
646 let _ = writeln!(out, " }});");
647 let _ = writeln!(out);
648 self.in_skip.set(true);
649 } else {
650 let _ = writeln!(
651 out,
652 " test('{escaped_desc}', () => _serialized(() => _withRetry(() async {{"
653 );
654 self.in_skip.set(false);
655 }
656 }
657
658 fn render_test_close(&self, out: &mut String) {
663 if self.in_skip.get() {
664 return;
666 }
667 let _ = writeln!(out, " }})));");
668 let _ = writeln!(out);
669 }
670
671 fn render_call(&self, out: &mut String, ctx: &client::CallCtx<'_>) {
681 const DART_RESTRICTED_HEADERS: &[&str] = &["content-length", "host", "transfer-encoding"];
683
684 let method = ctx.method.to_uppercase();
685 let escaped_method = escape_dart(&method);
686
687 let fixture_path = escape_dart(ctx.path);
689
690 let has_explicit_content_type = ctx.headers.keys().any(|k| k.to_lowercase() == "content-type");
692 let effective_content_type = if has_explicit_content_type {
693 ctx.headers
694 .iter()
695 .find(|(k, _)| k.to_lowercase() == "content-type")
696 .map(|(_, v)| v.as_str())
697 .unwrap_or("application/json")
698 } else if ctx.body.is_some() {
699 ctx.content_type.unwrap_or("application/json")
700 } else {
701 ""
702 };
703
704 let _ = writeln!(
705 out,
706 " final baseUrl = Platform.environment['MOCK_SERVER_URL'] ?? 'http://localhost:8080';"
707 );
708 let _ = writeln!(out, " final uri = Uri.parse('$baseUrl{fixture_path}');");
709 let _ = writeln!(
710 out,
711 " final ioReq = await _httpClient.openUrl('{escaped_method}', uri);"
712 );
713
714 if self.is_redirect.get() {
717 let _ = writeln!(out, " ioReq.followRedirects = false;");
718 }
719
720 if !effective_content_type.is_empty() {
722 let escaped_ct = escape_dart(effective_content_type);
723 let _ = writeln!(out, " ioReq.headers.set('content-type', '{escaped_ct}');");
724 }
725
726 let mut header_pairs: Vec<(&String, &String)> = ctx.headers.iter().collect();
728 header_pairs.sort_by_key(|(k, _)| k.as_str());
729 for (name, value) in &header_pairs {
730 if DART_RESTRICTED_HEADERS.contains(&name.to_lowercase().as_str()) {
731 continue;
732 }
733 if name.to_lowercase() == "content-type" {
734 continue; }
736 let escaped_name = escape_dart(&name.to_lowercase());
737 let escaped_value = escape_dart(value);
738 let _ = writeln!(out, " ioReq.headers.set('{escaped_name}', '{escaped_value}');");
739 }
740
741 if !ctx.cookies.is_empty() {
743 let mut cookie_pairs: Vec<(&String, &String)> = ctx.cookies.iter().collect();
744 cookie_pairs.sort_by_key(|(k, _)| k.as_str());
745 let cookie_str: Vec<String> = cookie_pairs.iter().map(|(k, v)| format!("{k}={v}")).collect();
746 let cookie_header = escape_dart(&cookie_str.join("; "));
747 let _ = writeln!(out, " ioReq.headers.set('cookie', '{cookie_header}');");
748 }
749
750 if let Some(body) = ctx.body {
752 let json_str = serde_json::to_string(body).unwrap_or_default();
753 let escaped = escape_dart(&json_str);
754 let _ = writeln!(out, " final bodyBytes = utf8.encode('{escaped}');");
755 let _ = writeln!(out, " ioReq.add(bodyBytes);");
756 }
757
758 let _ = writeln!(out, " final ioResp = await ioReq.close();");
759 if !self.is_redirect.get() {
763 let _ = writeln!(out, " final bodyStr = await ioResp.transform(utf8.decoder).join();");
764 };
765 }
766
767 fn render_assert_status(&self, out: &mut String, _response_var: &str, status: u16) {
768 let _ = writeln!(
769 out,
770 " expect(ioResp.statusCode, equals({status}), reason: 'status code mismatch');"
771 );
772 }
773
774 fn render_assert_header(&self, out: &mut String, _response_var: &str, name: &str, expected: &str) {
777 let escaped_name = escape_dart(&name.to_lowercase());
778 match expected {
779 "<<present>>" => {
780 let _ = writeln!(
781 out,
782 " expect(ioResp.headers.value('{escaped_name}'), isNotNull, reason: 'header {escaped_name} should be present');"
783 );
784 }
785 "<<absent>>" => {
786 let _ = writeln!(
787 out,
788 " expect(ioResp.headers.value('{escaped_name}'), isNull, reason: 'header {escaped_name} should be absent');"
789 );
790 }
791 "<<uuid>>" => {
792 let _ = writeln!(
793 out,
794 " 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');"
795 );
796 }
797 exact => {
798 let escaped_value = escape_dart(exact);
799 let _ = writeln!(
800 out,
801 " expect(ioResp.headers.value('{escaped_name}'), contains('{escaped_value}'), reason: 'header {escaped_name} mismatch');"
802 );
803 }
804 }
805 }
806
807 fn render_assert_json_body(&self, out: &mut String, _response_var: &str, expected: &serde_json::Value) {
812 match expected {
813 serde_json::Value::Object(_) | serde_json::Value::Array(_) => {
814 let json_str = serde_json::to_string(expected).unwrap_or_default();
815 let escaped = escape_dart(&json_str);
816 let _ = writeln!(out, " final bodyJson = jsonDecode(bodyStr);");
817 let _ = writeln!(out, " final expectedJson = jsonDecode('{escaped}');");
818 let _ = writeln!(
819 out,
820 " expect(bodyJson, equals(expectedJson), reason: 'body mismatch');"
821 );
822 }
823 serde_json::Value::String(s) => {
824 let escaped = escape_dart(s);
825 let _ = writeln!(
826 out,
827 " expect(bodyStr.trim(), equals('{escaped}'), reason: 'body mismatch');"
828 );
829 }
830 other => {
831 let escaped = escape_dart(&other.to_string());
832 let _ = writeln!(
833 out,
834 " expect(bodyStr.trim(), equals('{escaped}'), reason: 'body mismatch');"
835 );
836 }
837 }
838 }
839
840 fn render_assert_partial_body(&self, out: &mut String, _response_var: &str, expected: &serde_json::Value) {
843 let _ = writeln!(
844 out,
845 " final partialJson = jsonDecode(bodyStr) as Map<String, dynamic>;"
846 );
847 if let Some(obj) = expected.as_object() {
848 for (idx, (key, val)) in obj.iter().enumerate() {
849 let escaped_key = escape_dart(key);
850 let json_val = serde_json::to_string(val).unwrap_or_default();
851 let escaped_val = escape_dart(&json_val);
852 let _ = writeln!(out, " final _expectedField{idx} = jsonDecode('{escaped_val}');");
855 let _ = writeln!(
856 out,
857 " expect(partialJson['{escaped_key}'], equals(_expectedField{idx}), reason: 'partial body field \\'{escaped_key}\\' mismatch');"
858 );
859 }
860 }
861 }
862
863 fn render_assert_validation_errors(
865 &self,
866 out: &mut String,
867 _response_var: &str,
868 errors: &[ValidationErrorExpectation],
869 ) {
870 let _ = writeln!(out, " final errBody = jsonDecode(bodyStr) as Map<String, dynamic>;");
871 let _ = writeln!(out, " final errList = (errBody['errors'] ?? []) as List<dynamic>;");
872 for ve in errors {
873 let loc_dart: Vec<String> = ve.loc.iter().map(|s| format!("'{}'", escape_dart(s))).collect();
874 let loc_str = loc_dart.join(", ");
875 let escaped_msg = escape_dart(&ve.msg);
876 let _ = writeln!(
877 out,
878 " 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}');"
879 );
880 }
881 }
882}
883
884fn render_http_test_case(out: &mut String, fixture: &Fixture, http: &HttpFixture) {
891 if http.expected_response.status_code == 101 {
893 let description = escape_dart(&fixture.description);
894 let _ = writeln!(out, " test('{description}', () {{");
895 let _ = writeln!(
896 out,
897 " markTestSkipped('Skipped: Dart HttpClient cannot handle 101 Switching Protocols responses');"
898 );
899 let _ = writeln!(out, " }});");
900 let _ = writeln!(out);
901 return;
902 }
903
904 let is_redirect = http.expected_response.status_code / 100 == 3;
908 client::http_call::render_http_test(out, &DartTestClientRenderer::new(is_redirect), fixture);
909}
910
911fn mime_from_extension(path: &str) -> Option<&'static str> {
916 let ext = path.rsplit('.').next()?;
917 match ext.to_lowercase().as_str() {
918 "docx" => Some("application/vnd.openxmlformats-officedocument.wordprocessingml.document"),
919 "xlsx" => Some("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"),
920 "pptx" => Some("application/vnd.openxmlformats-officedocument.presentationml.presentation"),
921 "pdf" => Some("application/pdf"),
922 "txt" | "text" => Some("text/plain"),
923 "html" | "htm" => Some("text/html"),
924 "json" => Some("application/json"),
925 "xml" => Some("application/xml"),
926 "csv" => Some("text/csv"),
927 "md" | "markdown" => Some("text/markdown"),
928 "png" => Some("image/png"),
929 "jpg" | "jpeg" => Some("image/jpeg"),
930 "gif" => Some("image/gif"),
931 "zip" => Some("application/zip"),
932 "odt" => Some("application/vnd.oasis.opendocument.text"),
933 "ods" => Some("application/vnd.oasis.opendocument.spreadsheet"),
934 "odp" => Some("application/vnd.oasis.opendocument.presentation"),
935 "rtf" => Some("application/rtf"),
936 "epub" => Some("application/epub+zip"),
937 "msg" => Some("application/vnd.ms-outlook"),
938 "eml" => Some("message/rfc822"),
939 _ => None,
940 }
941}
942
943fn emit_dart_batch_item_array(arr: &serde_json::Value, elem_type: &str) -> String {
950 let items: Vec<String> = arr
951 .as_array()
952 .map(|a| a.as_slice())
953 .unwrap_or_default()
954 .iter()
955 .filter_map(|item| {
956 let obj = item.as_object()?;
957 match elem_type {
958 "BatchBytesItem" => {
959 let content_bytes = obj
960 .get("content")
961 .and_then(|v| v.as_array())
962 .map(|arr| {
963 let nums: Vec<String> =
964 arr.iter().filter_map(|v| v.as_u64().map(|n| n.to_string())).collect();
965 format!("Uint8List.fromList([{}])", nums.join(", "))
966 })
967 .unwrap_or_else(|| "Uint8List(0)".to_string());
968 let mime_type = obj
969 .get("mime_type")
970 .and_then(|v| v.as_str())
971 .unwrap_or("application/octet-stream");
972 Some(format!(
973 "BatchBytesItem(content: {content_bytes}, mimeType: '{}')",
974 escape_dart(mime_type)
975 ))
976 }
977 "BatchFileItem" => {
978 let path = obj.get("path").and_then(|v| v.as_str()).unwrap_or("");
979 Some(format!("BatchFileItem(path: '{}')", escape_dart(path)))
980 }
981 _ => None,
982 }
983 })
984 .collect();
985 format!("[{}]", items.join(", "))
986}
987
988fn escape_dart(s: &str) -> String {
990 s.replace('\\', "\\\\")
991 .replace('\'', "\\'")
992 .replace('\n', "\\n")
993 .replace('\r', "\\r")
994 .replace('\t', "\\t")
995 .replace('$', "\\$")
996}