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