Skip to main content

alef_e2e/codegen/
csharp.rs

1//! C# e2e test generator using xUnit.
2//!
3//! Generates `e2e/csharp/E2eTests.csproj` and `tests/{Category}Tests.cs`
4//! files from JSON fixtures, driven entirely by `E2eConfig` and `CallConfig`.
5
6use crate::config::E2eConfig;
7use crate::escape::{escape_csharp, sanitize_filename, sanitize_ident};
8use crate::field_access::FieldResolver;
9use crate::fixture::{Assertion, CallbackAction, Fixture, FixtureGroup, HttpFixture, ValidationErrorExpectation};
10use alef_core::backend::GeneratedFile;
11use alef_core::config::ResolvedCrateConfig;
12use alef_core::hash::{self, CommentStyle};
13use alef_core::template_versions as tv;
14use anyhow::Result;
15use heck::{ToLowerCamelCase, ToUpperCamelCase};
16use std::collections::HashMap;
17use std::fmt::Write as FmtWrite;
18use std::hash::{Hash, Hasher};
19use std::path::PathBuf;
20
21use super::E2eCodegen;
22use super::client;
23
24/// C# e2e code generator.
25pub struct CSharpCodegen;
26
27impl E2eCodegen for CSharpCodegen {
28    fn generate(
29        &self,
30        groups: &[FixtureGroup],
31        e2e_config: &E2eConfig,
32        config: &ResolvedCrateConfig,
33        type_defs: &[alef_core::ir::TypeDef],
34        enums: &[alef_core::ir::EnumDef],
35    ) -> Result<Vec<GeneratedFile>> {
36        let lang = self.language_name();
37        let output_base = PathBuf::from(e2e_config.effective_output()).join(lang);
38
39        let mut files = Vec::new();
40
41        // Resolve call config with overrides.
42        let call = &e2e_config.call;
43        let overrides = call.overrides.get(lang);
44        let function_name = overrides
45            .and_then(|o| o.function.as_ref())
46            .cloned()
47            .unwrap_or_else(|| call.function.to_upper_camel_case());
48        let class_name = overrides
49            .and_then(|o| o.class.as_ref())
50            .cloned()
51            .unwrap_or_else(|| format!("{}Lib", config.name.to_upper_camel_case()));
52        // The exception class is always {CrateName}Exception, generated by the C# backend.
53        let exception_class = format!("{}Exception", config.name.to_upper_camel_case());
54        let namespace = overrides
55            .and_then(|o| o.module.as_ref())
56            .cloned()
57            .or_else(|| config.csharp.as_ref().and_then(|cs| cs.namespace.clone()))
58            .unwrap_or_else(|| {
59                if call.module.is_empty() {
60                    config.name.to_upper_camel_case()
61                } else {
62                    call.module.to_upper_camel_case()
63                }
64            });
65        let result_is_simple = call.result_is_simple || overrides.is_some_and(|o| o.result_is_simple);
66        let result_var = &call.result_var;
67        let is_async = call.r#async;
68
69        // Resolve package config.
70        let cs_pkg = e2e_config.resolve_package("csharp");
71        let pkg_name = cs_pkg
72            .as_ref()
73            .and_then(|p| p.name.as_ref())
74            .cloned()
75            .unwrap_or_else(|| config.name.to_upper_camel_case());
76        // Alef scaffolds C# packages as packages/csharp/<Namespace>/<Namespace>.csproj.
77        let pkg_path = cs_pkg
78            .as_ref()
79            .and_then(|p| p.path.as_ref())
80            .cloned()
81            .unwrap_or_else(|| format!("../../packages/csharp/{pkg_name}/{pkg_name}.csproj"));
82        let pkg_version = cs_pkg
83            .as_ref()
84            .and_then(|p| p.version.as_ref())
85            .cloned()
86            .or_else(|| config.resolved_version())
87            .unwrap_or_else(|| "0.1.0".to_string());
88
89        // Generate the .csproj using a unique name derived from the package name so
90        // it does not conflict with any hand-written project files in the same directory.
91        let csproj_name = format!("{pkg_name}.E2eTests.csproj");
92        files.push(GeneratedFile {
93            path: output_base.join(&csproj_name),
94            content: render_csproj(&pkg_name, &pkg_path, &pkg_version, e2e_config.dep_mode),
95            generated_header: false,
96        });
97
98        // Detect whether any fixture needs the mock-server (HTTP fixtures or
99        // fixtures with a `mock_response`). When present, the generated
100        // TestSetup.cs will spawn the mock-server via [ModuleInitializer]
101        // before any test loads — mirroring the Ruby spec_helper / Python
102        // conftest spawn pattern. Without this, every fixture-bound test
103        // failed with `LiterLlmException : builder error` because reqwest
104        // rejected the relative URL when MOCK_SERVER_URL was unset.
105        let needs_mock_server = groups
106            .iter()
107            .flat_map(|g| g.fixtures.iter())
108            .any(|f| f.needs_mock_server());
109
110        // Emit a TestSetup.cs whose ModuleInitializer chdirs to test_documents
111        // so fixture-relative paths like "docx/fake.docx" resolve correctly when
112        // dotnet test runs from bin/{Configuration}/{Tfm}, and (when fixtures
113        // need it) spawns the mock-server binary.
114        files.push(GeneratedFile {
115            path: output_base.join("TestSetup.cs"),
116            content: render_test_setup(needs_mock_server, &e2e_config.test_documents_dir, &namespace),
117            generated_header: true,
118        });
119
120        // Collect all distinct sealed-union type names declared in `assert_enum_fields`
121        // across all call configs for this language.  For each such type we emit a
122        // `{TypeName}Display.cs` helper that pattern-matches on variants from the IR;
123        // projects that declare no `assert_enum_fields` get no extra helper files.
124        let sealed_display_types: std::collections::BTreeSet<String> = std::iter::once(&e2e_config.call)
125            .chain(e2e_config.calls.values())
126            .filter_map(|c| c.overrides.get(lang))
127            .flat_map(|o| o.assert_enum_fields.values().cloned())
128            .collect();
129
130        let tests_base = output_base.join("tests");
131        for type_name in &sealed_display_types {
132            if let Some(enum_def) = enums.iter().find(|e| &e.name == type_name) {
133                files.push(GeneratedFile {
134                    path: tests_base.join(format!("{type_name}Display.cs")),
135                    content: render_sealed_display(type_name, enum_def, type_defs, &namespace),
136                    generated_header: true,
137                });
138            }
139        }
140
141        // Generate test files per category.
142        let field_resolver = FieldResolver::new(
143            &e2e_config.fields,
144            &e2e_config.fields_optional,
145            &e2e_config.result_fields,
146            &e2e_config.fields_array,
147            &std::collections::HashSet::new(),
148        );
149
150        // Resolve enum_fields, assert_enum_fields and nested_types from C# override config,
151        // falling back to top-level call config.
152        static EMPTY_ENUM_FIELDS: std::sync::LazyLock<HashMap<String, String>> = std::sync::LazyLock::new(HashMap::new);
153        static EMPTY_ASSERT_ENUM_FIELDS: std::sync::LazyLock<HashMap<String, String>> =
154            std::sync::LazyLock::new(HashMap::new);
155        let enum_fields = overrides.map(|o| &o.enum_fields).unwrap_or(&EMPTY_ENUM_FIELDS);
156        let assert_enum_fields = overrides
157            .and_then(|o| {
158                if o.assert_enum_fields.is_empty() {
159                    None
160                } else {
161                    Some(&o.assert_enum_fields)
162                }
163            })
164            .unwrap_or(&EMPTY_ASSERT_ENUM_FIELDS);
165
166        // Build effective nested_types from C# override config (top-level doesn't have nested_types).
167        let mut effective_nested_types: HashMap<String, String> = HashMap::new();
168        if let Some(overrides_map) = overrides.map(|o| &o.nested_types) {
169            effective_nested_types.extend(overrides_map.clone());
170        }
171
172        for group in groups {
173            let active: Vec<&Fixture> = group
174                .fixtures
175                .iter()
176                .filter(|f| super::should_include_fixture(f, lang, e2e_config))
177                .collect();
178
179            if active.is_empty() {
180                continue;
181            }
182
183            let test_class = format!("{}Tests", sanitize_filename(&group.category).to_upper_camel_case());
184            let filename = format!("{test_class}.cs");
185            let content = render_test_file(
186                &group.category,
187                &active,
188                &namespace,
189                &class_name,
190                &function_name,
191                &exception_class,
192                result_var,
193                &test_class,
194                &e2e_config.call.args,
195                &field_resolver,
196                result_is_simple,
197                is_async,
198                e2e_config,
199                enum_fields,
200                assert_enum_fields,
201                &effective_nested_types,
202                &config.adapters,
203            );
204            files.push(GeneratedFile {
205                path: tests_base.join(filename),
206                content,
207                generated_header: true,
208            });
209        }
210
211        Ok(files)
212    }
213
214    fn language_name(&self) -> &'static str {
215        "csharp"
216    }
217}
218
219// ---------------------------------------------------------------------------
220// Rendering
221// ---------------------------------------------------------------------------
222
223fn render_csproj(pkg_name: &str, pkg_path: &str, pkg_version: &str, dep_mode: crate::config::DependencyMode) -> String {
224    let pkg_ref = match dep_mode {
225        crate::config::DependencyMode::Registry => {
226            format!("    <PackageReference Include=\"{pkg_name}\" Version=\"{pkg_version}\" />")
227        }
228        crate::config::DependencyMode::Local => {
229            format!("    <ProjectReference Include=\"{pkg_path}\" />")
230        }
231    };
232    crate::template_env::render(
233        "csharp/csproj.jinja",
234        minijinja::context! {
235            pkg_ref => pkg_ref,
236            microsoft_net_test_sdk_version => tv::nuget::MICROSOFT_NET_TEST_SDK,
237            xunit_version => tv::nuget::XUNIT,
238            xunit_runner_version => tv::nuget::XUNIT_RUNNER_VISUALSTUDIO,
239        },
240    )
241}
242
243fn render_test_setup(needs_mock_server: bool, test_documents_dir: &str, namespace: &str) -> String {
244    let mut out = String::new();
245    out.push_str(&hash::header(CommentStyle::DoubleSlash));
246    out.push_str("using System;\n");
247    out.push_str("using System.IO;\n");
248    if needs_mock_server {
249        out.push_str("using System.Diagnostics;\n");
250    }
251    out.push_str("using System.Runtime.CompilerServices;\n\n");
252    let _ = writeln!(out, "namespace {namespace};\n");
253    out.push_str("internal static class TestSetup\n");
254    out.push_str("{\n");
255    if needs_mock_server {
256        out.push_str("    private static Process? _mockServer;\n\n");
257    }
258    out.push_str("    [ModuleInitializer]\n");
259    out.push_str("    internal static void Init()\n");
260    out.push_str("    {\n");
261    let _ = writeln!(
262        out,
263        "        // Walk up from the assembly directory until we find the repo root."
264    );
265    let _ = writeln!(
266        out,
267        "        // Prefer a sibling {test_documents_dir}/ directory (chdir into it so that"
268    );
269    out.push_str("        // fixture paths like \"docx/fake.docx\" resolve relative to it). If that\n");
270    out.push_str("        // is absent (web-crawler-style repos with no document fixtures), fall\n");
271    out.push_str("        // back to a sibling alef.toml or fixtures/ marker as the repo root.\n");
272    out.push_str("        var dir = new DirectoryInfo(AppContext.BaseDirectory);\n");
273    out.push_str("        DirectoryInfo? repoRoot = null;\n");
274    out.push_str("        while (dir != null)\n");
275    out.push_str("        {\n");
276    let _ = writeln!(
277        out,
278        "            var documentsCandidate = Path.Combine(dir.FullName, \"{test_documents_dir}\");"
279    );
280    out.push_str("            if (Directory.Exists(documentsCandidate))\n");
281    out.push_str("            {\n");
282    out.push_str("                repoRoot = dir;\n");
283    out.push_str("                Directory.SetCurrentDirectory(documentsCandidate);\n");
284    out.push_str("                break;\n");
285    out.push_str("            }\n");
286    out.push_str("            if (File.Exists(Path.Combine(dir.FullName, \"alef.toml\"))\n");
287    out.push_str("                || Directory.Exists(Path.Combine(dir.FullName, \"fixtures\")))\n");
288    out.push_str("            {\n");
289    out.push_str("                repoRoot = dir;\n");
290    out.push_str("                break;\n");
291    out.push_str("            }\n");
292    out.push_str("            dir = dir.Parent;\n");
293    out.push_str("        }\n");
294    if needs_mock_server {
295        out.push('\n');
296        out.push_str("        // Spawn the mock-server binary before any test loads, mirroring the\n");
297        out.push_str("        // Ruby spec_helper / Python conftest pattern. Honors a pre-set\n");
298        out.push_str("        // MOCK_SERVER_URL (e.g. set by `task` or CI) by skipping the spawn.\n");
299        out.push_str("        // Without this, every fixture-bound test failed with\n");
300        out.push_str("        // `<Lib>Exception : builder error` because reqwest rejected the\n");
301        out.push_str("        // relative URL produced by `\"\" + \"/fixtures/<id>\"`.\n");
302        out.push_str("        var preset = Environment.GetEnvironmentVariable(\"MOCK_SERVER_URL\");\n");
303        out.push_str("        if (!string.IsNullOrEmpty(preset))\n");
304        out.push_str("        {\n");
305        out.push_str("            return;\n");
306        out.push_str("        }\n");
307        out.push_str("        if (repoRoot == null)\n");
308        out.push_str("        {\n");
309        let _ = writeln!(
310            out,
311            "            throw new InvalidOperationException(\"TestSetup: could not locate repo root ({test_documents_dir}/, alef.toml, or fixtures/ not found in any ancestor of \" + AppContext.BaseDirectory + \")\");"
312        );
313        out.push_str("        }\n");
314        out.push_str("        var bin = Path.Combine(\n");
315        out.push_str("            repoRoot.FullName,\n");
316        out.push_str("            \"e2e\", \"rust\", \"target\", \"release\", \"mock-server\");\n");
317        out.push_str("        if (OperatingSystem.IsWindows())\n");
318        out.push_str("        {\n");
319        out.push_str("            bin += \".exe\";\n");
320        out.push_str("        }\n");
321        out.push_str("        var fixturesDir = Path.Combine(repoRoot.FullName, \"fixtures\");\n");
322        out.push_str("        if (!File.Exists(bin))\n");
323        out.push_str("        {\n");
324        out.push_str("            throw new InvalidOperationException(\n");
325        out.push_str("                $\"TestSetup: mock-server binary not found at {bin} — run: cargo build --manifest-path e2e/rust/Cargo.toml --bin mock-server --release\");\n");
326        out.push_str("        }\n");
327        out.push_str("        var psi = new ProcessStartInfo\n");
328        out.push_str("        {\n");
329        out.push_str("            FileName = bin,\n");
330        out.push_str("            Arguments = $\"\\\"{fixturesDir}\\\"\",\n");
331        out.push_str("            RedirectStandardInput = true,\n");
332        out.push_str("            RedirectStandardOutput = true,\n");
333        out.push_str("            RedirectStandardError = true,\n");
334        out.push_str("            UseShellExecute = false,\n");
335        out.push_str("        };\n");
336        out.push_str("        _mockServer = Process.Start(psi)\n");
337        out.push_str(
338            "            ?? throw new InvalidOperationException(\"TestSetup: failed to start mock-server\");\n",
339        );
340        out.push_str("        // The mock-server prints MOCK_SERVER_URL=<url>, then optionally\n");
341        out.push_str("        // MOCK_SERVERS={...} for host-root fixtures. Read up to 16 lines.\n");
342        out.push_str("        string? url = null;\n");
343        out.push_str("        for (int i = 0; i < 16; i++)\n");
344        out.push_str("        {\n");
345        out.push_str("            var line = _mockServer.StandardOutput.ReadLine();\n");
346        out.push_str("            if (line == null)\n");
347        out.push_str("            {\n");
348        out.push_str("                break;\n");
349        out.push_str("            }\n");
350        out.push_str("            const string urlPrefix = \"MOCK_SERVER_URL=\";\n");
351        out.push_str("            const string serversPrefix = \"MOCK_SERVERS=\";\n");
352        out.push_str("            if (line.StartsWith(urlPrefix, StringComparison.Ordinal))\n");
353        out.push_str("            {\n");
354        out.push_str("                url = line.Substring(urlPrefix.Length).Trim();\n");
355        out.push_str("            }\n");
356        out.push_str("            else if (line.StartsWith(serversPrefix, StringComparison.Ordinal))\n");
357        out.push_str("            {\n");
358        out.push_str("                var jsonVal = line.Substring(serversPrefix.Length).Trim();\n");
359        out.push_str("                Environment.SetEnvironmentVariable(\"MOCK_SERVERS\", jsonVal);\n");
360        out.push_str("                // Parse JSON map and set per-fixture env vars (MOCK_SERVER_<FIXTURE_ID>).\n");
361        out.push_str("                var matches = System.Text.RegularExpressions.Regex.Matches(\n");
362        out.push_str("                    jsonVal, \"\\\"([^\\\"]+)\\\":\\\"([^\\\"]+)\\\"\");\n");
363        out.push_str("                foreach (System.Text.RegularExpressions.Match m in matches)\n");
364        out.push_str("                {\n");
365        out.push_str("                    Environment.SetEnvironmentVariable(\n");
366        out.push_str("                        \"MOCK_SERVER_\" + m.Groups[1].Value.ToUpperInvariant(),\n");
367        out.push_str("                        m.Groups[2].Value);\n");
368        out.push_str("                }\n");
369        out.push_str("                break;\n");
370        out.push_str("            }\n");
371        out.push_str("            else if (url != null)\n");
372        out.push_str("            {\n");
373        out.push_str("                break;\n");
374        out.push_str("            }\n");
375        out.push_str("        }\n");
376        out.push_str("        if (string.IsNullOrEmpty(url))\n");
377        out.push_str("        {\n");
378        out.push_str("            try { _mockServer.Kill(true); } catch { }\n");
379        out.push_str("            throw new InvalidOperationException(\"TestSetup: mock-server did not emit MOCK_SERVER_URL\");\n");
380        out.push_str("        }\n");
381        out.push_str("        Environment.SetEnvironmentVariable(\"MOCK_SERVER_URL\", url);\n");
382        out.push_str("        // TCP-readiness probe: ensure axum::serve is accepting before tests start.\n");
383        out.push_str("        // The mock-server binds the TcpListener synchronously then prints the URL\n");
384        out.push_str("        // before tokio::spawn(axum::serve(...)) is polled, so under xUnit\n");
385        out.push_str("        // class-parallel default tests can race startup. Poll-connect (max 5s,\n");
386        out.push_str("        // 50ms backoff) until success.\n");
387        out.push_str("        var healthUri = new System.Uri(url);\n");
388        out.push_str("        var deadline = System.Diagnostics.Stopwatch.StartNew();\n");
389        out.push_str("        while (deadline.ElapsedMilliseconds < 5000)\n");
390        out.push_str("        {\n");
391        out.push_str("            try\n");
392        out.push_str("            {\n");
393        out.push_str("                using var probe = new System.Net.Sockets.TcpClient();\n");
394        out.push_str("                var task = probe.ConnectAsync(healthUri.Host, healthUri.Port);\n");
395        out.push_str("                if (task.Wait(100) && probe.Connected) { break; }\n");
396        out.push_str("            }\n");
397        out.push_str("            catch (System.Exception) { }\n");
398        out.push_str("            System.Threading.Thread.Sleep(50);\n");
399        out.push_str("        }\n");
400        out.push_str("        // Drain stdout/stderr so the child does not block on a full pipe.\n");
401        out.push_str("        var server = _mockServer;\n");
402        out.push_str("        var stdoutThread = new System.Threading.Thread(() =>\n");
403        out.push_str("        {\n");
404        out.push_str("            try { server.StandardOutput.ReadToEnd(); } catch { }\n");
405        out.push_str("        }) { IsBackground = true };\n");
406        out.push_str("        stdoutThread.Start();\n");
407        out.push_str("        var stderrThread = new System.Threading.Thread(() =>\n");
408        out.push_str("        {\n");
409        out.push_str("            try { server.StandardError.ReadToEnd(); } catch { }\n");
410        out.push_str("        }) { IsBackground = true };\n");
411        out.push_str("        stderrThread.Start();\n");
412        out.push_str("        // Tear the child down on assembly unload / process exit by closing\n");
413        out.push_str("        // its stdin (the mock-server treats stdin EOF as a shutdown signal).\n");
414        out.push_str("        AppDomain.CurrentDomain.ProcessExit += (_, _) =>\n");
415        out.push_str("        {\n");
416        out.push_str("            try { _mockServer.StandardInput.Close(); } catch { }\n");
417        out.push_str("            try { if (!_mockServer.WaitForExit(2000)) { _mockServer.Kill(true); } } catch { }\n");
418        out.push_str("        };\n");
419    }
420    out.push_str("    }\n");
421    out.push_str("}\n");
422    out
423}
424
425#[allow(clippy::too_many_arguments)]
426fn render_test_file(
427    category: &str,
428    fixtures: &[&Fixture],
429    namespace: &str,
430    class_name: &str,
431    function_name: &str,
432    exception_class: &str,
433    result_var: &str,
434    test_class: &str,
435    args: &[crate::config::ArgMapping],
436    field_resolver: &FieldResolver,
437    result_is_simple: bool,
438    is_async: bool,
439    e2e_config: &E2eConfig,
440    enum_fields: &HashMap<String, String>,
441    assert_enum_fields: &HashMap<String, String>,
442    nested_types: &HashMap<String, String>,
443    adapters: &[alef_core::config::extras::AdapterConfig],
444) -> String {
445    // Collect using imports
446    let mut using_imports = String::new();
447    using_imports.push_str("using System;\n");
448    using_imports.push_str("using System.Collections.Generic;\n");
449    using_imports.push_str("using System.Linq;\n");
450    using_imports.push_str("using System.Net.Http;\n");
451    using_imports.push_str("using System.Text;\n");
452    using_imports.push_str("using System.Text.Json;\n");
453    using_imports.push_str("using System.Text.Json.Serialization;\n");
454    using_imports.push_str("using System.Threading.Tasks;\n");
455    using_imports.push_str("using Xunit;\n");
456    using_imports.push_str(&format!("using {namespace};\n"));
457    using_imports.push_str(&format!("using static {namespace}.{class_name};\n"));
458
459    // Shared options field
460    let config_options_field = "    private static readonly JsonSerializerOptions ConfigOptions = new() { Converters = { new JsonStringEnumConverter(JsonNamingPolicy.SnakeCaseLower) }, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingDefault };";
461
462    // Visitor class declarations accumulated across all fixtures — emitted as
463    // private nested classes inside the test class but outside any method body.
464    // C# does not allow local class declarations inside method bodies.
465    let mut visitor_class_decls: Vec<String> = Vec::new();
466
467    // Build fixtures body
468    let mut fixtures_body = String::new();
469    for (i, fixture) in fixtures.iter().enumerate() {
470        render_test_method(
471            &mut fixtures_body,
472            &mut visitor_class_decls,
473            fixture,
474            class_name,
475            function_name,
476            exception_class,
477            result_var,
478            args,
479            field_resolver,
480            result_is_simple,
481            is_async,
482            e2e_config,
483            enum_fields,
484            assert_enum_fields,
485            nested_types,
486            adapters,
487        );
488        if i + 1 < fixtures.len() {
489            fixtures_body.push('\n');
490        }
491    }
492
493    // Build visitor classes string
494    let mut visitor_classes_str = String::new();
495    for (i, decl) in visitor_class_decls.iter().enumerate() {
496        if i > 0 {
497            visitor_classes_str.push('\n');
498        }
499        visitor_classes_str.push('\n');
500        // Indent each line by 4 spaces to nest inside the test class
501        for line in decl.lines() {
502            visitor_classes_str.push_str("    ");
503            visitor_classes_str.push_str(line);
504            visitor_classes_str.push('\n');
505        }
506    }
507
508    let ctx = minijinja::context! {
509        header => hash::header(CommentStyle::DoubleSlash),
510        using_imports => using_imports,
511        category => category,
512        namespace => namespace,
513        test_class => test_class,
514        config_options_field => config_options_field,
515        fixtures_body => fixtures_body,
516        visitor_class_decls => visitor_classes_str,
517    };
518
519    crate::template_env::render("csharp/test_file.jinja", ctx)
520}
521
522// ---------------------------------------------------------------------------
523// HTTP test rendering — shared-driver integration
524// ---------------------------------------------------------------------------
525
526/// Renderer that emits xUnit `[Fact] public async Task Test_*()` methods using
527/// `System.Net.Http.HttpClient` against the mock server at `MOCK_SERVER_URL`.
528/// Satisfies [`client::TestClientRenderer`] so the shared
529/// [`client::http_call::render_http_test`] driver drives the call sequence.
530struct CSharpTestClientRenderer;
531
532/// C# HttpMethod static properties are PascalCase (Get, Post, Put, Delete, …).
533fn to_csharp_http_method(method: &str) -> String {
534    let lower = method.to_ascii_lowercase();
535    let mut chars = lower.chars();
536    match chars.next() {
537        Some(c) => c.to_ascii_uppercase().to_string() + chars.as_str(),
538        None => String::new(),
539    }
540}
541
542/// Headers that belong to `request.Content.Headers` rather than `request.Headers`.
543///
544/// Adding these to `request.Headers` causes .NET to throw "Misused header name".
545const CSHARP_RESTRICTED_REQUEST_HEADERS: &[&str] = &[
546    "content-length",
547    "host",
548    "connection",
549    "expect",
550    "transfer-encoding",
551    "upgrade",
552    // Content-Type is owned by request.Content.Headers and is set when
553    // StringContent is constructed; adding it to request.Headers throws.
554    "content-type",
555    // Other entity headers also belong to request.Content.Headers.
556    "content-encoding",
557    "content-language",
558    "content-location",
559    "content-md5",
560    "content-range",
561    "content-disposition",
562];
563
564/// Whether `name` (any case) belongs to `response.Content.Headers` rather than
565/// `response.Headers`. Picking the wrong collection causes .NET to throw
566/// "Misused header name".
567fn is_csharp_content_header(name: &str) -> bool {
568    matches!(
569        name.to_ascii_lowercase().as_str(),
570        "content-type"
571            | "content-length"
572            | "content-encoding"
573            | "content-language"
574            | "content-location"
575            | "content-md5"
576            | "content-range"
577            | "content-disposition"
578            | "expires"
579            | "last-modified"
580            | "allow"
581    )
582}
583
584impl client::TestClientRenderer for CSharpTestClientRenderer {
585    fn language_name(&self) -> &'static str {
586        "csharp"
587    }
588
589    /// Convert a fixture id to the PascalCase identifier used in `Test_{name}`.
590    fn sanitize_test_name(&self, id: &str) -> String {
591        id.to_upper_camel_case()
592    }
593
594    /// Emit `[Fact]` (or `[Fact(Skip = "…")]` for skipped tests), the method
595    /// signature, the opening brace, and the description comment.
596    fn render_test_open(&self, out: &mut String, fn_name: &str, description: &str, skip_reason: Option<&str>) {
597        let escaped_reason = skip_reason.map(escape_csharp);
598        let rendered = crate::template_env::render(
599            "csharp/http_test_open.jinja",
600            minijinja::context! {
601                fn_name => fn_name,
602                description => description,
603                skip_reason => escaped_reason,
604            },
605        );
606        out.push_str(&rendered);
607    }
608
609    /// Emit the closing `}` for a test method.
610    fn render_test_close(&self, out: &mut String) {
611        let rendered = crate::template_env::render("csharp/http_test_close.jinja", minijinja::context! {});
612        out.push_str(&rendered);
613    }
614
615    /// Emit the `HttpRequestMessage` construction, headers, cookies, body, and
616    /// `var response = await client.SendAsync(request)`.
617    ///
618    /// The fixture path follows the mock-server convention `/fixtures/<id>`.
619    fn render_call(&self, out: &mut String, ctx: &client::CallCtx<'_>) {
620        let method = to_csharp_http_method(ctx.method);
621        let path = escape_csharp(ctx.path);
622
623        out.push_str("        var baseUrl = Environment.GetEnvironmentVariable(\"MOCK_SERVER_URL\") ?? \"http://localhost:8080\";\n");
624        // Disable auto-follow so redirect-status fixtures (3xx) can assert the
625        // server's status code rather than the followed-target's status.
626        out.push_str(
627            "        using var handler = new System.Net.Http.HttpClientHandler { AllowAutoRedirect = false };\n",
628        );
629        out.push_str("        using var client = new System.Net.Http.HttpClient(handler);\n");
630        out.push_str(&format!("        var request = new System.Net.Http.HttpRequestMessage(System.Net.Http.HttpMethod.{method}, $\"{{baseUrl}}{path}\");\n"));
631
632        // Set body + Content-Type when a request body is present.
633        if let Some(body) = ctx.body {
634            let content_type = ctx.content_type.unwrap_or("application/json");
635            let json_str = serde_json::to_string(body).unwrap_or_default();
636            let escaped = escape_csharp(&json_str);
637            out.push_str(&format!("        request.Content = new System.Net.Http.StringContent(\"{escaped}\", System.Text.Encoding.UTF8, \"{content_type}\");\n"));
638        }
639
640        // Add request headers (skip restricted headers that belong to Content.Headers).
641        for (name, value) in ctx.headers {
642            if CSHARP_RESTRICTED_REQUEST_HEADERS.contains(&name.to_lowercase().as_str()) {
643                continue;
644            }
645            let escaped_name = escape_csharp(name);
646            let escaped_value = escape_csharp(value);
647            out.push_str(&format!(
648                "        request.Headers.Add(\"{escaped_name}\", \"{escaped_value}\");\n"
649            ));
650        }
651
652        // Combine cookies into a single `Cookie` header.
653        if !ctx.cookies.is_empty() {
654            let mut pairs: Vec<String> = ctx.cookies.iter().map(|(k, v)| format!("{k}={v}")).collect();
655            pairs.sort();
656            let cookie_header = escape_csharp(&pairs.join("; "));
657            out.push_str(&format!(
658                "        request.Headers.Add(\"Cookie\", \"{cookie_header}\");\n"
659            ));
660        }
661
662        out.push_str("        var response = await client.SendAsync(request);\n");
663    }
664
665    /// Emit `Assert.Equal(status, (int)response.StatusCode)`.
666    fn render_assert_status(&self, out: &mut String, _response_var: &str, status: u16) {
667        out.push_str(&format!("        Assert.Equal({status}, (int)response.StatusCode);\n"));
668    }
669
670    /// Emit a response-header assertion.
671    ///
672    /// Handles special tokens: `<<present>>`, `<<absent>>`, `<<uuid>>`.
673    /// Picks `response.Content.Headers` vs `response.Headers` based on the header name.
674    fn render_assert_header(&self, out: &mut String, _response_var: &str, name: &str, expected: &str) {
675        let target = if is_csharp_content_header(name) {
676            "response.Content.Headers"
677        } else {
678            "response.Headers"
679        };
680        let escaped_name = escape_csharp(name);
681        match expected {
682            "<<present>>" => {
683                out.push_str(&format!("        Assert.True({target}.Contains(\"{escaped_name}\"), \"expected header {escaped_name} to be present\");\n"));
684            }
685            "<<absent>>" => {
686                out.push_str(&format!("        Assert.False({target}.Contains(\"{escaped_name}\"), \"expected header {escaped_name} to be absent\");\n"));
687            }
688            "<<uuid>>" => {
689                // UUID regex: 8-4-4-4-12 hex groups.
690                out.push_str(&format!("        Assert.True({target}.TryGetValues(\"{escaped_name}\", out var _uuidHdr) && System.Text.RegularExpressions.Regex.IsMatch(string.Join(\", \", _uuidHdr), @\"^[0-9a-fA-F]{{8}}-[0-9a-fA-F]{{4}}-[0-9a-fA-F]{{4}}-[0-9a-fA-F]{{4}}-[0-9a-fA-F]{{12}}$\"), \"header {escaped_name} is not a UUID\");\n"));
691            }
692            literal => {
693                // Use a deterministic local-variable name derived from the header name so
694                // multiple header assertions in the same method body do not redeclare.
695                let var_name = format!("hdr{}", sanitize_ident(name));
696                let escaped_value = escape_csharp(literal);
697                out.push_str(&format!("        Assert.True({target}.TryGetValues(\"{escaped_name}\", out var {var_name}) && {var_name}.Any(v => v.Contains(\"{escaped_value}\")), \"header {escaped_name} mismatch\");\n"));
698            }
699        }
700    }
701
702    /// Emit a JSON body equality assertion via `JsonDocument`.
703    ///
704    /// Plain-string bodies are compared with `Assert.Equal` after trimming.
705    fn render_assert_json_body(&self, out: &mut String, _response_var: &str, expected: &serde_json::Value) {
706        match expected {
707            serde_json::Value::Object(_) | serde_json::Value::Array(_) => {
708                let json_str = serde_json::to_string(expected).unwrap_or_default();
709                let escaped = escape_csharp(&json_str);
710                out.push_str("        var bodyText = await response.Content.ReadAsStringAsync();\n");
711                out.push_str("        var body = JsonDocument.Parse(bodyText).RootElement;\n");
712                out.push_str(&format!(
713                    "        var expectedBody = JsonDocument.Parse(\"{escaped}\").RootElement;\n"
714                ));
715                out.push_str("        Assert.Equal(expectedBody.GetRawText(), body.GetRawText());\n");
716            }
717            serde_json::Value::String(s) => {
718                let escaped = escape_csharp(s);
719                out.push_str("        var bodyText = await response.Content.ReadAsStringAsync();\n");
720                out.push_str(&format!("        Assert.Equal(\"{escaped}\", bodyText.Trim());\n"));
721            }
722            other => {
723                let escaped = escape_csharp(&other.to_string());
724                out.push_str("        var bodyText = await response.Content.ReadAsStringAsync();\n");
725                out.push_str(&format!("        Assert.Equal(\"{escaped}\", bodyText.Trim());\n"));
726            }
727        }
728    }
729
730    /// Emit per-field equality assertions for a partial body match.
731    ///
732    /// Uses a separate `partialBodyText` local so it does not collide with
733    /// `bodyText` if `render_assert_json_body` was also called.
734    fn render_assert_partial_body(&self, out: &mut String, _response_var: &str, expected: &serde_json::Value) {
735        if let Some(obj) = expected.as_object() {
736            out.push_str("        var partialBodyText = await response.Content.ReadAsStringAsync();\n");
737            out.push_str("        var partialBody = JsonDocument.Parse(partialBodyText).RootElement;\n");
738            for (key, val) in obj {
739                let escaped_key = escape_csharp(key);
740                let json_str = serde_json::to_string(val).unwrap_or_default();
741                let escaped_val = escape_csharp(&json_str);
742                let var_name = format!("expected{}", key.to_upper_camel_case());
743                out.push_str(&format!(
744                    "        var {var_name} = JsonDocument.Parse(\"{escaped_val}\").RootElement;\n"
745                ));
746                out.push_str(&format!("        Assert.True(partialBody.TryGetProperty(\"{escaped_key}\", out var _partialProp{var_name}) && _partialProp{var_name}.GetRawText() == {var_name}.GetRawText(), \"partial body field '{escaped_key}' mismatch\");\n"));
747            }
748        }
749    }
750
751    /// Emit validation-error assertions by checking each expected `msg` string
752    /// appears in the JSON-encoded body.
753    fn render_assert_validation_errors(
754        &self,
755        out: &mut String,
756        _response_var: &str,
757        errors: &[ValidationErrorExpectation],
758    ) {
759        out.push_str("        var validationBodyText = await response.Content.ReadAsStringAsync();\n");
760        for err in errors {
761            let escaped_msg = escape_csharp(&err.msg);
762            out.push_str(&format!(
763                "        Assert.Contains(\"{escaped_msg}\", validationBodyText);\n"
764            ));
765        }
766    }
767}
768
769/// Render an HTTP server test method using the shared [`client::http_call::render_http_test`]
770/// driver via [`CSharpTestClientRenderer`].
771fn render_http_test_method(out: &mut String, fixture: &Fixture, _http: &HttpFixture) {
772    client::http_call::render_http_test(out, &CSharpTestClientRenderer, fixture);
773}
774
775#[allow(clippy::too_many_arguments)]
776fn render_test_method(
777    out: &mut String,
778    visitor_class_decls: &mut Vec<String>,
779    fixture: &Fixture,
780    class_name: &str,
781    _function_name: &str,
782    exception_class: &str,
783    _result_var: &str,
784    _args: &[crate::config::ArgMapping],
785    _field_resolver: &FieldResolver,
786    result_is_simple: bool,
787    _is_async: bool,
788    e2e_config: &E2eConfig,
789    enum_fields: &HashMap<String, String>,
790    assert_enum_fields: &HashMap<String, String>,
791    nested_types: &HashMap<String, String>,
792    adapters: &[alef_core::config::extras::AdapterConfig],
793) {
794    let method_name = fixture.id.to_upper_camel_case();
795    let description = &fixture.description;
796
797    // HTTP fixtures: generate real HTTP client tests using System.Net.Http.
798    if let Some(http) = &fixture.http {
799        render_http_test_method(out, fixture, http);
800        return;
801    }
802
803    // Non-HTTP fixtures with no mock_response: skip only if the C# binding
804    // does not have a callable for this function via [e2e.call.overrides.csharp].
805    if fixture.mock_response.is_none() && !fixture_has_csharp_callable(fixture, e2e_config) {
806        let skip_reason =
807            "non-HTTP fixture: C# binding does not expose a callable for the configured `[e2e.call]` function";
808        let ctx = minijinja::context! {
809            is_skipped => true,
810            skip_reason => skip_reason,
811            description => description,
812            method_name => method_name,
813        };
814        let rendered = crate::template_env::render("csharp/test_method.jinja", ctx);
815        out.push_str(&rendered);
816        return;
817    }
818
819    let expects_error = fixture.assertions.iter().any(|a| a.assertion_type == "error");
820
821    // Resolve call config per-fixture so named calls (e.g. "parse") use the
822    // correct function name, result variable, and async flag.
823    // Use resolve_call_for_fixture to support auto-routing via select_when.
824    let mut call_config = e2e_config.resolve_call_for_fixture(
825        fixture.call.as_deref(),
826        &fixture.id,
827        &fixture.resolved_category(),
828        &fixture.tags,
829        &fixture.input,
830    );
831    // Fallback: if the resolved call has required args missing from input,
832    // try to find a better-matching call from the named calls.
833    call_config = super::select_best_matching_call(call_config, e2e_config, fixture);
834    // Per-call field resolver: overrides the top-level resolver when this call
835    // declares its own result_fields / fields / fields_optional / fields_array.
836    // Without this, fields like `pages.length` on a `crawl` call would be skipped
837    // because the default `result_fields` (configured for the top-level `scrape`
838    // call) does not contain `pages`.
839    let call_field_resolver = FieldResolver::new(
840        e2e_config.effective_fields(call_config),
841        e2e_config.effective_fields_optional(call_config),
842        e2e_config.effective_result_fields(call_config),
843        e2e_config.effective_fields_array(call_config),
844        &std::collections::HashSet::new(),
845    );
846    let field_resolver = &call_field_resolver;
847    let lang = "csharp";
848    let cs_overrides = call_config.overrides.get(lang);
849
850    // Streaming branch: chat_stream returns IAsyncEnumerable<ChatCompletionChunk>,
851    // not Task<T>. Emit `await foreach` over the stream, building local
852    // aggregator vars (`chunks`, `streamContent`, `streamComplete`, ...) and
853    // asserting on those locals — never on response pseudo-fields.
854    let raw_function_name = cs_overrides
855        .and_then(|o| o.function.as_ref())
856        .cloned()
857        .unwrap_or_else(|| call_config.function.clone());
858    if raw_function_name == "chat_stream" {
859        render_chat_stream_test_method(
860            out,
861            fixture,
862            class_name,
863            call_config,
864            cs_overrides,
865            e2e_config,
866            enum_fields,
867            assert_enum_fields,
868            nested_types,
869            exception_class,
870            adapters,
871        );
872        return;
873    }
874
875    let _is_streaming = call_config.streaming.unwrap_or(false);
876    let effective_function_name = {
877        let mut name = cs_overrides
878            .and_then(|o| o.function.as_ref())
879            .cloned()
880            .unwrap_or_else(|| call_config.function.to_upper_camel_case());
881        if call_config.r#async && !name.ends_with("Async") {
882            name.push_str("Async");
883        }
884        name
885    };
886    let effective_result_var = &call_config.result_var;
887    let effective_is_async = call_config.r#async;
888    let function_name = effective_function_name.as_str();
889    let result_var = effective_result_var.as_str();
890    let is_async = effective_is_async;
891    let args = call_config.args.as_slice();
892
893    // Per-call overrides: result shape, void returns, extra trailing args.
894    // Pull `result_is_simple` from the per-call config first (call-level value
895    // wins, then per-language override, then the top-level call's value).
896    let per_call_result_is_simple = call_config.result_is_simple || cs_overrides.is_some_and(|o| o.result_is_simple);
897    // result_is_bytes: when set, the call returns a raw byte[] in C# (not a
898    // struct with named fields). Mirrors the C codegen flag added in 50e1d309.
899    // Treat byte-buffer returns like result_is_simple (no struct accessors) and
900    // emit byte-specific assertions for `not_empty`/`is_empty`.
901    let per_call_result_is_bytes = call_config.result_is_bytes || cs_overrides.is_some_and(|o| o.result_is_bytes);
902    let effective_result_is_simple = result_is_simple || per_call_result_is_simple || per_call_result_is_bytes;
903    let effective_result_is_bytes = per_call_result_is_bytes;
904    let returns_void = call_config.returns_void;
905    let extra_args_slice: &[String] = cs_overrides.map_or(&[], |o| o.extra_args.as_slice());
906    // options_type: prefer per-call override, fall back to top-level csharp override.
907    let top_level_options_type = e2e_config
908        .call
909        .overrides
910        .get("csharp")
911        .and_then(|o| o.options_type.as_deref());
912    let effective_options_type = cs_overrides
913        .and_then(|o| o.options_type.as_deref())
914        .or(top_level_options_type);
915
916    // options_via: how to construct the options object. Supported values:
917    //   "kwargs" (default) — emit a C# object initializer (`new T { ... }`).
918    //   "from_json"        — emit `JsonSerializer.Deserialize<T>(json, ConfigOptions)!`,
919    //                        sidestepping per-field type ambiguity for fields like
920    //                        `JsonElement?` (untagged unions) or `List<NamedRecord>`
921    //                        (where the codegen would otherwise default to `List<string>`).
922    let top_level_options_via = e2e_config
923        .call
924        .overrides
925        .get("csharp")
926        .and_then(|o| o.options_via.as_deref());
927    let effective_options_via = cs_overrides
928        .and_then(|o| o.options_via.as_deref())
929        .or(top_level_options_via);
930
931    let adapter_request_type_owned: Option<String> = adapters
932        .iter()
933        .find(|a| a.name == call_config.function.as_str())
934        .and_then(|a| a.request_type.as_deref())
935        .map(|rt| rt.rsplit("::").next().unwrap_or(rt).to_string());
936
937    // Build effective nested_types for this call, merging top-level with per-call C# overrides
938    let mut effective_call_nested_types: std::collections::HashMap<String, String> = nested_types.clone();
939    if let Some(o) = cs_overrides {
940        for (k, v) in &o.nested_types {
941            effective_call_nested_types.insert(k.clone(), v.clone());
942        }
943    }
944
945    let (mut setup_lines, mut args_str) = build_args_and_setup(
946        &fixture.input,
947        args,
948        class_name,
949        effective_options_type,
950        effective_options_via,
951        enum_fields,
952        &effective_call_nested_types,
953        fixture,
954        adapter_request_type_owned.as_deref(),
955    );
956
957    // For streaming methods with mock_url_list (batch_crawl_stream), wrap the URL list
958    // in the request type before passing to the method.
959    if _is_streaming && adapter_request_type_owned.is_some() {
960        // Check if any arg is a mock_url_list (needs wrapping) but doesn't have its own
961        // adapter_request_type wrapping (mock_url args auto-wrap via adapter_request_type)
962        let has_mock_url_list = args.iter().any(|arg| arg.arg_type == "mock_url_list");
963        if has_mock_url_list {
964            if let Some(req_type) = &adapter_request_type_owned {
965                // Find the urls argument part in args_str and wrap it in the request type
966                // The args_str typically looks like "engine, urls" where urls is List<string>
967                // We need to change it to "req" where req is a BatchCrawlStreamRequest wrapping urls
968                let parts: Vec<&str> = args_str.split(", ").collect();
969                if parts.len() >= 2 {
970                    let urls_var = parts[parts.len() - 1]; // Last arg is the URLs
971                    let req_var = format!("{}Req", urls_var);
972                    setup_lines.push(format!("var {req_var} = new {req_type} {{ Urls = {urls_var} }};"));
973                    // Replace the urls arg with the wrapped request
974                    args_str = parts[..parts.len() - 1].join(", ");
975                    if !args_str.is_empty() {
976                        args_str.push_str(", ");
977                    }
978                    args_str.push_str(&req_var);
979                }
980            }
981        }
982    }
983
984    // Build visitor if present: instantiate in method body, declare class at file scope.
985    let mut visitor_arg = String::new();
986    let has_visitor = fixture.visitor.is_some();
987    if let Some(visitor_spec) = &fixture.visitor {
988        visitor_arg = build_csharp_visitor(&mut setup_lines, visitor_class_decls, &fixture.id, visitor_spec);
989    }
990
991    // When a visitor is present, embed it in the options object instead of passing as a separate arg.
992    // args_str should contain the function arguments with null for missing options (e.g., "html, null").
993    // We need to replace that null with a ConversionOptions instance that has Visitor set.
994    let final_args = if has_visitor && !visitor_arg.is_empty() {
995        let opts_type = effective_options_type.unwrap_or("ConversionOptions");
996        if args_str.contains("JsonSerializer.Deserialize") {
997            // Deserialize form: extract the deserialized object and set Visitor on it
998            setup_lines.push(format!("var options = {args_str};"));
999            setup_lines.push(format!("options.Visitor = {visitor_arg};"));
1000            "options".to_string()
1001        } else if args_str.ends_with(", null") {
1002            // Replace trailing ", null" with options
1003            setup_lines.push(format!("var options = new {opts_type} {{ Visitor = {visitor_arg} }};"));
1004            let trimmed = args_str[..args_str.len() - 6].to_string(); // Remove ", null" (6 chars including space)
1005            format!("{trimmed}, options")
1006        } else if args_str.contains(", null,") {
1007            // Options parameter is null in the middle; replace it
1008            setup_lines.push(format!("var options = new {opts_type} {{ Visitor = {visitor_arg} }};"));
1009            args_str.replace(", null,", ", options,")
1010        } else if args_str.is_empty() {
1011            // No options were provided; create new instance with Visitor
1012            setup_lines.push(format!("var options = new {opts_type} {{ Visitor = {visitor_arg} }};"));
1013            "options".to_string()
1014        } else {
1015            // Fall back to appending options
1016            setup_lines.push(format!("var options = new {opts_type} {{ Visitor = {visitor_arg} }};"));
1017            format!("{args_str}, options")
1018        }
1019    } else if extra_args_slice.is_empty() {
1020        args_str
1021    } else if args_str.is_empty() {
1022        extra_args_slice.join(", ")
1023    } else {
1024        format!("{args_str}, {}", extra_args_slice.join(", "))
1025    };
1026
1027    // Always use the base function name (Convert) regardless of visitor presence
1028    // The visitor is now handled internally via options.Visitor
1029    let effective_function_name = function_name.to_string();
1030
1031    let return_type = if is_async { "async Task" } else { "void" };
1032    let await_kw = if is_async { "await " } else { "" };
1033
1034    // Client factory: when set, create a client instance and call methods on it
1035    // rather than using static class calls.
1036    let client_factory = cs_overrides.and_then(|o| o.client_factory.as_deref()).or_else(|| {
1037        e2e_config
1038            .call
1039            .overrides
1040            .get("csharp")
1041            .and_then(|o| o.client_factory.as_deref())
1042    });
1043    // For streaming methods, call instance methods on the engine object, not static methods
1044    let call_target = if client_factory.is_some() {
1045        "client".to_string()
1046    } else if _is_streaming {
1047        // Streaming methods are instance methods on the engine; extract the engine
1048        // variable name from args. Default to "engine" if not found.
1049        args.iter()
1050            .find(|arg| arg.arg_type == "handle")
1051            .map(|arg| arg.name.clone())
1052            .unwrap_or_else(|| "engine".to_string())
1053    } else {
1054        class_name.to_string()
1055    };
1056
1057    // Build client factory setup code. For fixtures whose env block sets
1058    // an `api_key_var` AND that have neither `mock_response` nor an `http`
1059    // override (live-smoke tests against real provider APIs), prepend an
1060    // early-return when the env var is unset so CI without API keys does
1061    // not fail with `not found: No mock route for ...`. Mirrors the
1062    // Elixir / Python skip pattern documented in CHANGELOG v0.15.9.
1063    let mut client_factory_setup = String::new();
1064    if let Some(factory) = client_factory {
1065        let factory_name = factory.to_upper_camel_case();
1066        let fixture_id = &fixture.id;
1067        let has_mock = fixture.mock_response.is_some() || fixture.http.is_some();
1068        let api_key_var_opt = fixture.env.as_ref().and_then(|e| e.api_key_var.as_deref());
1069        let is_live_smoke = !has_mock && api_key_var_opt.is_some();
1070        if let Some(api_key_var) = api_key_var_opt.filter(|_| has_mock) {
1071            client_factory_setup.push_str(&format!(
1072                "        var apiKey = System.Environment.GetEnvironmentVariable(\"{api_key_var}\");\n"
1073            ));
1074            client_factory_setup.push_str(&format!(
1075                "        var baseUrl = string.IsNullOrEmpty(apiKey)\n            ? (System.Environment.GetEnvironmentVariable(\"MOCK_SERVER_URL\") ?? string.Empty) + \"/fixtures/{fixture_id}\"\n            : null;\n"
1076            ));
1077            client_factory_setup.push_str(&format!(
1078                "        Console.WriteLine($\"{fixture_id}: \" + (baseUrl == null ? \"using real API ({api_key_var} is set)\" : \"using mock server ({api_key_var} not set)\"));\n"
1079            ));
1080            client_factory_setup.push_str(&format!(
1081                "        var client = {class_name}.{factory_name}(string.IsNullOrEmpty(apiKey) ? \"test-key\" : apiKey, baseUrl, null, null, null);\n"
1082            ));
1083        } else if let Some(api_key_var) = api_key_var_opt.filter(|_| is_live_smoke) {
1084            client_factory_setup.push_str(&format!(
1085                "        var apiKey = System.Environment.GetEnvironmentVariable(\"{api_key_var}\");\n"
1086            ));
1087            client_factory_setup.push_str("        if (string.IsNullOrEmpty(apiKey)) { return; }\n");
1088            client_factory_setup.push_str(&format!(
1089                "        var client = {class_name}.{factory_name}(apiKey, null, null, null, null);\n"
1090            ));
1091        } else if fixture.has_host_root_route() {
1092            let env_key = format!("MOCK_SERVER_{}", fixture_id.to_uppercase());
1093            client_factory_setup.push_str(&format!(
1094                "        var _perFixtureUrl = System.Environment.GetEnvironmentVariable(\"{env_key}\");\n"
1095            ));
1096            client_factory_setup.push_str(&format!("        var baseUrl = !string.IsNullOrEmpty(_perFixtureUrl) ? _perFixtureUrl : (System.Environment.GetEnvironmentVariable(\"MOCK_SERVER_URL\") ?? string.Empty) + \"/fixtures/{fixture_id}\";\n"));
1097            client_factory_setup.push_str(&format!(
1098                "        var client = {class_name}.{factory_name}(\"test-key\", baseUrl, null, null, null);\n"
1099            ));
1100        } else {
1101            client_factory_setup.push_str(&format!("        var baseUrl = (System.Environment.GetEnvironmentVariable(\"MOCK_SERVER_URL\") ?? string.Empty) + \"/fixtures/{fixture_id}\";\n"));
1102            client_factory_setup.push_str(&format!(
1103                "        var client = {class_name}.{factory_name}(\"test-key\", baseUrl, null, null, null);\n"
1104            ));
1105        }
1106    }
1107
1108    // Build call expression. For streaming methods, skip the engine argument since
1109    // we're calling an instance method on the engine object.
1110    let call_expr = if _is_streaming {
1111        // Remove the first (engine/handle) argument from final_args
1112        let args_parts: Vec<&str> = final_args.split(", ").collect();
1113        let args_without_engine = if args_parts.len() > 1 {
1114            args_parts[1..].join(", ")
1115        } else {
1116            String::new()
1117        };
1118        format!("{}({})", effective_function_name, args_without_engine)
1119    } else {
1120        format!("{}({})", effective_function_name, final_args)
1121    };
1122
1123    // Merge per-call C# `enum_fields` keys with the global file-level
1124    // `fields_enum` set so call-specific enum-typed result fields (e.g.
1125    // BatchObject's `status` → BatchStatus) trigger enum coercion in
1126    // assertions even when the global set does not list them. The
1127    // file-level `enum_fields` argument carries the default-call's override;
1128    // `cs_overrides.enum_fields` carries the per-fixture-call's override
1129    // (e.g. retrieve_batch.overrides.csharp.enum_fields).
1130    let mut effective_enum_fields: std::collections::HashSet<String> = e2e_config.fields_enum.clone();
1131    for k in enum_fields.keys() {
1132        effective_enum_fields.insert(k.clone());
1133    }
1134    if let Some(o) = cs_overrides {
1135        for k in o.enum_fields.keys() {
1136            effective_enum_fields.insert(k.clone());
1137        }
1138    }
1139
1140    // Merge per-call C# `assert_enum_fields` keys with the global file-level
1141    // so call-specific sealed-union fields trigger display wrapper usage.
1142    let mut effective_assert_enum_fields: std::collections::HashMap<String, String> = assert_enum_fields.clone();
1143    if let Some(o) = cs_overrides {
1144        for (k, v) in &o.assert_enum_fields {
1145            effective_assert_enum_fields.insert(k.clone(), v.clone());
1146        }
1147    }
1148
1149    // Build assertions body for non-error cases
1150    let mut assertions_body = String::new();
1151    if !expects_error && !returns_void {
1152        for assertion in &fixture.assertions {
1153            render_assertion(
1154                &mut assertions_body,
1155                assertion,
1156                result_var,
1157                class_name,
1158                exception_class,
1159                field_resolver,
1160                effective_result_is_simple,
1161                call_config.result_is_vec || cs_overrides.is_some_and(|o| o.result_is_vec),
1162                call_config.result_is_array,
1163                effective_result_is_bytes,
1164                &effective_enum_fields,
1165                &effective_assert_enum_fields,
1166            );
1167        }
1168    }
1169
1170    let ctx = minijinja::context! {
1171        is_skipped => false,
1172        expects_error => expects_error,
1173        description => description,
1174        return_type => return_type,
1175        method_name => method_name,
1176        async_kw => await_kw,
1177        call_target => call_target,
1178        setup_lines => setup_lines.clone(),
1179        call_expr => call_expr,
1180        exception_class => exception_class,
1181        client_factory_setup => client_factory_setup,
1182        has_usable_assertion => !expects_error && !returns_void,
1183        result_var => result_var,
1184        assertions_body => assertions_body,
1185        is_streaming => _is_streaming,
1186    };
1187
1188    let rendered = crate::template_env::render("csharp/test_method.jinja", ctx);
1189    // Indent each line by 4 spaces to nest inside the test class
1190    for line in rendered.lines() {
1191        out.push_str("    ");
1192        out.push_str(line);
1193        out.push('\n');
1194    }
1195}
1196
1197/// Render a `chat_stream` test method. The C# binding emits
1198/// `IAsyncEnumerable<ChatCompletionChunk> ChatStream(req)` (not `Task<T>`), so
1199/// the test body uses `await foreach` to drive the stream and aggregates
1200/// per-chunk data into local vars (`chunks`, `streamContent`, `streamComplete`,
1201/// optional `lastFinishReason`/`toolCallsJson`/`toolCalls0FunctionName`/`totalTokens`).
1202/// Assertions then run against those locals — never against pseudo-fields on a
1203/// response object.
1204#[allow(clippy::too_many_arguments)]
1205fn render_chat_stream_test_method(
1206    out: &mut String,
1207    fixture: &Fixture,
1208    class_name: &str,
1209    call_config: &crate::config::CallConfig,
1210    cs_overrides: Option<&crate::config::CallOverride>,
1211    e2e_config: &E2eConfig,
1212    enum_fields: &HashMap<String, String>,
1213    _assert_enum_fields: &HashMap<String, String>,
1214    nested_types: &HashMap<String, String>,
1215    exception_class: &str,
1216    adapters: &[alef_core::config::extras::AdapterConfig],
1217) {
1218    let method_name = fixture.id.to_upper_camel_case();
1219    let description = &fixture.description;
1220    let expects_error = fixture.assertions.iter().any(|a| a.assertion_type == "error");
1221
1222    // Streaming methods return IAsyncEnumerable<T> and carry the conventional
1223    // `Async` suffix to match the binding's generated DefaultClient surface
1224    // (which appends Async to every async-shaped method, streaming included).
1225    let effective_function_name = {
1226        let mut name = cs_overrides
1227            .and_then(|o| o.function.as_ref())
1228            .cloned()
1229            .unwrap_or_else(|| call_config.function.to_upper_camel_case());
1230        if !name.ends_with("Async") {
1231            name.push_str("Async");
1232        }
1233        name
1234    };
1235    let function_name = effective_function_name.as_str();
1236    let args = call_config.args.as_slice();
1237
1238    let top_level_options_type = e2e_config
1239        .call
1240        .overrides
1241        .get("csharp")
1242        .and_then(|o| o.options_type.as_deref());
1243    let effective_options_type = cs_overrides
1244        .and_then(|o| o.options_type.as_deref())
1245        .or(top_level_options_type);
1246    let top_level_options_via = e2e_config
1247        .call
1248        .overrides
1249        .get("csharp")
1250        .and_then(|o| o.options_via.as_deref());
1251    let effective_options_via = cs_overrides
1252        .and_then(|o| o.options_via.as_deref())
1253        .or(top_level_options_via);
1254
1255    let adapter_request_type_cs: Option<String> = adapters
1256        .iter()
1257        .find(|a| a.name == call_config.function.as_str())
1258        .and_then(|a| a.request_type.as_deref())
1259        .map(|rt| rt.rsplit("::").next().unwrap_or(rt).to_string());
1260    let (setup_lines, args_str) = build_args_and_setup(
1261        &fixture.input,
1262        args,
1263        class_name,
1264        effective_options_type,
1265        effective_options_via,
1266        enum_fields,
1267        nested_types,
1268        fixture,
1269        adapter_request_type_cs.as_deref(),
1270    );
1271
1272    let client_factory = cs_overrides.and_then(|o| o.client_factory.as_deref()).or_else(|| {
1273        e2e_config
1274            .call
1275            .overrides
1276            .get("csharp")
1277            .and_then(|o| o.client_factory.as_deref())
1278    });
1279    let mut client_factory_setup = String::new();
1280    if let Some(factory) = client_factory {
1281        let factory_name = factory.to_upper_camel_case();
1282        let fixture_id = &fixture.id;
1283        let has_mock = fixture.mock_response.is_some() || fixture.http.is_some();
1284        let api_key_var_opt = fixture.env.as_ref().and_then(|e| e.api_key_var.as_deref());
1285        let is_live_smoke = !has_mock && api_key_var_opt.is_some();
1286        if let Some(api_key_var) = api_key_var_opt.filter(|_| has_mock) {
1287            client_factory_setup.push_str(&format!(
1288                "        var apiKey = System.Environment.GetEnvironmentVariable(\"{api_key_var}\");\n"
1289            ));
1290            client_factory_setup.push_str(&format!(
1291                "        var baseUrl = string.IsNullOrEmpty(apiKey)\n            ? (System.Environment.GetEnvironmentVariable(\"MOCK_SERVER_URL\") ?? string.Empty) + \"/fixtures/{fixture_id}\"\n            : null;\n"
1292            ));
1293            client_factory_setup.push_str(&format!(
1294                "        Console.WriteLine($\"{fixture_id}: \" + (baseUrl == null ? \"using real API ({api_key_var} is set)\" : \"using mock server ({api_key_var} not set)\"));\n"
1295            ));
1296            client_factory_setup.push_str(&format!(
1297                "        var client = {class_name}.{factory_name}(string.IsNullOrEmpty(apiKey) ? \"test-key\" : apiKey, baseUrl, null, null, null);\n"
1298            ));
1299        } else if let Some(api_key_var) = api_key_var_opt.filter(|_| is_live_smoke) {
1300            client_factory_setup.push_str(&format!(
1301                "        var apiKey = System.Environment.GetEnvironmentVariable(\"{api_key_var}\");\n"
1302            ));
1303            client_factory_setup.push_str("        if (string.IsNullOrEmpty(apiKey)) { return; }\n");
1304            client_factory_setup.push_str(&format!(
1305                "        var client = {class_name}.{factory_name}(apiKey, null, null, null, null);\n"
1306            ));
1307        } else if fixture.has_host_root_route() {
1308            let env_key = format!("MOCK_SERVER_{}", fixture_id.to_uppercase());
1309            client_factory_setup.push_str(&format!(
1310                "        var _perFixtureUrl = System.Environment.GetEnvironmentVariable(\"{env_key}\");\n"
1311            ));
1312            client_factory_setup.push_str(&format!("        var baseUrl = !string.IsNullOrEmpty(_perFixtureUrl) ? _perFixtureUrl : (System.Environment.GetEnvironmentVariable(\"MOCK_SERVER_URL\") ?? string.Empty) + \"/fixtures/{fixture_id}\";\n"));
1313            client_factory_setup.push_str(&format!(
1314                "        var client = {class_name}.{factory_name}(\"test-key\", baseUrl, null, null, null);\n"
1315            ));
1316        } else {
1317            client_factory_setup.push_str(&format!(
1318                "        var baseUrl = (System.Environment.GetEnvironmentVariable(\"MOCK_SERVER_URL\") ?? string.Empty) + \"/fixtures/{fixture_id}\";\n"
1319            ));
1320            client_factory_setup.push_str(&format!(
1321                "        var client = {class_name}.{factory_name}(\"test-key\", baseUrl, null, null, null);\n"
1322            ));
1323        }
1324    }
1325
1326    let call_target = if client_factory.is_some() { "client" } else { class_name };
1327    let call_expr = format!("{call_target}.{function_name}({args_str})");
1328
1329    // Detect which aggregators a fixture's assertions actually need.
1330    let mut needs_finish_reason = false;
1331    let mut needs_tool_calls_json = false;
1332    let mut needs_tool_calls_0_function_name = false;
1333    let mut needs_total_tokens = false;
1334    for a in &fixture.assertions {
1335        if let Some(f) = a.field.as_deref() {
1336            match f {
1337                "finish_reason" => needs_finish_reason = true,
1338                "tool_calls" => needs_tool_calls_json = true,
1339                "tool_calls[0].function.name" => needs_tool_calls_0_function_name = true,
1340                "usage.total_tokens" => needs_total_tokens = true,
1341                _ => {}
1342            }
1343        }
1344    }
1345
1346    let mut body = String::new();
1347    let _ = writeln!(body, "    [Fact]");
1348    let _ = writeln!(body, "    public async Task Test_{method_name}()");
1349    let _ = writeln!(body, "    {{");
1350    let _ = writeln!(body, "        // {description}");
1351    if !client_factory_setup.is_empty() {
1352        body.push_str(&client_factory_setup);
1353    }
1354    for line in &setup_lines {
1355        let _ = writeln!(body, "        {line}");
1356    }
1357
1358    if expects_error {
1359        // Wrap the foreach in a lambda so the IAsyncEnumerable is actually
1360        // consumed (otherwise the producer never runs and no exception is raised).
1361        let _ = writeln!(
1362            body,
1363            "        await Assert.ThrowsAnyAsync<{exception_class}>(async () => {{"
1364        );
1365        let _ = writeln!(body, "            await foreach (var _chunk in {call_expr}) {{ }}");
1366        body.push_str("        });\n");
1367        body.push_str("    }\n");
1368        for line in body.lines() {
1369            out.push_str("    ");
1370            out.push_str(line);
1371            out.push('\n');
1372        }
1373        return;
1374    }
1375
1376    body.push_str("        var chunks = new List<ChatCompletionChunk>();\n");
1377    body.push_str("        var streamContent = new System.Text.StringBuilder();\n");
1378    body.push_str("        var streamComplete = false;\n");
1379    if needs_finish_reason {
1380        body.push_str("        string? lastFinishReason = null;\n");
1381    }
1382    if needs_tool_calls_json {
1383        body.push_str("        string? toolCallsJson = null;\n");
1384    }
1385    if needs_tool_calls_0_function_name {
1386        body.push_str("        string? toolCalls0FunctionName = null;\n");
1387    }
1388    if needs_total_tokens {
1389        body.push_str("        long? totalTokens = null;\n");
1390    }
1391    let _ = writeln!(body, "        await foreach (var chunk in {call_expr})");
1392    body.push_str("        {\n");
1393    body.push_str("            chunks.Add(chunk);\n");
1394    body.push_str(
1395        "            var choice = chunk.Choices != null && chunk.Choices.Count > 0 ? chunk.Choices[0] : null;\n",
1396    );
1397    body.push_str("            if (choice != null)\n");
1398    body.push_str("            {\n");
1399    body.push_str("                var delta = choice.Delta;\n");
1400    body.push_str("                if (delta != null && !string.IsNullOrEmpty(delta.Content))\n");
1401    body.push_str("                {\n");
1402    body.push_str("                    streamContent.Append(delta.Content);\n");
1403    body.push_str("                }\n");
1404    if needs_finish_reason {
1405        // Streaming accumulator must use the wire-form snake_case representation
1406        // (e.g. `tool_calls`) so equality assertions against the fixture-side
1407        // string match. `.ToString().ToLower()` collapses compound PascalCase
1408        // names like `ToolCalls` to `toolcalls` (no underscore), causing
1409        // assertion failures. `JsonNamingPolicy.SnakeCaseLower.ConvertName`
1410        // mirrors the policy used by the global `JsonStringEnumConverter`,
1411        // matching exactly what serde would emit on the wire.
1412        body.push_str("                if (choice.FinishReason != null)\n");
1413        body.push_str("                {\n");
1414        body.push_str(
1415            "                    lastFinishReason = JsonNamingPolicy.SnakeCaseLower.ConvertName(choice.FinishReason.ToString()!);\n",
1416        );
1417        body.push_str("                }\n");
1418    }
1419    if needs_tool_calls_json || needs_tool_calls_0_function_name {
1420        body.push_str("                var tcs = delta?.ToolCalls;\n");
1421        body.push_str("                if (tcs != null && tcs.Count > 0)\n");
1422        body.push_str("                {\n");
1423        if needs_tool_calls_json {
1424            body.push_str(
1425                "                    toolCallsJson ??= JsonSerializer.Serialize(tcs.Select(tc => new { function = new { name = tc.Function?.Name } }));\n",
1426            );
1427        }
1428        if needs_tool_calls_0_function_name {
1429            body.push_str("                    toolCalls0FunctionName ??= tcs[0].Function?.Name;\n");
1430        }
1431        body.push_str("                }\n");
1432    }
1433    body.push_str("            }\n");
1434    if needs_total_tokens {
1435        body.push_str("            if (chunk.Usage != null)\n");
1436        body.push_str("            {\n");
1437        body.push_str("                totalTokens = chunk.Usage.TotalTokens;\n");
1438        body.push_str("            }\n");
1439    }
1440    body.push_str("        }\n");
1441    body.push_str("        streamComplete = true;\n");
1442
1443    // Emit assertions on local aggregator vars.
1444    let mut had_explicit_complete = false;
1445    for assertion in &fixture.assertions {
1446        if assertion.field.as_deref() == Some("stream_complete") {
1447            had_explicit_complete = true;
1448        }
1449        emit_chat_stream_assertion(&mut body, assertion);
1450    }
1451    if !had_explicit_complete {
1452        body.push_str("        Assert.True(streamComplete);\n");
1453    }
1454
1455    body.push_str("    }\n");
1456
1457    for line in body.lines() {
1458        out.push_str("    ");
1459        out.push_str(line);
1460        out.push('\n');
1461    }
1462}
1463
1464/// Map a streaming fixture assertion to an `Assert` call on the local aggregator
1465/// variable produced by `render_chat_stream_test_method`. Pseudo-fields like
1466/// `chunks` / `stream_content` / `stream_complete` resolve to in-method locals.
1467fn emit_chat_stream_assertion(out: &mut String, assertion: &Assertion) {
1468    let atype = assertion.assertion_type.as_str();
1469    if atype == "not_error" || atype == "error" {
1470        return;
1471    }
1472    let field = assertion.field.as_deref().unwrap_or("");
1473
1474    enum Kind {
1475        Chunks,
1476        Bool,
1477        Str,
1478        IntTokens,
1479        Json,
1480        Unsupported,
1481    }
1482
1483    let (expr, kind) = match field {
1484        "chunks" => ("chunks", Kind::Chunks),
1485        "stream_content" => ("streamContent.ToString()", Kind::Str),
1486        "stream_complete" => ("streamComplete", Kind::Bool),
1487        "no_chunks_after_done" => ("streamComplete", Kind::Bool),
1488        "finish_reason" => ("lastFinishReason", Kind::Str),
1489        "tool_calls" => ("toolCallsJson", Kind::Json),
1490        "tool_calls[0].function.name" => ("toolCalls0FunctionName", Kind::Str),
1491        "usage.total_tokens" => ("totalTokens", Kind::IntTokens),
1492        _ => ("", Kind::Unsupported),
1493    };
1494
1495    if matches!(kind, Kind::Unsupported) {
1496        let _ = writeln!(
1497            out,
1498            "        // skipped: streaming assertion on unsupported field '{field}'"
1499        );
1500        return;
1501    }
1502
1503    match (atype, &kind) {
1504        ("count_min", Kind::Chunks) => {
1505            if let Some(n) = assertion.value.as_ref().and_then(|v| v.as_u64()) {
1506                let _ = writeln!(
1507                    out,
1508                    "        Assert.True(chunks.Count >= {n}, \"expected at least {n} chunks\");"
1509                );
1510            }
1511        }
1512        ("count_equals", Kind::Chunks) => {
1513            if let Some(n) = assertion.value.as_ref().and_then(|v| v.as_u64()) {
1514                let _ = writeln!(out, "        Assert.Equal({n}, chunks.Count);");
1515            }
1516        }
1517        ("equals", Kind::Str) => {
1518            if let Some(val) = &assertion.value {
1519                let cs_val = json_to_csharp(val);
1520                let _ = writeln!(out, "        Assert.Equal({cs_val}, {expr});");
1521            }
1522        }
1523        ("contains", Kind::Str) => {
1524            if let Some(val) = &assertion.value {
1525                let cs_val = json_to_csharp(val);
1526                let _ = writeln!(out, "        Assert.Contains({cs_val}, {expr} ?? string.Empty);");
1527            }
1528        }
1529        ("not_empty", Kind::Str) => {
1530            let _ = writeln!(
1531                out,
1532                "        Assert.False(string.IsNullOrEmpty({expr} ?? string.Empty));"
1533            );
1534        }
1535        ("not_empty", Kind::Json) => {
1536            let _ = writeln!(out, "        Assert.NotNull({expr});");
1537        }
1538        ("is_empty", Kind::Str) => {
1539            let _ = writeln!(
1540                out,
1541                "        Assert.True(string.IsNullOrEmpty({expr} ?? string.Empty));"
1542            );
1543        }
1544        ("is_true", Kind::Bool) => {
1545            let _ = writeln!(out, "        Assert.True({expr});");
1546        }
1547        ("is_false", Kind::Bool) => {
1548            let _ = writeln!(out, "        Assert.False({expr});");
1549        }
1550        ("greater_than_or_equal", Kind::IntTokens) => {
1551            if let Some(n) = assertion.value.as_ref().and_then(|v| v.as_u64()) {
1552                let _ = writeln!(out, "        Assert.True({expr} >= {n}, \"expected >= {n}\");");
1553            }
1554        }
1555        ("equals", Kind::IntTokens) => {
1556            if let Some(n) = assertion.value.as_ref().and_then(|v| v.as_u64()) {
1557                let _ = writeln!(out, "        Assert.Equal((long?){n}, {expr});");
1558            }
1559        }
1560        _ => {
1561            let _ = writeln!(
1562                out,
1563                "        // skipped: streaming assertion '{atype}' on field '{field}' not supported"
1564            );
1565        }
1566    }
1567}
1568
1569/// Build setup lines (e.g. handle creation) and the argument list for the function call.
1570///
1571/// Returns `(setup_lines, args_string)`.
1572#[allow(clippy::too_many_arguments)]
1573fn build_args_and_setup(
1574    input: &serde_json::Value,
1575    args: &[crate::config::ArgMapping],
1576    class_name: &str,
1577    options_type: Option<&str>,
1578    options_via: Option<&str>,
1579    enum_fields: &HashMap<String, String>,
1580    nested_types: &HashMap<String, String>,
1581    fixture: &crate::fixture::Fixture,
1582    adapter_request_type: Option<&str>,
1583) -> (Vec<String>, String) {
1584    let fixture_id = &fixture.id;
1585    if args.is_empty() {
1586        return (Vec::new(), String::new());
1587    }
1588
1589    let mut setup_lines: Vec<String> = Vec::new();
1590    let mut parts: Vec<String> = Vec::new();
1591
1592    for arg in args {
1593        if arg.arg_type == "bytes" {
1594            // bytes args must be passed as byte[] in C#.
1595            let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
1596            let val = input.get(field);
1597            match val {
1598                None | Some(serde_json::Value::Null) if arg.optional => {
1599                    parts.push("null".to_string());
1600                }
1601                None | Some(serde_json::Value::Null) => {
1602                    parts.push("System.Array.Empty<byte>()".to_string());
1603                }
1604                Some(v) => {
1605                    // Classify the value to determine how to interpret it:
1606                    // - File paths (like "pdf/fake.pdf") → File.ReadAllBytes(path)
1607                    // - Inline text → System.Text.Encoding.UTF8.GetBytes()
1608                    // - Base64 → Convert.FromBase64String()
1609                    if let Some(s) = v.as_str() {
1610                        let bytes_code = classify_bytes_value_csharp(s);
1611                        parts.push(bytes_code);
1612                    } else {
1613                        // Literal arrays or other non-string types: use as-is
1614                        let cs_str = json_to_csharp(v);
1615                        parts.push(format!("System.Text.Encoding.UTF8.GetBytes({cs_str})"));
1616                    }
1617                }
1618            }
1619            continue;
1620        }
1621
1622        if arg.arg_type == "mock_url" {
1623            if fixture.has_host_root_route() {
1624                let env_key = format!("MOCK_SERVER_{}", fixture_id.to_uppercase());
1625                setup_lines.push(format!(
1626                    "var _pfUrl_{name} = Environment.GetEnvironmentVariable(\"{env_key}\");",
1627                    name = arg.name,
1628                ));
1629                setup_lines.push(format!(
1630                    "var {} = !string.IsNullOrEmpty(_pfUrl_{name}) ? _pfUrl_{name} : Environment.GetEnvironmentVariable(\"MOCK_SERVER_URL\") + \"/fixtures/{fixture_id}\";",
1631                    arg.name,
1632                    name = arg.name,
1633                ));
1634            } else {
1635                setup_lines.push(format!(
1636                    "var {} = Environment.GetEnvironmentVariable(\"MOCK_SERVER_URL\") + \"/fixtures/{fixture_id}\";",
1637                    arg.name,
1638                ));
1639            }
1640            if let Some(req_type) = adapter_request_type {
1641                let req_var = format!("{}Req", arg.name);
1642                setup_lines.push(format!("var {req_var} = new {req_type} {{ Url = {} }};", arg.name));
1643                parts.push(req_var);
1644            } else {
1645                parts.push(arg.name.clone());
1646            }
1647            continue;
1648        }
1649
1650        if arg.arg_type == "mock_url_list" {
1651            // List<string> of URLs: each element is either a bare path (`/seed1`) — prefixed
1652            // with the per-fixture mock-server URL at runtime — or an absolute URL kept as-is.
1653            // Mirrors `mock_url` resolution: `MOCK_SERVER_<FIXTURE_ID>` first, then
1654            // `MOCK_SERVER_URL/fixtures/<id>`. Emitted as a typed `List<string>` so it matches
1655            // the C# binding signature (`Task<BatchScrapeResults> BatchScrapeAsync(handle, List<string> urls)`),
1656            // which does not accept `string[]`.
1657            let env_key = format!("MOCK_SERVER_{}", fixture_id.to_uppercase());
1658            let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
1659            // Try both the declared field and common aliases (batch_urls, urls, etc.)
1660            let val = if let Some(v) = input.get(field).filter(|v| !v.is_null()) {
1661                v.clone()
1662            } else {
1663                super::resolve_urls_field(input, &arg.field).clone()
1664            };
1665            let paths: Vec<String> = if let Some(arr) = val.as_array() {
1666                arr.iter()
1667                    .filter_map(|v| v.as_str().map(|s| format!("\"{}\"", escape_csharp(s))))
1668                    .collect()
1669            } else {
1670                Vec::new()
1671            };
1672            let paths_literal = paths.join(", ");
1673            let name = &arg.name;
1674            setup_lines.push(format!(
1675                "var _pfBase_{name} = Environment.GetEnvironmentVariable(\"{env_key}\");"
1676            ));
1677            setup_lines.push(format!(
1678                "var _base_{name} = !string.IsNullOrEmpty(_pfBase_{name}) ? _pfBase_{name} : Environment.GetEnvironmentVariable(\"MOCK_SERVER_URL\") + \"/fixtures/{fixture_id}\";"
1679            ));
1680            setup_lines.push(format!(
1681                "var {name} = new System.Collections.Generic.List<string>(new string[] {{ {paths_literal} }}.Select(p => p.StartsWith(\"http\") ? p : _base_{name} + p));"
1682            ));
1683            parts.push(name.clone());
1684            continue;
1685        }
1686
1687        if arg.arg_type == "handle" {
1688            // Generate a CreateEngine (or equivalent) call and pass the variable.
1689            let constructor_name = format!("Create{}", arg.name.to_upper_camel_case());
1690            let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
1691            let config_value = input.get(field).unwrap_or(&serde_json::Value::Null);
1692            if config_value.is_null()
1693                || config_value.is_object() && config_value.as_object().is_some_and(|o| o.is_empty())
1694            {
1695                setup_lines.push(format!("var {} = {class_name}.{constructor_name}(null);", arg.name,));
1696            } else {
1697                // Sort discriminator fields ("type") to appear first in nested objects so
1698                // System.Text.Json [JsonPolymorphic] can find the type discriminator before
1699                // reading other properties (a requirement as of .NET 8).
1700                let sorted = sort_discriminator_first(config_value.clone());
1701                let json_str = serde_json::to_string(&sorted).unwrap_or_default();
1702                let name = &arg.name;
1703                setup_lines.push(format!(
1704                    "var {name}Config = JsonSerializer.Deserialize<CrawlConfig>(\"{}\", ConfigOptions)!;",
1705                    escape_csharp(&json_str),
1706                ));
1707                setup_lines.push(format!(
1708                    "var {} = {class_name}.{constructor_name}({name}Config);",
1709                    arg.name,
1710                    name = name,
1711                ));
1712            }
1713            parts.push(arg.name.clone());
1714            continue;
1715        }
1716
1717        // When field is exactly "input", treat the entire input object as the value.
1718        // This matches the convention used by other language generators (e.g. Go).
1719        let val: Option<&serde_json::Value> = if arg.field == "input" {
1720            Some(input)
1721        } else {
1722            let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
1723            input.get(field)
1724        };
1725        match val {
1726            None | Some(serde_json::Value::Null) if arg.optional => {
1727                // Optional arg with no fixture value: pass null explicitly since
1728                // C# nullable parameters still require an argument at the call site.
1729                parts.push("null".to_string());
1730                continue;
1731            }
1732            None | Some(serde_json::Value::Null) => {
1733                // Required arg with no fixture value: pass a language-appropriate default.
1734                // For json_object args with a known options_type, use `new OptionsType()`
1735                // so the generated code compiles when the method parameter is non-nullable.
1736                let default_val = match arg.arg_type.as_str() {
1737                    "string" => "\"\"".to_string(),
1738                    "int" | "integer" => "0".to_string(),
1739                    "float" | "number" => "0.0d".to_string(),
1740                    "bool" | "boolean" => "false".to_string(),
1741                    "json_object" => {
1742                        if let Some(opts_type) = options_type {
1743                            format!("new {opts_type}()")
1744                        } else {
1745                            "null".to_string()
1746                        }
1747                    }
1748                    _ => "null".to_string(),
1749                };
1750                parts.push(default_val);
1751            }
1752            Some(v) => {
1753                if arg.arg_type == "json_object" {
1754                    // `options_via = "from_json"`: deserialize the entire value (object,
1755                    // array, or scalar) as the options type. This sidesteps per-field
1756                    // type ambiguity — e.g. `JsonElement?` (untagged unions) or
1757                    // `List<NamedRecord>` whose element type cannot be inferred from
1758                    // JSON shape alone — by delegating to System.Text.Json.
1759                    if options_via == Some("from_json")
1760                        && let Some(opts_type) = options_type
1761                    {
1762                        let sorted = sort_discriminator_first(v.clone());
1763                        let json_str = serde_json::to_string(&sorted).unwrap_or_default();
1764                        let escaped = escape_csharp(&json_str);
1765                        // Use the binding-emitted `<Type>.FromJson(...)` factory so any
1766                        // System.Text.Json deserialization failure is wrapped in
1767                        // `<Crate>Exception`, allowing error fixtures asserting
1768                        // `Assert.ThrowsAny<<Crate>Exception>(...)` to catch the parse
1769                        // failure (e.g. `Unknown FilePurpose value: invalid-purpose`).
1770                        parts.push(format!("{opts_type}.FromJson(\"{escaped}\")",));
1771                        continue;
1772                    }
1773                    // Array value: generate a typed List<T> based on element_type.
1774                    if let Some(arr) = v.as_array() {
1775                        parts.push(json_array_to_csharp_list(arr, arg.element_type.as_deref()));
1776                        continue;
1777                    }
1778                    // Object value with known type: generate idiomatic C# object initializer.
1779                    if let Some(opts_type) = options_type {
1780                        if let Some(obj) = v.as_object() {
1781                            parts.push(csharp_object_initializer(obj, opts_type, enum_fields, nested_types));
1782                            continue;
1783                        }
1784                    }
1785                }
1786                parts.push(json_to_csharp(v));
1787            }
1788        }
1789    }
1790
1791    (setup_lines, parts.join(", "))
1792}
1793
1794/// Convert a JSON array to a typed C# `List<T>` expression.
1795///
1796/// Mapping from `ArgMapping::element_type`:
1797/// - `None` or any string type → `List<string>`
1798/// - `"f32"` → `List<float>` with `(float)` casts
1799/// - `"(String, String)"` → `List<List<string>>` for key-value pair arrays
1800/// - `"BatchBytesItem"` / `"BatchFileItem"` → array of batch item instances
1801fn json_array_to_csharp_list(arr: &[serde_json::Value], element_type: Option<&str>) -> String {
1802    match element_type {
1803        Some("BatchBytesItem") => {
1804            let items: Vec<String> = arr
1805                .iter()
1806                .filter_map(|v| v.as_object())
1807                .map(|obj| {
1808                    let content = obj.get("content").and_then(|v| v.as_array());
1809                    let mime_type = obj.get("mime_type").and_then(|v| v.as_str()).unwrap_or("text/plain");
1810                    let content_code = if let Some(arr) = content {
1811                        let bytes: Vec<String> = arr
1812                            .iter()
1813                            .filter_map(|v| v.as_u64().map(|n| format!("(byte){}", n)))
1814                            .collect();
1815                        format!("new byte[] {{ {} }}", bytes.join(", "))
1816                    } else {
1817                        "new byte[] { }".to_string()
1818                    };
1819                    format!(
1820                        "new BatchBytesItem {{ Content = {}, MimeType = \"{}\" }}",
1821                        content_code, mime_type
1822                    )
1823                })
1824                .collect();
1825            format!("new List<BatchBytesItem>() {{ {} }}", items.join(", "))
1826        }
1827        Some("BatchFileItem") => {
1828            let items: Vec<String> = arr
1829                .iter()
1830                .filter_map(|v| v.as_object())
1831                .map(|obj| {
1832                    let path = obj.get("path").and_then(|v| v.as_str()).unwrap_or("");
1833                    format!("new BatchFileItem {{ Path = \"{}\" }}", path)
1834                })
1835                .collect();
1836            format!("new List<BatchFileItem>() {{ {} }}", items.join(", "))
1837        }
1838        Some("f32") => {
1839            let items: Vec<String> = arr.iter().map(|v| format!("(float){}", json_to_csharp(v))).collect();
1840            format!("new List<float>() {{ {} }}", items.join(", "))
1841        }
1842        Some("(String, String)") => {
1843            let items: Vec<String> = arr
1844                .iter()
1845                .map(|v| {
1846                    let strs: Vec<String> = v
1847                        .as_array()
1848                        .map_or_else(Vec::new, |a| a.iter().map(json_to_csharp).collect());
1849                    format!("new List<string>() {{ {} }}", strs.join(", "))
1850                })
1851                .collect();
1852            format!("new List<List<string>>() {{ {} }}", items.join(", "))
1853        }
1854        Some(et)
1855            if et != "f32"
1856                && et != "(String, String)"
1857                && et != "string"
1858                && et != "BatchBytesItem"
1859                && et != "BatchFileItem" =>
1860        {
1861            // Class/record types: deserialize each element from JSON
1862            let items: Vec<String> = arr
1863                .iter()
1864                .map(|v| {
1865                    let json_str = serde_json::to_string(v).unwrap_or_default();
1866                    let escaped = escape_csharp(&json_str);
1867                    format!("JsonSerializer.Deserialize<{et}>(\"{escaped}\", ConfigOptions)!")
1868                })
1869                .collect();
1870            format!("new List<{et}>() {{ {} }}", items.join(", "))
1871        }
1872        _ => {
1873            let items: Vec<String> = arr.iter().map(json_to_csharp).collect();
1874            format!("new List<string>() {{ {} }}", items.join(", "))
1875        }
1876    }
1877}
1878
1879/// Detect if a field path accesses a discriminated union variant in C#.
1880/// Pattern: `metadata.format.<variant_name>.<field_name>`
1881/// Returns: Some((accessor, variant_name, inner_field)) if matched, otherwise None
1882fn parse_discriminated_union_access(field: &str) -> Option<(String, String, String)> {
1883    let parts: Vec<&str> = field.split('.').collect();
1884    if parts.len() >= 3 && parts.len() <= 4 {
1885        // Check if this is metadata.format.{variant}.{field} pattern
1886        if parts[0] == "metadata" && parts[1] == "format" {
1887            let variant_name = parts[2];
1888            // Known C# discriminated union variants (lowercase in fixture paths)
1889            let known_variants = [
1890                "pdf",
1891                "docx",
1892                "excel",
1893                "email",
1894                "pptx",
1895                "archive",
1896                "image",
1897                "xml",
1898                "text",
1899                "html",
1900                "ocr",
1901                "csv",
1902                "bibtex",
1903                "citation",
1904                "fiction_book",
1905                "dbf",
1906                "jats",
1907                "epub",
1908                "pst",
1909                "code",
1910            ];
1911            if known_variants.contains(&variant_name) {
1912                let variant_pascal = variant_name.to_upper_camel_case();
1913                if parts.len() == 4 {
1914                    let inner_field = parts[3];
1915                    return Some((
1916                        format!("result.Metadata.Format! as FormatMetadata.{}", variant_pascal),
1917                        variant_pascal,
1918                        inner_field.to_string(),
1919                    ));
1920                } else if parts.len() == 3 {
1921                    // Just accessing the variant itself (no inner field)
1922                    return Some((
1923                        format!("result.Metadata.Format! as FormatMetadata.{}", variant_pascal),
1924                        variant_pascal,
1925                        String::new(),
1926                    ));
1927                }
1928            }
1929        }
1930    }
1931    None
1932}
1933
1934/// Render an assertion against a discriminated union variant's inner field.
1935/// `variant_var` is the unwrapped union variant (e.g., `variant` from pattern match).
1936/// `inner_field` is the field to access on the variant's Value (e.g., `sheet_count`).
1937fn render_discriminated_union_assertion(
1938    out: &mut String,
1939    assertion: &Assertion,
1940    variant_var: &str,
1941    inner_field: &str,
1942    _result_is_vec: bool,
1943    assert_enum_fields: &std::collections::HashMap<String, String>,
1944) {
1945    if inner_field.is_empty() {
1946        return; // No field to assert on
1947    }
1948
1949    let field_pascal = inner_field.to_upper_camel_case();
1950    let mut field_expr = format!("{variant_var}.Value.{field_pascal}");
1951
1952    // Wrap enum fields with display helper
1953    if assert_enum_fields.contains_key(&field_pascal) {
1954        let type_name = assert_enum_fields.get(&field_pascal).unwrap();
1955        field_expr = format!("{type_name}Display.ToDisplayString({field_expr})");
1956    }
1957
1958    match assertion.assertion_type.as_str() {
1959        "equals" => {
1960            if let Some(expected) = &assertion.value {
1961                let cs_val = json_to_csharp(expected);
1962                if expected.is_string() {
1963                    let _ = writeln!(out, "            Assert.Equal({cs_val}, {field_expr}!.Trim());");
1964                } else if expected.as_bool() == Some(true) {
1965                    let _ = writeln!(out, "            Assert.True({field_expr});");
1966                } else if expected.as_bool() == Some(false) {
1967                    let _ = writeln!(out, "            Assert.False({field_expr});");
1968                } else if expected.is_number() && !expected.as_f64().is_some_and(|f| f.fract() != 0.0) {
1969                    let _ = writeln!(out, "            Assert.True({field_expr} == {cs_val});");
1970                } else {
1971                    let _ = writeln!(out, "            Assert.Equal({cs_val}, {field_expr});");
1972                }
1973            }
1974        }
1975        "greater_than_or_equal" => {
1976            if let Some(val) = &assertion.value {
1977                let cs_val = json_to_csharp(val);
1978                let _ = writeln!(
1979                    out,
1980                    "            Assert.True({field_expr} >= {cs_val}, \"expected >= {cs_val}\");"
1981                );
1982            }
1983        }
1984        "contains_all" => {
1985            if let Some(values) = &assertion.values {
1986                let field_as_str = format!("JsonSerializer.Serialize({field_expr})");
1987                for val in values {
1988                    let lower_val = val.as_str().map(|s| s.to_lowercase());
1989                    let cs_val = lower_val
1990                        .as_deref()
1991                        .map(|s| format!("\"{}\"", escape_csharp(s)))
1992                        .unwrap_or_else(|| json_to_csharp(val));
1993                    let _ = writeln!(out, "            Assert.Contains({cs_val}, {field_as_str}.ToLower());");
1994                }
1995            }
1996        }
1997        "contains" => {
1998            if let Some(expected) = &assertion.value {
1999                let field_as_str = format!("JsonSerializer.Serialize({field_expr})");
2000                let lower_expected = expected.as_str().map(|s| s.to_lowercase());
2001                let cs_val = lower_expected
2002                    .as_deref()
2003                    .map(|s| format!("\"{}\"", escape_csharp(s)))
2004                    .unwrap_or_else(|| json_to_csharp(expected));
2005                let _ = writeln!(out, "            Assert.Contains({cs_val}, {field_as_str}.ToLower());");
2006            }
2007        }
2008        "not_empty" => {
2009            let _ = writeln!(out, "            Assert.NotEmpty({field_expr});");
2010        }
2011        "is_empty" => {
2012            let _ = writeln!(out, "            Assert.Empty({field_expr});");
2013        }
2014        _ => {
2015            let _ = writeln!(
2016                out,
2017                "            // skipped: assertion type '{}' not yet supported for discriminated union fields",
2018                assertion.assertion_type
2019            );
2020        }
2021    }
2022}
2023
2024#[allow(clippy::too_many_arguments)]
2025fn render_assertion(
2026    out: &mut String,
2027    assertion: &Assertion,
2028    result_var: &str,
2029    class_name: &str,
2030    exception_class: &str,
2031    field_resolver: &FieldResolver,
2032    result_is_simple: bool,
2033    result_is_vec: bool,
2034    result_is_array: bool,
2035    result_is_bytes: bool,
2036    fields_enum: &std::collections::HashSet<String>,
2037    assert_enum_fields: &std::collections::HashMap<String, String>,
2038) {
2039    // Byte-buffer returns: emit length-based assertions instead of struct-field
2040    // accessors. The result is a `byte[]` and has no named fields like
2041    // `result.Audio` or `result.Content`.
2042    if result_is_bytes {
2043        match assertion.assertion_type.as_str() {
2044            "not_empty" => {
2045                let _ = writeln!(out, "        Assert.NotEmpty({result_var});");
2046                return;
2047            }
2048            "is_empty" => {
2049                let _ = writeln!(out, "        Assert.Empty({result_var});");
2050                return;
2051            }
2052            "count_equals" | "length_equals" => {
2053                if let Some(n) = assertion.value.as_ref().and_then(|v| v.as_u64()) {
2054                    let _ = writeln!(out, "        Assert.Equal({n}, {result_var}.Length);");
2055                }
2056                return;
2057            }
2058            "count_min" | "length_min" => {
2059                if let Some(n) = assertion.value.as_ref().and_then(|v| v.as_u64()) {
2060                    let _ = writeln!(out, "        Assert.True({result_var}.Length >= {n});");
2061                }
2062                return;
2063            }
2064            "not_error" => {
2065                let _ = writeln!(out, "        Assert.NotNull({result_var});");
2066                return;
2067            }
2068            _ => {
2069                // Other assertion types are not meaningful on raw byte buffers;
2070                // emit a comment so the test still compiles but flags unsupported
2071                // assertion types for fixture authors.
2072                let _ = writeln!(
2073                    out,
2074                    "        // skipped: assertion type '{}' not supported on byte[] result",
2075                    assertion.assertion_type
2076                );
2077                return;
2078            }
2079        }
2080    }
2081    // Handle synthetic / derived fields before the is_valid_for_result check
2082    // so they are never treated as struct property accesses on the result.
2083    if let Some(f) = &assertion.field {
2084        match f.as_str() {
2085            "chunks_have_content" => {
2086                let synthetic_pred =
2087                    format!("({result_var}.Chunks ?? new()).All(c => !string.IsNullOrEmpty(c.Content))");
2088                let synthetic_pred_type = match assertion.assertion_type.as_str() {
2089                    "is_true" => "is_true",
2090                    "is_false" => "is_false",
2091                    _ => {
2092                        out.push_str(&format!(
2093                            "        // skipped: unsupported assertion type on synthetic field '{f}'\n"
2094                        ));
2095                        return;
2096                    }
2097                };
2098                let rendered = crate::template_env::render(
2099                    "csharp/assertion.jinja",
2100                    minijinja::context! {
2101                        assertion_type => "synthetic_assertion",
2102                        synthetic_pred => synthetic_pred,
2103                        synthetic_pred_type => synthetic_pred_type,
2104                    },
2105                );
2106                out.push_str(&rendered);
2107                return;
2108            }
2109            "chunks_have_embeddings" => {
2110                let synthetic_pred =
2111                    format!("({result_var}.Chunks ?? new()).All(c => c.Embedding != null && c.Embedding.Count > 0)");
2112                let synthetic_pred_type = match assertion.assertion_type.as_str() {
2113                    "is_true" => "is_true",
2114                    "is_false" => "is_false",
2115                    _ => {
2116                        out.push_str(&format!(
2117                            "        // skipped: unsupported assertion type on synthetic field '{f}'\n"
2118                        ));
2119                        return;
2120                    }
2121                };
2122                let rendered = crate::template_env::render(
2123                    "csharp/assertion.jinja",
2124                    minijinja::context! {
2125                        assertion_type => "synthetic_assertion",
2126                        synthetic_pred => synthetic_pred,
2127                        synthetic_pred_type => synthetic_pred_type,
2128                    },
2129                );
2130                out.push_str(&rendered);
2131                return;
2132            }
2133            "chunks_have_heading_context" => {
2134                let synthetic_pred =
2135                    format!("({result_var}.Chunks ?? new()).All(c => c.Metadata?.HeadingContext != null)");
2136                let synthetic_pred_type = match assertion.assertion_type.as_str() {
2137                    "is_true" => "is_true",
2138                    "is_false" => "is_false",
2139                    _ => {
2140                        out.push_str(&format!(
2141                            "        // skipped: unsupported assertion type on synthetic field '{f}'\n"
2142                        ));
2143                        return;
2144                    }
2145                };
2146                let rendered = crate::template_env::render(
2147                    "csharp/assertion.jinja",
2148                    minijinja::context! {
2149                        assertion_type => "synthetic_assertion",
2150                        synthetic_pred => synthetic_pred,
2151                        synthetic_pred_type => synthetic_pred_type,
2152                    },
2153                );
2154                out.push_str(&rendered);
2155                return;
2156            }
2157            "first_chunk_starts_with_heading" => {
2158                let synthetic_pred =
2159                    format!("({result_var}.Chunks ?? new()).FirstOrDefault()?.Metadata?.HeadingContext != null");
2160                let synthetic_pred_type = match assertion.assertion_type.as_str() {
2161                    "is_true" => "is_true",
2162                    "is_false" => "is_false",
2163                    _ => {
2164                        out.push_str(&format!(
2165                            "        // skipped: unsupported assertion type on synthetic field '{f}'\n"
2166                        ));
2167                        return;
2168                    }
2169                };
2170                let rendered = crate::template_env::render(
2171                    "csharp/assertion.jinja",
2172                    minijinja::context! {
2173                        assertion_type => "synthetic_assertion",
2174                        synthetic_pred => synthetic_pred,
2175                        synthetic_pred_type => synthetic_pred_type,
2176                    },
2177                );
2178                out.push_str(&rendered);
2179                return;
2180            }
2181            // ---- EmbedResponse virtual fields ----
2182            // embed_texts returns List<List<float>> in C# — no wrapper object.
2183            // result_var is the embedding matrix; use it directly.
2184            "embeddings" => {
2185                match assertion.assertion_type.as_str() {
2186                    "count_equals" => {
2187                        if let Some(val) = &assertion.value {
2188                            if let Some(n) = val.as_u64() {
2189                                let rendered = crate::template_env::render(
2190                                    "csharp/assertion.jinja",
2191                                    minijinja::context! {
2192                                        assertion_type => "synthetic_embeddings_count_equals",
2193                                        synthetic_pred => format!("{result_var}.Count"),
2194                                        n => n,
2195                                    },
2196                                );
2197                                out.push_str(&rendered);
2198                            }
2199                        }
2200                    }
2201                    "count_min" => {
2202                        if let Some(val) = &assertion.value {
2203                            if let Some(n) = val.as_u64() {
2204                                let rendered = crate::template_env::render(
2205                                    "csharp/assertion.jinja",
2206                                    minijinja::context! {
2207                                        assertion_type => "synthetic_embeddings_count_min",
2208                                        synthetic_pred => format!("{result_var}.Count"),
2209                                        n => n,
2210                                    },
2211                                );
2212                                out.push_str(&rendered);
2213                            }
2214                        }
2215                    }
2216                    "not_empty" => {
2217                        let rendered = crate::template_env::render(
2218                            "csharp/assertion.jinja",
2219                            minijinja::context! {
2220                                assertion_type => "synthetic_embeddings_not_empty",
2221                                synthetic_pred => result_var.to_string(),
2222                            },
2223                        );
2224                        out.push_str(&rendered);
2225                    }
2226                    "is_empty" => {
2227                        let rendered = crate::template_env::render(
2228                            "csharp/assertion.jinja",
2229                            minijinja::context! {
2230                                assertion_type => "synthetic_embeddings_is_empty",
2231                                synthetic_pred => result_var.to_string(),
2232                            },
2233                        );
2234                        out.push_str(&rendered);
2235                    }
2236                    _ => {
2237                        out.push_str(
2238                            "        // skipped: unsupported assertion type on synthetic field 'embeddings'\n",
2239                        );
2240                    }
2241                }
2242                return;
2243            }
2244            "embedding_dimensions" => {
2245                let expr = format!("({result_var}.Count > 0 ? {result_var}[0].Count : 0)");
2246                match assertion.assertion_type.as_str() {
2247                    "equals" => {
2248                        if let Some(val) = &assertion.value {
2249                            if let Some(n) = val.as_u64() {
2250                                let rendered = crate::template_env::render(
2251                                    "csharp/assertion.jinja",
2252                                    minijinja::context! {
2253                                        assertion_type => "synthetic_embedding_dimensions_equals",
2254                                        synthetic_pred => expr,
2255                                        n => n,
2256                                    },
2257                                );
2258                                out.push_str(&rendered);
2259                            }
2260                        }
2261                    }
2262                    "greater_than" => {
2263                        if let Some(val) = &assertion.value {
2264                            if let Some(n) = val.as_u64() {
2265                                let rendered = crate::template_env::render(
2266                                    "csharp/assertion.jinja",
2267                                    minijinja::context! {
2268                                        assertion_type => "synthetic_embedding_dimensions_greater_than",
2269                                        synthetic_pred => expr,
2270                                        n => n,
2271                                    },
2272                                );
2273                                out.push_str(&rendered);
2274                            }
2275                        }
2276                    }
2277                    _ => {
2278                        out.push_str("        // skipped: unsupported assertion type on synthetic field 'embedding_dimensions'\n");
2279                    }
2280                }
2281                return;
2282            }
2283            "embeddings_valid" | "embeddings_finite" | "embeddings_non_zero" | "embeddings_normalized" => {
2284                let synthetic_pred = match f.as_str() {
2285                    "embeddings_valid" => {
2286                        format!("{result_var}.All(e => e.Count > 0)")
2287                    }
2288                    "embeddings_finite" => {
2289                        format!("{result_var}.All(e => e.All(v => !float.IsInfinity(v) && !float.IsNaN(v)))")
2290                    }
2291                    "embeddings_non_zero" => {
2292                        format!("{result_var}.All(e => e.Any(v => v != 0.0f))")
2293                    }
2294                    "embeddings_normalized" => {
2295                        format!(
2296                            "{result_var}.All(e => {{ var n = e.Sum(v => (double)v * v); return Math.Abs(n - 1.0) < 1e-3; }})"
2297                        )
2298                    }
2299                    _ => unreachable!(),
2300                };
2301                let synthetic_pred_type = match assertion.assertion_type.as_str() {
2302                    "is_true" => "is_true",
2303                    "is_false" => "is_false",
2304                    _ => {
2305                        out.push_str(&format!(
2306                            "        // skipped: unsupported assertion type on synthetic field '{f}'\n"
2307                        ));
2308                        return;
2309                    }
2310                };
2311                let rendered = crate::template_env::render(
2312                    "csharp/assertion.jinja",
2313                    minijinja::context! {
2314                        assertion_type => "synthetic_assertion",
2315                        synthetic_pred => synthetic_pred,
2316                        synthetic_pred_type => synthetic_pred_type,
2317                    },
2318                );
2319                out.push_str(&rendered);
2320                return;
2321            }
2322            // ---- keywords / keywords_count ----
2323            // C# ExtractionResult does not expose extracted_keywords; skip.
2324            "keywords" | "keywords_count" => {
2325                let skipped_reason = format!("field '{f}' not available on C# ExtractionResult");
2326                let rendered = crate::template_env::render(
2327                    "csharp/assertion.jinja",
2328                    minijinja::context! {
2329                        skipped_reason => skipped_reason,
2330                    },
2331                );
2332                out.push_str(&rendered);
2333                return;
2334            }
2335            _ => {}
2336        }
2337    }
2338
2339    // Skip assertions on fields that don't exist on the result type.
2340    if let Some(f) = &assertion.field {
2341        if !f.is_empty() && !field_resolver.is_valid_for_result(f) {
2342            let skipped_reason = format!("field '{f}' not available on result type");
2343            let rendered = crate::template_env::render(
2344                "csharp/assertion.jinja",
2345                minijinja::context! {
2346                    skipped_reason => skipped_reason,
2347                },
2348            );
2349            out.push_str(&rendered);
2350            return;
2351        }
2352    }
2353
2354    // For count assertions on list results with no field specified, use the list directly.
2355    // Otherwise, when the result is a List<T>, index into the first element for field access.
2356    let is_count_assertion = matches!(
2357        assertion.assertion_type.as_str(),
2358        "count_equals" | "count_min" | "count_max"
2359    );
2360    let is_no_field = assertion.field.is_none() || assertion.field.as_ref().is_some_and(|f| f.is_empty());
2361    let use_list_directly = result_is_vec && is_count_assertion && is_no_field;
2362
2363    let effective_result_var: String = if result_is_vec && !use_list_directly {
2364        format!("{result_var}[0]")
2365    } else {
2366        result_var.to_string()
2367    };
2368
2369    // Check if this is a discriminated union access (e.g., metadata.format.excel.sheet_count)
2370    let is_discriminated_union = assertion
2371        .field
2372        .as_ref()
2373        .is_some_and(|f| parse_discriminated_union_access(f).is_some());
2374
2375    // For discriminated union assertions, generate pattern-matching wrapper
2376    if is_discriminated_union {
2377        if let Some((_, variant_name, inner_field)) = assertion
2378            .field
2379            .as_ref()
2380            .and_then(|f| parse_discriminated_union_access(f))
2381        {
2382            // Use a unique variable name based on the field hash to avoid shadowing
2383            let mut hasher = std::collections::hash_map::DefaultHasher::new();
2384            inner_field.hash(&mut hasher);
2385            let var_hash = format!("{:x}", hasher.finish());
2386            let variant_var = format!("variant_{}", &var_hash[..8]);
2387            let _ = writeln!(
2388                out,
2389                "        if ({effective_result_var}.Metadata.Format is FormatMetadata.{} {})",
2390                variant_name, &variant_var
2391            );
2392            let _ = writeln!(out, "        {{");
2393            render_discriminated_union_assertion(
2394                out,
2395                assertion,
2396                &variant_var,
2397                &inner_field,
2398                result_is_vec,
2399                assert_enum_fields,
2400            );
2401            let _ = writeln!(out, "        }}");
2402            let _ = writeln!(out, "        else");
2403            let _ = writeln!(out, "        {{");
2404            let _ = writeln!(
2405                out,
2406                "            Assert.Fail(\"Expected {} format metadata\");",
2407                variant_name.to_lowercase()
2408            );
2409            let _ = writeln!(out, "        }}");
2410            return;
2411        }
2412    }
2413
2414    let field_expr = if result_is_simple {
2415        effective_result_var.clone()
2416    } else {
2417        match &assertion.field {
2418            Some(f) if !f.is_empty() => field_resolver.accessor(f, "csharp", &effective_result_var),
2419            _ => effective_result_var.clone(),
2420        }
2421    };
2422
2423    // Fields declared in `assert_enum_fields` map to sealed/internally-tagged enum
2424    // types. Wrap the accessor with a display helper (e.g., `FormatMetadataDisplay.ToDisplayString`)
2425    // so the assertion sees a display string rather than the raw sealed-union object.
2426    let field_expr = match &assertion.field {
2427        Some(f) if assert_enum_fields.contains_key(f.as_str()) => {
2428            let type_name = assert_enum_fields.get(f.as_str()).unwrap();
2429            format!("{type_name}Display.ToDisplayString({field_expr})")
2430        }
2431        _ => field_expr,
2432    };
2433
2434    // Determine if field_expr is a list or complex object that requires JSON serialization
2435    // for string-based assertions (contains, not_contains, etc.). List<T>.ToString() in C#
2436    // returns the type name, not the contents.
2437    let field_needs_json_serialize = if result_is_simple {
2438        // Simple results are scalars, but when they're also arrays (e.g., List<string>),
2439        // JSON-serialize so substring checks see actual content, not the type name.
2440        result_is_array
2441    } else {
2442        match &assertion.field {
2443            Some(f) if !f.is_empty() => field_resolver.is_array(f),
2444            // No field specified — the whole result object; needs serialization when complex.
2445            _ => !result_is_simple,
2446        }
2447    };
2448    // Build the string representation of field_expr for substring-based assertions.
2449    let field_as_str = if field_needs_json_serialize {
2450        format!("JsonSerializer.Serialize({field_expr})")
2451    } else {
2452        format!("{field_expr}.ToString()")
2453    };
2454
2455    // Detect enum-typed fields. C# emits typed enums (e.g. `FinishReason?`) for
2456    // these so the codegen must avoid `.Trim()` (string-only) and instead
2457    // compare via `?.ToString()?.ToLower()` to match snake_case JSON.
2458    let field_is_enum = assertion.field.as_deref().filter(|f| !f.is_empty()).is_some_and(|f| {
2459        let resolved = field_resolver.resolve(f);
2460        fields_enum.contains(f) || fields_enum.contains(resolved)
2461    });
2462
2463    match assertion.assertion_type.as_str() {
2464        "equals" => {
2465            if let Some(expected) = &assertion.value {
2466                // Enum field equality bypasses the template (which would emit `.Trim()`,
2467                // a string-only API). Compare the snake-cased ToString() against the
2468                // expected value to match the wire JSON form (`InProgress` → `in_progress`,
2469                // `ContentFilter` → `content_filter`, etc.). `JsonNamingPolicy.SnakeCaseLower`
2470                // is the same policy used by the global JsonStringEnumConverter, so the
2471                // assertion compares against exactly what serde would emit.
2472                if field_is_enum && expected.is_string() {
2473                    let s_lower = expected.as_str().map(|s| s.to_lowercase()).unwrap_or_default();
2474                    let _ = writeln!(
2475                        out,
2476                        "        Assert.Equal(\"{}\", {field_expr} == null ? null : JsonNamingPolicy.SnakeCaseLower.ConvertName({field_expr}.ToString()!));",
2477                        escape_csharp(&s_lower)
2478                    );
2479                    return;
2480                }
2481                let cs_val = json_to_csharp(expected);
2482                let is_string_val = expected.is_string();
2483                let is_bool_true = expected.as_bool() == Some(true);
2484                let is_bool_false = expected.as_bool() == Some(false);
2485                let is_integer_val = expected.is_number() && !expected.as_f64().is_some_and(|f| f.fract() != 0.0);
2486
2487                let rendered = crate::template_env::render(
2488                    "csharp/assertion.jinja",
2489                    minijinja::context! {
2490                        assertion_type => "equals",
2491                        field_expr => field_expr.clone(),
2492                        cs_val => cs_val,
2493                        is_string_val => is_string_val,
2494                        is_bool_true => is_bool_true,
2495                        is_bool_false => is_bool_false,
2496                        is_integer_val => is_integer_val,
2497                    },
2498                );
2499                out.push_str(&rendered);
2500            }
2501        }
2502        "contains" => {
2503            if let Some(expected) = &assertion.value {
2504                // Lowercase both expected and actual so that enum fields (where .ToString()
2505                // returns the PascalCase C# member name like "Anchor") correctly match
2506                // fixture snake_case values like "anchor".  String fields are unaffected
2507                // because lowercasing both sides preserves substring matches.
2508                // List/complex fields use JsonSerializer.Serialize() since List<T>.ToString()
2509                // returns the type name, not the contents.
2510                let lower_expected = expected.as_str().map(|s| s.to_lowercase());
2511                let cs_val = lower_expected
2512                    .as_deref()
2513                    .map(|s| format!("\"{}\"", escape_csharp(s)))
2514                    .unwrap_or_else(|| json_to_csharp(expected));
2515
2516                let rendered = crate::template_env::render(
2517                    "csharp/assertion.jinja",
2518                    minijinja::context! {
2519                        assertion_type => "contains",
2520                        field_as_str => field_as_str.clone(),
2521                        cs_val => cs_val,
2522                    },
2523                );
2524                out.push_str(&rendered);
2525            }
2526        }
2527        "contains_all" => {
2528            if let Some(values) = &assertion.values {
2529                let values_cs_lower: Vec<String> = values
2530                    .iter()
2531                    .map(|val| {
2532                        let lower_val = val.as_str().map(|s| s.to_lowercase());
2533                        lower_val
2534                            .as_deref()
2535                            .map(|s| format!("\"{}\"", escape_csharp(s)))
2536                            .unwrap_or_else(|| json_to_csharp(val))
2537                    })
2538                    .collect();
2539
2540                let rendered = crate::template_env::render(
2541                    "csharp/assertion.jinja",
2542                    minijinja::context! {
2543                        assertion_type => "contains_all",
2544                        field_as_str => field_as_str.clone(),
2545                        values_cs_lower => values_cs_lower,
2546                    },
2547                );
2548                out.push_str(&rendered);
2549            }
2550        }
2551        "not_contains" => {
2552            if let Some(expected) = &assertion.value {
2553                let cs_val = json_to_csharp(expected);
2554
2555                let rendered = crate::template_env::render(
2556                    "csharp/assertion.jinja",
2557                    minijinja::context! {
2558                        assertion_type => "not_contains",
2559                        field_as_str => field_as_str.clone(),
2560                        cs_val => cs_val,
2561                    },
2562                );
2563                out.push_str(&rendered);
2564            }
2565        }
2566        "not_empty" => {
2567            let rendered = crate::template_env::render(
2568                "csharp/assertion.jinja",
2569                minijinja::context! {
2570                    assertion_type => "not_empty",
2571                    field_expr => field_expr.clone(),
2572                    field_needs_json_serialize => field_needs_json_serialize,
2573                },
2574            );
2575            out.push_str(&rendered);
2576        }
2577        "is_empty" => {
2578            let rendered = crate::template_env::render(
2579                "csharp/assertion.jinja",
2580                minijinja::context! {
2581                    assertion_type => "is_empty",
2582                    field_expr => field_expr.clone(),
2583                    field_needs_json_serialize => field_needs_json_serialize,
2584                },
2585            );
2586            out.push_str(&rendered);
2587        }
2588        "contains_any" => {
2589            if let Some(values) = &assertion.values {
2590                let checks: Vec<String> = values
2591                    .iter()
2592                    .map(|v| {
2593                        let cs_val = json_to_csharp(v);
2594                        format!("{field_as_str}.Contains({cs_val})")
2595                    })
2596                    .collect();
2597                let contains_any_expr = checks.join(" || ");
2598
2599                let rendered = crate::template_env::render(
2600                    "csharp/assertion.jinja",
2601                    minijinja::context! {
2602                        assertion_type => "contains_any",
2603                        contains_any_expr => contains_any_expr,
2604                    },
2605                );
2606                out.push_str(&rendered);
2607            }
2608        }
2609        "greater_than" => {
2610            if let Some(val) = &assertion.value {
2611                let cs_val = json_to_csharp(val);
2612
2613                let rendered = crate::template_env::render(
2614                    "csharp/assertion.jinja",
2615                    minijinja::context! {
2616                        assertion_type => "greater_than",
2617                        field_expr => field_expr.clone(),
2618                        cs_val => cs_val,
2619                    },
2620                );
2621                out.push_str(&rendered);
2622            }
2623        }
2624        "less_than" => {
2625            if let Some(val) = &assertion.value {
2626                let cs_val = json_to_csharp(val);
2627
2628                let rendered = crate::template_env::render(
2629                    "csharp/assertion.jinja",
2630                    minijinja::context! {
2631                        assertion_type => "less_than",
2632                        field_expr => field_expr.clone(),
2633                        cs_val => cs_val,
2634                    },
2635                );
2636                out.push_str(&rendered);
2637            }
2638        }
2639        "greater_than_or_equal" => {
2640            if let Some(val) = &assertion.value {
2641                let cs_val = json_to_csharp(val);
2642
2643                let rendered = crate::template_env::render(
2644                    "csharp/assertion.jinja",
2645                    minijinja::context! {
2646                        assertion_type => "greater_than_or_equal",
2647                        field_expr => field_expr.clone(),
2648                        cs_val => cs_val,
2649                    },
2650                );
2651                out.push_str(&rendered);
2652            }
2653        }
2654        "less_than_or_equal" => {
2655            if let Some(val) = &assertion.value {
2656                let cs_val = json_to_csharp(val);
2657
2658                let rendered = crate::template_env::render(
2659                    "csharp/assertion.jinja",
2660                    minijinja::context! {
2661                        assertion_type => "less_than_or_equal",
2662                        field_expr => field_expr.clone(),
2663                        cs_val => cs_val,
2664                    },
2665                );
2666                out.push_str(&rendered);
2667            }
2668        }
2669        "starts_with" => {
2670            if let Some(expected) = &assertion.value {
2671                let cs_val = json_to_csharp(expected);
2672
2673                let rendered = crate::template_env::render(
2674                    "csharp/assertion.jinja",
2675                    minijinja::context! {
2676                        assertion_type => "starts_with",
2677                        field_expr => field_expr.clone(),
2678                        cs_val => cs_val,
2679                    },
2680                );
2681                out.push_str(&rendered);
2682            }
2683        }
2684        "ends_with" => {
2685            if let Some(expected) = &assertion.value {
2686                let cs_val = json_to_csharp(expected);
2687
2688                let rendered = crate::template_env::render(
2689                    "csharp/assertion.jinja",
2690                    minijinja::context! {
2691                        assertion_type => "ends_with",
2692                        field_expr => field_expr.clone(),
2693                        cs_val => cs_val,
2694                    },
2695                );
2696                out.push_str(&rendered);
2697            }
2698        }
2699        "min_length" => {
2700            if let Some(val) = &assertion.value {
2701                if let Some(n) = val.as_u64() {
2702                    let rendered = crate::template_env::render(
2703                        "csharp/assertion.jinja",
2704                        minijinja::context! {
2705                            assertion_type => "min_length",
2706                            field_expr => field_expr.clone(),
2707                            n => n,
2708                        },
2709                    );
2710                    out.push_str(&rendered);
2711                }
2712            }
2713        }
2714        "max_length" => {
2715            if let Some(val) = &assertion.value {
2716                if let Some(n) = val.as_u64() {
2717                    let rendered = crate::template_env::render(
2718                        "csharp/assertion.jinja",
2719                        minijinja::context! {
2720                            assertion_type => "max_length",
2721                            field_expr => field_expr.clone(),
2722                            n => n,
2723                        },
2724                    );
2725                    out.push_str(&rendered);
2726                }
2727            }
2728        }
2729        "count_min" => {
2730            if let Some(val) = &assertion.value {
2731                if let Some(n) = val.as_u64() {
2732                    let rendered = crate::template_env::render(
2733                        "csharp/assertion.jinja",
2734                        minijinja::context! {
2735                            assertion_type => "count_min",
2736                            field_expr => field_expr.clone(),
2737                            n => n,
2738                        },
2739                    );
2740                    out.push_str(&rendered);
2741                }
2742            }
2743        }
2744        "count_equals" => {
2745            if let Some(val) = &assertion.value {
2746                if let Some(n) = val.as_u64() {
2747                    let rendered = crate::template_env::render(
2748                        "csharp/assertion.jinja",
2749                        minijinja::context! {
2750                            assertion_type => "count_equals",
2751                            field_expr => field_expr.clone(),
2752                            n => n,
2753                        },
2754                    );
2755                    out.push_str(&rendered);
2756                }
2757            }
2758        }
2759        "is_true" => {
2760            // When the field expression is not a simple bool (e.g., a complex object or
2761            // result type), use Assert.NotNull instead of Assert.True to avoid cast issues.
2762            // If it's clearly not a boolean type (contains null-checking operators or is a
2763            // complex object), treat it as a not-null check.
2764            let is_complex_or_object = field_expr.contains("(object)")
2765                || (field_expr.contains(".")
2766                    && !result_is_simple
2767                    && !field_expr.contains("?")
2768                    && !field_expr.contains("=="));
2769
2770            let rendered = if is_complex_or_object {
2771                crate::template_env::render(
2772                    "csharp/assertion.jinja",
2773                    minijinja::context! {
2774                        assertion_type => "not_empty",
2775                        field_expr => field_expr.clone(),
2776                        field_needs_json_serialize => false,
2777                    },
2778                )
2779            } else {
2780                crate::template_env::render(
2781                    "csharp/assertion.jinja",
2782                    minijinja::context! {
2783                        assertion_type => "is_true",
2784                        field_expr => field_expr.clone(),
2785                    },
2786                )
2787            };
2788            out.push_str(&rendered);
2789        }
2790        "is_false" => {
2791            let is_complex_or_object = field_expr.contains("(object)")
2792                || (field_expr.contains(".")
2793                    && !result_is_simple
2794                    && !field_expr.contains("?")
2795                    && !field_expr.contains("=="));
2796
2797            let rendered = if is_complex_or_object {
2798                // For complex types, is_false means "is empty/null"
2799                crate::template_env::render(
2800                    "csharp/assertion.jinja",
2801                    minijinja::context! {
2802                        assertion_type => "is_empty",
2803                        field_expr => field_expr.clone(),
2804                        field_needs_json_serialize => false,
2805                    },
2806                )
2807            } else {
2808                crate::template_env::render(
2809                    "csharp/assertion.jinja",
2810                    minijinja::context! {
2811                        assertion_type => "is_false",
2812                        field_expr => field_expr.clone(),
2813                    },
2814                )
2815            };
2816            out.push_str(&rendered);
2817        }
2818        "not_error" => {
2819            // Already handled by the call succeeding without exception.
2820            let rendered = crate::template_env::render(
2821                "csharp/assertion.jinja",
2822                minijinja::context! {
2823                    assertion_type => "not_error",
2824                },
2825            );
2826            out.push_str(&rendered);
2827        }
2828        "error" => {
2829            // Handled at the test method level.
2830            let rendered = crate::template_env::render(
2831                "csharp/assertion.jinja",
2832                minijinja::context! {
2833                    assertion_type => "error",
2834                },
2835            );
2836            out.push_str(&rendered);
2837        }
2838        "method_result" => {
2839            if let Some(method_name) = &assertion.method {
2840                let call_expr = build_csharp_method_call(result_var, method_name, assertion.args.as_ref(), class_name);
2841                let check = assertion.check.as_deref().unwrap_or("is_true");
2842
2843                match check {
2844                    "equals" => {
2845                        if let Some(val) = &assertion.value {
2846                            let is_check_bool_true = val.as_bool() == Some(true);
2847                            let is_check_bool_false = val.as_bool() == Some(false);
2848                            let cs_check_val = json_to_csharp(val);
2849
2850                            let rendered = crate::template_env::render(
2851                                "csharp/assertion.jinja",
2852                                minijinja::context! {
2853                                    assertion_type => "method_result",
2854                                    check => "equals",
2855                                    call_expr => call_expr.clone(),
2856                                    is_check_bool_true => is_check_bool_true,
2857                                    is_check_bool_false => is_check_bool_false,
2858                                    cs_check_val => cs_check_val,
2859                                },
2860                            );
2861                            out.push_str(&rendered);
2862                        }
2863                    }
2864                    "is_true" => {
2865                        let rendered = crate::template_env::render(
2866                            "csharp/assertion.jinja",
2867                            minijinja::context! {
2868                                assertion_type => "method_result",
2869                                check => "is_true",
2870                                call_expr => call_expr.clone(),
2871                            },
2872                        );
2873                        out.push_str(&rendered);
2874                    }
2875                    "is_false" => {
2876                        let rendered = crate::template_env::render(
2877                            "csharp/assertion.jinja",
2878                            minijinja::context! {
2879                                assertion_type => "method_result",
2880                                check => "is_false",
2881                                call_expr => call_expr.clone(),
2882                            },
2883                        );
2884                        out.push_str(&rendered);
2885                    }
2886                    "greater_than_or_equal" => {
2887                        if let Some(val) = &assertion.value {
2888                            let check_n = val.as_u64().unwrap_or(0);
2889
2890                            let rendered = crate::template_env::render(
2891                                "csharp/assertion.jinja",
2892                                minijinja::context! {
2893                                    assertion_type => "method_result",
2894                                    check => "greater_than_or_equal",
2895                                    call_expr => call_expr.clone(),
2896                                    check_n => check_n,
2897                                },
2898                            );
2899                            out.push_str(&rendered);
2900                        }
2901                    }
2902                    "count_min" => {
2903                        if let Some(val) = &assertion.value {
2904                            let check_n = val.as_u64().unwrap_or(0);
2905
2906                            let rendered = crate::template_env::render(
2907                                "csharp/assertion.jinja",
2908                                minijinja::context! {
2909                                    assertion_type => "method_result",
2910                                    check => "count_min",
2911                                    call_expr => call_expr.clone(),
2912                                    check_n => check_n,
2913                                },
2914                            );
2915                            out.push_str(&rendered);
2916                        }
2917                    }
2918                    "is_error" => {
2919                        let rendered = crate::template_env::render(
2920                            "csharp/assertion.jinja",
2921                            minijinja::context! {
2922                                assertion_type => "method_result",
2923                                check => "is_error",
2924                                call_expr => call_expr.clone(),
2925                                exception_class => exception_class,
2926                            },
2927                        );
2928                        out.push_str(&rendered);
2929                    }
2930                    "contains" => {
2931                        if let Some(val) = &assertion.value {
2932                            let cs_check_val = json_to_csharp(val);
2933
2934                            let rendered = crate::template_env::render(
2935                                "csharp/assertion.jinja",
2936                                minijinja::context! {
2937                                    assertion_type => "method_result",
2938                                    check => "contains",
2939                                    call_expr => call_expr.clone(),
2940                                    cs_check_val => cs_check_val,
2941                                },
2942                            );
2943                            out.push_str(&rendered);
2944                        }
2945                    }
2946                    other_check => {
2947                        panic!("C# e2e generator: unsupported method_result check type: {other_check}");
2948                    }
2949                }
2950            } else {
2951                panic!("C# e2e generator: method_result assertion missing 'method' field");
2952            }
2953        }
2954        "matches_regex" => {
2955            if let Some(expected) = &assertion.value {
2956                let cs_val = json_to_csharp(expected);
2957
2958                let rendered = crate::template_env::render(
2959                    "csharp/assertion.jinja",
2960                    minijinja::context! {
2961                        assertion_type => "matches_regex",
2962                        field_expr => field_expr.clone(),
2963                        cs_val => cs_val,
2964                    },
2965                );
2966                out.push_str(&rendered);
2967            }
2968        }
2969        other => {
2970            panic!("C# e2e generator: unsupported assertion type: {other}");
2971        }
2972    }
2973}
2974
2975/// Recursively sort JSON objects so that any key named `"type"` appears first.
2976///
2977/// System.Text.Json's `[JsonPolymorphic]` requires the type discriminator to be
2978/// the first property when deserializing polymorphic types. Fixture config values
2979/// serialised via serde_json preserve insertion/alphabetical order, which may put
2980/// `"type"` after other keys (e.g. `"password"` before `"type"` in auth configs).
2981fn sort_discriminator_first(value: serde_json::Value) -> serde_json::Value {
2982    match value {
2983        serde_json::Value::Object(map) => {
2984            let mut sorted = serde_json::Map::with_capacity(map.len());
2985            // Insert "type" first if present.
2986            if let Some(type_val) = map.get("type") {
2987                sorted.insert("type".to_string(), sort_discriminator_first(type_val.clone()));
2988            }
2989            for (k, v) in map {
2990                if k != "type" {
2991                    sorted.insert(k, sort_discriminator_first(v));
2992                }
2993            }
2994            serde_json::Value::Object(sorted)
2995        }
2996        serde_json::Value::Array(arr) => {
2997            serde_json::Value::Array(arr.into_iter().map(sort_discriminator_first).collect())
2998        }
2999        other => other,
3000    }
3001}
3002
3003/// Render a C# sealed-union display helper for assert_enum_fields.
3004/// Pattern-matches on variants from the IR and returns a displayable string.
3005fn render_sealed_display(
3006    type_name: &str,
3007    enum_def: &alef_core::ir::EnumDef,
3008    type_defs: &[alef_core::ir::TypeDef],
3009    namespace: &str,
3010) -> String {
3011    let header = hash::header(CommentStyle::DoubleSlash);
3012    let mut out = header;
3013    out.push_str(&format!("namespace {namespace}.E2e;\n\n"));
3014    out.push_str(&format!(
3015        "/// <summary>\n/// Helper class for extracting display strings from {type_name} sealed interface.\n /// </summary>\n"
3016    ));
3017    out.push_str(&format!("internal static class {type_name}Display\n"));
3018    out.push_str("{\n");
3019    out.push_str(&format!(
3020        "    internal static string ToDisplayString({type_name}? value)\n"
3021    ));
3022    out.push_str("    {\n");
3023    out.push_str("        if (value == null) return \"\";\n");
3024    out.push_str("        return value switch\n");
3025    out.push_str("        {\n");
3026
3027    for variant in &enum_def.variants {
3028        let variant_name = &variant.name;
3029        // Determine the display string for this variant's arm.
3030        // Tuple variants with one field whose resolved struct type has a `format`
3031        // field return the inner `.Value.Format` — this gives the actual format
3032        // string (e.g. "PNG") rather than the generic variant label (e.g. "image").
3033        let has_format_field = variant.is_tuple && variant.fields.len() == 1 && {
3034            let field_type_name = match &variant.fields[0].ty {
3035                alef_core::ir::TypeRef::Named(n) => Some(n.as_str()),
3036                _ => None,
3037            };
3038            field_type_name.is_some_and(|tn| {
3039                type_defs
3040                    .iter()
3041                    .find(|td| td.name == tn)
3042                    .is_some_and(|td| td.fields.iter().any(|f| f.name == "format"))
3043            })
3044        };
3045
3046        let display = if has_format_field {
3047            "i.Value.Format".to_string()
3048        } else {
3049            // Use the serde rename when present; otherwise lowercase the variant name.
3050            let serde_name = variant
3051                .serde_rename
3052                .as_deref()
3053                .unwrap_or(variant_name.as_str())
3054                .to_lowercase();
3055            format!("\"{serde_name}\"")
3056        };
3057
3058        let binding = if has_format_field {
3059            format!("{type_name}.{variant_name} i")
3060        } else {
3061            format!("{type_name}.{variant_name}")
3062        };
3063
3064        out.push_str(&format!("            {binding} => {display},\n"));
3065    }
3066
3067    out.push_str("            _ => \"unknown\",\n");
3068    out.push_str("        };\n");
3069    out.push_str("    }\n");
3070    out.push_str("}\n");
3071    out
3072}
3073
3074/// Convert a `serde_json::Value` to a C# literal string.
3075fn json_to_csharp(value: &serde_json::Value) -> String {
3076    match value {
3077        serde_json::Value::String(s) => format!("\"{}\"", escape_csharp(s)),
3078        serde_json::Value::Bool(true) => "true".to_string(),
3079        serde_json::Value::Bool(false) => "false".to_string(),
3080        serde_json::Value::Number(n) => {
3081            if n.is_f64() {
3082                format!("{}d", n)
3083            } else {
3084                n.to_string()
3085            }
3086        }
3087        serde_json::Value::Null => "null".to_string(),
3088        serde_json::Value::Array(arr) => {
3089            let items: Vec<String> = arr.iter().map(json_to_csharp).collect();
3090            format!("new[] {{ {} }}", items.join(", "))
3091        }
3092        serde_json::Value::Object(_) => {
3093            let json_str = serde_json::to_string(value).unwrap_or_default();
3094            format!("\"{}\"", escape_csharp(&json_str))
3095        }
3096    }
3097}
3098
3099/// Emit a C# object initializer for a JSON options object.
3100///
3101/// - camelCase fixture keys → PascalCase C# property names
3102/// - Enum fields (from `enum_fields`) → `EnumType.Member`
3103/// - Nested objects with known type (from `nested_types`) → `JsonSerializer.Deserialize<T>(...)`
3104/// - Arrays → `new List<string> { ... }`
3105/// - Primitives → C# literals via `json_to_csharp`
3106fn csharp_object_initializer(
3107    obj: &serde_json::Map<String, serde_json::Value>,
3108    type_name: &str,
3109    enum_fields: &HashMap<String, String>,
3110    nested_types: &HashMap<String, String>,
3111) -> String {
3112    if obj.is_empty() {
3113        return format!("new {type_name}()");
3114    }
3115
3116    // Snake_case fixture keys for fields that are real C# enums in the binding.
3117    // The fixture string value (e.g. "markdown") maps to `EnumType.Member` (e.g. `OutputFormat.Markdown`).
3118    static IMPLICIT_ENUM_FIELDS: &[(&str, &str)] = &[("output_format", "OutputFormat")];
3119
3120    let props: Vec<String> = obj
3121        .iter()
3122        .map(|(key, val)| {
3123            let pascal_key = key.to_upper_camel_case();
3124            let implicit_enum_type = IMPLICIT_ENUM_FIELDS
3125                .iter()
3126                .find(|(k, _)| *k == key.as_str())
3127                .map(|(_, t)| *t);
3128            // Check enum_fields both with the original snake_case key AND with camelCase key.
3129            // The alef.toml config uses camelCase keys (e.g., "codeBlockStyle"), but fixture
3130            // JSON uses snake_case keys (e.g., "code_block_style"). So we check both.
3131            let camel_key = key.to_lower_camel_case();
3132            let cs_val = if let Some(enum_type) = enum_fields
3133                .get(key.as_str())
3134                .or_else(|| enum_fields.get(camel_key.as_str()))
3135                .map(String::as_str)
3136                .or(implicit_enum_type)
3137            {
3138                // Enum: EnumType.Member
3139                if val.is_null() {
3140                    "null".to_string()
3141                } else {
3142                    let member = val
3143                        .as_str()
3144                        .map(|s| s.to_upper_camel_case())
3145                        .unwrap_or_else(|| "null".to_string());
3146                    format!("{enum_type}.{member}")
3147                }
3148            } else if let Some(nested_type) = nested_types
3149                .get(key.as_str())
3150                .or_else(|| nested_types.get(camel_key.as_str()))
3151            {
3152                // Nested type: deserialize via JsonSerializer using the binding's custom converters.
3153                // This handles sealed records, custom JsonConverters, and sealed unions correctly.
3154                let normalized = normalize_csharp_enum_values(val, enum_fields);
3155                let json_str = serde_json::to_string(&normalized).unwrap_or_default();
3156                let escaped = escape_csharp(&json_str);
3157                format!("JsonSerializer.Deserialize<{nested_type}>(\"{escaped}\", ConfigOptions)!")
3158            } else if let Some(arr) = val.as_array() {
3159                // Array: List<string>
3160                let items: Vec<String> = arr.iter().map(json_to_csharp).collect();
3161                format!("new List<string> {{ {} }}", items.join(", "))
3162            } else {
3163                json_to_csharp(val)
3164            };
3165            format!("{pascal_key} = {cs_val}")
3166        })
3167        .collect();
3168    format!("new {} {{ {} }}", type_name, props.join(", "))
3169}
3170
3171/// Convert enum values in a JSON object to lowercase to match C# [JsonPropertyName] attributes.
3172/// The JSON deserialization uses JsonPropertyName("lowercase_value"), so fixture enum values
3173/// (typically PascalCase like "Tildes") must be converted to lowercase ("tildes") for correct
3174/// deserialization with JsonStringEnumConverter.
3175fn normalize_csharp_enum_values(value: &serde_json::Value, enum_fields: &HashMap<String, String>) -> serde_json::Value {
3176    match value {
3177        serde_json::Value::Object(map) => {
3178            let mut result = map.clone();
3179            for (key, val) in result.iter_mut() {
3180                // Check both snake_case and camelCase keys, since alef.toml uses camelCase
3181                // but fixture JSON uses snake_case.
3182                let camel_key = key.to_lower_camel_case();
3183                if enum_fields.contains_key(key) || enum_fields.contains_key(camel_key.as_str()) {
3184                    // This is an enum field; convert the string value to lowercase.
3185                    if let Some(s) = val.as_str() {
3186                        *val = serde_json::Value::String(s.to_lowercase());
3187                    }
3188                }
3189            }
3190            serde_json::Value::Object(result)
3191        }
3192        other => other.clone(),
3193    }
3194}
3195
3196// ---------------------------------------------------------------------------
3197// Visitor generation
3198// ---------------------------------------------------------------------------
3199
3200/// Build a C# visitor: add an instantiation line to `setup_lines` and push
3201/// a private nested class declaration to `class_decls` (emitted at class scope,
3202/// outside any method body — C# does not allow local class declarations inside
3203/// methods).  Each fixture gets a unique class name derived from its ID to avoid
3204/// duplicate-name compile errors when multiple visitor fixtures exist per file.
3205/// Returns the visitor variable name for use as a call argument.
3206fn build_csharp_visitor(
3207    setup_lines: &mut Vec<String>,
3208    class_decls: &mut Vec<String>,
3209    fixture_id: &str,
3210    visitor_spec: &crate::fixture::VisitorSpec,
3211) -> String {
3212    use heck::ToUpperCamelCase;
3213    let class_name = format!("{}Visitor", fixture_id.to_upper_camel_case());
3214    let var_name = format!("_visitor_{}", fixture_id.replace('-', "_"));
3215
3216    setup_lines.push(format!("var {var_name} = new {class_name}();"));
3217
3218    // Build the class declaration string (indented for nesting inside the test class).
3219    let mut decl = String::new();
3220    decl.push_str(&format!("    private sealed class {class_name} : IHtmlVisitor\n"));
3221    decl.push_str("    {\n");
3222
3223    // List of all visitor methods that must be implemented by IHtmlVisitor.
3224    let all_methods = [
3225        "visit_element_start",
3226        "visit_element_end",
3227        "visit_text",
3228        "visit_link",
3229        "visit_image",
3230        "visit_heading",
3231        "visit_code_block",
3232        "visit_code_inline",
3233        "visit_list_item",
3234        "visit_list_start",
3235        "visit_list_end",
3236        "visit_table_start",
3237        "visit_table_row",
3238        "visit_table_end",
3239        "visit_blockquote",
3240        "visit_strong",
3241        "visit_emphasis",
3242        "visit_strikethrough",
3243        "visit_underline",
3244        "visit_subscript",
3245        "visit_superscript",
3246        "visit_mark",
3247        "visit_line_break",
3248        "visit_horizontal_rule",
3249        "visit_custom_element",
3250        "visit_definition_list_start",
3251        "visit_definition_term",
3252        "visit_definition_description",
3253        "visit_definition_list_end",
3254        "visit_form",
3255        "visit_input",
3256        "visit_button",
3257        "visit_audio",
3258        "visit_video",
3259        "visit_iframe",
3260        "visit_details",
3261        "visit_summary",
3262        "visit_figure_start",
3263        "visit_figcaption",
3264        "visit_figure_end",
3265    ];
3266
3267    // Emit all methods: use fixture action if specified, otherwise default to Continue.
3268    for method_name in &all_methods {
3269        if let Some(action) = visitor_spec.callbacks.get(*method_name) {
3270            emit_csharp_visitor_method(&mut decl, method_name, action);
3271        } else {
3272            // Default: Continue for methods not in the fixture
3273            emit_csharp_visitor_method(&mut decl, method_name, &CallbackAction::Continue);
3274        }
3275    }
3276
3277    decl.push_str("    }\n");
3278    class_decls.push(decl);
3279
3280    var_name
3281}
3282
3283/// Emit a C# visitor method into a class declaration string.
3284fn emit_csharp_visitor_method(decl: &mut String, method_name: &str, action: &CallbackAction) {
3285    let camel_method = method_to_camel(method_name);
3286    let params = match method_name {
3287        "visit_link" => "NodeContext ctx, string href, string text, string title",
3288        "visit_image" => "NodeContext ctx, string src, string alt, string title",
3289        "visit_heading" => "NodeContext ctx, uint level, string text, string id",
3290        "visit_code_block" => "NodeContext ctx, string lang, string code",
3291        "visit_code_inline"
3292        | "visit_strong"
3293        | "visit_emphasis"
3294        | "visit_strikethrough"
3295        | "visit_underline"
3296        | "visit_subscript"
3297        | "visit_superscript"
3298        | "visit_mark"
3299        | "visit_button"
3300        | "visit_summary"
3301        | "visit_figcaption"
3302        | "visit_definition_term"
3303        | "visit_definition_description" => "NodeContext ctx, string text",
3304        "visit_text" => "NodeContext ctx, string text",
3305        "visit_list_item" => "NodeContext ctx, bool ordered, string marker, string text",
3306        "visit_blockquote" => "NodeContext ctx, string content, ulong depth",
3307        "visit_table_row" => "NodeContext ctx, List<string> cells, bool isHeader",
3308        "visit_custom_element" => "NodeContext ctx, string tagName, string html",
3309        "visit_form" => "NodeContext ctx, string actionUrl, string method",
3310        "visit_input" => "NodeContext ctx, string inputType, string name, string value",
3311        "visit_audio" | "visit_video" | "visit_iframe" => "NodeContext ctx, string src",
3312        "visit_details" => "NodeContext ctx, bool isOpen",
3313        "visit_element_end" | "visit_table_end" | "visit_definition_list_end" | "visit_figure_end" => {
3314            "NodeContext ctx, string output"
3315        }
3316        "visit_list_start" => "NodeContext ctx, bool ordered",
3317        "visit_list_end" => "NodeContext ctx, bool ordered, string output",
3318        "visit_element_start"
3319        | "visit_table_start"
3320        | "visit_definition_list_start"
3321        | "visit_figure_start"
3322        | "visit_line_break"
3323        | "visit_horizontal_rule" => "NodeContext ctx",
3324        _ => "NodeContext ctx",
3325    };
3326
3327    let (action_type, action_value) = match action {
3328        CallbackAction::Skip => ("skip", String::new()),
3329        CallbackAction::Continue => ("continue", String::new()),
3330        CallbackAction::PreserveHtml => ("preserve_html", String::new()),
3331        CallbackAction::Custom { output } => ("custom", escape_csharp(output)),
3332        CallbackAction::CustomTemplate { template, .. } => {
3333            let camel = snake_case_template_to_camel(template);
3334            ("custom_template", escape_csharp(&camel))
3335        }
3336    };
3337
3338    let rendered = crate::template_env::render(
3339        "csharp/visitor_method.jinja",
3340        minijinja::context! {
3341            camel_method => camel_method,
3342            params => params,
3343            action_type => action_type,
3344            action_value => action_value,
3345        },
3346    );
3347    let _ = write!(decl, "{}", rendered);
3348}
3349
3350/// Convert snake_case method names to C# PascalCase.
3351fn method_to_camel(snake: &str) -> String {
3352    use heck::ToUpperCamelCase;
3353    snake.to_upper_camel_case()
3354}
3355
3356/// Rewrite `{snake_case}` placeholders in a custom template to `{camelCase}` so
3357/// they match C# parameter names (which alef emits in camelCase).
3358fn snake_case_template_to_camel(template: &str) -> String {
3359    use heck::ToLowerCamelCase;
3360    let mut out = String::with_capacity(template.len());
3361    let mut chars = template.chars().peekable();
3362    while let Some(c) = chars.next() {
3363        if c == '{' {
3364            let mut name = String::new();
3365            while let Some(&nc) = chars.peek() {
3366                if nc == '}' {
3367                    chars.next();
3368                    break;
3369                }
3370                name.push(nc);
3371                chars.next();
3372            }
3373            out.push('{');
3374            out.push_str(&name.to_lower_camel_case());
3375            out.push('}');
3376        } else {
3377            out.push(c);
3378        }
3379    }
3380    out
3381}
3382
3383/// Build a C# call expression for a `method_result` assertion on a tree-sitter Tree.
3384///
3385/// Maps well-known method names to the appropriate C# static helper calls on the
3386/// generated lib class, falling back to `result_var.PascalCase()` for unknowns.
3387fn build_csharp_method_call(
3388    result_var: &str,
3389    method_name: &str,
3390    args: Option<&serde_json::Value>,
3391    class_name: &str,
3392) -> String {
3393    match method_name {
3394        "root_child_count" => format!("{result_var}.RootNode.ChildCount"),
3395        "root_node_type" => format!("{result_var}.RootNode.Kind"),
3396        "named_children_count" => format!("{result_var}.RootNode.NamedChildCount"),
3397        "has_error_nodes" => format!("{class_name}.TreeHasErrorNodes({result_var})"),
3398        "error_count" | "tree_error_count" => format!("{class_name}.TreeErrorCount({result_var})"),
3399        "tree_to_sexp" => format!("{class_name}.TreeToSexp({result_var})"),
3400        "contains_node_type" => {
3401            let node_type = args
3402                .and_then(|a| a.get("node_type"))
3403                .and_then(|v| v.as_str())
3404                .unwrap_or("");
3405            format!("{class_name}.TreeContainsNodeType({result_var}, \"{node_type}\")")
3406        }
3407        "find_nodes_by_type" => {
3408            let node_type = args
3409                .and_then(|a| a.get("node_type"))
3410                .and_then(|v| v.as_str())
3411                .unwrap_or("");
3412            format!("{class_name}.FindNodesByType({result_var}, \"{node_type}\")")
3413        }
3414        "run_query" => {
3415            let query_source = args
3416                .and_then(|a| a.get("query_source"))
3417                .and_then(|v| v.as_str())
3418                .unwrap_or("");
3419            let language = args
3420                .and_then(|a| a.get("language"))
3421                .and_then(|v| v.as_str())
3422                .unwrap_or("");
3423            format!("{class_name}.RunQuery({result_var}, \"{language}\", \"{query_source}\", source)")
3424        }
3425        _ => {
3426            use heck::ToUpperCamelCase;
3427            let pascal = method_name.to_upper_camel_case();
3428            format!("{result_var}.{pascal}()")
3429        }
3430    }
3431}
3432
3433fn fixture_has_csharp_callable(fixture: &Fixture, e2e_config: &E2eConfig) -> bool {
3434    // HTTP fixtures are handled separately — not our concern here.
3435    if fixture.is_http_test() {
3436        return false;
3437    }
3438    // Use resolve_call_for_fixture to support auto-routing via select_when.
3439    let call_config = e2e_config.resolve_call_for_fixture(
3440        fixture.call.as_deref(),
3441        &fixture.id,
3442        &fixture.resolved_category(),
3443        &fixture.tags,
3444        &fixture.input,
3445    );
3446    let cs_override = call_config
3447        .overrides
3448        .get("csharp")
3449        .or_else(|| e2e_config.call.overrides.get("csharp"));
3450    // When a client_factory is configured the fixture is callable via the client pattern.
3451    if cs_override.and_then(|o| o.client_factory.as_deref()).is_some() {
3452        return true;
3453    }
3454    // C# binding provides a default class name (e.g., KreuzcrawlLib) if not overridden,
3455    // so any function name makes a callable available.
3456    cs_override.and_then(|o| o.function.as_deref()).is_some() || !call_config.function.is_empty()
3457}
3458
3459/// Classify a fixture string value that maps to a `bytes` argument.
3460/// Determines whether to treat it as a file path, inline text, or base64-encoded data.
3461fn classify_bytes_value_csharp(s: &str) -> String {
3462    // File paths: start with alphanumeric/underscore, contain "/" with extension
3463    // e.g., "pdf/fake.pdf", "images/test.png"
3464    if let Some(first) = s.chars().next() {
3465        if first.is_ascii_alphanumeric() || first == '_' {
3466            if let Some(slash_pos) = s.find('/') {
3467                if slash_pos > 0 {
3468                    let after_slash = &s[slash_pos + 1..];
3469                    if after_slash.contains('.') && !after_slash.is_empty() {
3470                        // File path: use File.ReadAllBytes(path)
3471                        return format!("System.IO.File.ReadAllBytes(\"{}\")", s);
3472                    }
3473                }
3474            }
3475        }
3476    }
3477
3478    // Inline text: starts with markup or contains spaces
3479    // e.g., "<html>...", "{...}", "[...]", "text with spaces"
3480    if s.starts_with('<') || s.starts_with('{') || s.starts_with('[') || s.contains(' ') {
3481        // Inline text: use System.Text.Encoding.UTF8.GetBytes()
3482        return format!("System.Text.Encoding.UTF8.GetBytes(\"{}\")", escape_csharp(s));
3483    }
3484
3485    // Base64: base64-like pattern (uppercase/lowercase letters, digits, +, /, =)
3486    // e.g., "/9j/4AAQ", "SGVsbG8gV29ybGQ="
3487    // Use Convert.FromBase64String()
3488    format!("System.Convert.FromBase64String(\"{}\")", s)
3489}
3490
3491#[cfg(test)]
3492mod tests {
3493    use crate::config::{CallConfig, E2eConfig, SelectWhen};
3494    use crate::fixture::Fixture;
3495    use std::collections::HashMap;
3496
3497    fn make_fixture_with_input(id: &str, input: serde_json::Value) -> Fixture {
3498        Fixture {
3499            id: id.to_string(),
3500            category: None,
3501            description: "test fixture".to_string(),
3502            tags: vec![],
3503            skip: None,
3504            env: None,
3505            call: None,
3506            input,
3507            mock_response: None,
3508            source: String::new(),
3509            http: None,
3510            assertions: vec![],
3511            visitor: None,
3512        }
3513    }
3514
3515    /// Test that resolve_call_for_fixture correctly routes to batch_scrape
3516    /// when input has batch_urls and select_when condition matches.
3517    #[test]
3518    fn test_csharp_select_when_routes_to_batch_scrape() {
3519        let mut calls = HashMap::new();
3520        calls.insert(
3521            "batch_scrape".to_string(),
3522            CallConfig {
3523                function: "BatchScrape".to_string(),
3524                module: "KreuzBrowser".to_string(),
3525                select_when: Some(SelectWhen {
3526                    input_has: Some("batch_urls".to_string()),
3527                    ..Default::default()
3528                }),
3529                ..CallConfig::default()
3530            },
3531        );
3532
3533        let e2e_config = E2eConfig {
3534            call: CallConfig {
3535                function: "Scrape".to_string(),
3536                module: "KreuzBrowser".to_string(),
3537                ..CallConfig::default()
3538            },
3539            calls,
3540            ..E2eConfig::default()
3541        };
3542
3543        // Fixture with batch_urls but no explicit call field should route to batch_scrape
3544        let fixture = make_fixture_with_input("batch_empty_urls", serde_json::json!({ "batch_urls": [] }));
3545
3546        let resolved_call = e2e_config.resolve_call_for_fixture(
3547            fixture.call.as_deref(),
3548            &fixture.id,
3549            &fixture.resolved_category(),
3550            &fixture.tags,
3551            &fixture.input,
3552        );
3553        assert_eq!(resolved_call.function, "BatchScrape");
3554
3555        // Fixture without batch_urls should fall back to default Scrape
3556        let fixture_no_batch =
3557            make_fixture_with_input("simple_scrape", serde_json::json!({ "url": "https://example.com" }));
3558        let resolved_default = e2e_config.resolve_call_for_fixture(
3559            fixture_no_batch.call.as_deref(),
3560            &fixture_no_batch.id,
3561            &fixture_no_batch.resolved_category(),
3562            &fixture_no_batch.tags,
3563            &fixture_no_batch.input,
3564        );
3565        assert_eq!(resolved_default.function, "Scrape");
3566    }
3567}