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