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
343    // Resolve options_type and options_via from per-fixture → per-call → default.
344    // These drive how `json_object` args are constructed:
345    //   options_via = "from_json" — call `createTypeNameFromJson(json: r'...')` bridge
346    //                               helper and pass the result as a named parameter `req:`.
347    //   All other values (or absent) — existing behaviour (batch arrays, config objects,
348    //   generic JSON arrays, or nothing).
349    let options_type: Option<&str> = call_overrides.and_then(|o| o.options_type.as_deref());
350    let options_via: &str = call_overrides
351        .and_then(|o| o.options_via.as_deref())
352        .unwrap_or("kwargs");
353
354    // Build argument list from fixture.input and call_config.args.
355    // Use `resolve_field` (respects the `field` path like "input.data") rather than
356    // looking up by `arg_def.name` directly — the name and the field key may differ.
357    //
358    // For `extract_file_sync` / `extract_file` fixtures that omit `mime_type`,
359    // derive the MIME from the path extension so `extractBytesSync`/`extractBytes`
360    // can be called (both require an explicit MIME type).
361    let file_path_for_mime: Option<&str> = call_config
362        .args
363        .iter()
364        .find(|a| a.arg_type == "file_path")
365        .and_then(|a| resolve_field(&fixture.input, &a.field).as_str());
366
367    // Detect whether this call converts a file_path arg to bytes at test-run time.
368    // Dart cannot pass OS-level file paths through the FRB bridge — the idiomatic API
369    // is always bytes. When a file_path arg is present (and no caller-supplied dart
370    // function override has already been applied), remap the function name:
371    //   extractFile      → extractBytes
372    //   extractFileSync  → extractBytesSync
373    let has_file_path_arg = call_config.args.iter().any(|a| a.arg_type == "file_path");
374    // Apply the remap only when no per-fixture dart override has already specified the
375    // function — if the fixture author set a dart-specific function name we trust it.
376    let caller_supplied_override = call_overrides.and_then(|o| o.function.as_ref()).is_some();
377    if has_file_path_arg && !caller_supplied_override {
378        function_name = match function_name.as_str() {
379            "extractFile" => "extractBytes".to_string(),
380            "extractFileSync" => "extractBytesSync".to_string(),
381            other => other.to_string(),
382        };
383    }
384
385    // setup_lines holds per-test statements that must precede the main call:
386    // engine construction (handle args) and URL building (mock_url args).
387    let mut setup_lines: Vec<String> = Vec::new();
388    let mut args = Vec::new();
389
390    for arg_def in &call_config.args {
391        match arg_def.arg_type.as_str() {
392            "mock_url" => {
393                let name = arg_def.name.clone();
394                if fixture.has_host_root_route() {
395                    let env_key = format!("MOCK_SERVER_{}", fixture_id.to_uppercase());
396                    setup_lines.push(format!(
397                        r#"final {name} = Platform.environment["{env_key}"] ?? (Platform.environment["MOCK_SERVER_URL"]! + "/fixtures/{fixture_id}");"#
398                    ));
399                } else {
400                    setup_lines.push(format!(
401                        r#"final {name} = "${{Platform.environment["MOCK_SERVER_URL"] ?? "http://localhost:8080"}}/fixtures/{fixture_id}";"#
402                    ));
403                }
404                args.push(name);
405                continue;
406            }
407            "handle" => {
408                let name = arg_def.name.clone();
409                let field = arg_def.field.strip_prefix("input.").unwrap_or(&arg_def.field);
410                let config_value = fixture.input.get(field).cloned().unwrap_or(serde_json::Value::Null);
411                // Derive the create-function name: "engine" → "createEngine".
412                let create_fn = {
413                    let mut chars = name.chars();
414                    let pascal = match chars.next() {
415                        None => String::new(),
416                        Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
417                    };
418                    format!("create{pascal}")
419                };
420                if config_value.is_null()
421                    || config_value.is_object() && config_value.as_object().is_some_and(|o| o.is_empty())
422                {
423                    setup_lines.push(format!("final {name} = await {bridge_class}.{create_fn}(null);"));
424                } else {
425                    let json_str = serde_json::to_string(&config_value).unwrap_or_default();
426                    let config_var = format!("{name}Config");
427                    setup_lines.push(format!(
428                        "final {config_var} = CrawlConfig.fromJson(jsonDecode(r'{json_str}') as Map<String, dynamic>);"
429                    ));
430                    setup_lines.push(format!(
431                        "final {name} = await {bridge_class}.{create_fn}({config_var});"
432                    ));
433                }
434                args.push(name);
435                continue;
436            }
437            _ => {}
438        }
439
440        let arg_value = resolve_field(&fixture.input, &arg_def.field);
441        match arg_def.arg_type.as_str() {
442            "bytes" | "file_path" => {
443                // `bytes`: value is a file path string; load file contents at test-run time.
444                // `file_path`: also loaded as bytes for dart — extractBytes/extractBytesSync is
445                // the idiomatic Dart API since the Dart runtime cannot pass OS-level file paths
446                // through the FFI bridge.
447                if let serde_json::Value::String(file_path) = arg_value {
448                    args.push(format!("File('{}').readAsBytesSync()", file_path));
449                }
450            }
451            "string" => {
452                // FRB generates all bridge method parameters as named (`{required T name}`)
453                // in Dart, so string args must be passed as `paramName: 'value'`.
454                // Convert the arg name from snake_case to camelCase for the Dart named param.
455                let dart_param_name = snake_to_camel(&arg_def.name);
456                match arg_value {
457                    serde_json::Value::String(s) => {
458                        args.push(format!("{dart_param_name}: '{}'", escape_dart(s)));
459                    }
460                    serde_json::Value::Null
461                        if arg_def.optional
462                        // Optional string absent from fixture — try to infer MIME from path
463                        // when the arg name looks like a MIME-type parameter.
464                        && arg_def.name == "mime_type" =>
465                    {
466                        let inferred = file_path_for_mime
467                            .and_then(mime_from_extension)
468                            .unwrap_or("application/octet-stream");
469                        args.push(format!("{dart_param_name}: '{inferred}'"));
470                    }
471                    // Other optional strings with null value are omitted.
472                    _ => {}
473                }
474            }
475            "json_object" => {
476                // Handle batch item arrays (BatchBytesItem / BatchFileItem).
477                if let Some(elem_type) = &arg_def.element_type {
478                    if (elem_type == "BatchBytesItem" || elem_type == "BatchFileItem") && arg_value.is_array() {
479                        let dart_items = emit_dart_batch_item_array(arg_value, elem_type);
480                        args.push(dart_items);
481                    }
482                } else if options_via == "from_json" {
483                    // `from_json` path: construct a typed mirror-struct via the generated
484                    // `create<TypeName>FromJson(json: '...')` bridge helper, then pass it
485                    // as the named FRB parameter `req: _var`.
486                    //
487                    // The helper is generated by `emit_from_json_fn` in the dart bridge-crate
488                    // generator and made available as a top-level function via the exported
489                    // `liter_llm_bridge_generated/lib.dart`. The parameter name used in the
490                    // bridge method call is always `req:` for single-request-object methods
491                    // (derived from the Rust IR param name).
492                    if let Some(opts_type) = options_type {
493                        if !arg_value.is_null() {
494                            let json_str = serde_json::to_string(&arg_value).unwrap_or_default();
495                            // Escape for Dart single-quoted string literal (handles embedded quotes,
496                            // backslashes, and interpolation markers).
497                            let escaped_json = escape_dart(&json_str);
498                            let var_name = format!("_{}", arg_def.name);
499                            let dart_fn = type_name_to_create_from_json_dart(opts_type);
500                            setup_lines.push(format!("final {var_name} = await {dart_fn}(json: '{escaped_json}');"));
501                            // FRB bridge method param name is `req` for all single-request methods.
502                            // Use `req:` as the named argument label.
503                            args.push(format!("req: {var_name}"));
504                        }
505                    }
506                } else if arg_def.name == "config" {
507                    if let serde_json::Value::Object(map) = &arg_value {
508                        // Fixture provides config overrides — build an ExtractionConfig constructor
509                        // with defaults, overriding only the fields present in the fixture JSON.
510                        // This handles error-triggering configs like {force_ocr:true, disable_ocr:true}.
511                        if !map.is_empty() {
512                            args.push(emit_extraction_config_dart(map));
513                        }
514                    }
515                    // If config is null/absent, the wrapper supplies the default ExtractionConfig.
516                } else if arg_value.is_array() {
517                    // Generic JSON array (e.g. batch_urls: ["/page1", "/page2"]).
518                    // Decode via jsonDecode and cast to List<String> at test-run time.
519                    let json_str = serde_json::to_string(&arg_value).unwrap_or_default();
520                    let var_name = arg_def.name.clone();
521                    setup_lines.push(format!(
522                        "final {var_name} = (jsonDecode(r'{json_str}') as List<dynamic>).cast<String>();"
523                    ));
524                    args.push(var_name);
525                }
526            }
527            _ => {}
528        }
529    }
530
531    // Resolve client_factory: when set, tests create a client instance and call
532    // methods on it rather than using static bridge-class calls. This mirrors the
533    // go/python/zig pattern for stateful clients (e.g. liter-llm).
534    let client_factory: Option<&str> = call_overrides.and_then(|o| o.client_factory.as_deref()).or_else(|| {
535        e2e_config
536            .call
537            .overrides
538            .get(lang)
539            .and_then(|o| o.client_factory.as_deref())
540    });
541
542    // Convert factory name to camelCase (same rule as function_name above).
543    let client_factory_camel: Option<String> = client_factory.map(|f| {
544        f.split('_')
545            .enumerate()
546            .map(|(i, part)| {
547                if i == 0 {
548                    part.to_string()
549                } else {
550                    let mut chars = part.chars();
551                    match chars.next() {
552                        None => String::new(),
553                        Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
554                    }
555                }
556            })
557            .collect::<Vec<_>>()
558            .join("")
559    });
560
561    // All bridge methods return Future<T> because FRB v2 wraps every Rust
562    // function as async in Dart — even "sync" Rust functions. Always emit an async
563    // test body and await the call so the test framework waits for the future.
564    let _ = writeln!(out, "  test('{description}', () async {{");
565
566    let args_str = args.join(", ");
567    let receiver_class = call_overrides
568        .and_then(|o| o.class.as_ref())
569        .cloned()
570        .unwrap_or_else(|| bridge_class.to_string());
571
572    // When client_factory is set, determine the mock URL and emit client instantiation.
573    // The mock URL derivation follows the same has_host_root_route / plain-fixture split
574    // used by the mock_url arg handler above.
575    let (receiver, extra_setup): (String, Option<String>) = if let Some(factory) = &client_factory_camel {
576        let has_mock_url = call_config.args.iter().any(|a| a.arg_type == "mock_url");
577        let mock_url_setup = if !has_mock_url {
578            // No explicit mock_url arg — derive the URL inline.
579            if fixture.has_host_root_route() {
580                let env_key = format!("MOCK_SERVER_{}", fixture_id.to_uppercase());
581                Some(format!(
582                    "final _mockUrl = Platform.environment[\"{env_key}\"] ?? (Platform.environment[\"MOCK_SERVER_URL\"]! + \"/fixtures/{fixture_id}\");"
583                ))
584            } else {
585                Some(format!(
586                    r#"final _mockUrl = "${{Platform.environment["MOCK_SERVER_URL"] ?? "http://localhost:8080"}}/fixtures/{fixture_id}";"#
587                ))
588            }
589        } else {
590            None
591        };
592        let url_expr = if has_mock_url {
593            // A mock_url arg was emitted into setup_lines already — reuse the variable name
594            // from the first mock_url arg definition so we don't duplicate the URL.
595            call_config
596                .args
597                .iter()
598                .find(|a| a.arg_type == "mock_url")
599                .map(|a| a.name.clone())
600                .unwrap_or_else(|| "_mockUrl".to_string())
601        } else {
602            "_mockUrl".to_string()
603        };
604        let create_line = format!("final _client = await {receiver_class}.{factory}('test-key', baseUrl: {url_expr});");
605        let full_setup = if let Some(url_line) = mock_url_setup {
606            Some(format!("{url_line}\n    {create_line}"))
607        } else {
608            Some(create_line)
609        };
610        ("_client".to_string(), full_setup)
611    } else {
612        (receiver_class.clone(), None)
613    };
614
615    if expects_error && (!setup_lines.is_empty() || extra_setup.is_some()) {
616        // Wrap setup + call in an async lambda so any exception at any step is caught.
617        // flutter_rust_bridge 2.x decodes Rust errors as raw String values (not Exception
618        // subtypes), so throwsException will not match. Use throwsA(anything) instead.
619        let _ = writeln!(out, "    await expectLater(() async {{");
620        for line in &setup_lines {
621            let _ = writeln!(out, "      {line}");
622        }
623        if let Some(extra) = &extra_setup {
624            for line in extra.lines() {
625                let _ = writeln!(out, "      {line}");
626            }
627        }
628        if is_streaming {
629            let _ = writeln!(out, "      return {receiver}.{function_name}({args_str}).toList();");
630        } else {
631            let _ = writeln!(out, "      return {receiver}.{function_name}({args_str});");
632        }
633        let _ = writeln!(out, "    }}(), throwsA(anything));");
634    } else if expects_error {
635        // No setup lines, direct call — same throwsA(anything) rationale as above.
636        if let Some(extra) = &extra_setup {
637            for line in extra.lines() {
638                let _ = writeln!(out, "    {line}");
639            }
640        }
641        if is_streaming {
642            let _ = writeln!(
643                out,
644                "    await expectLater({receiver}.{function_name}({args_str}).toList(), throwsA(anything));"
645            );
646        } else {
647            let _ = writeln!(
648                out,
649                "    await expectLater({receiver}.{function_name}({args_str}), throwsA(anything));"
650            );
651        }
652    } else {
653        for line in &setup_lines {
654            let _ = writeln!(out, "    {line}");
655        }
656        if let Some(extra) = &extra_setup {
657            for line in extra.lines() {
658                let _ = writeln!(out, "    {line}");
659            }
660        }
661        if is_streaming {
662            let _ = writeln!(
663                out,
664                "    final {result_var} = await {receiver}.{function_name}({args_str}).toList();"
665            );
666        } else {
667            let _ = writeln!(
668                out,
669                "    final {result_var} = await {receiver}.{function_name}({args_str});"
670            );
671        }
672        for assertion in &fixture.assertions {
673            if is_streaming {
674                render_streaming_assertion_dart(out, assertion, result_var);
675            } else {
676                render_assertion_dart(out, assertion, result_var);
677            }
678        }
679    }
680
681    let _ = writeln!(out, "  }});");
682    let _ = writeln!(out);
683}
684
685/// Render a single fixture assertion as a Dart `package:test` `expect(...)` call.
686///
687/// Field paths are converted per-segment to camelCase (FRB v2 convention) using
688/// [`field_to_dart_accessor`].  All 24 fixture assertion types are handled.
689fn render_assertion_dart(out: &mut String, assertion: &Assertion, result_var: &str) {
690    let field_accessor = match assertion.field.as_deref() {
691        Some(f) if !f.is_empty() => format!("{result_var}.{}", field_to_dart_accessor(f)),
692        _ => result_var.to_string(),
693    };
694
695    let format_value = |val: &serde_json::Value| -> String {
696        match val {
697            serde_json::Value::String(s) => format!("'{}'", escape_dart(s)),
698            serde_json::Value::Bool(b) => b.to_string(),
699            serde_json::Value::Number(n) => n.to_string(),
700            serde_json::Value::Null => "null".to_string(),
701            other => format!("'{}'", escape_dart(&other.to_string())),
702        }
703    };
704
705    match assertion.assertion_type.as_str() {
706        "equals" | "field_equals" => {
707            if let Some(expected) = &assertion.value {
708                let dart_val = format_value(expected);
709                let _ = writeln!(out, "    expect({field_accessor}, equals({dart_val}));");
710            } else {
711                let _ = writeln!(
712                    out,
713                    "    // skipped: '{}' assertion missing value",
714                    assertion.assertion_type
715                );
716            }
717        }
718        "not_equals" => {
719            if let Some(expected) = &assertion.value {
720                let dart_val = format_value(expected);
721                let _ = writeln!(out, "    expect({field_accessor}, isNot(equals({dart_val})));");
722            }
723        }
724        "contains" => {
725            if let Some(expected) = &assertion.value {
726                let dart_val = format_value(expected);
727                let _ = writeln!(out, "    expect({field_accessor}, contains({dart_val}));");
728            } else {
729                let _ = writeln!(out, "    // skipped: 'contains' assertion missing value");
730            }
731        }
732        "contains_all" => {
733            if let Some(values) = &assertion.values {
734                for val in values {
735                    let dart_val = format_value(val);
736                    let _ = writeln!(out, "    expect({field_accessor}, contains({dart_val}));");
737                }
738            }
739        }
740        "contains_any" => {
741            if let Some(values) = &assertion.values {
742                let checks: Vec<String> = values
743                    .iter()
744                    .map(|v| {
745                        let dart_val = format_value(v);
746                        format!("{field_accessor}.contains({dart_val})")
747                    })
748                    .collect();
749                let joined = checks.join(" || ");
750                let _ = writeln!(out, "    expect({joined}, isTrue);");
751            }
752        }
753        "not_contains" => {
754            if let Some(expected) = &assertion.value {
755                let dart_val = format_value(expected);
756                let _ = writeln!(out, "    expect({field_accessor}, isNot(contains({dart_val})));");
757            } else if let Some(values) = &assertion.values {
758                for val in values {
759                    let dart_val = format_value(val);
760                    let _ = writeln!(out, "    expect({field_accessor}, isNot(contains({dart_val})));");
761                }
762            }
763        }
764        "not_empty" => {
765            let _ = writeln!(out, "    expect({field_accessor}, isNotEmpty);");
766        }
767        "is_empty" => {
768            let _ = writeln!(out, "    expect({field_accessor}, isEmpty);");
769        }
770        "starts_with" => {
771            if let Some(expected) = &assertion.value {
772                let dart_val = format_value(expected);
773                let _ = writeln!(out, "    expect({field_accessor}, startsWith({dart_val}));");
774            }
775        }
776        "ends_with" => {
777            if let Some(expected) = &assertion.value {
778                let dart_val = format_value(expected);
779                let _ = writeln!(out, "    expect({field_accessor}, endsWith({dart_val}));");
780            }
781        }
782        "min_length" => {
783            if let Some(val) = &assertion.value {
784                if let Some(n) = val.as_u64() {
785                    let _ = writeln!(out, "    expect({field_accessor}.length, greaterThanOrEqualTo({n}));");
786                }
787            }
788        }
789        "max_length" => {
790            if let Some(val) = &assertion.value {
791                if let Some(n) = val.as_u64() {
792                    let _ = writeln!(out, "    expect({field_accessor}.length, lessThanOrEqualTo({n}));");
793                }
794            }
795        }
796        "count_equals" => {
797            if let Some(val) = &assertion.value {
798                if let Some(n) = val.as_u64() {
799                    let _ = writeln!(out, "    expect({field_accessor}.length, equals({n}));");
800                }
801            }
802        }
803        "count_min" => {
804            if let Some(val) = &assertion.value {
805                if let Some(n) = val.as_u64() {
806                    let _ = writeln!(out, "    expect({field_accessor}.length, greaterThanOrEqualTo({n}));");
807                }
808            }
809        }
810        "matches_regex" => {
811            if let Some(expected) = &assertion.value {
812                let dart_val = format_value(expected);
813                let _ = writeln!(out, "    expect({field_accessor}, matches(RegExp({dart_val})));");
814            }
815        }
816        "is_true" => {
817            let _ = writeln!(out, "    expect({field_accessor}, isTrue);");
818        }
819        "is_false" => {
820            let _ = writeln!(out, "    expect({field_accessor}, isFalse);");
821        }
822        "greater_than" => {
823            if let Some(val) = &assertion.value {
824                let dart_val = format_value(val);
825                let _ = writeln!(out, "    expect({field_accessor}, greaterThan({dart_val}));");
826            }
827        }
828        "less_than" => {
829            if let Some(val) = &assertion.value {
830                let dart_val = format_value(val);
831                let _ = writeln!(out, "    expect({field_accessor}, lessThan({dart_val}));");
832            }
833        }
834        "greater_than_or_equal" => {
835            if let Some(val) = &assertion.value {
836                let dart_val = format_value(val);
837                let _ = writeln!(out, "    expect({field_accessor}, greaterThanOrEqualTo({dart_val}));");
838            }
839        }
840        "less_than_or_equal" => {
841            if let Some(val) = &assertion.value {
842                let dart_val = format_value(val);
843                let _ = writeln!(out, "    expect({field_accessor}, lessThanOrEqualTo({dart_val}));");
844            }
845        }
846        "not_null" => {
847            let _ = writeln!(out, "    expect({field_accessor}, isNotNull);");
848        }
849        "not_error" => {
850            // No-op: a thrown error from `await` would have failed the test already.
851        }
852        "error" => {
853            // Handled at the test method level via throwsA(anything).
854        }
855        "method_result" => {
856            if let Some(method) = &assertion.method {
857                let dart_method = method.to_lower_camel_case();
858                let check = assertion.check.as_deref().unwrap_or("not_null");
859                let method_call = format!("{field_accessor}.{dart_method}()");
860                match check {
861                    "equals" => {
862                        if let Some(expected) = &assertion.value {
863                            let dart_val = format_value(expected);
864                            let _ = writeln!(out, "    expect({method_call}, equals({dart_val}));");
865                        }
866                    }
867                    "is_true" => {
868                        let _ = writeln!(out, "    expect({method_call}, isTrue);");
869                    }
870                    "is_false" => {
871                        let _ = writeln!(out, "    expect({method_call}, isFalse);");
872                    }
873                    "greater_than_or_equal" => {
874                        if let Some(val) = &assertion.value {
875                            let dart_val = format_value(val);
876                            let _ = writeln!(out, "    expect({method_call}, greaterThanOrEqualTo({dart_val}));");
877                        }
878                    }
879                    "count_min" => {
880                        if let Some(val) = &assertion.value {
881                            if let Some(n) = val.as_u64() {
882                                let _ = writeln!(out, "    expect({method_call}.length, greaterThanOrEqualTo({n}));");
883                            }
884                        }
885                    }
886                    _ => {
887                        let _ = writeln!(out, "    expect({method_call}, isNotNull);");
888                    }
889                }
890            }
891        }
892        other => {
893            let _ = writeln!(out, "    // skipped: unknown assertion type '{other}'");
894        }
895    }
896}
897
898/// Render a single fixture assertion for a streaming result.
899///
900/// `result_var` is the `List<T>` collected via `.toList()` on the stream.
901/// Supports:
902/// - `not_error`: no-op (a thrown error would already fail the test).
903/// - `count_min` with `field = "chunks"`: assert `result_var.length >= value`.
904/// - `equals` with `field = "stream_content"`: concatenate `delta.content` and compare.
905/// Other assertion types are emitted as comments.
906fn render_streaming_assertion_dart(out: &mut String, assertion: &Assertion, result_var: &str) {
907    match assertion.assertion_type.as_str() {
908        "not_error" => {
909            // No-op: a thrown error from `.toList()` would have failed the test already.
910        }
911        "count_min" if assertion.field.as_deref() == Some("chunks") => {
912            if let Some(serde_json::Value::Number(n)) = &assertion.value {
913                let _ = writeln!(out, "    expect({result_var}.length, greaterThanOrEqualTo({n}));");
914            }
915        }
916        "equals" if assertion.field.as_deref() == Some("stream_content") => {
917            if let Some(serde_json::Value::String(expected)) = &assertion.value {
918                let escaped = escape_dart(expected);
919                let _ = writeln!(
920                    out,
921                    "    final _content = {result_var}.map((c) => c.choices.firstOrNull?.delta.content ?? '').join();"
922                );
923                let _ = writeln!(out, "    expect(_content, equals('{escaped}'));");
924            }
925        }
926        other => {
927            let _ = writeln!(out, "    // skipped streaming assertion: '{other}'");
928        }
929    }
930}
931
932/// Converts a snake_case JSON key to Dart camelCase.
933fn snake_to_camel(s: &str) -> String {
934    let mut result = String::with_capacity(s.len());
935    let mut next_upper = false;
936    for ch in s.chars() {
937        if ch == '_' {
938            next_upper = true;
939        } else if next_upper {
940            result.extend(ch.to_uppercase());
941            next_upper = false;
942        } else {
943            result.push(ch);
944        }
945    }
946    result
947}
948
949/// Convert a dot-separated fixture field path to a Dart accessor expression.
950///
951/// Each segment is converted to camelCase (FRB v2 convention); array-index brackets
952/// (e.g. `choices[0]`) and map-key brackets (e.g. `tags[name]`) are preserved.
953/// This replaces the former single-pass `snake_to_camel` call which incorrectly
954/// treated the entire path string as one identifier.
955///
956/// Examples:
957/// - `"choices"` → `"choices"`
958/// - `"choices[0].message.content"` → `"choices[0].message.content"`
959/// - `"metadata.document_title"` → `"metadata.documentTitle"`
960/// - `"model_id"` → `"modelId"`
961fn field_to_dart_accessor(path: &str) -> String {
962    let mut result = String::with_capacity(path.len());
963    for (i, segment) in path.split('.').enumerate() {
964        if i > 0 {
965            result.push('.');
966        }
967        // Separate a trailing `[...]` bracket from the field name so we only
968        // camelCase the identifier part, not the bracket content.
969        if let Some(bracket_pos) = segment.find('[') {
970            let name = &segment[..bracket_pos];
971            let bracket = &segment[bracket_pos..];
972            result.push_str(&name.to_lower_camel_case());
973            result.push_str(bracket);
974        } else {
975            result.push_str(&segment.to_lower_camel_case());
976        }
977    }
978    result
979}
980
981/// Emits a Dart `ExtractionConfig(...)` constructor with default values, overriding
982/// fields present in `overrides` (from fixture JSON, snake_case keys).
983///
984/// Only simple scalar overrides (bool, int) are supported. Complex nested types
985/// (ocr, chunking, etc.) are left at their defaults (null).
986fn emit_extraction_config_dart(overrides: &serde_json::Map<String, serde_json::Value>) -> String {
987    // Collect scalar overrides; convert keys to camelCase.
988    let mut field_overrides: std::collections::HashMap<String, String> = std::collections::HashMap::new();
989    for (key, val) in overrides {
990        let camel = snake_to_camel(key);
991        let dart_val = match val {
992            serde_json::Value::Bool(b) => {
993                if *b {
994                    "true".to_string()
995                } else {
996                    "false".to_string()
997                }
998            }
999            serde_json::Value::Number(n) => n.to_string(),
1000            serde_json::Value::String(s) => format!("'{s}'"),
1001            _ => continue, // skip complex nested objects
1002        };
1003        field_overrides.insert(camel, dart_val);
1004    }
1005
1006    let use_cache = field_overrides.remove("useCache").unwrap_or_else(|| "true".to_string());
1007    let enable_quality_processing = field_overrides
1008        .remove("enableQualityProcessing")
1009        .unwrap_or_else(|| "true".to_string());
1010    let force_ocr = field_overrides
1011        .remove("forceOcr")
1012        .unwrap_or_else(|| "false".to_string());
1013    let disable_ocr = field_overrides
1014        .remove("disableOcr")
1015        .unwrap_or_else(|| "false".to_string());
1016    let include_document_structure = field_overrides
1017        .remove("includeDocumentStructure")
1018        .unwrap_or_else(|| "false".to_string());
1019    let max_archive_depth = field_overrides
1020        .remove("maxArchiveDepth")
1021        .unwrap_or_else(|| "3".to_string());
1022
1023    format!(
1024        "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})"
1025    )
1026}
1027
1028// ---------------------------------------------------------------------------
1029// HTTP server test rendering — DartTestClientRenderer impl + thin driver wrapper
1030// ---------------------------------------------------------------------------
1031
1032/// Renderer that emits `package:test` `test(...)` blocks using `dart:io HttpClient`
1033/// against the mock server (`Platform.environment['MOCK_SERVER_URL']`).
1034///
1035/// Skipped tests are emitted as self-contained stubs (complete test block with
1036/// `markTestSkipped`) entirely inside `render_test_open`. `render_test_close` uses
1037/// `in_skip` to emit the right closing token: nothing extra for skip stubs (already
1038/// closed) vs. `})));` for regular tests.
1039///
1040/// `is_redirect` must be set to `true` before invoking the shared driver for 3xx
1041/// fixtures so that `render_call` can inject `ioReq.followRedirects = false` after
1042/// the `openUrl` call.
1043struct DartTestClientRenderer {
1044    /// Set to `true` when `render_test_open` is called with a skip reason so that
1045    /// `render_test_close` can match the opening shape.
1046    in_skip: Cell<bool>,
1047    /// Pre-set to `true` by the thin wrapper when the fixture expects a 3xx response.
1048    /// `render_call` injects `ioReq.followRedirects = false` when this is `true`.
1049    is_redirect: Cell<bool>,
1050}
1051
1052impl DartTestClientRenderer {
1053    fn new(is_redirect: bool) -> Self {
1054        Self {
1055            in_skip: Cell::new(false),
1056            is_redirect: Cell::new(is_redirect),
1057        }
1058    }
1059}
1060
1061impl client::TestClientRenderer for DartTestClientRenderer {
1062    fn language_name(&self) -> &'static str {
1063        "dart"
1064    }
1065
1066    /// Emit the test opening.
1067    ///
1068    /// For skipped fixtures: emit the entire self-contained stub (open + body +
1069    /// close + blank line) and set `in_skip = true` so `render_test_close` is a
1070    /// no-op.
1071    ///
1072    /// For active fixtures: emit `test('desc', () => _serialized(() => _withRetry(() async {`
1073    /// leaving the block open for the assertion primitives.
1074    fn render_test_open(&self, out: &mut String, _fn_name: &str, description: &str, skip_reason: Option<&str>) {
1075        let escaped_desc = escape_dart(description);
1076        if let Some(reason) = skip_reason {
1077            let escaped_reason = escape_dart(reason);
1078            let _ = writeln!(out, "  test('{escaped_desc}', () {{");
1079            let _ = writeln!(out, "    markTestSkipped('{escaped_reason}');");
1080            let _ = writeln!(out, "  }});");
1081            let _ = writeln!(out);
1082            self.in_skip.set(true);
1083        } else {
1084            let _ = writeln!(
1085                out,
1086                "  test('{escaped_desc}', () => _serialized(() => _withRetry(() async {{"
1087            );
1088            self.in_skip.set(false);
1089        }
1090    }
1091
1092    /// Emit the test closing token.
1093    ///
1094    /// No-op for skip stubs (the stub was fully closed in `render_test_open`).
1095    /// Emits `})));` followed by a blank line for regular tests.
1096    fn render_test_close(&self, out: &mut String) {
1097        if self.in_skip.get() {
1098            // Stub was already closed in render_test_open.
1099            return;
1100        }
1101        let _ = writeln!(out, "  }})));");
1102        let _ = writeln!(out);
1103    }
1104
1105    /// Emit the full `dart:io HttpClient` request scaffolding.
1106    ///
1107    /// Emits:
1108    /// - URL construction from `MOCK_SERVER_URL`.
1109    /// - `_httpClient.openUrl(method, uri)`.
1110    /// - `followRedirects = false` when `is_redirect` is pre-set on the renderer.
1111    /// - Content-Type header, request headers, cookies, optional body bytes.
1112    /// - `ioReq.close()` → `ioResp`.
1113    /// - Response-body drain into `bodyStr` (skipped for redirect responses).
1114    fn render_call(&self, out: &mut String, ctx: &client::CallCtx<'_>) {
1115        // dart:io restricted headers (handled automatically by the HTTP stack).
1116        const DART_RESTRICTED_HEADERS: &[&str] = &["content-length", "host", "transfer-encoding"];
1117
1118        let method = ctx.method.to_uppercase();
1119        let escaped_method = escape_dart(&method);
1120
1121        // Fixture path is `/fixtures/<id>` — extract the id portion for URL construction.
1122        let fixture_path = escape_dart(ctx.path);
1123
1124        // Determine effective content-type.
1125        let has_explicit_content_type = ctx.headers.keys().any(|k| k.to_lowercase() == "content-type");
1126        let effective_content_type = if has_explicit_content_type {
1127            ctx.headers
1128                .iter()
1129                .find(|(k, _)| k.to_lowercase() == "content-type")
1130                .map(|(_, v)| v.as_str())
1131                .unwrap_or("application/json")
1132        } else if ctx.body.is_some() {
1133            ctx.content_type.unwrap_or("application/json")
1134        } else {
1135            ""
1136        };
1137
1138        let _ = writeln!(
1139            out,
1140            "    final baseUrl = Platform.environment['MOCK_SERVER_URL'] ?? 'http://localhost:8080';"
1141        );
1142        let _ = writeln!(out, "    final uri = Uri.parse('$baseUrl{fixture_path}');");
1143        let _ = writeln!(
1144            out,
1145            "    final ioReq = await _httpClient.openUrl('{escaped_method}', uri);"
1146        );
1147
1148        // Disable automatic redirect following for 3xx fixtures so the test can
1149        // assert on the redirect status code itself.
1150        if self.is_redirect.get() {
1151            let _ = writeln!(out, "    ioReq.followRedirects = false;");
1152        }
1153
1154        // Set content-type header.
1155        if !effective_content_type.is_empty() {
1156            let escaped_ct = escape_dart(effective_content_type);
1157            let _ = writeln!(out, "    ioReq.headers.set('content-type', '{escaped_ct}');");
1158        }
1159
1160        // Set request headers (skip dart:io restricted headers and content-type, already handled).
1161        let mut header_pairs: Vec<(&String, &String)> = ctx.headers.iter().collect();
1162        header_pairs.sort_by_key(|(k, _)| k.as_str());
1163        for (name, value) in &header_pairs {
1164            if DART_RESTRICTED_HEADERS.contains(&name.to_lowercase().as_str()) {
1165                continue;
1166            }
1167            if name.to_lowercase() == "content-type" {
1168                continue; // Already handled above.
1169            }
1170            let escaped_name = escape_dart(&name.to_lowercase());
1171            let escaped_value = escape_dart(value);
1172            let _ = writeln!(out, "    ioReq.headers.set('{escaped_name}', '{escaped_value}');");
1173        }
1174
1175        // Add cookies.
1176        if !ctx.cookies.is_empty() {
1177            let mut cookie_pairs: Vec<(&String, &String)> = ctx.cookies.iter().collect();
1178            cookie_pairs.sort_by_key(|(k, _)| k.as_str());
1179            let cookie_str: Vec<String> = cookie_pairs.iter().map(|(k, v)| format!("{k}={v}")).collect();
1180            let cookie_header = escape_dart(&cookie_str.join("; "));
1181            let _ = writeln!(out, "    ioReq.headers.set('cookie', '{cookie_header}');");
1182        }
1183
1184        // Write body bytes if present (bypass charset-based encoding issues).
1185        if let Some(body) = ctx.body {
1186            let json_str = serde_json::to_string(body).unwrap_or_default();
1187            let escaped = escape_dart(&json_str);
1188            let _ = writeln!(out, "    final bodyBytes = utf8.encode('{escaped}');");
1189            let _ = writeln!(out, "    ioReq.add(bodyBytes);");
1190        }
1191
1192        let _ = writeln!(out, "    final ioResp = await ioReq.close();");
1193        // Drain the response body to bind `bodyStr` for assertion primitives and to
1194        // allow the server to cleanly close the connection (prevents RST packets).
1195        // Redirect responses have no body to drain — skip to avoid a potential hang.
1196        if !self.is_redirect.get() {
1197            let _ = writeln!(out, "    final bodyStr = await ioResp.transform(utf8.decoder).join();");
1198        };
1199    }
1200
1201    fn render_assert_status(&self, out: &mut String, _response_var: &str, status: u16) {
1202        let _ = writeln!(
1203            out,
1204            "    expect(ioResp.statusCode, equals({status}), reason: 'status code mismatch');"
1205        );
1206    }
1207
1208    /// Emit a single header assertion, handling special tokens `<<present>>`,
1209    /// `<<absent>>`, and `<<uuid>>`.
1210    fn render_assert_header(&self, out: &mut String, _response_var: &str, name: &str, expected: &str) {
1211        let escaped_name = escape_dart(&name.to_lowercase());
1212        match expected {
1213            "<<present>>" => {
1214                let _ = writeln!(
1215                    out,
1216                    "    expect(ioResp.headers.value('{escaped_name}'), isNotNull, reason: 'header {escaped_name} should be present');"
1217                );
1218            }
1219            "<<absent>>" => {
1220                let _ = writeln!(
1221                    out,
1222                    "    expect(ioResp.headers.value('{escaped_name}'), isNull, reason: 'header {escaped_name} should be absent');"
1223                );
1224            }
1225            "<<uuid>>" => {
1226                let _ = writeln!(
1227                    out,
1228                    "    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');"
1229                );
1230            }
1231            exact => {
1232                let escaped_value = escape_dart(exact);
1233                let _ = writeln!(
1234                    out,
1235                    "    expect(ioResp.headers.value('{escaped_name}'), contains('{escaped_value}'), reason: 'header {escaped_name} mismatch');"
1236                );
1237            }
1238        }
1239    }
1240
1241    /// Emit an exact-equality body assertion.
1242    ///
1243    /// String bodies are compared as decoded text; structured JSON bodies are
1244    /// compared via `jsonDecode`.
1245    fn render_assert_json_body(&self, out: &mut String, _response_var: &str, expected: &serde_json::Value) {
1246        match expected {
1247            serde_json::Value::Object(_) | serde_json::Value::Array(_) => {
1248                let json_str = serde_json::to_string(expected).unwrap_or_default();
1249                let escaped = escape_dart(&json_str);
1250                let _ = writeln!(out, "    final bodyJson = jsonDecode(bodyStr);");
1251                let _ = writeln!(out, "    final expectedJson = jsonDecode('{escaped}');");
1252                let _ = writeln!(
1253                    out,
1254                    "    expect(bodyJson, equals(expectedJson), reason: 'body mismatch');"
1255                );
1256            }
1257            serde_json::Value::String(s) => {
1258                let escaped = escape_dart(s);
1259                let _ = writeln!(
1260                    out,
1261                    "    expect(bodyStr.trim(), equals('{escaped}'), reason: 'body mismatch');"
1262                );
1263            }
1264            other => {
1265                let escaped = escape_dart(&other.to_string());
1266                let _ = writeln!(
1267                    out,
1268                    "    expect(bodyStr.trim(), equals('{escaped}'), reason: 'body mismatch');"
1269                );
1270            }
1271        }
1272    }
1273
1274    /// Emit partial-body assertions — every key in `expected` must match the
1275    /// corresponding field in the parsed JSON response.
1276    fn render_assert_partial_body(&self, out: &mut String, _response_var: &str, expected: &serde_json::Value) {
1277        let _ = writeln!(
1278            out,
1279            "    final partialJson = jsonDecode(bodyStr) as Map<String, dynamic>;"
1280        );
1281        if let Some(obj) = expected.as_object() {
1282            for (idx, (key, val)) in obj.iter().enumerate() {
1283                let escaped_key = escape_dart(key);
1284                let json_val = serde_json::to_string(val).unwrap_or_default();
1285                let escaped_val = escape_dart(&json_val);
1286                // Use an index-based variable name so keys with special characters
1287                // don't produce invalid Dart identifiers.
1288                let _ = writeln!(out, "    final _expectedField{idx} = jsonDecode('{escaped_val}');");
1289                let _ = writeln!(
1290                    out,
1291                    "    expect(partialJson['{escaped_key}'], equals(_expectedField{idx}), reason: 'partial body field \\'{escaped_key}\\' mismatch');"
1292                );
1293            }
1294        }
1295    }
1296
1297    /// Emit validation-error assertions for 422 responses.
1298    fn render_assert_validation_errors(
1299        &self,
1300        out: &mut String,
1301        _response_var: &str,
1302        errors: &[ValidationErrorExpectation],
1303    ) {
1304        let _ = writeln!(out, "    final errBody = jsonDecode(bodyStr) as Map<String, dynamic>;");
1305        let _ = writeln!(out, "    final errList = (errBody['errors'] ?? []) as List<dynamic>;");
1306        for ve in errors {
1307            let loc_dart: Vec<String> = ve.loc.iter().map(|s| format!("'{}'", escape_dart(s))).collect();
1308            let loc_str = loc_dart.join(", ");
1309            let escaped_msg = escape_dart(&ve.msg);
1310            let _ = writeln!(
1311                out,
1312                "    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}');"
1313            );
1314        }
1315    }
1316}
1317
1318/// Render a `package:test` `test(...)` block for an HTTP server fixture.
1319///
1320/// Delegates to the shared [`client::http_call::render_http_test`] driver via
1321/// [`DartTestClientRenderer`]. HTTP 101 (WebSocket upgrade) fixtures are emitted
1322/// as skip stubs before reaching the driver because `dart:io HttpClient` cannot
1323/// handle protocol-switch responses.
1324fn render_http_test_case(out: &mut String, fixture: &Fixture, http: &HttpFixture) {
1325    // HTTP 101 (WebSocket upgrade) — dart:io HttpClient cannot handle upgrade responses.
1326    if http.expected_response.status_code == 101 {
1327        let description = escape_dart(&fixture.description);
1328        let _ = writeln!(out, "  test('{description}', () {{");
1329        let _ = writeln!(
1330            out,
1331            "    markTestSkipped('Skipped: Dart HttpClient cannot handle 101 Switching Protocols responses');"
1332        );
1333        let _ = writeln!(out, "  }});");
1334        let _ = writeln!(out);
1335        return;
1336    }
1337
1338    // Pre-set `is_redirect` on the renderer so `render_call` can inject
1339    // `ioReq.followRedirects = false` for 3xx fixtures. The shared driver has no
1340    // concept of expected status code so we thread it through renderer state.
1341    let is_redirect = http.expected_response.status_code / 100 == 3;
1342    client::http_call::render_http_test(out, &DartTestClientRenderer::new(is_redirect), fixture);
1343}
1344
1345/// Infer a MIME type from a file path extension.
1346///
1347/// Returns `None` when the extension is unknown so the caller can supply a fallback.
1348/// Used in dart e2e tests when a fixture omits `mime_type` but uses a `file_path` arg.
1349fn mime_from_extension(path: &str) -> Option<&'static str> {
1350    let ext = path.rsplit('.').next()?;
1351    match ext.to_lowercase().as_str() {
1352        "docx" => Some("application/vnd.openxmlformats-officedocument.wordprocessingml.document"),
1353        "xlsx" => Some("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"),
1354        "pptx" => Some("application/vnd.openxmlformats-officedocument.presentationml.presentation"),
1355        "pdf" => Some("application/pdf"),
1356        "txt" | "text" => Some("text/plain"),
1357        "html" | "htm" => Some("text/html"),
1358        "json" => Some("application/json"),
1359        "xml" => Some("application/xml"),
1360        "csv" => Some("text/csv"),
1361        "md" | "markdown" => Some("text/markdown"),
1362        "png" => Some("image/png"),
1363        "jpg" | "jpeg" => Some("image/jpeg"),
1364        "gif" => Some("image/gif"),
1365        "zip" => Some("application/zip"),
1366        "odt" => Some("application/vnd.oasis.opendocument.text"),
1367        "ods" => Some("application/vnd.oasis.opendocument.spreadsheet"),
1368        "odp" => Some("application/vnd.oasis.opendocument.presentation"),
1369        "rtf" => Some("application/rtf"),
1370        "epub" => Some("application/epub+zip"),
1371        "msg" => Some("application/vnd.ms-outlook"),
1372        "eml" => Some("message/rfc822"),
1373        _ => None,
1374    }
1375}
1376
1377/// Emit Dart constructors for a batch item array (`BatchBytesItem` or `BatchFileItem`).
1378///
1379/// Returns a Dart list literal like:
1380/// ```dart
1381/// [BatchBytesItem(content: Uint8List.fromList([72, 101, ...]), mimeType: 'text/plain')]
1382/// ```
1383fn emit_dart_batch_item_array(arr: &serde_json::Value, elem_type: &str) -> String {
1384    let items: Vec<String> = arr
1385        .as_array()
1386        .map(|a| a.as_slice())
1387        .unwrap_or_default()
1388        .iter()
1389        .filter_map(|item| {
1390            let obj = item.as_object()?;
1391            match elem_type {
1392                "BatchBytesItem" => {
1393                    let content_bytes = obj
1394                        .get("content")
1395                        .and_then(|v| v.as_array())
1396                        .map(|arr| {
1397                            let nums: Vec<String> =
1398                                arr.iter().filter_map(|v| v.as_u64().map(|n| n.to_string())).collect();
1399                            format!("Uint8List.fromList([{}])", nums.join(", "))
1400                        })
1401                        .unwrap_or_else(|| "Uint8List(0)".to_string());
1402                    let mime_type = obj
1403                        .get("mime_type")
1404                        .and_then(|v| v.as_str())
1405                        .unwrap_or("application/octet-stream");
1406                    Some(format!(
1407                        "BatchBytesItem(content: {content_bytes}, mimeType: '{}')",
1408                        escape_dart(mime_type)
1409                    ))
1410                }
1411                "BatchFileItem" => {
1412                    let path = obj.get("path").and_then(|v| v.as_str()).unwrap_or("");
1413                    Some(format!("BatchFileItem(path: '{}')", escape_dart(path)))
1414                }
1415                _ => None,
1416            }
1417        })
1418        .collect();
1419    format!("[{}]", items.join(", "))
1420}
1421
1422/// Escape a string for embedding in a Dart single-quoted string literal.
1423fn escape_dart(s: &str) -> String {
1424    s.replace('\\', "\\\\")
1425        .replace('\'', "\\'")
1426        .replace('\n', "\\n")
1427        .replace('\r', "\\r")
1428        .replace('\t', "\\t")
1429        .replace('$', "\\$")
1430}
1431
1432/// Derive the Dart top-level helper function name for constructing a mirror type from JSON.
1433///
1434/// The alef dart bridge-crate generator emits a Rust free function
1435/// `create_<snake_type>_from_json(json: String)` for each non-opaque mirror struct.
1436/// FRB generates the corresponding Dart function as `createTypeNameFromJson` (camelCase).
1437///
1438/// Example: `"ChatCompletionRequest"` → `"createChatCompletionRequestFromJson"`.
1439fn type_name_to_create_from_json_dart(type_name: &str) -> String {
1440    // Convert PascalCase type name to snake_case.
1441    let mut snake = String::with_capacity(type_name.len() + 8);
1442    for (i, ch) in type_name.char_indices() {
1443        if ch.is_uppercase() {
1444            if i > 0 {
1445                snake.push('_');
1446            }
1447            snake.extend(ch.to_lowercase());
1448        } else {
1449            snake.push(ch);
1450        }
1451    }
1452    // snake is now e.g. "chat_completion_request"
1453    // Full Rust function name: "create_chat_completion_request_from_json"
1454    let rust_fn = format!("create_{snake}_from_json");
1455    // Convert to Dart camelCase: "createChatCompletionRequestFromJson"
1456    rust_fn
1457        .split('_')
1458        .enumerate()
1459        .map(|(i, part)| {
1460            if i == 0 {
1461                part.to_string()
1462            } else {
1463                let mut chars = part.chars();
1464                match chars.next() {
1465                    None => String::new(),
1466                    Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
1467                }
1468            }
1469        })
1470        .collect::<Vec<_>>()
1471        .join("")
1472}