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!(out, "        Assert.False(string.IsNullOrEmpty({expr}));");
1531        }
1532        ("not_empty", Kind::Json) => {
1533            let _ = writeln!(out, "        Assert.NotNull({expr});");
1534        }
1535        ("is_empty", Kind::Str) => {
1536            let _ = writeln!(out, "        Assert.True(string.IsNullOrEmpty({expr}));");
1537        }
1538        ("is_true", Kind::Bool) => {
1539            let _ = writeln!(out, "        Assert.True({expr});");
1540        }
1541        ("is_false", Kind::Bool) => {
1542            let _ = writeln!(out, "        Assert.False({expr});");
1543        }
1544        ("greater_than_or_equal", Kind::IntTokens) => {
1545            if let Some(n) = assertion.value.as_ref().and_then(|v| v.as_u64()) {
1546                let _ = writeln!(out, "        Assert.True({expr} >= {n}, \"expected >= {n}\");");
1547            }
1548        }
1549        ("equals", Kind::IntTokens) => {
1550            if let Some(n) = assertion.value.as_ref().and_then(|v| v.as_u64()) {
1551                let _ = writeln!(out, "        Assert.Equal((long?){n}, {expr});");
1552            }
1553        }
1554        _ => {
1555            let _ = writeln!(
1556                out,
1557                "        // skipped: streaming assertion '{atype}' on field '{field}' not supported"
1558            );
1559        }
1560    }
1561}
1562
1563/// Build setup lines (e.g. handle creation) and the argument list for the function call.
1564///
1565/// Returns `(setup_lines, args_string)`.
1566#[allow(clippy::too_many_arguments)]
1567fn build_args_and_setup(
1568    input: &serde_json::Value,
1569    args: &[crate::config::ArgMapping],
1570    class_name: &str,
1571    options_type: Option<&str>,
1572    options_via: Option<&str>,
1573    enum_fields: &HashMap<String, String>,
1574    nested_types: &HashMap<String, String>,
1575    fixture: &crate::fixture::Fixture,
1576    adapter_request_type: Option<&str>,
1577) -> (Vec<String>, String) {
1578    let fixture_id = &fixture.id;
1579    if args.is_empty() {
1580        return (Vec::new(), String::new());
1581    }
1582
1583    let mut setup_lines: Vec<String> = Vec::new();
1584    let mut parts: Vec<String> = Vec::new();
1585
1586    for arg in args {
1587        if arg.arg_type == "bytes" {
1588            // bytes args must be passed as byte[] in C#.
1589            let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
1590            let val = input.get(field);
1591            match val {
1592                None | Some(serde_json::Value::Null) if arg.optional => {
1593                    parts.push("null".to_string());
1594                }
1595                None | Some(serde_json::Value::Null) => {
1596                    parts.push("System.Array.Empty<byte>()".to_string());
1597                }
1598                Some(v) => {
1599                    // Classify the value to determine how to interpret it:
1600                    // - File paths (like "pdf/fake.pdf") → File.ReadAllBytes(path)
1601                    // - Inline text → System.Text.Encoding.UTF8.GetBytes()
1602                    // - Base64 → Convert.FromBase64String()
1603                    if let Some(s) = v.as_str() {
1604                        let bytes_code = classify_bytes_value_csharp(s);
1605                        parts.push(bytes_code);
1606                    } else {
1607                        // Literal arrays or other non-string types: use as-is
1608                        let cs_str = json_to_csharp(v);
1609                        parts.push(format!("System.Text.Encoding.UTF8.GetBytes({cs_str})"));
1610                    }
1611                }
1612            }
1613            continue;
1614        }
1615
1616        if arg.arg_type == "mock_url" {
1617            if fixture.has_host_root_route() {
1618                let env_key = format!("MOCK_SERVER_{}", fixture_id.to_uppercase());
1619                setup_lines.push(format!(
1620                    "var _pfUrl_{name} = Environment.GetEnvironmentVariable(\"{env_key}\");",
1621                    name = arg.name,
1622                ));
1623                setup_lines.push(format!(
1624                    "var {} = !string.IsNullOrEmpty(_pfUrl_{name}) ? _pfUrl_{name} : Environment.GetEnvironmentVariable(\"MOCK_SERVER_URL\") + \"/fixtures/{fixture_id}\";",
1625                    arg.name,
1626                    name = arg.name,
1627                ));
1628            } else {
1629                setup_lines.push(format!(
1630                    "var {} = Environment.GetEnvironmentVariable(\"MOCK_SERVER_URL\") + \"/fixtures/{fixture_id}\";",
1631                    arg.name,
1632                ));
1633            }
1634            if let Some(req_type) = adapter_request_type {
1635                let req_var = format!("{}Req", arg.name);
1636                setup_lines.push(format!("var {req_var} = new {req_type} {{ Url = {} }};", arg.name));
1637                parts.push(req_var);
1638            } else {
1639                parts.push(arg.name.clone());
1640            }
1641            continue;
1642        }
1643
1644        if arg.arg_type == "mock_url_list" {
1645            // List<string> of URLs: each element is either a bare path (`/seed1`) — prefixed
1646            // with the per-fixture mock-server URL at runtime — or an absolute URL kept as-is.
1647            // Mirrors `mock_url` resolution: `MOCK_SERVER_<FIXTURE_ID>` first, then
1648            // `MOCK_SERVER_URL/fixtures/<id>`. Emitted as a typed `List<string>` so it matches
1649            // the C# binding signature (`Task<BatchScrapeResults> BatchScrapeAsync(handle, List<string> urls)`),
1650            // which does not accept `string[]`.
1651            let env_key = format!("MOCK_SERVER_{}", fixture_id.to_uppercase());
1652            let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
1653            // Try both the declared field and common aliases (batch_urls, urls, etc.)
1654            let val = if let Some(v) = input.get(field).filter(|v| !v.is_null()) {
1655                v.clone()
1656            } else {
1657                super::resolve_urls_field(input, &arg.field).clone()
1658            };
1659            let paths: Vec<String> = if let Some(arr) = val.as_array() {
1660                arr.iter()
1661                    .filter_map(|v| v.as_str().map(|s| format!("\"{}\"", escape_csharp(s))))
1662                    .collect()
1663            } else {
1664                Vec::new()
1665            };
1666            let paths_literal = paths.join(", ");
1667            let name = &arg.name;
1668            setup_lines.push(format!(
1669                "var _pfBase_{name} = Environment.GetEnvironmentVariable(\"{env_key}\");"
1670            ));
1671            setup_lines.push(format!(
1672                "var _base_{name} = !string.IsNullOrEmpty(_pfBase_{name}) ? _pfBase_{name} : Environment.GetEnvironmentVariable(\"MOCK_SERVER_URL\") + \"/fixtures/{fixture_id}\";"
1673            ));
1674            setup_lines.push(format!(
1675                "var {name} = new System.Collections.Generic.List<string>(new string[] {{ {paths_literal} }}.Select(p => p.StartsWith(\"http\") ? p : _base_{name} + p));"
1676            ));
1677            parts.push(name.clone());
1678            continue;
1679        }
1680
1681        if arg.arg_type == "handle" {
1682            // Generate a CreateEngine (or equivalent) call and pass the variable.
1683            let constructor_name = format!("Create{}", arg.name.to_upper_camel_case());
1684            let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
1685            let config_value = input.get(field).unwrap_or(&serde_json::Value::Null);
1686            if config_value.is_null()
1687                || config_value.is_object() && config_value.as_object().is_some_and(|o| o.is_empty())
1688            {
1689                setup_lines.push(format!("var {} = {class_name}.{constructor_name}(null);", arg.name,));
1690            } else {
1691                // Sort discriminator fields ("type") to appear first in nested objects so
1692                // System.Text.Json [JsonPolymorphic] can find the type discriminator before
1693                // reading other properties (a requirement as of .NET 8).
1694                let sorted = sort_discriminator_first(config_value.clone());
1695                let json_str = serde_json::to_string(&sorted).unwrap_or_default();
1696                let name = &arg.name;
1697                setup_lines.push(format!(
1698                    "var {name}Config = JsonSerializer.Deserialize<CrawlConfig>(\"{}\", ConfigOptions)!;",
1699                    escape_csharp(&json_str),
1700                ));
1701                setup_lines.push(format!(
1702                    "var {} = {class_name}.{constructor_name}({name}Config);",
1703                    arg.name,
1704                    name = name,
1705                ));
1706            }
1707            parts.push(arg.name.clone());
1708            continue;
1709        }
1710
1711        // When field is exactly "input", treat the entire input object as the value.
1712        // This matches the convention used by other language generators (e.g. Go).
1713        let val: Option<&serde_json::Value> = if arg.field == "input" {
1714            Some(input)
1715        } else {
1716            let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
1717            input.get(field)
1718        };
1719        match val {
1720            None | Some(serde_json::Value::Null) if arg.optional => {
1721                // Optional arg with no fixture value: pass null explicitly since
1722                // C# nullable parameters still require an argument at the call site.
1723                parts.push("null".to_string());
1724                continue;
1725            }
1726            None | Some(serde_json::Value::Null) => {
1727                // Required arg with no fixture value: pass a language-appropriate default.
1728                // For json_object args with a known options_type, use `new OptionsType()`
1729                // so the generated code compiles when the method parameter is non-nullable.
1730                let default_val = match arg.arg_type.as_str() {
1731                    "string" => "\"\"".to_string(),
1732                    "int" | "integer" => "0".to_string(),
1733                    "float" | "number" => "0.0d".to_string(),
1734                    "bool" | "boolean" => "false".to_string(),
1735                    "json_object" => {
1736                        if let Some(opts_type) = options_type {
1737                            format!("new {opts_type}()")
1738                        } else {
1739                            "null".to_string()
1740                        }
1741                    }
1742                    _ => "null".to_string(),
1743                };
1744                parts.push(default_val);
1745            }
1746            Some(v) => {
1747                if arg.arg_type == "json_object" {
1748                    // `options_via = "from_json"`: deserialize the entire value (object,
1749                    // array, or scalar) as the options type. This sidesteps per-field
1750                    // type ambiguity — e.g. `JsonElement?` (untagged unions) or
1751                    // `List<NamedRecord>` whose element type cannot be inferred from
1752                    // JSON shape alone — by delegating to System.Text.Json.
1753                    if options_via == Some("from_json")
1754                        && let Some(opts_type) = options_type
1755                    {
1756                        let sorted = sort_discriminator_first(v.clone());
1757                        let json_str = serde_json::to_string(&sorted).unwrap_or_default();
1758                        let escaped = escape_csharp(&json_str);
1759                        // Use the binding-emitted `<Type>.FromJson(...)` factory so any
1760                        // System.Text.Json deserialization failure is wrapped in
1761                        // `<Crate>Exception`, allowing error fixtures asserting
1762                        // `Assert.ThrowsAny<<Crate>Exception>(...)` to catch the parse
1763                        // failure (e.g. `Unknown FilePurpose value: invalid-purpose`).
1764                        parts.push(format!("{opts_type}.FromJson(\"{escaped}\")",));
1765                        continue;
1766                    }
1767                    // Array value: generate a typed List<T> based on element_type.
1768                    if let Some(arr) = v.as_array() {
1769                        parts.push(json_array_to_csharp_list(arr, arg.element_type.as_deref()));
1770                        continue;
1771                    }
1772                    // Object value with known type: generate idiomatic C# object initializer.
1773                    if let Some(opts_type) = options_type {
1774                        if let Some(obj) = v.as_object() {
1775                            parts.push(csharp_object_initializer(obj, opts_type, enum_fields, nested_types));
1776                            continue;
1777                        }
1778                    }
1779                }
1780                parts.push(json_to_csharp(v));
1781            }
1782        }
1783    }
1784
1785    (setup_lines, parts.join(", "))
1786}
1787
1788/// Convert a JSON array to a typed C# `List<T>` expression.
1789///
1790/// Mapping from `ArgMapping::element_type`:
1791/// - `None` or any string type → `List<string>`
1792/// - `"f32"` → `List<float>` with `(float)` casts
1793/// - `"(String, String)"` → `List<List<string>>` for key-value pair arrays
1794/// - `"BatchBytesItem"` / `"BatchFileItem"` → array of batch item instances
1795fn json_array_to_csharp_list(arr: &[serde_json::Value], element_type: Option<&str>) -> String {
1796    match element_type {
1797        Some("BatchBytesItem") => {
1798            let items: Vec<String> = arr
1799                .iter()
1800                .filter_map(|v| v.as_object())
1801                .map(|obj| {
1802                    let content = obj.get("content").and_then(|v| v.as_array());
1803                    let mime_type = obj.get("mime_type").and_then(|v| v.as_str()).unwrap_or("text/plain");
1804                    let content_code = if let Some(arr) = content {
1805                        let bytes: Vec<String> = arr
1806                            .iter()
1807                            .filter_map(|v| v.as_u64().map(|n| format!("(byte){}", n)))
1808                            .collect();
1809                        format!("new byte[] {{ {} }}", bytes.join(", "))
1810                    } else {
1811                        "new byte[] { }".to_string()
1812                    };
1813                    format!(
1814                        "new BatchBytesItem {{ Content = {}, MimeType = \"{}\" }}",
1815                        content_code, mime_type
1816                    )
1817                })
1818                .collect();
1819            format!("new List<BatchBytesItem>() {{ {} }}", items.join(", "))
1820        }
1821        Some("BatchFileItem") => {
1822            let items: Vec<String> = arr
1823                .iter()
1824                .filter_map(|v| v.as_object())
1825                .map(|obj| {
1826                    let path = obj.get("path").and_then(|v| v.as_str()).unwrap_or("");
1827                    format!("new BatchFileItem {{ Path = \"{}\" }}", path)
1828                })
1829                .collect();
1830            format!("new List<BatchFileItem>() {{ {} }}", items.join(", "))
1831        }
1832        Some("f32") => {
1833            let items: Vec<String> = arr.iter().map(|v| format!("(float){}", json_to_csharp(v))).collect();
1834            format!("new List<float>() {{ {} }}", items.join(", "))
1835        }
1836        Some("(String, String)") => {
1837            let items: Vec<String> = arr
1838                .iter()
1839                .map(|v| {
1840                    let strs: Vec<String> = v
1841                        .as_array()
1842                        .map_or_else(Vec::new, |a| a.iter().map(json_to_csharp).collect());
1843                    format!("new List<string>() {{ {} }}", strs.join(", "))
1844                })
1845                .collect();
1846            format!("new List<List<string>>() {{ {} }}", items.join(", "))
1847        }
1848        Some(et)
1849            if et != "f32"
1850                && et != "(String, String)"
1851                && et != "string"
1852                && et != "BatchBytesItem"
1853                && et != "BatchFileItem" =>
1854        {
1855            // Class/record types: deserialize each element from JSON
1856            let items: Vec<String> = arr
1857                .iter()
1858                .map(|v| {
1859                    let json_str = serde_json::to_string(v).unwrap_or_default();
1860                    let escaped = escape_csharp(&json_str);
1861                    format!("JsonSerializer.Deserialize<{et}>(\"{escaped}\", ConfigOptions)!")
1862                })
1863                .collect();
1864            format!("new List<{et}>() {{ {} }}", items.join(", "))
1865        }
1866        _ => {
1867            let items: Vec<String> = arr.iter().map(json_to_csharp).collect();
1868            format!("new List<string>() {{ {} }}", items.join(", "))
1869        }
1870    }
1871}
1872
1873/// Detect if a field path accesses a discriminated union variant in C#.
1874/// Pattern: `metadata.format.<variant_name>.<field_name>`
1875/// Returns: Some((accessor, variant_name, inner_field)) if matched, otherwise None
1876fn parse_discriminated_union_access(field: &str) -> Option<(String, String, String)> {
1877    let parts: Vec<&str> = field.split('.').collect();
1878    if parts.len() >= 3 && parts.len() <= 4 {
1879        // Check if this is metadata.format.{variant}.{field} pattern
1880        if parts[0] == "metadata" && parts[1] == "format" {
1881            let variant_name = parts[2];
1882            // Known C# discriminated union variants (lowercase in fixture paths)
1883            let known_variants = [
1884                "pdf",
1885                "docx",
1886                "excel",
1887                "email",
1888                "pptx",
1889                "archive",
1890                "image",
1891                "xml",
1892                "text",
1893                "html",
1894                "ocr",
1895                "csv",
1896                "bibtex",
1897                "citation",
1898                "fiction_book",
1899                "dbf",
1900                "jats",
1901                "epub",
1902                "pst",
1903                "code",
1904            ];
1905            if known_variants.contains(&variant_name) {
1906                let variant_pascal = variant_name.to_upper_camel_case();
1907                if parts.len() == 4 {
1908                    let inner_field = parts[3];
1909                    return Some((
1910                        format!("result.Metadata.Format! as FormatMetadata.{}", variant_pascal),
1911                        variant_pascal,
1912                        inner_field.to_string(),
1913                    ));
1914                } else if parts.len() == 3 {
1915                    // Just accessing the variant itself (no inner field)
1916                    return Some((
1917                        format!("result.Metadata.Format! as FormatMetadata.{}", variant_pascal),
1918                        variant_pascal,
1919                        String::new(),
1920                    ));
1921                }
1922            }
1923        }
1924    }
1925    None
1926}
1927
1928/// Render an assertion against a discriminated union variant's inner field.
1929/// `variant_var` is the unwrapped union variant (e.g., `variant` from pattern match).
1930/// `inner_field` is the field to access on the variant's Value (e.g., `sheet_count`).
1931fn render_discriminated_union_assertion(
1932    out: &mut String,
1933    assertion: &Assertion,
1934    variant_var: &str,
1935    inner_field: &str,
1936    _result_is_vec: bool,
1937    assert_enum_fields: &std::collections::HashMap<String, String>,
1938) {
1939    if inner_field.is_empty() {
1940        return; // No field to assert on
1941    }
1942
1943    let field_pascal = inner_field.to_upper_camel_case();
1944    let mut field_expr = format!("{variant_var}.Value.{field_pascal}");
1945
1946    // Wrap enum fields with display helper
1947    if assert_enum_fields.contains_key(&field_pascal) {
1948        let type_name = assert_enum_fields.get(&field_pascal).unwrap();
1949        field_expr = format!("{type_name}Display.ToDisplayString({field_expr})");
1950    }
1951
1952    match assertion.assertion_type.as_str() {
1953        "equals" => {
1954            if let Some(expected) = &assertion.value {
1955                let cs_val = json_to_csharp(expected);
1956                if expected.is_string() {
1957                    let _ = writeln!(out, "            Assert.Equal({cs_val}, {field_expr}!.Trim());");
1958                } else if expected.as_bool() == Some(true) {
1959                    let _ = writeln!(out, "            Assert.True({field_expr});");
1960                } else if expected.as_bool() == Some(false) {
1961                    let _ = writeln!(out, "            Assert.False({field_expr});");
1962                } else if expected.is_number() && !expected.as_f64().is_some_and(|f| f.fract() != 0.0) {
1963                    let _ = writeln!(out, "            Assert.True({field_expr} == {cs_val});");
1964                } else {
1965                    let _ = writeln!(out, "            Assert.Equal({cs_val}, {field_expr});");
1966                }
1967            }
1968        }
1969        "greater_than_or_equal" => {
1970            if let Some(val) = &assertion.value {
1971                let cs_val = json_to_csharp(val);
1972                let _ = writeln!(
1973                    out,
1974                    "            Assert.True({field_expr} >= {cs_val}, \"expected >= {cs_val}\");"
1975                );
1976            }
1977        }
1978        "contains_all" => {
1979            if let Some(values) = &assertion.values {
1980                let field_as_str = format!("JsonSerializer.Serialize({field_expr})");
1981                for val in values {
1982                    let lower_val = val.as_str().map(|s| s.to_lowercase());
1983                    let cs_val = lower_val
1984                        .as_deref()
1985                        .map(|s| format!("\"{}\"", escape_csharp(s)))
1986                        .unwrap_or_else(|| json_to_csharp(val));
1987                    let _ = writeln!(out, "            Assert.Contains({cs_val}, {field_as_str}.ToLower());");
1988                }
1989            }
1990        }
1991        "contains" => {
1992            if let Some(expected) = &assertion.value {
1993                let field_as_str = format!("JsonSerializer.Serialize({field_expr})");
1994                let lower_expected = expected.as_str().map(|s| s.to_lowercase());
1995                let cs_val = lower_expected
1996                    .as_deref()
1997                    .map(|s| format!("\"{}\"", escape_csharp(s)))
1998                    .unwrap_or_else(|| json_to_csharp(expected));
1999                let _ = writeln!(out, "            Assert.Contains({cs_val}, {field_as_str}.ToLower());");
2000            }
2001        }
2002        "not_empty" => {
2003            let _ = writeln!(out, "            Assert.NotEmpty({field_expr});");
2004        }
2005        "is_empty" => {
2006            let _ = writeln!(out, "            Assert.Empty({field_expr});");
2007        }
2008        _ => {
2009            let _ = writeln!(
2010                out,
2011                "            // skipped: assertion type '{}' not yet supported for discriminated union fields",
2012                assertion.assertion_type
2013            );
2014        }
2015    }
2016}
2017
2018#[allow(clippy::too_many_arguments)]
2019fn render_assertion(
2020    out: &mut String,
2021    assertion: &Assertion,
2022    result_var: &str,
2023    class_name: &str,
2024    exception_class: &str,
2025    field_resolver: &FieldResolver,
2026    result_is_simple: bool,
2027    result_is_vec: bool,
2028    result_is_array: bool,
2029    result_is_bytes: bool,
2030    fields_enum: &std::collections::HashSet<String>,
2031    assert_enum_fields: &std::collections::HashMap<String, String>,
2032) {
2033    // Byte-buffer returns: emit length-based assertions instead of struct-field
2034    // accessors. The result is a `byte[]` and has no named fields like
2035    // `result.Audio` or `result.Content`.
2036    if result_is_bytes {
2037        match assertion.assertion_type.as_str() {
2038            "not_empty" => {
2039                let _ = writeln!(out, "        Assert.NotEmpty({result_var});");
2040                return;
2041            }
2042            "is_empty" => {
2043                let _ = writeln!(out, "        Assert.Empty({result_var});");
2044                return;
2045            }
2046            "count_equals" | "length_equals" => {
2047                if let Some(n) = assertion.value.as_ref().and_then(|v| v.as_u64()) {
2048                    let _ = writeln!(out, "        Assert.Equal({n}, {result_var}.Length);");
2049                }
2050                return;
2051            }
2052            "count_min" | "length_min" => {
2053                if let Some(n) = assertion.value.as_ref().and_then(|v| v.as_u64()) {
2054                    let _ = writeln!(out, "        Assert.True({result_var}.Length >= {n});");
2055                }
2056                return;
2057            }
2058            "not_error" => {
2059                let _ = writeln!(out, "        Assert.NotNull({result_var});");
2060                return;
2061            }
2062            _ => {
2063                // Other assertion types are not meaningful on raw byte buffers;
2064                // emit a comment so the test still compiles but flags unsupported
2065                // assertion types for fixture authors.
2066                let _ = writeln!(
2067                    out,
2068                    "        // skipped: assertion type '{}' not supported on byte[] result",
2069                    assertion.assertion_type
2070                );
2071                return;
2072            }
2073        }
2074    }
2075    // Handle synthetic / derived fields before the is_valid_for_result check
2076    // so they are never treated as struct property accesses on the result.
2077    if let Some(f) = &assertion.field {
2078        match f.as_str() {
2079            "chunks_have_content" => {
2080                let synthetic_pred =
2081                    format!("({result_var}.Chunks ?? new()).All(c => !string.IsNullOrEmpty(c.Content))");
2082                let synthetic_pred_type = match assertion.assertion_type.as_str() {
2083                    "is_true" => "is_true",
2084                    "is_false" => "is_false",
2085                    _ => {
2086                        out.push_str(&format!(
2087                            "        // skipped: unsupported assertion type on synthetic field '{f}'\n"
2088                        ));
2089                        return;
2090                    }
2091                };
2092                let rendered = crate::template_env::render(
2093                    "csharp/assertion.jinja",
2094                    minijinja::context! {
2095                        assertion_type => "synthetic_assertion",
2096                        synthetic_pred => synthetic_pred,
2097                        synthetic_pred_type => synthetic_pred_type,
2098                    },
2099                );
2100                out.push_str(&rendered);
2101                return;
2102            }
2103            "chunks_have_embeddings" => {
2104                let synthetic_pred =
2105                    format!("({result_var}.Chunks ?? new()).All(c => c.Embedding != null && c.Embedding.Count > 0)");
2106                let synthetic_pred_type = match assertion.assertion_type.as_str() {
2107                    "is_true" => "is_true",
2108                    "is_false" => "is_false",
2109                    _ => {
2110                        out.push_str(&format!(
2111                            "        // skipped: unsupported assertion type on synthetic field '{f}'\n"
2112                        ));
2113                        return;
2114                    }
2115                };
2116                let rendered = crate::template_env::render(
2117                    "csharp/assertion.jinja",
2118                    minijinja::context! {
2119                        assertion_type => "synthetic_assertion",
2120                        synthetic_pred => synthetic_pred,
2121                        synthetic_pred_type => synthetic_pred_type,
2122                    },
2123                );
2124                out.push_str(&rendered);
2125                return;
2126            }
2127            "chunks_have_heading_context" => {
2128                let synthetic_pred =
2129                    format!("({result_var}.Chunks ?? new()).All(c => c.Metadata?.HeadingContext != null)");
2130                let synthetic_pred_type = match assertion.assertion_type.as_str() {
2131                    "is_true" => "is_true",
2132                    "is_false" => "is_false",
2133                    _ => {
2134                        out.push_str(&format!(
2135                            "        // skipped: unsupported assertion type on synthetic field '{f}'\n"
2136                        ));
2137                        return;
2138                    }
2139                };
2140                let rendered = crate::template_env::render(
2141                    "csharp/assertion.jinja",
2142                    minijinja::context! {
2143                        assertion_type => "synthetic_assertion",
2144                        synthetic_pred => synthetic_pred,
2145                        synthetic_pred_type => synthetic_pred_type,
2146                    },
2147                );
2148                out.push_str(&rendered);
2149                return;
2150            }
2151            "first_chunk_starts_with_heading" => {
2152                let synthetic_pred =
2153                    format!("({result_var}.Chunks ?? new()).FirstOrDefault()?.Metadata?.HeadingContext != null");
2154                let synthetic_pred_type = match assertion.assertion_type.as_str() {
2155                    "is_true" => "is_true",
2156                    "is_false" => "is_false",
2157                    _ => {
2158                        out.push_str(&format!(
2159                            "        // skipped: unsupported assertion type on synthetic field '{f}'\n"
2160                        ));
2161                        return;
2162                    }
2163                };
2164                let rendered = crate::template_env::render(
2165                    "csharp/assertion.jinja",
2166                    minijinja::context! {
2167                        assertion_type => "synthetic_assertion",
2168                        synthetic_pred => synthetic_pred,
2169                        synthetic_pred_type => synthetic_pred_type,
2170                    },
2171                );
2172                out.push_str(&rendered);
2173                return;
2174            }
2175            // ---- EmbedResponse virtual fields ----
2176            // embed_texts returns List<List<float>> in C# — no wrapper object.
2177            // result_var is the embedding matrix; use it directly.
2178            "embeddings" => {
2179                match assertion.assertion_type.as_str() {
2180                    "count_equals" => {
2181                        if let Some(val) = &assertion.value {
2182                            if let Some(n) = val.as_u64() {
2183                                let rendered = crate::template_env::render(
2184                                    "csharp/assertion.jinja",
2185                                    minijinja::context! {
2186                                        assertion_type => "synthetic_embeddings_count_equals",
2187                                        synthetic_pred => format!("{result_var}.Count"),
2188                                        n => n,
2189                                    },
2190                                );
2191                                out.push_str(&rendered);
2192                            }
2193                        }
2194                    }
2195                    "count_min" => {
2196                        if let Some(val) = &assertion.value {
2197                            if let Some(n) = val.as_u64() {
2198                                let rendered = crate::template_env::render(
2199                                    "csharp/assertion.jinja",
2200                                    minijinja::context! {
2201                                        assertion_type => "synthetic_embeddings_count_min",
2202                                        synthetic_pred => format!("{result_var}.Count"),
2203                                        n => n,
2204                                    },
2205                                );
2206                                out.push_str(&rendered);
2207                            }
2208                        }
2209                    }
2210                    "not_empty" => {
2211                        let rendered = crate::template_env::render(
2212                            "csharp/assertion.jinja",
2213                            minijinja::context! {
2214                                assertion_type => "synthetic_embeddings_not_empty",
2215                                synthetic_pred => result_var.to_string(),
2216                            },
2217                        );
2218                        out.push_str(&rendered);
2219                    }
2220                    "is_empty" => {
2221                        let rendered = crate::template_env::render(
2222                            "csharp/assertion.jinja",
2223                            minijinja::context! {
2224                                assertion_type => "synthetic_embeddings_is_empty",
2225                                synthetic_pred => result_var.to_string(),
2226                            },
2227                        );
2228                        out.push_str(&rendered);
2229                    }
2230                    _ => {
2231                        out.push_str(
2232                            "        // skipped: unsupported assertion type on synthetic field 'embeddings'\n",
2233                        );
2234                    }
2235                }
2236                return;
2237            }
2238            "embedding_dimensions" => {
2239                let expr = format!("({result_var}.Count > 0 ? {result_var}[0].Count : 0)");
2240                match assertion.assertion_type.as_str() {
2241                    "equals" => {
2242                        if let Some(val) = &assertion.value {
2243                            if let Some(n) = val.as_u64() {
2244                                let rendered = crate::template_env::render(
2245                                    "csharp/assertion.jinja",
2246                                    minijinja::context! {
2247                                        assertion_type => "synthetic_embedding_dimensions_equals",
2248                                        synthetic_pred => expr,
2249                                        n => n,
2250                                    },
2251                                );
2252                                out.push_str(&rendered);
2253                            }
2254                        }
2255                    }
2256                    "greater_than" => {
2257                        if let Some(val) = &assertion.value {
2258                            if let Some(n) = val.as_u64() {
2259                                let rendered = crate::template_env::render(
2260                                    "csharp/assertion.jinja",
2261                                    minijinja::context! {
2262                                        assertion_type => "synthetic_embedding_dimensions_greater_than",
2263                                        synthetic_pred => expr,
2264                                        n => n,
2265                                    },
2266                                );
2267                                out.push_str(&rendered);
2268                            }
2269                        }
2270                    }
2271                    _ => {
2272                        out.push_str("        // skipped: unsupported assertion type on synthetic field 'embedding_dimensions'\n");
2273                    }
2274                }
2275                return;
2276            }
2277            "embeddings_valid" | "embeddings_finite" | "embeddings_non_zero" | "embeddings_normalized" => {
2278                let synthetic_pred = match f.as_str() {
2279                    "embeddings_valid" => {
2280                        format!("{result_var}.All(e => e.Count > 0)")
2281                    }
2282                    "embeddings_finite" => {
2283                        format!("{result_var}.All(e => e.All(v => !float.IsInfinity(v) && !float.IsNaN(v)))")
2284                    }
2285                    "embeddings_non_zero" => {
2286                        format!("{result_var}.All(e => e.Any(v => v != 0.0f))")
2287                    }
2288                    "embeddings_normalized" => {
2289                        format!(
2290                            "{result_var}.All(e => {{ var n = e.Sum(v => (double)v * v); return Math.Abs(n - 1.0) < 1e-3; }})"
2291                        )
2292                    }
2293                    _ => unreachable!(),
2294                };
2295                let synthetic_pred_type = match assertion.assertion_type.as_str() {
2296                    "is_true" => "is_true",
2297                    "is_false" => "is_false",
2298                    _ => {
2299                        out.push_str(&format!(
2300                            "        // skipped: unsupported assertion type on synthetic field '{f}'\n"
2301                        ));
2302                        return;
2303                    }
2304                };
2305                let rendered = crate::template_env::render(
2306                    "csharp/assertion.jinja",
2307                    minijinja::context! {
2308                        assertion_type => "synthetic_assertion",
2309                        synthetic_pred => synthetic_pred,
2310                        synthetic_pred_type => synthetic_pred_type,
2311                    },
2312                );
2313                out.push_str(&rendered);
2314                return;
2315            }
2316            // ---- keywords / keywords_count ----
2317            // C# ExtractionResult does not expose extracted_keywords; skip.
2318            "keywords" | "keywords_count" => {
2319                let skipped_reason = format!("field '{f}' not available on C# ExtractionResult");
2320                let rendered = crate::template_env::render(
2321                    "csharp/assertion.jinja",
2322                    minijinja::context! {
2323                        skipped_reason => skipped_reason,
2324                    },
2325                );
2326                out.push_str(&rendered);
2327                return;
2328            }
2329            _ => {}
2330        }
2331    }
2332
2333    // Skip assertions on fields that don't exist on the result type.
2334    if let Some(f) = &assertion.field {
2335        if !f.is_empty() && !field_resolver.is_valid_for_result(f) {
2336            let skipped_reason = format!("field '{f}' not available on result type");
2337            let rendered = crate::template_env::render(
2338                "csharp/assertion.jinja",
2339                minijinja::context! {
2340                    skipped_reason => skipped_reason,
2341                },
2342            );
2343            out.push_str(&rendered);
2344            return;
2345        }
2346    }
2347
2348    // For count assertions on list results with no field specified, use the list directly.
2349    // Otherwise, when the result is a List<T>, index into the first element for field access.
2350    let is_count_assertion = matches!(
2351        assertion.assertion_type.as_str(),
2352        "count_equals" | "count_min" | "count_max"
2353    );
2354    let is_no_field = assertion.field.is_none() || assertion.field.as_ref().is_some_and(|f| f.is_empty());
2355    let use_list_directly = result_is_vec && is_count_assertion && is_no_field;
2356
2357    let effective_result_var: String = if result_is_vec && !use_list_directly {
2358        format!("{result_var}[0]")
2359    } else {
2360        result_var.to_string()
2361    };
2362
2363    // Check if this is a discriminated union access (e.g., metadata.format.excel.sheet_count)
2364    let is_discriminated_union = assertion
2365        .field
2366        .as_ref()
2367        .is_some_and(|f| parse_discriminated_union_access(f).is_some());
2368
2369    // For discriminated union assertions, generate pattern-matching wrapper
2370    if is_discriminated_union {
2371        if let Some((_, variant_name, inner_field)) = assertion
2372            .field
2373            .as_ref()
2374            .and_then(|f| parse_discriminated_union_access(f))
2375        {
2376            // Use a unique variable name based on the field hash to avoid shadowing
2377            let mut hasher = std::collections::hash_map::DefaultHasher::new();
2378            inner_field.hash(&mut hasher);
2379            let var_hash = format!("{:x}", hasher.finish());
2380            let variant_var = format!("variant_{}", &var_hash[..8]);
2381            let _ = writeln!(
2382                out,
2383                "        if ({effective_result_var}.Metadata.Format is FormatMetadata.{} {})",
2384                variant_name, &variant_var
2385            );
2386            let _ = writeln!(out, "        {{");
2387            render_discriminated_union_assertion(
2388                out,
2389                assertion,
2390                &variant_var,
2391                &inner_field,
2392                result_is_vec,
2393                assert_enum_fields,
2394            );
2395            let _ = writeln!(out, "        }}");
2396            let _ = writeln!(out, "        else");
2397            let _ = writeln!(out, "        {{");
2398            let _ = writeln!(
2399                out,
2400                "            Assert.Fail(\"Expected {} format metadata\");",
2401                variant_name.to_lowercase()
2402            );
2403            let _ = writeln!(out, "        }}");
2404            return;
2405        }
2406    }
2407
2408    let field_expr = if result_is_simple {
2409        effective_result_var.clone()
2410    } else {
2411        match &assertion.field {
2412            Some(f) if !f.is_empty() => field_resolver.accessor(f, "csharp", &effective_result_var),
2413            _ => effective_result_var.clone(),
2414        }
2415    };
2416
2417    // Fields declared in `assert_enum_fields` map to sealed/internally-tagged enum
2418    // types. Wrap the accessor with a display helper (e.g., `FormatMetadataDisplay.ToDisplayString`)
2419    // so the assertion sees a display string rather than the raw sealed-union object.
2420    let field_expr = match &assertion.field {
2421        Some(f) if assert_enum_fields.contains_key(f.as_str()) => {
2422            let type_name = assert_enum_fields.get(f.as_str()).unwrap();
2423            format!("{type_name}Display.ToDisplayString({field_expr})")
2424        }
2425        _ => field_expr,
2426    };
2427
2428    // Determine if field_expr is a list or complex object that requires JSON serialization
2429    // for string-based assertions (contains, not_contains, etc.). List<T>.ToString() in C#
2430    // returns the type name, not the contents.
2431    let field_needs_json_serialize = if result_is_simple {
2432        // Simple results are scalars, but when they're also arrays (e.g., List<string>),
2433        // JSON-serialize so substring checks see actual content, not the type name.
2434        result_is_array
2435    } else {
2436        match &assertion.field {
2437            Some(f) if !f.is_empty() => field_resolver.is_array(f),
2438            // No field specified — the whole result object; needs serialization when complex.
2439            _ => !result_is_simple,
2440        }
2441    };
2442    // Build the string representation of field_expr for substring-based assertions.
2443    let field_as_str = if field_needs_json_serialize {
2444        format!("JsonSerializer.Serialize({field_expr})")
2445    } else {
2446        format!("{field_expr}.ToString()")
2447    };
2448
2449    // Detect enum-typed fields. C# emits typed enums (e.g. `FinishReason?`) for
2450    // these so the codegen must avoid `.Trim()` (string-only) and instead
2451    // compare via `?.ToString()?.ToLower()` to match snake_case JSON.
2452    let field_is_enum = assertion.field.as_deref().filter(|f| !f.is_empty()).is_some_and(|f| {
2453        let resolved = field_resolver.resolve(f);
2454        fields_enum.contains(f) || fields_enum.contains(resolved)
2455    });
2456
2457    match assertion.assertion_type.as_str() {
2458        "equals" => {
2459            if let Some(expected) = &assertion.value {
2460                // Enum field equality bypasses the template (which would emit `.Trim()`,
2461                // a string-only API). Compare the snake-cased ToString() against the
2462                // expected value to match the wire JSON form (`InProgress` → `in_progress`,
2463                // `ContentFilter` → `content_filter`, etc.). `JsonNamingPolicy.SnakeCaseLower`
2464                // is the same policy used by the global JsonStringEnumConverter, so the
2465                // assertion compares against exactly what serde would emit.
2466                if field_is_enum && expected.is_string() {
2467                    let s_lower = expected.as_str().map(|s| s.to_lowercase()).unwrap_or_default();
2468                    let _ = writeln!(
2469                        out,
2470                        "        Assert.Equal(\"{}\", {field_expr} == null ? null : JsonNamingPolicy.SnakeCaseLower.ConvertName({field_expr}.ToString()!));",
2471                        escape_csharp(&s_lower)
2472                    );
2473                    return;
2474                }
2475                let cs_val = json_to_csharp(expected);
2476                let is_string_val = expected.is_string();
2477                let is_bool_true = expected.as_bool() == Some(true);
2478                let is_bool_false = expected.as_bool() == Some(false);
2479                let is_integer_val = expected.is_number() && !expected.as_f64().is_some_and(|f| f.fract() != 0.0);
2480
2481                let rendered = crate::template_env::render(
2482                    "csharp/assertion.jinja",
2483                    minijinja::context! {
2484                        assertion_type => "equals",
2485                        field_expr => field_expr.clone(),
2486                        cs_val => cs_val,
2487                        is_string_val => is_string_val,
2488                        is_bool_true => is_bool_true,
2489                        is_bool_false => is_bool_false,
2490                        is_integer_val => is_integer_val,
2491                    },
2492                );
2493                out.push_str(&rendered);
2494            }
2495        }
2496        "contains" => {
2497            if let Some(expected) = &assertion.value {
2498                // Lowercase both expected and actual so that enum fields (where .ToString()
2499                // returns the PascalCase C# member name like "Anchor") correctly match
2500                // fixture snake_case values like "anchor".  String fields are unaffected
2501                // because lowercasing both sides preserves substring matches.
2502                // List/complex fields use JsonSerializer.Serialize() since List<T>.ToString()
2503                // returns the type name, not the contents.
2504                let lower_expected = expected.as_str().map(|s| s.to_lowercase());
2505                let cs_val = lower_expected
2506                    .as_deref()
2507                    .map(|s| format!("\"{}\"", escape_csharp(s)))
2508                    .unwrap_or_else(|| json_to_csharp(expected));
2509
2510                let rendered = crate::template_env::render(
2511                    "csharp/assertion.jinja",
2512                    minijinja::context! {
2513                        assertion_type => "contains",
2514                        field_as_str => field_as_str.clone(),
2515                        cs_val => cs_val,
2516                    },
2517                );
2518                out.push_str(&rendered);
2519            }
2520        }
2521        "contains_all" => {
2522            if let Some(values) = &assertion.values {
2523                let values_cs_lower: Vec<String> = values
2524                    .iter()
2525                    .map(|val| {
2526                        let lower_val = val.as_str().map(|s| s.to_lowercase());
2527                        lower_val
2528                            .as_deref()
2529                            .map(|s| format!("\"{}\"", escape_csharp(s)))
2530                            .unwrap_or_else(|| json_to_csharp(val))
2531                    })
2532                    .collect();
2533
2534                let rendered = crate::template_env::render(
2535                    "csharp/assertion.jinja",
2536                    minijinja::context! {
2537                        assertion_type => "contains_all",
2538                        field_as_str => field_as_str.clone(),
2539                        values_cs_lower => values_cs_lower,
2540                    },
2541                );
2542                out.push_str(&rendered);
2543            }
2544        }
2545        "not_contains" => {
2546            if let Some(expected) = &assertion.value {
2547                let cs_val = json_to_csharp(expected);
2548
2549                let rendered = crate::template_env::render(
2550                    "csharp/assertion.jinja",
2551                    minijinja::context! {
2552                        assertion_type => "not_contains",
2553                        field_as_str => field_as_str.clone(),
2554                        cs_val => cs_val,
2555                    },
2556                );
2557                out.push_str(&rendered);
2558            }
2559        }
2560        "not_empty" => {
2561            let rendered = crate::template_env::render(
2562                "csharp/assertion.jinja",
2563                minijinja::context! {
2564                    assertion_type => "not_empty",
2565                    field_expr => field_expr.clone(),
2566                    field_needs_json_serialize => field_needs_json_serialize,
2567                },
2568            );
2569            out.push_str(&rendered);
2570        }
2571        "is_empty" => {
2572            let rendered = crate::template_env::render(
2573                "csharp/assertion.jinja",
2574                minijinja::context! {
2575                    assertion_type => "is_empty",
2576                    field_expr => field_expr.clone(),
2577                    field_needs_json_serialize => field_needs_json_serialize,
2578                },
2579            );
2580            out.push_str(&rendered);
2581        }
2582        "contains_any" => {
2583            if let Some(values) = &assertion.values {
2584                let checks: Vec<String> = values
2585                    .iter()
2586                    .map(|v| {
2587                        let cs_val = json_to_csharp(v);
2588                        format!("{field_as_str}.Contains({cs_val})")
2589                    })
2590                    .collect();
2591                let contains_any_expr = checks.join(" || ");
2592
2593                let rendered = crate::template_env::render(
2594                    "csharp/assertion.jinja",
2595                    minijinja::context! {
2596                        assertion_type => "contains_any",
2597                        contains_any_expr => contains_any_expr,
2598                    },
2599                );
2600                out.push_str(&rendered);
2601            }
2602        }
2603        "greater_than" => {
2604            if let Some(val) = &assertion.value {
2605                let cs_val = json_to_csharp(val);
2606
2607                let rendered = crate::template_env::render(
2608                    "csharp/assertion.jinja",
2609                    minijinja::context! {
2610                        assertion_type => "greater_than",
2611                        field_expr => field_expr.clone(),
2612                        cs_val => cs_val,
2613                    },
2614                );
2615                out.push_str(&rendered);
2616            }
2617        }
2618        "less_than" => {
2619            if let Some(val) = &assertion.value {
2620                let cs_val = json_to_csharp(val);
2621
2622                let rendered = crate::template_env::render(
2623                    "csharp/assertion.jinja",
2624                    minijinja::context! {
2625                        assertion_type => "less_than",
2626                        field_expr => field_expr.clone(),
2627                        cs_val => cs_val,
2628                    },
2629                );
2630                out.push_str(&rendered);
2631            }
2632        }
2633        "greater_than_or_equal" => {
2634            if let Some(val) = &assertion.value {
2635                let cs_val = json_to_csharp(val);
2636
2637                let rendered = crate::template_env::render(
2638                    "csharp/assertion.jinja",
2639                    minijinja::context! {
2640                        assertion_type => "greater_than_or_equal",
2641                        field_expr => field_expr.clone(),
2642                        cs_val => cs_val,
2643                    },
2644                );
2645                out.push_str(&rendered);
2646            }
2647        }
2648        "less_than_or_equal" => {
2649            if let Some(val) = &assertion.value {
2650                let cs_val = json_to_csharp(val);
2651
2652                let rendered = crate::template_env::render(
2653                    "csharp/assertion.jinja",
2654                    minijinja::context! {
2655                        assertion_type => "less_than_or_equal",
2656                        field_expr => field_expr.clone(),
2657                        cs_val => cs_val,
2658                    },
2659                );
2660                out.push_str(&rendered);
2661            }
2662        }
2663        "starts_with" => {
2664            if let Some(expected) = &assertion.value {
2665                let cs_val = json_to_csharp(expected);
2666
2667                let rendered = crate::template_env::render(
2668                    "csharp/assertion.jinja",
2669                    minijinja::context! {
2670                        assertion_type => "starts_with",
2671                        field_expr => field_expr.clone(),
2672                        cs_val => cs_val,
2673                    },
2674                );
2675                out.push_str(&rendered);
2676            }
2677        }
2678        "ends_with" => {
2679            if let Some(expected) = &assertion.value {
2680                let cs_val = json_to_csharp(expected);
2681
2682                let rendered = crate::template_env::render(
2683                    "csharp/assertion.jinja",
2684                    minijinja::context! {
2685                        assertion_type => "ends_with",
2686                        field_expr => field_expr.clone(),
2687                        cs_val => cs_val,
2688                    },
2689                );
2690                out.push_str(&rendered);
2691            }
2692        }
2693        "min_length" => {
2694            if let Some(val) = &assertion.value {
2695                if let Some(n) = val.as_u64() {
2696                    let rendered = crate::template_env::render(
2697                        "csharp/assertion.jinja",
2698                        minijinja::context! {
2699                            assertion_type => "min_length",
2700                            field_expr => field_expr.clone(),
2701                            n => n,
2702                        },
2703                    );
2704                    out.push_str(&rendered);
2705                }
2706            }
2707        }
2708        "max_length" => {
2709            if let Some(val) = &assertion.value {
2710                if let Some(n) = val.as_u64() {
2711                    let rendered = crate::template_env::render(
2712                        "csharp/assertion.jinja",
2713                        minijinja::context! {
2714                            assertion_type => "max_length",
2715                            field_expr => field_expr.clone(),
2716                            n => n,
2717                        },
2718                    );
2719                    out.push_str(&rendered);
2720                }
2721            }
2722        }
2723        "count_min" => {
2724            if let Some(val) = &assertion.value {
2725                if let Some(n) = val.as_u64() {
2726                    let rendered = crate::template_env::render(
2727                        "csharp/assertion.jinja",
2728                        minijinja::context! {
2729                            assertion_type => "count_min",
2730                            field_expr => field_expr.clone(),
2731                            n => n,
2732                        },
2733                    );
2734                    out.push_str(&rendered);
2735                }
2736            }
2737        }
2738        "count_equals" => {
2739            if let Some(val) = &assertion.value {
2740                if let Some(n) = val.as_u64() {
2741                    let rendered = crate::template_env::render(
2742                        "csharp/assertion.jinja",
2743                        minijinja::context! {
2744                            assertion_type => "count_equals",
2745                            field_expr => field_expr.clone(),
2746                            n => n,
2747                        },
2748                    );
2749                    out.push_str(&rendered);
2750                }
2751            }
2752        }
2753        "is_true" => {
2754            // When the field expression is not a simple bool (e.g., a complex object or
2755            // result type), use Assert.NotNull instead of Assert.True to avoid cast issues.
2756            // If it's clearly not a boolean type (contains null-checking operators or is a
2757            // complex object), treat it as a not-null check.
2758            let is_complex_or_object = field_expr.contains("(object)")
2759                || (field_expr.contains(".")
2760                    && !result_is_simple
2761                    && !field_expr.contains("?")
2762                    && !field_expr.contains("=="));
2763
2764            let rendered = if is_complex_or_object {
2765                crate::template_env::render(
2766                    "csharp/assertion.jinja",
2767                    minijinja::context! {
2768                        assertion_type => "not_empty",
2769                        field_expr => field_expr.clone(),
2770                        field_needs_json_serialize => false,
2771                    },
2772                )
2773            } else {
2774                crate::template_env::render(
2775                    "csharp/assertion.jinja",
2776                    minijinja::context! {
2777                        assertion_type => "is_true",
2778                        field_expr => field_expr.clone(),
2779                    },
2780                )
2781            };
2782            out.push_str(&rendered);
2783        }
2784        "is_false" => {
2785            let is_complex_or_object = field_expr.contains("(object)")
2786                || (field_expr.contains(".")
2787                    && !result_is_simple
2788                    && !field_expr.contains("?")
2789                    && !field_expr.contains("=="));
2790
2791            let rendered = if is_complex_or_object {
2792                // For complex types, is_false means "is empty/null"
2793                crate::template_env::render(
2794                    "csharp/assertion.jinja",
2795                    minijinja::context! {
2796                        assertion_type => "is_empty",
2797                        field_expr => field_expr.clone(),
2798                        field_needs_json_serialize => false,
2799                    },
2800                )
2801            } else {
2802                crate::template_env::render(
2803                    "csharp/assertion.jinja",
2804                    minijinja::context! {
2805                        assertion_type => "is_false",
2806                        field_expr => field_expr.clone(),
2807                    },
2808                )
2809            };
2810            out.push_str(&rendered);
2811        }
2812        "not_error" => {
2813            // Already handled by the call succeeding without exception.
2814            let rendered = crate::template_env::render(
2815                "csharp/assertion.jinja",
2816                minijinja::context! {
2817                    assertion_type => "not_error",
2818                },
2819            );
2820            out.push_str(&rendered);
2821        }
2822        "error" => {
2823            // Handled at the test method level.
2824            let rendered = crate::template_env::render(
2825                "csharp/assertion.jinja",
2826                minijinja::context! {
2827                    assertion_type => "error",
2828                },
2829            );
2830            out.push_str(&rendered);
2831        }
2832        "method_result" => {
2833            if let Some(method_name) = &assertion.method {
2834                let call_expr = build_csharp_method_call(result_var, method_name, assertion.args.as_ref(), class_name);
2835                let check = assertion.check.as_deref().unwrap_or("is_true");
2836
2837                match check {
2838                    "equals" => {
2839                        if let Some(val) = &assertion.value {
2840                            let is_check_bool_true = val.as_bool() == Some(true);
2841                            let is_check_bool_false = val.as_bool() == Some(false);
2842                            let cs_check_val = json_to_csharp(val);
2843
2844                            let rendered = crate::template_env::render(
2845                                "csharp/assertion.jinja",
2846                                minijinja::context! {
2847                                    assertion_type => "method_result",
2848                                    check => "equals",
2849                                    call_expr => call_expr.clone(),
2850                                    is_check_bool_true => is_check_bool_true,
2851                                    is_check_bool_false => is_check_bool_false,
2852                                    cs_check_val => cs_check_val,
2853                                },
2854                            );
2855                            out.push_str(&rendered);
2856                        }
2857                    }
2858                    "is_true" => {
2859                        let rendered = crate::template_env::render(
2860                            "csharp/assertion.jinja",
2861                            minijinja::context! {
2862                                assertion_type => "method_result",
2863                                check => "is_true",
2864                                call_expr => call_expr.clone(),
2865                            },
2866                        );
2867                        out.push_str(&rendered);
2868                    }
2869                    "is_false" => {
2870                        let rendered = crate::template_env::render(
2871                            "csharp/assertion.jinja",
2872                            minijinja::context! {
2873                                assertion_type => "method_result",
2874                                check => "is_false",
2875                                call_expr => call_expr.clone(),
2876                            },
2877                        );
2878                        out.push_str(&rendered);
2879                    }
2880                    "greater_than_or_equal" => {
2881                        if let Some(val) = &assertion.value {
2882                            let check_n = val.as_u64().unwrap_or(0);
2883
2884                            let rendered = crate::template_env::render(
2885                                "csharp/assertion.jinja",
2886                                minijinja::context! {
2887                                    assertion_type => "method_result",
2888                                    check => "greater_than_or_equal",
2889                                    call_expr => call_expr.clone(),
2890                                    check_n => check_n,
2891                                },
2892                            );
2893                            out.push_str(&rendered);
2894                        }
2895                    }
2896                    "count_min" => {
2897                        if let Some(val) = &assertion.value {
2898                            let check_n = val.as_u64().unwrap_or(0);
2899
2900                            let rendered = crate::template_env::render(
2901                                "csharp/assertion.jinja",
2902                                minijinja::context! {
2903                                    assertion_type => "method_result",
2904                                    check => "count_min",
2905                                    call_expr => call_expr.clone(),
2906                                    check_n => check_n,
2907                                },
2908                            );
2909                            out.push_str(&rendered);
2910                        }
2911                    }
2912                    "is_error" => {
2913                        let rendered = crate::template_env::render(
2914                            "csharp/assertion.jinja",
2915                            minijinja::context! {
2916                                assertion_type => "method_result",
2917                                check => "is_error",
2918                                call_expr => call_expr.clone(),
2919                                exception_class => exception_class,
2920                            },
2921                        );
2922                        out.push_str(&rendered);
2923                    }
2924                    "contains" => {
2925                        if let Some(val) = &assertion.value {
2926                            let cs_check_val = json_to_csharp(val);
2927
2928                            let rendered = crate::template_env::render(
2929                                "csharp/assertion.jinja",
2930                                minijinja::context! {
2931                                    assertion_type => "method_result",
2932                                    check => "contains",
2933                                    call_expr => call_expr.clone(),
2934                                    cs_check_val => cs_check_val,
2935                                },
2936                            );
2937                            out.push_str(&rendered);
2938                        }
2939                    }
2940                    other_check => {
2941                        panic!("C# e2e generator: unsupported method_result check type: {other_check}");
2942                    }
2943                }
2944            } else {
2945                panic!("C# e2e generator: method_result assertion missing 'method' field");
2946            }
2947        }
2948        "matches_regex" => {
2949            if let Some(expected) = &assertion.value {
2950                let cs_val = json_to_csharp(expected);
2951
2952                let rendered = crate::template_env::render(
2953                    "csharp/assertion.jinja",
2954                    minijinja::context! {
2955                        assertion_type => "matches_regex",
2956                        field_expr => field_expr.clone(),
2957                        cs_val => cs_val,
2958                    },
2959                );
2960                out.push_str(&rendered);
2961            }
2962        }
2963        other => {
2964            panic!("C# e2e generator: unsupported assertion type: {other}");
2965        }
2966    }
2967}
2968
2969/// Recursively sort JSON objects so that any key named `"type"` appears first.
2970///
2971/// System.Text.Json's `[JsonPolymorphic]` requires the type discriminator to be
2972/// the first property when deserializing polymorphic types. Fixture config values
2973/// serialised via serde_json preserve insertion/alphabetical order, which may put
2974/// `"type"` after other keys (e.g. `"password"` before `"type"` in auth configs).
2975fn sort_discriminator_first(value: serde_json::Value) -> serde_json::Value {
2976    match value {
2977        serde_json::Value::Object(map) => {
2978            let mut sorted = serde_json::Map::with_capacity(map.len());
2979            // Insert "type" first if present.
2980            if let Some(type_val) = map.get("type") {
2981                sorted.insert("type".to_string(), sort_discriminator_first(type_val.clone()));
2982            }
2983            for (k, v) in map {
2984                if k != "type" {
2985                    sorted.insert(k, sort_discriminator_first(v));
2986                }
2987            }
2988            serde_json::Value::Object(sorted)
2989        }
2990        serde_json::Value::Array(arr) => {
2991            serde_json::Value::Array(arr.into_iter().map(sort_discriminator_first).collect())
2992        }
2993        other => other,
2994    }
2995}
2996
2997/// Render a C# sealed-union display helper for assert_enum_fields.
2998/// Pattern-matches on variants from the IR and returns a displayable string.
2999fn render_sealed_display(
3000    type_name: &str,
3001    enum_def: &alef_core::ir::EnumDef,
3002    type_defs: &[alef_core::ir::TypeDef],
3003    namespace: &str,
3004) -> String {
3005    let header = hash::header(CommentStyle::DoubleSlash);
3006    let mut out = header;
3007    out.push_str(&format!("namespace {namespace}.E2e;\n\n"));
3008    out.push_str(&format!(
3009        "/// <summary>\n/// Helper class for extracting display strings from {type_name} sealed interface.\n /// </summary>\n"
3010    ));
3011    out.push_str(&format!("internal static class {type_name}Display\n"));
3012    out.push_str("{\n");
3013    out.push_str(&format!(
3014        "    internal static string ToDisplayString({type_name}? value)\n"
3015    ));
3016    out.push_str("    {\n");
3017    out.push_str("        if (value == null) return \"\";\n");
3018    out.push_str("        return value switch\n");
3019    out.push_str("        {\n");
3020
3021    for variant in &enum_def.variants {
3022        let variant_name = &variant.name;
3023        // Determine the display string for this variant's arm.
3024        // Tuple variants with one field whose resolved struct type has a `format`
3025        // field return the inner `.Value.Format` — this gives the actual format
3026        // string (e.g. "PNG") rather than the generic variant label (e.g. "image").
3027        let has_format_field = variant.is_tuple && variant.fields.len() == 1 && {
3028            let field_type_name = match &variant.fields[0].ty {
3029                alef_core::ir::TypeRef::Named(n) => Some(n.as_str()),
3030                _ => None,
3031            };
3032            field_type_name.is_some_and(|tn| {
3033                type_defs
3034                    .iter()
3035                    .find(|td| td.name == tn)
3036                    .is_some_and(|td| td.fields.iter().any(|f| f.name == "format"))
3037            })
3038        };
3039
3040        let display = if has_format_field {
3041            "i.Value.Format".to_string()
3042        } else {
3043            // Use the serde rename when present; otherwise lowercase the variant name.
3044            let serde_name = variant
3045                .serde_rename
3046                .as_deref()
3047                .unwrap_or(variant_name.as_str())
3048                .to_lowercase();
3049            format!("\"{serde_name}\"")
3050        };
3051
3052        let binding = if has_format_field {
3053            format!("{type_name}.{variant_name} i")
3054        } else {
3055            format!("{type_name}.{variant_name}")
3056        };
3057
3058        out.push_str(&format!("            {binding} => {display},\n"));
3059    }
3060
3061    out.push_str("            _ => \"unknown\",\n");
3062    out.push_str("        };\n");
3063    out.push_str("    }\n");
3064    out.push_str("}\n");
3065    out
3066}
3067
3068/// Convert a `serde_json::Value` to a C# literal string.
3069fn json_to_csharp(value: &serde_json::Value) -> String {
3070    match value {
3071        serde_json::Value::String(s) => format!("\"{}\"", escape_csharp(s)),
3072        serde_json::Value::Bool(true) => "true".to_string(),
3073        serde_json::Value::Bool(false) => "false".to_string(),
3074        serde_json::Value::Number(n) => {
3075            if n.is_f64() {
3076                format!("{}d", n)
3077            } else {
3078                n.to_string()
3079            }
3080        }
3081        serde_json::Value::Null => "null".to_string(),
3082        serde_json::Value::Array(arr) => {
3083            let items: Vec<String> = arr.iter().map(json_to_csharp).collect();
3084            format!("new[] {{ {} }}", items.join(", "))
3085        }
3086        serde_json::Value::Object(_) => {
3087            let json_str = serde_json::to_string(value).unwrap_or_default();
3088            format!("\"{}\"", escape_csharp(&json_str))
3089        }
3090    }
3091}
3092
3093/// Emit a C# object initializer for a JSON options object.
3094///
3095/// - camelCase fixture keys → PascalCase C# property names
3096/// - Enum fields (from `enum_fields`) → `EnumType.Member`
3097/// - Nested objects with known type (from `nested_types`) → `JsonSerializer.Deserialize<T>(...)`
3098/// - Arrays → `new List<string> { ... }`
3099/// - Primitives → C# literals via `json_to_csharp`
3100fn csharp_object_initializer(
3101    obj: &serde_json::Map<String, serde_json::Value>,
3102    type_name: &str,
3103    enum_fields: &HashMap<String, String>,
3104    nested_types: &HashMap<String, String>,
3105) -> String {
3106    if obj.is_empty() {
3107        return format!("new {type_name}()");
3108    }
3109
3110    // Snake_case fixture keys for fields that are real C# enums in the binding.
3111    // The fixture string value (e.g. "markdown") maps to `EnumType.Member` (e.g. `OutputFormat.Markdown`).
3112    static IMPLICIT_ENUM_FIELDS: &[(&str, &str)] = &[("output_format", "OutputFormat")];
3113
3114    let props: Vec<String> = obj
3115        .iter()
3116        .map(|(key, val)| {
3117            let pascal_key = key.to_upper_camel_case();
3118            let implicit_enum_type = IMPLICIT_ENUM_FIELDS
3119                .iter()
3120                .find(|(k, _)| *k == key.as_str())
3121                .map(|(_, t)| *t);
3122            // Check enum_fields both with the original snake_case key AND with camelCase key.
3123            // The alef.toml config uses camelCase keys (e.g., "codeBlockStyle"), but fixture
3124            // JSON uses snake_case keys (e.g., "code_block_style"). So we check both.
3125            let camel_key = key.to_lower_camel_case();
3126            let cs_val = if let Some(enum_type) = enum_fields
3127                .get(key.as_str())
3128                .or_else(|| enum_fields.get(camel_key.as_str()))
3129                .map(String::as_str)
3130                .or(implicit_enum_type)
3131            {
3132                // Enum: EnumType.Member
3133                if val.is_null() {
3134                    "null".to_string()
3135                } else {
3136                    let member = val
3137                        .as_str()
3138                        .map(|s| s.to_upper_camel_case())
3139                        .unwrap_or_else(|| "null".to_string());
3140                    format!("{enum_type}.{member}")
3141                }
3142            } else if let Some(nested_type) = nested_types
3143                .get(key.as_str())
3144                .or_else(|| nested_types.get(camel_key.as_str()))
3145            {
3146                // Nested type: deserialize via JsonSerializer using the binding's custom converters.
3147                // This handles sealed records, custom JsonConverters, and sealed unions correctly.
3148                let normalized = normalize_csharp_enum_values(val, enum_fields);
3149                let json_str = serde_json::to_string(&normalized).unwrap_or_default();
3150                let escaped = escape_csharp(&json_str);
3151                format!("JsonSerializer.Deserialize<{nested_type}>(\"{escaped}\", ConfigOptions)!")
3152            } else if let Some(arr) = val.as_array() {
3153                // Array: List<string>
3154                let items: Vec<String> = arr.iter().map(json_to_csharp).collect();
3155                format!("new List<string> {{ {} }}", items.join(", "))
3156            } else {
3157                json_to_csharp(val)
3158            };
3159            format!("{pascal_key} = {cs_val}")
3160        })
3161        .collect();
3162    format!("new {} {{ {} }}", type_name, props.join(", "))
3163}
3164
3165/// Convert enum values in a JSON object to lowercase to match C# [JsonPropertyName] attributes.
3166/// The JSON deserialization uses JsonPropertyName("lowercase_value"), so fixture enum values
3167/// (typically PascalCase like "Tildes") must be converted to lowercase ("tildes") for correct
3168/// deserialization with JsonStringEnumConverter.
3169fn normalize_csharp_enum_values(value: &serde_json::Value, enum_fields: &HashMap<String, String>) -> serde_json::Value {
3170    match value {
3171        serde_json::Value::Object(map) => {
3172            let mut result = map.clone();
3173            for (key, val) in result.iter_mut() {
3174                // Check both snake_case and camelCase keys, since alef.toml uses camelCase
3175                // but fixture JSON uses snake_case.
3176                let camel_key = key.to_lower_camel_case();
3177                if enum_fields.contains_key(key) || enum_fields.contains_key(camel_key.as_str()) {
3178                    // This is an enum field; convert the string value to lowercase.
3179                    if let Some(s) = val.as_str() {
3180                        *val = serde_json::Value::String(s.to_lowercase());
3181                    }
3182                }
3183            }
3184            serde_json::Value::Object(result)
3185        }
3186        other => other.clone(),
3187    }
3188}
3189
3190// ---------------------------------------------------------------------------
3191// Visitor generation
3192// ---------------------------------------------------------------------------
3193
3194/// Build a C# visitor: add an instantiation line to `setup_lines` and push
3195/// a private nested class declaration to `class_decls` (emitted at class scope,
3196/// outside any method body — C# does not allow local class declarations inside
3197/// methods).  Each fixture gets a unique class name derived from its ID to avoid
3198/// duplicate-name compile errors when multiple visitor fixtures exist per file.
3199/// Returns the visitor variable name for use as a call argument.
3200fn build_csharp_visitor(
3201    setup_lines: &mut Vec<String>,
3202    class_decls: &mut Vec<String>,
3203    fixture_id: &str,
3204    visitor_spec: &crate::fixture::VisitorSpec,
3205) -> String {
3206    use heck::ToUpperCamelCase;
3207    let class_name = format!("{}Visitor", fixture_id.to_upper_camel_case());
3208    let var_name = format!("_visitor_{}", fixture_id.replace('-', "_"));
3209
3210    setup_lines.push(format!("var {var_name} = new {class_name}();"));
3211
3212    // Build the class declaration string (indented for nesting inside the test class).
3213    let mut decl = String::new();
3214    decl.push_str(&format!("    private sealed class {class_name} : IHtmlVisitor\n"));
3215    decl.push_str("    {\n");
3216
3217    // List of all visitor methods that must be implemented by IHtmlVisitor.
3218    let all_methods = [
3219        "visit_element_start",
3220        "visit_element_end",
3221        "visit_text",
3222        "visit_link",
3223        "visit_image",
3224        "visit_heading",
3225        "visit_code_block",
3226        "visit_code_inline",
3227        "visit_list_item",
3228        "visit_list_start",
3229        "visit_list_end",
3230        "visit_table_start",
3231        "visit_table_row",
3232        "visit_table_end",
3233        "visit_blockquote",
3234        "visit_strong",
3235        "visit_emphasis",
3236        "visit_strikethrough",
3237        "visit_underline",
3238        "visit_subscript",
3239        "visit_superscript",
3240        "visit_mark",
3241        "visit_line_break",
3242        "visit_horizontal_rule",
3243        "visit_custom_element",
3244        "visit_definition_list_start",
3245        "visit_definition_term",
3246        "visit_definition_description",
3247        "visit_definition_list_end",
3248        "visit_form",
3249        "visit_input",
3250        "visit_button",
3251        "visit_audio",
3252        "visit_video",
3253        "visit_iframe",
3254        "visit_details",
3255        "visit_summary",
3256        "visit_figure_start",
3257        "visit_figcaption",
3258        "visit_figure_end",
3259    ];
3260
3261    // Emit all methods: use fixture action if specified, otherwise default to Continue.
3262    for method_name in &all_methods {
3263        if let Some(action) = visitor_spec.callbacks.get(*method_name) {
3264            emit_csharp_visitor_method(&mut decl, method_name, action);
3265        } else {
3266            // Default: Continue for methods not in the fixture
3267            emit_csharp_visitor_method(&mut decl, method_name, &CallbackAction::Continue);
3268        }
3269    }
3270
3271    decl.push_str("    }\n");
3272    class_decls.push(decl);
3273
3274    var_name
3275}
3276
3277/// Emit a C# visitor method into a class declaration string.
3278fn emit_csharp_visitor_method(decl: &mut String, method_name: &str, action: &CallbackAction) {
3279    let camel_method = method_to_camel(method_name);
3280    let params = match method_name {
3281        "visit_link" => "NodeContext ctx, string href, string text, string title",
3282        "visit_image" => "NodeContext ctx, string src, string alt, string title",
3283        "visit_heading" => "NodeContext ctx, uint level, string text, string id",
3284        "visit_code_block" => "NodeContext ctx, string lang, string code",
3285        "visit_code_inline"
3286        | "visit_strong"
3287        | "visit_emphasis"
3288        | "visit_strikethrough"
3289        | "visit_underline"
3290        | "visit_subscript"
3291        | "visit_superscript"
3292        | "visit_mark"
3293        | "visit_button"
3294        | "visit_summary"
3295        | "visit_figcaption"
3296        | "visit_definition_term"
3297        | "visit_definition_description" => "NodeContext ctx, string text",
3298        "visit_text" => "NodeContext ctx, string text",
3299        "visit_list_item" => "NodeContext ctx, bool ordered, string marker, string text",
3300        "visit_blockquote" => "NodeContext ctx, string content, ulong depth",
3301        "visit_table_row" => "NodeContext ctx, List<string> cells, bool isHeader",
3302        "visit_custom_element" => "NodeContext ctx, string tagName, string html",
3303        "visit_form" => "NodeContext ctx, string actionUrl, string method",
3304        "visit_input" => "NodeContext ctx, string inputType, string name, string value",
3305        "visit_audio" | "visit_video" | "visit_iframe" => "NodeContext ctx, string src",
3306        "visit_details" => "NodeContext ctx, bool isOpen",
3307        "visit_element_end" | "visit_table_end" | "visit_definition_list_end" | "visit_figure_end" => {
3308            "NodeContext ctx, string output"
3309        }
3310        "visit_list_start" => "NodeContext ctx, bool ordered",
3311        "visit_list_end" => "NodeContext ctx, bool ordered, string output",
3312        "visit_element_start"
3313        | "visit_table_start"
3314        | "visit_definition_list_start"
3315        | "visit_figure_start"
3316        | "visit_line_break"
3317        | "visit_horizontal_rule" => "NodeContext ctx",
3318        _ => "NodeContext ctx",
3319    };
3320
3321    let (action_type, action_value) = match action {
3322        CallbackAction::Skip => ("skip", String::new()),
3323        CallbackAction::Continue => ("continue", String::new()),
3324        CallbackAction::PreserveHtml => ("preserve_html", String::new()),
3325        CallbackAction::Custom { output } => ("custom", escape_csharp(output)),
3326        CallbackAction::CustomTemplate { template, .. } => {
3327            let camel = snake_case_template_to_camel(template);
3328            ("custom_template", escape_csharp(&camel))
3329        }
3330    };
3331
3332    let rendered = crate::template_env::render(
3333        "csharp/visitor_method.jinja",
3334        minijinja::context! {
3335            camel_method => camel_method,
3336            params => params,
3337            action_type => action_type,
3338            action_value => action_value,
3339        },
3340    );
3341    let _ = write!(decl, "{}", rendered);
3342}
3343
3344/// Convert snake_case method names to C# PascalCase.
3345fn method_to_camel(snake: &str) -> String {
3346    use heck::ToUpperCamelCase;
3347    snake.to_upper_camel_case()
3348}
3349
3350/// Rewrite `{snake_case}` placeholders in a custom template to `{camelCase}` so
3351/// they match C# parameter names (which alef emits in camelCase).
3352fn snake_case_template_to_camel(template: &str) -> String {
3353    use heck::ToLowerCamelCase;
3354    let mut out = String::with_capacity(template.len());
3355    let mut chars = template.chars().peekable();
3356    while let Some(c) = chars.next() {
3357        if c == '{' {
3358            let mut name = String::new();
3359            while let Some(&nc) = chars.peek() {
3360                if nc == '}' {
3361                    chars.next();
3362                    break;
3363                }
3364                name.push(nc);
3365                chars.next();
3366            }
3367            out.push('{');
3368            out.push_str(&name.to_lower_camel_case());
3369            out.push('}');
3370        } else {
3371            out.push(c);
3372        }
3373    }
3374    out
3375}
3376
3377/// Build a C# call expression for a `method_result` assertion on a tree-sitter Tree.
3378///
3379/// Maps well-known method names to the appropriate C# static helper calls on the
3380/// generated lib class, falling back to `result_var.PascalCase()` for unknowns.
3381fn build_csharp_method_call(
3382    result_var: &str,
3383    method_name: &str,
3384    args: Option<&serde_json::Value>,
3385    class_name: &str,
3386) -> String {
3387    match method_name {
3388        "root_child_count" => format!("{result_var}.RootNode.ChildCount"),
3389        "root_node_type" => format!("{result_var}.RootNode.Kind"),
3390        "named_children_count" => format!("{result_var}.RootNode.NamedChildCount"),
3391        "has_error_nodes" => format!("{class_name}.TreeHasErrorNodes({result_var})"),
3392        "error_count" | "tree_error_count" => format!("{class_name}.TreeErrorCount({result_var})"),
3393        "tree_to_sexp" => format!("{class_name}.TreeToSexp({result_var})"),
3394        "contains_node_type" => {
3395            let node_type = args
3396                .and_then(|a| a.get("node_type"))
3397                .and_then(|v| v.as_str())
3398                .unwrap_or("");
3399            format!("{class_name}.TreeContainsNodeType({result_var}, \"{node_type}\")")
3400        }
3401        "find_nodes_by_type" => {
3402            let node_type = args
3403                .and_then(|a| a.get("node_type"))
3404                .and_then(|v| v.as_str())
3405                .unwrap_or("");
3406            format!("{class_name}.FindNodesByType({result_var}, \"{node_type}\")")
3407        }
3408        "run_query" => {
3409            let query_source = args
3410                .and_then(|a| a.get("query_source"))
3411                .and_then(|v| v.as_str())
3412                .unwrap_or("");
3413            let language = args
3414                .and_then(|a| a.get("language"))
3415                .and_then(|v| v.as_str())
3416                .unwrap_or("");
3417            format!("{class_name}.RunQuery({result_var}, \"{language}\", \"{query_source}\", source)")
3418        }
3419        _ => {
3420            use heck::ToUpperCamelCase;
3421            let pascal = method_name.to_upper_camel_case();
3422            format!("{result_var}.{pascal}()")
3423        }
3424    }
3425}
3426
3427fn fixture_has_csharp_callable(fixture: &Fixture, e2e_config: &E2eConfig) -> bool {
3428    // HTTP fixtures are handled separately — not our concern here.
3429    if fixture.is_http_test() {
3430        return false;
3431    }
3432    // Use resolve_call_for_fixture to support auto-routing via select_when.
3433    let call_config = e2e_config.resolve_call_for_fixture(
3434        fixture.call.as_deref(),
3435        &fixture.id,
3436        &fixture.resolved_category(),
3437        &fixture.tags,
3438        &fixture.input,
3439    );
3440    let cs_override = call_config
3441        .overrides
3442        .get("csharp")
3443        .or_else(|| e2e_config.call.overrides.get("csharp"));
3444    // When a client_factory is configured the fixture is callable via the client pattern.
3445    if cs_override.and_then(|o| o.client_factory.as_deref()).is_some() {
3446        return true;
3447    }
3448    // C# binding provides a default class name (e.g., KreuzcrawlLib) if not overridden,
3449    // so any function name makes a callable available.
3450    cs_override.and_then(|o| o.function.as_deref()).is_some() || !call_config.function.is_empty()
3451}
3452
3453/// Classify a fixture string value that maps to a `bytes` argument.
3454/// Determines whether to treat it as a file path, inline text, or base64-encoded data.
3455fn classify_bytes_value_csharp(s: &str) -> String {
3456    // File paths: start with alphanumeric/underscore, contain "/" with extension
3457    // e.g., "pdf/fake.pdf", "images/test.png"
3458    if let Some(first) = s.chars().next() {
3459        if first.is_ascii_alphanumeric() || first == '_' {
3460            if let Some(slash_pos) = s.find('/') {
3461                if slash_pos > 0 {
3462                    let after_slash = &s[slash_pos + 1..];
3463                    if after_slash.contains('.') && !after_slash.is_empty() {
3464                        // File path: use File.ReadAllBytes(path)
3465                        return format!("System.IO.File.ReadAllBytes(\"{}\")", s);
3466                    }
3467                }
3468            }
3469        }
3470    }
3471
3472    // Inline text: starts with markup or contains spaces
3473    // e.g., "<html>...", "{...}", "[...]", "text with spaces"
3474    if s.starts_with('<') || s.starts_with('{') || s.starts_with('[') || s.contains(' ') {
3475        // Inline text: use System.Text.Encoding.UTF8.GetBytes()
3476        return format!("System.Text.Encoding.UTF8.GetBytes(\"{}\")", escape_csharp(s));
3477    }
3478
3479    // Base64: base64-like pattern (uppercase/lowercase letters, digits, +, /, =)
3480    // e.g., "/9j/4AAQ", "SGVsbG8gV29ybGQ="
3481    // Use Convert.FromBase64String()
3482    format!("System.Convert.FromBase64String(\"{}\")", s)
3483}
3484
3485#[cfg(test)]
3486mod tests {
3487    use crate::config::{CallConfig, E2eConfig, SelectWhen};
3488    use crate::fixture::Fixture;
3489    use std::collections::HashMap;
3490
3491    fn make_fixture_with_input(id: &str, input: serde_json::Value) -> Fixture {
3492        Fixture {
3493            id: id.to_string(),
3494            category: None,
3495            description: "test fixture".to_string(),
3496            tags: vec![],
3497            skip: None,
3498            env: None,
3499            call: None,
3500            input,
3501            mock_response: None,
3502            source: String::new(),
3503            http: None,
3504            assertions: vec![],
3505            visitor: None,
3506        }
3507    }
3508
3509    /// Test that resolve_call_for_fixture correctly routes to batch_scrape
3510    /// when input has batch_urls and select_when condition matches.
3511    #[test]
3512    fn test_csharp_select_when_routes_to_batch_scrape() {
3513        let mut calls = HashMap::new();
3514        calls.insert(
3515            "batch_scrape".to_string(),
3516            CallConfig {
3517                function: "BatchScrape".to_string(),
3518                module: "KreuzBrowser".to_string(),
3519                select_when: Some(SelectWhen {
3520                    input_has: Some("batch_urls".to_string()),
3521                    ..Default::default()
3522                }),
3523                ..CallConfig::default()
3524            },
3525        );
3526
3527        let e2e_config = E2eConfig {
3528            call: CallConfig {
3529                function: "Scrape".to_string(),
3530                module: "KreuzBrowser".to_string(),
3531                ..CallConfig::default()
3532            },
3533            calls,
3534            ..E2eConfig::default()
3535        };
3536
3537        // Fixture with batch_urls but no explicit call field should route to batch_scrape
3538        let fixture = make_fixture_with_input("batch_empty_urls", serde_json::json!({ "batch_urls": [] }));
3539
3540        let resolved_call = e2e_config.resolve_call_for_fixture(
3541            fixture.call.as_deref(),
3542            &fixture.id,
3543            &fixture.resolved_category(),
3544            &fixture.tags,
3545            &fixture.input,
3546        );
3547        assert_eq!(resolved_call.function, "BatchScrape");
3548
3549        // Fixture without batch_urls should fall back to default Scrape
3550        let fixture_no_batch =
3551            make_fixture_with_input("simple_scrape", serde_json::json!({ "url": "https://example.com" }));
3552        let resolved_default = e2e_config.resolve_call_for_fixture(
3553            fixture_no_batch.call.as_deref(),
3554            &fixture_no_batch.id,
3555            &fixture_no_batch.resolved_category(),
3556            &fixture_no_batch.tags,
3557            &fixture_no_batch.input,
3558        );
3559        assert_eq!(resolved_default.function, "Scrape");
3560    }
3561}