Skip to main content

alef_e2e/codegen/
dart.rs

1//! Dart e2e test generator using package:test and package:http.
2//!
3//! Generates `e2e/dart/test/<category>_test.dart` files from JSON fixtures.
4//! HTTP fixtures hit the mock server at `MOCK_SERVER_URL/fixtures/<id>`.
5//! Non-HTTP fixtures without a dart-specific call override emit a skip stub.
6
7use crate::codegen::resolve_field;
8use crate::config::E2eConfig;
9use crate::escape::sanitize_filename;
10use crate::field_access::FieldResolver;
11use crate::fixture::{Assertion, Fixture, FixtureGroup, HttpFixture, ValidationErrorExpectation};
12use alef_core::backend::GeneratedFile;
13use alef_core::config::ResolvedCrateConfig;
14use alef_core::hash::{self, CommentStyle};
15use alef_core::template_versions::pub_dev;
16use anyhow::Result;
17use heck::ToLowerCamelCase;
18use std::cell::Cell;
19use std::fmt::Write as FmtWrite;
20use std::path::PathBuf;
21
22use super::E2eCodegen;
23use super::client;
24
25/// Dart e2e code generator.
26pub struct DartE2eCodegen;
27
28impl E2eCodegen for DartE2eCodegen {
29    fn generate(
30        &self,
31        groups: &[FixtureGroup],
32        e2e_config: &E2eConfig,
33        config: &ResolvedCrateConfig,
34        _type_defs: &[alef_core::ir::TypeDef],
35        _enums: &[alef_core::ir::EnumDef],
36    ) -> Result<Vec<GeneratedFile>> {
37        let lang = self.language_name();
38        let output_base = PathBuf::from(e2e_config.effective_output()).join(lang);
39
40        let mut files = Vec::new();
41
42        // Resolve package config.
43        let dart_pkg = e2e_config.resolve_package("dart");
44        let pkg_name = dart_pkg
45            .as_ref()
46            .and_then(|p| p.name.as_ref())
47            .cloned()
48            .unwrap_or_else(|| config.dart_pubspec_name());
49        let pkg_path = dart_pkg
50            .as_ref()
51            .and_then(|p| p.path.as_ref())
52            .cloned()
53            .unwrap_or_else(|| "../../packages/dart".to_string());
54        let pkg_version = dart_pkg
55            .as_ref()
56            .and_then(|p| p.version.as_ref())
57            .cloned()
58            .or_else(|| config.resolved_version())
59            .unwrap_or_else(|| "0.1.0".to_string());
60
61        // Generate pubspec.yaml with http dependency for HTTP client tests.
62        files.push(GeneratedFile {
63            path: output_base.join("pubspec.yaml"),
64            content: render_pubspec(&pkg_name, &pkg_path, &pkg_version, e2e_config.dep_mode),
65            generated_header: false,
66        });
67
68        // Generate dart_test.yaml to limit parallelism — the mock server uses keep-alive
69        // connections and gets overwhelmed when test files run in parallel.
70        files.push(GeneratedFile {
71            path: output_base.join("dart_test.yaml"),
72            content: concat!(
73                "# Generated by alef — DO NOT EDIT.\n",
74                "# Run test files sequentially to avoid overwhelming the mock server with\n",
75                "# concurrent keep-alive connections.\n",
76                "concurrency: 1\n",
77            )
78            .to_string(),
79            generated_header: false,
80        });
81
82        let test_base = output_base.join("test");
83
84        // One test file per fixture group.
85        let bridge_class = config.dart_bridge_class_name();
86
87        // FRB places its generated dart code under `lib/src/{module_name}_bridge_generated/`,
88        // where `module_name` is the snake_cased crate name (e.g. `html_to_markdown_rs`).
89        // This is independent of the pubspec `name` (which may be a short alias like `h2m`).
90        let frb_module_name = config.name.replace('-', "_");
91
92        // Methods declared as `stub_methods` in `[crates.dart]` cannot be bridged through
93        // FRB and have `unimplemented!()` bodies on the Rust side. Emitting e2e tests for
94        // these fixtures would result in `PanicException` at run-time. Filter them out
95        // here so the dart e2e suite mirrors the actual runtime surface of the binding.
96        let dart_stub_methods: std::collections::HashSet<String> = config
97            .dart
98            .as_ref()
99            .map(|d| d.stub_methods.iter().cloned().collect())
100            .unwrap_or_default();
101
102        for group in groups {
103            let active: Vec<&Fixture> = group
104                .fixtures
105                .iter()
106                .filter(|f| super::should_include_fixture(f, lang, e2e_config))
107                .filter(|f| {
108                    let call_config = e2e_config.resolve_call_for_fixture(
109                        f.call.as_deref(),
110                        &f.id,
111                        &f.resolved_category(),
112                        &f.tags,
113                        &f.input,
114                    );
115                    let resolved_function = call_config
116                        .overrides
117                        .get(lang)
118                        .and_then(|o| o.function.as_ref())
119                        .cloned()
120                        .unwrap_or_else(|| call_config.function.clone());
121                    !dart_stub_methods.contains(&resolved_function)
122                })
123                .collect();
124
125            if active.is_empty() {
126                continue;
127            }
128
129            let filename = format!("{}_test.dart", sanitize_filename(&group.category));
130            let content = render_test_file(
131                &group.category,
132                &active,
133                e2e_config,
134                lang,
135                &pkg_name,
136                &frb_module_name,
137                &bridge_class,
138            );
139            files.push(GeneratedFile {
140                path: test_base.join(filename),
141                content,
142                generated_header: true,
143            });
144        }
145
146        Ok(files)
147    }
148
149    fn language_name(&self) -> &'static str {
150        "dart"
151    }
152}
153
154// ---------------------------------------------------------------------------
155// Rendering
156// ---------------------------------------------------------------------------
157
158fn render_pubspec(
159    pkg_name: &str,
160    pkg_path: &str,
161    pkg_version: &str,
162    dep_mode: crate::config::DependencyMode,
163) -> String {
164    let test_ver = pub_dev::TEST_PACKAGE;
165    let http_ver = pub_dev::HTTP_PACKAGE;
166
167    let dep_block = match dep_mode {
168        crate::config::DependencyMode::Registry => {
169            // Only add ^ prefix if version doesn't already start with a constraint operator
170            let constraint = if pkg_version.starts_with('^')
171                || pkg_version.starts_with('~')
172                || pkg_version.starts_with('>')
173                || pkg_version.starts_with('<')
174                || pkg_version.starts_with('=')
175            {
176                pkg_version.to_string()
177            } else {
178                format!("^{pkg_version}")
179            };
180            format!("  {pkg_name}: {constraint}")
181        }
182        crate::config::DependencyMode::Local => {
183            format!("  {pkg_name}:\n    path: {pkg_path}")
184        }
185    };
186
187    let sdk = alef_core::template_versions::toolchain::DART_SDK_CONSTRAINT;
188    format!(
189        r#"name: e2e_dart
190version: 0.1.0
191publish_to: none
192
193environment:
194  sdk: "{sdk}"
195
196dependencies:
197{dep_block}
198
199dev_dependencies:
200  test: {test_ver}
201  http: {http_ver}
202"#
203    )
204}
205
206fn render_test_file(
207    category: &str,
208    fixtures: &[&Fixture],
209    e2e_config: &E2eConfig,
210    lang: &str,
211    pkg_name: &str,
212    frb_module_name: &str,
213    bridge_class: &str,
214) -> String {
215    let mut out = String::new();
216    out.push_str(&hash::header(CommentStyle::DoubleSlash));
217    // Suppress unused_local_variable: `final result = await api.method(...)` is
218    // emitted for every test case; tests that only check for absence of errors
219    // do not consume `result`, triggering this dart-analyze warning.
220    out.push_str("// ignore_for_file: unused_local_variable\n\n");
221
222    // Check if any fixture needs the http package (HTTP server tests).
223    let has_http_fixtures = fixtures.iter().any(|f| f.is_http_test());
224
225    // Check if any fixture needs Uint8List.fromList (batch item byte arrays).
226    let has_batch_byte_items = fixtures.iter().any(|f| {
227        let call_config =
228            e2e_config.resolve_call_for_fixture(f.call.as_deref(), &f.id, &f.resolved_category(), &f.tags, &f.input);
229        call_config.args.iter().any(|a| {
230            a.element_type.as_deref() == Some("BatchBytesItem") && resolve_field(&f.input, &a.field).is_array()
231        })
232    });
233
234    // Detect whether any fixture uses file_path or bytes args — if so, setUpAll must chdir
235    // to the test_documents directory so that relative paths like "docx/fake.docx" resolve.
236    // Mirrors the Ruby/Python conftest and Swift setUp patterns.
237    let needs_chdir = fixtures.iter().any(|f| {
238        if f.is_http_test() {
239            return false;
240        }
241        let call_config =
242            e2e_config.resolve_call_for_fixture(f.call.as_deref(), &f.id, &f.resolved_category(), &f.tags, &f.input);
243        call_config
244            .args
245            .iter()
246            .any(|a| a.arg_type == "file_path" || a.arg_type == "bytes")
247    });
248
249    // Detect whether any non-HTTP fixture uses a json_object arg that resolves to a JSON array —
250    // those are materialized via `jsonDecode` at test-run time and cast to `List<String>`.
251    // Handle args themselves no longer require `jsonDecode` since they construct the config via
252    // the FRB-generated `createCrawlConfigFromJson(json:)` helper which accepts the JSON string
253    // directly. The variable name is kept as `has_handle_args` for downstream stability.
254    let has_handle_args = fixtures.iter().any(|f| {
255        if f.is_http_test() {
256            return false;
257        }
258        let call_config =
259            e2e_config.resolve_call_for_fixture(f.call.as_deref(), &f.id, &f.resolved_category(), &f.tags, &f.input);
260        call_config
261            .args
262            .iter()
263            .any(|a| a.arg_type == "json_object" && super::resolve_field(&f.input, &a.field).is_array())
264    });
265
266    // Non-HTTP fixtures that build a mock-server URL still reference `Platform.environment`
267    // (from `dart:io`). This applies to `mock_url` args and to fixtures routed through a
268    // `client_factory` (per-call override or per-language override) that derives `_mockUrl`
269    // inline. Without this, the generated tests fail to compile with
270    // `Error: Undefined name 'Platform'`.
271    let lang_client_factory = e2e_config
272        .call
273        .overrides
274        .get(lang)
275        .and_then(|o| o.client_factory.as_deref())
276        .is_some();
277    let has_mock_url_refs = lang_client_factory
278        || fixtures.iter().any(|f| {
279            if f.is_http_test() {
280                return false;
281            }
282            let call_config = e2e_config.resolve_call_for_fixture(
283                f.call.as_deref(),
284                &f.id,
285                &f.resolved_category(),
286                &f.tags,
287                &f.input,
288            );
289            if call_config.args.iter().any(|a| a.arg_type == "mock_url") {
290                return true;
291            }
292            call_config
293                .overrides
294                .get(lang)
295                .and_then(|o| o.client_factory.as_deref())
296                .is_some()
297        });
298
299    let _ = writeln!(out, "import 'package:test/test.dart';");
300    // `dart:io` provides HttpClient/SocketException (HTTP fixtures), Platform/Directory
301    // (file-path/bytes fixtures requiring chdir), and Platform.environment (mock-url
302    // fixtures). Skip the import when none of these are in play — unconditional emission
303    // triggers `unused_import` warnings.
304    if has_http_fixtures || needs_chdir || has_mock_url_refs {
305        let _ = writeln!(out, "import 'dart:io';");
306    }
307    if has_batch_byte_items {
308        let _ = writeln!(out, "import 'dart:typed_data';");
309    }
310    let _ = writeln!(out, "import 'package:{pkg_name}/{pkg_name}.dart';");
311    // RustLib is the flutter_rust_bridge entrypoint; must be initialized before any FRB call.
312    // FRB places its generated dart sources under `lib/src/{module_name}_bridge_generated/`,
313    // where `module_name` is the snake_cased crate name (independent of the pubspec `name`,
314    // which may be a short alias like `h2m`). `RustLib` lives in `frb_generated.dart` and
315    // is not re-exported by the FRB barrel `lib.dart`, so we import it directly.
316    let _ = writeln!(
317        out,
318        "import 'package:{pkg_name}/src/{frb_module_name}_bridge_generated/frb_generated.dart' show RustLib;"
319    );
320    if has_http_fixtures {
321        let _ = writeln!(out, "import 'dart:async';");
322    }
323    // dart:convert provides jsonDecode for handle-arg engine construction and HTTP response parsing.
324    if has_http_fixtures || has_handle_args {
325        let _ = writeln!(out, "import 'dart:convert';");
326    }
327    let _ = writeln!(out);
328
329    // Emit file-level HTTP client and serialization mutex.
330    //
331    // The shared HttpClient reuses keep-alive connections to minimize TCP overhead.
332    // The mutex (_lock) ensures requests are serialized within the file so the
333    // connection pool is not exercised concurrently by dart:test's async runner.
334    //
335    // _withRetry wraps the entire request closure with one automatic retry on
336    // transient connection errors (keep-alive connections can be silently closed
337    // by the server just as the client tries to reuse them).
338    if has_http_fixtures {
339        let _ = writeln!(out, "HttpClient _httpClient = HttpClient()..maxConnectionsPerHost = 1;");
340        let _ = writeln!(out);
341        let _ = writeln!(out, "var _lock = Future<void>.value();");
342        let _ = writeln!(out);
343        let _ = writeln!(out, "Future<T> _serialized<T>(Future<T> Function() fn) async {{");
344        let _ = writeln!(out, "  final current = _lock;");
345        let _ = writeln!(out, "  final next = Completer<void>();");
346        let _ = writeln!(out, "  _lock = next.future;");
347        let _ = writeln!(out, "  try {{");
348        let _ = writeln!(out, "    await current;");
349        let _ = writeln!(out, "    return await fn();");
350        let _ = writeln!(out, "  }} finally {{");
351        let _ = writeln!(out, "    next.complete();");
352        let _ = writeln!(out, "  }}");
353        let _ = writeln!(out, "}}");
354        let _ = writeln!(out);
355        // The `fn` here should be the full request closure — on socket failure we
356        // recreate the HttpClient (drops old pooled connections) and retry once.
357        let _ = writeln!(out, "Future<T> _withRetry<T>(Future<T> Function() fn) async {{");
358        let _ = writeln!(out, "  try {{");
359        let _ = writeln!(out, "    return await fn();");
360        let _ = writeln!(out, "  }} on SocketException {{");
361        let _ = writeln!(out, "    _httpClient.close(force: true);");
362        let _ = writeln!(out, "    _httpClient = HttpClient()..maxConnectionsPerHost = 1;");
363        let _ = writeln!(out, "    return fn();");
364        let _ = writeln!(out, "  }} on HttpException {{");
365        let _ = writeln!(out, "    _httpClient.close(force: true);");
366        let _ = writeln!(out, "    _httpClient = HttpClient()..maxConnectionsPerHost = 1;");
367        let _ = writeln!(out, "    return fn();");
368        let _ = writeln!(out, "  }}");
369        let _ = writeln!(out, "}}");
370        let _ = writeln!(out);
371    }
372
373    let _ = writeln!(out, "// E2e tests for category: {category}");
374    let _ = writeln!(out);
375
376    // Emit a helper function to normalize enum values to their serde wire format.
377    // Dart enums' .toString() returns "EnumName.variant" but fixtures use serde wire format
378    // (e.g. "stop" for FinishReason.stop, "tool_calls" for FinishReason.toolCalls).
379    // This helper handles enum-to-wire conversion by calling .name (which gives the Dart
380    // variant name like "toolCalls") and converting back to snake_case for multi-word variants.
381    let _ = writeln!(out, "String _alefE2eText(Object? value) {{");
382    let _ = writeln!(out, "  if (value == null) return '';");
383    let _ = writeln!(
384        out,
385        "  // Check if it's an enum by examining its toString representation."
386    );
387    let _ = writeln!(out, "  final str = value.toString();");
388    let _ = writeln!(out, "  if (str.contains('.')) {{");
389    let _ = writeln!(
390        out,
391        "    // Enum.toString() returns 'EnumName.variantName'. Extract the variant name."
392    );
393    let _ = writeln!(out, "    final parts = str.split('.');");
394    let _ = writeln!(out, "    if (parts.length == 2) {{");
395    let _ = writeln!(out, "      final variantName = parts[1];");
396    let _ = writeln!(
397        out,
398        "      // Convert camelCase variant names to snake_case for serde compatibility."
399    );
400    let _ = writeln!(out, "      // E.g. 'toolCalls' -> 'tool_calls', 'stop' -> 'stop'.");
401    let _ = writeln!(out, "      return _camelToSnake(variantName);");
402    let _ = writeln!(out, "    }}");
403    let _ = writeln!(out, "  }}");
404    let _ = writeln!(out, "  return str;");
405    let _ = writeln!(out, "}}");
406    let _ = writeln!(out);
407
408    // Helper to convert camelCase to snake_case.
409    let _ = writeln!(out, "String _camelToSnake(String camel) {{");
410    let _ = writeln!(out, "  final buffer = StringBuffer();");
411    let _ = writeln!(out, "  for (int i = 0; i < camel.length; i++) {{");
412    let _ = writeln!(out, "    final char = camel[i];");
413    let _ = writeln!(out, "    if (char.contains(RegExp(r'[A-Z]'))) {{");
414    let _ = writeln!(out, "      if (i > 0) buffer.write('_');");
415    let _ = writeln!(out, "      buffer.write(char.toLowerCase());");
416    let _ = writeln!(out, "    }} else {{");
417    let _ = writeln!(out, "      buffer.write(char);");
418    let _ = writeln!(out, "    }}");
419    let _ = writeln!(out, "  }}");
420    let _ = writeln!(out, "  return buffer.toString();");
421    let _ = writeln!(out, "}}");
422    let _ = writeln!(out);
423
424    let _ = writeln!(out, "void main() {{");
425
426    // Emit setUpAll to initialize the flutter_rust_bridge before any test runs and,
427    // when fixtures load files by path, chdir to test_documents so that relative
428    // paths like "docx/fake.docx" resolve correctly.
429    //
430    // The test_documents directory lives two levels above e2e/dart/ (at the repo root).
431    // The FIXTURES_DIR environment variable can override this for CI environments.
432    let _ = writeln!(out, "  setUpAll(() async {{");
433    let _ = writeln!(out, "    await RustLib.init();");
434    if needs_chdir {
435        let test_docs_path = e2e_config.test_documents_relative_from(0);
436        let _ = writeln!(
437            out,
438            "    final _testDocs = Platform.environment['FIXTURES_DIR'] ?? '{test_docs_path}';"
439        );
440        let _ = writeln!(out, "    final _dir = Directory(_testDocs);");
441        let _ = writeln!(out, "    if (_dir.existsSync()) Directory.current = _dir;");
442    }
443    let _ = writeln!(out, "  }});");
444    let _ = writeln!(out);
445
446    // Close the shared client after all tests in this file complete.
447    if has_http_fixtures {
448        let _ = writeln!(out, "  tearDownAll(() => _httpClient.close());");
449        let _ = writeln!(out);
450    }
451
452    for fixture in fixtures {
453        render_test_case(&mut out, fixture, e2e_config, lang, bridge_class);
454    }
455
456    let _ = writeln!(out, "}}");
457    out
458}
459
460fn render_test_case(out: &mut String, fixture: &Fixture, e2e_config: &E2eConfig, lang: &str, bridge_class: &str) {
461    // HTTP fixtures: hit the mock server.
462    if let Some(http) = &fixture.http {
463        render_http_test_case(out, fixture, http);
464        return;
465    }
466
467    // Non-HTTP fixtures: render a call-based test using the resolved call config.
468    let call_config = e2e_config.resolve_call_for_fixture(
469        fixture.call.as_deref(),
470        &fixture.id,
471        &fixture.resolved_category(),
472        &fixture.tags,
473        &fixture.input,
474    );
475    // Build per-call field resolver using the effective field sets for this call.
476    let call_field_resolver = FieldResolver::new(
477        e2e_config.effective_fields(call_config),
478        e2e_config.effective_fields_optional(call_config),
479        e2e_config.effective_result_fields(call_config),
480        e2e_config.effective_fields_array(call_config),
481        e2e_config.effective_fields_method_calls(call_config),
482    );
483    let field_resolver = &call_field_resolver;
484    let enum_fields_base = e2e_config.effective_fields_enum(call_config);
485
486    // Merge per-language enum_fields from the Dart override into the effective enum set so that
487    // fields like "status" (BatchStatus on BatchObject) are treated as enum-typed
488    // even when they are not globally listed in fields_enum (they are context-
489    // dependent — BatchStatus on BatchObject but plain String on ResponseObject).
490    let effective_enum_fields: std::collections::HashSet<String> = {
491        let dart_overrides = call_config.overrides.get("dart");
492        if let Some(overrides) = dart_overrides {
493            let mut merged = enum_fields_base.clone();
494            merged.extend(overrides.enum_fields.keys().cloned());
495            merged
496        } else {
497            enum_fields_base.clone()
498        }
499    };
500    let enum_fields = &effective_enum_fields;
501    let call_overrides = call_config.overrides.get(lang);
502    let mut function_name = call_overrides
503        .and_then(|o| o.function.as_ref())
504        .cloned()
505        .unwrap_or_else(|| call_config.function.clone());
506    // Convert snake_case function names to camelCase for Dart conventions.
507    function_name = function_name
508        .split('_')
509        .enumerate()
510        .map(|(i, part)| {
511            if i == 0 {
512                part.to_string()
513            } else {
514                let mut chars = part.chars();
515                match chars.next() {
516                    None => String::new(),
517                    Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
518                }
519            }
520        })
521        .collect::<Vec<_>>()
522        .join("");
523    let result_var = &call_config.result_var;
524    let description = escape_dart(&fixture.description);
525    let fixture_id = &fixture.id;
526    // `is_async` retained for future use (e.g. non-FRB backends); unused with FRB since
527    // all wrappers return Future<T>.
528    let _is_async = call_overrides.and_then(|o| o.r#async).unwrap_or(call_config.r#async);
529
530    let expects_error = fixture.assertions.iter().any(|a| a.assertion_type == "error");
531    let is_streaming = crate::codegen::streaming_assertions::resolve_is_streaming(fixture, call_config.streaming);
532    // `result_is_simple = true` means the dart return is a scalar/bytes value
533    // (e.g. `Uint8List` for speech/file_content), not a struct. Field-based
534    // assertions like `audio.not_empty` collapse to whole-result checks so we
535    // don't emit `result.audio` against a `Uint8List` receiver.
536    let result_is_simple = call_overrides.is_some_and(|o| o.result_is_simple) || call_config.result_is_simple;
537
538    // Resolve options_type and options_via from per-fixture → per-call → default.
539    // These drive how `json_object` args are constructed:
540    //   options_via = "from_json" — call `createTypeNameFromJson(json: r'...')` bridge
541    //                               helper and pass the result as a named parameter `req:`.
542    //   All other values (or absent) — existing behaviour (batch arrays, config objects,
543    //   generic JSON arrays, or nothing).
544    let options_type: Option<&str> = call_overrides.and_then(|o| o.options_type.as_deref());
545    let options_via: &str = call_overrides
546        .and_then(|o| o.options_via.as_deref())
547        .unwrap_or("kwargs");
548
549    // Build argument list from fixture.input and call_config.args.
550    // Use `resolve_field` (respects the `field` path like "input.data") rather than
551    // looking up by `arg_def.name` directly — the name and the field key may differ.
552    //
553    // For `extract_file_sync` / `extract_file` fixtures that omit `mime_type`,
554    // derive the MIME from the path extension so `extractBytesSync`/`extractBytes`
555    // can be called (both require an explicit MIME type).
556    let file_path_for_mime: Option<&str> = call_config
557        .args
558        .iter()
559        .find(|a| a.arg_type == "file_path")
560        .and_then(|a| resolve_field(&fixture.input, &a.field).as_str());
561
562    // Detect whether this call converts a file_path arg to bytes at test-run time.
563    // Dart cannot pass OS-level file paths through the FRB bridge — the idiomatic API
564    // is always bytes. When a file_path arg is present (and no caller-supplied dart
565    // function override has already been applied), remap the function name:
566    //   extractFile      → extractBytes
567    //   extractFileSync  → extractBytesSync
568    let has_file_path_arg = call_config.args.iter().any(|a| a.arg_type == "file_path");
569    // Apply the remap only when no per-fixture dart override has already specified the
570    // function — if the fixture author set a dart-specific function name we trust it.
571    let caller_supplied_override = call_overrides.and_then(|o| o.function.as_ref()).is_some();
572    if has_file_path_arg && !caller_supplied_override {
573        function_name = match function_name.as_str() {
574            "extractFile" => "extractBytes".to_string(),
575            "extractFileSync" => "extractBytesSync".to_string(),
576            other => other.to_string(),
577        };
578    }
579
580    // Resolve client_factory early so the per-arg builders below can pick the
581    // calling convention. When `client_factory` is set the test calls methods on
582    // an FRB-generated client instance (e.g. liter-llm's `retrieveFile`), and FRB
583    // emits every non-`config` parameter as a Dart named-required parameter. When
584    // unset the call routes through a hand-written facade whose required args are
585    // positional. See the `"string"` arg handler below.
586    let client_factory_for_args: Option<&str> =
587        call_overrides.and_then(|o| o.client_factory.as_deref()).or_else(|| {
588            e2e_config
589                .call
590                .overrides
591                .get(lang)
592                .and_then(|o| o.client_factory.as_deref())
593        });
594
595    // setup_lines holds per-test statements that must precede the main call:
596    // engine construction (handle args) and URL building (mock_url args).
597    let mut setup_lines: Vec<String> = Vec::new();
598    let mut args = Vec::new();
599
600    for arg_def in &call_config.args {
601        match arg_def.arg_type.as_str() {
602            "mock_url" => {
603                let name = arg_def.name.clone();
604                if fixture.has_host_root_route() {
605                    let env_key = format!("MOCK_SERVER_{}", fixture_id.to_uppercase());
606                    setup_lines.push(format!(
607                        r#"final {name} = Platform.environment["{env_key}"] ?? (Platform.environment["MOCK_SERVER_URL"]! + "/fixtures/{fixture_id}");"#
608                    ));
609                } else {
610                    setup_lines.push(format!(
611                        r#"final {name} = "${{Platform.environment["MOCK_SERVER_URL"] ?? "http://localhost:8080"}}/fixtures/{fixture_id}";"#
612                    ));
613                }
614                args.push(name);
615                continue;
616            }
617            "handle" => {
618                let name = arg_def.name.clone();
619                let field = arg_def.field.strip_prefix("input.").unwrap_or(&arg_def.field);
620                let config_value = fixture.input.get(field).cloned().unwrap_or(serde_json::Value::Null);
621                // Derive the create-function name: "engine" → "createEngine".
622                let create_fn = {
623                    let mut chars = name.chars();
624                    let pascal = match chars.next() {
625                        None => String::new(),
626                        Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
627                    };
628                    format!("create{pascal}")
629                };
630                if config_value.is_null()
631                    || config_value.is_object() && config_value.as_object().is_some_and(|o| o.is_empty())
632                {
633                    setup_lines.push(format!("final {name} = await {bridge_class}.{create_fn}();"));
634                } else {
635                    let json_str = serde_json::to_string(&config_value).unwrap_or_default();
636                    let config_var = format!("{name}Config");
637                    // FRB-generated free function: `createCrawlConfigFromJson(json: '...')` — async,
638                    // deserializes the JSON into the mirror struct via the Rust `create_<type>_from_json`
639                    // helper emitted by the dart backend. This avoids relying on a Dart-side `fromJson`
640                    // constructor (FRB classes don't expose one).
641                    setup_lines.push(format!(
642                        "final {config_var} = await createCrawlConfigFromJson(json: r'{json_str}');"
643                    ));
644                    // Facade exposes `createEngine` with a named `config:` parameter — call it that way.
645                    setup_lines.push(format!(
646                        "final {name} = await {bridge_class}.{create_fn}(config: {config_var});"
647                    ));
648                }
649                args.push(name);
650                continue;
651            }
652            _ => {}
653        }
654
655        let arg_value = resolve_field(&fixture.input, &arg_def.field);
656        match arg_def.arg_type.as_str() {
657            "bytes" | "file_path" => {
658                // `bytes`: value is a file path string; load file contents at test-run time.
659                // `file_path`: also loaded as bytes for dart — extractBytes/extractBytesSync is
660                // the idiomatic Dart API since the Dart runtime cannot pass OS-level file paths
661                // through the FFI bridge.
662                if let serde_json::Value::String(file_path) = arg_value {
663                    args.push(format!("File('{}').readAsBytesSync()", file_path));
664                }
665            }
666            "string" => {
667                // Polyglot repos expose their Dart surface through a hand-written facade
668                // (e.g. `H2mBridge.convert(String html, {ConversionOptions? options})`,
669                // `TreeSitterLanguagePackBridge.process(String source, ProcessConfig config)`,
670                // `KreuzbergBridge.extractBytes(Uint8List content, String mimeType, [ExtractionConfig? config])`)
671                // that wraps the FRB-generated bridge methods. Those facades follow the
672                // Rust idiom: required args are positional, optional args are named with
673                // defaults. The "always emit named" heuristic targets the raw FRB bridge
674                // call site but breaks every hand-written facade.
675                //
676                // Mirror the policy used by the `json_object` handler below: required →
677                // positional, optional → named. Liter-llm's `chat`/`embed` calls are
678                // unaffected because they route through the `from_json` path (which
679                // always emits `req:` named) and the `client_factory` path (which
680                // hardcodes its own arg shape).
681                let dart_param_name = snake_to_camel(&arg_def.name);
682                match arg_value {
683                    serde_json::Value::String(s) => {
684                        let literal = format!("'{}'", escape_dart(s));
685                        // FRB-generated client methods (the `client_factory` path, e.g.
686                        // liter-llm's `retrieveFile({required String fileId})`) declare
687                        // every non-`config` parameter as named-required, so required
688                        // string args must be passed with a `name:` label too. Facade
689                        // methods (no `client_factory`) keep required args positional.
690                        if arg_def.optional || client_factory_for_args.is_some() {
691                            args.push(format!("{dart_param_name}: {literal}"));
692                        } else {
693                            args.push(literal);
694                        }
695                    }
696                    serde_json::Value::Null
697                        if arg_def.optional
698                        // Optional string absent from fixture — try to infer MIME from path
699                        // when the arg name looks like a MIME-type parameter.
700                        && arg_def.name == "mime_type" =>
701                    {
702                        let inferred = file_path_for_mime
703                            .and_then(mime_from_extension)
704                            .unwrap_or("application/octet-stream");
705                        args.push(format!("{dart_param_name}: '{inferred}'"));
706                    }
707                    // Other optional strings with null value are omitted.
708                    _ => {}
709                }
710            }
711            "json_object" => {
712                // Handle batch item arrays (BatchBytesItem / BatchFileItem).
713                if let Some(elem_type) = &arg_def.element_type {
714                    if (elem_type == "BatchBytesItem" || elem_type == "BatchFileItem") && arg_value.is_array() {
715                        let dart_items = emit_dart_batch_item_array(arg_value, elem_type);
716                        args.push(dart_items);
717                    } else if elem_type == "String" && arg_value.is_array() {
718                        // Scalar string array (e.g. `texts: ["a", "b"]` for embed_texts).
719                        // The `KreuzbergBridge` facade declares these parameters as required
720                        // positional (e.g. `embedTexts(List<String> texts, EmbeddingConfig config)`),
721                        // so the list literal must be passed positionally — matching the
722                        // facade contract rather than the underlying FRB bridge's named-arg
723                        // convention.
724                        let items: Vec<String> = arg_value
725                            .as_array()
726                            .unwrap()
727                            .iter()
728                            .filter_map(|v| v.as_str())
729                            .map(|s| format!("'{}'", escape_dart(s)))
730                            .collect();
731                        args.push(format!("<String>[{}]", items.join(", ")));
732                    }
733                } else if options_via == "from_json" {
734                    // `from_json` path: construct a typed mirror-struct via the generated
735                    // `create<TypeName>FromJson(json: '...')` bridge helper, then pass it
736                    // as the named FRB parameter `req: _var`.
737                    //
738                    // The helper is generated by `emit_from_json_fn` in the dart bridge-crate
739                    // generator and made available as a top-level function via the exported
740                    // `liter_llm_bridge_generated/lib.dart`. The parameter name used in the
741                    // bridge method call is always `req:` for single-request-object methods
742                    // (derived from the Rust IR param name).
743                    if let Some(opts_type) = options_type {
744                        if !arg_value.is_null() {
745                            let json_str = serde_json::to_string(&arg_value).unwrap_or_default();
746                            // Escape for Dart single-quoted string literal (handles embedded quotes,
747                            // backslashes, and interpolation markers).
748                            let escaped_json = escape_dart(&json_str);
749                            let var_name = format!("_{}", arg_def.name);
750                            let dart_fn = type_name_to_create_from_json_dart(opts_type);
751                            setup_lines.push(format!("final {var_name} = await {dart_fn}(json: '{escaped_json}');"));
752                            // FRB bridge method param name is `req` for all single-request methods.
753                            // Use `req:` as the named argument label.
754                            args.push(format!("req: {var_name}"));
755                        }
756                    }
757                } else if arg_def.name == "config" {
758                    if let serde_json::Value::Object(map) = &arg_value {
759                        if !map.is_empty() {
760                            // When the call override specifies a non-default `options_type`
761                            // (e.g. `EmbeddingConfig` for `embed_texts`), or the override map
762                            // contains a non-scalar field that the literal `ExtractionConfig`
763                            // constructor cannot express (e.g. `output_format: "markdown"` is
764                            // a tagged enum, not a plain string), fall back to the
765                            // FRB-generated `create<Type>FromJson(json: '...')` helper which
766                            // round-trips the JSON through serde and so preserves enum tags,
767                            // nested configs, and string-valued enum variants verbatim.
768                            let explicit_options =
769                                options_type.is_some_and(|t| t != "ExtractionConfig" && t != "FileExtractionConfig");
770                            let has_non_scalar = map.values().any(|v| {
771                                matches!(
772                                    v,
773                                    serde_json::Value::String(_)
774                                        | serde_json::Value::Object(_)
775                                        | serde_json::Value::Array(_)
776                                )
777                            });
778                            if explicit_options || has_non_scalar {
779                                let opts_type = options_type.unwrap_or("ExtractionConfig");
780                                let json_str = serde_json::to_string(&arg_value).unwrap_or_default();
781                                let escaped_json = escape_dart(&json_str);
782                                let var_name = format!("_{}", arg_def.name);
783                                let dart_fn = type_name_to_create_from_json_dart(opts_type);
784                                setup_lines
785                                    .push(format!("final {var_name} = await {dart_fn}(json: '{escaped_json}');"));
786                                args.push(var_name);
787                            } else {
788                                // Fixture provides scalar-only overrides — build an
789                                // `ExtractionConfig` constructor literal with defaults,
790                                // overriding only the bool/int fields present in the
791                                // fixture JSON. Handles configs such as
792                                // {force_ocr:true, disable_ocr:true} that toggle error paths.
793                                args.push(emit_extraction_config_dart(map));
794                            }
795                        } else {
796                            // Empty config object: construct a default instance via FRB's
797                            // `create<Type>FromJson(json: '{}')` helper (supports all
798                            // config types, not just ExtractionConfig). This ensures the
799                            // call signature matches the binding, which expects a required
800                            // config parameter even when all fields use their defaults.
801                            if let Some(opts_type) = options_type {
802                                let var_name = format!("_{}", arg_def.name);
803                                let dart_fn = type_name_to_create_from_json_dart(opts_type);
804                                setup_lines.push(format!("final {var_name} = await {dart_fn}(json: '{{}}');"));
805                                args.push(var_name);
806                            }
807                        }
808                    }
809                    // If config is null/absent, the wrapper supplies the default ExtractionConfig.
810                } else if arg_value.is_array() {
811                    // Generic JSON array (e.g. batch_urls: ["/page1", "/page2"]).
812                    // Decode via jsonDecode and cast to List<String> at test-run time.
813                    let json_str = serde_json::to_string(&arg_value).unwrap_or_default();
814                    let var_name = arg_def.name.clone();
815                    setup_lines.push(format!(
816                        "final {var_name} = (jsonDecode(r'{json_str}') as List<dynamic>).cast<String>();"
817                    ));
818                    args.push(var_name);
819                } else if let serde_json::Value::Object(map) = &arg_value {
820                    // Generic options-style json_object arg (e.g. h2m's
821                    // `options: ConversionOptions` on `convert(html, options)`). When the
822                    // fixture provides input.options and the call config declares an
823                    // `options_type`, build the mirror struct via the FRB-generated
824                    // `create<OptionsType>FromJson(json: '...')` helper. Use the arg's
825                    // original name (e.g. `options`) as the named parameter label.
826                    //
827                    // When the fixture also carries a visitor spec, swap to the
828                    // `create<OptionsType>FromJsonWithVisitor(json, visitor)` helper
829                    // (emitted by `alef-backend-dart` for trait bridges with `type_alias`
830                    // + `options_field` binding). The `_visitor` variable is materialised
831                    // in the visitor block below — its setup line is inserted ahead of
832                    // this options call by `build_dart_visitor`.
833                    if !map.is_empty() {
834                        if let Some(opts_type) = options_type {
835                            let json_str = serde_json::to_string(&arg_value).unwrap_or_default();
836                            let escaped_json = escape_dart(&json_str);
837                            let dart_param_name = snake_to_camel(&arg_def.name);
838                            let var_name = format!("_{}", arg_def.name);
839                            let dart_fn = type_name_to_create_from_json_dart(opts_type);
840                            if fixture.visitor.is_some() {
841                                setup_lines.push(format!(
842                                    "final {var_name} = await {dart_fn}WithVisitor(json: '{escaped_json}', visitor: _visitor);"
843                                ));
844                            } else {
845                                setup_lines
846                                    .push(format!("final {var_name} = await {dart_fn}(json: '{escaped_json}');"));
847                            }
848                            if arg_def.optional {
849                                args.push(format!("{dart_param_name}: {var_name}"));
850                            } else {
851                                args.push(var_name);
852                            }
853                        }
854                    }
855                }
856            }
857            _ => {}
858        }
859    }
860
861    // Fixture-driven visitor handle. When `fixture.visitor` is set we build a
862    // `_visitor` via the `createHtmlVisitor(...)` factory (emitted by
863    // `alef-backend-dart`'s trait-bridge generator in the `type_alias` mode)
864    // and thread it into the options blob via the
865    // `create<OptionsType>FromJsonWithVisitor(json, visitor)` helper (handled
866    // a few lines above in the json_object arg branch).
867    //
868    // The visitor setup line is INSERTED at the front of `setup_lines` so
869    // `_visitor` is defined before any `_options` line that references it.
870    // Fixtures without an `options` json_object in input still need an options
871    // blob to carry the visitor through to convert — we synthesise an empty-
872    // options call to `createConversionOptionsFromJsonWithVisitor(json: '{}',
873    // visitor: _visitor)` here when no `options` arg was emitted in the loop
874    // above.
875    if let Some(visitor_spec) = &fixture.visitor {
876        let mut visitor_setup: Vec<String> = Vec::new();
877        let _ = super::dart_visitors::build_dart_visitor(&mut visitor_setup, visitor_spec);
878        // Prepend the visitor block so `_visitor` is in scope by the time the
879        // options call (which may reference it) runs.
880        for line in visitor_setup.into_iter().rev() {
881            setup_lines.insert(0, line);
882        }
883
884        // If no `options` arg was emitted by the loop above (the fixture has no
885        // input.options block), build an empty options-with-visitor and add it as
886        // an `options:` named arg so the visitor reaches the convert call.
887        let already_has_options = args.iter().any(|a| a.starts_with("options:") || a == "_options");
888        if !already_has_options {
889            if let Some(opts_type) = options_type {
890                let dart_fn = type_name_to_create_from_json_dart(opts_type);
891                setup_lines.push(format!(
892                    "final _options = await {dart_fn}WithVisitor(json: '{{}}', visitor: _visitor);"
893                ));
894                args.push("options: _options".to_string());
895            }
896        }
897    }
898
899    // Resolve client_factory: when set, tests create a client instance and call
900    // methods on it rather than using static bridge-class calls. This mirrors the
901    // go/python/zig pattern for stateful clients (e.g. liter-llm).
902    let client_factory: Option<&str> = call_overrides.and_then(|o| o.client_factory.as_deref()).or_else(|| {
903        e2e_config
904            .call
905            .overrides
906            .get(lang)
907            .and_then(|o| o.client_factory.as_deref())
908    });
909
910    // Convert factory name to camelCase (same rule as function_name above).
911    let client_factory_camel: Option<String> = client_factory.map(|f| {
912        f.split('_')
913            .enumerate()
914            .map(|(i, part)| {
915                if i == 0 {
916                    part.to_string()
917                } else {
918                    let mut chars = part.chars();
919                    match chars.next() {
920                        None => String::new(),
921                        Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
922                    }
923                }
924            })
925            .collect::<Vec<_>>()
926            .join("")
927    });
928
929    // All bridge methods return Future<T> because FRB v2 wraps every Rust
930    // function as async in Dart — even "sync" Rust functions. Always emit an async
931    // test body and await the call so the test framework waits for the future.
932    let _ = writeln!(out, "  test('{description}', () async {{");
933
934    let args_str = args.join(", ");
935    let receiver_class = call_overrides
936        .and_then(|o| o.class.as_ref())
937        .cloned()
938        .unwrap_or_else(|| bridge_class.to_string());
939
940    // When client_factory is set, determine the mock URL and emit client instantiation.
941    // The mock URL derivation follows the same has_host_root_route / plain-fixture split
942    // used by the mock_url arg handler above.
943    let (receiver, extra_setup): (String, Option<String>) = if let Some(factory) = &client_factory_camel {
944        let has_mock_url = call_config.args.iter().any(|a| a.arg_type == "mock_url");
945        let mock_url_setup = if !has_mock_url {
946            // No explicit mock_url arg — derive the URL inline.
947            if fixture.has_host_root_route() {
948                let env_key = format!("MOCK_SERVER_{}", fixture_id.to_uppercase());
949                Some(format!(
950                    "final _mockUrl = Platform.environment[\"{env_key}\"] ?? (Platform.environment[\"MOCK_SERVER_URL\"]! + \"/fixtures/{fixture_id}\");"
951                ))
952            } else {
953                Some(format!(
954                    r#"final _mockUrl = "${{Platform.environment["MOCK_SERVER_URL"] ?? "http://localhost:8080"}}/fixtures/{fixture_id}";"#
955                ))
956            }
957        } else {
958            None
959        };
960        let url_expr = if has_mock_url {
961            // A mock_url arg was emitted into setup_lines already — reuse the variable name
962            // from the first mock_url arg definition so we don't duplicate the URL.
963            call_config
964                .args
965                .iter()
966                .find(|a| a.arg_type == "mock_url")
967                .map(|a| a.name.clone())
968                .unwrap_or_else(|| "_mockUrl".to_string())
969        } else {
970            "_mockUrl".to_string()
971        };
972        let create_line = format!("final _client = await {receiver_class}.{factory}('test-key', baseUrl: {url_expr});");
973        let full_setup = if let Some(url_line) = mock_url_setup {
974            Some(format!("{url_line}\n    {create_line}"))
975        } else {
976            Some(create_line)
977        };
978        ("_client".to_string(), full_setup)
979    } else {
980        (receiver_class.clone(), None)
981    };
982
983    if expects_error && (!setup_lines.is_empty() || extra_setup.is_some()) {
984        // Wrap setup + call in an async lambda so any exception at any step is caught.
985        // flutter_rust_bridge 2.x decodes Rust errors as raw String values (not Exception
986        // subtypes), so throwsException will not match. Use throwsA(anything) instead.
987        let _ = writeln!(out, "    await expectLater(() async {{");
988        for line in &setup_lines {
989            let _ = writeln!(out, "      {line}");
990        }
991        if let Some(extra) = &extra_setup {
992            for line in extra.lines() {
993                let _ = writeln!(out, "      {line}");
994            }
995        }
996        if is_streaming {
997            let _ = writeln!(out, "      return {receiver}.{function_name}({args_str}).toList();");
998        } else {
999            let _ = writeln!(out, "      return {receiver}.{function_name}({args_str});");
1000        }
1001        let _ = writeln!(out, "    }}(), throwsA(anything));");
1002    } else if expects_error {
1003        // No setup lines, direct call — same throwsA(anything) rationale as above.
1004        if let Some(extra) = &extra_setup {
1005            for line in extra.lines() {
1006                let _ = writeln!(out, "    {line}");
1007            }
1008        }
1009        if is_streaming {
1010            let _ = writeln!(
1011                out,
1012                "    await expectLater({receiver}.{function_name}({args_str}).toList(), throwsA(anything));"
1013            );
1014        } else {
1015            let _ = writeln!(
1016                out,
1017                "    await expectLater({receiver}.{function_name}({args_str}), throwsA(anything));"
1018            );
1019        }
1020    } else {
1021        for line in &setup_lines {
1022            let _ = writeln!(out, "    {line}");
1023        }
1024        if let Some(extra) = &extra_setup {
1025            for line in extra.lines() {
1026                let _ = writeln!(out, "    {line}");
1027            }
1028        }
1029        if is_streaming {
1030            let _ = writeln!(
1031                out,
1032                "    final {result_var} = await {receiver}.{function_name}({args_str}).toList();"
1033            );
1034        } else {
1035            let _ = writeln!(
1036                out,
1037                "    final {result_var} = await {receiver}.{function_name}({args_str});"
1038            );
1039        }
1040        for assertion in &fixture.assertions {
1041            if is_streaming {
1042                render_streaming_assertion_dart(out, assertion, result_var);
1043            } else {
1044                render_assertion_dart(
1045                    out,
1046                    assertion,
1047                    result_var,
1048                    result_is_simple,
1049                    field_resolver,
1050                    enum_fields,
1051                );
1052            }
1053        }
1054    }
1055
1056    let _ = writeln!(out, "  }});");
1057    let _ = writeln!(out);
1058}
1059
1060/// Render `.length` / `?.length ?? 0` against a Dart field accessor.
1061///
1062/// Count-style assertions (`count_equals`, `count_min`, `min_length`, `max_length`)
1063/// operate on collection-typed fields. FRB v2 maps `Option<Vec<T>>` to `List<T>?`
1064/// (nullable) but `Vec<T>` to `List<T>` (non-null). Emitting `?.length ?? 0`
1065/// against a non-null receiver triggers `invalid_null_aware_operator`. Inspect
1066/// the IR via `FieldResolver::is_optional` and choose the safe form per field.
1067fn dart_length_expr(field_accessor: &str, field: Option<&str>, field_resolver: &FieldResolver) -> String {
1068    let is_optional = field
1069        .map(|f| {
1070            let resolved = field_resolver.resolve(f);
1071            field_resolver.is_optional(f) || field_resolver.is_optional(resolved)
1072        })
1073        .unwrap_or(false);
1074    if is_optional {
1075        format!("{field_accessor}?.length ?? 0")
1076    } else {
1077        format!("{field_accessor}.length")
1078    }
1079}
1080
1081fn dart_format_value(val: &serde_json::Value) -> String {
1082    match val {
1083        serde_json::Value::String(s) => format!("'{}'", escape_dart(s)),
1084        serde_json::Value::Bool(b) => b.to_string(),
1085        serde_json::Value::Number(n) => n.to_string(),
1086        serde_json::Value::Null => "null".to_string(),
1087        other => format!("'{}'", escape_dart(&other.to_string())),
1088    }
1089}
1090
1091/// Render a single fixture assertion as a Dart `package:test` `expect(...)` call.
1092///
1093/// Field paths are converted per-segment to camelCase (FRB v2 convention) using
1094/// [`field_to_dart_accessor`].  All 24 fixture assertion types are handled.
1095///
1096/// Assertions on fixture fields that are not in the configured `result_fields` set
1097/// are emitted as a `// skipped:` comment instead — the Dart binding may model a
1098/// different result shape than the fixture asserts on (e.g. flat `ScrapeResult` vs.
1099/// nested `result.browser.*`), and emitting unresolvable getters would break the
1100/// whole file at compile time.
1101fn render_assertion_dart(
1102    out: &mut String,
1103    assertion: &Assertion,
1104    result_var: &str,
1105    result_is_simple: bool,
1106    field_resolver: &FieldResolver,
1107    enum_fields: &std::collections::HashSet<String>,
1108) {
1109    // Skip assertions on fields that don't exist on the dart result type. This must run
1110    // BEFORE the array-traversal and standard accessor paths since both emit code that
1111    // references the field — an unknown field path produces an `isn't defined` error.
1112    if !result_is_simple {
1113        if let Some(f) = assertion.field.as_deref() {
1114            // Use the head segment (before any `[].`) for validation since `is_valid_for_result`
1115            // only checks the first path component.
1116            let head = f.split("[].").next().unwrap_or(f);
1117            if !head.is_empty() && !field_resolver.is_valid_for_result(head) {
1118                let _ = writeln!(out, "    // skipped: field '{f}' not available on dart result type");
1119                return;
1120            }
1121        }
1122    }
1123
1124    // Skip assertions that traverse a tagged-union variant boundary. FRB exposes
1125    // tagged unions like `FormatMetadata` as sealed classes whose variants are
1126    // accessed via pattern matching (`switch (m) { case FormatMetadata_Excel ... }`)
1127    // — there is no `.excel?` getter, so the fixture path cannot be expressed as
1128    // a simple chained accessor without language-specific pattern-matching codegen.
1129    if let Some(f) = assertion.field.as_deref() {
1130        if !f.is_empty() && field_resolver.tagged_union_split(f).is_some() {
1131            let _ = writeln!(
1132                out,
1133                "    // skipped: field '{f}' crosses a tagged-union variant boundary (not expressible in Dart)"
1134            );
1135            return;
1136        }
1137    }
1138
1139    // Handle array traversal (e.g. "links[].link_type" → any() expression).
1140    if let Some(f) = assertion.field.as_deref() {
1141        if let Some(dot) = f.find("[].") {
1142            // Apply the alias mapping to the full `xxx[].yyy` path first so renamed
1143            // sub-fields (e.g. `assets[].category` → `assets[].asset_category`) resolve
1144            // correctly. Split *after* resolving so both the array head and the element
1145            // path reflect any alias rewrites.
1146            let resolved_full = field_resolver.resolve(f);
1147            let (array_part, elem_part) = match resolved_full.find("[].") {
1148                Some(rdot) => (&resolved_full[..rdot], &resolved_full[rdot + 3..]),
1149                // Resolver mapped the path away from `[].` form — fall back to the original
1150                // split, since downstream code expects the array/elem structure.
1151                None => (&f[..dot], &f[dot + 3..]),
1152            };
1153            let array_accessor = if array_part.is_empty() {
1154                result_var.to_string()
1155            } else {
1156                field_resolver.accessor(array_part, "dart", result_var)
1157            };
1158            let elem_accessor = field_to_dart_accessor(elem_part);
1159            match assertion.assertion_type.as_str() {
1160                "contains" => {
1161                    if let Some(expected) = &assertion.value {
1162                        let dart_val = dart_format_value(expected);
1163                        let _ = writeln!(
1164                            out,
1165                            "    expect({array_accessor}.any((e) => e.{elem_accessor}.toString().contains({dart_val})), isTrue);"
1166                        );
1167                    }
1168                }
1169                "contains_all" => {
1170                    if let Some(values) = &assertion.values {
1171                        for val in values {
1172                            let dart_val = dart_format_value(val);
1173                            let _ = writeln!(
1174                                out,
1175                                "    expect({array_accessor}.any((e) => e.{elem_accessor}.toString().contains({dart_val})), isTrue);"
1176                            );
1177                        }
1178                    }
1179                }
1180                "not_contains" => {
1181                    if let Some(expected) = &assertion.value {
1182                        let dart_val = dart_format_value(expected);
1183                        let _ = writeln!(
1184                            out,
1185                            "    expect({array_accessor}.any((e) => e.{elem_accessor}.toString().contains({dart_val})), isFalse);"
1186                        );
1187                    } else if let Some(values) = &assertion.values {
1188                        for val in values {
1189                            let dart_val = dart_format_value(val);
1190                            let _ = writeln!(
1191                                out,
1192                                "    expect({array_accessor}.any((e) => e.{elem_accessor}.toString().contains({dart_val})), isFalse);"
1193                            );
1194                        }
1195                    }
1196                }
1197                "not_empty" => {
1198                    let _ = writeln!(
1199                        out,
1200                        "    expect({array_accessor}.any((e) => e.{elem_accessor}.toString().isNotEmpty), isTrue);"
1201                    );
1202                }
1203                other => {
1204                    let _ = writeln!(
1205                        out,
1206                        "    // skipped: unsupported traversal assertion '{other}' on '{f}'"
1207                    );
1208                }
1209            }
1210            return;
1211        }
1212    }
1213
1214    let field_accessor = if result_is_simple {
1215        // Whole-result assertion path: the dart return is a scalar (e.g. a
1216        // `Uint8List` for speech/file_content), so any `field` on the
1217        // assertion resolves to the whole value rather than a sub-accessor.
1218        result_var.to_string()
1219    } else {
1220        match assertion.field.as_deref() {
1221            // Use the shared accessor builder (`FieldResolver::accessor`) — it applies the
1222            // alias mapping (e.g. `robots.is_allowed` → `is_allowed`), expands array
1223            // segments to `[0]` lookups, and injects `!` after optional intermediates so
1224            // chained access compiles under sound null safety.
1225            Some(f) if !f.is_empty() => field_resolver.accessor(f, "dart", result_var),
1226            _ => result_var.to_string(),
1227        }
1228    };
1229
1230    let format_value = |val: &serde_json::Value| -> String { dart_format_value(val) };
1231
1232    match assertion.assertion_type.as_str() {
1233        "equals" | "field_equals" => {
1234            if let Some(expected) = &assertion.value {
1235                let dart_val = format_value(expected);
1236                // Check if this field is an enum field. Enum fields need _alefE2eText for serde
1237                // wire format conversion (e.g. FinishReason.toolCalls → "tool_calls").
1238                let is_enum_field = assertion
1239                    .field
1240                    .as_deref()
1241                    .map(|f| {
1242                        let resolved = field_resolver.resolve(f);
1243                        enum_fields.contains(f) || enum_fields.contains(resolved)
1244                    })
1245                    .unwrap_or(false);
1246
1247                // Match the rust codegen's behaviour: trim both sides for string equality
1248                // so trailing-newline differences between h2m's emitted markdown and the
1249                // fixture's expected value don't produce false positives.
1250                if expected.is_string() {
1251                    if is_enum_field {
1252                        // For enum fields, use _alefE2eText to normalize the enum value to its
1253                        // serde wire format before comparison.
1254                        let _ = writeln!(
1255                            out,
1256                            "    expect(_alefE2eText({field_accessor}).trim(), equals({dart_val}.toString().trim()));"
1257                        );
1258                    } else {
1259                        // When result_is_simple is true and the field_accessor is nullable (e.g. String?),
1260                        // use null-coalescing operator (?? '') to handle null gracefully.
1261                        let safe_accessor = if result_is_simple && assertion.field.is_none() {
1262                            format!("({field_accessor} ?? '').toString().trim()")
1263                        } else {
1264                            format!("{field_accessor}.toString().trim()")
1265                        };
1266                        let _ = writeln!(
1267                            out,
1268                            "    expect({safe_accessor}, equals({dart_val}.toString().trim()));"
1269                        );
1270                    }
1271                } else {
1272                    let _ = writeln!(out, "    expect({field_accessor}, equals({dart_val}));");
1273                }
1274            } else {
1275                let _ = writeln!(
1276                    out,
1277                    "    // skipped: '{}' assertion missing value",
1278                    assertion.assertion_type
1279                );
1280            }
1281        }
1282        "not_equals" => {
1283            if let Some(expected) = &assertion.value {
1284                let dart_val = format_value(expected);
1285                // Check if this field is an enum field.
1286                let is_enum_field = assertion
1287                    .field
1288                    .as_deref()
1289                    .map(|f| {
1290                        let resolved = field_resolver.resolve(f);
1291                        enum_fields.contains(f) || enum_fields.contains(resolved)
1292                    })
1293                    .unwrap_or(false);
1294
1295                if expected.is_string() {
1296                    if is_enum_field {
1297                        let _ = writeln!(
1298                            out,
1299                            "    expect(_alefE2eText({field_accessor}).trim(), isNot(equals({dart_val}.toString().trim())));"
1300                        );
1301                    } else {
1302                        // When result_is_simple is true and the field_accessor is nullable (e.g. String?),
1303                        // use null-coalescing operator (?? '') to handle null gracefully.
1304                        let safe_accessor = if result_is_simple && assertion.field.is_none() {
1305                            format!("({field_accessor} ?? '').toString().trim()")
1306                        } else {
1307                            format!("{field_accessor}.toString().trim()")
1308                        };
1309                        let _ = writeln!(
1310                            out,
1311                            "    expect({safe_accessor}, isNot(equals({dart_val}.toString().trim())));"
1312                        );
1313                    }
1314                } else {
1315                    let _ = writeln!(out, "    expect({field_accessor}, isNot(equals({dart_val})));");
1316                }
1317            }
1318        }
1319        "contains" => {
1320            if let Some(expected) = &assertion.value {
1321                let dart_val = format_value(expected);
1322                let _ = writeln!(out, "    expect({field_accessor}, contains({dart_val}));");
1323            } else {
1324                let _ = writeln!(out, "    // skipped: 'contains' assertion missing value");
1325            }
1326        }
1327        "contains_all" => {
1328            if let Some(values) = &assertion.values {
1329                for val in values {
1330                    let dart_val = format_value(val);
1331                    let _ = writeln!(out, "    expect({field_accessor}, contains({dart_val}));");
1332                }
1333            }
1334        }
1335        "contains_any" => {
1336            if let Some(values) = &assertion.values {
1337                let checks: Vec<String> = values
1338                    .iter()
1339                    .map(|v| {
1340                        let dart_val = format_value(v);
1341                        format!("{field_accessor}.contains({dart_val})")
1342                    })
1343                    .collect();
1344                let joined = checks.join(" || ");
1345                let _ = writeln!(out, "    expect({joined}, isTrue);");
1346            }
1347        }
1348        "not_contains" => {
1349            if let Some(expected) = &assertion.value {
1350                let dart_val = format_value(expected);
1351                let _ = writeln!(out, "    expect({field_accessor}, isNot(contains({dart_val})));");
1352            } else if let Some(values) = &assertion.values {
1353                for val in values {
1354                    let dart_val = format_value(val);
1355                    let _ = writeln!(out, "    expect({field_accessor}, isNot(contains({dart_val})));");
1356                }
1357            }
1358        }
1359        "not_empty" => {
1360            // `isNotEmpty` only applies to types with a `.isEmpty` getter (collections,
1361            // strings, maps). For struct-shaped fields (e.g. `document: DocumentStructure`)
1362            // we instead assert the value is non-null — those types have no notion of
1363            // "empty" and the fixture intent is "the field is present".
1364            let is_collection = assertion.field.as_deref().is_some_and(|f| {
1365                let resolved = field_resolver.resolve(f);
1366                field_resolver.is_array(f) || field_resolver.is_array(resolved)
1367            });
1368            if is_collection {
1369                let _ = writeln!(out, "    expect({field_accessor}, isNotEmpty);");
1370            } else {
1371                let _ = writeln!(out, "    expect({field_accessor}, isNotNull);");
1372            }
1373        }
1374        "is_empty" => {
1375            // FRB models `Option<String>` / `Option<Vec<T>>` as nullable in Dart. The `isEmpty`
1376            // matcher throws `NoSuchMethodError` on `null`. Accept `null` as semantically
1377            // empty by combining `isNull` with `isEmpty` via `anyOf`.
1378            let _ = writeln!(out, "    expect({field_accessor}, anyOf(isNull, isEmpty));");
1379        }
1380        "starts_with" => {
1381            if let Some(expected) = &assertion.value {
1382                let dart_val = format_value(expected);
1383                let _ = writeln!(out, "    expect({field_accessor}, startsWith({dart_val}));");
1384            }
1385        }
1386        "ends_with" => {
1387            if let Some(expected) = &assertion.value {
1388                let dart_val = format_value(expected);
1389                let _ = writeln!(out, "    expect({field_accessor}, endsWith({dart_val}));");
1390            }
1391        }
1392        "min_length" => {
1393            if let Some(val) = &assertion.value {
1394                if let Some(n) = val.as_u64() {
1395                    let length_expr = dart_length_expr(&field_accessor, assertion.field.as_deref(), field_resolver);
1396                    let _ = writeln!(out, "    expect({length_expr}, greaterThanOrEqualTo({n}));");
1397                }
1398            }
1399        }
1400        "max_length" => {
1401            if let Some(val) = &assertion.value {
1402                if let Some(n) = val.as_u64() {
1403                    let length_expr = dart_length_expr(&field_accessor, assertion.field.as_deref(), field_resolver);
1404                    let _ = writeln!(out, "    expect({length_expr}, lessThanOrEqualTo({n}));");
1405                }
1406            }
1407        }
1408        "count_equals" => {
1409            if let Some(val) = &assertion.value {
1410                if let Some(n) = val.as_u64() {
1411                    let length_expr = dart_length_expr(&field_accessor, assertion.field.as_deref(), field_resolver);
1412                    let _ = writeln!(out, "    expect({length_expr}, equals({n}));");
1413                }
1414            }
1415        }
1416        "count_min" => {
1417            if let Some(val) = &assertion.value {
1418                if let Some(n) = val.as_u64() {
1419                    let length_expr = dart_length_expr(&field_accessor, assertion.field.as_deref(), field_resolver);
1420                    let _ = writeln!(out, "    expect({length_expr}, greaterThanOrEqualTo({n}));");
1421                }
1422            }
1423        }
1424        "matches_regex" => {
1425            if let Some(expected) = &assertion.value {
1426                let dart_val = format_value(expected);
1427                let _ = writeln!(out, "    expect({field_accessor}, matches(RegExp({dart_val})));");
1428            }
1429        }
1430        "is_true" => {
1431            let _ = writeln!(out, "    expect({field_accessor}, isTrue);");
1432        }
1433        "is_false" => {
1434            let _ = writeln!(out, "    expect({field_accessor}, isFalse);");
1435        }
1436        "greater_than" => {
1437            if let Some(val) = &assertion.value {
1438                let dart_val = format_value(val);
1439                let _ = writeln!(out, "    expect({field_accessor}, greaterThan({dart_val}));");
1440            }
1441        }
1442        "less_than" => {
1443            if let Some(val) = &assertion.value {
1444                let dart_val = format_value(val);
1445                let _ = writeln!(out, "    expect({field_accessor}, lessThan({dart_val}));");
1446            }
1447        }
1448        "greater_than_or_equal" => {
1449            if let Some(val) = &assertion.value {
1450                let dart_val = format_value(val);
1451                let _ = writeln!(out, "    expect({field_accessor}, greaterThanOrEqualTo({dart_val}));");
1452            }
1453        }
1454        "less_than_or_equal" => {
1455            if let Some(val) = &assertion.value {
1456                let dart_val = format_value(val);
1457                let _ = writeln!(out, "    expect({field_accessor}, lessThanOrEqualTo({dart_val}));");
1458            }
1459        }
1460        "not_null" => {
1461            let _ = writeln!(out, "    expect({field_accessor}, isNotNull);");
1462        }
1463        "not_error" => {
1464            // The `await` already guarantees no thrown error reaches this point — if
1465            // the call throws, the test fails before reaching here. Don't emit
1466            // `expect(result, isNotNull)`: for void-returning trait-bridge fns
1467            // (clear_*) Dart rejects `expect(<void>, ...)` with "expression has type
1468            // 'void' and can't be used". The implicit exception handling proves
1469            // success.
1470        }
1471        "error" => {
1472            // Handled at the test method level via throwsA(anything).
1473        }
1474        "method_result" => {
1475            if let Some(method) = &assertion.method {
1476                let dart_method = method.to_lower_camel_case();
1477                let check = assertion.check.as_deref().unwrap_or("not_null");
1478                let method_call = format!("{field_accessor}.{dart_method}()");
1479                match check {
1480                    "equals" => {
1481                        if let Some(expected) = &assertion.value {
1482                            let dart_val = format_value(expected);
1483                            let _ = writeln!(out, "    expect({method_call}, equals({dart_val}));");
1484                        }
1485                    }
1486                    "is_true" => {
1487                        let _ = writeln!(out, "    expect({method_call}, isTrue);");
1488                    }
1489                    "is_false" => {
1490                        let _ = writeln!(out, "    expect({method_call}, isFalse);");
1491                    }
1492                    "greater_than_or_equal" => {
1493                        if let Some(val) = &assertion.value {
1494                            let dart_val = format_value(val);
1495                            let _ = writeln!(out, "    expect({method_call}, greaterThanOrEqualTo({dart_val}));");
1496                        }
1497                    }
1498                    "count_min" => {
1499                        if let Some(val) = &assertion.value {
1500                            if let Some(n) = val.as_u64() {
1501                                let _ = writeln!(out, "    expect({method_call}.length, greaterThanOrEqualTo({n}));");
1502                            }
1503                        }
1504                    }
1505                    _ => {
1506                        let _ = writeln!(out, "    expect({method_call}, isNotNull);");
1507                    }
1508                }
1509            }
1510        }
1511        other => {
1512            let _ = writeln!(out, "    // skipped: unknown assertion type '{other}'");
1513        }
1514    }
1515}
1516
1517/// Render a single fixture assertion for a streaming result.
1518///
1519/// `result_var` is the `List<T>` collected via `.toList()` on the stream.
1520/// Supports:
1521/// - `not_error`: `expect(result, isNotNull)` (a thrown error would already fail
1522///   the test; the explicit expect keeps the test body non-empty).
1523/// - `count_min` with `field = "chunks"`: assert `result_var.length >= value`.
1524/// - `equals` with `field = "stream_content"`: concatenate `delta.content` and compare.
1525///
1526/// Other assertion types are emitted as comments.
1527fn render_streaming_assertion_dart(out: &mut String, assertion: &Assertion, result_var: &str) {
1528    match assertion.assertion_type.as_str() {
1529        "not_error" => {
1530            // `.toList()` would have thrown to fail the test on error; emit an
1531            // explicit `expect` so the test body isn't empty and the collected
1532            // stream variable is consumed.
1533            let _ = writeln!(out, "    expect({result_var}, isNotNull);");
1534        }
1535        "count_min" if assertion.field.as_deref() == Some("chunks") => {
1536            if let Some(serde_json::Value::Number(n)) = &assertion.value {
1537                let _ = writeln!(out, "    expect({result_var}.length, greaterThanOrEqualTo({n}));");
1538            }
1539        }
1540        "equals" if assertion.field.as_deref() == Some("stream_content") => {
1541            if let Some(serde_json::Value::String(expected)) = &assertion.value {
1542                let escaped = escape_dart(expected);
1543                let _ = writeln!(
1544                    out,
1545                    "    final _content = {result_var}.map((c) => c.choices.firstOrNull?.delta.content ?? '').join();"
1546                );
1547                let _ = writeln!(out, "    expect(_content, equals('{escaped}'));");
1548            }
1549        }
1550        other => {
1551            let _ = writeln!(out, "    // skipped streaming assertion: '{other}'");
1552        }
1553    }
1554}
1555
1556/// Converts a snake_case JSON key to Dart camelCase.
1557fn snake_to_camel(s: &str) -> String {
1558    let mut result = String::with_capacity(s.len());
1559    let mut next_upper = false;
1560    for ch in s.chars() {
1561        if ch == '_' {
1562            next_upper = true;
1563        } else if next_upper {
1564            result.extend(ch.to_uppercase());
1565            next_upper = false;
1566        } else {
1567            result.push(ch);
1568        }
1569    }
1570    result
1571}
1572
1573/// Convert a dot-separated fixture field path to a Dart accessor expression.
1574///
1575/// Each segment is converted to camelCase (FRB v2 convention); array-index brackets
1576/// (e.g. `choices[0]`) and map-key brackets (e.g. `tags[name]`) are preserved.
1577/// This replaces the former single-pass `snake_to_camel` call which incorrectly
1578/// treated the entire path string as one identifier.
1579///
1580/// Examples:
1581/// - `"choices"` → `"choices"`
1582/// - `"choices[0].message.content"` → `"choices[0].message.content"`
1583/// - `"metadata.document_title"` → `"metadata.documentTitle"`
1584/// - `"model_id"` → `"modelId"`
1585fn field_to_dart_accessor(path: &str) -> String {
1586    let mut result = String::with_capacity(path.len());
1587    for (i, segment) in path.split('.').enumerate() {
1588        if i > 0 {
1589            result.push('.');
1590        }
1591        // Separate a trailing `[...]` bracket from the field name so we only
1592        // camelCase the identifier part, not the bracket content. The owning
1593        // collection may be `List<T>?` when the underlying Rust field is
1594        // `Option<Vec<T>>`; force-unwrap with `!` so the `[N]` lookup and any
1595        // subsequent member access compile under sound null safety.
1596        if let Some(bracket_pos) = segment.find('[') {
1597            let name = &segment[..bracket_pos];
1598            let bracket = &segment[bracket_pos..];
1599            result.push_str(&name.to_lower_camel_case());
1600            result.push('!');
1601            result.push_str(bracket);
1602        } else {
1603            result.push_str(&segment.to_lower_camel_case());
1604        }
1605    }
1606    result
1607}
1608
1609/// Emits a Dart `ExtractionConfig(...)` constructor with default values, overriding
1610/// fields present in `overrides` (from fixture JSON, snake_case keys).
1611///
1612/// Only simple scalar overrides (bool, int) are supported. Complex nested types
1613/// (ocr, chunking, etc.) are left at their defaults (null).
1614fn emit_extraction_config_dart(overrides: &serde_json::Map<String, serde_json::Value>) -> String {
1615    // Collect scalar overrides; convert keys to camelCase.
1616    let mut field_overrides: std::collections::HashMap<String, String> = std::collections::HashMap::new();
1617    for (key, val) in overrides {
1618        let camel = snake_to_camel(key);
1619        let dart_val = match val {
1620            serde_json::Value::Bool(b) => {
1621                if *b {
1622                    "true".to_string()
1623                } else {
1624                    "false".to_string()
1625                }
1626            }
1627            serde_json::Value::Number(n) => n.to_string(),
1628            serde_json::Value::String(s) => format!("'{s}'"),
1629            _ => continue, // skip complex nested objects
1630        };
1631        field_overrides.insert(camel, dart_val);
1632    }
1633
1634    let use_cache = field_overrides.remove("useCache").unwrap_or_else(|| "true".to_string());
1635    let enable_quality_processing = field_overrides
1636        .remove("enableQualityProcessing")
1637        .unwrap_or_else(|| "true".to_string());
1638    let force_ocr = field_overrides
1639        .remove("forceOcr")
1640        .unwrap_or_else(|| "false".to_string());
1641    let disable_ocr = field_overrides
1642        .remove("disableOcr")
1643        .unwrap_or_else(|| "false".to_string());
1644    let include_document_structure = field_overrides
1645        .remove("includeDocumentStructure")
1646        .unwrap_or_else(|| "false".to_string());
1647    let use_layout_for_markdown = field_overrides
1648        .remove("useLayoutForMarkdown")
1649        .unwrap_or_else(|| "false".to_string());
1650    let max_archive_depth = field_overrides
1651        .remove("maxArchiveDepth")
1652        .unwrap_or_else(|| "3".to_string());
1653
1654    format!(
1655        "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})"
1656    )
1657}
1658
1659// ---------------------------------------------------------------------------
1660// HTTP server test rendering — DartTestClientRenderer impl + thin driver wrapper
1661// ---------------------------------------------------------------------------
1662
1663/// Renderer that emits `package:test` `test(...)` blocks using `dart:io HttpClient`
1664/// against the mock server (`Platform.environment['MOCK_SERVER_URL']`).
1665///
1666/// Skipped tests are emitted as self-contained stubs (complete test block with
1667/// `markTestSkipped`) entirely inside `render_test_open`. `render_test_close` uses
1668/// `in_skip` to emit the right closing token: nothing extra for skip stubs (already
1669/// closed) vs. `})));` for regular tests.
1670///
1671/// `is_redirect` must be set to `true` before invoking the shared driver for 3xx
1672/// fixtures so that `render_call` can inject `ioReq.followRedirects = false` after
1673/// the `openUrl` call.
1674struct DartTestClientRenderer {
1675    /// Set to `true` when `render_test_open` is called with a skip reason so that
1676    /// `render_test_close` can match the opening shape.
1677    in_skip: Cell<bool>,
1678    /// Pre-set to `true` by the thin wrapper when the fixture expects a 3xx response.
1679    /// `render_call` injects `ioReq.followRedirects = false` when this is `true`.
1680    is_redirect: Cell<bool>,
1681}
1682
1683impl DartTestClientRenderer {
1684    fn new(is_redirect: bool) -> Self {
1685        Self {
1686            in_skip: Cell::new(false),
1687            is_redirect: Cell::new(is_redirect),
1688        }
1689    }
1690}
1691
1692impl client::TestClientRenderer for DartTestClientRenderer {
1693    fn language_name(&self) -> &'static str {
1694        "dart"
1695    }
1696
1697    /// Emit the test opening.
1698    ///
1699    /// For skipped fixtures: emit the entire self-contained stub (open + body +
1700    /// close + blank line) and set `in_skip = true` so `render_test_close` is a
1701    /// no-op.
1702    ///
1703    /// For active fixtures: emit `test('desc', () => _serialized(() => _withRetry(() async {`
1704    /// leaving the block open for the assertion primitives.
1705    fn render_test_open(&self, out: &mut String, _fn_name: &str, description: &str, skip_reason: Option<&str>) {
1706        let escaped_desc = escape_dart(description);
1707        if let Some(reason) = skip_reason {
1708            let escaped_reason = escape_dart(reason);
1709            let _ = writeln!(out, "  test('{escaped_desc}', () {{");
1710            let _ = writeln!(out, "    markTestSkipped('{escaped_reason}');");
1711            let _ = writeln!(out, "  }});");
1712            let _ = writeln!(out);
1713            self.in_skip.set(true);
1714        } else {
1715            let _ = writeln!(
1716                out,
1717                "  test('{escaped_desc}', () => _serialized(() => _withRetry(() async {{"
1718            );
1719            self.in_skip.set(false);
1720        }
1721    }
1722
1723    /// Emit the test closing token.
1724    ///
1725    /// No-op for skip stubs (the stub was fully closed in `render_test_open`).
1726    /// Emits `})));` followed by a blank line for regular tests.
1727    fn render_test_close(&self, out: &mut String) {
1728        if self.in_skip.get() {
1729            // Stub was already closed in render_test_open.
1730            return;
1731        }
1732        let _ = writeln!(out, "  }})));");
1733        let _ = writeln!(out);
1734    }
1735
1736    /// Emit the full `dart:io HttpClient` request scaffolding.
1737    ///
1738    /// Emits:
1739    /// - URL construction from `MOCK_SERVER_URL`.
1740    /// - `_httpClient.openUrl(method, uri)`.
1741    /// - `followRedirects = false` when `is_redirect` is pre-set on the renderer.
1742    /// - Content-Type header, request headers, cookies, optional body bytes.
1743    /// - `ioReq.close()` → `ioResp`.
1744    /// - Response-body drain into `bodyStr` (skipped for redirect responses).
1745    fn render_call(&self, out: &mut String, ctx: &client::CallCtx<'_>) {
1746        // dart:io restricted headers (handled automatically by the HTTP stack).
1747        const DART_RESTRICTED_HEADERS: &[&str] = &["content-length", "host", "transfer-encoding"];
1748
1749        let method = ctx.method.to_uppercase();
1750        let escaped_method = escape_dart(&method);
1751
1752        // Fixture path is `/fixtures/<id>` — extract the id portion for URL construction.
1753        let fixture_path = escape_dart(ctx.path);
1754
1755        // Determine effective content-type.
1756        let has_explicit_content_type = ctx.headers.keys().any(|k| k.to_lowercase() == "content-type");
1757        let effective_content_type = if has_explicit_content_type {
1758            ctx.headers
1759                .iter()
1760                .find(|(k, _)| k.to_lowercase() == "content-type")
1761                .map(|(_, v)| v.as_str())
1762                .unwrap_or("application/json")
1763        } else if ctx.body.is_some() {
1764            ctx.content_type.unwrap_or("application/json")
1765        } else {
1766            ""
1767        };
1768
1769        let _ = writeln!(
1770            out,
1771            "    final baseUrl = Platform.environment['MOCK_SERVER_URL'] ?? 'http://localhost:8080';"
1772        );
1773        let _ = writeln!(out, "    final uri = Uri.parse('$baseUrl{fixture_path}');");
1774        let _ = writeln!(
1775            out,
1776            "    final ioReq = await _httpClient.openUrl('{escaped_method}', uri);"
1777        );
1778
1779        // Disable automatic redirect following for 3xx fixtures so the test can
1780        // assert on the redirect status code itself.
1781        if self.is_redirect.get() {
1782            let _ = writeln!(out, "    ioReq.followRedirects = false;");
1783        }
1784
1785        // Set content-type header.
1786        if !effective_content_type.is_empty() {
1787            let escaped_ct = escape_dart(effective_content_type);
1788            let _ = writeln!(out, "    ioReq.headers.set('content-type', '{escaped_ct}');");
1789        }
1790
1791        // Set request headers (skip dart:io restricted headers and content-type, already handled).
1792        let mut header_pairs: Vec<(&String, &String)> = ctx.headers.iter().collect();
1793        header_pairs.sort_by_key(|(k, _)| k.as_str());
1794        for (name, value) in &header_pairs {
1795            if DART_RESTRICTED_HEADERS.contains(&name.to_lowercase().as_str()) {
1796                continue;
1797            }
1798            if name.to_lowercase() == "content-type" {
1799                continue; // Already handled above.
1800            }
1801            let escaped_name = escape_dart(&name.to_lowercase());
1802            let escaped_value = escape_dart(value);
1803            let _ = writeln!(out, "    ioReq.headers.set('{escaped_name}', '{escaped_value}');");
1804        }
1805
1806        // Add cookies.
1807        if !ctx.cookies.is_empty() {
1808            let mut cookie_pairs: Vec<(&String, &String)> = ctx.cookies.iter().collect();
1809            cookie_pairs.sort_by_key(|(k, _)| k.as_str());
1810            let cookie_str: Vec<String> = cookie_pairs.iter().map(|(k, v)| format!("{k}={v}")).collect();
1811            let cookie_header = escape_dart(&cookie_str.join("; "));
1812            let _ = writeln!(out, "    ioReq.headers.set('cookie', '{cookie_header}');");
1813        }
1814
1815        // Write body bytes if present (bypass charset-based encoding issues).
1816        if let Some(body) = ctx.body {
1817            let json_str = serde_json::to_string(body).unwrap_or_default();
1818            let escaped = escape_dart(&json_str);
1819            let _ = writeln!(out, "    final bodyBytes = utf8.encode('{escaped}');");
1820            let _ = writeln!(out, "    ioReq.add(bodyBytes);");
1821        }
1822
1823        let _ = writeln!(out, "    final ioResp = await ioReq.close();");
1824        // Drain the response body to bind `bodyStr` for assertion primitives and to
1825        // allow the server to cleanly close the connection (prevents RST packets).
1826        // Redirect responses have no body to drain — skip to avoid a potential hang.
1827        if !self.is_redirect.get() {
1828            let _ = writeln!(out, "    final bodyStr = await ioResp.transform(utf8.decoder).join();");
1829        };
1830    }
1831
1832    fn render_assert_status(&self, out: &mut String, _response_var: &str, status: u16) {
1833        let _ = writeln!(
1834            out,
1835            "    expect(ioResp.statusCode, equals({status}), reason: 'status code mismatch');"
1836        );
1837    }
1838
1839    /// Emit a single header assertion, handling special tokens `<<present>>`,
1840    /// `<<absent>>`, and `<<uuid>>`.
1841    fn render_assert_header(&self, out: &mut String, _response_var: &str, name: &str, expected: &str) {
1842        let escaped_name = escape_dart(&name.to_lowercase());
1843        match expected {
1844            "<<present>>" => {
1845                let _ = writeln!(
1846                    out,
1847                    "    expect(ioResp.headers.value('{escaped_name}'), isNotNull, reason: 'header {escaped_name} should be present');"
1848                );
1849            }
1850            "<<absent>>" => {
1851                let _ = writeln!(
1852                    out,
1853                    "    expect(ioResp.headers.value('{escaped_name}'), isNull, reason: 'header {escaped_name} should be absent');"
1854                );
1855            }
1856            "<<uuid>>" => {
1857                let _ = writeln!(
1858                    out,
1859                    "    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');"
1860                );
1861            }
1862            exact => {
1863                let escaped_value = escape_dart(exact);
1864                let _ = writeln!(
1865                    out,
1866                    "    expect(ioResp.headers.value('{escaped_name}'), contains('{escaped_value}'), reason: 'header {escaped_name} mismatch');"
1867                );
1868            }
1869        }
1870    }
1871
1872    /// Emit an exact-equality body assertion.
1873    ///
1874    /// String bodies are compared as decoded text; structured JSON bodies are
1875    /// compared via `jsonDecode`.
1876    fn render_assert_json_body(&self, out: &mut String, _response_var: &str, expected: &serde_json::Value) {
1877        match expected {
1878            serde_json::Value::Object(_) | serde_json::Value::Array(_) => {
1879                let json_str = serde_json::to_string(expected).unwrap_or_default();
1880                let escaped = escape_dart(&json_str);
1881                let _ = writeln!(out, "    final bodyJson = jsonDecode(bodyStr);");
1882                let _ = writeln!(out, "    final expectedJson = jsonDecode('{escaped}');");
1883                let _ = writeln!(
1884                    out,
1885                    "    expect(bodyJson, equals(expectedJson), reason: 'body mismatch');"
1886                );
1887            }
1888            serde_json::Value::String(s) => {
1889                let escaped = escape_dart(s);
1890                let _ = writeln!(
1891                    out,
1892                    "    expect(bodyStr.trim(), equals('{escaped}'), reason: 'body mismatch');"
1893                );
1894            }
1895            other => {
1896                let escaped = escape_dart(&other.to_string());
1897                let _ = writeln!(
1898                    out,
1899                    "    expect(bodyStr.trim(), equals('{escaped}'), reason: 'body mismatch');"
1900                );
1901            }
1902        }
1903    }
1904
1905    /// Emit partial-body assertions — every key in `expected` must match the
1906    /// corresponding field in the parsed JSON response.
1907    fn render_assert_partial_body(&self, out: &mut String, _response_var: &str, expected: &serde_json::Value) {
1908        let _ = writeln!(
1909            out,
1910            "    final partialJson = jsonDecode(bodyStr) as Map<String, dynamic>;"
1911        );
1912        if let Some(obj) = expected.as_object() {
1913            for (idx, (key, val)) in obj.iter().enumerate() {
1914                let escaped_key = escape_dart(key);
1915                let json_val = serde_json::to_string(val).unwrap_or_default();
1916                let escaped_val = escape_dart(&json_val);
1917                // Use an index-based variable name so keys with special characters
1918                // don't produce invalid Dart identifiers.
1919                let _ = writeln!(out, "    final _expectedField{idx} = jsonDecode('{escaped_val}');");
1920                let _ = writeln!(
1921                    out,
1922                    "    expect(partialJson['{escaped_key}'], equals(_expectedField{idx}), reason: 'partial body field \\'{escaped_key}\\' mismatch');"
1923                );
1924            }
1925        }
1926    }
1927
1928    /// Emit validation-error assertions for 422 responses.
1929    fn render_assert_validation_errors(
1930        &self,
1931        out: &mut String,
1932        _response_var: &str,
1933        errors: &[ValidationErrorExpectation],
1934    ) {
1935        let _ = writeln!(out, "    final errBody = jsonDecode(bodyStr) as Map<String, dynamic>;");
1936        let _ = writeln!(out, "    final errList = (errBody['errors'] ?? []) as List<dynamic>;");
1937        for ve in errors {
1938            let loc_dart: Vec<String> = ve.loc.iter().map(|s| format!("'{}'", escape_dart(s))).collect();
1939            let loc_str = loc_dart.join(", ");
1940            let escaped_msg = escape_dart(&ve.msg);
1941            let _ = writeln!(
1942                out,
1943                "    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}');"
1944            );
1945        }
1946    }
1947}
1948
1949/// Render a `package:test` `test(...)` block for an HTTP server fixture.
1950///
1951/// Delegates to the shared [`client::http_call::render_http_test`] driver via
1952/// [`DartTestClientRenderer`]. HTTP 101 (WebSocket upgrade) fixtures are emitted
1953/// as skip stubs before reaching the driver because `dart:io HttpClient` cannot
1954/// handle protocol-switch responses.
1955fn render_http_test_case(out: &mut String, fixture: &Fixture, http: &HttpFixture) {
1956    // HTTP 101 (WebSocket upgrade) — dart:io HttpClient cannot handle upgrade responses.
1957    if http.expected_response.status_code == 101 {
1958        let description = escape_dart(&fixture.description);
1959        let _ = writeln!(out, "  test('{description}', () {{");
1960        let _ = writeln!(
1961            out,
1962            "    markTestSkipped('Skipped: Dart HttpClient cannot handle 101 Switching Protocols responses');"
1963        );
1964        let _ = writeln!(out, "  }});");
1965        let _ = writeln!(out);
1966        return;
1967    }
1968
1969    // Pre-set `is_redirect` on the renderer so `render_call` can inject
1970    // `ioReq.followRedirects = false` for 3xx fixtures. The shared driver has no
1971    // concept of expected status code so we thread it through renderer state.
1972    let is_redirect = http.expected_response.status_code / 100 == 3;
1973    client::http_call::render_http_test(out, &DartTestClientRenderer::new(is_redirect), fixture);
1974}
1975
1976/// Infer a MIME type from a file path extension.
1977///
1978/// Returns `None` when the extension is unknown so the caller can supply a fallback.
1979/// Used in dart e2e tests when a fixture omits `mime_type` but uses a `file_path` arg.
1980fn mime_from_extension(path: &str) -> Option<&'static str> {
1981    let ext = path.rsplit('.').next()?;
1982    match ext.to_lowercase().as_str() {
1983        "docx" => Some("application/vnd.openxmlformats-officedocument.wordprocessingml.document"),
1984        "xlsx" => Some("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"),
1985        "pptx" => Some("application/vnd.openxmlformats-officedocument.presentationml.presentation"),
1986        "pdf" => Some("application/pdf"),
1987        "txt" | "text" => Some("text/plain"),
1988        "html" | "htm" => Some("text/html"),
1989        "json" => Some("application/json"),
1990        "xml" => Some("application/xml"),
1991        "csv" => Some("text/csv"),
1992        "md" | "markdown" => Some("text/markdown"),
1993        "png" => Some("image/png"),
1994        "jpg" | "jpeg" => Some("image/jpeg"),
1995        "gif" => Some("image/gif"),
1996        "zip" => Some("application/zip"),
1997        "odt" => Some("application/vnd.oasis.opendocument.text"),
1998        "ods" => Some("application/vnd.oasis.opendocument.spreadsheet"),
1999        "odp" => Some("application/vnd.oasis.opendocument.presentation"),
2000        "rtf" => Some("application/rtf"),
2001        "epub" => Some("application/epub+zip"),
2002        "msg" => Some("application/vnd.ms-outlook"),
2003        "eml" => Some("message/rfc822"),
2004        _ => None,
2005    }
2006}
2007
2008/// Emit Dart constructors for a batch item array (`BatchBytesItem` or `BatchFileItem`).
2009///
2010/// Returns a Dart list literal like:
2011/// ```dart
2012/// [BatchBytesItem(content: Uint8List.fromList([72, 101, ...]), mimeType: 'text/plain')]
2013/// ```
2014fn emit_dart_batch_item_array(arr: &serde_json::Value, elem_type: &str) -> String {
2015    let items: Vec<String> = arr
2016        .as_array()
2017        .map(|a| a.as_slice())
2018        .unwrap_or_default()
2019        .iter()
2020        .filter_map(|item| {
2021            let obj = item.as_object()?;
2022            match elem_type {
2023                "BatchBytesItem" => {
2024                    let content_bytes = obj
2025                        .get("content")
2026                        .and_then(|v| v.as_array())
2027                        .map(|arr| {
2028                            let nums: Vec<String> =
2029                                arr.iter().filter_map(|v| v.as_u64().map(|n| n.to_string())).collect();
2030                            format!("Uint8List.fromList([{}])", nums.join(", "))
2031                        })
2032                        .unwrap_or_else(|| "Uint8List(0)".to_string());
2033                    let mime_type = obj
2034                        .get("mime_type")
2035                        .and_then(|v| v.as_str())
2036                        .unwrap_or("application/octet-stream");
2037                    Some(format!(
2038                        "BatchBytesItem(content: {content_bytes}, mimeType: '{}')",
2039                        escape_dart(mime_type)
2040                    ))
2041                }
2042                "BatchFileItem" => {
2043                    let path = obj.get("path").and_then(|v| v.as_str()).unwrap_or("");
2044                    Some(format!("BatchFileItem(path: '{}')", escape_dart(path)))
2045                }
2046                _ => None,
2047            }
2048        })
2049        .collect();
2050    format!("[{}]", items.join(", "))
2051}
2052
2053/// Escape a string for embedding in a Dart single-quoted string literal.
2054pub(super) fn escape_dart(s: &str) -> String {
2055    s.replace('\\', "\\\\")
2056        .replace('\'', "\\'")
2057        .replace('\n', "\\n")
2058        .replace('\r', "\\r")
2059        .replace('\t', "\\t")
2060        .replace('$', "\\$")
2061}
2062
2063/// Derive the Dart top-level helper function name for constructing a mirror type from JSON.
2064///
2065/// The alef dart bridge-crate generator emits a Rust free function
2066/// `create_<snake_type>_from_json(json: String)` for each non-opaque mirror struct.
2067/// FRB generates the corresponding Dart function as `createTypeNameFromJson` (camelCase).
2068///
2069/// Example: `"ChatCompletionRequest"` → `"createChatCompletionRequestFromJson"`.
2070fn type_name_to_create_from_json_dart(type_name: &str) -> String {
2071    // Convert PascalCase type name to snake_case.
2072    let mut snake = String::with_capacity(type_name.len() + 8);
2073    for (i, ch) in type_name.char_indices() {
2074        if ch.is_uppercase() {
2075            if i > 0 {
2076                snake.push('_');
2077            }
2078            snake.extend(ch.to_lowercase());
2079        } else {
2080            snake.push(ch);
2081        }
2082    }
2083    // snake is now e.g. "chat_completion_request"
2084    // Full Rust function name: "create_chat_completion_request_from_json"
2085    let rust_fn = format!("create_{snake}_from_json");
2086    // Convert to Dart camelCase: "createChatCompletionRequestFromJson"
2087    rust_fn
2088        .split('_')
2089        .enumerate()
2090        .map(|(i, part)| {
2091            if i == 0 {
2092                part.to_string()
2093            } else {
2094                let mut chars = part.chars();
2095                match chars.next() {
2096                    None => String::new(),
2097                    Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
2098                }
2099            }
2100        })
2101        .collect::<Vec<_>>()
2102        .join("")
2103}