1use crate::config::E2eConfig;
7use crate::escape::{escape_kotlin, sanitize_filename, sanitize_ident};
8use crate::field_access::FieldResolver;
9use crate::fixture::{Assertion, 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::{maven, toolchain};
14use anyhow::Result;
15use heck::{ToLowerCamelCase, ToUpperCamelCase};
16use std::collections::HashSet;
17use std::fmt::Write as FmtWrite;
18use std::path::PathBuf;
19
20use super::E2eCodegen;
21use super::client;
22
23pub struct KotlinE2eCodegen;
25
26impl E2eCodegen for KotlinE2eCodegen {
27 fn generate(
28 &self,
29 groups: &[FixtureGroup],
30 e2e_config: &E2eConfig,
31 config: &ResolvedCrateConfig,
32 type_defs: &[alef_core::ir::TypeDef],
33 ) -> Result<Vec<GeneratedFile>> {
34 let lang = self.language_name();
35 let output_base = PathBuf::from(e2e_config.effective_output()).join(lang);
36
37 let mut files = Vec::new();
38
39 let call = &e2e_config.call;
41 let overrides = call.overrides.get(lang);
42 let _module_path = overrides
43 .and_then(|o| o.module.as_ref())
44 .cloned()
45 .unwrap_or_else(|| call.module.clone());
46 let function_name = overrides
47 .and_then(|o| o.function.as_ref())
48 .cloned()
49 .unwrap_or_else(|| call.function.clone());
50 let class_name = overrides
51 .and_then(|o| o.class.as_ref())
52 .cloned()
53 .unwrap_or_else(|| config.name.to_upper_camel_case());
54 let result_is_simple = overrides.is_some_and(|o| o.result_is_simple);
55 let result_var = &call.result_var;
56
57 let kotlin_pkg = e2e_config.resolve_package("kotlin");
59 let pkg_name = kotlin_pkg
60 .as_ref()
61 .and_then(|p| p.name.as_ref())
62 .cloned()
63 .unwrap_or_else(|| config.name.clone());
64
65 let _kotlin_pkg_path = kotlin_pkg
67 .as_ref()
68 .and_then(|p| p.path.as_ref())
69 .cloned()
70 .unwrap_or_else(|| "../../packages/kotlin".to_string());
71 let kotlin_version = kotlin_pkg
72 .as_ref()
73 .and_then(|p| p.version.as_ref())
74 .cloned()
75 .or_else(|| config.resolved_version())
76 .unwrap_or_else(|| "0.1.0".to_string());
77 let kotlin_pkg_id = config.kotlin_package();
78
79 let needs_mock_server = groups
85 .iter()
86 .flat_map(|g| g.fixtures.iter())
87 .any(|f| f.needs_mock_server());
88
89 files.push(GeneratedFile {
91 path: output_base.join("build.gradle.kts"),
92 content: render_build_gradle(
93 &pkg_name,
94 &kotlin_pkg_id,
95 &kotlin_version,
96 e2e_config.dep_mode,
97 needs_mock_server,
98 ),
99 generated_header: false,
100 });
101
102 let mut test_base = output_base.join("src").join("test").join("kotlin");
106 for segment in kotlin_pkg_id.split('.') {
107 test_base = test_base.join(segment);
108 }
109 let test_base = test_base.join("e2e");
110
111 if needs_mock_server {
112 files.push(GeneratedFile {
113 path: test_base.join("MockServerListener.kt"),
114 content: render_mock_server_listener_kt(&kotlin_pkg_id),
115 generated_header: true,
116 });
117 files.push(GeneratedFile {
118 path: output_base
119 .join("src")
120 .join("test")
121 .join("resources")
122 .join("META-INF")
123 .join("services")
124 .join("org.junit.platform.launcher.LauncherSessionListener"),
125 content: format!("{kotlin_pkg_id}.e2e.MockServerListener\n"),
126 generated_header: false,
127 });
128 }
129
130 let options_type = overrides.and_then(|o| o.options_type.clone());
132 let field_resolver = FieldResolver::new(
133 &e2e_config.fields,
134 &e2e_config.fields_optional,
135 &e2e_config.result_fields,
136 &e2e_config.fields_array,
137 &HashSet::new(),
138 );
139
140 let struct_names: HashSet<&str> = type_defs.iter().map(|td| td.name.as_str()).collect();
146 let type_enum_fields: std::collections::HashMap<String, HashSet<String>> = type_defs
147 .iter()
148 .filter_map(|td| {
149 let enum_field_names: HashSet<String> = td
150 .fields
151 .iter()
152 .filter(|field| is_enum_typed(&field.ty, &struct_names))
153 .map(|field| field.name.clone())
154 .collect();
155 if enum_field_names.is_empty() {
156 None
157 } else {
158 Some((td.name.clone(), enum_field_names))
159 }
160 })
161 .collect();
162
163 for group in groups {
164 let active: Vec<&Fixture> = group
165 .fixtures
166 .iter()
167 .filter(|f| super::should_include_fixture(f, lang, e2e_config))
168 .collect();
169
170 if active.is_empty() {
171 continue;
172 }
173
174 let class_file_name = format!("{}Test.kt", sanitize_filename(&group.category).to_upper_camel_case());
175 let content = render_test_file(
176 &group.category,
177 &active,
178 &class_name,
179 &function_name,
180 &kotlin_pkg_id,
181 result_var,
182 &e2e_config.call.args,
183 options_type.as_deref(),
184 &field_resolver,
185 result_is_simple,
186 &e2e_config.fields_enum,
187 e2e_config,
188 &type_enum_fields,
189 );
190 files.push(GeneratedFile {
191 path: test_base.join(class_file_name),
192 content,
193 generated_header: true,
194 });
195 }
196
197 Ok(files)
198 }
199
200 fn language_name(&self) -> &'static str {
201 "kotlin"
202 }
203}
204
205fn is_enum_typed(ty: &alef_core::ir::TypeRef, struct_names: &HashSet<&str>) -> bool {
213 use alef_core::ir::TypeRef;
214 match ty {
215 TypeRef::Named(name) => !struct_names.contains(name.as_str()),
216 TypeRef::Optional(inner) => {
217 matches!(inner.as_ref(), TypeRef::Named(name) if !struct_names.contains(name.as_str()))
218 }
219 _ => false,
220 }
221}
222
223pub(crate) fn render_build_gradle(
228 pkg_name: &str,
229 kotlin_pkg_id: &str,
230 pkg_version: &str,
231 dep_mode: crate::config::DependencyMode,
232 needs_mock_server: bool,
233) -> String {
234 let dep_block = match dep_mode {
235 crate::config::DependencyMode::Registry => {
236 format!(r#" testImplementation("{kotlin_pkg_id}:{pkg_name}:{pkg_version}")"#)
238 }
239 crate::config::DependencyMode::Local => {
240 let jar_name = pkg_name.rsplit(':').next().unwrap_or(pkg_name).replace('-', "_");
247 let jna = maven::JNA;
248 let jackson = maven::JACKSON_E2E;
249 let jspecify = maven::JSPECIFY;
250 let coroutines = maven::KOTLINX_COROUTINES_CORE;
251 format!(
252 r#" testImplementation(files("../../packages/kotlin/build/libs/{jar_name}-{pkg_version}.jar"))
253 testImplementation("net.java.dev.jna:jna:{jna}")
254 testImplementation("com.fasterxml.jackson.core:jackson-annotations:{jackson}")
255 testImplementation("com.fasterxml.jackson.core:jackson-databind:{jackson}")
256 testImplementation("com.fasterxml.jackson.datatype:jackson-datatype-jdk8:{jackson}")
257 testImplementation("org.jspecify:jspecify:{jspecify}")
258 testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:{coroutines}")"#
259 )
260 }
261 };
262
263 let kotlin_plugin = maven::KOTLIN_JVM_PLUGIN;
264 let junit = maven::JUNIT;
265 let jackson = maven::JACKSON_E2E;
266 let jvm_target = toolchain::JVM_TARGET;
267 let launcher_dep = if needs_mock_server {
268 format!(r#" testImplementation("org.junit.platform:junit-platform-launcher:{junit}")"#)
269 } else {
270 String::new()
271 };
272 format!(
273 r#"import org.jetbrains.kotlin.gradle.dsl.JvmTarget
274
275plugins {{
276 kotlin("jvm") version "{kotlin_plugin}"
277 java
278}}
279
280group = "{kotlin_pkg_id}"
281version = "0.1.0"
282
283java {{
284 sourceCompatibility = JavaVersion.VERSION_{jvm_target}
285 targetCompatibility = JavaVersion.VERSION_{jvm_target}
286}}
287
288kotlin {{
289 compilerOptions {{
290 jvmTarget.set(JvmTarget.JVM_{jvm_target})
291 }}
292}}
293
294repositories {{
295 mavenCentral()
296}}
297
298dependencies {{
299{dep_block}
300 testImplementation("org.junit.jupiter:junit-jupiter-api:{junit}")
301 testImplementation("org.junit.jupiter:junit-jupiter-engine:{junit}")
302{launcher_dep}
303 testImplementation("com.fasterxml.jackson.core:jackson-databind:{jackson}")
304 testImplementation("com.fasterxml.jackson.datatype:jackson-datatype-jdk8:{jackson}")
305 testImplementation(kotlin("test"))
306}}
307
308tasks.test {{
309 useJUnitPlatform()
310 val libPath = System.getProperty("native.lib.path") ?: "${{rootDir}}/../../target/release"
311 systemProperty("java.library.path", libPath)
312 systemProperty("jna.library.path", libPath)
313 // Resolve fixture paths (e.g. "docx/fake.docx") against test_documents/.
314 workingDir = file("${{rootDir}}/../../test_documents")
315}}
316"#
317 )
318}
319
320pub(crate) fn render_mock_server_listener_kt(kotlin_pkg_id: &str) -> String {
329 let header = hash::header(CommentStyle::DoubleSlash);
330 format!(
331 r#"{header}package {kotlin_pkg_id}.e2e
332
333import java.io.BufferedReader
334import java.io.IOException
335import java.io.InputStreamReader
336import java.nio.charset.StandardCharsets
337import java.nio.file.Path
338import java.nio.file.Paths
339import java.util.regex.Pattern
340import org.junit.platform.launcher.LauncherSession
341import org.junit.platform.launcher.LauncherSessionListener
342
343/**
344 * Spawns the mock-server binary once per JUnit launcher session and
345 * exposes its URL as the `mockServerUrl` system property. Generated
346 * test bodies read the property (with `MOCK_SERVER_URL` env-var
347 * fallback) so tests can run via plain `./gradlew test` without any
348 * external mock-server orchestration. Mirrors the Ruby spec_helper /
349 * Python conftest spawn pattern. Honors a pre-set MOCK_SERVER_URL by
350 * skipping the spawn entirely.
351 */
352class MockServerListener : LauncherSessionListener {{
353 private var mockServer: Process? = null
354
355 override fun launcherSessionOpened(session: LauncherSession) {{
356 val preset = System.getenv("MOCK_SERVER_URL")
357 if (!preset.isNullOrEmpty()) {{
358 System.setProperty("mockServerUrl", preset)
359 return
360 }}
361 val repoRoot = locateRepoRoot()
362 ?: error("MockServerListener: could not locate repo root (looked for fixtures/ in ancestors of ${{System.getProperty("user.dir")}})")
363 val binName = if (System.getProperty("os.name", "").lowercase().contains("win")) "mock-server.exe" else "mock-server"
364 val bin = repoRoot.resolve("e2e").resolve("rust").resolve("target").resolve("release").resolve(binName).toFile()
365 val fixturesDir = repoRoot.resolve("fixtures").toFile()
366 check(bin.exists()) {{
367 "MockServerListener: mock-server binary not found at $bin — run: cargo build --manifest-path e2e/rust/Cargo.toml --bin mock-server --release"
368 }}
369 val pb = ProcessBuilder(bin.absolutePath, fixturesDir.absolutePath)
370 .redirectErrorStream(false)
371 val server = try {{
372 pb.start()
373 }} catch (e: IOException) {{
374 throw IllegalStateException("MockServerListener: failed to start mock-server", e)
375 }}
376 mockServer = server
377 // Read until we see MOCK_SERVER_URL= and optionally MOCK_SERVERS=.
378 // Cap the loop so a misbehaving mock-server cannot block indefinitely.
379 val stdout = BufferedReader(InputStreamReader(server.inputStream, StandardCharsets.UTF_8))
380 var url: String? = null
381 try {{
382 for (i in 0 until 16) {{
383 val line = stdout.readLine() ?: break
384 when {{
385 line.startsWith("MOCK_SERVER_URL=") -> {{
386 url = line.removePrefix("MOCK_SERVER_URL=").trim()
387 }}
388 line.startsWith("MOCK_SERVERS=") -> {{
389 val jsonVal = line.removePrefix("MOCK_SERVERS=").trim()
390 System.setProperty("mockServers", jsonVal)
391 // Parse JSON map of fixture_id -> url and expose as system properties.
392 val p = Pattern.compile(""""([^"]+)":"([^"]+)"""")
393 val matcher = p.matcher(jsonVal)
394 while (matcher.find()) {{
395 System.setProperty("mockServer.${{matcher.group(1)}}", matcher.group(2))
396 }}
397 break
398 }}
399 url != null -> break
400 }}
401 }}
402 }} catch (e: IOException) {{
403 server.destroyForcibly()
404 throw IllegalStateException("MockServerListener: failed to read mock-server stdout", e)
405 }}
406 if (url.isNullOrEmpty()) {{
407 server.destroyForcibly()
408 error("MockServerListener: mock-server did not emit MOCK_SERVER_URL")
409 }}
410 // TCP-readiness probe: ensure axum::serve is accepting before tests start.
411 // The mock-server binds the TcpListener synchronously then prints the URL
412 // before tokio::spawn(axum::serve(...)) is polled, so under Gradle parallel
413 // mode tests can race startup. Poll-connect (max 5s, 50ms backoff) until success.
414 val healthUri = java.net.URI.create(url)
415 val host = healthUri.host
416 val port = healthUri.port
417 val deadline = System.nanoTime() + 5_000_000_000L
418 while (System.nanoTime() < deadline) {{
419 try {{
420 java.net.Socket().use {{ s ->
421 s.connect(java.net.InetSocketAddress(host, port), 100)
422 break
423 }}
424 }} catch (_: java.io.IOException) {{
425 try {{ Thread.sleep(50) }} catch (ie: InterruptedException) {{ Thread.currentThread().interrupt(); break }}
426 }}
427 }}
428 System.setProperty("mockServerUrl", url)
429 // Drain remaining stdout/stderr in daemon threads so a full pipe
430 // does not block the child.
431 Thread {{ drain(stdout) }}.also {{ it.isDaemon = true }}.start()
432 Thread {{ drain(BufferedReader(InputStreamReader(server.errorStream, StandardCharsets.UTF_8))) }}.also {{ it.isDaemon = true }}.start()
433 }}
434
435 override fun launcherSessionClosed(session: LauncherSession) {{
436 val server = mockServer ?: return
437 try {{ server.outputStream.close() }} catch (_: IOException) {{}}
438 try {{
439 if (!server.waitFor(2, java.util.concurrent.TimeUnit.SECONDS)) {{
440 server.destroyForcibly()
441 }}
442 }} catch (ie: InterruptedException) {{
443 Thread.currentThread().interrupt()
444 server.destroyForcibly()
445 }}
446 }}
447
448 companion object {{
449 private fun locateRepoRoot(): Path? {{
450 var dir: Path? = Paths.get("").toAbsolutePath()
451 while (dir != null) {{
452 if (dir.resolve("fixtures").toFile().isDirectory
453 && dir.resolve("e2e").toFile().isDirectory) {{
454 return dir
455 }}
456 dir = dir.parent
457 }}
458 return null
459 }}
460
461 private fun drain(reader: BufferedReader) {{
462 try {{
463 val buf = CharArray(1024)
464 while (reader.read(buf) >= 0) {{ /* drain */ }}
465 }} catch (_: IOException) {{}}
466 }}
467 }}
468}}
469"#
470 )
471}
472
473#[allow(clippy::too_many_arguments)]
474pub(crate) fn render_test_file(
475 category: &str,
476 fixtures: &[&Fixture],
477 class_name: &str,
478 function_name: &str,
479 kotlin_pkg_id: &str,
480 result_var: &str,
481 args: &[crate::config::ArgMapping],
482 options_type: Option<&str>,
483 field_resolver: &FieldResolver,
484 result_is_simple: bool,
485 enum_fields: &HashSet<String>,
486 e2e_config: &E2eConfig,
487 type_enum_fields: &std::collections::HashMap<String, HashSet<String>>,
488) -> String {
489 render_test_file_inner(
490 category,
491 fixtures,
492 class_name,
493 function_name,
494 kotlin_pkg_id,
495 result_var,
496 args,
497 options_type,
498 field_resolver,
499 result_is_simple,
500 enum_fields,
501 e2e_config,
502 type_enum_fields,
503 false,
504 )
505}
506
507#[allow(clippy::too_many_arguments)]
522pub(crate) fn render_test_file_android(
523 category: &str,
524 fixtures: &[&Fixture],
525 class_name: &str,
526 function_name: &str,
527 kotlin_pkg_id: &str,
528 result_var: &str,
529 args: &[crate::config::ArgMapping],
530 options_type: Option<&str>,
531 field_resolver: &FieldResolver,
532 result_is_simple: bool,
533 enum_fields: &HashSet<String>,
534 e2e_config: &E2eConfig,
535 type_enum_fields: &std::collections::HashMap<String, HashSet<String>>,
536) -> String {
537 render_test_file_inner(
538 category,
539 fixtures,
540 class_name,
541 function_name,
542 kotlin_pkg_id,
543 result_var,
544 args,
545 options_type,
546 field_resolver,
547 result_is_simple,
548 enum_fields,
549 e2e_config,
550 type_enum_fields,
551 true,
552 )
553}
554
555#[allow(clippy::too_many_arguments)]
556fn render_test_file_inner(
557 category: &str,
558 fixtures: &[&Fixture],
559 class_name: &str,
560 function_name: &str,
561 kotlin_pkg_id: &str,
562 result_var: &str,
563 args: &[crate::config::ArgMapping],
564 options_type: Option<&str>,
565 field_resolver: &FieldResolver,
566 result_is_simple: bool,
567 enum_fields: &HashSet<String>,
568 e2e_config: &E2eConfig,
569 type_enum_fields: &std::collections::HashMap<String, HashSet<String>>,
570 kotlin_android_style: bool,
571) -> String {
572 let mut out = String::new();
573 out.push_str(&hash::header(CommentStyle::DoubleSlash));
574 let test_class_name = format!("{}Test", sanitize_filename(category).to_upper_camel_case());
575
576 let (import_path, simple_class) = if class_name.contains('.') {
579 let simple = class_name.rsplit('.').next().unwrap_or(class_name);
580 (class_name, simple)
581 } else {
582 ("", class_name)
583 };
584
585 let _ = writeln!(out, "package {kotlin_pkg_id}.e2e");
586 let _ = writeln!(out);
587
588 let has_http_fixtures = fixtures.iter().any(|f| f.is_http_test());
590
591 let has_client_factory_fixtures = fixtures.iter().any(|f| {
594 if f.is_http_test() {
595 return false;
596 }
597 let cc = e2e_config.resolve_call_for_fixture(f.call.as_deref(), &f.input);
598 let per_call_factory = cc.overrides.get("kotlin").and_then(|o| o.client_factory.as_deref());
599 let global_factory = e2e_config
600 .call
601 .overrides
602 .get("kotlin")
603 .and_then(|o| o.client_factory.as_deref());
604 per_call_factory.or(global_factory).is_some()
605 });
606
607 let mut per_fixture_options_types: HashSet<String> = HashSet::new();
611 for f in fixtures.iter() {
612 let cc = e2e_config.resolve_call_for_fixture(f.call.as_deref(), &f.input);
613 let call_overrides = cc.overrides.get("kotlin");
614 let effective_opts: Option<String> = call_overrides
615 .and_then(|o| o.options_type.clone())
616 .or_else(|| options_type.map(|s| s.to_string()))
617 .or_else(|| {
618 for cand in ["csharp", "c", "go", "php", "python"] {
619 if let Some(o) = cc.overrides.get(cand) {
620 if let Some(t) = &o.options_type {
621 return Some(t.clone());
622 }
623 }
624 }
625 None
626 });
627 if let Some(opts) = effective_opts {
628 let fixture_args = if cc.args.is_empty() { args } else { cc.args.as_slice() };
631 let needs_opts_type = fixture_args.iter().any(|arg| {
636 if arg.arg_type != "json_object" {
637 return false;
638 }
639 let v = super::resolve_field(&f.input, &arg.field);
640 !v.is_null() || arg.optional
641 });
642 if needs_opts_type {
643 per_fixture_options_types.insert(opts.to_string());
644 }
645 }
646 }
647 let needs_object_mapper_for_options = !per_fixture_options_types.is_empty();
648 let needs_object_mapper_for_handle = fixtures.iter().any(|f| {
650 args.iter().filter(|a| a.arg_type == "handle").any(|a| {
651 let v = f.input.get(&a.field).unwrap_or(&serde_json::Value::Null);
652 !(v.is_null() || v.is_object() && v.as_object().is_some_and(|o| o.is_empty()))
653 })
654 });
655 let needs_object_mapper = needs_object_mapper_for_options || needs_object_mapper_for_handle || has_http_fixtures;
657
658 let _ = writeln!(out, "import org.junit.jupiter.api.Test");
659 let _ = writeln!(out, "import kotlin.test.assertEquals");
660 let _ = writeln!(out, "import kotlin.test.assertTrue");
661 let _ = writeln!(out, "import kotlin.test.assertFalse");
662 let _ = writeln!(out, "import kotlin.test.assertFailsWith");
663 if has_client_factory_fixtures || kotlin_android_style {
664 let _ = writeln!(out, "import kotlinx.coroutines.runBlocking");
665 }
666 let binding_pkg_for_imports: String = if !import_path.is_empty() {
672 import_path
673 .rsplit_once('.')
674 .map(|(p, _)| p.to_string())
675 .unwrap_or_else(|| kotlin_pkg_id.to_string())
676 } else {
677 kotlin_pkg_id.to_string()
678 };
679 let has_call_fixtures = fixtures.iter().any(|f| !f.is_http_test());
681 if has_call_fixtures {
682 if !import_path.is_empty() {
683 let _ = writeln!(out, "import {import_path}");
684 } else if !class_name.is_empty() {
685 let _ = writeln!(out, "import {binding_pkg_for_imports}.{class_name}");
686 }
687 }
688 if needs_object_mapper {
689 let _ = writeln!(out, "import com.fasterxml.jackson.databind.ObjectMapper");
690 let _ = writeln!(out, "import com.fasterxml.jackson.datatype.jdk8.Jdk8Module");
691 }
692 if has_call_fixtures {
696 let mut sorted_opts: Vec<&String> = per_fixture_options_types.iter().collect();
697 sorted_opts.sort();
698 for opts_type in sorted_opts {
699 let _ = writeln!(out, "import {binding_pkg_for_imports}.{opts_type}");
700 }
701 }
702 if needs_object_mapper_for_handle {
704 let _ = writeln!(out, "import {binding_pkg_for_imports}.CrawlConfig");
705 }
706 let mut batch_elem_imports: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
709 for f in fixtures.iter() {
710 let cc = e2e_config.resolve_call_for_fixture(f.call.as_deref(), &f.input);
711 let fixture_args = if cc.args.is_empty() { args } else { cc.args.as_slice() };
712 for arg in fixture_args.iter() {
713 if arg.arg_type != "json_object" {
714 continue;
715 }
716 let v = super::resolve_field(&f.input, &arg.field);
717 if !v.is_array() {
718 continue;
719 }
720 if let Some(elem) = &arg.element_type {
721 if elem == "BatchBytesItem" || elem == "BatchFileItem" {
722 batch_elem_imports.insert(elem.clone());
723 }
724 }
725 }
726 }
727 for elem in &batch_elem_imports {
728 let _ = writeln!(out, "import {binding_pkg_for_imports}.{elem}");
729 }
730 let _ = writeln!(out);
731
732 let _ = writeln!(out, "/** E2e tests for category: {category}. */");
733 let _ = writeln!(out, "class {test_class_name} {{");
734
735 if needs_object_mapper {
736 let _ = writeln!(out);
737 let _ = writeln!(out, " companion object {{");
738 let _ = writeln!(
739 out,
740 " private val MAPPER = ObjectMapper().registerModule(Jdk8Module()).setPropertyNamingStrategy(com.fasterxml.jackson.databind.PropertyNamingStrategies.SNAKE_CASE)"
741 );
742 let _ = writeln!(out, " }}");
743 }
744
745 for fixture in fixtures {
746 render_test_method(
747 &mut out,
748 fixture,
749 simple_class,
750 function_name,
751 result_var,
752 args,
753 options_type,
754 field_resolver,
755 result_is_simple,
756 enum_fields,
757 e2e_config,
758 type_enum_fields,
759 kotlin_android_style,
760 );
761 let _ = writeln!(out);
762 }
763
764 let _ = writeln!(out, "}}");
765 out
766}
767
768pub(crate) struct KotlinTestClientRenderer;
775
776impl client::TestClientRenderer for KotlinTestClientRenderer {
777 fn language_name(&self) -> &'static str {
778 "kotlin"
779 }
780
781 fn sanitize_test_name(&self, id: &str) -> String {
782 sanitize_ident(id).to_upper_camel_case()
783 }
784
785 fn render_test_open(&self, out: &mut String, fn_name: &str, description: &str, skip_reason: Option<&str>) {
786 let _ = writeln!(out, " @Test");
787 let _ = writeln!(out, " fun test{fn_name}() {{");
788 let _ = writeln!(out, " // {description}");
789 if let Some(reason) = skip_reason {
790 let escaped = escape_kotlin(reason);
791 let _ = writeln!(
792 out,
793 " org.junit.jupiter.api.Assumptions.assumeTrue(false, \"{escaped}\")"
794 );
795 }
796 }
797
798 fn render_test_close(&self, out: &mut String) {
799 let _ = writeln!(out, " }}");
800 }
801
802 fn render_call(&self, out: &mut String, ctx: &client::CallCtx<'_>) {
803 let method = ctx.method.to_uppercase();
804 let fixture_path = ctx.path;
805
806 const JAVA_RESTRICTED_HEADERS: &[&str] = &["connection", "content-length", "expect", "host", "upgrade"];
808
809 let _ = writeln!(
810 out,
811 " val baseUrl = System.getenv(\"MOCK_SERVER_URL\") ?: \"http://localhost:8080\""
812 );
813 let _ = writeln!(out, " val uri = java.net.URI.create(\"$baseUrl{fixture_path}\")");
814
815 let body_publisher = if let Some(body) = ctx.body {
816 let json = serde_json::to_string(body).unwrap_or_default();
817 let escaped = escape_kotlin(&json);
818 format!("java.net.http.HttpRequest.BodyPublishers.ofString(\"{escaped}\")")
819 } else {
820 "java.net.http.HttpRequest.BodyPublishers.noBody()".to_string()
821 };
822
823 let _ = writeln!(out, " val builder = java.net.http.HttpRequest.newBuilder(uri)");
824 let _ = writeln!(out, " .method(\"{method}\", {body_publisher})");
825
826 if ctx.body.is_some() {
828 let content_type = ctx.content_type.unwrap_or("application/json");
829 let _ = writeln!(out, " .header(\"Content-Type\", \"{content_type}\")");
830 }
831
832 let mut header_pairs: Vec<(&String, &String)> = ctx.headers.iter().collect();
834 header_pairs.sort_by_key(|(k, _)| k.as_str());
835 for (name, value) in &header_pairs {
836 if JAVA_RESTRICTED_HEADERS.contains(&name.to_lowercase().as_str()) {
837 continue;
838 }
839 let escaped_name = escape_kotlin(name);
840 let escaped_value = escape_kotlin(value);
841 let _ = writeln!(out, " .header(\"{escaped_name}\", \"{escaped_value}\")");
842 }
843
844 if !ctx.cookies.is_empty() {
846 let mut cookie_pairs: Vec<(&String, &String)> = ctx.cookies.iter().collect();
847 cookie_pairs.sort_by_key(|(k, _)| k.as_str());
848 let cookie_str: Vec<String> = cookie_pairs.iter().map(|(k, v)| format!("{k}={v}")).collect();
849 let cookie_header = escape_kotlin(&cookie_str.join("; "));
850 let _ = writeln!(out, " .header(\"Cookie\", \"{cookie_header}\")");
851 }
852
853 let _ = writeln!(
854 out,
855 " val {} = java.net.http.HttpClient.newHttpClient()",
856 ctx.response_var
857 );
858 let _ = writeln!(
859 out,
860 " .send(builder.build(), java.net.http.HttpResponse.BodyHandlers.ofString())"
861 );
862 }
863
864 fn render_assert_status(&self, out: &mut String, response_var: &str, status: u16) {
865 let _ = writeln!(
866 out,
867 " assertEquals({status}, {response_var}.statusCode(), \"status code mismatch\")"
868 );
869 }
870
871 fn render_assert_header(&self, out: &mut String, response_var: &str, name: &str, expected: &str) {
872 let escaped_name = escape_kotlin(name);
873 match expected {
874 "<<present>>" => {
875 let _ = writeln!(
876 out,
877 " assertTrue({response_var}.headers().firstValue(\"{escaped_name}\").isPresent, \"header {escaped_name} should be present\")"
878 );
879 }
880 "<<absent>>" => {
881 let _ = writeln!(
882 out,
883 " assertFalse({response_var}.headers().firstValue(\"{escaped_name}\").isPresent, \"header {escaped_name} should be absent\")"
884 );
885 }
886 "<<uuid>>" => {
887 let _ = writeln!(
888 out,
889 " assertTrue({response_var}.headers().firstValue(\"{escaped_name}\").orElse(\"\").matches(\"[0-9a-f]{{8}}-[0-9a-f]{{4}}-[0-9a-f]{{4}}-[0-9a-f]{{4}}-[0-9a-f]{{12}}\"), \"header {escaped_name} should be a UUID\")"
890 );
891 }
892 exact => {
893 let escaped_value = escape_kotlin(exact);
894 let _ = writeln!(
895 out,
896 " assertTrue({response_var}.headers().firstValue(\"{escaped_name}\").orElse(\"\").contains(\"{escaped_value}\"), \"header {escaped_name} mismatch\")"
897 );
898 }
899 }
900 }
901
902 fn render_assert_json_body(&self, out: &mut String, response_var: &str, expected: &serde_json::Value) {
903 match expected {
904 serde_json::Value::Object(_) | serde_json::Value::Array(_) => {
905 let json_str = serde_json::to_string(expected).unwrap_or_default();
906 let escaped = escape_kotlin(&json_str);
907 let _ = writeln!(out, " val bodyJson = MAPPER.readTree({response_var}.body())");
908 let _ = writeln!(out, " val expectedJson = MAPPER.readTree(\"{escaped}\")");
909 let _ = writeln!(out, " assertEquals(expectedJson, bodyJson, \"body mismatch\")");
910 }
911 serde_json::Value::String(s) => {
912 let escaped = escape_kotlin(s);
913 let _ = writeln!(
914 out,
915 " assertEquals(\"{escaped}\", {response_var}.body().trim(), \"body mismatch\")"
916 );
917 }
918 other => {
919 let escaped = escape_kotlin(&other.to_string());
920 let _ = writeln!(
921 out,
922 " assertEquals(\"{escaped}\", {response_var}.body().trim(), \"body mismatch\")"
923 );
924 }
925 }
926 }
927
928 fn render_assert_partial_body(&self, out: &mut String, response_var: &str, expected: &serde_json::Value) {
929 if let Some(obj) = expected.as_object() {
930 let _ = writeln!(out, " val _partialTree = MAPPER.readTree({response_var}.body())");
931 for (key, val) in obj {
932 let escaped_key = escape_kotlin(key);
933 match val {
934 serde_json::Value::String(s) => {
935 let escaped_val = escape_kotlin(s);
936 let _ = writeln!(
937 out,
938 " assertEquals(\"{escaped_val}\", _partialTree.path(\"{escaped_key}\").asText(), \"partial body field '{escaped_key}' mismatch\")"
939 );
940 }
941 serde_json::Value::Bool(b) => {
942 let _ = writeln!(
943 out,
944 " assertEquals({b}, _partialTree.path(\"{escaped_key}\").asBoolean(), \"partial body field '{escaped_key}' mismatch\")"
945 );
946 }
947 serde_json::Value::Number(n) => {
948 let _ = writeln!(
949 out,
950 " assertEquals({n}, _partialTree.path(\"{escaped_key}\").numberValue(), \"partial body field '{escaped_key}' mismatch\")"
951 );
952 }
953 other => {
954 let json_str = serde_json::to_string(other).unwrap_or_default();
955 let escaped_val = escape_kotlin(&json_str);
956 let _ = writeln!(
957 out,
958 " assertEquals(MAPPER.readTree(\"{escaped_val}\"), _partialTree.path(\"{escaped_key}\"), \"partial body field '{escaped_key}' mismatch\")"
959 );
960 }
961 }
962 }
963 }
964 }
965
966 fn render_assert_validation_errors(
967 &self,
968 out: &mut String,
969 response_var: &str,
970 errors: &[ValidationErrorExpectation],
971 ) {
972 let _ = writeln!(out, " val _veTree = MAPPER.readTree({response_var}.body())");
973 let _ = writeln!(out, " val _veErrors = _veTree.path(\"errors\")");
974 for ve in errors {
975 let escaped_msg = escape_kotlin(&ve.msg);
976 let _ = writeln!(
977 out,
978 " assertTrue((0 until _veErrors.size()).any {{ _veErrors.get(it).path(\"msg\").asText().contains(\"{escaped_msg}\") }}, \"expected validation error containing: {escaped_msg}\")"
979 );
980 }
981 }
982}
983
984fn render_http_test_method(out: &mut String, fixture: &Fixture, http: &HttpFixture) {
989 if http.expected_response.status_code == 101 {
991 let method_name = sanitize_ident(&fixture.id).to_upper_camel_case();
992 let description = &fixture.description;
993 let _ = writeln!(out, " @Test");
994 let _ = writeln!(out, " fun test{method_name}() {{");
995 let _ = writeln!(out, " // {description}");
996 let _ = writeln!(
997 out,
998 " org.junit.jupiter.api.Assumptions.assumeTrue(false, \"Skipped: Java HttpClient cannot handle 101 Switching Protocols responses\")"
999 );
1000 let _ = writeln!(out, " }}");
1001 return;
1002 }
1003
1004 client::http_call::render_http_test(out, &KotlinTestClientRenderer, fixture);
1005}
1006
1007#[allow(clippy::too_many_arguments)]
1008fn render_test_method(
1009 out: &mut String,
1010 fixture: &Fixture,
1011 class_name: &str,
1012 _function_name: &str,
1013 _result_var: &str,
1014 _args: &[crate::config::ArgMapping],
1015 options_type: Option<&str>,
1016 field_resolver: &FieldResolver,
1017 result_is_simple: bool,
1018 enum_fields: &HashSet<String>,
1019 e2e_config: &E2eConfig,
1020 type_enum_fields: &std::collections::HashMap<String, HashSet<String>>,
1021 kotlin_android_style: bool,
1022) {
1023 if let Some(http) = &fixture.http {
1025 render_http_test_method(out, fixture, http);
1026 return;
1027 }
1028
1029 let call_config = e2e_config.resolve_call_for_fixture(fixture.call.as_deref(), &fixture.input);
1031 let lang = "kotlin";
1032 let call_overrides = call_config.overrides.get(lang);
1033
1034 let client_factory = call_overrides.and_then(|o| o.client_factory.as_deref()).or_else(|| {
1038 e2e_config
1039 .call
1040 .overrides
1041 .get(lang)
1042 .and_then(|o| o.client_factory.as_deref())
1043 });
1044
1045 let effective_function_name = call_overrides
1046 .and_then(|o| o.function.as_ref())
1047 .cloned()
1048 .unwrap_or_else(|| call_config.function.to_lower_camel_case());
1049 let effective_result_var = &call_config.result_var;
1050 let effective_args = &call_config.args;
1051 let function_name = effective_function_name.as_str();
1052 let result_var = effective_result_var.as_str();
1053 let args: &[crate::config::ArgMapping] = effective_args.as_slice();
1054 let effective_options_type: Option<String> = call_overrides
1059 .and_then(|o| o.options_type.clone())
1060 .or_else(|| options_type.map(|s| s.to_string()))
1061 .or_else(|| {
1062 for cand in ["csharp", "c", "go", "php", "python"] {
1063 if let Some(o) = call_config.overrides.get(cand) {
1064 if let Some(t) = &o.options_type {
1065 return Some(t.clone());
1066 }
1067 }
1068 }
1069 None
1070 });
1071 let options_type = effective_options_type.as_deref();
1072
1073 let effective_result_is_simple = call_overrides.is_some_and(|o| o.result_is_simple)
1078 || call_config.result_is_simple
1079 || result_is_simple
1080 || ["java", "csharp", "go"]
1081 .iter()
1082 .any(|cand| call_config.overrides.get(*cand).is_some_and(|o| o.result_is_simple));
1083 let result_is_simple = effective_result_is_simple;
1084
1085 let result_is_option = call_overrides.is_some_and(|o| o.result_is_option) || call_config.result_is_option;
1089
1090 let method_name = fixture.id.to_upper_camel_case();
1091 let description = &fixture.description;
1092 let expects_error = fixture.assertions.iter().any(|a| a.assertion_type == "error");
1093
1094 let is_streaming = crate::codegen::streaming_assertions::resolve_is_streaming(fixture, call_config.streaming);
1096 let collect_snippet = if is_streaming && !expects_error {
1097 crate::codegen::streaming_assertions::StreamingFieldResolver::collect_snippet("kotlin", result_var, "chunks")
1098 .unwrap_or_default()
1099 } else {
1100 String::new()
1101 };
1102
1103 let needs_deser = options_type.is_some()
1107 && args
1108 .iter()
1109 .any(|arg| arg.arg_type == "json_object" && !super::resolve_field(&fixture.input, &arg.field).is_null());
1110
1111 let effective_enum_fields: std::borrow::Cow<HashSet<String>> = {
1122 let result_type_name: Option<&str> = call_overrides
1125 .and_then(|co| co.result_type.as_deref())
1126 .or_else(|| call_config.overrides.get("java").and_then(|o| o.result_type.as_deref()))
1127 .or_else(|| call_config.overrides.get("c").and_then(|o| o.result_type.as_deref()));
1128 let auto_enum_fields: Option<&HashSet<String>> = result_type_name.and_then(|name| type_enum_fields.get(name));
1129 let has_per_call = call_overrides.is_some_and(|co| !co.enum_fields.is_empty());
1130 let has_auto = auto_enum_fields.is_some_and(|f| !f.is_empty());
1131 if has_per_call || has_auto {
1132 let mut merged = enum_fields.clone();
1133 if let Some(co) = call_overrides {
1134 merged.extend(co.enum_fields.keys().cloned());
1135 }
1136 if let Some(auto_fields) = auto_enum_fields {
1137 merged.extend(auto_fields.iter().cloned());
1138 }
1139 std::borrow::Cow::Owned(merged)
1140 } else {
1141 std::borrow::Cow::Borrowed(enum_fields)
1142 }
1143 };
1144 let enum_fields: &HashSet<String> = &effective_enum_fields;
1145
1146 let _ = writeln!(out, " @Test");
1147 if client_factory.is_some() || kotlin_android_style {
1148 let _ = writeln!(out, " fun test{method_name}() = runBlocking {{");
1149 } else {
1150 let _ = writeln!(out, " fun test{method_name}() {{");
1151 }
1152 let _ = writeln!(out, " // {description}");
1153
1154 if needs_deser {
1160 for arg in args {
1161 if arg.arg_type != "json_object" {
1162 continue;
1163 }
1164 let val = super::resolve_field(&fixture.input, &arg.field);
1165 if val.is_null() {
1166 continue;
1167 }
1168 if val.is_array() && arg.element_type.is_some() {
1171 continue;
1172 }
1173 let Some(opts_type) = options_type else { continue };
1174 let normalized = super::transform_json_keys_for_language(val, "snake_case");
1175 let json_str = serde_json::to_string(&normalized).unwrap_or_default();
1176 let var_name = &arg.name;
1177 let _ = writeln!(
1178 out,
1179 " val {var_name} = MAPPER.readValue(\"{}\", {opts_type}::class.java)",
1180 escape_kotlin(&json_str)
1181 );
1182 }
1183 }
1184
1185 let (setup_lines, args_str) =
1186 build_args_and_setup(fixture, &fixture.input, args, class_name, options_type, &fixture.id);
1187
1188 if let Some(factory) = client_factory {
1193 let fixture_id = &fixture.id;
1194 let mock_url_expr = format!(
1199 "System.getProperty(\"mockServer.{fixture_id}\", System.getProperty(\"mockServerUrl\", System.getenv(\"MOCK_SERVER_URL\") ?: \"\") + \"/fixtures/{fixture_id}\")"
1200 );
1201 for line in &setup_lines {
1202 let _ = writeln!(out, " {line}");
1203 }
1204 let _ = writeln!(
1205 out,
1206 " val client = {class_name}.{factory}(apiKey = \"test-key\", baseUrl = {mock_url_expr})"
1207 );
1208 if expects_error {
1209 let _ = writeln!(out, " assertFailsWith<Exception> {{");
1210 let _ = writeln!(out, " client.{function_name}({args_str})");
1211 let _ = writeln!(out, " }}");
1212 let _ = writeln!(out, " client.close()");
1213 let _ = writeln!(out, " }}");
1214 return;
1215 }
1216 let _ = writeln!(out, " val {result_var} = client.{function_name}({args_str})");
1217 if !collect_snippet.is_empty() {
1218 let _ = writeln!(out, " {collect_snippet}");
1219 }
1220 for assertion in &fixture.assertions {
1221 render_assertion(
1222 out,
1223 assertion,
1224 result_var,
1225 class_name,
1226 field_resolver,
1227 result_is_simple,
1228 result_is_option,
1229 enum_fields,
1230 &e2e_config.fields_c_types,
1231 is_streaming,
1232 kotlin_android_style,
1233 );
1234 }
1235 let _ = writeln!(out, " client.close()");
1236 let _ = writeln!(out, " }}");
1237 return;
1238 }
1239
1240 if expects_error {
1242 let _ = writeln!(out, " assertFailsWith<Exception> {{");
1245 for line in &setup_lines {
1246 let _ = writeln!(out, " {line}");
1247 }
1248 let _ = writeln!(out, " {class_name}.{function_name}({args_str})");
1249 let _ = writeln!(out, " }}");
1250 let _ = writeln!(out, " }}");
1251 return;
1252 }
1253
1254 for line in &setup_lines {
1255 let _ = writeln!(out, " {line}");
1256 }
1257
1258 let _ = writeln!(
1259 out,
1260 " val {result_var} = {class_name}.{function_name}({args_str})"
1261 );
1262
1263 if !collect_snippet.is_empty() {
1264 let _ = writeln!(out, " {collect_snippet}");
1265 }
1266
1267 for assertion in &fixture.assertions {
1268 render_assertion(
1269 out,
1270 assertion,
1271 result_var,
1272 class_name,
1273 field_resolver,
1274 result_is_simple,
1275 result_is_option,
1276 enum_fields,
1277 &e2e_config.fields_c_types,
1278 is_streaming,
1279 kotlin_android_style,
1280 );
1281 }
1282
1283 let _ = writeln!(out, " }}");
1284}
1285
1286fn build_args_and_setup(
1290 fixture: &Fixture,
1291 input: &serde_json::Value,
1292 args: &[crate::config::ArgMapping],
1293 class_name: &str,
1294 options_type: Option<&str>,
1295 fixture_id: &str,
1296) -> (Vec<String>, String) {
1297 if args.is_empty() {
1298 return (Vec::new(), String::new());
1299 }
1300
1301 let mut setup_lines: Vec<String> = Vec::new();
1302 let mut parts: Vec<String> = Vec::new();
1303
1304 for arg in args {
1305 if arg.arg_type == "mock_url" {
1306 if fixture.has_host_root_route() {
1307 setup_lines.push(format!(
1308 "val {} = System.getProperty(\"mockServer.{fixture_id}\", System.getProperty(\"mockServerUrl\", System.getenv(\"MOCK_SERVER_URL\")) + \"/fixtures/{fixture_id}\")",
1309 arg.name,
1310 ));
1311 } else {
1312 setup_lines.push(format!(
1313 "val {} = System.getProperty(\"mockServerUrl\", System.getenv(\"MOCK_SERVER_URL\")) + \"/fixtures/{fixture_id}\"",
1314 arg.name,
1315 ));
1316 }
1317 parts.push(arg.name.clone());
1318 continue;
1319 }
1320
1321 if arg.arg_type == "handle" {
1322 let constructor_name = format!("create{}", arg.name.to_upper_camel_case());
1323 let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
1324 let config_value = input.get(field).unwrap_or(&serde_json::Value::Null);
1325 if config_value.is_null()
1326 || config_value.is_object() && config_value.as_object().is_some_and(|o| o.is_empty())
1327 {
1328 setup_lines.push(format!("val {} = {class_name}.{constructor_name}(null)", arg.name,));
1329 } else {
1330 let json_str = serde_json::to_string(config_value).unwrap_or_default();
1331 let name = &arg.name;
1332 setup_lines.push(format!(
1333 "val {name}Config = MAPPER.readValue(\"{}\", CrawlConfig::class.java)",
1334 escape_kotlin(&json_str),
1335 ));
1336 setup_lines.push(format!(
1337 "val {} = {class_name}.{constructor_name}({name}Config)",
1338 arg.name,
1339 name = name,
1340 ));
1341 }
1342 parts.push(arg.name.clone());
1343 continue;
1344 }
1345
1346 let val_resolved = super::resolve_field(input, &arg.field);
1348 let val: Option<&serde_json::Value> = if val_resolved.is_null() {
1349 None
1350 } else {
1351 Some(val_resolved)
1352 };
1353 match val {
1354 None | Some(serde_json::Value::Null) if arg.optional => {
1355 if arg.arg_type == "json_object" {
1360 if let Some(opts_type) = options_type {
1361 parts.push(format!("{opts_type}.builder().build()"));
1362 } else {
1363 parts.push("null".to_string());
1364 }
1365 } else {
1366 parts.push("null".to_string());
1367 }
1368 }
1369 None | Some(serde_json::Value::Null) => {
1370 let default_val = match arg.arg_type.as_str() {
1371 "string" => "\"\"".to_string(),
1372 "int" | "integer" => "0".to_string(),
1373 "float" | "number" => "0.0".to_string(),
1374 "bool" | "boolean" => "false".to_string(),
1375 _ => "null".to_string(),
1376 };
1377 parts.push(default_val);
1378 }
1379 Some(v) => {
1380 if arg.arg_type == "json_object" && v.is_array() {
1385 if let Some(elem) = &arg.element_type {
1386 if elem == "BatchBytesItem" || elem == "BatchFileItem" {
1387 parts.push(emit_kotlin_batch_item_array(v, elem));
1388 continue;
1389 }
1390 let items: Vec<String> = v
1392 .as_array()
1393 .map(|arr| arr.iter().map(json_to_kotlin).collect())
1394 .unwrap_or_default();
1395 parts.push(format!("listOf({})", items.join(", ")));
1396 continue;
1397 }
1398 }
1399 if arg.arg_type == "json_object" && options_type.is_some() {
1401 parts.push(arg.name.clone());
1402 continue;
1403 }
1404 if arg.arg_type == "bytes" {
1408 let val = json_to_kotlin(v);
1409 parts.push(format!(
1410 "java.nio.file.Files.readAllBytes(java.nio.file.Path.of({val}))"
1411 ));
1412 continue;
1413 }
1414 if arg.arg_type == "file_path" {
1418 let val = json_to_kotlin(v);
1419 parts.push(format!("java.nio.file.Path.of({val})"));
1420 continue;
1421 }
1422 parts.push(json_to_kotlin(v));
1423 }
1424 }
1425 }
1426
1427 (setup_lines, parts.join(", "))
1428}
1429
1430fn emit_kotlin_batch_item_array(arr: &serde_json::Value, elem_type: &str) -> String {
1434 let Some(items) = arr.as_array() else {
1435 return "emptyList()".to_string();
1436 };
1437 let parts: Vec<String> = items
1438 .iter()
1439 .filter_map(|item| {
1440 let obj = item.as_object()?;
1441 match elem_type {
1442 "BatchBytesItem" => {
1443 let mime_type = obj.get("mime_type").and_then(|v| v.as_str()).unwrap_or("text/plain");
1444 let content_code = obj
1445 .get("content")
1446 .and_then(|v| v.as_array())
1447 .map(|arr| {
1448 let bytes: Vec<String> =
1449 arr.iter().filter_map(|v| v.as_u64().map(|n| format!("{n}"))).collect();
1450 format!("byteArrayOf({})", bytes.join(", "))
1451 })
1452 .unwrap_or_else(|| "byteArrayOf()".to_string());
1453 Some(format!("{elem_type}({content_code}, \"{mime_type}\", null)"))
1454 }
1455 "BatchFileItem" => {
1456 let path = obj.get("path").and_then(|v| v.as_str()).unwrap_or("");
1457 Some(format!("{elem_type}(java.nio.file.Paths.get(\"{path}\"), null)"))
1458 }
1459 _ => None,
1460 }
1461 })
1462 .collect();
1463 format!("listOf({})", parts.join(", "))
1464}
1465
1466#[allow(clippy::too_many_arguments)]
1467fn render_assertion(
1468 out: &mut String,
1469 assertion: &Assertion,
1470 result_var: &str,
1471 _class_name: &str,
1472 field_resolver: &FieldResolver,
1473 result_is_simple: bool,
1474 result_is_option: bool,
1475 enum_fields: &HashSet<String>,
1476 fields_c_types: &std::collections::HashMap<String, String>,
1477 is_streaming: bool,
1478 kotlin_android_style: bool,
1479) {
1480 if is_streaming {
1485 if let Some(f) = &assertion.field {
1486 if f == "usage" || f.starts_with("usage.") {
1487 let base_expr =
1488 crate::codegen::streaming_assertions::StreamingFieldResolver::accessor("usage", "kotlin", "chunks")
1489 .unwrap_or_else(|| "(if (chunks.isEmpty()) null else chunks.last().usage())".to_string());
1490
1491 let expr = if let Some(tail) = f.strip_prefix("usage.") {
1494 use heck::ToLowerCamelCase;
1495 tail.split('.')
1497 .fold(base_expr, |acc, seg| format!("{acc}?.{}()", seg.to_lower_camel_case()))
1498 } else {
1499 base_expr
1500 };
1501
1502 let field_is_long = fields_c_types
1504 .get(f.as_str())
1505 .is_some_and(|t| matches!(t.as_str(), "uint64_t" | "int64_t"));
1506
1507 let line = match assertion.assertion_type.as_str() {
1508 "equals" => {
1509 if let Some(expected) = &assertion.value {
1510 let kotlin_val = if field_is_long && expected.is_number() && !expected.is_f64() {
1511 format!("{}L", expected)
1512 } else {
1513 json_to_kotlin(expected)
1514 };
1515 format!(" assertEquals({kotlin_val}, {expr}!!)\n")
1516 } else {
1517 String::new()
1518 }
1519 }
1520 _ => String::new(),
1521 };
1522 if !line.is_empty() {
1523 out.push_str(&line);
1524 }
1525 return;
1526 }
1527 }
1528 }
1529
1530 if let Some(f) = &assertion.field {
1533 if !f.is_empty() && crate::codegen::streaming_assertions::is_streaming_virtual_field(f) {
1534 if let Some(expr) =
1535 crate::codegen::streaming_assertions::StreamingFieldResolver::accessor(f, "kotlin", "chunks")
1536 {
1537 let line = match assertion.assertion_type.as_str() {
1538 "count_min" => {
1539 if let Some(n) = assertion.value.as_ref().and_then(|v| v.as_u64()) {
1540 format!(" assertTrue({expr}.size >= {n}, \"expected >= {n} chunks\")\n")
1541 } else {
1542 String::new()
1543 }
1544 }
1545 "count_equals" => {
1546 if let Some(n) = assertion.value.as_ref().and_then(|v| v.as_u64()) {
1547 format!(
1548 " assertEquals({n}.toLong(), {expr}.size.toLong(), \"expected exactly {n} elements\")\n"
1549 )
1550 } else {
1551 String::new()
1552 }
1553 }
1554 "equals" => {
1555 if let Some(serde_json::Value::String(s)) = &assertion.value {
1556 let escaped = escape_kotlin(s);
1557 format!(" assertEquals(\"{escaped}\", {expr})\n")
1558 } else if let Some(b) = assertion.value.as_ref().and_then(|v| v.as_bool()) {
1559 format!(" assertEquals({b}, {expr})\n")
1560 } else {
1561 String::new()
1562 }
1563 }
1564 "not_empty" => {
1565 format!(" assertFalse({expr}.isEmpty(), \"expected non-empty\")\n")
1566 }
1567 "is_empty" => {
1568 format!(" assertTrue({expr}.isEmpty(), \"expected empty\")\n")
1569 }
1570 "is_true" => {
1571 format!(" assertTrue({expr}, \"expected true\")\n")
1572 }
1573 "is_false" => {
1574 format!(" assertFalse({expr}, \"expected false\")\n")
1575 }
1576 "greater_than" => {
1577 if let Some(n) = assertion.value.as_ref().and_then(|v| v.as_u64()) {
1578 format!(" assertTrue({expr} > {n}, \"expected > {n}\")\n")
1579 } else {
1580 String::new()
1581 }
1582 }
1583 "contains" => {
1584 if let Some(serde_json::Value::String(s)) = &assertion.value {
1585 let escaped = escape_kotlin(s);
1586 format!(
1587 " assertTrue({expr}.contains(\"{escaped}\"), \"expected to contain: {escaped}\")\n"
1588 )
1589 } else {
1590 String::new()
1591 }
1592 }
1593 _ => format!(
1594 " // streaming field '{f}': assertion type '{}' not rendered\n",
1595 assertion.assertion_type
1596 ),
1597 };
1598 if !line.is_empty() {
1599 out.push_str(&line);
1600 }
1601 }
1602 return;
1603 }
1604 }
1605
1606 if let Some(f) = &assertion.field {
1608 if !f.is_empty() && !field_resolver.is_valid_for_result(f) {
1609 let _ = writeln!(out, " // skipped: field '{f}' not available on result type");
1610 return;
1611 }
1612 }
1613
1614 let field_is_enum = assertion
1616 .field
1617 .as_deref()
1618 .is_some_and(|f| enum_fields.contains(f) || enum_fields.contains(field_resolver.resolve(f)));
1619
1620 let field_expr = if result_is_simple {
1622 result_var.to_string()
1623 } else {
1624 match &assertion.field {
1625 Some(f) if !f.is_empty() => field_resolver.accessor(f, "kotlin", result_var),
1626 _ => result_var.to_string(),
1627 }
1628 };
1629
1630 let field_is_optional = !result_is_simple
1640 && (field_expr.contains("?.")
1641 || assertion.field.as_deref().filter(|f| !f.is_empty()).is_some_and(|f| {
1642 let resolved = field_resolver.resolve(f);
1643 if field_resolver.has_map_access(f) {
1644 return false;
1645 }
1646 if field_resolver.is_optional(resolved) {
1648 return true;
1649 }
1650 let mut prefix = String::new();
1653 for part in resolved.split('.') {
1654 let key = part.split('[').next().unwrap_or(part);
1656 if !prefix.is_empty() {
1657 prefix.push('.');
1658 }
1659 prefix.push_str(key);
1660 if field_resolver.is_optional(&prefix) {
1661 return true;
1662 }
1663 }
1664 false
1665 }));
1666
1667 let string_field_expr = if field_is_optional {
1673 format!("{field_expr}.orEmpty()")
1674 } else {
1675 field_expr.clone()
1676 };
1677
1678 let nonnull_field_expr = if field_is_optional {
1681 format!("{field_expr}!!")
1682 } else {
1683 field_expr.clone()
1684 };
1685
1686 let string_expr = match (field_is_enum, field_is_optional) {
1692 (true, true) => format!("{field_expr}?.getValue().orEmpty()"),
1693 (true, false) => format!("{field_expr}.getValue()"),
1694 (false, _) => string_field_expr.clone(),
1695 };
1696
1697 let field_is_long = assertion.field.as_deref().filter(|f| !f.is_empty()).is_some_and(|f| {
1701 let resolved = field_resolver.resolve(f);
1702 matches!(
1703 fields_c_types.get(resolved).map(String::as_str),
1704 Some("uint64_t") | Some("int64_t")
1705 )
1706 });
1707
1708 match assertion.assertion_type.as_str() {
1709 "equals" => {
1710 if let Some(expected) = &assertion.value {
1711 let kotlin_val = if field_is_long && expected.is_number() && !expected.is_f64() {
1715 format!("{}L", expected)
1716 } else {
1717 json_to_kotlin(expected)
1718 };
1719 if expected.is_string() {
1720 let _ = writeln!(out, " assertEquals({kotlin_val}, {string_expr}.trim())");
1721 } else {
1722 let _ = writeln!(out, " assertEquals({kotlin_val}, {nonnull_field_expr})");
1723 }
1724 }
1725 }
1726 "contains" => {
1727 if let Some(expected) = &assertion.value {
1728 let kotlin_val = json_to_kotlin(expected);
1729 let _ = writeln!(
1730 out,
1731 " assertTrue({string_expr}.contains({kotlin_val}), \"expected to contain: \" + {kotlin_val})"
1732 );
1733 }
1734 }
1735 "contains_all" => {
1736 if let Some(values) = &assertion.values {
1737 for val in values {
1738 let kotlin_val = json_to_kotlin(val);
1739 let _ = writeln!(
1740 out,
1741 " assertTrue({string_expr}.contains({kotlin_val}), \"expected to contain: \" + {kotlin_val})"
1742 );
1743 }
1744 }
1745 }
1746 "not_contains" => {
1747 if let Some(expected) = &assertion.value {
1748 let kotlin_val = json_to_kotlin(expected);
1749 let _ = writeln!(
1750 out,
1751 " assertFalse({string_expr}.contains({kotlin_val}), \"expected NOT to contain: \" + {kotlin_val})"
1752 );
1753 }
1754 }
1755 "not_empty" => {
1756 let bare_result_is_option =
1769 result_is_option && assertion.field.as_deref().filter(|f| !f.is_empty()).is_none();
1770 if bare_result_is_option && !kotlin_android_style {
1771 let _ = writeln!(
1772 out,
1773 " assertTrue({field_expr}.isPresent, \"expected non-empty value\")"
1774 );
1775 } else if bare_result_is_option || field_is_optional {
1776 let _ = writeln!(
1777 out,
1778 " assertTrue({field_expr} != null, \"expected non-empty value\")"
1779 );
1780 } else {
1781 let _ = writeln!(
1782 out,
1783 " assertFalse({string_field_expr}.isEmpty(), \"expected non-empty value\")"
1784 );
1785 }
1786 }
1787 "is_empty" => {
1788 let bare_result_is_option =
1789 result_is_option && assertion.field.as_deref().filter(|f| !f.is_empty()).is_none();
1790 if bare_result_is_option && !kotlin_android_style {
1791 let _ = writeln!(
1792 out,
1793 " assertTrue({field_expr}.isEmpty, \"expected empty value\")"
1794 );
1795 } else if bare_result_is_option || field_is_optional {
1796 let _ = writeln!(
1797 out,
1798 " assertTrue({field_expr} == null, \"expected empty value\")"
1799 );
1800 } else {
1801 let _ = writeln!(
1802 out,
1803 " assertTrue({string_field_expr}.isEmpty(), \"expected empty value\")"
1804 );
1805 }
1806 }
1807 "contains_any" => {
1808 if let Some(values) = &assertion.values {
1809 let checks: Vec<String> = values
1810 .iter()
1811 .map(|v| {
1812 let kotlin_val = json_to_kotlin(v);
1813 format!("{string_expr}.contains({kotlin_val})")
1814 })
1815 .collect();
1816 let joined = checks.join(" || ");
1817 let _ = writeln!(
1818 out,
1819 " assertTrue({joined}, \"expected to contain at least one of the specified values\")"
1820 );
1821 }
1822 }
1823 "greater_than" => {
1824 if let Some(val) = &assertion.value {
1825 let kotlin_val = json_to_kotlin(val);
1826 let _ = writeln!(
1827 out,
1828 " assertTrue({nonnull_field_expr} > {kotlin_val}, \"expected > {kotlin_val}\")"
1829 );
1830 }
1831 }
1832 "less_than" => {
1833 if let Some(val) = &assertion.value {
1834 let kotlin_val = json_to_kotlin(val);
1835 let _ = writeln!(
1836 out,
1837 " assertTrue({nonnull_field_expr} < {kotlin_val}, \"expected < {kotlin_val}\")"
1838 );
1839 }
1840 }
1841 "greater_than_or_equal" => {
1842 if let Some(val) = &assertion.value {
1843 let kotlin_val = json_to_kotlin(val);
1844 let _ = writeln!(
1845 out,
1846 " assertTrue({nonnull_field_expr} >= {kotlin_val}, \"expected >= {kotlin_val}\")"
1847 );
1848 }
1849 }
1850 "less_than_or_equal" => {
1851 if let Some(val) = &assertion.value {
1852 let kotlin_val = json_to_kotlin(val);
1853 let _ = writeln!(
1854 out,
1855 " assertTrue({nonnull_field_expr} <= {kotlin_val}, \"expected <= {kotlin_val}\")"
1856 );
1857 }
1858 }
1859 "starts_with" => {
1860 if let Some(expected) = &assertion.value {
1861 let kotlin_val = json_to_kotlin(expected);
1862 let _ = writeln!(
1863 out,
1864 " assertTrue({string_expr}.startsWith({kotlin_val}), \"expected to start with: \" + {kotlin_val})"
1865 );
1866 }
1867 }
1868 "ends_with" => {
1869 if let Some(expected) = &assertion.value {
1870 let kotlin_val = json_to_kotlin(expected);
1871 let _ = writeln!(
1872 out,
1873 " assertTrue({string_expr}.endsWith({kotlin_val}), \"expected to end with: \" + {kotlin_val})"
1874 );
1875 }
1876 }
1877 "min_length" => {
1878 if let Some(val) = &assertion.value {
1879 if let Some(n) = val.as_u64() {
1880 let _ = writeln!(
1881 out,
1882 " assertTrue({string_field_expr}.length >= {n}, \"expected length >= {n}\")"
1883 );
1884 }
1885 }
1886 }
1887 "max_length" => {
1888 if let Some(val) = &assertion.value {
1889 if let Some(n) = val.as_u64() {
1890 let _ = writeln!(
1891 out,
1892 " assertTrue({string_field_expr}.length <= {n}, \"expected length <= {n}\")"
1893 );
1894 }
1895 }
1896 }
1897 "count_min" => {
1898 if let Some(val) = &assertion.value {
1899 if let Some(n) = val.as_u64() {
1900 let _ = writeln!(
1901 out,
1902 " assertTrue({nonnull_field_expr}.size >= {n}, \"expected at least {n} elements\")"
1903 );
1904 }
1905 }
1906 }
1907 "count_equals" => {
1908 if let Some(val) = &assertion.value {
1909 if let Some(n) = val.as_u64() {
1910 let _ = writeln!(
1911 out,
1912 " assertEquals({n}, {nonnull_field_expr}.size, \"expected exactly {n} elements\")"
1913 );
1914 }
1915 }
1916 }
1917 "is_true" => {
1918 let _ = writeln!(out, " assertTrue({field_expr}, \"expected true\")");
1919 }
1920 "is_false" => {
1921 let _ = writeln!(out, " assertFalse({field_expr}, \"expected false\")");
1922 }
1923 "matches_regex" => {
1924 if let Some(expected) = &assertion.value {
1925 let kotlin_val = json_to_kotlin(expected);
1926 let _ = writeln!(
1927 out,
1928 " assertTrue(Regex({kotlin_val}).containsMatchIn({string_expr}), \"expected value to match regex: \" + {kotlin_val})"
1929 );
1930 }
1931 }
1932 "not_error" => {
1933 }
1935 "error" => {
1936 }
1938 "method_result" => {
1939 let _ = writeln!(
1941 out,
1942 " // method_result assertions not yet implemented for Kotlin"
1943 );
1944 }
1945 other => {
1946 panic!("Kotlin e2e generator: unsupported assertion type: {other}");
1947 }
1948 }
1949}
1950
1951fn json_to_kotlin(value: &serde_json::Value) -> String {
1953 match value {
1954 serde_json::Value::String(s) => format!("\"{}\"", escape_kotlin(s)),
1955 serde_json::Value::Bool(b) => b.to_string(),
1956 serde_json::Value::Number(n) => {
1957 if n.is_f64() {
1958 let s = n.to_string();
1961 if s.contains('.') || s.contains('e') || s.contains('E') {
1962 s
1963 } else {
1964 format!("{s}.0")
1965 }
1966 } else {
1967 n.to_string()
1968 }
1969 }
1970 serde_json::Value::Null => "null".to_string(),
1971 serde_json::Value::Array(arr) => {
1972 let items: Vec<String> = arr.iter().map(json_to_kotlin).collect();
1973 format!("listOf({})", items.join(", "))
1974 }
1975 serde_json::Value::Object(_) => {
1976 let json_str = serde_json::to_string(value).unwrap_or_default();
1977 format!("\"{}\"", escape_kotlin(&json_str))
1978 }
1979 }
1980}
1981
1982#[cfg(test)]
1983mod tests {
1984 use super::*;
1985 use std::collections::HashMap;
1986
1987 fn make_resolver_for_finish_reason() -> FieldResolver {
1988 let mut optional = HashSet::new();
1992 optional.insert("choices.finish_reason".to_string());
1993 let mut arrays = HashSet::new();
1994 arrays.insert("choices".to_string());
1995 FieldResolver::new(&HashMap::new(), &optional, &HashSet::new(), &arrays, &HashSet::new())
1996 }
1997
1998 #[test]
2002 fn assertion_enum_optional_uses_safe_get_value_then_or_empty() {
2003 let resolver = make_resolver_for_finish_reason();
2004 let mut enum_fields = HashSet::new();
2005 enum_fields.insert("choices.finish_reason".to_string());
2006 let assertion = Assertion {
2007 assertion_type: "equals".to_string(),
2008 field: Some("choices.finish_reason".to_string()),
2009 value: Some(serde_json::Value::String("stop".to_string())),
2010 values: None,
2011 method: None,
2012 check: None,
2013 args: None,
2014 return_type: None,
2015 };
2016 let mut out = String::new();
2017 render_assertion(
2018 &mut out,
2019 &assertion,
2020 "result",
2021 "",
2022 &resolver,
2023 false,
2024 false,
2025 &enum_fields,
2026 &HashMap::new(),
2027 false,
2028 false,
2029 );
2030 assert!(
2031 out.contains("result.choices().first().finishReason()?.getValue().orEmpty().trim()"),
2032 "expected enum-optional safe-call pattern, got: {out}"
2033 );
2034 assert!(
2035 !out.contains(".finishReason().orEmpty().getValue()"),
2036 "must not emit .orEmpty().getValue() on a nullable enum: {out}"
2037 );
2038 }
2039
2040 #[test]
2043 fn assertion_enum_non_optional_uses_plain_get_value() {
2044 let mut arrays = HashSet::new();
2045 arrays.insert("choices".to_string());
2046 let resolver = FieldResolver::new(
2047 &HashMap::new(),
2048 &HashSet::new(),
2049 &HashSet::new(),
2050 &arrays,
2051 &HashSet::new(),
2052 );
2053 let mut enum_fields = HashSet::new();
2054 enum_fields.insert("choices.finish_reason".to_string());
2055 let assertion = Assertion {
2056 assertion_type: "equals".to_string(),
2057 field: Some("choices.finish_reason".to_string()),
2058 value: Some(serde_json::Value::String("stop".to_string())),
2059 values: None,
2060 method: None,
2061 check: None,
2062 args: None,
2063 return_type: None,
2064 };
2065 let mut out = String::new();
2066 render_assertion(
2067 &mut out,
2068 &assertion,
2069 "result",
2070 "",
2071 &resolver,
2072 false,
2073 false,
2074 &enum_fields,
2075 &HashMap::new(),
2076 false,
2077 false,
2078 );
2079 assert!(
2080 out.contains("result.choices().first().finishReason().getValue().trim()"),
2081 "expected plain .getValue() for non-optional enum, got: {out}"
2082 );
2083 }
2084
2085 #[test]
2091 fn per_call_enum_field_override_routes_through_get_value() {
2092 let resolver = FieldResolver::new(
2094 &HashMap::new(),
2095 &HashSet::new(),
2096 &HashSet::new(),
2097 &HashSet::new(),
2098 &HashSet::new(),
2099 );
2100 let global_enum_fields: HashSet<String> = HashSet::new();
2102 let mut per_call_enum_fields: HashSet<String> = global_enum_fields.clone();
2104 per_call_enum_fields.insert("status".to_string());
2105
2106 let assertion = Assertion {
2107 assertion_type: "equals".to_string(),
2108 field: Some("status".to_string()),
2109 value: Some(serde_json::Value::String("validating".to_string())),
2110 values: None,
2111 method: None,
2112 check: None,
2113 args: None,
2114 return_type: None,
2115 };
2116
2117 let mut out_no_merge = String::new();
2119 render_assertion(
2120 &mut out_no_merge,
2121 &assertion,
2122 "result",
2123 "",
2124 &resolver,
2125 false,
2126 false,
2127 &global_enum_fields,
2128 &HashMap::new(),
2129 false,
2130 false,
2131 );
2132 assert!(
2133 !out_no_merge.contains(".getValue()"),
2134 "global-only set must not emit .getValue() for unregistered status: {out_no_merge}"
2135 );
2136
2137 let mut out_merged = String::new();
2139 render_assertion(
2140 &mut out_merged,
2141 &assertion,
2142 "result",
2143 "",
2144 &resolver,
2145 false,
2146 false,
2147 &per_call_enum_fields,
2148 &HashMap::new(),
2149 false,
2150 false,
2151 );
2152 assert!(
2153 out_merged.contains(".getValue()"),
2154 "merged per-call set must emit .getValue() for status: {out_merged}"
2155 );
2156 }
2157
2158 #[test]
2163 fn auto_detected_enum_fields_from_type_defs_route_through_get_value() {
2164 use alef_core::ir::{CoreWrapper, FieldDef, TypeDef, TypeRef};
2165
2166 let batch_object_def = TypeDef {
2168 name: "BatchObject".to_string(),
2169 rust_path: "liter_llm::BatchObject".to_string(),
2170 original_rust_path: String::new(),
2171 fields: vec![
2172 FieldDef {
2173 name: "id".to_string(),
2174 ty: TypeRef::String,
2175 optional: false,
2176 default: None,
2177 doc: String::new(),
2178 sanitized: false,
2179 is_boxed: false,
2180 type_rust_path: None,
2181 cfg: None,
2182 typed_default: None,
2183 core_wrapper: CoreWrapper::None,
2184 vec_inner_core_wrapper: CoreWrapper::None,
2185 newtype_wrapper: None,
2186 serde_rename: None,
2187 serde_flatten: false,
2188 },
2189 FieldDef {
2190 name: "status".to_string(),
2191 ty: TypeRef::Named("BatchStatus".to_string()),
2192 optional: false,
2193 default: None,
2194 doc: String::new(),
2195 sanitized: false,
2196 is_boxed: false,
2197 type_rust_path: None,
2198 cfg: None,
2199 typed_default: None,
2200 core_wrapper: CoreWrapper::None,
2201 vec_inner_core_wrapper: CoreWrapper::None,
2202 newtype_wrapper: None,
2203 serde_rename: None,
2204 serde_flatten: false,
2205 },
2206 ],
2207 methods: vec![],
2208 is_opaque: false,
2209 is_clone: true,
2210 is_copy: false,
2211 doc: String::new(),
2212 cfg: None,
2213 is_trait: false,
2214 has_default: false,
2215 has_stripped_cfg_fields: false,
2216 is_return_type: true,
2217 serde_rename_all: None,
2218 has_serde: true,
2219 super_traits: vec![],
2220 };
2221
2222 let type_defs = [batch_object_def];
2224 let struct_names: HashSet<&str> = type_defs.iter().map(|td| td.name.as_str()).collect();
2225
2226 let status_ty = TypeRef::Named("BatchStatus".to_string());
2228 assert!(
2229 is_enum_typed(&status_ty, &struct_names),
2230 "BatchStatus (not a known struct) should be detected as enum-typed"
2231 );
2232 let id_ty = TypeRef::String;
2233 assert!(
2234 !is_enum_typed(&id_ty, &struct_names),
2235 "String field should NOT be detected as enum-typed"
2236 );
2237
2238 let type_enum_fields: std::collections::HashMap<String, HashSet<String>> = type_defs
2240 .iter()
2241 .filter_map(|td| {
2242 let enum_field_names: HashSet<String> = td
2243 .fields
2244 .iter()
2245 .filter(|field| is_enum_typed(&field.ty, &struct_names))
2246 .map(|field| field.name.clone())
2247 .collect();
2248 if enum_field_names.is_empty() {
2249 None
2250 } else {
2251 Some((td.name.clone(), enum_field_names))
2252 }
2253 })
2254 .collect();
2255
2256 let batch_enum_fields = type_enum_fields
2257 .get("BatchObject")
2258 .expect("BatchObject should have enum fields");
2259 assert!(
2260 batch_enum_fields.contains("status"),
2261 "BatchObject.status should be auto-detected as enum-typed, got: {batch_enum_fields:?}"
2262 );
2263 assert!(
2264 !batch_enum_fields.contains("id"),
2265 "BatchObject.id (String) must not be in enum fields"
2266 );
2267
2268 let resolver = FieldResolver::new(
2270 &HashMap::new(),
2271 &HashSet::new(),
2272 &HashSet::new(),
2273 &HashSet::new(),
2274 &HashSet::new(),
2275 );
2276 let assertion = Assertion {
2277 assertion_type: "equals".to_string(),
2278 field: Some("status".to_string()),
2279 value: Some(serde_json::Value::String("validating".to_string())),
2280 values: None,
2281 method: None,
2282 check: None,
2283 args: None,
2284 return_type: None,
2285 };
2286 let mut out = String::new();
2287 render_assertion(
2288 &mut out,
2289 &assertion,
2290 "result",
2291 "",
2292 &resolver,
2293 false,
2294 false,
2295 batch_enum_fields,
2296 &HashMap::new(),
2297 false,
2298 false,
2299 );
2300 assert!(
2301 out.contains(".getValue()"),
2302 "auto-detected enum field must route through .getValue(), got: {out}"
2303 );
2304 }
2305}