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