1use 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
24pub 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 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 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 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 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 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 let needs_mock_server = groups
106 .iter()
107 .flat_map(|g| g.fixtures.iter())
108 .any(|f| f.needs_mock_server());
109
110 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 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 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 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 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
219fn 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 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 let config_options_field = " private static readonly JsonSerializerOptions ConfigOptions = new() { Converters = { new JsonStringEnumConverter(JsonNamingPolicy.SnakeCaseLower) }, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingDefault };";
473
474 let mut visitor_class_decls: Vec<String> = Vec::new();
478
479 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 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 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
534struct CSharpTestClientRenderer;
543
544fn 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
554const CSHARP_RESTRICTED_REQUEST_HEADERS: &[&str] = &[
558 "content-length",
559 "host",
560 "connection",
561 "expect",
562 "transfer-encoding",
563 "upgrade",
564 "content-type",
567 "content-encoding",
569 "content-language",
570 "content-location",
571 "content-md5",
572 "content-range",
573 "content-disposition",
574];
575
576fn 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 fn sanitize_test_name(&self, id: &str) -> String {
603 id.to_upper_camel_case()
604 }
605
606 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 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 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 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 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 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 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 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 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 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 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 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 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 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
781fn 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 if let Some(http) = &fixture.http {
811 render_http_test_method(out, fixture, http);
812 return;
813 }
814
815 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 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 call_config = super::select_best_matching_call(call_config, e2e_config, fixture);
846 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 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 let per_call_result_is_simple = call_config.result_is_simple || cs_overrides.is_some_and(|o| o.result_is_simple);
909 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 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 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 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 if _is_streaming && adapter_request_type_owned.is_some() {
972 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 let parts: Vec<&str> = args_str.split(", ").collect();
981 if parts.len() >= 2 {
982 let urls_var = parts[parts.len() - 1]; let req_var = format!("{}Req", urls_var);
984 setup_lines.push(format!("var {req_var} = new {req_type} {{ Urls = {urls_var} }};"));
985 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 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 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 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 setup_lines.push(format!("var options = new {opts_type} {{ Visitor = {visitor_arg} }};"));
1016 let trimmed = args_str[..args_str.len() - 6].to_string(); format!("{trimmed}, options")
1018 } else if args_str.contains(", null,") {
1019 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 setup_lines.push(format!("var options = new {opts_type} {{ Visitor = {visitor_arg} }};"));
1025 "options".to_string()
1026 } else {
1027 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 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 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 let call_target = if client_factory.is_some() {
1057 "client".to_string()
1058 } else if _is_streaming {
1059 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 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 let call_expr = if _is_streaming {
1123 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 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 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 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 for line in rendered.lines() {
1203 out.push_str(" ");
1204 out.push_str(line);
1205 out.push('\n');
1206 }
1207}
1208
1209#[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 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 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 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 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 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
1476fn 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#[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 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 if let Some(s) = v.as_str() {
1622 let bytes_code = classify_bytes_value_csharp(s);
1623 parts.push(bytes_code);
1624 } else {
1625 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 let env_key = format!("MOCK_SERVER_{}", fixture_id.to_uppercase());
1670 let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
1671 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 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 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 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 parts.push("null".to_string());
1742 continue;
1743 }
1744 None | Some(serde_json::Value::Null) => {
1745 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 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 parts.push(format!("{opts_type}.FromJson(\"{escaped}\")",));
1783 continue;
1784 }
1785 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 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
1806fn 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 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
1891fn 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 if parts[0] == "metadata" && parts[1] == "format" {
1899 let variant_name = parts[2];
1900 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 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
1946fn 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; }
1960
1961 let field_pascal = inner_field.to_upper_camel_case();
1962 let mut field_expr = format!("{variant_var}.Value.{field_pascal}");
1963
1964 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 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 let _ = writeln!(
2085 out,
2086 " // skipped: assertion type '{}' not supported on byte[] result",
2087 assertion.assertion_type
2088 );
2089 return;
2090 }
2091 }
2092 }
2093 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 "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" => {
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 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 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 let is_discriminated_union = assertion
2383 .field
2384 .as_ref()
2385 .is_some_and(|f| parse_discriminated_union_access(f).is_some());
2386
2387 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 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 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 let field_needs_json_serialize = if result_is_simple {
2450 result_is_array
2453 } else {
2454 match &assertion.field {
2455 Some(f) if !f.is_empty() => field_resolver.is_array(f),
2456 _ => !result_is_simple,
2458 }
2459 };
2460 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 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 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 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 field_is_nullable = !field_expr.contains('!') && !field_expr.contains(")");
2581 let rendered = crate::template_env::render(
2582 "csharp/assertion.jinja",
2583 minijinja::context! {
2584 assertion_type => "not_empty",
2585 field_expr => field_expr.clone(),
2586 field_needs_json_serialize => field_needs_json_serialize,
2587 field_is_nullable => field_is_nullable,
2588 },
2589 );
2590 out.push_str(&rendered);
2591 }
2592 "is_empty" => {
2593 let field_is_nullable = !field_expr.contains('!') && !field_expr.contains(")");
2595 let rendered = crate::template_env::render(
2596 "csharp/assertion.jinja",
2597 minijinja::context! {
2598 assertion_type => "is_empty",
2599 field_expr => field_expr.clone(),
2600 field_needs_json_serialize => field_needs_json_serialize,
2601 field_is_nullable => field_is_nullable,
2602 },
2603 );
2604 out.push_str(&rendered);
2605 }
2606 "contains_any" => {
2607 if let Some(values) = &assertion.values {
2608 let checks: Vec<String> = values
2609 .iter()
2610 .map(|v| {
2611 let cs_val = json_to_csharp(v);
2612 format!("{field_as_str}.Contains({cs_val})")
2613 })
2614 .collect();
2615 let contains_any_expr = checks.join(" || ");
2616
2617 let rendered = crate::template_env::render(
2618 "csharp/assertion.jinja",
2619 minijinja::context! {
2620 assertion_type => "contains_any",
2621 contains_any_expr => contains_any_expr,
2622 },
2623 );
2624 out.push_str(&rendered);
2625 }
2626 }
2627 "greater_than" => {
2628 if let Some(val) = &assertion.value {
2629 let cs_val = json_to_csharp(val);
2630
2631 let rendered = crate::template_env::render(
2632 "csharp/assertion.jinja",
2633 minijinja::context! {
2634 assertion_type => "greater_than",
2635 field_expr => field_expr.clone(),
2636 cs_val => cs_val,
2637 },
2638 );
2639 out.push_str(&rendered);
2640 }
2641 }
2642 "less_than" => {
2643 if let Some(val) = &assertion.value {
2644 let cs_val = json_to_csharp(val);
2645
2646 let rendered = crate::template_env::render(
2647 "csharp/assertion.jinja",
2648 minijinja::context! {
2649 assertion_type => "less_than",
2650 field_expr => field_expr.clone(),
2651 cs_val => cs_val,
2652 },
2653 );
2654 out.push_str(&rendered);
2655 }
2656 }
2657 "greater_than_or_equal" => {
2658 if let Some(val) = &assertion.value {
2659 let cs_val = json_to_csharp(val);
2660
2661 let rendered = crate::template_env::render(
2662 "csharp/assertion.jinja",
2663 minijinja::context! {
2664 assertion_type => "greater_than_or_equal",
2665 field_expr => field_expr.clone(),
2666 cs_val => cs_val,
2667 },
2668 );
2669 out.push_str(&rendered);
2670 }
2671 }
2672 "less_than_or_equal" => {
2673 if let Some(val) = &assertion.value {
2674 let cs_val = json_to_csharp(val);
2675
2676 let rendered = crate::template_env::render(
2677 "csharp/assertion.jinja",
2678 minijinja::context! {
2679 assertion_type => "less_than_or_equal",
2680 field_expr => field_expr.clone(),
2681 cs_val => cs_val,
2682 },
2683 );
2684 out.push_str(&rendered);
2685 }
2686 }
2687 "starts_with" => {
2688 if let Some(expected) = &assertion.value {
2689 let cs_val = json_to_csharp(expected);
2690
2691 let rendered = crate::template_env::render(
2692 "csharp/assertion.jinja",
2693 minijinja::context! {
2694 assertion_type => "starts_with",
2695 field_expr => field_expr.clone(),
2696 cs_val => cs_val,
2697 },
2698 );
2699 out.push_str(&rendered);
2700 }
2701 }
2702 "ends_with" => {
2703 if let Some(expected) = &assertion.value {
2704 let cs_val = json_to_csharp(expected);
2705
2706 let rendered = crate::template_env::render(
2707 "csharp/assertion.jinja",
2708 minijinja::context! {
2709 assertion_type => "ends_with",
2710 field_expr => field_expr.clone(),
2711 cs_val => cs_val,
2712 },
2713 );
2714 out.push_str(&rendered);
2715 }
2716 }
2717 "min_length" => {
2718 if let Some(val) = &assertion.value {
2719 if let Some(n) = val.as_u64() {
2720 let rendered = crate::template_env::render(
2721 "csharp/assertion.jinja",
2722 minijinja::context! {
2723 assertion_type => "min_length",
2724 field_expr => field_expr.clone(),
2725 n => n,
2726 },
2727 );
2728 out.push_str(&rendered);
2729 }
2730 }
2731 }
2732 "max_length" => {
2733 if let Some(val) = &assertion.value {
2734 if let Some(n) = val.as_u64() {
2735 let rendered = crate::template_env::render(
2736 "csharp/assertion.jinja",
2737 minijinja::context! {
2738 assertion_type => "max_length",
2739 field_expr => field_expr.clone(),
2740 n => n,
2741 },
2742 );
2743 out.push_str(&rendered);
2744 }
2745 }
2746 }
2747 "count_min" => {
2748 if let Some(val) = &assertion.value {
2749 if let Some(n) = val.as_u64() {
2750 let rendered = crate::template_env::render(
2751 "csharp/assertion.jinja",
2752 minijinja::context! {
2753 assertion_type => "count_min",
2754 field_expr => field_expr.clone(),
2755 n => n,
2756 },
2757 );
2758 out.push_str(&rendered);
2759 }
2760 }
2761 }
2762 "count_equals" => {
2763 if let Some(val) = &assertion.value {
2764 if let Some(n) = val.as_u64() {
2765 let rendered = crate::template_env::render(
2766 "csharp/assertion.jinja",
2767 minijinja::context! {
2768 assertion_type => "count_equals",
2769 field_expr => field_expr.clone(),
2770 n => n,
2771 },
2772 );
2773 out.push_str(&rendered);
2774 }
2775 }
2776 }
2777 "is_true" => {
2778 let is_complex_or_object = field_expr.contains("(object)")
2783 || (field_expr.contains(".")
2784 && !result_is_simple
2785 && !field_expr.contains("?")
2786 && !field_expr.contains("=="));
2787
2788 let rendered = if is_complex_or_object {
2789 crate::template_env::render(
2790 "csharp/assertion.jinja",
2791 minijinja::context! {
2792 assertion_type => "not_empty",
2793 field_expr => field_expr.clone(),
2794 field_needs_json_serialize => false,
2795 },
2796 )
2797 } else {
2798 crate::template_env::render(
2799 "csharp/assertion.jinja",
2800 minijinja::context! {
2801 assertion_type => "is_true",
2802 field_expr => field_expr.clone(),
2803 },
2804 )
2805 };
2806 out.push_str(&rendered);
2807 }
2808 "is_false" => {
2809 let is_complex_or_object = field_expr.contains("(object)")
2810 || (field_expr.contains(".")
2811 && !result_is_simple
2812 && !field_expr.contains("?")
2813 && !field_expr.contains("=="));
2814
2815 let rendered = if is_complex_or_object {
2816 crate::template_env::render(
2818 "csharp/assertion.jinja",
2819 minijinja::context! {
2820 assertion_type => "is_empty",
2821 field_expr => field_expr.clone(),
2822 field_needs_json_serialize => false,
2823 },
2824 )
2825 } else {
2826 crate::template_env::render(
2827 "csharp/assertion.jinja",
2828 minijinja::context! {
2829 assertion_type => "is_false",
2830 field_expr => field_expr.clone(),
2831 },
2832 )
2833 };
2834 out.push_str(&rendered);
2835 }
2836 "not_error" => {
2837 let rendered = crate::template_env::render(
2839 "csharp/assertion.jinja",
2840 minijinja::context! {
2841 assertion_type => "not_error",
2842 },
2843 );
2844 out.push_str(&rendered);
2845 }
2846 "error" => {
2847 let rendered = crate::template_env::render(
2849 "csharp/assertion.jinja",
2850 minijinja::context! {
2851 assertion_type => "error",
2852 },
2853 );
2854 out.push_str(&rendered);
2855 }
2856 "method_result" => {
2857 if let Some(method_name) = &assertion.method {
2858 let call_expr = build_csharp_method_call(result_var, method_name, assertion.args.as_ref(), class_name);
2859 let check = assertion.check.as_deref().unwrap_or("is_true");
2860
2861 match check {
2862 "equals" => {
2863 if let Some(val) = &assertion.value {
2864 let is_check_bool_true = val.as_bool() == Some(true);
2865 let is_check_bool_false = val.as_bool() == Some(false);
2866 let cs_check_val = json_to_csharp(val);
2867
2868 let rendered = crate::template_env::render(
2869 "csharp/assertion.jinja",
2870 minijinja::context! {
2871 assertion_type => "method_result",
2872 check => "equals",
2873 call_expr => call_expr.clone(),
2874 is_check_bool_true => is_check_bool_true,
2875 is_check_bool_false => is_check_bool_false,
2876 cs_check_val => cs_check_val,
2877 },
2878 );
2879 out.push_str(&rendered);
2880 }
2881 }
2882 "is_true" => {
2883 let rendered = crate::template_env::render(
2884 "csharp/assertion.jinja",
2885 minijinja::context! {
2886 assertion_type => "method_result",
2887 check => "is_true",
2888 call_expr => call_expr.clone(),
2889 },
2890 );
2891 out.push_str(&rendered);
2892 }
2893 "is_false" => {
2894 let rendered = crate::template_env::render(
2895 "csharp/assertion.jinja",
2896 minijinja::context! {
2897 assertion_type => "method_result",
2898 check => "is_false",
2899 call_expr => call_expr.clone(),
2900 },
2901 );
2902 out.push_str(&rendered);
2903 }
2904 "greater_than_or_equal" => {
2905 if let Some(val) = &assertion.value {
2906 let check_n = val.as_u64().unwrap_or(0);
2907
2908 let rendered = crate::template_env::render(
2909 "csharp/assertion.jinja",
2910 minijinja::context! {
2911 assertion_type => "method_result",
2912 check => "greater_than_or_equal",
2913 call_expr => call_expr.clone(),
2914 check_n => check_n,
2915 },
2916 );
2917 out.push_str(&rendered);
2918 }
2919 }
2920 "count_min" => {
2921 if let Some(val) = &assertion.value {
2922 let check_n = val.as_u64().unwrap_or(0);
2923
2924 let rendered = crate::template_env::render(
2925 "csharp/assertion.jinja",
2926 minijinja::context! {
2927 assertion_type => "method_result",
2928 check => "count_min",
2929 call_expr => call_expr.clone(),
2930 check_n => check_n,
2931 },
2932 );
2933 out.push_str(&rendered);
2934 }
2935 }
2936 "is_error" => {
2937 let rendered = crate::template_env::render(
2938 "csharp/assertion.jinja",
2939 minijinja::context! {
2940 assertion_type => "method_result",
2941 check => "is_error",
2942 call_expr => call_expr.clone(),
2943 exception_class => exception_class,
2944 },
2945 );
2946 out.push_str(&rendered);
2947 }
2948 "contains" => {
2949 if let Some(val) = &assertion.value {
2950 let cs_check_val = json_to_csharp(val);
2951
2952 let rendered = crate::template_env::render(
2953 "csharp/assertion.jinja",
2954 minijinja::context! {
2955 assertion_type => "method_result",
2956 check => "contains",
2957 call_expr => call_expr.clone(),
2958 cs_check_val => cs_check_val,
2959 },
2960 );
2961 out.push_str(&rendered);
2962 }
2963 }
2964 other_check => {
2965 panic!("C# e2e generator: unsupported method_result check type: {other_check}");
2966 }
2967 }
2968 } else {
2969 panic!("C# e2e generator: method_result assertion missing 'method' field");
2970 }
2971 }
2972 "matches_regex" => {
2973 if let Some(expected) = &assertion.value {
2974 let cs_val = json_to_csharp(expected);
2975
2976 let rendered = crate::template_env::render(
2977 "csharp/assertion.jinja",
2978 minijinja::context! {
2979 assertion_type => "matches_regex",
2980 field_expr => field_expr.clone(),
2981 cs_val => cs_val,
2982 },
2983 );
2984 out.push_str(&rendered);
2985 }
2986 }
2987 other => {
2988 panic!("C# e2e generator: unsupported assertion type: {other}");
2989 }
2990 }
2991}
2992
2993fn sort_discriminator_first(value: serde_json::Value) -> serde_json::Value {
3000 match value {
3001 serde_json::Value::Object(map) => {
3002 let mut sorted = serde_json::Map::with_capacity(map.len());
3003 if let Some(type_val) = map.get("type") {
3005 sorted.insert("type".to_string(), sort_discriminator_first(type_val.clone()));
3006 }
3007 for (k, v) in map {
3008 if k != "type" {
3009 sorted.insert(k, sort_discriminator_first(v));
3010 }
3011 }
3012 serde_json::Value::Object(sorted)
3013 }
3014 serde_json::Value::Array(arr) => {
3015 serde_json::Value::Array(arr.into_iter().map(sort_discriminator_first).collect())
3016 }
3017 other => other,
3018 }
3019}
3020
3021fn render_sealed_display(
3024 type_name: &str,
3025 enum_def: &alef_core::ir::EnumDef,
3026 type_defs: &[alef_core::ir::TypeDef],
3027 namespace: &str,
3028) -> String {
3029 let header = hash::header(CommentStyle::DoubleSlash);
3030 let mut out = header;
3031 out.push_str(&format!("namespace {namespace}.E2e;\n\n"));
3032 out.push_str(&format!(
3033 "/// <summary>\n/// Helper class for extracting display strings from {type_name} sealed interface.\n /// </summary>\n"
3034 ));
3035 out.push_str(&format!("internal static class {type_name}Display\n"));
3036 out.push_str("{\n");
3037 out.push_str(&format!(
3038 " internal static string ToDisplayString({type_name}? value)\n"
3039 ));
3040 out.push_str(" {\n");
3041 out.push_str(" if (value == null) return \"\";\n");
3042 out.push_str(" return value switch\n");
3043 out.push_str(" {\n");
3044
3045 for variant in &enum_def.variants {
3046 let variant_name = &variant.name;
3047 let has_format_field = variant.is_tuple && variant.fields.len() == 1 && {
3052 let field_type_name = match &variant.fields[0].ty {
3053 alef_core::ir::TypeRef::Named(n) => Some(n.as_str()),
3054 _ => None,
3055 };
3056 field_type_name.is_some_and(|tn| {
3057 type_defs
3058 .iter()
3059 .find(|td| td.name == tn)
3060 .is_some_and(|td| td.fields.iter().any(|f| f.name == "format"))
3061 })
3062 };
3063
3064 let display = if has_format_field {
3065 "i.Value.Format".to_string()
3066 } else {
3067 let serde_name = variant
3069 .serde_rename
3070 .as_deref()
3071 .unwrap_or(variant_name.as_str())
3072 .to_lowercase();
3073 format!("\"{serde_name}\"")
3074 };
3075
3076 let binding = if has_format_field {
3077 format!("{type_name}.{variant_name} i")
3078 } else {
3079 format!("{type_name}.{variant_name}")
3080 };
3081
3082 out.push_str(&format!(" {binding} => {display},\n"));
3083 }
3084
3085 out.push_str(" _ => \"unknown\",\n");
3086 out.push_str(" };\n");
3087 out.push_str(" }\n");
3088 out.push_str("}\n");
3089 out
3090}
3091
3092fn json_to_csharp(value: &serde_json::Value) -> String {
3094 match value {
3095 serde_json::Value::String(s) => format!("\"{}\"", escape_csharp(s)),
3096 serde_json::Value::Bool(true) => "true".to_string(),
3097 serde_json::Value::Bool(false) => "false".to_string(),
3098 serde_json::Value::Number(n) => {
3099 if n.is_f64() {
3100 format!("{}d", n)
3101 } else {
3102 n.to_string()
3103 }
3104 }
3105 serde_json::Value::Null => "null".to_string(),
3106 serde_json::Value::Array(arr) => {
3107 let items: Vec<String> = arr.iter().map(json_to_csharp).collect();
3108 format!("new[] {{ {} }}", items.join(", "))
3109 }
3110 serde_json::Value::Object(_) => {
3111 let json_str = serde_json::to_string(value).unwrap_or_default();
3112 format!("\"{}\"", escape_csharp(&json_str))
3113 }
3114 }
3115}
3116
3117fn csharp_object_initializer(
3125 obj: &serde_json::Map<String, serde_json::Value>,
3126 type_name: &str,
3127 enum_fields: &HashMap<String, String>,
3128 nested_types: &HashMap<String, String>,
3129) -> String {
3130 if obj.is_empty() {
3131 return format!("new {type_name}()");
3132 }
3133
3134 static IMPLICIT_ENUM_FIELDS: &[(&str, &str)] = &[("output_format", "OutputFormat")];
3137
3138 let props: Vec<String> = obj
3139 .iter()
3140 .map(|(key, val)| {
3141 let pascal_key = key.to_upper_camel_case();
3142 let implicit_enum_type = IMPLICIT_ENUM_FIELDS
3143 .iter()
3144 .find(|(k, _)| *k == key.as_str())
3145 .map(|(_, t)| *t);
3146 let camel_key = key.to_lower_camel_case();
3150 let cs_val = if let Some(enum_type) = enum_fields
3151 .get(key.as_str())
3152 .or_else(|| enum_fields.get(camel_key.as_str()))
3153 .map(String::as_str)
3154 .or(implicit_enum_type)
3155 {
3156 if val.is_null() {
3158 "null".to_string()
3159 } else {
3160 let member = val
3161 .as_str()
3162 .map(|s| s.to_upper_camel_case())
3163 .unwrap_or_else(|| "null".to_string());
3164 format!("{enum_type}.{member}")
3165 }
3166 } else if let Some(nested_type) = nested_types
3167 .get(key.as_str())
3168 .or_else(|| nested_types.get(camel_key.as_str()))
3169 {
3170 let normalized = normalize_csharp_enum_values(val, enum_fields);
3173 let json_str = serde_json::to_string(&normalized).unwrap_or_default();
3174 let escaped = escape_csharp(&json_str);
3175 format!("JsonSerializer.Deserialize<{nested_type}>(\"{escaped}\", ConfigOptions)!")
3176 } else if let Some(arr) = val.as_array() {
3177 let items: Vec<String> = arr.iter().map(json_to_csharp).collect();
3179 format!("new List<string> {{ {} }}", items.join(", "))
3180 } else {
3181 json_to_csharp(val)
3182 };
3183 format!("{pascal_key} = {cs_val}")
3184 })
3185 .collect();
3186 format!("new {} {{ {} }}", type_name, props.join(", "))
3187}
3188
3189fn normalize_csharp_enum_values(value: &serde_json::Value, enum_fields: &HashMap<String, String>) -> serde_json::Value {
3194 match value {
3195 serde_json::Value::Object(map) => {
3196 let mut result = map.clone();
3197 for (key, val) in result.iter_mut() {
3198 let camel_key = key.to_lower_camel_case();
3201 if enum_fields.contains_key(key) || enum_fields.contains_key(camel_key.as_str()) {
3202 if let Some(s) = val.as_str() {
3204 *val = serde_json::Value::String(s.to_lowercase());
3205 }
3206 }
3207 }
3208 serde_json::Value::Object(result)
3209 }
3210 other => other.clone(),
3211 }
3212}
3213
3214fn build_csharp_visitor(
3225 setup_lines: &mut Vec<String>,
3226 class_decls: &mut Vec<String>,
3227 fixture_id: &str,
3228 visitor_spec: &crate::fixture::VisitorSpec,
3229) -> String {
3230 use heck::ToUpperCamelCase;
3231 let class_name = format!("{}Visitor", fixture_id.to_upper_camel_case());
3232 let var_name = format!("_visitor_{}", fixture_id.replace('-', "_"));
3233
3234 setup_lines.push(format!("var {var_name} = new {class_name}();"));
3235
3236 let mut decl = String::new();
3238 decl.push_str(&format!(" private sealed class {class_name} : IHtmlVisitor\n"));
3239 decl.push_str(" {\n");
3240
3241 let all_methods = [
3243 "visit_element_start",
3244 "visit_element_end",
3245 "visit_text",
3246 "visit_link",
3247 "visit_image",
3248 "visit_heading",
3249 "visit_code_block",
3250 "visit_code_inline",
3251 "visit_list_item",
3252 "visit_list_start",
3253 "visit_list_end",
3254 "visit_table_start",
3255 "visit_table_row",
3256 "visit_table_end",
3257 "visit_blockquote",
3258 "visit_strong",
3259 "visit_emphasis",
3260 "visit_strikethrough",
3261 "visit_underline",
3262 "visit_subscript",
3263 "visit_superscript",
3264 "visit_mark",
3265 "visit_line_break",
3266 "visit_horizontal_rule",
3267 "visit_custom_element",
3268 "visit_definition_list_start",
3269 "visit_definition_term",
3270 "visit_definition_description",
3271 "visit_definition_list_end",
3272 "visit_form",
3273 "visit_input",
3274 "visit_button",
3275 "visit_audio",
3276 "visit_video",
3277 "visit_iframe",
3278 "visit_details",
3279 "visit_summary",
3280 "visit_figure_start",
3281 "visit_figcaption",
3282 "visit_figure_end",
3283 ];
3284
3285 for method_name in &all_methods {
3287 if let Some(action) = visitor_spec.callbacks.get(*method_name) {
3288 emit_csharp_visitor_method(&mut decl, method_name, action);
3289 } else {
3290 emit_csharp_visitor_method(&mut decl, method_name, &CallbackAction::Continue);
3292 }
3293 }
3294
3295 decl.push_str(" }\n");
3296 class_decls.push(decl);
3297
3298 var_name
3299}
3300
3301fn emit_csharp_visitor_method(decl: &mut String, method_name: &str, action: &CallbackAction) {
3303 let camel_method = method_to_camel(method_name);
3304 let params = match method_name {
3305 "visit_link" => "NodeContext ctx, string href, string text, string title",
3306 "visit_image" => "NodeContext ctx, string src, string alt, string title",
3307 "visit_heading" => "NodeContext ctx, uint level, string text, string id",
3308 "visit_code_block" => "NodeContext ctx, string lang, string code",
3309 "visit_code_inline"
3310 | "visit_strong"
3311 | "visit_emphasis"
3312 | "visit_strikethrough"
3313 | "visit_underline"
3314 | "visit_subscript"
3315 | "visit_superscript"
3316 | "visit_mark"
3317 | "visit_button"
3318 | "visit_summary"
3319 | "visit_figcaption"
3320 | "visit_definition_term"
3321 | "visit_definition_description" => "NodeContext ctx, string text",
3322 "visit_text" => "NodeContext ctx, string text",
3323 "visit_list_item" => "NodeContext ctx, bool ordered, string marker, string text",
3324 "visit_blockquote" => "NodeContext ctx, string content, ulong depth",
3325 "visit_table_row" => "NodeContext ctx, List<string> cells, bool isHeader",
3326 "visit_custom_element" => "NodeContext ctx, string tagName, string html",
3327 "visit_form" => "NodeContext ctx, string actionUrl, string method",
3328 "visit_input" => "NodeContext ctx, string inputType, string name, string value",
3329 "visit_audio" | "visit_video" | "visit_iframe" => "NodeContext ctx, string src",
3330 "visit_details" => "NodeContext ctx, bool isOpen",
3331 "visit_element_end" | "visit_table_end" | "visit_definition_list_end" | "visit_figure_end" => {
3332 "NodeContext ctx, string output"
3333 }
3334 "visit_list_start" => "NodeContext ctx, bool ordered",
3335 "visit_list_end" => "NodeContext ctx, bool ordered, string output",
3336 "visit_element_start"
3337 | "visit_table_start"
3338 | "visit_definition_list_start"
3339 | "visit_figure_start"
3340 | "visit_line_break"
3341 | "visit_horizontal_rule" => "NodeContext ctx",
3342 _ => "NodeContext ctx",
3343 };
3344
3345 let (action_type, action_value) = match action {
3346 CallbackAction::Skip => ("skip", String::new()),
3347 CallbackAction::Continue => ("continue", String::new()),
3348 CallbackAction::PreserveHtml => ("preserve_html", String::new()),
3349 CallbackAction::Custom { output } => ("custom", escape_csharp(output)),
3350 CallbackAction::CustomTemplate { template, .. } => {
3351 let camel = snake_case_template_to_camel(template);
3352 ("custom_template", escape_csharp(&camel))
3353 }
3354 };
3355
3356 let rendered = crate::template_env::render(
3357 "csharp/visitor_method.jinja",
3358 minijinja::context! {
3359 camel_method => camel_method,
3360 params => params,
3361 action_type => action_type,
3362 action_value => action_value,
3363 },
3364 );
3365 let _ = write!(decl, "{}", rendered);
3366}
3367
3368fn method_to_camel(snake: &str) -> String {
3370 use heck::ToUpperCamelCase;
3371 snake.to_upper_camel_case()
3372}
3373
3374fn snake_case_template_to_camel(template: &str) -> String {
3377 use heck::ToLowerCamelCase;
3378 let mut out = String::with_capacity(template.len());
3379 let mut chars = template.chars().peekable();
3380 while let Some(c) = chars.next() {
3381 if c == '{' {
3382 let mut name = String::new();
3383 while let Some(&nc) = chars.peek() {
3384 if nc == '}' {
3385 chars.next();
3386 break;
3387 }
3388 name.push(nc);
3389 chars.next();
3390 }
3391 out.push('{');
3392 out.push_str(&name.to_lower_camel_case());
3393 out.push('}');
3394 } else {
3395 out.push(c);
3396 }
3397 }
3398 out
3399}
3400
3401fn build_csharp_method_call(
3406 result_var: &str,
3407 method_name: &str,
3408 args: Option<&serde_json::Value>,
3409 class_name: &str,
3410) -> String {
3411 match method_name {
3412 "root_child_count" => format!("{result_var}.RootNode.ChildCount"),
3413 "root_node_type" => format!("{result_var}.RootNode.Kind"),
3414 "named_children_count" => format!("{result_var}.RootNode.NamedChildCount"),
3415 "has_error_nodes" => format!("{class_name}.TreeHasErrorNodes({result_var})"),
3416 "error_count" | "tree_error_count" => format!("{class_name}.TreeErrorCount({result_var})"),
3417 "tree_to_sexp" => format!("{class_name}.TreeToSexp({result_var})"),
3418 "contains_node_type" => {
3419 let node_type = args
3420 .and_then(|a| a.get("node_type"))
3421 .and_then(|v| v.as_str())
3422 .unwrap_or("");
3423 format!("{class_name}.TreeContainsNodeType({result_var}, \"{node_type}\")")
3424 }
3425 "find_nodes_by_type" => {
3426 let node_type = args
3427 .and_then(|a| a.get("node_type"))
3428 .and_then(|v| v.as_str())
3429 .unwrap_or("");
3430 format!("{class_name}.FindNodesByType({result_var}, \"{node_type}\")")
3431 }
3432 "run_query" => {
3433 let query_source = args
3434 .and_then(|a| a.get("query_source"))
3435 .and_then(|v| v.as_str())
3436 .unwrap_or("");
3437 let language = args
3438 .and_then(|a| a.get("language"))
3439 .and_then(|v| v.as_str())
3440 .unwrap_or("");
3441 format!("{class_name}.RunQuery({result_var}, \"{language}\", \"{query_source}\", source)")
3442 }
3443 _ => {
3444 use heck::ToUpperCamelCase;
3445 let pascal = method_name.to_upper_camel_case();
3446 format!("{result_var}.{pascal}()")
3447 }
3448 }
3449}
3450
3451fn fixture_has_csharp_callable(fixture: &Fixture, e2e_config: &E2eConfig) -> bool {
3452 if fixture.is_http_test() {
3454 return false;
3455 }
3456 let call_config = e2e_config.resolve_call_for_fixture(
3458 fixture.call.as_deref(),
3459 &fixture.id,
3460 &fixture.resolved_category(),
3461 &fixture.tags,
3462 &fixture.input,
3463 );
3464 let cs_override = call_config
3465 .overrides
3466 .get("csharp")
3467 .or_else(|| e2e_config.call.overrides.get("csharp"));
3468 if cs_override.and_then(|o| o.client_factory.as_deref()).is_some() {
3470 return true;
3471 }
3472 cs_override.and_then(|o| o.function.as_deref()).is_some() || !call_config.function.is_empty()
3475}
3476
3477fn classify_bytes_value_csharp(s: &str) -> String {
3480 if let Some(first) = s.chars().next() {
3483 if first.is_ascii_alphanumeric() || first == '_' {
3484 if let Some(slash_pos) = s.find('/') {
3485 if slash_pos > 0 {
3486 let after_slash = &s[slash_pos + 1..];
3487 if after_slash.contains('.') && !after_slash.is_empty() {
3488 return format!("System.IO.File.ReadAllBytes(\"{}\")", s);
3490 }
3491 }
3492 }
3493 }
3494 }
3495
3496 if s.starts_with('<') || s.starts_with('{') || s.starts_with('[') || s.contains(' ') {
3499 return format!("System.Text.Encoding.UTF8.GetBytes(\"{}\")", escape_csharp(s));
3501 }
3502
3503 format!("System.Convert.FromBase64String(\"{}\")", s)
3507}
3508
3509#[cfg(test)]
3510mod tests {
3511 use crate::config::{CallConfig, E2eConfig, SelectWhen};
3512 use crate::fixture::Fixture;
3513 use std::collections::HashMap;
3514
3515 fn make_fixture_with_input(id: &str, input: serde_json::Value) -> Fixture {
3516 Fixture {
3517 id: id.to_string(),
3518 category: None,
3519 description: "test fixture".to_string(),
3520 tags: vec![],
3521 skip: None,
3522 env: None,
3523 call: None,
3524 input,
3525 mock_response: None,
3526 source: String::new(),
3527 http: None,
3528 assertions: vec![],
3529 visitor: None,
3530 }
3531 }
3532
3533 #[test]
3536 fn test_csharp_select_when_routes_to_batch_scrape() {
3537 let mut calls = HashMap::new();
3538 calls.insert(
3539 "batch_scrape".to_string(),
3540 CallConfig {
3541 function: "BatchScrape".to_string(),
3542 module: "KreuzBrowser".to_string(),
3543 select_when: Some(SelectWhen {
3544 input_has: Some("batch_urls".to_string()),
3545 ..Default::default()
3546 }),
3547 ..CallConfig::default()
3548 },
3549 );
3550
3551 let e2e_config = E2eConfig {
3552 call: CallConfig {
3553 function: "Scrape".to_string(),
3554 module: "KreuzBrowser".to_string(),
3555 ..CallConfig::default()
3556 },
3557 calls,
3558 ..E2eConfig::default()
3559 };
3560
3561 let fixture = make_fixture_with_input("batch_empty_urls", serde_json::json!({ "batch_urls": [] }));
3563
3564 let resolved_call = e2e_config.resolve_call_for_fixture(
3565 fixture.call.as_deref(),
3566 &fixture.id,
3567 &fixture.resolved_category(),
3568 &fixture.tags,
3569 &fixture.input,
3570 );
3571 assert_eq!(resolved_call.function, "BatchScrape");
3572
3573 let fixture_no_batch =
3575 make_fixture_with_input("simple_scrape", serde_json::json!({ "url": "https://example.com" }));
3576 let resolved_default = e2e_config.resolve_call_for_fixture(
3577 fixture_no_batch.call.as_deref(),
3578 &fixture_no_batch.id,
3579 &fixture_no_batch.resolved_category(),
3580 &fixture_no_batch.tags,
3581 &fixture_no_batch.input,
3582 );
3583 assert_eq!(resolved_default.function, "Scrape");
3584 }
3585}