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 tests_base = output_base.join("tests");
122 let field_resolver = FieldResolver::new(
123 &e2e_config.fields,
124 &e2e_config.fields_optional,
125 &e2e_config.result_fields,
126 &e2e_config.fields_array,
127 &std::collections::HashSet::new(),
128 );
129
130 static EMPTY_ENUM_FIELDS: std::sync::LazyLock<HashMap<String, String>> = std::sync::LazyLock::new(HashMap::new);
132 let enum_fields = overrides.map(|o| &o.enum_fields).unwrap_or(&EMPTY_ENUM_FIELDS);
133
134 let mut effective_nested_types: HashMap<String, String> = HashMap::new();
136 if let Some(overrides_map) = overrides.map(|o| &o.nested_types) {
137 effective_nested_types.extend(overrides_map.clone());
138 }
139
140 for group in groups {
141 let active: Vec<&Fixture> = group
142 .fixtures
143 .iter()
144 .filter(|f| super::should_include_fixture(f, lang, e2e_config))
145 .collect();
146
147 if active.is_empty() {
148 continue;
149 }
150
151 let test_class = format!("{}Tests", sanitize_filename(&group.category).to_upper_camel_case());
152 let filename = format!("{test_class}.cs");
153 let content = render_test_file(
154 &group.category,
155 &active,
156 &namespace,
157 &class_name,
158 &function_name,
159 &exception_class,
160 result_var,
161 &test_class,
162 &e2e_config.call.args,
163 &field_resolver,
164 result_is_simple,
165 is_async,
166 e2e_config,
167 enum_fields,
168 &effective_nested_types,
169 &config.adapters,
170 );
171 files.push(GeneratedFile {
172 path: tests_base.join(filename),
173 content,
174 generated_header: true,
175 });
176 }
177
178 Ok(files)
179 }
180
181 fn language_name(&self) -> &'static str {
182 "csharp"
183 }
184}
185
186fn render_csproj(pkg_name: &str, pkg_path: &str, pkg_version: &str, dep_mode: crate::config::DependencyMode) -> String {
191 let pkg_ref = match dep_mode {
192 crate::config::DependencyMode::Registry => {
193 format!(" <PackageReference Include=\"{pkg_name}\" Version=\"{pkg_version}\" />")
194 }
195 crate::config::DependencyMode::Local => {
196 format!(" <ProjectReference Include=\"{pkg_path}\" />")
197 }
198 };
199 crate::template_env::render(
200 "csharp/csproj.jinja",
201 minijinja::context! {
202 pkg_ref => pkg_ref,
203 microsoft_net_test_sdk_version => tv::nuget::MICROSOFT_NET_TEST_SDK,
204 xunit_version => tv::nuget::XUNIT,
205 xunit_runner_version => tv::nuget::XUNIT_RUNNER_VISUALSTUDIO,
206 },
207 )
208}
209
210fn render_test_setup(needs_mock_server: bool, test_documents_dir: &str, namespace: &str) -> String {
211 let mut out = String::new();
212 out.push_str(&hash::header(CommentStyle::DoubleSlash));
213 out.push_str("using System;\n");
214 out.push_str("using System.IO;\n");
215 if needs_mock_server {
216 out.push_str("using System.Diagnostics;\n");
217 }
218 out.push_str("using System.Runtime.CompilerServices;\n\n");
219 let _ = writeln!(out, "namespace {namespace};\n");
220 out.push_str("internal static class TestSetup\n");
221 out.push_str("{\n");
222 if needs_mock_server {
223 out.push_str(" private static Process? _mockServer;\n\n");
224 }
225 out.push_str(" [ModuleInitializer]\n");
226 out.push_str(" internal static void Init()\n");
227 out.push_str(" {\n");
228 let _ = writeln!(
229 out,
230 " // Walk up from the assembly directory until we find the repo root."
231 );
232 let _ = writeln!(
233 out,
234 " // Prefer a sibling {test_documents_dir}/ directory (chdir into it so that"
235 );
236 out.push_str(" // fixture paths like \"docx/fake.docx\" resolve relative to it). If that\n");
237 out.push_str(" // is absent (web-crawler-style repos with no document fixtures), fall\n");
238 out.push_str(" // back to a sibling alef.toml or fixtures/ marker as the repo root.\n");
239 out.push_str(" var dir = new DirectoryInfo(AppContext.BaseDirectory);\n");
240 out.push_str(" DirectoryInfo? repoRoot = null;\n");
241 out.push_str(" while (dir != null)\n");
242 out.push_str(" {\n");
243 let _ = writeln!(
244 out,
245 " var documentsCandidate = Path.Combine(dir.FullName, \"{test_documents_dir}\");"
246 );
247 out.push_str(" if (Directory.Exists(documentsCandidate))\n");
248 out.push_str(" {\n");
249 out.push_str(" repoRoot = dir;\n");
250 out.push_str(" Directory.SetCurrentDirectory(documentsCandidate);\n");
251 out.push_str(" break;\n");
252 out.push_str(" }\n");
253 out.push_str(" if (File.Exists(Path.Combine(dir.FullName, \"alef.toml\"))\n");
254 out.push_str(" || Directory.Exists(Path.Combine(dir.FullName, \"fixtures\")))\n");
255 out.push_str(" {\n");
256 out.push_str(" repoRoot = dir;\n");
257 out.push_str(" break;\n");
258 out.push_str(" }\n");
259 out.push_str(" dir = dir.Parent;\n");
260 out.push_str(" }\n");
261 if needs_mock_server {
262 out.push('\n');
263 out.push_str(" // Spawn the mock-server binary before any test loads, mirroring the\n");
264 out.push_str(" // Ruby spec_helper / Python conftest pattern. Honors a pre-set\n");
265 out.push_str(" // MOCK_SERVER_URL (e.g. set by `task` or CI) by skipping the spawn.\n");
266 out.push_str(" // Without this, every fixture-bound test failed with\n");
267 out.push_str(" // `<Lib>Exception : builder error` because reqwest rejected the\n");
268 out.push_str(" // relative URL produced by `\"\" + \"/fixtures/<id>\"`.\n");
269 out.push_str(" var preset = Environment.GetEnvironmentVariable(\"MOCK_SERVER_URL\");\n");
270 out.push_str(" if (!string.IsNullOrEmpty(preset))\n");
271 out.push_str(" {\n");
272 out.push_str(" return;\n");
273 out.push_str(" }\n");
274 out.push_str(" if (repoRoot == null)\n");
275 out.push_str(" {\n");
276 let _ = writeln!(
277 out,
278 " throw new InvalidOperationException(\"TestSetup: could not locate repo root ({test_documents_dir}/, alef.toml, or fixtures/ not found in any ancestor of \" + AppContext.BaseDirectory + \")\");"
279 );
280 out.push_str(" }\n");
281 out.push_str(" var bin = Path.Combine(\n");
282 out.push_str(" repoRoot.FullName,\n");
283 out.push_str(" \"e2e\", \"rust\", \"target\", \"release\", \"mock-server\");\n");
284 out.push_str(" if (OperatingSystem.IsWindows())\n");
285 out.push_str(" {\n");
286 out.push_str(" bin += \".exe\";\n");
287 out.push_str(" }\n");
288 out.push_str(" var fixturesDir = Path.Combine(repoRoot.FullName, \"fixtures\");\n");
289 out.push_str(" if (!File.Exists(bin))\n");
290 out.push_str(" {\n");
291 out.push_str(" throw new InvalidOperationException(\n");
292 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");
293 out.push_str(" }\n");
294 out.push_str(" var psi = new ProcessStartInfo\n");
295 out.push_str(" {\n");
296 out.push_str(" FileName = bin,\n");
297 out.push_str(" Arguments = $\"\\\"{fixturesDir}\\\"\",\n");
298 out.push_str(" RedirectStandardInput = true,\n");
299 out.push_str(" RedirectStandardOutput = true,\n");
300 out.push_str(" RedirectStandardError = true,\n");
301 out.push_str(" UseShellExecute = false,\n");
302 out.push_str(" };\n");
303 out.push_str(" _mockServer = Process.Start(psi)\n");
304 out.push_str(
305 " ?? throw new InvalidOperationException(\"TestSetup: failed to start mock-server\");\n",
306 );
307 out.push_str(" // The mock-server prints MOCK_SERVER_URL=<url>, then optionally\n");
308 out.push_str(" // MOCK_SERVERS={...} for host-root fixtures. Read up to 16 lines.\n");
309 out.push_str(" string? url = null;\n");
310 out.push_str(" for (int i = 0; i < 16; i++)\n");
311 out.push_str(" {\n");
312 out.push_str(" var line = _mockServer.StandardOutput.ReadLine();\n");
313 out.push_str(" if (line == null)\n");
314 out.push_str(" {\n");
315 out.push_str(" break;\n");
316 out.push_str(" }\n");
317 out.push_str(" const string urlPrefix = \"MOCK_SERVER_URL=\";\n");
318 out.push_str(" const string serversPrefix = \"MOCK_SERVERS=\";\n");
319 out.push_str(" if (line.StartsWith(urlPrefix, StringComparison.Ordinal))\n");
320 out.push_str(" {\n");
321 out.push_str(" url = line.Substring(urlPrefix.Length).Trim();\n");
322 out.push_str(" }\n");
323 out.push_str(" else if (line.StartsWith(serversPrefix, StringComparison.Ordinal))\n");
324 out.push_str(" {\n");
325 out.push_str(" var jsonVal = line.Substring(serversPrefix.Length).Trim();\n");
326 out.push_str(" Environment.SetEnvironmentVariable(\"MOCK_SERVERS\", jsonVal);\n");
327 out.push_str(" // Parse JSON map and set per-fixture env vars (MOCK_SERVER_<FIXTURE_ID>).\n");
328 out.push_str(" var matches = System.Text.RegularExpressions.Regex.Matches(\n");
329 out.push_str(" jsonVal, \"\\\"([^\\\"]+)\\\":\\\"([^\\\"]+)\\\"\");\n");
330 out.push_str(" foreach (System.Text.RegularExpressions.Match m in matches)\n");
331 out.push_str(" {\n");
332 out.push_str(" Environment.SetEnvironmentVariable(\n");
333 out.push_str(" \"MOCK_SERVER_\" + m.Groups[1].Value.ToUpperInvariant(),\n");
334 out.push_str(" m.Groups[2].Value);\n");
335 out.push_str(" }\n");
336 out.push_str(" break;\n");
337 out.push_str(" }\n");
338 out.push_str(" else if (url != null)\n");
339 out.push_str(" {\n");
340 out.push_str(" break;\n");
341 out.push_str(" }\n");
342 out.push_str(" }\n");
343 out.push_str(" if (string.IsNullOrEmpty(url))\n");
344 out.push_str(" {\n");
345 out.push_str(" try { _mockServer.Kill(true); } catch { }\n");
346 out.push_str(" throw new InvalidOperationException(\"TestSetup: mock-server did not emit MOCK_SERVER_URL\");\n");
347 out.push_str(" }\n");
348 out.push_str(" Environment.SetEnvironmentVariable(\"MOCK_SERVER_URL\", url);\n");
349 out.push_str(" // TCP-readiness probe: ensure axum::serve is accepting before tests start.\n");
350 out.push_str(" // The mock-server binds the TcpListener synchronously then prints the URL\n");
351 out.push_str(" // before tokio::spawn(axum::serve(...)) is polled, so under xUnit\n");
352 out.push_str(" // class-parallel default tests can race startup. Poll-connect (max 5s,\n");
353 out.push_str(" // 50ms backoff) until success.\n");
354 out.push_str(" var healthUri = new System.Uri(url);\n");
355 out.push_str(" var deadline = System.Diagnostics.Stopwatch.StartNew();\n");
356 out.push_str(" while (deadline.ElapsedMilliseconds < 5000)\n");
357 out.push_str(" {\n");
358 out.push_str(" try\n");
359 out.push_str(" {\n");
360 out.push_str(" using var probe = new System.Net.Sockets.TcpClient();\n");
361 out.push_str(" var task = probe.ConnectAsync(healthUri.Host, healthUri.Port);\n");
362 out.push_str(" if (task.Wait(100) && probe.Connected) { break; }\n");
363 out.push_str(" }\n");
364 out.push_str(" catch (System.Exception) { }\n");
365 out.push_str(" System.Threading.Thread.Sleep(50);\n");
366 out.push_str(" }\n");
367 out.push_str(" // Drain stdout/stderr so the child does not block on a full pipe.\n");
368 out.push_str(" var server = _mockServer;\n");
369 out.push_str(" var stdoutThread = new System.Threading.Thread(() =>\n");
370 out.push_str(" {\n");
371 out.push_str(" try { server.StandardOutput.ReadToEnd(); } catch { }\n");
372 out.push_str(" }) { IsBackground = true };\n");
373 out.push_str(" stdoutThread.Start();\n");
374 out.push_str(" var stderrThread = new System.Threading.Thread(() =>\n");
375 out.push_str(" {\n");
376 out.push_str(" try { server.StandardError.ReadToEnd(); } catch { }\n");
377 out.push_str(" }) { IsBackground = true };\n");
378 out.push_str(" stderrThread.Start();\n");
379 out.push_str(" // Tear the child down on assembly unload / process exit by closing\n");
380 out.push_str(" // its stdin (the mock-server treats stdin EOF as a shutdown signal).\n");
381 out.push_str(" AppDomain.CurrentDomain.ProcessExit += (_, _) =>\n");
382 out.push_str(" {\n");
383 out.push_str(" try { _mockServer.StandardInput.Close(); } catch { }\n");
384 out.push_str(" try { if (!_mockServer.WaitForExit(2000)) { _mockServer.Kill(true); } } catch { }\n");
385 out.push_str(" };\n");
386 }
387 out.push_str(" }\n");
388 out.push_str("}\n");
389 out
390}
391
392#[allow(clippy::too_many_arguments)]
393fn render_test_file(
394 category: &str,
395 fixtures: &[&Fixture],
396 namespace: &str,
397 class_name: &str,
398 function_name: &str,
399 exception_class: &str,
400 result_var: &str,
401 test_class: &str,
402 args: &[crate::config::ArgMapping],
403 field_resolver: &FieldResolver,
404 result_is_simple: bool,
405 is_async: bool,
406 e2e_config: &E2eConfig,
407 enum_fields: &HashMap<String, String>,
408 nested_types: &HashMap<String, String>,
409 adapters: &[alef_core::config::extras::AdapterConfig],
410) -> String {
411 let mut using_imports = String::new();
413 using_imports.push_str("using System;\n");
414 using_imports.push_str("using System.Collections.Generic;\n");
415 using_imports.push_str("using System.Linq;\n");
416 using_imports.push_str("using System.Net.Http;\n");
417 using_imports.push_str("using System.Text;\n");
418 using_imports.push_str("using System.Text.Json;\n");
419 using_imports.push_str("using System.Text.Json.Serialization;\n");
420 using_imports.push_str("using System.Threading.Tasks;\n");
421 using_imports.push_str("using Xunit;\n");
422 using_imports.push_str(&format!("using {namespace};\n"));
423 using_imports.push_str(&format!("using static {namespace}.{class_name};\n"));
424
425 let config_options_field = " private static readonly JsonSerializerOptions ConfigOptions = new() { Converters = { new JsonStringEnumConverter(JsonNamingPolicy.SnakeCaseLower) }, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingDefault };";
427
428 let mut visitor_class_decls: Vec<String> = Vec::new();
432
433 let mut fixtures_body = String::new();
435 for (i, fixture) in fixtures.iter().enumerate() {
436 render_test_method(
437 &mut fixtures_body,
438 &mut visitor_class_decls,
439 fixture,
440 class_name,
441 function_name,
442 exception_class,
443 result_var,
444 args,
445 field_resolver,
446 result_is_simple,
447 is_async,
448 e2e_config,
449 enum_fields,
450 nested_types,
451 adapters,
452 );
453 if i + 1 < fixtures.len() {
454 fixtures_body.push('\n');
455 }
456 }
457
458 let mut visitor_classes_str = String::new();
460 for (i, decl) in visitor_class_decls.iter().enumerate() {
461 if i > 0 {
462 visitor_classes_str.push('\n');
463 }
464 visitor_classes_str.push('\n');
465 for line in decl.lines() {
467 visitor_classes_str.push_str(" ");
468 visitor_classes_str.push_str(line);
469 visitor_classes_str.push('\n');
470 }
471 }
472
473 let ctx = minijinja::context! {
474 header => hash::header(CommentStyle::DoubleSlash),
475 using_imports => using_imports,
476 category => category,
477 namespace => namespace,
478 test_class => test_class,
479 config_options_field => config_options_field,
480 fixtures_body => fixtures_body,
481 visitor_class_decls => visitor_classes_str,
482 };
483
484 crate::template_env::render("csharp/test_file.jinja", ctx)
485}
486
487struct CSharpTestClientRenderer;
496
497fn to_csharp_http_method(method: &str) -> String {
499 let lower = method.to_ascii_lowercase();
500 let mut chars = lower.chars();
501 match chars.next() {
502 Some(c) => c.to_ascii_uppercase().to_string() + chars.as_str(),
503 None => String::new(),
504 }
505}
506
507const CSHARP_RESTRICTED_REQUEST_HEADERS: &[&str] = &[
511 "content-length",
512 "host",
513 "connection",
514 "expect",
515 "transfer-encoding",
516 "upgrade",
517 "content-type",
520 "content-encoding",
522 "content-language",
523 "content-location",
524 "content-md5",
525 "content-range",
526 "content-disposition",
527];
528
529fn is_csharp_content_header(name: &str) -> bool {
533 matches!(
534 name.to_ascii_lowercase().as_str(),
535 "content-type"
536 | "content-length"
537 | "content-encoding"
538 | "content-language"
539 | "content-location"
540 | "content-md5"
541 | "content-range"
542 | "content-disposition"
543 | "expires"
544 | "last-modified"
545 | "allow"
546 )
547}
548
549impl client::TestClientRenderer for CSharpTestClientRenderer {
550 fn language_name(&self) -> &'static str {
551 "csharp"
552 }
553
554 fn sanitize_test_name(&self, id: &str) -> String {
556 id.to_upper_camel_case()
557 }
558
559 fn render_test_open(&self, out: &mut String, fn_name: &str, description: &str, skip_reason: Option<&str>) {
562 let escaped_reason = skip_reason.map(escape_csharp);
563 let rendered = crate::template_env::render(
564 "csharp/http_test_open.jinja",
565 minijinja::context! {
566 fn_name => fn_name,
567 description => description,
568 skip_reason => escaped_reason,
569 },
570 );
571 out.push_str(&rendered);
572 }
573
574 fn render_test_close(&self, out: &mut String) {
576 let rendered = crate::template_env::render("csharp/http_test_close.jinja", minijinja::context! {});
577 out.push_str(&rendered);
578 }
579
580 fn render_call(&self, out: &mut String, ctx: &client::CallCtx<'_>) {
585 let method = to_csharp_http_method(ctx.method);
586 let path = escape_csharp(ctx.path);
587
588 out.push_str(" var baseUrl = Environment.GetEnvironmentVariable(\"MOCK_SERVER_URL\") ?? \"http://localhost:8080\";\n");
589 out.push_str(
592 " using var handler = new System.Net.Http.HttpClientHandler { AllowAutoRedirect = false };\n",
593 );
594 out.push_str(" using var client = new System.Net.Http.HttpClient(handler);\n");
595 out.push_str(&format!(" var request = new System.Net.Http.HttpRequestMessage(System.Net.Http.HttpMethod.{method}, $\"{{baseUrl}}{path}\");\n"));
596
597 if let Some(body) = ctx.body {
599 let content_type = ctx.content_type.unwrap_or("application/json");
600 let json_str = serde_json::to_string(body).unwrap_or_default();
601 let escaped = escape_csharp(&json_str);
602 out.push_str(&format!(" request.Content = new System.Net.Http.StringContent(\"{escaped}\", System.Text.Encoding.UTF8, \"{content_type}\");\n"));
603 }
604
605 for (name, value) in ctx.headers {
607 if CSHARP_RESTRICTED_REQUEST_HEADERS.contains(&name.to_lowercase().as_str()) {
608 continue;
609 }
610 let escaped_name = escape_csharp(name);
611 let escaped_value = escape_csharp(value);
612 out.push_str(&format!(
613 " request.Headers.Add(\"{escaped_name}\", \"{escaped_value}\");\n"
614 ));
615 }
616
617 if !ctx.cookies.is_empty() {
619 let mut pairs: Vec<String> = ctx.cookies.iter().map(|(k, v)| format!("{k}={v}")).collect();
620 pairs.sort();
621 let cookie_header = escape_csharp(&pairs.join("; "));
622 out.push_str(&format!(
623 " request.Headers.Add(\"Cookie\", \"{cookie_header}\");\n"
624 ));
625 }
626
627 out.push_str(" var response = await client.SendAsync(request);\n");
628 }
629
630 fn render_assert_status(&self, out: &mut String, _response_var: &str, status: u16) {
632 out.push_str(&format!(" Assert.Equal({status}, (int)response.StatusCode);\n"));
633 }
634
635 fn render_assert_header(&self, out: &mut String, _response_var: &str, name: &str, expected: &str) {
640 let target = if is_csharp_content_header(name) {
641 "response.Content.Headers"
642 } else {
643 "response.Headers"
644 };
645 let escaped_name = escape_csharp(name);
646 match expected {
647 "<<present>>" => {
648 out.push_str(&format!(" Assert.True({target}.Contains(\"{escaped_name}\"), \"expected header {escaped_name} to be present\");\n"));
649 }
650 "<<absent>>" => {
651 out.push_str(&format!(" Assert.False({target}.Contains(\"{escaped_name}\"), \"expected header {escaped_name} to be absent\");\n"));
652 }
653 "<<uuid>>" => {
654 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"));
656 }
657 literal => {
658 let var_name = format!("hdr{}", sanitize_ident(name));
661 let escaped_value = escape_csharp(literal);
662 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"));
663 }
664 }
665 }
666
667 fn render_assert_json_body(&self, out: &mut String, _response_var: &str, expected: &serde_json::Value) {
671 match expected {
672 serde_json::Value::Object(_) | serde_json::Value::Array(_) => {
673 let json_str = serde_json::to_string(expected).unwrap_or_default();
674 let escaped = escape_csharp(&json_str);
675 out.push_str(" var bodyText = await response.Content.ReadAsStringAsync();\n");
676 out.push_str(" var body = JsonDocument.Parse(bodyText).RootElement;\n");
677 out.push_str(&format!(
678 " var expectedBody = JsonDocument.Parse(\"{escaped}\").RootElement;\n"
679 ));
680 out.push_str(" Assert.Equal(expectedBody.GetRawText(), body.GetRawText());\n");
681 }
682 serde_json::Value::String(s) => {
683 let escaped = escape_csharp(s);
684 out.push_str(" var bodyText = await response.Content.ReadAsStringAsync();\n");
685 out.push_str(&format!(" Assert.Equal(\"{escaped}\", bodyText.Trim());\n"));
686 }
687 other => {
688 let escaped = escape_csharp(&other.to_string());
689 out.push_str(" var bodyText = await response.Content.ReadAsStringAsync();\n");
690 out.push_str(&format!(" Assert.Equal(\"{escaped}\", bodyText.Trim());\n"));
691 }
692 }
693 }
694
695 fn render_assert_partial_body(&self, out: &mut String, _response_var: &str, expected: &serde_json::Value) {
700 if let Some(obj) = expected.as_object() {
701 out.push_str(" var partialBodyText = await response.Content.ReadAsStringAsync();\n");
702 out.push_str(" var partialBody = JsonDocument.Parse(partialBodyText).RootElement;\n");
703 for (key, val) in obj {
704 let escaped_key = escape_csharp(key);
705 let json_str = serde_json::to_string(val).unwrap_or_default();
706 let escaped_val = escape_csharp(&json_str);
707 let var_name = format!("expected{}", key.to_upper_camel_case());
708 out.push_str(&format!(
709 " var {var_name} = JsonDocument.Parse(\"{escaped_val}\").RootElement;\n"
710 ));
711 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"));
712 }
713 }
714 }
715
716 fn render_assert_validation_errors(
719 &self,
720 out: &mut String,
721 _response_var: &str,
722 errors: &[ValidationErrorExpectation],
723 ) {
724 out.push_str(" var validationBodyText = await response.Content.ReadAsStringAsync();\n");
725 for err in errors {
726 let escaped_msg = escape_csharp(&err.msg);
727 out.push_str(&format!(
728 " Assert.Contains(\"{escaped_msg}\", validationBodyText);\n"
729 ));
730 }
731 }
732}
733
734fn render_http_test_method(out: &mut String, fixture: &Fixture, _http: &HttpFixture) {
737 client::http_call::render_http_test(out, &CSharpTestClientRenderer, fixture);
738}
739
740#[allow(clippy::too_many_arguments)]
741fn render_test_method(
742 out: &mut String,
743 visitor_class_decls: &mut Vec<String>,
744 fixture: &Fixture,
745 class_name: &str,
746 _function_name: &str,
747 exception_class: &str,
748 _result_var: &str,
749 _args: &[crate::config::ArgMapping],
750 _field_resolver: &FieldResolver,
751 result_is_simple: bool,
752 _is_async: bool,
753 e2e_config: &E2eConfig,
754 enum_fields: &HashMap<String, String>,
755 nested_types: &HashMap<String, String>,
756 adapters: &[alef_core::config::extras::AdapterConfig],
757) {
758 let method_name = fixture.id.to_upper_camel_case();
759 let description = &fixture.description;
760
761 if let Some(http) = &fixture.http {
763 render_http_test_method(out, fixture, http);
764 return;
765 }
766
767 if fixture.mock_response.is_none() && !fixture_has_csharp_callable(fixture, e2e_config) {
770 let skip_reason =
771 "non-HTTP fixture: C# binding does not expose a callable for the configured `[e2e.call]` function";
772 let ctx = minijinja::context! {
773 is_skipped => true,
774 skip_reason => skip_reason,
775 description => description,
776 method_name => method_name,
777 };
778 let rendered = crate::template_env::render("csharp/test_method.jinja", ctx);
779 out.push_str(&rendered);
780 return;
781 }
782
783 let expects_error = fixture.assertions.iter().any(|a| a.assertion_type == "error");
784
785 let call_config = e2e_config.resolve_call_for_fixture(
789 fixture.call.as_deref(),
790 &fixture.id,
791 &fixture.resolved_category(),
792 &fixture.tags,
793 &fixture.input,
794 );
795 let call_field_resolver = FieldResolver::new(
801 e2e_config.effective_fields(call_config),
802 e2e_config.effective_fields_optional(call_config),
803 e2e_config.effective_result_fields(call_config),
804 e2e_config.effective_fields_array(call_config),
805 &std::collections::HashSet::new(),
806 );
807 let field_resolver = &call_field_resolver;
808 let lang = "csharp";
809 let cs_overrides = call_config.overrides.get(lang);
810
811 let raw_function_name = cs_overrides
816 .and_then(|o| o.function.as_ref())
817 .cloned()
818 .unwrap_or_else(|| call_config.function.clone());
819 if raw_function_name == "chat_stream" {
820 render_chat_stream_test_method(
821 out,
822 fixture,
823 class_name,
824 call_config,
825 cs_overrides,
826 e2e_config,
827 enum_fields,
828 nested_types,
829 exception_class,
830 adapters,
831 );
832 return;
833 }
834
835 let _is_streaming = call_config.streaming.unwrap_or(false);
836 let effective_function_name = {
837 let mut name = cs_overrides
838 .and_then(|o| o.function.as_ref())
839 .cloned()
840 .unwrap_or_else(|| call_config.function.to_upper_camel_case());
841 if call_config.r#async && !name.ends_with("Async") {
842 name.push_str("Async");
843 }
844 name
845 };
846 let effective_result_var = &call_config.result_var;
847 let effective_is_async = call_config.r#async;
848 let function_name = effective_function_name.as_str();
849 let result_var = effective_result_var.as_str();
850 let is_async = effective_is_async;
851 let args = call_config.args.as_slice();
852
853 let per_call_result_is_simple = call_config.result_is_simple || cs_overrides.is_some_and(|o| o.result_is_simple);
857 let per_call_result_is_bytes = call_config.result_is_bytes || cs_overrides.is_some_and(|o| o.result_is_bytes);
862 let effective_result_is_simple = result_is_simple || per_call_result_is_simple || per_call_result_is_bytes;
863 let effective_result_is_bytes = per_call_result_is_bytes;
864 let returns_void = call_config.returns_void;
865 let extra_args_slice: &[String] = cs_overrides.map_or(&[], |o| o.extra_args.as_slice());
866 let top_level_options_type = e2e_config
868 .call
869 .overrides
870 .get("csharp")
871 .and_then(|o| o.options_type.as_deref());
872 let effective_options_type = cs_overrides
873 .and_then(|o| o.options_type.as_deref())
874 .or(top_level_options_type);
875
876 let top_level_options_via = e2e_config
883 .call
884 .overrides
885 .get("csharp")
886 .and_then(|o| o.options_via.as_deref());
887 let effective_options_via = cs_overrides
888 .and_then(|o| o.options_via.as_deref())
889 .or(top_level_options_via);
890
891 let adapter_request_type_owned: Option<String> = adapters
892 .iter()
893 .find(|a| a.name == call_config.function.as_str())
894 .and_then(|a| a.request_type.as_deref())
895 .map(|rt| rt.rsplit("::").next().unwrap_or(rt).to_string());
896 let (mut setup_lines, mut args_str) = build_args_and_setup(
897 &fixture.input,
898 args,
899 class_name,
900 effective_options_type,
901 effective_options_via,
902 enum_fields,
903 nested_types,
904 fixture,
905 adapter_request_type_owned.as_deref(),
906 );
907
908 if _is_streaming && adapter_request_type_owned.is_some() {
911 let has_mock_url_list = args.iter().any(|arg| arg.arg_type == "mock_url_list");
914 if has_mock_url_list {
915 if let Some(req_type) = &adapter_request_type_owned {
916 let parts: Vec<&str> = args_str.split(", ").collect();
920 if parts.len() >= 2 {
921 let urls_var = parts[parts.len() - 1]; let req_var = format!("{}Req", urls_var);
923 setup_lines.push(format!(
924 "var {req_var} = new {req_type} {{ Urls = {urls_var} }};"
925 ));
926 args_str = parts[..parts.len()-1].join(", ");
928 if !args_str.is_empty() {
929 args_str.push_str(", ");
930 }
931 args_str.push_str(&req_var);
932 }
933 }
934 }
935 }
936
937 let mut visitor_arg = String::new();
939 let has_visitor = fixture.visitor.is_some();
940 if let Some(visitor_spec) = &fixture.visitor {
941 visitor_arg = build_csharp_visitor(&mut setup_lines, visitor_class_decls, &fixture.id, visitor_spec);
942 }
943
944 let final_args = if has_visitor && !visitor_arg.is_empty() {
948 let opts_type = effective_options_type.unwrap_or("ConversionOptions");
949 if args_str.contains("JsonSerializer.Deserialize") {
950 setup_lines.push(format!("var options = {args_str};"));
952 setup_lines.push(format!("options.Visitor = {visitor_arg};"));
953 "options".to_string()
954 } else if args_str.ends_with(", null") {
955 setup_lines.push(format!("var options = new {opts_type} {{ Visitor = {visitor_arg} }};"));
957 let trimmed = args_str[..args_str.len() - 6].to_string(); format!("{trimmed}, options")
959 } else if args_str.contains(", null,") {
960 setup_lines.push(format!("var options = new {opts_type} {{ Visitor = {visitor_arg} }};"));
962 args_str.replace(", null,", ", options,")
963 } else if args_str.is_empty() {
964 setup_lines.push(format!("var options = new {opts_type} {{ Visitor = {visitor_arg} }};"));
966 "options".to_string()
967 } else {
968 setup_lines.push(format!("var options = new {opts_type} {{ Visitor = {visitor_arg} }};"));
970 format!("{args_str}, options")
971 }
972 } else if extra_args_slice.is_empty() {
973 args_str
974 } else if args_str.is_empty() {
975 extra_args_slice.join(", ")
976 } else {
977 format!("{args_str}, {}", extra_args_slice.join(", "))
978 };
979
980 let effective_function_name = function_name.to_string();
983
984 let return_type = if is_async { "async Task" } else { "void" };
985 let await_kw = if is_async { "await " } else { "" };
986
987 let client_factory = cs_overrides.and_then(|o| o.client_factory.as_deref()).or_else(|| {
990 e2e_config
991 .call
992 .overrides
993 .get("csharp")
994 .and_then(|o| o.client_factory.as_deref())
995 });
996 let call_target = if client_factory.is_some() {
998 "client".to_string()
999 } else if _is_streaming {
1000 args.iter()
1003 .find(|arg| arg.arg_type == "handle")
1004 .map(|arg| arg.name.clone())
1005 .unwrap_or_else(|| "engine".to_string())
1006 } else {
1007 class_name.to_string()
1008 };
1009
1010 let mut client_factory_setup = String::new();
1017 if let Some(factory) = client_factory {
1018 let factory_name = factory.to_upper_camel_case();
1019 let fixture_id = &fixture.id;
1020 let has_mock = fixture.mock_response.is_some() || fixture.http.is_some();
1021 let api_key_var_opt = fixture.env.as_ref().and_then(|e| e.api_key_var.as_deref());
1022 let is_live_smoke = !has_mock && api_key_var_opt.is_some();
1023 if let Some(api_key_var) = api_key_var_opt.filter(|_| has_mock) {
1024 client_factory_setup.push_str(&format!(
1025 " var apiKey = System.Environment.GetEnvironmentVariable(\"{api_key_var}\");\n"
1026 ));
1027 client_factory_setup.push_str(&format!(
1028 " var baseUrl = string.IsNullOrEmpty(apiKey)\n ? (System.Environment.GetEnvironmentVariable(\"MOCK_SERVER_URL\") ?? string.Empty) + \"/fixtures/{fixture_id}\"\n : null;\n"
1029 ));
1030 client_factory_setup.push_str(&format!(
1031 " Console.WriteLine($\"{fixture_id}: \" + (baseUrl == null ? \"using real API ({api_key_var} is set)\" : \"using mock server ({api_key_var} not set)\"));\n"
1032 ));
1033 client_factory_setup.push_str(&format!(
1034 " var client = {class_name}.{factory_name}(string.IsNullOrEmpty(apiKey) ? \"test-key\" : apiKey, baseUrl, null, null, null);\n"
1035 ));
1036 } else if let Some(api_key_var) = api_key_var_opt.filter(|_| is_live_smoke) {
1037 client_factory_setup.push_str(&format!(
1038 " var apiKey = System.Environment.GetEnvironmentVariable(\"{api_key_var}\");\n"
1039 ));
1040 client_factory_setup.push_str(" if (string.IsNullOrEmpty(apiKey)) { return; }\n");
1041 client_factory_setup.push_str(&format!(
1042 " var client = {class_name}.{factory_name}(apiKey, null, null, null, null);\n"
1043 ));
1044 } else if fixture.has_host_root_route() {
1045 let env_key = format!("MOCK_SERVER_{}", fixture_id.to_uppercase());
1046 client_factory_setup.push_str(&format!(
1047 " var _perFixtureUrl = System.Environment.GetEnvironmentVariable(\"{env_key}\");\n"
1048 ));
1049 client_factory_setup.push_str(&format!(" var baseUrl = !string.IsNullOrEmpty(_perFixtureUrl) ? _perFixtureUrl : (System.Environment.GetEnvironmentVariable(\"MOCK_SERVER_URL\") ?? string.Empty) + \"/fixtures/{fixture_id}\";\n"));
1050 client_factory_setup.push_str(&format!(
1051 " var client = {class_name}.{factory_name}(\"test-key\", baseUrl, null, null, null);\n"
1052 ));
1053 } else {
1054 client_factory_setup.push_str(&format!(" var baseUrl = (System.Environment.GetEnvironmentVariable(\"MOCK_SERVER_URL\") ?? string.Empty) + \"/fixtures/{fixture_id}\";\n"));
1055 client_factory_setup.push_str(&format!(
1056 " var client = {class_name}.{factory_name}(\"test-key\", baseUrl, null, null, null);\n"
1057 ));
1058 }
1059 }
1060
1061 let call_expr = if _is_streaming {
1064 let args_parts: Vec<&str> = final_args.split(", ").collect();
1066 let args_without_engine = if args_parts.len() > 1 {
1067 args_parts[1..].join(", ")
1068 } else {
1069 String::new()
1070 };
1071 format!("{}({})", effective_function_name, args_without_engine)
1072 } else {
1073 format!("{}({})", effective_function_name, final_args)
1074 };
1075
1076 let mut effective_enum_fields: std::collections::HashSet<String> = e2e_config.fields_enum.clone();
1084 for k in enum_fields.keys() {
1085 effective_enum_fields.insert(k.clone());
1086 }
1087 if let Some(o) = cs_overrides {
1088 for k in o.enum_fields.keys() {
1089 effective_enum_fields.insert(k.clone());
1090 }
1091 }
1092
1093 let mut assertions_body = String::new();
1095 if !expects_error && !returns_void {
1096 for assertion in &fixture.assertions {
1097 render_assertion(
1098 &mut assertions_body,
1099 assertion,
1100 result_var,
1101 class_name,
1102 exception_class,
1103 field_resolver,
1104 effective_result_is_simple,
1105 call_config.result_is_vec || cs_overrides.is_some_and(|o| o.result_is_vec),
1106 call_config.result_is_array,
1107 effective_result_is_bytes,
1108 &effective_enum_fields,
1109 );
1110 }
1111 }
1112
1113 let ctx = minijinja::context! {
1114 is_skipped => false,
1115 expects_error => expects_error,
1116 description => description,
1117 return_type => return_type,
1118 method_name => method_name,
1119 async_kw => await_kw,
1120 call_target => call_target,
1121 setup_lines => setup_lines.clone(),
1122 call_expr => call_expr,
1123 exception_class => exception_class,
1124 client_factory_setup => client_factory_setup,
1125 has_usable_assertion => !expects_error && !returns_void,
1126 result_var => result_var,
1127 assertions_body => assertions_body,
1128 is_streaming => _is_streaming,
1129 };
1130
1131 let rendered = crate::template_env::render("csharp/test_method.jinja", ctx);
1132 for line in rendered.lines() {
1134 out.push_str(" ");
1135 out.push_str(line);
1136 out.push('\n');
1137 }
1138}
1139
1140#[allow(clippy::too_many_arguments)]
1148fn render_chat_stream_test_method(
1149 out: &mut String,
1150 fixture: &Fixture,
1151 class_name: &str,
1152 call_config: &crate::config::CallConfig,
1153 cs_overrides: Option<&crate::config::CallOverride>,
1154 e2e_config: &E2eConfig,
1155 enum_fields: &HashMap<String, String>,
1156 nested_types: &HashMap<String, String>,
1157 exception_class: &str,
1158 adapters: &[alef_core::config::extras::AdapterConfig],
1159) {
1160 let method_name = fixture.id.to_upper_camel_case();
1161 let description = &fixture.description;
1162 let expects_error = fixture.assertions.iter().any(|a| a.assertion_type == "error");
1163
1164 let effective_function_name = {
1168 let mut name = cs_overrides
1169 .and_then(|o| o.function.as_ref())
1170 .cloned()
1171 .unwrap_or_else(|| call_config.function.to_upper_camel_case());
1172 if !name.ends_with("Async") {
1173 name.push_str("Async");
1174 }
1175 name
1176 };
1177 let function_name = effective_function_name.as_str();
1178 let args = call_config.args.as_slice();
1179
1180 let top_level_options_type = e2e_config
1181 .call
1182 .overrides
1183 .get("csharp")
1184 .and_then(|o| o.options_type.as_deref());
1185 let effective_options_type = cs_overrides
1186 .and_then(|o| o.options_type.as_deref())
1187 .or(top_level_options_type);
1188 let top_level_options_via = e2e_config
1189 .call
1190 .overrides
1191 .get("csharp")
1192 .and_then(|o| o.options_via.as_deref());
1193 let effective_options_via = cs_overrides
1194 .and_then(|o| o.options_via.as_deref())
1195 .or(top_level_options_via);
1196
1197 let adapter_request_type_cs: Option<String> = adapters
1198 .iter()
1199 .find(|a| a.name == call_config.function.as_str())
1200 .and_then(|a| a.request_type.as_deref())
1201 .map(|rt| rt.rsplit("::").next().unwrap_or(rt).to_string());
1202 let (setup_lines, args_str) = build_args_and_setup(
1203 &fixture.input,
1204 args,
1205 class_name,
1206 effective_options_type,
1207 effective_options_via,
1208 enum_fields,
1209 nested_types,
1210 fixture,
1211 adapter_request_type_cs.as_deref(),
1212 );
1213
1214 let client_factory = cs_overrides.and_then(|o| o.client_factory.as_deref()).or_else(|| {
1215 e2e_config
1216 .call
1217 .overrides
1218 .get("csharp")
1219 .and_then(|o| o.client_factory.as_deref())
1220 });
1221 let mut client_factory_setup = String::new();
1222 if let Some(factory) = client_factory {
1223 let factory_name = factory.to_upper_camel_case();
1224 let fixture_id = &fixture.id;
1225 let has_mock = fixture.mock_response.is_some() || fixture.http.is_some();
1226 let api_key_var_opt = fixture.env.as_ref().and_then(|e| e.api_key_var.as_deref());
1227 let is_live_smoke = !has_mock && api_key_var_opt.is_some();
1228 if let Some(api_key_var) = api_key_var_opt.filter(|_| has_mock) {
1229 client_factory_setup.push_str(&format!(
1230 " var apiKey = System.Environment.GetEnvironmentVariable(\"{api_key_var}\");\n"
1231 ));
1232 client_factory_setup.push_str(&format!(
1233 " var baseUrl = string.IsNullOrEmpty(apiKey)\n ? (System.Environment.GetEnvironmentVariable(\"MOCK_SERVER_URL\") ?? string.Empty) + \"/fixtures/{fixture_id}\"\n : null;\n"
1234 ));
1235 client_factory_setup.push_str(&format!(
1236 " Console.WriteLine($\"{fixture_id}: \" + (baseUrl == null ? \"using real API ({api_key_var} is set)\" : \"using mock server ({api_key_var} not set)\"));\n"
1237 ));
1238 client_factory_setup.push_str(&format!(
1239 " var client = {class_name}.{factory_name}(string.IsNullOrEmpty(apiKey) ? \"test-key\" : apiKey, baseUrl, null, null, null);\n"
1240 ));
1241 } else if let Some(api_key_var) = api_key_var_opt.filter(|_| is_live_smoke) {
1242 client_factory_setup.push_str(&format!(
1243 " var apiKey = System.Environment.GetEnvironmentVariable(\"{api_key_var}\");\n"
1244 ));
1245 client_factory_setup.push_str(" if (string.IsNullOrEmpty(apiKey)) { return; }\n");
1246 client_factory_setup.push_str(&format!(
1247 " var client = {class_name}.{factory_name}(apiKey, null, null, null, null);\n"
1248 ));
1249 } else if fixture.has_host_root_route() {
1250 let env_key = format!("MOCK_SERVER_{}", fixture_id.to_uppercase());
1251 client_factory_setup.push_str(&format!(
1252 " var _perFixtureUrl = System.Environment.GetEnvironmentVariable(\"{env_key}\");\n"
1253 ));
1254 client_factory_setup.push_str(&format!(" var baseUrl = !string.IsNullOrEmpty(_perFixtureUrl) ? _perFixtureUrl : (System.Environment.GetEnvironmentVariable(\"MOCK_SERVER_URL\") ?? string.Empty) + \"/fixtures/{fixture_id}\";\n"));
1255 client_factory_setup.push_str(&format!(
1256 " var client = {class_name}.{factory_name}(\"test-key\", baseUrl, null, null, null);\n"
1257 ));
1258 } else {
1259 client_factory_setup.push_str(&format!(
1260 " var baseUrl = (System.Environment.GetEnvironmentVariable(\"MOCK_SERVER_URL\") ?? string.Empty) + \"/fixtures/{fixture_id}\";\n"
1261 ));
1262 client_factory_setup.push_str(&format!(
1263 " var client = {class_name}.{factory_name}(\"test-key\", baseUrl, null, null, null);\n"
1264 ));
1265 }
1266 }
1267
1268 let call_target = if client_factory.is_some() { "client" } else { class_name };
1269 let call_expr = format!("{call_target}.{function_name}({args_str})");
1270
1271 let mut needs_finish_reason = false;
1273 let mut needs_tool_calls_json = false;
1274 let mut needs_tool_calls_0_function_name = false;
1275 let mut needs_total_tokens = false;
1276 for a in &fixture.assertions {
1277 if let Some(f) = a.field.as_deref() {
1278 match f {
1279 "finish_reason" => needs_finish_reason = true,
1280 "tool_calls" => needs_tool_calls_json = true,
1281 "tool_calls[0].function.name" => needs_tool_calls_0_function_name = true,
1282 "usage.total_tokens" => needs_total_tokens = true,
1283 _ => {}
1284 }
1285 }
1286 }
1287
1288 let mut body = String::new();
1289 let _ = writeln!(body, " [Fact]");
1290 let _ = writeln!(body, " public async Task Test_{method_name}()");
1291 let _ = writeln!(body, " {{");
1292 let _ = writeln!(body, " // {description}");
1293 if !client_factory_setup.is_empty() {
1294 body.push_str(&client_factory_setup);
1295 }
1296 for line in &setup_lines {
1297 let _ = writeln!(body, " {line}");
1298 }
1299
1300 if expects_error {
1301 let _ = writeln!(
1304 body,
1305 " await Assert.ThrowsAnyAsync<{exception_class}>(async () => {{"
1306 );
1307 let _ = writeln!(body, " await foreach (var _chunk in {call_expr}) {{ }}");
1308 body.push_str(" });\n");
1309 body.push_str(" }\n");
1310 for line in body.lines() {
1311 out.push_str(" ");
1312 out.push_str(line);
1313 out.push('\n');
1314 }
1315 return;
1316 }
1317
1318 body.push_str(" var chunks = new List<ChatCompletionChunk>();\n");
1319 body.push_str(" var streamContent = new System.Text.StringBuilder();\n");
1320 body.push_str(" var streamComplete = false;\n");
1321 if needs_finish_reason {
1322 body.push_str(" string? lastFinishReason = null;\n");
1323 }
1324 if needs_tool_calls_json {
1325 body.push_str(" string? toolCallsJson = null;\n");
1326 }
1327 if needs_tool_calls_0_function_name {
1328 body.push_str(" string? toolCalls0FunctionName = null;\n");
1329 }
1330 if needs_total_tokens {
1331 body.push_str(" long? totalTokens = null;\n");
1332 }
1333 let _ = writeln!(body, " await foreach (var chunk in {call_expr})");
1334 body.push_str(" {\n");
1335 body.push_str(" chunks.Add(chunk);\n");
1336 body.push_str(
1337 " var choice = chunk.Choices != null && chunk.Choices.Count > 0 ? chunk.Choices[0] : null;\n",
1338 );
1339 body.push_str(" if (choice != null)\n");
1340 body.push_str(" {\n");
1341 body.push_str(" var delta = choice.Delta;\n");
1342 body.push_str(" if (delta != null && !string.IsNullOrEmpty(delta.Content))\n");
1343 body.push_str(" {\n");
1344 body.push_str(" streamContent.Append(delta.Content);\n");
1345 body.push_str(" }\n");
1346 if needs_finish_reason {
1347 body.push_str(" if (choice.FinishReason != null)\n");
1355 body.push_str(" {\n");
1356 body.push_str(
1357 " lastFinishReason = JsonNamingPolicy.SnakeCaseLower.ConvertName(choice.FinishReason.ToString()!);\n",
1358 );
1359 body.push_str(" }\n");
1360 }
1361 if needs_tool_calls_json || needs_tool_calls_0_function_name {
1362 body.push_str(" var tcs = delta?.ToolCalls;\n");
1363 body.push_str(" if (tcs != null && tcs.Count > 0)\n");
1364 body.push_str(" {\n");
1365 if needs_tool_calls_json {
1366 body.push_str(
1367 " toolCallsJson ??= JsonSerializer.Serialize(tcs.Select(tc => new { function = new { name = tc.Function?.Name } }));\n",
1368 );
1369 }
1370 if needs_tool_calls_0_function_name {
1371 body.push_str(" toolCalls0FunctionName ??= tcs[0].Function?.Name;\n");
1372 }
1373 body.push_str(" }\n");
1374 }
1375 body.push_str(" }\n");
1376 if needs_total_tokens {
1377 body.push_str(" if (chunk.Usage != null)\n");
1378 body.push_str(" {\n");
1379 body.push_str(" totalTokens = chunk.Usage.TotalTokens;\n");
1380 body.push_str(" }\n");
1381 }
1382 body.push_str(" }\n");
1383 body.push_str(" streamComplete = true;\n");
1384
1385 let mut had_explicit_complete = false;
1387 for assertion in &fixture.assertions {
1388 if assertion.field.as_deref() == Some("stream_complete") {
1389 had_explicit_complete = true;
1390 }
1391 emit_chat_stream_assertion(&mut body, assertion);
1392 }
1393 if !had_explicit_complete {
1394 body.push_str(" Assert.True(streamComplete);\n");
1395 }
1396
1397 body.push_str(" }\n");
1398
1399 for line in body.lines() {
1400 out.push_str(" ");
1401 out.push_str(line);
1402 out.push('\n');
1403 }
1404}
1405
1406fn emit_chat_stream_assertion(out: &mut String, assertion: &Assertion) {
1410 let atype = assertion.assertion_type.as_str();
1411 if atype == "not_error" || atype == "error" {
1412 return;
1413 }
1414 let field = assertion.field.as_deref().unwrap_or("");
1415
1416 enum Kind {
1417 Chunks,
1418 Bool,
1419 Str,
1420 IntTokens,
1421 Json,
1422 Unsupported,
1423 }
1424
1425 let (expr, kind) = match field {
1426 "chunks" => ("chunks", Kind::Chunks),
1427 "stream_content" => ("streamContent.ToString()", Kind::Str),
1428 "stream_complete" => ("streamComplete", Kind::Bool),
1429 "no_chunks_after_done" => ("streamComplete", Kind::Bool),
1430 "finish_reason" => ("lastFinishReason", Kind::Str),
1431 "tool_calls" => ("toolCallsJson", Kind::Json),
1432 "tool_calls[0].function.name" => ("toolCalls0FunctionName", Kind::Str),
1433 "usage.total_tokens" => ("totalTokens", Kind::IntTokens),
1434 _ => ("", Kind::Unsupported),
1435 };
1436
1437 if matches!(kind, Kind::Unsupported) {
1438 let _ = writeln!(
1439 out,
1440 " // skipped: streaming assertion on unsupported field '{field}'"
1441 );
1442 return;
1443 }
1444
1445 match (atype, &kind) {
1446 ("count_min", Kind::Chunks) => {
1447 if let Some(n) = assertion.value.as_ref().and_then(|v| v.as_u64()) {
1448 let _ = writeln!(
1449 out,
1450 " Assert.True(chunks.Count >= {n}, \"expected at least {n} chunks\");"
1451 );
1452 }
1453 }
1454 ("count_equals", Kind::Chunks) => {
1455 if let Some(n) = assertion.value.as_ref().and_then(|v| v.as_u64()) {
1456 let _ = writeln!(out, " Assert.Equal({n}, chunks.Count);");
1457 }
1458 }
1459 ("equals", Kind::Str) => {
1460 if let Some(val) = &assertion.value {
1461 let cs_val = json_to_csharp(val);
1462 let _ = writeln!(out, " Assert.Equal({cs_val}, {expr});");
1463 }
1464 }
1465 ("contains", Kind::Str) => {
1466 if let Some(val) = &assertion.value {
1467 let cs_val = json_to_csharp(val);
1468 let _ = writeln!(out, " Assert.Contains({cs_val}, {expr} ?? string.Empty);");
1469 }
1470 }
1471 ("not_empty", Kind::Str) => {
1472 let _ = writeln!(out, " Assert.False(string.IsNullOrEmpty({expr}));");
1473 }
1474 ("not_empty", Kind::Json) => {
1475 let _ = writeln!(out, " Assert.NotNull({expr});");
1476 }
1477 ("is_empty", Kind::Str) => {
1478 let _ = writeln!(out, " Assert.True(string.IsNullOrEmpty({expr}));");
1479 }
1480 ("is_true", Kind::Bool) => {
1481 let _ = writeln!(out, " Assert.True({expr});");
1482 }
1483 ("is_false", Kind::Bool) => {
1484 let _ = writeln!(out, " Assert.False({expr});");
1485 }
1486 ("greater_than_or_equal", Kind::IntTokens) => {
1487 if let Some(n) = assertion.value.as_ref().and_then(|v| v.as_u64()) {
1488 let _ = writeln!(out, " Assert.True({expr} >= {n}, \"expected >= {n}\");");
1489 }
1490 }
1491 ("equals", Kind::IntTokens) => {
1492 if let Some(n) = assertion.value.as_ref().and_then(|v| v.as_u64()) {
1493 let _ = writeln!(out, " Assert.Equal((long?){n}, {expr});");
1494 }
1495 }
1496 _ => {
1497 let _ = writeln!(
1498 out,
1499 " // skipped: streaming assertion '{atype}' on field '{field}' not supported"
1500 );
1501 }
1502 }
1503}
1504
1505#[allow(clippy::too_many_arguments)]
1509fn build_args_and_setup(
1510 input: &serde_json::Value,
1511 args: &[crate::config::ArgMapping],
1512 class_name: &str,
1513 options_type: Option<&str>,
1514 options_via: Option<&str>,
1515 enum_fields: &HashMap<String, String>,
1516 nested_types: &HashMap<String, String>,
1517 fixture: &crate::fixture::Fixture,
1518 adapter_request_type: Option<&str>,
1519) -> (Vec<String>, String) {
1520 let fixture_id = &fixture.id;
1521 if args.is_empty() {
1522 return (Vec::new(), String::new());
1523 }
1524
1525 let mut setup_lines: Vec<String> = Vec::new();
1526 let mut parts: Vec<String> = Vec::new();
1527
1528 for arg in args {
1529 if arg.arg_type == "bytes" {
1530 let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
1532 let val = input.get(field);
1533 match val {
1534 None | Some(serde_json::Value::Null) if arg.optional => {
1535 parts.push("null".to_string());
1536 }
1537 None | Some(serde_json::Value::Null) => {
1538 parts.push("System.Array.Empty<byte>()".to_string());
1539 }
1540 Some(v) => {
1541 if let Some(s) = v.as_str() {
1546 let bytes_code = classify_bytes_value_csharp(s);
1547 parts.push(bytes_code);
1548 } else {
1549 let cs_str = json_to_csharp(v);
1551 parts.push(format!("System.Text.Encoding.UTF8.GetBytes({cs_str})"));
1552 }
1553 }
1554 }
1555 continue;
1556 }
1557
1558 if arg.arg_type == "mock_url" {
1559 if fixture.has_host_root_route() {
1560 let env_key = format!("MOCK_SERVER_{}", fixture_id.to_uppercase());
1561 setup_lines.push(format!(
1562 "var _pfUrl_{name} = Environment.GetEnvironmentVariable(\"{env_key}\");",
1563 name = arg.name,
1564 ));
1565 setup_lines.push(format!(
1566 "var {} = !string.IsNullOrEmpty(_pfUrl_{name}) ? _pfUrl_{name} : Environment.GetEnvironmentVariable(\"MOCK_SERVER_URL\") + \"/fixtures/{fixture_id}\";",
1567 arg.name,
1568 name = arg.name,
1569 ));
1570 } else {
1571 setup_lines.push(format!(
1572 "var {} = Environment.GetEnvironmentVariable(\"MOCK_SERVER_URL\") + \"/fixtures/{fixture_id}\";",
1573 arg.name,
1574 ));
1575 }
1576 if let Some(req_type) = adapter_request_type {
1577 let req_var = format!("{}Req", arg.name);
1578 setup_lines.push(format!(
1579 "var {req_var} = new {req_type} {{ Url = {} }};",
1580 arg.name
1581 ));
1582 parts.push(req_var);
1583 } else {
1584 parts.push(arg.name.clone());
1585 }
1586 continue;
1587 }
1588
1589 if arg.arg_type == "mock_url_list" {
1590 let env_key = format!("MOCK_SERVER_{}", fixture_id.to_uppercase());
1597 let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
1598 let val = input.get(field).unwrap_or(&serde_json::Value::Null);
1599 let paths: Vec<String> = if let Some(arr) = val.as_array() {
1600 arr.iter()
1601 .filter_map(|v| v.as_str().map(|s| format!("\"{}\"", escape_csharp(s))))
1602 .collect()
1603 } else {
1604 Vec::new()
1605 };
1606 let paths_literal = paths.join(", ");
1607 let name = &arg.name;
1608 setup_lines.push(format!(
1609 "var _pfBase_{name} = Environment.GetEnvironmentVariable(\"{env_key}\");"
1610 ));
1611 setup_lines.push(format!(
1612 "var _base_{name} = !string.IsNullOrEmpty(_pfBase_{name}) ? _pfBase_{name} : Environment.GetEnvironmentVariable(\"MOCK_SERVER_URL\") + \"/fixtures/{fixture_id}\";"
1613 ));
1614 setup_lines.push(format!(
1615 "var {name} = new System.Collections.Generic.List<string>(new string[] {{ {paths_literal} }}.Select(p => p.StartsWith(\"http\") ? p : _base_{name} + p));"
1616 ));
1617 parts.push(name.clone());
1618 continue;
1619 }
1620
1621 if arg.arg_type == "handle" {
1622 let constructor_name = format!("Create{}", arg.name.to_upper_camel_case());
1624 let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
1625 let config_value = input.get(field).unwrap_or(&serde_json::Value::Null);
1626 if config_value.is_null()
1627 || config_value.is_object() && config_value.as_object().is_some_and(|o| o.is_empty())
1628 {
1629 setup_lines.push(format!("var {} = {class_name}.{constructor_name}(null);", arg.name,));
1630 } else {
1631 let sorted = sort_discriminator_first(config_value.clone());
1635 let json_str = serde_json::to_string(&sorted).unwrap_or_default();
1636 let name = &arg.name;
1637 setup_lines.push(format!(
1638 "var {name}Config = JsonSerializer.Deserialize<CrawlConfig>(\"{}\", ConfigOptions)!;",
1639 escape_csharp(&json_str),
1640 ));
1641 setup_lines.push(format!(
1642 "var {} = {class_name}.{constructor_name}({name}Config);",
1643 arg.name,
1644 name = name,
1645 ));
1646 }
1647 parts.push(arg.name.clone());
1648 continue;
1649 }
1650
1651 let val: Option<&serde_json::Value> = if arg.field == "input" {
1654 Some(input)
1655 } else {
1656 let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
1657 input.get(field)
1658 };
1659 match val {
1660 None | Some(serde_json::Value::Null) if arg.optional => {
1661 parts.push("null".to_string());
1664 continue;
1665 }
1666 None | Some(serde_json::Value::Null) => {
1667 let default_val = match arg.arg_type.as_str() {
1671 "string" => "\"\"".to_string(),
1672 "int" | "integer" => "0".to_string(),
1673 "float" | "number" => "0.0d".to_string(),
1674 "bool" | "boolean" => "false".to_string(),
1675 "json_object" => {
1676 if let Some(opts_type) = options_type {
1677 format!("new {opts_type}()")
1678 } else {
1679 "null".to_string()
1680 }
1681 }
1682 _ => "null".to_string(),
1683 };
1684 parts.push(default_val);
1685 }
1686 Some(v) => {
1687 if arg.arg_type == "json_object" {
1688 if options_via == Some("from_json")
1694 && let Some(opts_type) = options_type
1695 {
1696 let sorted = sort_discriminator_first(v.clone());
1697 let json_str = serde_json::to_string(&sorted).unwrap_or_default();
1698 let escaped = escape_csharp(&json_str);
1699 parts.push(format!("{opts_type}.FromJson(\"{escaped}\")",));
1705 continue;
1706 }
1707 if let Some(arr) = v.as_array() {
1709 parts.push(json_array_to_csharp_list(arr, arg.element_type.as_deref()));
1710 continue;
1711 }
1712 if let Some(opts_type) = options_type {
1714 if let Some(obj) = v.as_object() {
1715 parts.push(csharp_object_initializer(obj, opts_type, enum_fields, nested_types));
1716 continue;
1717 }
1718 }
1719 }
1720 parts.push(json_to_csharp(v));
1721 }
1722 }
1723 }
1724
1725 (setup_lines, parts.join(", "))
1726}
1727
1728fn json_array_to_csharp_list(arr: &[serde_json::Value], element_type: Option<&str>) -> String {
1736 match element_type {
1737 Some("BatchBytesItem") => {
1738 let items: Vec<String> = arr
1739 .iter()
1740 .filter_map(|v| v.as_object())
1741 .map(|obj| {
1742 let content = obj.get("content").and_then(|v| v.as_array());
1743 let mime_type = obj.get("mime_type").and_then(|v| v.as_str()).unwrap_or("text/plain");
1744 let content_code = if let Some(arr) = content {
1745 let bytes: Vec<String> = arr
1746 .iter()
1747 .filter_map(|v| v.as_u64().map(|n| format!("(byte){}", n)))
1748 .collect();
1749 format!("new byte[] {{ {} }}", bytes.join(", "))
1750 } else {
1751 "new byte[] { }".to_string()
1752 };
1753 format!(
1754 "new BatchBytesItem {{ Content = {}, MimeType = \"{}\" }}",
1755 content_code, mime_type
1756 )
1757 })
1758 .collect();
1759 format!("new List<BatchBytesItem>() {{ {} }}", items.join(", "))
1760 }
1761 Some("BatchFileItem") => {
1762 let items: Vec<String> = arr
1763 .iter()
1764 .filter_map(|v| v.as_object())
1765 .map(|obj| {
1766 let path = obj.get("path").and_then(|v| v.as_str()).unwrap_or("");
1767 format!("new BatchFileItem {{ Path = \"{}\" }}", path)
1768 })
1769 .collect();
1770 format!("new List<BatchFileItem>() {{ {} }}", items.join(", "))
1771 }
1772 Some("f32") => {
1773 let items: Vec<String> = arr.iter().map(|v| format!("(float){}", json_to_csharp(v))).collect();
1774 format!("new List<float>() {{ {} }}", items.join(", "))
1775 }
1776 Some("(String, String)") => {
1777 let items: Vec<String> = arr
1778 .iter()
1779 .map(|v| {
1780 let strs: Vec<String> = v
1781 .as_array()
1782 .map_or_else(Vec::new, |a| a.iter().map(json_to_csharp).collect());
1783 format!("new List<string>() {{ {} }}", strs.join(", "))
1784 })
1785 .collect();
1786 format!("new List<List<string>>() {{ {} }}", items.join(", "))
1787 }
1788 Some(et)
1789 if et != "f32"
1790 && et != "(String, String)"
1791 && et != "string"
1792 && et != "BatchBytesItem"
1793 && et != "BatchFileItem" =>
1794 {
1795 let items: Vec<String> = arr
1797 .iter()
1798 .map(|v| {
1799 let json_str = serde_json::to_string(v).unwrap_or_default();
1800 let escaped = escape_csharp(&json_str);
1801 format!("JsonSerializer.Deserialize<{et}>(\"{escaped}\", ConfigOptions)!")
1802 })
1803 .collect();
1804 format!("new List<{et}>() {{ {} }}", items.join(", "))
1805 }
1806 _ => {
1807 let items: Vec<String> = arr.iter().map(json_to_csharp).collect();
1808 format!("new List<string>() {{ {} }}", items.join(", "))
1809 }
1810 }
1811}
1812
1813fn parse_discriminated_union_access(field: &str) -> Option<(String, String, String)> {
1817 let parts: Vec<&str> = field.split('.').collect();
1818 if parts.len() >= 3 && parts.len() <= 4 {
1819 if parts[0] == "metadata" && parts[1] == "format" {
1821 let variant_name = parts[2];
1822 let known_variants = [
1824 "pdf",
1825 "docx",
1826 "excel",
1827 "email",
1828 "pptx",
1829 "archive",
1830 "image",
1831 "xml",
1832 "text",
1833 "html",
1834 "ocr",
1835 "csv",
1836 "bibtex",
1837 "citation",
1838 "fiction_book",
1839 "dbf",
1840 "jats",
1841 "epub",
1842 "pst",
1843 "code",
1844 ];
1845 if known_variants.contains(&variant_name) {
1846 let variant_pascal = variant_name.to_upper_camel_case();
1847 if parts.len() == 4 {
1848 let inner_field = parts[3];
1849 return Some((
1850 format!("result.Metadata.Format! as FormatMetadata.{}", variant_pascal),
1851 variant_pascal,
1852 inner_field.to_string(),
1853 ));
1854 } else if parts.len() == 3 {
1855 return Some((
1857 format!("result.Metadata.Format! as FormatMetadata.{}", variant_pascal),
1858 variant_pascal,
1859 String::new(),
1860 ));
1861 }
1862 }
1863 }
1864 }
1865 None
1866}
1867
1868fn render_discriminated_union_assertion(
1872 out: &mut String,
1873 assertion: &Assertion,
1874 variant_var: &str,
1875 inner_field: &str,
1876 _result_is_vec: bool,
1877) {
1878 if inner_field.is_empty() {
1879 return; }
1881
1882 let field_pascal = inner_field.to_upper_camel_case();
1883 let field_expr = format!("{variant_var}.Value.{field_pascal}");
1884
1885 match assertion.assertion_type.as_str() {
1886 "equals" => {
1887 if let Some(expected) = &assertion.value {
1888 let cs_val = json_to_csharp(expected);
1889 if expected.is_string() {
1890 let _ = writeln!(out, " Assert.Equal({cs_val}, {field_expr}!.Trim());");
1891 } else if expected.as_bool() == Some(true) {
1892 let _ = writeln!(out, " Assert.True({field_expr});");
1893 } else if expected.as_bool() == Some(false) {
1894 let _ = writeln!(out, " Assert.False({field_expr});");
1895 } else if expected.is_number() && !expected.as_f64().is_some_and(|f| f.fract() != 0.0) {
1896 let _ = writeln!(out, " Assert.True({field_expr} == {cs_val});");
1897 } else {
1898 let _ = writeln!(out, " Assert.Equal({cs_val}, {field_expr});");
1899 }
1900 }
1901 }
1902 "greater_than_or_equal" => {
1903 if let Some(val) = &assertion.value {
1904 let cs_val = json_to_csharp(val);
1905 let _ = writeln!(
1906 out,
1907 " Assert.True({field_expr} >= {cs_val}, \"expected >= {cs_val}\");"
1908 );
1909 }
1910 }
1911 "contains_all" => {
1912 if let Some(values) = &assertion.values {
1913 let field_as_str = format!("JsonSerializer.Serialize({field_expr})");
1914 for val in values {
1915 let lower_val = val.as_str().map(|s| s.to_lowercase());
1916 let cs_val = lower_val
1917 .as_deref()
1918 .map(|s| format!("\"{}\"", escape_csharp(s)))
1919 .unwrap_or_else(|| json_to_csharp(val));
1920 let _ = writeln!(out, " Assert.Contains({cs_val}, {field_as_str}.ToLower());");
1921 }
1922 }
1923 }
1924 "contains" => {
1925 if let Some(expected) = &assertion.value {
1926 let field_as_str = format!("JsonSerializer.Serialize({field_expr})");
1927 let lower_expected = expected.as_str().map(|s| s.to_lowercase());
1928 let cs_val = lower_expected
1929 .as_deref()
1930 .map(|s| format!("\"{}\"", escape_csharp(s)))
1931 .unwrap_or_else(|| json_to_csharp(expected));
1932 let _ = writeln!(out, " Assert.Contains({cs_val}, {field_as_str}.ToLower());");
1933 }
1934 }
1935 "not_empty" => {
1936 let _ = writeln!(out, " Assert.NotEmpty({field_expr});");
1937 }
1938 "is_empty" => {
1939 let _ = writeln!(out, " Assert.Empty({field_expr});");
1940 }
1941 _ => {
1942 let _ = writeln!(
1943 out,
1944 " // skipped: assertion type '{}' not yet supported for discriminated union fields",
1945 assertion.assertion_type
1946 );
1947 }
1948 }
1949}
1950
1951#[allow(clippy::too_many_arguments)]
1952fn render_assertion(
1953 out: &mut String,
1954 assertion: &Assertion,
1955 result_var: &str,
1956 class_name: &str,
1957 exception_class: &str,
1958 field_resolver: &FieldResolver,
1959 result_is_simple: bool,
1960 result_is_vec: bool,
1961 result_is_array: bool,
1962 result_is_bytes: bool,
1963 fields_enum: &std::collections::HashSet<String>,
1964) {
1965 if result_is_bytes {
1969 match assertion.assertion_type.as_str() {
1970 "not_empty" => {
1971 let _ = writeln!(out, " Assert.NotEmpty({result_var});");
1972 return;
1973 }
1974 "is_empty" => {
1975 let _ = writeln!(out, " Assert.Empty({result_var});");
1976 return;
1977 }
1978 "count_equals" | "length_equals" => {
1979 if let Some(n) = assertion.value.as_ref().and_then(|v| v.as_u64()) {
1980 let _ = writeln!(out, " Assert.Equal({n}, {result_var}.Length);");
1981 }
1982 return;
1983 }
1984 "count_min" | "length_min" => {
1985 if let Some(n) = assertion.value.as_ref().and_then(|v| v.as_u64()) {
1986 let _ = writeln!(out, " Assert.True({result_var}.Length >= {n});");
1987 }
1988 return;
1989 }
1990 "not_error" => {
1991 let _ = writeln!(out, " Assert.NotNull({result_var});");
1992 return;
1993 }
1994 _ => {
1995 let _ = writeln!(
1999 out,
2000 " // skipped: assertion type '{}' not supported on byte[] result",
2001 assertion.assertion_type
2002 );
2003 return;
2004 }
2005 }
2006 }
2007 if let Some(f) = &assertion.field {
2010 match f.as_str() {
2011 "chunks_have_content" => {
2012 let synthetic_pred =
2013 format!("({result_var}.Chunks ?? new()).All(c => !string.IsNullOrEmpty(c.Content))");
2014 let synthetic_pred_type = match assertion.assertion_type.as_str() {
2015 "is_true" => "is_true",
2016 "is_false" => "is_false",
2017 _ => {
2018 out.push_str(&format!(
2019 " // skipped: unsupported assertion type on synthetic field '{f}'\n"
2020 ));
2021 return;
2022 }
2023 };
2024 let rendered = crate::template_env::render(
2025 "csharp/assertion.jinja",
2026 minijinja::context! {
2027 assertion_type => "synthetic_assertion",
2028 synthetic_pred => synthetic_pred,
2029 synthetic_pred_type => synthetic_pred_type,
2030 },
2031 );
2032 out.push_str(&rendered);
2033 return;
2034 }
2035 "chunks_have_embeddings" => {
2036 let synthetic_pred =
2037 format!("({result_var}.Chunks ?? new()).All(c => c.Embedding != null && c.Embedding.Count > 0)");
2038 let synthetic_pred_type = match assertion.assertion_type.as_str() {
2039 "is_true" => "is_true",
2040 "is_false" => "is_false",
2041 _ => {
2042 out.push_str(&format!(
2043 " // skipped: unsupported assertion type on synthetic field '{f}'\n"
2044 ));
2045 return;
2046 }
2047 };
2048 let rendered = crate::template_env::render(
2049 "csharp/assertion.jinja",
2050 minijinja::context! {
2051 assertion_type => "synthetic_assertion",
2052 synthetic_pred => synthetic_pred,
2053 synthetic_pred_type => synthetic_pred_type,
2054 },
2055 );
2056 out.push_str(&rendered);
2057 return;
2058 }
2059 "embeddings" => {
2063 match assertion.assertion_type.as_str() {
2064 "count_equals" => {
2065 if let Some(val) = &assertion.value {
2066 if let Some(n) = val.as_u64() {
2067 let rendered = crate::template_env::render(
2068 "csharp/assertion.jinja",
2069 minijinja::context! {
2070 assertion_type => "synthetic_embeddings_count_equals",
2071 synthetic_pred => format!("{result_var}.Count"),
2072 n => n,
2073 },
2074 );
2075 out.push_str(&rendered);
2076 }
2077 }
2078 }
2079 "count_min" => {
2080 if let Some(val) = &assertion.value {
2081 if let Some(n) = val.as_u64() {
2082 let rendered = crate::template_env::render(
2083 "csharp/assertion.jinja",
2084 minijinja::context! {
2085 assertion_type => "synthetic_embeddings_count_min",
2086 synthetic_pred => format!("{result_var}.Count"),
2087 n => n,
2088 },
2089 );
2090 out.push_str(&rendered);
2091 }
2092 }
2093 }
2094 "not_empty" => {
2095 let rendered = crate::template_env::render(
2096 "csharp/assertion.jinja",
2097 minijinja::context! {
2098 assertion_type => "synthetic_embeddings_not_empty",
2099 synthetic_pred => result_var.to_string(),
2100 },
2101 );
2102 out.push_str(&rendered);
2103 }
2104 "is_empty" => {
2105 let rendered = crate::template_env::render(
2106 "csharp/assertion.jinja",
2107 minijinja::context! {
2108 assertion_type => "synthetic_embeddings_is_empty",
2109 synthetic_pred => result_var.to_string(),
2110 },
2111 );
2112 out.push_str(&rendered);
2113 }
2114 _ => {
2115 out.push_str(
2116 " // skipped: unsupported assertion type on synthetic field 'embeddings'\n",
2117 );
2118 }
2119 }
2120 return;
2121 }
2122 "embedding_dimensions" => {
2123 let expr = format!("({result_var}.Count > 0 ? {result_var}[0].Count : 0)");
2124 match assertion.assertion_type.as_str() {
2125 "equals" => {
2126 if let Some(val) = &assertion.value {
2127 if let Some(n) = val.as_u64() {
2128 let rendered = crate::template_env::render(
2129 "csharp/assertion.jinja",
2130 minijinja::context! {
2131 assertion_type => "synthetic_embedding_dimensions_equals",
2132 synthetic_pred => expr,
2133 n => n,
2134 },
2135 );
2136 out.push_str(&rendered);
2137 }
2138 }
2139 }
2140 "greater_than" => {
2141 if let Some(val) = &assertion.value {
2142 if let Some(n) = val.as_u64() {
2143 let rendered = crate::template_env::render(
2144 "csharp/assertion.jinja",
2145 minijinja::context! {
2146 assertion_type => "synthetic_embedding_dimensions_greater_than",
2147 synthetic_pred => expr,
2148 n => n,
2149 },
2150 );
2151 out.push_str(&rendered);
2152 }
2153 }
2154 }
2155 _ => {
2156 out.push_str(" // skipped: unsupported assertion type on synthetic field 'embedding_dimensions'\n");
2157 }
2158 }
2159 return;
2160 }
2161 "embeddings_valid" | "embeddings_finite" | "embeddings_non_zero" | "embeddings_normalized" => {
2162 let synthetic_pred = match f.as_str() {
2163 "embeddings_valid" => {
2164 format!("{result_var}.All(e => e.Count > 0)")
2165 }
2166 "embeddings_finite" => {
2167 format!("{result_var}.All(e => e.All(v => !float.IsInfinity(v) && !float.IsNaN(v)))")
2168 }
2169 "embeddings_non_zero" => {
2170 format!("{result_var}.All(e => e.Any(v => v != 0.0f))")
2171 }
2172 "embeddings_normalized" => {
2173 format!(
2174 "{result_var}.All(e => {{ var n = e.Sum(v => (double)v * v); return Math.Abs(n - 1.0) < 1e-3; }})"
2175 )
2176 }
2177 _ => unreachable!(),
2178 };
2179 let synthetic_pred_type = match assertion.assertion_type.as_str() {
2180 "is_true" => "is_true",
2181 "is_false" => "is_false",
2182 _ => {
2183 out.push_str(&format!(
2184 " // skipped: unsupported assertion type on synthetic field '{f}'\n"
2185 ));
2186 return;
2187 }
2188 };
2189 let rendered = crate::template_env::render(
2190 "csharp/assertion.jinja",
2191 minijinja::context! {
2192 assertion_type => "synthetic_assertion",
2193 synthetic_pred => synthetic_pred,
2194 synthetic_pred_type => synthetic_pred_type,
2195 },
2196 );
2197 out.push_str(&rendered);
2198 return;
2199 }
2200 "keywords" | "keywords_count" => {
2203 let skipped_reason = format!("field '{f}' not available on C# ExtractionResult");
2204 let rendered = crate::template_env::render(
2205 "csharp/assertion.jinja",
2206 minijinja::context! {
2207 skipped_reason => skipped_reason,
2208 },
2209 );
2210 out.push_str(&rendered);
2211 return;
2212 }
2213 _ => {}
2214 }
2215 }
2216
2217 if let Some(f) = &assertion.field {
2219 if !f.is_empty() && !field_resolver.is_valid_for_result(f) {
2220 let skipped_reason = format!("field '{f}' not available on result type");
2221 let rendered = crate::template_env::render(
2222 "csharp/assertion.jinja",
2223 minijinja::context! {
2224 skipped_reason => skipped_reason,
2225 },
2226 );
2227 out.push_str(&rendered);
2228 return;
2229 }
2230 }
2231
2232 let is_count_assertion = matches!(
2235 assertion.assertion_type.as_str(),
2236 "count_equals" | "count_min" | "count_max"
2237 );
2238 let is_no_field = assertion.field.is_none() || assertion.field.as_ref().is_some_and(|f| f.is_empty());
2239 let use_list_directly = result_is_vec && is_count_assertion && is_no_field;
2240
2241 let effective_result_var: String = if result_is_vec && !use_list_directly {
2242 format!("{result_var}[0]")
2243 } else {
2244 result_var.to_string()
2245 };
2246
2247 let is_discriminated_union = assertion
2249 .field
2250 .as_ref()
2251 .is_some_and(|f| parse_discriminated_union_access(f).is_some());
2252
2253 if is_discriminated_union {
2255 if let Some((_, variant_name, inner_field)) = assertion
2256 .field
2257 .as_ref()
2258 .and_then(|f| parse_discriminated_union_access(f))
2259 {
2260 let mut hasher = std::collections::hash_map::DefaultHasher::new();
2262 inner_field.hash(&mut hasher);
2263 let var_hash = format!("{:x}", hasher.finish());
2264 let variant_var = format!("variant_{}", &var_hash[..8]);
2265 let _ = writeln!(
2266 out,
2267 " if ({effective_result_var}.Metadata.Format is FormatMetadata.{} {})",
2268 variant_name, &variant_var
2269 );
2270 let _ = writeln!(out, " {{");
2271 render_discriminated_union_assertion(out, assertion, &variant_var, &inner_field, result_is_vec);
2272 let _ = writeln!(out, " }}");
2273 let _ = writeln!(out, " else");
2274 let _ = writeln!(out, " {{");
2275 let _ = writeln!(
2276 out,
2277 " Assert.Fail(\"Expected {} format metadata\");",
2278 variant_name.to_lowercase()
2279 );
2280 let _ = writeln!(out, " }}");
2281 return;
2282 }
2283 }
2284
2285 let field_expr = if result_is_simple {
2286 effective_result_var.clone()
2287 } else {
2288 match &assertion.field {
2289 Some(f) if !f.is_empty() => field_resolver.accessor(f, "csharp", &effective_result_var),
2290 _ => effective_result_var.clone(),
2291 }
2292 };
2293
2294 let field_needs_json_serialize = if result_is_simple {
2298 result_is_array
2301 } else {
2302 match &assertion.field {
2303 Some(f) if !f.is_empty() => field_resolver.is_array(f),
2304 _ => !result_is_simple,
2306 }
2307 };
2308 let field_as_str = if field_needs_json_serialize {
2310 format!("JsonSerializer.Serialize({field_expr})")
2311 } else {
2312 format!("{field_expr}.ToString()")
2313 };
2314
2315 let field_is_enum = assertion.field.as_deref().filter(|f| !f.is_empty()).is_some_and(|f| {
2319 let resolved = field_resolver.resolve(f);
2320 fields_enum.contains(f) || fields_enum.contains(resolved)
2321 });
2322
2323 match assertion.assertion_type.as_str() {
2324 "equals" => {
2325 if let Some(expected) = &assertion.value {
2326 if field_is_enum && expected.is_string() {
2333 let s_lower = expected.as_str().map(|s| s.to_lowercase()).unwrap_or_default();
2334 let _ = writeln!(
2335 out,
2336 " Assert.Equal(\"{}\", {field_expr} == null ? null : JsonNamingPolicy.SnakeCaseLower.ConvertName({field_expr}.ToString()!));",
2337 escape_csharp(&s_lower)
2338 );
2339 return;
2340 }
2341 let cs_val = json_to_csharp(expected);
2342 let is_string_val = expected.is_string();
2343 let is_bool_true = expected.as_bool() == Some(true);
2344 let is_bool_false = expected.as_bool() == Some(false);
2345 let is_integer_val = expected.is_number() && !expected.as_f64().is_some_and(|f| f.fract() != 0.0);
2346
2347 let rendered = crate::template_env::render(
2348 "csharp/assertion.jinja",
2349 minijinja::context! {
2350 assertion_type => "equals",
2351 field_expr => field_expr.clone(),
2352 cs_val => cs_val,
2353 is_string_val => is_string_val,
2354 is_bool_true => is_bool_true,
2355 is_bool_false => is_bool_false,
2356 is_integer_val => is_integer_val,
2357 },
2358 );
2359 out.push_str(&rendered);
2360 }
2361 }
2362 "contains" => {
2363 if let Some(expected) = &assertion.value {
2364 let lower_expected = expected.as_str().map(|s| s.to_lowercase());
2371 let cs_val = lower_expected
2372 .as_deref()
2373 .map(|s| format!("\"{}\"", escape_csharp(s)))
2374 .unwrap_or_else(|| json_to_csharp(expected));
2375
2376 let rendered = crate::template_env::render(
2377 "csharp/assertion.jinja",
2378 minijinja::context! {
2379 assertion_type => "contains",
2380 field_as_str => field_as_str.clone(),
2381 cs_val => cs_val,
2382 },
2383 );
2384 out.push_str(&rendered);
2385 }
2386 }
2387 "contains_all" => {
2388 if let Some(values) = &assertion.values {
2389 let values_cs_lower: Vec<String> = values
2390 .iter()
2391 .map(|val| {
2392 let lower_val = val.as_str().map(|s| s.to_lowercase());
2393 lower_val
2394 .as_deref()
2395 .map(|s| format!("\"{}\"", escape_csharp(s)))
2396 .unwrap_or_else(|| json_to_csharp(val))
2397 })
2398 .collect();
2399
2400 let rendered = crate::template_env::render(
2401 "csharp/assertion.jinja",
2402 minijinja::context! {
2403 assertion_type => "contains_all",
2404 field_as_str => field_as_str.clone(),
2405 values_cs_lower => values_cs_lower,
2406 },
2407 );
2408 out.push_str(&rendered);
2409 }
2410 }
2411 "not_contains" => {
2412 if let Some(expected) = &assertion.value {
2413 let cs_val = json_to_csharp(expected);
2414
2415 let rendered = crate::template_env::render(
2416 "csharp/assertion.jinja",
2417 minijinja::context! {
2418 assertion_type => "not_contains",
2419 field_as_str => field_as_str.clone(),
2420 cs_val => cs_val,
2421 },
2422 );
2423 out.push_str(&rendered);
2424 }
2425 }
2426 "not_empty" => {
2427 let rendered = crate::template_env::render(
2428 "csharp/assertion.jinja",
2429 minijinja::context! {
2430 assertion_type => "not_empty",
2431 field_expr => field_expr.clone(),
2432 field_needs_json_serialize => field_needs_json_serialize,
2433 },
2434 );
2435 out.push_str(&rendered);
2436 }
2437 "is_empty" => {
2438 let rendered = crate::template_env::render(
2439 "csharp/assertion.jinja",
2440 minijinja::context! {
2441 assertion_type => "is_empty",
2442 field_expr => field_expr.clone(),
2443 field_needs_json_serialize => field_needs_json_serialize,
2444 },
2445 );
2446 out.push_str(&rendered);
2447 }
2448 "contains_any" => {
2449 if let Some(values) = &assertion.values {
2450 let checks: Vec<String> = values
2451 .iter()
2452 .map(|v| {
2453 let cs_val = json_to_csharp(v);
2454 format!("{field_as_str}.Contains({cs_val})")
2455 })
2456 .collect();
2457 let contains_any_expr = checks.join(" || ");
2458
2459 let rendered = crate::template_env::render(
2460 "csharp/assertion.jinja",
2461 minijinja::context! {
2462 assertion_type => "contains_any",
2463 contains_any_expr => contains_any_expr,
2464 },
2465 );
2466 out.push_str(&rendered);
2467 }
2468 }
2469 "greater_than" => {
2470 if let Some(val) = &assertion.value {
2471 let cs_val = json_to_csharp(val);
2472
2473 let rendered = crate::template_env::render(
2474 "csharp/assertion.jinja",
2475 minijinja::context! {
2476 assertion_type => "greater_than",
2477 field_expr => field_expr.clone(),
2478 cs_val => cs_val,
2479 },
2480 );
2481 out.push_str(&rendered);
2482 }
2483 }
2484 "less_than" => {
2485 if let Some(val) = &assertion.value {
2486 let cs_val = json_to_csharp(val);
2487
2488 let rendered = crate::template_env::render(
2489 "csharp/assertion.jinja",
2490 minijinja::context! {
2491 assertion_type => "less_than",
2492 field_expr => field_expr.clone(),
2493 cs_val => cs_val,
2494 },
2495 );
2496 out.push_str(&rendered);
2497 }
2498 }
2499 "greater_than_or_equal" => {
2500 if let Some(val) = &assertion.value {
2501 let cs_val = json_to_csharp(val);
2502
2503 let rendered = crate::template_env::render(
2504 "csharp/assertion.jinja",
2505 minijinja::context! {
2506 assertion_type => "greater_than_or_equal",
2507 field_expr => field_expr.clone(),
2508 cs_val => cs_val,
2509 },
2510 );
2511 out.push_str(&rendered);
2512 }
2513 }
2514 "less_than_or_equal" => {
2515 if let Some(val) = &assertion.value {
2516 let cs_val = json_to_csharp(val);
2517
2518 let rendered = crate::template_env::render(
2519 "csharp/assertion.jinja",
2520 minijinja::context! {
2521 assertion_type => "less_than_or_equal",
2522 field_expr => field_expr.clone(),
2523 cs_val => cs_val,
2524 },
2525 );
2526 out.push_str(&rendered);
2527 }
2528 }
2529 "starts_with" => {
2530 if let Some(expected) = &assertion.value {
2531 let cs_val = json_to_csharp(expected);
2532
2533 let rendered = crate::template_env::render(
2534 "csharp/assertion.jinja",
2535 minijinja::context! {
2536 assertion_type => "starts_with",
2537 field_expr => field_expr.clone(),
2538 cs_val => cs_val,
2539 },
2540 );
2541 out.push_str(&rendered);
2542 }
2543 }
2544 "ends_with" => {
2545 if let Some(expected) = &assertion.value {
2546 let cs_val = json_to_csharp(expected);
2547
2548 let rendered = crate::template_env::render(
2549 "csharp/assertion.jinja",
2550 minijinja::context! {
2551 assertion_type => "ends_with",
2552 field_expr => field_expr.clone(),
2553 cs_val => cs_val,
2554 },
2555 );
2556 out.push_str(&rendered);
2557 }
2558 }
2559 "min_length" => {
2560 if let Some(val) = &assertion.value {
2561 if let Some(n) = val.as_u64() {
2562 let rendered = crate::template_env::render(
2563 "csharp/assertion.jinja",
2564 minijinja::context! {
2565 assertion_type => "min_length",
2566 field_expr => field_expr.clone(),
2567 n => n,
2568 },
2569 );
2570 out.push_str(&rendered);
2571 }
2572 }
2573 }
2574 "max_length" => {
2575 if let Some(val) = &assertion.value {
2576 if let Some(n) = val.as_u64() {
2577 let rendered = crate::template_env::render(
2578 "csharp/assertion.jinja",
2579 minijinja::context! {
2580 assertion_type => "max_length",
2581 field_expr => field_expr.clone(),
2582 n => n,
2583 },
2584 );
2585 out.push_str(&rendered);
2586 }
2587 }
2588 }
2589 "count_min" => {
2590 if let Some(val) = &assertion.value {
2591 if let Some(n) = val.as_u64() {
2592 let rendered = crate::template_env::render(
2593 "csharp/assertion.jinja",
2594 minijinja::context! {
2595 assertion_type => "count_min",
2596 field_expr => field_expr.clone(),
2597 n => n,
2598 },
2599 );
2600 out.push_str(&rendered);
2601 }
2602 }
2603 }
2604 "count_equals" => {
2605 if let Some(val) = &assertion.value {
2606 if let Some(n) = val.as_u64() {
2607 let rendered = crate::template_env::render(
2608 "csharp/assertion.jinja",
2609 minijinja::context! {
2610 assertion_type => "count_equals",
2611 field_expr => field_expr.clone(),
2612 n => n,
2613 },
2614 );
2615 out.push_str(&rendered);
2616 }
2617 }
2618 }
2619 "is_true" => {
2620 let is_complex_or_object = field_expr.contains("(object)") ||
2625 (field_expr.contains(".") && !result_is_simple && !field_expr.contains("?") && !field_expr.contains("=="));
2626
2627 let rendered = if is_complex_or_object {
2628 crate::template_env::render(
2629 "csharp/assertion.jinja",
2630 minijinja::context! {
2631 assertion_type => "not_empty",
2632 field_expr => field_expr.clone(),
2633 field_needs_json_serialize => false,
2634 },
2635 )
2636 } else {
2637 crate::template_env::render(
2638 "csharp/assertion.jinja",
2639 minijinja::context! {
2640 assertion_type => "is_true",
2641 field_expr => field_expr.clone(),
2642 },
2643 )
2644 };
2645 out.push_str(&rendered);
2646 }
2647 "is_false" => {
2648 let is_complex_or_object = field_expr.contains("(object)") ||
2649 (field_expr.contains(".") && !result_is_simple && !field_expr.contains("?") && !field_expr.contains("=="));
2650
2651 let rendered = if is_complex_or_object {
2652 crate::template_env::render(
2654 "csharp/assertion.jinja",
2655 minijinja::context! {
2656 assertion_type => "is_empty",
2657 field_expr => field_expr.clone(),
2658 field_needs_json_serialize => false,
2659 },
2660 )
2661 } else {
2662 crate::template_env::render(
2663 "csharp/assertion.jinja",
2664 minijinja::context! {
2665 assertion_type => "is_false",
2666 field_expr => field_expr.clone(),
2667 },
2668 )
2669 };
2670 out.push_str(&rendered);
2671 }
2672 "not_error" => {
2673 let rendered = crate::template_env::render(
2675 "csharp/assertion.jinja",
2676 minijinja::context! {
2677 assertion_type => "not_error",
2678 },
2679 );
2680 out.push_str(&rendered);
2681 }
2682 "error" => {
2683 let rendered = crate::template_env::render(
2685 "csharp/assertion.jinja",
2686 minijinja::context! {
2687 assertion_type => "error",
2688 },
2689 );
2690 out.push_str(&rendered);
2691 }
2692 "method_result" => {
2693 if let Some(method_name) = &assertion.method {
2694 let call_expr = build_csharp_method_call(result_var, method_name, assertion.args.as_ref(), class_name);
2695 let check = assertion.check.as_deref().unwrap_or("is_true");
2696
2697 match check {
2698 "equals" => {
2699 if let Some(val) = &assertion.value {
2700 let is_check_bool_true = val.as_bool() == Some(true);
2701 let is_check_bool_false = val.as_bool() == Some(false);
2702 let cs_check_val = json_to_csharp(val);
2703
2704 let rendered = crate::template_env::render(
2705 "csharp/assertion.jinja",
2706 minijinja::context! {
2707 assertion_type => "method_result",
2708 check => "equals",
2709 call_expr => call_expr.clone(),
2710 is_check_bool_true => is_check_bool_true,
2711 is_check_bool_false => is_check_bool_false,
2712 cs_check_val => cs_check_val,
2713 },
2714 );
2715 out.push_str(&rendered);
2716 }
2717 }
2718 "is_true" => {
2719 let rendered = crate::template_env::render(
2720 "csharp/assertion.jinja",
2721 minijinja::context! {
2722 assertion_type => "method_result",
2723 check => "is_true",
2724 call_expr => call_expr.clone(),
2725 },
2726 );
2727 out.push_str(&rendered);
2728 }
2729 "is_false" => {
2730 let rendered = crate::template_env::render(
2731 "csharp/assertion.jinja",
2732 minijinja::context! {
2733 assertion_type => "method_result",
2734 check => "is_false",
2735 call_expr => call_expr.clone(),
2736 },
2737 );
2738 out.push_str(&rendered);
2739 }
2740 "greater_than_or_equal" => {
2741 if let Some(val) = &assertion.value {
2742 let check_n = val.as_u64().unwrap_or(0);
2743
2744 let rendered = crate::template_env::render(
2745 "csharp/assertion.jinja",
2746 minijinja::context! {
2747 assertion_type => "method_result",
2748 check => "greater_than_or_equal",
2749 call_expr => call_expr.clone(),
2750 check_n => check_n,
2751 },
2752 );
2753 out.push_str(&rendered);
2754 }
2755 }
2756 "count_min" => {
2757 if let Some(val) = &assertion.value {
2758 let check_n = val.as_u64().unwrap_or(0);
2759
2760 let rendered = crate::template_env::render(
2761 "csharp/assertion.jinja",
2762 minijinja::context! {
2763 assertion_type => "method_result",
2764 check => "count_min",
2765 call_expr => call_expr.clone(),
2766 check_n => check_n,
2767 },
2768 );
2769 out.push_str(&rendered);
2770 }
2771 }
2772 "is_error" => {
2773 let rendered = crate::template_env::render(
2774 "csharp/assertion.jinja",
2775 minijinja::context! {
2776 assertion_type => "method_result",
2777 check => "is_error",
2778 call_expr => call_expr.clone(),
2779 exception_class => exception_class,
2780 },
2781 );
2782 out.push_str(&rendered);
2783 }
2784 "contains" => {
2785 if let Some(val) = &assertion.value {
2786 let cs_check_val = json_to_csharp(val);
2787
2788 let rendered = crate::template_env::render(
2789 "csharp/assertion.jinja",
2790 minijinja::context! {
2791 assertion_type => "method_result",
2792 check => "contains",
2793 call_expr => call_expr.clone(),
2794 cs_check_val => cs_check_val,
2795 },
2796 );
2797 out.push_str(&rendered);
2798 }
2799 }
2800 other_check => {
2801 panic!("C# e2e generator: unsupported method_result check type: {other_check}");
2802 }
2803 }
2804 } else {
2805 panic!("C# e2e generator: method_result assertion missing 'method' field");
2806 }
2807 }
2808 "matches_regex" => {
2809 if let Some(expected) = &assertion.value {
2810 let cs_val = json_to_csharp(expected);
2811
2812 let rendered = crate::template_env::render(
2813 "csharp/assertion.jinja",
2814 minijinja::context! {
2815 assertion_type => "matches_regex",
2816 field_expr => field_expr.clone(),
2817 cs_val => cs_val,
2818 },
2819 );
2820 out.push_str(&rendered);
2821 }
2822 }
2823 other => {
2824 panic!("C# e2e generator: unsupported assertion type: {other}");
2825 }
2826 }
2827}
2828
2829fn sort_discriminator_first(value: serde_json::Value) -> serde_json::Value {
2836 match value {
2837 serde_json::Value::Object(map) => {
2838 let mut sorted = serde_json::Map::with_capacity(map.len());
2839 if let Some(type_val) = map.get("type") {
2841 sorted.insert("type".to_string(), sort_discriminator_first(type_val.clone()));
2842 }
2843 for (k, v) in map {
2844 if k != "type" {
2845 sorted.insert(k, sort_discriminator_first(v));
2846 }
2847 }
2848 serde_json::Value::Object(sorted)
2849 }
2850 serde_json::Value::Array(arr) => {
2851 serde_json::Value::Array(arr.into_iter().map(sort_discriminator_first).collect())
2852 }
2853 other => other,
2854 }
2855}
2856
2857fn json_to_csharp(value: &serde_json::Value) -> String {
2859 match value {
2860 serde_json::Value::String(s) => format!("\"{}\"", escape_csharp(s)),
2861 serde_json::Value::Bool(true) => "true".to_string(),
2862 serde_json::Value::Bool(false) => "false".to_string(),
2863 serde_json::Value::Number(n) => {
2864 if n.is_f64() {
2865 format!("{}d", n)
2866 } else {
2867 n.to_string()
2868 }
2869 }
2870 serde_json::Value::Null => "null".to_string(),
2871 serde_json::Value::Array(arr) => {
2872 let items: Vec<String> = arr.iter().map(json_to_csharp).collect();
2873 format!("new[] {{ {} }}", items.join(", "))
2874 }
2875 serde_json::Value::Object(_) => {
2876 let json_str = serde_json::to_string(value).unwrap_or_default();
2877 format!("\"{}\"", escape_csharp(&json_str))
2878 }
2879 }
2880}
2881
2882fn csharp_object_initializer(
2890 obj: &serde_json::Map<String, serde_json::Value>,
2891 type_name: &str,
2892 enum_fields: &HashMap<String, String>,
2893 nested_types: &HashMap<String, String>,
2894) -> String {
2895 if obj.is_empty() {
2896 return format!("new {type_name}()");
2897 }
2898
2899 static IMPLICIT_ENUM_FIELDS: &[(&str, &str)] = &[("output_format", "OutputFormat")];
2902
2903 let props: Vec<String> = obj
2904 .iter()
2905 .map(|(key, val)| {
2906 let pascal_key = key.to_upper_camel_case();
2907 let implicit_enum_type = IMPLICIT_ENUM_FIELDS
2908 .iter()
2909 .find(|(k, _)| *k == key.as_str())
2910 .map(|(_, t)| *t);
2911 let camel_key = key.to_lower_camel_case();
2915 let cs_val = if let Some(enum_type) = enum_fields
2916 .get(key.as_str())
2917 .or_else(|| enum_fields.get(camel_key.as_str()))
2918 .map(String::as_str)
2919 .or(implicit_enum_type)
2920 {
2921 if val.is_null() {
2923 "null".to_string()
2924 } else {
2925 let member = val
2926 .as_str()
2927 .map(|s| s.to_upper_camel_case())
2928 .unwrap_or_else(|| "null".to_string());
2929 format!("{enum_type}.{member}")
2930 }
2931 } else if let Some(nested_type) = nested_types
2932 .get(key.as_str())
2933 .or_else(|| nested_types.get(camel_key.as_str()))
2934 {
2935 let normalized = normalize_csharp_enum_values(val, enum_fields);
2937 let json_str = serde_json::to_string(&normalized).unwrap_or_default();
2938 format!(
2939 "JsonSerializer.Deserialize<{nested_type}>(\"{}\", ConfigOptions)!",
2940 escape_csharp(&json_str)
2941 )
2942 } else if let Some(arr) = val.as_array() {
2943 let items: Vec<String> = arr.iter().map(json_to_csharp).collect();
2945 format!("new List<string> {{ {} }}", items.join(", "))
2946 } else {
2947 json_to_csharp(val)
2948 };
2949 format!("{pascal_key} = {cs_val}")
2950 })
2951 .collect();
2952 format!("new {} {{ {} }}", type_name, props.join(", "))
2953}
2954
2955fn normalize_csharp_enum_values(value: &serde_json::Value, enum_fields: &HashMap<String, String>) -> serde_json::Value {
2960 match value {
2961 serde_json::Value::Object(map) => {
2962 let mut result = map.clone();
2963 for (key, val) in result.iter_mut() {
2964 let camel_key = key.to_lower_camel_case();
2967 if enum_fields.contains_key(key) || enum_fields.contains_key(camel_key.as_str()) {
2968 if let Some(s) = val.as_str() {
2970 *val = serde_json::Value::String(s.to_lowercase());
2971 }
2972 }
2973 }
2974 serde_json::Value::Object(result)
2975 }
2976 other => other.clone(),
2977 }
2978}
2979
2980fn build_csharp_visitor(
2991 setup_lines: &mut Vec<String>,
2992 class_decls: &mut Vec<String>,
2993 fixture_id: &str,
2994 visitor_spec: &crate::fixture::VisitorSpec,
2995) -> String {
2996 use heck::ToUpperCamelCase;
2997 let class_name = format!("{}Visitor", fixture_id.to_upper_camel_case());
2998 let var_name = format!("_visitor_{}", fixture_id.replace('-', "_"));
2999
3000 setup_lines.push(format!("var {var_name} = new {class_name}();"));
3001
3002 let mut decl = String::new();
3004 decl.push_str(&format!(" private sealed class {class_name} : IHtmlVisitor\n"));
3005 decl.push_str(" {\n");
3006
3007 let all_methods = [
3009 "visit_element_start",
3010 "visit_element_end",
3011 "visit_text",
3012 "visit_link",
3013 "visit_image",
3014 "visit_heading",
3015 "visit_code_block",
3016 "visit_code_inline",
3017 "visit_list_item",
3018 "visit_list_start",
3019 "visit_list_end",
3020 "visit_table_start",
3021 "visit_table_row",
3022 "visit_table_end",
3023 "visit_blockquote",
3024 "visit_strong",
3025 "visit_emphasis",
3026 "visit_strikethrough",
3027 "visit_underline",
3028 "visit_subscript",
3029 "visit_superscript",
3030 "visit_mark",
3031 "visit_line_break",
3032 "visit_horizontal_rule",
3033 "visit_custom_element",
3034 "visit_definition_list_start",
3035 "visit_definition_term",
3036 "visit_definition_description",
3037 "visit_definition_list_end",
3038 "visit_form",
3039 "visit_input",
3040 "visit_button",
3041 "visit_audio",
3042 "visit_video",
3043 "visit_iframe",
3044 "visit_details",
3045 "visit_summary",
3046 "visit_figure_start",
3047 "visit_figcaption",
3048 "visit_figure_end",
3049 ];
3050
3051 for method_name in &all_methods {
3053 if let Some(action) = visitor_spec.callbacks.get(*method_name) {
3054 emit_csharp_visitor_method(&mut decl, method_name, action);
3055 } else {
3056 emit_csharp_visitor_method(&mut decl, method_name, &CallbackAction::Continue);
3058 }
3059 }
3060
3061 decl.push_str(" }\n");
3062 class_decls.push(decl);
3063
3064 var_name
3065}
3066
3067fn emit_csharp_visitor_method(decl: &mut String, method_name: &str, action: &CallbackAction) {
3069 let camel_method = method_to_camel(method_name);
3070 let params = match method_name {
3071 "visit_link" => "NodeContext ctx, string href, string text, string title",
3072 "visit_image" => "NodeContext ctx, string src, string alt, string title",
3073 "visit_heading" => "NodeContext ctx, uint level, string text, string id",
3074 "visit_code_block" => "NodeContext ctx, string lang, string code",
3075 "visit_code_inline"
3076 | "visit_strong"
3077 | "visit_emphasis"
3078 | "visit_strikethrough"
3079 | "visit_underline"
3080 | "visit_subscript"
3081 | "visit_superscript"
3082 | "visit_mark"
3083 | "visit_button"
3084 | "visit_summary"
3085 | "visit_figcaption"
3086 | "visit_definition_term"
3087 | "visit_definition_description" => "NodeContext ctx, string text",
3088 "visit_text" => "NodeContext ctx, string text",
3089 "visit_list_item" => "NodeContext ctx, bool ordered, string marker, string text",
3090 "visit_blockquote" => "NodeContext ctx, string content, ulong depth",
3091 "visit_table_row" => "NodeContext ctx, List<string> cells, bool isHeader",
3092 "visit_custom_element" => "NodeContext ctx, string tagName, string html",
3093 "visit_form" => "NodeContext ctx, string actionUrl, string method",
3094 "visit_input" => "NodeContext ctx, string inputType, string name, string value",
3095 "visit_audio" | "visit_video" | "visit_iframe" => "NodeContext ctx, string src",
3096 "visit_details" => "NodeContext ctx, bool isOpen",
3097 "visit_element_end" | "visit_table_end" | "visit_definition_list_end" | "visit_figure_end" => {
3098 "NodeContext ctx, string output"
3099 }
3100 "visit_list_start" => "NodeContext ctx, bool ordered",
3101 "visit_list_end" => "NodeContext ctx, bool ordered, string output",
3102 "visit_element_start"
3103 | "visit_table_start"
3104 | "visit_definition_list_start"
3105 | "visit_figure_start"
3106 | "visit_line_break"
3107 | "visit_horizontal_rule" => "NodeContext ctx",
3108 _ => "NodeContext ctx",
3109 };
3110
3111 let (action_type, action_value) = match action {
3112 CallbackAction::Skip => ("skip", String::new()),
3113 CallbackAction::Continue => ("continue", String::new()),
3114 CallbackAction::PreserveHtml => ("preserve_html", String::new()),
3115 CallbackAction::Custom { output } => ("custom", escape_csharp(output)),
3116 CallbackAction::CustomTemplate { template, .. } => {
3117 let camel = snake_case_template_to_camel(template);
3118 ("custom_template", escape_csharp(&camel))
3119 }
3120 };
3121
3122 let rendered = crate::template_env::render(
3123 "csharp/visitor_method.jinja",
3124 minijinja::context! {
3125 camel_method => camel_method,
3126 params => params,
3127 action_type => action_type,
3128 action_value => action_value,
3129 },
3130 );
3131 let _ = write!(decl, "{}", rendered);
3132}
3133
3134fn method_to_camel(snake: &str) -> String {
3136 use heck::ToUpperCamelCase;
3137 snake.to_upper_camel_case()
3138}
3139
3140fn snake_case_template_to_camel(template: &str) -> String {
3143 use heck::ToLowerCamelCase;
3144 let mut out = String::with_capacity(template.len());
3145 let mut chars = template.chars().peekable();
3146 while let Some(c) = chars.next() {
3147 if c == '{' {
3148 let mut name = String::new();
3149 while let Some(&nc) = chars.peek() {
3150 if nc == '}' {
3151 chars.next();
3152 break;
3153 }
3154 name.push(nc);
3155 chars.next();
3156 }
3157 out.push('{');
3158 out.push_str(&name.to_lower_camel_case());
3159 out.push('}');
3160 } else {
3161 out.push(c);
3162 }
3163 }
3164 out
3165}
3166
3167fn build_csharp_method_call(
3172 result_var: &str,
3173 method_name: &str,
3174 args: Option<&serde_json::Value>,
3175 class_name: &str,
3176) -> String {
3177 match method_name {
3178 "root_child_count" => format!("{result_var}.RootNode.ChildCount"),
3179 "root_node_type" => format!("{result_var}.RootNode.Kind"),
3180 "named_children_count" => format!("{result_var}.RootNode.NamedChildCount"),
3181 "has_error_nodes" => format!("{class_name}.TreeHasErrorNodes({result_var})"),
3182 "error_count" | "tree_error_count" => format!("{class_name}.TreeErrorCount({result_var})"),
3183 "tree_to_sexp" => format!("{class_name}.TreeToSexp({result_var})"),
3184 "contains_node_type" => {
3185 let node_type = args
3186 .and_then(|a| a.get("node_type"))
3187 .and_then(|v| v.as_str())
3188 .unwrap_or("");
3189 format!("{class_name}.TreeContainsNodeType({result_var}, \"{node_type}\")")
3190 }
3191 "find_nodes_by_type" => {
3192 let node_type = args
3193 .and_then(|a| a.get("node_type"))
3194 .and_then(|v| v.as_str())
3195 .unwrap_or("");
3196 format!("{class_name}.FindNodesByType({result_var}, \"{node_type}\")")
3197 }
3198 "run_query" => {
3199 let query_source = args
3200 .and_then(|a| a.get("query_source"))
3201 .and_then(|v| v.as_str())
3202 .unwrap_or("");
3203 let language = args
3204 .and_then(|a| a.get("language"))
3205 .and_then(|v| v.as_str())
3206 .unwrap_or("");
3207 format!("{class_name}.RunQuery({result_var}, \"{language}\", \"{query_source}\", source)")
3208 }
3209 _ => {
3210 use heck::ToUpperCamelCase;
3211 let pascal = method_name.to_upper_camel_case();
3212 format!("{result_var}.{pascal}()")
3213 }
3214 }
3215}
3216
3217fn fixture_has_csharp_callable(fixture: &Fixture, e2e_config: &E2eConfig) -> bool {
3218 if fixture.is_http_test() {
3220 return false;
3221 }
3222 let call_config = e2e_config.resolve_call_for_fixture(
3224 fixture.call.as_deref(),
3225 &fixture.id,
3226 &fixture.resolved_category(),
3227 &fixture.tags,
3228 &fixture.input,
3229 );
3230 let cs_override = call_config
3231 .overrides
3232 .get("csharp")
3233 .or_else(|| e2e_config.call.overrides.get("csharp"));
3234 if cs_override.and_then(|o| o.client_factory.as_deref()).is_some() {
3236 return true;
3237 }
3238 cs_override.and_then(|o| o.function.as_deref()).is_some() || !call_config.function.is_empty()
3241}
3242
3243fn classify_bytes_value_csharp(s: &str) -> String {
3246 if let Some(first) = s.chars().next() {
3249 if first.is_ascii_alphanumeric() || first == '_' {
3250 if let Some(slash_pos) = s.find('/') {
3251 if slash_pos > 0 {
3252 let after_slash = &s[slash_pos + 1..];
3253 if after_slash.contains('.') && !after_slash.is_empty() {
3254 return format!("System.IO.File.ReadAllBytes(\"{}\")", s);
3256 }
3257 }
3258 }
3259 }
3260 }
3261
3262 if s.starts_with('<') || s.starts_with('{') || s.starts_with('[') || s.contains(' ') {
3265 return format!("System.Text.Encoding.UTF8.GetBytes(\"{}\")", escape_csharp(s));
3267 }
3268
3269 format!("System.Convert.FromBase64String(\"{}\")", s)
3273}
3274
3275#[cfg(test)]
3276mod tests {
3277 use crate::config::{CallConfig, E2eConfig, SelectWhen};
3278 use crate::fixture::Fixture;
3279 use std::collections::HashMap;
3280
3281 fn make_fixture_with_input(id: &str, input: serde_json::Value) -> Fixture {
3282 Fixture {
3283 id: id.to_string(),
3284 category: None,
3285 description: "test fixture".to_string(),
3286 tags: vec![],
3287 skip: None,
3288 env: None,
3289 call: None,
3290 input,
3291 mock_response: None,
3292 source: String::new(),
3293 http: None,
3294 assertions: vec![],
3295 visitor: None,
3296 }
3297 }
3298
3299 #[test]
3302 fn test_csharp_select_when_routes_to_batch_scrape() {
3303 let mut calls = HashMap::new();
3304 calls.insert(
3305 "batch_scrape".to_string(),
3306 CallConfig {
3307 function: "BatchScrape".to_string(),
3308 module: "KreuzBrowser".to_string(),
3309 select_when: Some(SelectWhen {
3310 input_has: Some("batch_urls".to_string()),
3311 ..Default::default()
3312 }),
3313 ..CallConfig::default()
3314 },
3315 );
3316
3317 let e2e_config = E2eConfig {
3318 call: CallConfig {
3319 function: "Scrape".to_string(),
3320 module: "KreuzBrowser".to_string(),
3321 ..CallConfig::default()
3322 },
3323 calls,
3324 ..E2eConfig::default()
3325 };
3326
3327 let fixture = make_fixture_with_input("batch_empty_urls", serde_json::json!({ "batch_urls": [] }));
3329
3330 let resolved_call = e2e_config.resolve_call_for_fixture(
3331 fixture.call.as_deref(),
3332 &fixture.id,
3333 &fixture.resolved_category(),
3334 &fixture.tags,
3335 &fixture.input,
3336 );
3337 assert_eq!(resolved_call.function, "BatchScrape");
3338
3339 let fixture_no_batch =
3341 make_fixture_with_input("simple_scrape", serde_json::json!({ "url": "https://example.com" }));
3342 let resolved_default = e2e_config.resolve_call_for_fixture(
3343 fixture_no_batch.call.as_deref(),
3344 &fixture_no_batch.id,
3345 &fixture_no_batch.resolved_category(),
3346 &fixture_no_batch.tags,
3347 &fixture_no_batch.input,
3348 );
3349 assert_eq!(resolved_default.function, "Scrape");
3350 }
3351}