Skip to main content

alef_e2e/codegen/
dart.rs

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