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