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