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 _enums: &[alef_core::ir::EnumDef],
34 ) -> Result<Vec<GeneratedFile>> {
35 let lang = self.language_name();
36 let output_base = PathBuf::from(e2e_config.effective_output()).join(lang);
37
38 let mut files = Vec::new();
39
40 let call = &e2e_config.call;
42 let overrides = call.overrides.get(lang);
43 let _module_path = overrides
44 .and_then(|o| o.module.as_ref())
45 .cloned()
46 .unwrap_or_else(|| call.module.clone());
47 let function_name = overrides
48 .and_then(|o| o.function.as_ref())
49 .cloned()
50 .unwrap_or_else(|| call.function.clone());
51 let class_name = overrides
52 .and_then(|o| o.class.as_ref())
53 .cloned()
54 .unwrap_or_else(|| config.name.to_upper_camel_case());
55 let result_is_simple = overrides.is_some_and(|o| o.result_is_simple);
56 let result_var = &call.result_var;
57
58 let kotlin_pkg = e2e_config.resolve_package("kotlin");
60 let pkg_name = kotlin_pkg
61 .as_ref()
62 .and_then(|p| p.name.as_ref())
63 .cloned()
64 .unwrap_or_else(|| config.name.clone());
65
66 let _kotlin_pkg_path = kotlin_pkg
68 .as_ref()
69 .and_then(|p| p.path.as_ref())
70 .cloned()
71 .unwrap_or_else(|| "../../packages/kotlin".to_string());
72 let kotlin_version = kotlin_pkg
73 .as_ref()
74 .and_then(|p| p.version.as_ref())
75 .cloned()
76 .or_else(|| config.resolved_version())
77 .unwrap_or_else(|| "0.1.0".to_string());
78 let kotlin_pkg_id = config.kotlin_package();
79
80 let needs_mock_server = groups
86 .iter()
87 .flat_map(|g| g.fixtures.iter())
88 .any(|f| f.needs_mock_server());
89
90 files.push(GeneratedFile {
92 path: output_base.join("build.gradle.kts"),
93 content: render_build_gradle(
94 &pkg_name,
95 &kotlin_pkg_id,
96 &kotlin_version,
97 e2e_config.dep_mode,
98 needs_mock_server,
99 ),
100 generated_header: false,
101 });
102
103 let mut test_base = output_base.join("src").join("test").join("kotlin");
107 for segment in kotlin_pkg_id.split('.') {
108 test_base = test_base.join(segment);
109 }
110 let test_base = test_base.join("e2e");
111
112 if needs_mock_server {
113 files.push(GeneratedFile {
114 path: test_base.join("MockServerListener.kt"),
115 content: render_mock_server_listener_kt(&kotlin_pkg_id),
116 generated_header: true,
117 });
118 files.push(GeneratedFile {
119 path: output_base
120 .join("src")
121 .join("test")
122 .join("resources")
123 .join("META-INF")
124 .join("services")
125 .join("org.junit.platform.launcher.LauncherSessionListener"),
126 content: format!("{kotlin_pkg_id}.e2e.MockServerListener\n"),
127 generated_header: false,
128 });
129 }
130
131 let options_type = overrides.and_then(|o| o.options_type.clone());
133
134 let struct_names: HashSet<&str> = type_defs.iter().map(|td| td.name.as_str()).collect();
140 let type_enum_fields: std::collections::HashMap<String, HashSet<String>> = type_defs
141 .iter()
142 .filter_map(|td| {
143 let enum_field_names: HashSet<String> = td
144 .fields
145 .iter()
146 .filter(|field| is_enum_typed(&field.ty, &struct_names))
147 .map(|field| field.name.clone())
148 .collect();
149 if enum_field_names.is_empty() {
150 None
151 } else {
152 Some((td.name.clone(), enum_field_names))
153 }
154 })
155 .collect();
156
157 for group in groups {
158 let active: Vec<&Fixture> = group
159 .fixtures
160 .iter()
161 .filter(|f| super::should_include_fixture(f, lang, e2e_config))
162 .collect();
163
164 if active.is_empty() {
165 continue;
166 }
167
168 let class_file_name = format!("{}Test.kt", sanitize_filename(&group.category).to_upper_camel_case());
169 let content = render_test_file(
170 &group.category,
171 &active,
172 &class_name,
173 &function_name,
174 &kotlin_pkg_id,
175 result_var,
176 &e2e_config.call.args,
177 options_type.as_deref(),
178 result_is_simple,
179 e2e_config,
180 &type_enum_fields,
181 );
182 files.push(GeneratedFile {
183 path: test_base.join(class_file_name),
184 content,
185 generated_header: true,
186 });
187 }
188
189 Ok(files)
190 }
191
192 fn language_name(&self) -> &'static str {
193 "kotlin"
194 }
195}
196
197fn is_enum_typed(ty: &alef_core::ir::TypeRef, struct_names: &HashSet<&str>) -> bool {
205 use alef_core::ir::TypeRef;
206 match ty {
207 TypeRef::Named(name) => !struct_names.contains(name.as_str()),
208 TypeRef::Optional(inner) => {
209 matches!(inner.as_ref(), TypeRef::Named(name) if !struct_names.contains(name.as_str()))
210 }
211 _ => false,
212 }
213}
214
215pub(crate) fn render_build_gradle(
220 pkg_name: &str,
221 kotlin_pkg_id: &str,
222 pkg_version: &str,
223 dep_mode: crate::config::DependencyMode,
224 needs_mock_server: bool,
225) -> String {
226 let dep_block = match dep_mode {
227 crate::config::DependencyMode::Registry => {
228 format!(r#" testImplementation("{kotlin_pkg_id}:{pkg_name}:{pkg_version}")"#)
230 }
231 crate::config::DependencyMode::Local => {
232 let jar_name = pkg_name.rsplit(':').next().unwrap_or(pkg_name).replace('-', "_");
239 let jna = maven::JNA;
240 let jackson = maven::JACKSON_E2E;
241 let jspecify = maven::JSPECIFY;
242 let coroutines = maven::KOTLINX_COROUTINES_CORE;
243 format!(
244 r#" testImplementation(files("../../packages/kotlin/build/libs/{jar_name}-{pkg_version}.jar"))
245 testImplementation("net.java.dev.jna:jna:{jna}")
246 testImplementation("com.fasterxml.jackson.core:jackson-annotations:{jackson}")
247 testImplementation("com.fasterxml.jackson.core:jackson-databind:{jackson}")
248 testImplementation("com.fasterxml.jackson.datatype:jackson-datatype-jdk8:{jackson}")
249 testImplementation("org.jspecify:jspecify:{jspecify}")
250 testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:{coroutines}")"#
251 )
252 }
253 };
254
255 let kotlin_plugin = maven::KOTLIN_JVM_PLUGIN;
256 let junit = maven::JUNIT;
257 let jackson = maven::JACKSON_E2E;
258 let jvm_target = toolchain::KOTLIN_JVM_TARGET;
259 let launcher_dep = if needs_mock_server {
260 format!(r#" testImplementation("org.junit.platform:junit-platform-launcher:{junit}")"#)
261 } else {
262 String::new()
263 };
264 format!(
265 r#"import org.jetbrains.kotlin.gradle.dsl.JvmTarget
266
267plugins {{
268 kotlin("jvm") version "{kotlin_plugin}"
269 java
270}}
271
272group = "{kotlin_pkg_id}"
273version = "0.1.0"
274
275java {{
276 sourceCompatibility = JavaVersion.VERSION_{jvm_target}
277 targetCompatibility = JavaVersion.VERSION_{jvm_target}
278}}
279
280kotlin {{
281 compilerOptions {{
282 jvmTarget.set(JvmTarget.JVM_{jvm_target})
283 }}
284}}
285
286repositories {{
287 mavenCentral()
288}}
289
290dependencies {{
291{dep_block}
292 testImplementation("org.junit.jupiter:junit-jupiter-api:{junit}")
293 testImplementation("org.junit.jupiter:junit-jupiter-engine:{junit}")
294{launcher_dep}
295 testImplementation("com.fasterxml.jackson.core:jackson-databind:{jackson}")
296 testImplementation("com.fasterxml.jackson.datatype:jackson-datatype-jdk8:{jackson}")
297 testImplementation(kotlin("test"))
298}}
299
300tasks.test {{
301 useJUnitPlatform()
302 val libPath = System.getProperty("native.lib.path") ?: "${{rootDir}}/../../target/release"
303 systemProperty("java.library.path", libPath)
304 systemProperty("jna.library.path", libPath)
305 // Resolve fixture paths (e.g. "docx/fake.docx") against test_documents/.
306 workingDir = file("${{rootDir}}/../../test_documents")
307}}
308"#
309 )
310}
311
312pub(crate) fn render_mock_server_listener_kt(kotlin_pkg_id: &str) -> String {
321 let header = hash::header(CommentStyle::DoubleSlash);
322 format!(
323 r#"{header}package {kotlin_pkg_id}.e2e
324
325import java.io.BufferedReader
326import java.io.IOException
327import java.io.InputStreamReader
328import java.nio.charset.StandardCharsets
329import java.nio.file.Path
330import java.nio.file.Paths
331import java.util.regex.Pattern
332import org.junit.platform.launcher.LauncherSession
333import org.junit.platform.launcher.LauncherSessionListener
334
335/**
336 * Spawns the mock-server binary once per JUnit launcher session and
337 * exposes its URL as the `mockServerUrl` system property. Generated
338 * test bodies read the property (with `MOCK_SERVER_URL` env-var
339 * fallback) so tests can run via plain `./gradlew test` without any
340 * external mock-server orchestration. Mirrors the Ruby spec_helper /
341 * Python conftest spawn pattern. Honors a pre-set MOCK_SERVER_URL by
342 * skipping the spawn entirely.
343 */
344class MockServerListener : LauncherSessionListener {{
345 private var mockServer: Process? = null
346
347 override fun launcherSessionOpened(session: LauncherSession) {{
348 val preset = System.getenv("MOCK_SERVER_URL")
349 if (!preset.isNullOrEmpty()) {{
350 System.setProperty("mockServerUrl", preset)
351 return
352 }}
353 val repoRoot = locateRepoRoot()
354 ?: error("MockServerListener: could not locate repo root (looked for fixtures/ in ancestors of ${{System.getProperty("user.dir")}})")
355 val binName = if (System.getProperty("os.name", "").lowercase().contains("win")) "mock-server.exe" else "mock-server"
356 val bin = repoRoot.resolve("e2e").resolve("rust").resolve("target").resolve("release").resolve(binName).toFile()
357 val fixturesDir = repoRoot.resolve("fixtures").toFile()
358 check(bin.exists()) {{
359 "MockServerListener: mock-server binary not found at $bin — run: cargo build --manifest-path e2e/rust/Cargo.toml --bin mock-server --release"
360 }}
361 val pb = ProcessBuilder(bin.absolutePath, fixturesDir.absolutePath)
362 .redirectErrorStream(false)
363 val server = try {{
364 pb.start()
365 }} catch (e: IOException) {{
366 throw IllegalStateException("MockServerListener: failed to start mock-server", e)
367 }}
368 mockServer = server
369 // Read until we see MOCK_SERVER_URL= and optionally MOCK_SERVERS=.
370 // Cap the loop so a misbehaving mock-server cannot block indefinitely.
371 val stdout = BufferedReader(InputStreamReader(server.inputStream, StandardCharsets.UTF_8))
372 var url: String? = null
373 try {{
374 for (i in 0 until 16) {{
375 val line = stdout.readLine() ?: break
376 when {{
377 line.startsWith("MOCK_SERVER_URL=") -> {{
378 url = line.removePrefix("MOCK_SERVER_URL=").trim()
379 }}
380 line.startsWith("MOCK_SERVERS=") -> {{
381 val jsonVal = line.removePrefix("MOCK_SERVERS=").trim()
382 System.setProperty("mockServers", jsonVal)
383 // Parse JSON map of fixture_id -> url and expose as system properties.
384 val p = Pattern.compile(""""([^"]+)":"([^"]+)"""")
385 val matcher = p.matcher(jsonVal)
386 while (matcher.find()) {{
387 System.setProperty("mockServer.${{matcher.group(1)}}", matcher.group(2))
388 }}
389 break
390 }}
391 url != null -> break
392 }}
393 }}
394 }} catch (e: IOException) {{
395 server.destroyForcibly()
396 throw IllegalStateException("MockServerListener: failed to read mock-server stdout", e)
397 }}
398 if (url.isNullOrEmpty()) {{
399 server.destroyForcibly()
400 error("MockServerListener: mock-server did not emit MOCK_SERVER_URL")
401 }}
402 // TCP-readiness probe: ensure axum::serve is accepting before tests start.
403 // The mock-server binds the TcpListener synchronously then prints the URL
404 // before tokio::spawn(axum::serve(...)) is polled, so under Gradle parallel
405 // mode tests can race startup. Poll-connect (max 5s, 50ms backoff) until success.
406 val healthUri = java.net.URI.create(url)
407 val host = healthUri.host
408 val port = healthUri.port
409 val deadline = System.nanoTime() + 5_000_000_000L
410 while (System.nanoTime() < deadline) {{
411 try {{
412 java.net.Socket().use {{ s ->
413 s.connect(java.net.InetSocketAddress(host, port), 100)
414 break
415 }}
416 }} catch (_: java.io.IOException) {{
417 try {{ Thread.sleep(50) }} catch (ie: InterruptedException) {{ Thread.currentThread().interrupt(); break }}
418 }}
419 }}
420 System.setProperty("mockServerUrl", url)
421 // Drain remaining stdout/stderr in daemon threads so a full pipe
422 // does not block the child.
423 Thread {{ drain(stdout) }}.also {{ it.isDaemon = true }}.start()
424 Thread {{ drain(BufferedReader(InputStreamReader(server.errorStream, StandardCharsets.UTF_8))) }}.also {{ it.isDaemon = true }}.start()
425 }}
426
427 override fun launcherSessionClosed(session: LauncherSession) {{
428 val server = mockServer ?: return
429 try {{ server.outputStream.close() }} catch (_: IOException) {{}}
430 try {{
431 if (!server.waitFor(2, java.util.concurrent.TimeUnit.SECONDS)) {{
432 server.destroyForcibly()
433 }}
434 }} catch (ie: InterruptedException) {{
435 Thread.currentThread().interrupt()
436 server.destroyForcibly()
437 }}
438 }}
439
440 companion object {{
441 private fun locateRepoRoot(): Path? {{
442 var dir: Path? = Paths.get("").toAbsolutePath()
443 while (dir != null) {{
444 if (dir.resolve("fixtures").toFile().isDirectory
445 && dir.resolve("e2e").toFile().isDirectory) {{
446 return dir
447 }}
448 dir = dir.parent
449 }}
450 return null
451 }}
452
453 private fun drain(reader: BufferedReader) {{
454 try {{
455 val buf = CharArray(1024)
456 while (reader.read(buf) >= 0) {{ /* drain */ }}
457 }} catch (_: IOException) {{}}
458 }}
459 }}
460}}
461"#
462 )
463}
464
465#[allow(clippy::too_many_arguments)]
466pub(crate) fn render_test_file(
467 category: &str,
468 fixtures: &[&Fixture],
469 class_name: &str,
470 function_name: &str,
471 kotlin_pkg_id: &str,
472 result_var: &str,
473 args: &[crate::config::ArgMapping],
474 options_type: Option<&str>,
475 result_is_simple: bool,
476 e2e_config: &E2eConfig,
477 type_enum_fields: &std::collections::HashMap<String, HashSet<String>>,
478) -> String {
479 render_test_file_inner(
480 category,
481 fixtures,
482 class_name,
483 function_name,
484 kotlin_pkg_id,
485 result_var,
486 args,
487 options_type,
488 result_is_simple,
489 e2e_config,
490 type_enum_fields,
491 false,
492 )
493}
494
495#[allow(clippy::too_many_arguments)]
510pub(crate) fn render_test_file_android(
511 category: &str,
512 fixtures: &[&Fixture],
513 class_name: &str,
514 function_name: &str,
515 kotlin_pkg_id: &str,
516 result_var: &str,
517 args: &[crate::config::ArgMapping],
518 options_type: Option<&str>,
519 result_is_simple: bool,
520 e2e_config: &E2eConfig,
521 type_enum_fields: &std::collections::HashMap<String, HashSet<String>>,
522) -> String {
523 render_test_file_inner(
524 category,
525 fixtures,
526 class_name,
527 function_name,
528 kotlin_pkg_id,
529 result_var,
530 args,
531 options_type,
532 result_is_simple,
533 e2e_config,
534 type_enum_fields,
535 true,
536 )
537}
538
539#[allow(clippy::too_many_arguments)]
540fn render_test_file_inner(
541 category: &str,
542 fixtures: &[&Fixture],
543 class_name: &str,
544 function_name: &str,
545 kotlin_pkg_id: &str,
546 result_var: &str,
547 args: &[crate::config::ArgMapping],
548 options_type: Option<&str>,
549 result_is_simple: bool,
550 e2e_config: &E2eConfig,
551 type_enum_fields: &std::collections::HashMap<String, HashSet<String>>,
552 kotlin_android_style: bool,
553) -> String {
554 let mut out = String::new();
555 out.push_str(&hash::header(CommentStyle::DoubleSlash));
556 let test_class_name = format!("{}Test", sanitize_filename(category).to_upper_camel_case());
557
558 let (import_path, simple_class) = if class_name.contains('.') {
561 let simple = class_name.rsplit('.').next().unwrap_or(class_name);
562 (class_name, simple)
563 } else {
564 ("", class_name)
565 };
566
567 let _ = writeln!(out, "package {kotlin_pkg_id}.e2e");
568 let _ = writeln!(out);
569
570 let has_http_fixtures = fixtures.iter().any(|f| f.is_http_test());
572
573 let has_client_factory_fixtures = fixtures.iter().any(|f| {
576 if f.is_http_test() {
577 return false;
578 }
579 let cc =
580 e2e_config.resolve_call_for_fixture(f.call.as_deref(), &f.id, &f.resolved_category(), &f.tags, &f.input);
581 let per_call_factory = cc.overrides.get("kotlin").and_then(|o| o.client_factory.as_deref());
582 let global_factory = e2e_config
583 .call
584 .overrides
585 .get("kotlin")
586 .and_then(|o| o.client_factory.as_deref());
587 per_call_factory.or(global_factory).is_some()
588 });
589
590 let mut per_fixture_options_types: HashSet<String> = HashSet::new();
594 for f in fixtures.iter() {
595 let cc =
596 e2e_config.resolve_call_for_fixture(f.call.as_deref(), &f.id, &f.resolved_category(), &f.tags, &f.input);
597 let call_overrides = cc.overrides.get("kotlin");
598 let effective_opts: Option<String> = call_overrides
599 .and_then(|o| o.options_type.clone())
600 .or_else(|| options_type.map(|s| s.to_string()))
601 .or_else(|| {
602 for cand in ["csharp", "c", "go", "php", "python"] {
603 if let Some(o) = cc.overrides.get(cand) {
604 if let Some(t) = &o.options_type {
605 return Some(t.clone());
606 }
607 }
608 }
609 None
610 });
611 if let Some(opts) = effective_opts {
612 let fixture_args = if cc.args.is_empty() { args } else { cc.args.as_slice() };
615 let needs_opts_type = fixture_args.iter().any(|arg| {
620 if arg.arg_type != "json_object" {
621 return false;
622 }
623 let v = super::resolve_field(&f.input, &arg.field);
624 !v.is_null() || arg.optional
625 });
626 if needs_opts_type {
627 per_fixture_options_types.insert(opts.to_string());
628 }
629 }
630 }
631 let needs_object_mapper_for_options = !per_fixture_options_types.is_empty();
632 let needs_object_mapper_for_handle = fixtures.iter().any(|f| {
634 args.iter().filter(|a| a.arg_type == "handle").any(|a| {
635 let v = f.input.get(&a.field).unwrap_or(&serde_json::Value::Null);
636 !(v.is_null() || v.is_object() && v.as_object().is_some_and(|o| o.is_empty()))
637 })
638 });
639 let needs_object_mapper = needs_object_mapper_for_options || needs_object_mapper_for_handle || has_http_fixtures;
641
642 let has_streaming_fixtures = kotlin_android_style
646 && fixtures.iter().any(|f| {
647 if f.is_http_test() {
648 return false;
649 }
650 let cc = e2e_config.resolve_call_for_fixture(
651 f.call.as_deref(),
652 &f.id,
653 &f.resolved_category(),
654 &f.tags,
655 &f.input,
656 );
657 crate::codegen::streaming_assertions::resolve_is_streaming(f, cc.streaming)
658 });
659
660 let _ = writeln!(out, "import org.junit.jupiter.api.Test");
661 let _ = writeln!(out, "import kotlin.test.assertEquals");
662 let _ = writeln!(out, "import kotlin.test.assertTrue");
663 let _ = writeln!(out, "import kotlin.test.assertFalse");
664 let _ = writeln!(out, "import kotlin.test.assertFailsWith");
665 if has_client_factory_fixtures || kotlin_android_style {
666 let _ = writeln!(out, "import kotlinx.coroutines.runBlocking");
667 }
668 if has_streaming_fixtures {
671 let _ = writeln!(out, "import kotlinx.coroutines.flow.toList");
672 }
673 let binding_pkg_for_imports: String = if !import_path.is_empty() {
679 import_path
680 .rsplit_once('.')
681 .map(|(p, _)| p.to_string())
682 .unwrap_or_else(|| kotlin_pkg_id.to_string())
683 } else {
684 kotlin_pkg_id.to_string()
685 };
686 let has_call_fixtures = fixtures.iter().any(|f| !f.is_http_test());
688 if has_call_fixtures {
689 if !import_path.is_empty() {
690 let _ = writeln!(out, "import {import_path}");
691 } else if !class_name.is_empty() {
692 let _ = writeln!(out, "import {binding_pkg_for_imports}.{class_name}");
693 }
694 }
695 if needs_object_mapper {
696 let _ = writeln!(out, "import com.fasterxml.jackson.databind.ObjectMapper");
697 let _ = writeln!(out, "import com.fasterxml.jackson.datatype.jdk8.Jdk8Module");
698 if kotlin_android_style {
702 let _ = writeln!(out, "import com.fasterxml.jackson.module.kotlin.registerKotlinModule");
703 }
704 }
705 if has_call_fixtures {
709 let mut sorted_opts: Vec<&String> = per_fixture_options_types.iter().collect();
710 sorted_opts.sort();
711 for opts_type in sorted_opts {
712 let _ = writeln!(out, "import {binding_pkg_for_imports}.{opts_type}");
713 }
714 }
715 if needs_object_mapper_for_handle {
717 let _ = writeln!(out, "import {binding_pkg_for_imports}.CrawlConfig");
718 }
719 let mut batch_elem_imports: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
722 for f in fixtures.iter() {
723 let cc =
724 e2e_config.resolve_call_for_fixture(f.call.as_deref(), &f.id, &f.resolved_category(), &f.tags, &f.input);
725 let fixture_args = if cc.args.is_empty() { args } else { cc.args.as_slice() };
726 for arg in fixture_args.iter() {
727 if arg.arg_type != "json_object" {
728 continue;
729 }
730 let v = super::resolve_field(&f.input, &arg.field);
731 if !v.is_array() {
732 continue;
733 }
734 if let Some(elem) = &arg.element_type {
735 if elem == "BatchBytesItem" || elem == "BatchFileItem" {
736 batch_elem_imports.insert(elem.clone());
737 }
738 }
739 }
740 }
741 for elem in &batch_elem_imports {
742 let _ = writeln!(out, "import {binding_pkg_for_imports}.{elem}");
743 }
744 let _ = writeln!(out);
745
746 let _ = writeln!(out, "/** E2e tests for category: {category}. */");
747 let _ = writeln!(out, "class {test_class_name} {{");
748
749 if needs_object_mapper {
750 let _ = writeln!(out);
751 let _ = writeln!(out, " companion object {{");
752 let kotlin_module_call = if kotlin_android_style {
757 ".registerKotlinModule()"
758 } else {
759 ""
760 };
761 let _ = writeln!(
762 out,
763 " private val MAPPER = ObjectMapper().registerModule(Jdk8Module()){kotlin_module_call}.setPropertyNamingStrategy(com.fasterxml.jackson.databind.PropertyNamingStrategies.SNAKE_CASE)"
764 );
765 let _ = writeln!(out, " }}");
766 }
767
768 for fixture in fixtures {
769 render_test_method(
770 &mut out,
771 fixture,
772 simple_class,
773 function_name,
774 result_var,
775 args,
776 options_type,
777 result_is_simple,
778 e2e_config,
779 type_enum_fields,
780 kotlin_android_style,
781 );
782 let _ = writeln!(out);
783 }
784
785 let _ = writeln!(out, "}}");
786 out
787}
788
789pub(crate) struct KotlinTestClientRenderer;
796
797impl client::TestClientRenderer for KotlinTestClientRenderer {
798 fn language_name(&self) -> &'static str {
799 "kotlin"
800 }
801
802 fn sanitize_test_name(&self, id: &str) -> String {
803 sanitize_ident(id).to_upper_camel_case()
804 }
805
806 fn render_test_open(&self, out: &mut String, fn_name: &str, description: &str, skip_reason: Option<&str>) {
807 let _ = writeln!(out, " @Test");
808 let _ = writeln!(out, " fun test{fn_name}() {{");
809 let _ = writeln!(out, " // {description}");
810 if let Some(reason) = skip_reason {
811 let escaped = escape_kotlin(reason);
812 let _ = writeln!(
813 out,
814 " org.junit.jupiter.api.Assumptions.assumeTrue(false, \"{escaped}\")"
815 );
816 }
817 }
818
819 fn render_test_close(&self, out: &mut String) {
820 let _ = writeln!(out, " }}");
821 }
822
823 fn render_call(&self, out: &mut String, ctx: &client::CallCtx<'_>) {
824 let method = ctx.method.to_uppercase();
825 let fixture_path = ctx.path;
826
827 const JAVA_RESTRICTED_HEADERS: &[&str] = &["connection", "content-length", "expect", "host", "upgrade"];
829
830 let _ = writeln!(
831 out,
832 " val baseUrl = System.getenv(\"MOCK_SERVER_URL\") ?: \"http://localhost:8080\""
833 );
834 let _ = writeln!(out, " val uri = java.net.URI.create(\"$baseUrl{fixture_path}\")");
835
836 let body_publisher = if let Some(body) = ctx.body {
837 let json = serde_json::to_string(body).unwrap_or_default();
838 let escaped = escape_kotlin(&json);
839 format!("java.net.http.HttpRequest.BodyPublishers.ofString(\"{escaped}\")")
840 } else {
841 "java.net.http.HttpRequest.BodyPublishers.noBody()".to_string()
842 };
843
844 let _ = writeln!(out, " val builder = java.net.http.HttpRequest.newBuilder(uri)");
845 let _ = writeln!(out, " .method(\"{method}\", {body_publisher})");
846
847 if ctx.body.is_some() {
849 let content_type = ctx.content_type.unwrap_or("application/json");
850 let _ = writeln!(out, " .header(\"Content-Type\", \"{content_type}\")");
851 }
852
853 let mut header_pairs: Vec<(&String, &String)> = ctx.headers.iter().collect();
855 header_pairs.sort_by_key(|(k, _)| k.as_str());
856 for (name, value) in &header_pairs {
857 if JAVA_RESTRICTED_HEADERS.contains(&name.to_lowercase().as_str()) {
858 continue;
859 }
860 let escaped_name = escape_kotlin(name);
861 let escaped_value = escape_kotlin(value);
862 let _ = writeln!(out, " .header(\"{escaped_name}\", \"{escaped_value}\")");
863 }
864
865 if !ctx.cookies.is_empty() {
867 let mut cookie_pairs: Vec<(&String, &String)> = ctx.cookies.iter().collect();
868 cookie_pairs.sort_by_key(|(k, _)| k.as_str());
869 let cookie_str: Vec<String> = cookie_pairs.iter().map(|(k, v)| format!("{k}={v}")).collect();
870 let cookie_header = escape_kotlin(&cookie_str.join("; "));
871 let _ = writeln!(out, " .header(\"Cookie\", \"{cookie_header}\")");
872 }
873
874 let _ = writeln!(
875 out,
876 " val {} = java.net.http.HttpClient.newHttpClient()",
877 ctx.response_var
878 );
879 let _ = writeln!(
880 out,
881 " .send(builder.build(), java.net.http.HttpResponse.BodyHandlers.ofString())"
882 );
883 }
884
885 fn render_assert_status(&self, out: &mut String, response_var: &str, status: u16) {
886 let _ = writeln!(
887 out,
888 " assertEquals({status}, {response_var}.statusCode(), \"status code mismatch\")"
889 );
890 }
891
892 fn render_assert_header(&self, out: &mut String, response_var: &str, name: &str, expected: &str) {
893 let escaped_name = escape_kotlin(name);
894 match expected {
895 "<<present>>" => {
896 let _ = writeln!(
897 out,
898 " assertTrue({response_var}.headers().firstValue(\"{escaped_name}\").isPresent, \"header {escaped_name} should be present\")"
899 );
900 }
901 "<<absent>>" => {
902 let _ = writeln!(
903 out,
904 " assertFalse({response_var}.headers().firstValue(\"{escaped_name}\").isPresent, \"header {escaped_name} should be absent\")"
905 );
906 }
907 "<<uuid>>" => {
908 let _ = writeln!(
909 out,
910 " 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\")"
911 );
912 }
913 exact => {
914 let escaped_value = escape_kotlin(exact);
915 let _ = writeln!(
916 out,
917 " assertTrue({response_var}.headers().firstValue(\"{escaped_name}\").orElse(\"\").contains(\"{escaped_value}\"), \"header {escaped_name} mismatch\")"
918 );
919 }
920 }
921 }
922
923 fn render_assert_json_body(&self, out: &mut String, response_var: &str, expected: &serde_json::Value) {
924 match expected {
925 serde_json::Value::Object(_) | serde_json::Value::Array(_) => {
926 let json_str = serde_json::to_string(expected).unwrap_or_default();
927 let escaped = escape_kotlin(&json_str);
928 let _ = writeln!(out, " val bodyJson = MAPPER.readTree({response_var}.body())");
929 let _ = writeln!(out, " val expectedJson = MAPPER.readTree(\"{escaped}\")");
930 let _ = writeln!(out, " assertEquals(expectedJson, bodyJson, \"body mismatch\")");
931 }
932 serde_json::Value::String(s) => {
933 let escaped = escape_kotlin(s);
934 let _ = writeln!(
935 out,
936 " assertEquals(\"{escaped}\", {response_var}.body().trim(), \"body mismatch\")"
937 );
938 }
939 other => {
940 let escaped = escape_kotlin(&other.to_string());
941 let _ = writeln!(
942 out,
943 " assertEquals(\"{escaped}\", {response_var}.body().trim(), \"body mismatch\")"
944 );
945 }
946 }
947 }
948
949 fn render_assert_partial_body(&self, out: &mut String, response_var: &str, expected: &serde_json::Value) {
950 if let Some(obj) = expected.as_object() {
951 let _ = writeln!(out, " val _partialTree = MAPPER.readTree({response_var}.body())");
952 for (key, val) in obj {
953 let escaped_key = escape_kotlin(key);
954 match val {
955 serde_json::Value::String(s) => {
956 let escaped_val = escape_kotlin(s);
957 let _ = writeln!(
958 out,
959 " assertEquals(\"{escaped_val}\", _partialTree.path(\"{escaped_key}\").asText(), \"partial body field '{escaped_key}' mismatch\")"
960 );
961 }
962 serde_json::Value::Bool(b) => {
963 let _ = writeln!(
964 out,
965 " assertEquals({b}, _partialTree.path(\"{escaped_key}\").asBoolean(), \"partial body field '{escaped_key}' mismatch\")"
966 );
967 }
968 serde_json::Value::Number(n) => {
969 let _ = writeln!(
970 out,
971 " assertEquals({n}, _partialTree.path(\"{escaped_key}\").numberValue(), \"partial body field '{escaped_key}' mismatch\")"
972 );
973 }
974 other => {
975 let json_str = serde_json::to_string(other).unwrap_or_default();
976 let escaped_val = escape_kotlin(&json_str);
977 let _ = writeln!(
978 out,
979 " assertEquals(MAPPER.readTree(\"{escaped_val}\"), _partialTree.path(\"{escaped_key}\"), \"partial body field '{escaped_key}' mismatch\")"
980 );
981 }
982 }
983 }
984 }
985 }
986
987 fn render_assert_validation_errors(
988 &self,
989 out: &mut String,
990 response_var: &str,
991 errors: &[ValidationErrorExpectation],
992 ) {
993 let _ = writeln!(out, " val _veTree = MAPPER.readTree({response_var}.body())");
994 let _ = writeln!(out, " val _veErrors = _veTree.path(\"errors\")");
995 for ve in errors {
996 let escaped_msg = escape_kotlin(&ve.msg);
997 let _ = writeln!(
998 out,
999 " assertTrue((0 until _veErrors.size()).any {{ _veErrors.get(it).path(\"msg\").asText().contains(\"{escaped_msg}\") }}, \"expected validation error containing: {escaped_msg}\")"
1000 );
1001 }
1002 }
1003}
1004
1005fn render_http_test_method(out: &mut String, fixture: &Fixture, http: &HttpFixture) {
1010 if http.expected_response.status_code == 101 {
1012 let method_name = sanitize_ident(&fixture.id).to_upper_camel_case();
1013 let description = &fixture.description;
1014 let _ = writeln!(out, " @Test");
1015 let _ = writeln!(out, " fun test{method_name}() {{");
1016 let _ = writeln!(out, " // {description}");
1017 let _ = writeln!(
1018 out,
1019 " org.junit.jupiter.api.Assumptions.assumeTrue(false, \"Skipped: Java HttpClient cannot handle 101 Switching Protocols responses\")"
1020 );
1021 let _ = writeln!(out, " }}");
1022 return;
1023 }
1024
1025 client::http_call::render_http_test(out, &KotlinTestClientRenderer, fixture);
1026}
1027
1028#[allow(clippy::too_many_arguments)]
1029fn render_test_method(
1030 out: &mut String,
1031 fixture: &Fixture,
1032 class_name: &str,
1033 _function_name: &str,
1034 _result_var: &str,
1035 _args: &[crate::config::ArgMapping],
1036 options_type: Option<&str>,
1037 result_is_simple: bool,
1038 e2e_config: &E2eConfig,
1039 type_enum_fields: &std::collections::HashMap<String, HashSet<String>>,
1040 kotlin_android_style: bool,
1041) {
1042 if let Some(http) = &fixture.http {
1044 render_http_test_method(out, fixture, http);
1045 return;
1046 }
1047
1048 let call_config = e2e_config.resolve_call_for_fixture(
1050 fixture.call.as_deref(),
1051 &fixture.id,
1052 &fixture.resolved_category(),
1053 &fixture.tags,
1054 &fixture.input,
1055 );
1056 let call_field_resolver = FieldResolver::new(
1058 e2e_config.effective_fields(call_config),
1059 e2e_config.effective_fields_optional(call_config),
1060 e2e_config.effective_result_fields(call_config),
1061 e2e_config.effective_fields_array(call_config),
1062 &HashSet::new(),
1063 );
1064 let field_resolver = &call_field_resolver;
1065 let enum_fields = e2e_config.effective_fields_enum(call_config);
1066 let lang = "kotlin";
1067 let call_overrides = call_config.overrides.get(lang);
1068
1069 let client_factory = call_overrides
1078 .and_then(|o| o.client_factory.as_deref())
1079 .or_else(|| {
1080 e2e_config
1081 .call
1082 .overrides
1083 .get(lang)
1084 .and_then(|o| o.client_factory.as_deref())
1085 })
1086 .or_else(|| {
1087 if !kotlin_android_style {
1088 return None;
1089 }
1090 call_config
1093 .overrides
1094 .get("kotlin_android")
1095 .and_then(|o| o.client_factory.as_deref())
1096 .or_else(|| {
1097 call_config
1098 .overrides
1099 .get("java")
1100 .and_then(|o| o.client_factory.as_deref())
1101 })
1102 .or_else(|| {
1103 e2e_config
1104 .call
1105 .overrides
1106 .get("kotlin_android")
1107 .and_then(|o| o.client_factory.as_deref())
1108 })
1109 .or_else(|| {
1110 e2e_config
1111 .call
1112 .overrides
1113 .get("java")
1114 .and_then(|o| o.client_factory.as_deref())
1115 })
1116 });
1117
1118 let effective_function_name = call_overrides
1119 .and_then(|o| o.function.as_ref())
1120 .cloned()
1121 .unwrap_or_else(|| call_config.function.to_lower_camel_case());
1122 let effective_result_var = &call_config.result_var;
1123 let effective_args = &call_config.args;
1124 let function_name = effective_function_name.as_str();
1125 let result_var = effective_result_var.as_str();
1126 let args: &[crate::config::ArgMapping] = effective_args.as_slice();
1127 let effective_options_type: Option<String> = call_overrides
1133 .and_then(|o| o.options_type.clone())
1134 .or_else(|| options_type.map(|s| s.to_string()))
1135 .or_else(|| {
1136 if kotlin_android_style {
1138 for cand in ["kotlin_android", "java", "csharp", "c", "go", "php", "python"] {
1139 if let Some(o) = call_config.overrides.get(cand) {
1140 if let Some(t) = &o.options_type {
1141 return Some(t.clone());
1142 }
1143 }
1144 }
1145 } else {
1146 for cand in ["csharp", "c", "go", "php", "python"] {
1147 if let Some(o) = call_config.overrides.get(cand) {
1148 if let Some(t) = &o.options_type {
1149 return Some(t.clone());
1150 }
1151 }
1152 }
1153 }
1154 None
1155 });
1156 let options_type = effective_options_type.as_deref();
1157
1158 let effective_result_is_simple = call_overrides.is_some_and(|o| o.result_is_simple)
1163 || call_config.result_is_simple
1164 || result_is_simple
1165 || ["java", "csharp", "go"]
1166 .iter()
1167 .any(|cand| call_config.overrides.get(*cand).is_some_and(|o| o.result_is_simple));
1168 let result_is_simple = effective_result_is_simple;
1169
1170 let result_is_option = call_overrides.is_some_and(|o| o.result_is_option) || call_config.result_is_option;
1174
1175 let method_name = fixture.id.to_upper_camel_case();
1176 let description = &fixture.description;
1177 let expects_error = fixture.assertions.iter().any(|a| a.assertion_type == "error");
1178
1179 let is_streaming = crate::codegen::streaming_assertions::resolve_is_streaming(fixture, call_config.streaming);
1181 let stream_lang = if kotlin_android_style {
1182 "kotlin_android"
1183 } else {
1184 "kotlin"
1185 };
1186 let collect_snippet = if is_streaming && !expects_error {
1187 crate::codegen::streaming_assertions::StreamingFieldResolver::collect_snippet(stream_lang, result_var, "chunks")
1188 .unwrap_or_default()
1189 } else {
1190 String::new()
1191 };
1192
1193 let needs_deser = options_type.is_some()
1197 && args
1198 .iter()
1199 .any(|arg| arg.arg_type == "json_object" && !super::resolve_field(&fixture.input, &arg.field).is_null());
1200
1201 let effective_enum_fields: std::borrow::Cow<HashSet<String>> = {
1212 let result_type_name: Option<&str> = call_overrides
1215 .and_then(|co| co.result_type.as_deref())
1216 .or_else(|| call_config.overrides.get("java").and_then(|o| o.result_type.as_deref()))
1217 .or_else(|| call_config.overrides.get("c").and_then(|o| o.result_type.as_deref()));
1218 let auto_enum_fields: Option<&HashSet<String>> = result_type_name.and_then(|name| type_enum_fields.get(name));
1219 let java_call_overrides = if kotlin_android_style {
1223 call_config
1224 .overrides
1225 .get("java")
1226 .or_else(|| call_config.overrides.get("kotlin_android"))
1227 } else {
1228 None
1229 };
1230 let has_per_call = call_overrides.is_some_and(|co| !co.enum_fields.is_empty())
1231 || java_call_overrides.is_some_and(|co| !co.enum_fields.is_empty());
1232 let has_auto = auto_enum_fields.is_some_and(|f| !f.is_empty());
1233 if has_per_call || has_auto {
1234 let mut merged = enum_fields.clone();
1235 if let Some(co) = call_overrides {
1236 merged.extend(co.enum_fields.keys().cloned());
1237 }
1238 if let Some(co) = java_call_overrides {
1239 merged.extend(co.enum_fields.keys().cloned());
1240 }
1241 if let Some(auto_fields) = auto_enum_fields {
1242 merged.extend(auto_fields.iter().cloned());
1243 }
1244 std::borrow::Cow::Owned(merged)
1245 } else {
1246 std::borrow::Cow::Borrowed(enum_fields)
1247 }
1248 };
1249 let enum_fields: &HashSet<String> = &effective_enum_fields;
1250
1251 let _ = writeln!(out, " @Test");
1252 if client_factory.is_some() || kotlin_android_style {
1253 let _ = writeln!(out, " fun test{method_name}() = runBlocking {{");
1254 } else {
1255 let _ = writeln!(out, " fun test{method_name}() {{");
1256 }
1257 let _ = writeln!(out, " // {description}");
1258
1259 if needs_deser {
1265 for arg in args {
1266 if arg.arg_type != "json_object" {
1267 continue;
1268 }
1269 let val = super::resolve_field(&fixture.input, &arg.field);
1270 if val.is_null() {
1271 continue;
1272 }
1273 if val.is_array() && arg.element_type.is_some() {
1276 continue;
1277 }
1278 let Some(opts_type) = options_type else { continue };
1279 let normalized = super::transform_json_keys_for_language(val, "snake_case");
1280 let json_str = serde_json::to_string(&normalized).unwrap_or_default();
1281 let var_name = &arg.name;
1282 let _ = writeln!(
1283 out,
1284 " val {var_name} = MAPPER.readValue(\"{}\", {opts_type}::class.java)",
1285 escape_kotlin(&json_str)
1286 );
1287 }
1288 }
1289
1290 let (setup_lines, args_str) = build_args_and_setup(
1291 fixture,
1292 &fixture.input,
1293 args,
1294 class_name,
1295 options_type,
1296 &fixture.id,
1297 kotlin_android_style,
1298 );
1299
1300 if let Some(factory) = client_factory {
1305 let fixture_id = &fixture.id;
1306 let mock_url_expr = format!(
1311 "System.getProperty(\"mockServer.{fixture_id}\", System.getProperty(\"mockServerUrl\", System.getenv(\"MOCK_SERVER_URL\") ?: \"\") + \"/fixtures/{fixture_id}\")"
1312 );
1313 for line in &setup_lines {
1314 let _ = writeln!(out, " {line}");
1315 }
1316 let _ = writeln!(
1317 out,
1318 " val client = {class_name}.{factory}(apiKey = \"test-key\", baseUrl = {mock_url_expr})"
1319 );
1320 if expects_error {
1321 let _ = writeln!(out, " assertFailsWith<Exception> {{");
1322 let _ = writeln!(out, " client.{function_name}({args_str})");
1323 let _ = writeln!(out, " }}");
1324 let _ = writeln!(out, " client.close()");
1325 let _ = writeln!(out, " }}");
1326 return;
1327 }
1328 let _ = writeln!(out, " val {result_var} = client.{function_name}({args_str})");
1329 if !collect_snippet.is_empty() {
1330 let _ = writeln!(out, " {collect_snippet}");
1331 }
1332 for assertion in &fixture.assertions {
1333 render_assertion(
1334 out,
1335 assertion,
1336 result_var,
1337 class_name,
1338 field_resolver,
1339 result_is_simple,
1340 result_is_option,
1341 enum_fields,
1342 e2e_config.effective_fields_c_types(call_config),
1343 is_streaming,
1344 kotlin_android_style,
1345 );
1346 }
1347 let _ = writeln!(out, " client.close()");
1348 let _ = writeln!(out, " }}");
1349 return;
1350 }
1351
1352 if expects_error {
1354 let _ = writeln!(out, " assertFailsWith<Exception> {{");
1357 for line in &setup_lines {
1358 let _ = writeln!(out, " {line}");
1359 }
1360 let _ = writeln!(out, " {class_name}.{function_name}({args_str})");
1361 let _ = writeln!(out, " }}");
1362 let _ = writeln!(out, " }}");
1363 return;
1364 }
1365
1366 for line in &setup_lines {
1367 let _ = writeln!(out, " {line}");
1368 }
1369
1370 let _ = writeln!(
1371 out,
1372 " val {result_var} = {class_name}.{function_name}({args_str})"
1373 );
1374
1375 if !collect_snippet.is_empty() {
1376 let _ = writeln!(out, " {collect_snippet}");
1377 }
1378
1379 for assertion in &fixture.assertions {
1380 render_assertion(
1381 out,
1382 assertion,
1383 result_var,
1384 class_name,
1385 field_resolver,
1386 result_is_simple,
1387 result_is_option,
1388 enum_fields,
1389 &e2e_config.fields_c_types,
1390 is_streaming,
1391 kotlin_android_style,
1392 );
1393 }
1394
1395 let _ = writeln!(out, " }}");
1396}
1397
1398fn build_args_and_setup(
1411 fixture: &Fixture,
1412 input: &serde_json::Value,
1413 args: &[crate::config::ArgMapping],
1414 class_name: &str,
1415 options_type: Option<&str>,
1416 fixture_id: &str,
1417 kotlin_android_style: bool,
1418) -> (Vec<String>, String) {
1419 if args.is_empty() {
1420 return (Vec::new(), String::new());
1421 }
1422
1423 let mut setup_lines: Vec<String> = Vec::new();
1424 let mut parts: Vec<String> = Vec::new();
1425
1426 for arg in args {
1427 if arg.arg_type == "mock_url" {
1428 if fixture.has_host_root_route() {
1429 setup_lines.push(format!(
1430 "val {} = System.getProperty(\"mockServer.{fixture_id}\", System.getProperty(\"mockServerUrl\", System.getenv(\"MOCK_SERVER_URL\")) + \"/fixtures/{fixture_id}\")",
1431 arg.name,
1432 ));
1433 } else {
1434 setup_lines.push(format!(
1435 "val {} = System.getProperty(\"mockServerUrl\", System.getenv(\"MOCK_SERVER_URL\")) + \"/fixtures/{fixture_id}\"",
1436 arg.name,
1437 ));
1438 }
1439 parts.push(arg.name.clone());
1440 continue;
1441 }
1442
1443 if arg.arg_type == "handle" {
1444 let constructor_name = format!("create{}", arg.name.to_upper_camel_case());
1445 let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
1446 let config_value = input.get(field).unwrap_or(&serde_json::Value::Null);
1447 if config_value.is_null()
1448 || config_value.is_object() && config_value.as_object().is_some_and(|o| o.is_empty())
1449 {
1450 setup_lines.push(format!("val {} = {class_name}.{constructor_name}(null)", arg.name,));
1451 } else {
1452 let json_str = serde_json::to_string(config_value).unwrap_or_default();
1453 let name = &arg.name;
1454 setup_lines.push(format!(
1455 "val {name}Config = MAPPER.readValue(\"{}\", CrawlConfig::class.java)",
1456 escape_kotlin(&json_str),
1457 ));
1458 setup_lines.push(format!(
1459 "val {} = {class_name}.{constructor_name}({name}Config)",
1460 arg.name,
1461 name = name,
1462 ));
1463 }
1464 parts.push(arg.name.clone());
1465 continue;
1466 }
1467
1468 let val_resolved = super::resolve_field(input, &arg.field);
1470 let val: Option<&serde_json::Value> = if val_resolved.is_null() {
1471 None
1472 } else {
1473 Some(val_resolved)
1474 };
1475 match val {
1476 None | Some(serde_json::Value::Null) if arg.optional => {
1477 if arg.arg_type == "json_object" {
1485 if let Some(opts_type) = options_type {
1486 if kotlin_android_style {
1487 parts.push("null".to_string());
1488 } else {
1489 parts.push(format!("{opts_type}.builder().build()"));
1490 }
1491 } else {
1492 parts.push("null".to_string());
1493 }
1494 } else {
1495 parts.push("null".to_string());
1496 }
1497 }
1498 None | Some(serde_json::Value::Null) => {
1499 let default_val = match arg.arg_type.as_str() {
1500 "string" => "\"\"".to_string(),
1501 "int" | "integer" => "0".to_string(),
1502 "float" | "number" => "0.0".to_string(),
1503 "bool" | "boolean" => "false".to_string(),
1504 _ => "null".to_string(),
1505 };
1506 parts.push(default_val);
1507 }
1508 Some(v) => {
1509 if arg.arg_type == "json_object" && v.is_array() {
1514 if let Some(elem) = &arg.element_type {
1515 if elem == "BatchBytesItem" || elem == "BatchFileItem" {
1516 parts.push(emit_kotlin_batch_item_array(v, elem));
1517 continue;
1518 }
1519 let items: Vec<String> = v
1521 .as_array()
1522 .map(|arr| arr.iter().map(json_to_kotlin).collect())
1523 .unwrap_or_default();
1524 parts.push(format!("listOf({})", items.join(", ")));
1525 continue;
1526 }
1527 }
1528 if arg.arg_type == "json_object" && options_type.is_some() {
1530 parts.push(arg.name.clone());
1531 continue;
1532 }
1533 if arg.arg_type == "bytes" {
1537 let val = json_to_kotlin(v);
1538 parts.push(format!(
1539 "java.nio.file.Files.readAllBytes(java.nio.file.Path.of({val}))"
1540 ));
1541 continue;
1542 }
1543 if arg.arg_type == "file_path" {
1547 let val = json_to_kotlin(v);
1548 parts.push(format!("java.nio.file.Path.of({val})"));
1549 continue;
1550 }
1551 parts.push(json_to_kotlin(v));
1552 }
1553 }
1554 }
1555
1556 (setup_lines, parts.join(", "))
1557}
1558
1559fn emit_kotlin_batch_item_array(arr: &serde_json::Value, elem_type: &str) -> String {
1563 let Some(items) = arr.as_array() else {
1564 return "emptyList()".to_string();
1565 };
1566 let parts: Vec<String> = items
1567 .iter()
1568 .filter_map(|item| {
1569 let obj = item.as_object()?;
1570 match elem_type {
1571 "BatchBytesItem" => {
1572 let mime_type = obj.get("mime_type").and_then(|v| v.as_str()).unwrap_or("text/plain");
1573 let content_code = obj
1574 .get("content")
1575 .and_then(|v| v.as_array())
1576 .map(|arr| {
1577 let bytes: Vec<String> =
1578 arr.iter().filter_map(|v| v.as_u64().map(|n| format!("{n}"))).collect();
1579 format!("byteArrayOf({})", bytes.join(", "))
1580 })
1581 .unwrap_or_else(|| "byteArrayOf()".to_string());
1582 Some(format!("{elem_type}({content_code}, \"{mime_type}\", null)"))
1583 }
1584 "BatchFileItem" => {
1585 let path = obj.get("path").and_then(|v| v.as_str()).unwrap_or("");
1586 Some(format!("{elem_type}(java.nio.file.Paths.get(\"{path}\"), null)"))
1587 }
1588 _ => None,
1589 }
1590 })
1591 .collect();
1592 format!("listOf({})", parts.join(", "))
1593}
1594
1595#[allow(clippy::too_many_arguments)]
1596fn render_assertion(
1597 out: &mut String,
1598 assertion: &Assertion,
1599 result_var: &str,
1600 _class_name: &str,
1601 field_resolver: &FieldResolver,
1602 result_is_simple: bool,
1603 result_is_option: bool,
1604 enum_fields: &HashSet<String>,
1605 fields_c_types: &std::collections::HashMap<String, String>,
1606 is_streaming: bool,
1607 kotlin_android_style: bool,
1608) {
1609 if is_streaming {
1614 if let Some(f) = &assertion.field {
1615 if f == "usage" || f.starts_with("usage.") {
1616 let stream_lang = if kotlin_android_style {
1617 "kotlin_android"
1618 } else {
1619 "kotlin"
1620 };
1621 let base_expr = crate::codegen::streaming_assertions::StreamingFieldResolver::accessor(
1622 "usage",
1623 stream_lang,
1624 "chunks",
1625 )
1626 .unwrap_or_else(|| {
1627 if kotlin_android_style {
1628 "(if (chunks.isEmpty()) null else chunks.last().usage)".to_string()
1629 } else {
1630 "(if (chunks.isEmpty()) null else chunks.last().usage())".to_string()
1631 }
1632 });
1633
1634 let expr = if let Some(tail) = f.strip_prefix("usage.") {
1637 use heck::ToLowerCamelCase;
1638 if kotlin_android_style {
1639 tail.split('.')
1641 .fold(base_expr, |acc, seg| format!("{acc}?.{}", seg.to_lower_camel_case()))
1642 } else {
1643 tail.split('.')
1645 .fold(base_expr, |acc, seg| format!("{acc}?.{}()", seg.to_lower_camel_case()))
1646 }
1647 } else {
1648 base_expr
1649 };
1650
1651 let field_is_long = fields_c_types
1653 .get(f.as_str())
1654 .is_some_and(|t| matches!(t.as_str(), "uint64_t" | "int64_t"));
1655
1656 let line = match assertion.assertion_type.as_str() {
1657 "equals" => {
1658 if let Some(expected) = &assertion.value {
1659 let kotlin_val = if field_is_long && expected.is_number() && !expected.is_f64() {
1660 format!("{}L", expected)
1661 } else {
1662 json_to_kotlin(expected)
1663 };
1664 format!(" assertEquals({kotlin_val}, {expr}!!)\n")
1665 } else {
1666 String::new()
1667 }
1668 }
1669 _ => String::new(),
1670 };
1671 if !line.is_empty() {
1672 out.push_str(&line);
1673 }
1674 return;
1675 }
1676 }
1677 }
1678
1679 if let Some(f) = &assertion.field {
1682 if !f.is_empty() && crate::codegen::streaming_assertions::is_streaming_virtual_field(f) {
1683 let stream_lang = if kotlin_android_style {
1684 "kotlin_android"
1685 } else {
1686 "kotlin"
1687 };
1688 if let Some(expr) =
1689 crate::codegen::streaming_assertions::StreamingFieldResolver::accessor(f, stream_lang, "chunks")
1690 {
1691 let line = match assertion.assertion_type.as_str() {
1692 "count_min" => {
1693 if let Some(n) = assertion.value.as_ref().and_then(|v| v.as_u64()) {
1694 format!(" assertTrue({expr}.size >= {n}, \"expected >= {n} chunks\")\n")
1695 } else {
1696 String::new()
1697 }
1698 }
1699 "count_equals" => {
1700 if let Some(n) = assertion.value.as_ref().and_then(|v| v.as_u64()) {
1701 format!(
1702 " assertEquals({n}.toLong(), {expr}.size.toLong(), \"expected exactly {n} elements\")\n"
1703 )
1704 } else {
1705 String::new()
1706 }
1707 }
1708 "equals" => {
1709 if let Some(serde_json::Value::String(s)) = &assertion.value {
1710 let escaped = escape_kotlin(s);
1711 format!(" assertEquals(\"{escaped}\", {expr})\n")
1712 } else if let Some(b) = assertion.value.as_ref().and_then(|v| v.as_bool()) {
1713 format!(" assertEquals({b}, {expr})\n")
1714 } else {
1715 String::new()
1716 }
1717 }
1718 "not_empty" => {
1719 format!(" assertFalse({expr}.isEmpty(), \"expected non-empty\")\n")
1720 }
1721 "is_empty" => {
1722 format!(" assertTrue({expr}.isEmpty(), \"expected empty\")\n")
1723 }
1724 "is_true" => {
1725 format!(" assertTrue({expr}, \"expected true\")\n")
1726 }
1727 "is_false" => {
1728 format!(" assertFalse({expr}, \"expected false\")\n")
1729 }
1730 "greater_than" => {
1731 if let Some(n) = assertion.value.as_ref().and_then(|v| v.as_u64()) {
1732 format!(" assertTrue({expr} > {n}, \"expected > {n}\")\n")
1733 } else {
1734 String::new()
1735 }
1736 }
1737 "contains" => {
1738 if let Some(serde_json::Value::String(s)) = &assertion.value {
1739 let escaped = escape_kotlin(s);
1740 format!(
1741 " assertTrue({expr}.contains(\"{escaped}\"), \"expected to contain: {escaped}\")\n"
1742 )
1743 } else {
1744 String::new()
1745 }
1746 }
1747 _ => format!(
1748 " // streaming field '{f}': assertion type '{}' not rendered\n",
1749 assertion.assertion_type
1750 ),
1751 };
1752 if !line.is_empty() {
1753 out.push_str(&line);
1754 }
1755 }
1756 return;
1757 }
1758 }
1759
1760 if let Some(f) = &assertion.field {
1762 if !f.is_empty() && !field_resolver.is_valid_for_result(f) {
1763 let _ = writeln!(out, " // skipped: field '{f}' not available on result type");
1764 return;
1765 }
1766 }
1767
1768 let field_is_enum = assertion
1770 .field
1771 .as_deref()
1772 .is_some_and(|f| enum_fields.contains(f) || enum_fields.contains(field_resolver.resolve(f)));
1773
1774 let accessor_lang = if kotlin_android_style {
1778 "kotlin_android"
1779 } else {
1780 "kotlin"
1781 };
1782 let field_expr = if result_is_simple {
1783 result_var.to_string()
1784 } else {
1785 match &assertion.field {
1786 Some(f) if !f.is_empty() => field_resolver.accessor(f, accessor_lang, result_var),
1787 _ => result_var.to_string(),
1788 }
1789 };
1790
1791 let field_is_optional = !result_is_simple
1801 && (field_expr.contains("?.")
1802 || assertion.field.as_deref().filter(|f| !f.is_empty()).is_some_and(|f| {
1803 let resolved = field_resolver.resolve(f);
1804 if field_resolver.has_map_access(f) {
1805 return false;
1806 }
1807 if field_resolver.is_optional(resolved) {
1809 return true;
1810 }
1811 let mut prefix = String::new();
1814 for part in resolved.split('.') {
1815 let key = part.split('[').next().unwrap_or(part);
1817 if !prefix.is_empty() {
1818 prefix.push('.');
1819 }
1820 prefix.push_str(key);
1821 if field_resolver.is_optional(&prefix) {
1822 return true;
1823 }
1824 }
1825 false
1826 }));
1827
1828 let string_field_expr = if field_is_optional {
1834 format!("{field_expr}.orEmpty()")
1835 } else {
1836 field_expr.clone()
1837 };
1838
1839 let nonnull_field_expr = if field_is_optional {
1842 format!("{field_expr}!!")
1843 } else {
1844 field_expr.clone()
1845 };
1846
1847 let string_expr = if kotlin_android_style {
1859 match (field_is_enum, field_is_optional) {
1860 (true, true) => format!("{field_expr}?.name?.lowercase().orEmpty()"),
1861 (true, false) => format!("{field_expr}.name.lowercase()"),
1862 (false, _) => string_field_expr.clone(),
1863 }
1864 } else {
1865 match (field_is_enum, field_is_optional) {
1866 (true, true) => format!("{field_expr}?.getValue().orEmpty()"),
1867 (true, false) => format!("{field_expr}.getValue()"),
1868 (false, _) => string_field_expr.clone(),
1869 }
1870 };
1871
1872 let field_is_long = assertion.field.as_deref().filter(|f| !f.is_empty()).is_some_and(|f| {
1876 let resolved = field_resolver.resolve(f);
1877 matches!(
1878 fields_c_types.get(resolved).map(String::as_str),
1879 Some("uint64_t") | Some("int64_t")
1880 )
1881 });
1882
1883 match assertion.assertion_type.as_str() {
1884 "equals" => {
1885 if let Some(expected) = &assertion.value {
1886 let kotlin_val = if field_is_long && expected.is_number() && !expected.is_f64() {
1890 format!("{}L", expected)
1891 } else {
1892 json_to_kotlin(expected)
1893 };
1894 if expected.is_string() {
1895 let _ = writeln!(out, " assertEquals({kotlin_val}, {string_expr}.trim())");
1896 } else {
1897 let _ = writeln!(out, " assertEquals({kotlin_val}, {nonnull_field_expr})");
1898 }
1899 }
1900 }
1901 "contains" => {
1902 if let Some(expected) = &assertion.value {
1903 let kotlin_val = json_to_kotlin(expected);
1904 let _ = writeln!(
1905 out,
1906 " assertTrue({string_expr}.contains({kotlin_val}), \"expected to contain: \" + {kotlin_val})"
1907 );
1908 }
1909 }
1910 "contains_all" => {
1911 if let Some(values) = &assertion.values {
1912 for val in values {
1913 let kotlin_val = json_to_kotlin(val);
1914 let _ = writeln!(
1915 out,
1916 " assertTrue({string_expr}.contains({kotlin_val}), \"expected to contain: \" + {kotlin_val})"
1917 );
1918 }
1919 }
1920 }
1921 "not_contains" => {
1922 if let Some(expected) = &assertion.value {
1923 let kotlin_val = json_to_kotlin(expected);
1924 let _ = writeln!(
1925 out,
1926 " assertFalse({string_expr}.contains({kotlin_val}), \"expected NOT to contain: \" + {kotlin_val})"
1927 );
1928 }
1929 }
1930 "not_empty" => {
1931 let bare_result_is_option =
1944 result_is_option && assertion.field.as_deref().filter(|f| !f.is_empty()).is_none();
1945 if bare_result_is_option && !kotlin_android_style {
1946 let _ = writeln!(
1947 out,
1948 " assertTrue({field_expr}.isPresent, \"expected non-empty value\")"
1949 );
1950 } else if bare_result_is_option || field_is_optional {
1951 let _ = writeln!(
1952 out,
1953 " assertTrue({field_expr} != null, \"expected non-empty value\")"
1954 );
1955 } else {
1956 let _ = writeln!(
1957 out,
1958 " assertFalse({string_field_expr}.isEmpty(), \"expected non-empty value\")"
1959 );
1960 }
1961 }
1962 "is_empty" => {
1963 let bare_result_is_option =
1964 result_is_option && assertion.field.as_deref().filter(|f| !f.is_empty()).is_none();
1965 if bare_result_is_option && !kotlin_android_style {
1966 let _ = writeln!(
1967 out,
1968 " assertTrue({field_expr}.isEmpty, \"expected empty value\")"
1969 );
1970 } else if bare_result_is_option || field_is_optional {
1971 let _ = writeln!(
1972 out,
1973 " assertTrue({field_expr} == null, \"expected empty value\")"
1974 );
1975 } else {
1976 let _ = writeln!(
1977 out,
1978 " assertTrue({string_field_expr}.isEmpty(), \"expected empty value\")"
1979 );
1980 }
1981 }
1982 "contains_any" => {
1983 if let Some(values) = &assertion.values {
1984 let checks: Vec<String> = values
1985 .iter()
1986 .map(|v| {
1987 let kotlin_val = json_to_kotlin(v);
1988 format!("{string_expr}.contains({kotlin_val})")
1989 })
1990 .collect();
1991 let joined = checks.join(" || ");
1992 let _ = writeln!(
1993 out,
1994 " assertTrue({joined}, \"expected to contain at least one of the specified values\")"
1995 );
1996 }
1997 }
1998 "greater_than" => {
1999 if let Some(val) = &assertion.value {
2000 let kotlin_val = json_to_kotlin(val);
2001 let _ = writeln!(
2002 out,
2003 " assertTrue({nonnull_field_expr} > {kotlin_val}, \"expected > {kotlin_val}\")"
2004 );
2005 }
2006 }
2007 "less_than" => {
2008 if let Some(val) = &assertion.value {
2009 let kotlin_val = json_to_kotlin(val);
2010 let _ = writeln!(
2011 out,
2012 " assertTrue({nonnull_field_expr} < {kotlin_val}, \"expected < {kotlin_val}\")"
2013 );
2014 }
2015 }
2016 "greater_than_or_equal" => {
2017 if let Some(val) = &assertion.value {
2018 let kotlin_val = json_to_kotlin(val);
2019 let _ = writeln!(
2020 out,
2021 " assertTrue({nonnull_field_expr} >= {kotlin_val}, \"expected >= {kotlin_val}\")"
2022 );
2023 }
2024 }
2025 "less_than_or_equal" => {
2026 if let Some(val) = &assertion.value {
2027 let kotlin_val = json_to_kotlin(val);
2028 let _ = writeln!(
2029 out,
2030 " assertTrue({nonnull_field_expr} <= {kotlin_val}, \"expected <= {kotlin_val}\")"
2031 );
2032 }
2033 }
2034 "starts_with" => {
2035 if let Some(expected) = &assertion.value {
2036 let kotlin_val = json_to_kotlin(expected);
2037 let _ = writeln!(
2038 out,
2039 " assertTrue({string_expr}.startsWith({kotlin_val}), \"expected to start with: \" + {kotlin_val})"
2040 );
2041 }
2042 }
2043 "ends_with" => {
2044 if let Some(expected) = &assertion.value {
2045 let kotlin_val = json_to_kotlin(expected);
2046 let _ = writeln!(
2047 out,
2048 " assertTrue({string_expr}.endsWith({kotlin_val}), \"expected to end with: \" + {kotlin_val})"
2049 );
2050 }
2051 }
2052 "min_length" => {
2053 if let Some(val) = &assertion.value {
2054 if let Some(n) = val.as_u64() {
2055 let _ = writeln!(
2056 out,
2057 " assertTrue({string_field_expr}.length >= {n}, \"expected length >= {n}\")"
2058 );
2059 }
2060 }
2061 }
2062 "max_length" => {
2063 if let Some(val) = &assertion.value {
2064 if let Some(n) = val.as_u64() {
2065 let _ = writeln!(
2066 out,
2067 " assertTrue({string_field_expr}.length <= {n}, \"expected length <= {n}\")"
2068 );
2069 }
2070 }
2071 }
2072 "count_min" => {
2073 if let Some(val) = &assertion.value {
2074 if let Some(n) = val.as_u64() {
2075 let _ = writeln!(
2076 out,
2077 " assertTrue({nonnull_field_expr}.size >= {n}, \"expected at least {n} elements\")"
2078 );
2079 }
2080 }
2081 }
2082 "count_equals" => {
2083 if let Some(val) = &assertion.value {
2084 if let Some(n) = val.as_u64() {
2085 let _ = writeln!(
2086 out,
2087 " assertEquals({n}, {nonnull_field_expr}.size, \"expected exactly {n} elements\")"
2088 );
2089 }
2090 }
2091 }
2092 "is_true" => {
2093 let _ = writeln!(out, " assertTrue({field_expr}, \"expected true\")");
2094 }
2095 "is_false" => {
2096 let _ = writeln!(out, " assertFalse({field_expr}, \"expected false\")");
2097 }
2098 "matches_regex" => {
2099 if let Some(expected) = &assertion.value {
2100 let kotlin_val = json_to_kotlin(expected);
2101 let _ = writeln!(
2102 out,
2103 " assertTrue(Regex({kotlin_val}).containsMatchIn({string_expr}), \"expected value to match regex: \" + {kotlin_val})"
2104 );
2105 }
2106 }
2107 "not_error" => {
2108 }
2110 "error" => {
2111 }
2113 "method_result" => {
2114 let _ = writeln!(
2116 out,
2117 " // method_result assertions not yet implemented for Kotlin"
2118 );
2119 }
2120 other => {
2121 panic!("Kotlin e2e generator: unsupported assertion type: {other}");
2122 }
2123 }
2124}
2125
2126fn json_to_kotlin(value: &serde_json::Value) -> String {
2128 match value {
2129 serde_json::Value::String(s) => format!("\"{}\"", escape_kotlin(s)),
2130 serde_json::Value::Bool(b) => b.to_string(),
2131 serde_json::Value::Number(n) => {
2132 if n.is_f64() {
2133 let s = n.to_string();
2136 if s.contains('.') || s.contains('e') || s.contains('E') {
2137 s
2138 } else {
2139 format!("{s}.0")
2140 }
2141 } else {
2142 n.to_string()
2143 }
2144 }
2145 serde_json::Value::Null => "null".to_string(),
2146 serde_json::Value::Array(arr) => {
2147 let items: Vec<String> = arr.iter().map(json_to_kotlin).collect();
2148 format!("listOf({})", items.join(", "))
2149 }
2150 serde_json::Value::Object(_) => {
2151 let json_str = serde_json::to_string(value).unwrap_or_default();
2152 format!("\"{}\"", escape_kotlin(&json_str))
2153 }
2154 }
2155}
2156
2157#[cfg(test)]
2158mod tests {
2159 use super::*;
2160 use std::collections::HashMap;
2161
2162 fn make_resolver_for_finish_reason() -> FieldResolver {
2163 let mut optional = HashSet::new();
2167 optional.insert("choices.finish_reason".to_string());
2168 let mut arrays = HashSet::new();
2169 arrays.insert("choices".to_string());
2170 FieldResolver::new(&HashMap::new(), &optional, &HashSet::new(), &arrays, &HashSet::new())
2171 }
2172
2173 #[test]
2177 fn assertion_enum_optional_uses_safe_get_value_then_or_empty() {
2178 let resolver = make_resolver_for_finish_reason();
2179 let mut enum_fields = HashSet::new();
2180 enum_fields.insert("choices.finish_reason".to_string());
2181 let assertion = Assertion {
2182 assertion_type: "equals".to_string(),
2183 field: Some("choices.finish_reason".to_string()),
2184 value: Some(serde_json::Value::String("stop".to_string())),
2185 values: None,
2186 method: None,
2187 check: None,
2188 args: None,
2189 return_type: None,
2190 };
2191 let mut out = String::new();
2192 render_assertion(
2193 &mut out,
2194 &assertion,
2195 "result",
2196 "",
2197 &resolver,
2198 false,
2199 false,
2200 &enum_fields,
2201 &HashMap::new(),
2202 false,
2203 false,
2204 );
2205 assert!(
2206 out.contains("result.choices().first().finishReason()?.getValue().orEmpty().trim()"),
2207 "expected enum-optional safe-call pattern, got: {out}"
2208 );
2209 assert!(
2210 !out.contains(".finishReason().orEmpty().getValue()"),
2211 "must not emit .orEmpty().getValue() on a nullable enum: {out}"
2212 );
2213 }
2214
2215 #[test]
2218 fn assertion_enum_non_optional_uses_plain_get_value() {
2219 let mut arrays = HashSet::new();
2220 arrays.insert("choices".to_string());
2221 let resolver = FieldResolver::new(
2222 &HashMap::new(),
2223 &HashSet::new(),
2224 &HashSet::new(),
2225 &arrays,
2226 &HashSet::new(),
2227 );
2228 let mut enum_fields = HashSet::new();
2229 enum_fields.insert("choices.finish_reason".to_string());
2230 let assertion = Assertion {
2231 assertion_type: "equals".to_string(),
2232 field: Some("choices.finish_reason".to_string()),
2233 value: Some(serde_json::Value::String("stop".to_string())),
2234 values: None,
2235 method: None,
2236 check: None,
2237 args: None,
2238 return_type: None,
2239 };
2240 let mut out = String::new();
2241 render_assertion(
2242 &mut out,
2243 &assertion,
2244 "result",
2245 "",
2246 &resolver,
2247 false,
2248 false,
2249 &enum_fields,
2250 &HashMap::new(),
2251 false,
2252 false,
2253 );
2254 assert!(
2255 out.contains("result.choices().first().finishReason().getValue().trim()"),
2256 "expected plain .getValue() for non-optional enum, got: {out}"
2257 );
2258 }
2259
2260 #[test]
2266 fn per_call_enum_field_override_routes_through_get_value() {
2267 let resolver = FieldResolver::new(
2269 &HashMap::new(),
2270 &HashSet::new(),
2271 &HashSet::new(),
2272 &HashSet::new(),
2273 &HashSet::new(),
2274 );
2275 let global_enum_fields: HashSet<String> = HashSet::new();
2277 let mut per_call_enum_fields: HashSet<String> = global_enum_fields.clone();
2279 per_call_enum_fields.insert("status".to_string());
2280
2281 let assertion = Assertion {
2282 assertion_type: "equals".to_string(),
2283 field: Some("status".to_string()),
2284 value: Some(serde_json::Value::String("validating".to_string())),
2285 values: None,
2286 method: None,
2287 check: None,
2288 args: None,
2289 return_type: None,
2290 };
2291
2292 let mut out_no_merge = String::new();
2294 render_assertion(
2295 &mut out_no_merge,
2296 &assertion,
2297 "result",
2298 "",
2299 &resolver,
2300 false,
2301 false,
2302 &global_enum_fields,
2303 &HashMap::new(),
2304 false,
2305 false,
2306 );
2307 assert!(
2308 !out_no_merge.contains(".getValue()"),
2309 "global-only set must not emit .getValue() for unregistered status: {out_no_merge}"
2310 );
2311
2312 let mut out_merged = String::new();
2314 render_assertion(
2315 &mut out_merged,
2316 &assertion,
2317 "result",
2318 "",
2319 &resolver,
2320 false,
2321 false,
2322 &per_call_enum_fields,
2323 &HashMap::new(),
2324 false,
2325 false,
2326 );
2327 assert!(
2328 out_merged.contains(".getValue()"),
2329 "merged per-call set must emit .getValue() for status: {out_merged}"
2330 );
2331 }
2332
2333 #[test]
2338 fn auto_detected_enum_fields_from_type_defs_route_through_get_value() {
2339 use alef_core::ir::{CoreWrapper, FieldDef, TypeDef, TypeRef};
2340
2341 let batch_object_def = TypeDef {
2343 name: "BatchObject".to_string(),
2344 rust_path: "liter_llm::BatchObject".to_string(),
2345 original_rust_path: String::new(),
2346 fields: vec![
2347 FieldDef {
2348 name: "id".to_string(),
2349 ty: TypeRef::String,
2350 optional: false,
2351 default: None,
2352 doc: String::new(),
2353 sanitized: false,
2354 is_boxed: false,
2355 type_rust_path: None,
2356 cfg: None,
2357 typed_default: None,
2358 core_wrapper: CoreWrapper::None,
2359 vec_inner_core_wrapper: CoreWrapper::None,
2360 newtype_wrapper: None,
2361 serde_rename: None,
2362 serde_flatten: false,
2363 binding_excluded: false,
2364 binding_exclusion_reason: None,
2365 original_type: None,
2366 },
2367 FieldDef {
2368 name: "status".to_string(),
2369 ty: TypeRef::Named("BatchStatus".to_string()),
2370 optional: false,
2371 default: None,
2372 doc: String::new(),
2373 sanitized: false,
2374 is_boxed: false,
2375 type_rust_path: None,
2376 cfg: None,
2377 typed_default: None,
2378 core_wrapper: CoreWrapper::None,
2379 vec_inner_core_wrapper: CoreWrapper::None,
2380 newtype_wrapper: None,
2381 serde_rename: None,
2382 serde_flatten: false,
2383 binding_excluded: false,
2384 binding_exclusion_reason: None,
2385 original_type: None,
2386 },
2387 ],
2388 methods: vec![],
2389 is_opaque: false,
2390 is_clone: true,
2391 is_copy: false,
2392 doc: String::new(),
2393 cfg: None,
2394 is_trait: false,
2395 has_default: false,
2396 has_stripped_cfg_fields: false,
2397 is_return_type: true,
2398 serde_rename_all: None,
2399 has_serde: true,
2400 super_traits: vec![],
2401 binding_excluded: false,
2402 binding_exclusion_reason: None,
2403 };
2404
2405 let type_defs = [batch_object_def];
2407 let struct_names: HashSet<&str> = type_defs.iter().map(|td| td.name.as_str()).collect();
2408
2409 let status_ty = TypeRef::Named("BatchStatus".to_string());
2411 assert!(
2412 is_enum_typed(&status_ty, &struct_names),
2413 "BatchStatus (not a known struct) should be detected as enum-typed"
2414 );
2415 let id_ty = TypeRef::String;
2416 assert!(
2417 !is_enum_typed(&id_ty, &struct_names),
2418 "String field should NOT be detected as enum-typed"
2419 );
2420
2421 let type_enum_fields: std::collections::HashMap<String, HashSet<String>> = type_defs
2423 .iter()
2424 .filter_map(|td| {
2425 let enum_field_names: HashSet<String> = td
2426 .fields
2427 .iter()
2428 .filter(|field| is_enum_typed(&field.ty, &struct_names))
2429 .map(|field| field.name.clone())
2430 .collect();
2431 if enum_field_names.is_empty() {
2432 None
2433 } else {
2434 Some((td.name.clone(), enum_field_names))
2435 }
2436 })
2437 .collect();
2438
2439 let batch_enum_fields = type_enum_fields
2440 .get("BatchObject")
2441 .expect("BatchObject should have enum fields");
2442 assert!(
2443 batch_enum_fields.contains("status"),
2444 "BatchObject.status should be auto-detected as enum-typed, got: {batch_enum_fields:?}"
2445 );
2446 assert!(
2447 !batch_enum_fields.contains("id"),
2448 "BatchObject.id (String) must not be in enum fields"
2449 );
2450
2451 let resolver = FieldResolver::new(
2453 &HashMap::new(),
2454 &HashSet::new(),
2455 &HashSet::new(),
2456 &HashSet::new(),
2457 &HashSet::new(),
2458 );
2459 let assertion = Assertion {
2460 assertion_type: "equals".to_string(),
2461 field: Some("status".to_string()),
2462 value: Some(serde_json::Value::String("validating".to_string())),
2463 values: None,
2464 method: None,
2465 check: None,
2466 args: None,
2467 return_type: None,
2468 };
2469 let mut out = String::new();
2470 render_assertion(
2471 &mut out,
2472 &assertion,
2473 "result",
2474 "",
2475 &resolver,
2476 false,
2477 false,
2478 batch_enum_fields,
2479 &HashMap::new(),
2480 false,
2481 false,
2482 );
2483 assert!(
2484 out.contains(".getValue()"),
2485 "auto-detected enum field must route through .getValue(), got: {out}"
2486 );
2487 }
2488
2489 #[test]
2493 fn kotlin_android_streaming_fixture_emits_flow_to_list_import() {
2494 use crate::fixture::MockResponse;
2495 use alef_core::config::e2e::CallConfig;
2496
2497 let streaming_fixture = Fixture {
2499 id: "smoke_stream".to_string(),
2500 category: None,
2501 description: "streaming test".to_string(),
2502 tags: vec![],
2503 skip: None,
2504 env: None,
2505 call: None,
2506 input: serde_json::json!({}),
2507 mock_response: Some(MockResponse {
2508 status: 200,
2509 body: None,
2510 stream_chunks: Some(vec![serde_json::json!({"delta": "hi"})]),
2511 headers: HashMap::new(),
2512 }),
2513 visitor: None,
2514 assertions: vec![],
2515 source: String::new(),
2516 http: None,
2517 };
2518
2519 let e2e_config = E2eConfig {
2520 call: CallConfig::default(),
2521 ..E2eConfig::default()
2522 };
2523 let out_android = render_test_file_inner(
2525 "streaming",
2526 &[&streaming_fixture],
2527 "LlmClient",
2528 "chatStream",
2529 "dev.kreuzberg.literllm.android",
2530 "result",
2531 &[],
2532 None,
2533 false,
2534 &e2e_config,
2535 &HashMap::new(),
2536 true,
2537 );
2538 assert!(
2539 out_android.contains("import kotlinx.coroutines.flow.toList"),
2540 "kotlin_android streaming file must import flow.toList, got:\n{out_android}"
2541 );
2542
2543 let out_jvm = render_test_file_inner(
2545 "streaming",
2546 &[&streaming_fixture],
2547 "LlmClient",
2548 "chatStream",
2549 "dev.kreuzberg.literllm.android",
2550 "result",
2551 &[],
2552 None,
2553 false,
2554 &e2e_config,
2555 &HashMap::new(),
2556 false,
2557 );
2558 assert!(
2559 !out_jvm.contains("import kotlinx.coroutines.flow.toList"),
2560 "non-android streaming file must NOT import flow.toList, got:\n{out_jvm}"
2561 );
2562 }
2563
2564 #[test]
2569 fn kotlin_android_object_mapper_emits_register_kotlin_module() {
2570 use crate::fixture::{HttpExpectedResponse, HttpFixture, HttpHandler, HttpRequest};
2571 use alef_core::config::e2e::CallConfig;
2572
2573 let http_fixture = Fixture {
2575 id: "http_test".to_string(),
2576 category: None,
2577 description: "http test".to_string(),
2578 tags: vec![],
2579 skip: None,
2580 env: None,
2581 call: None,
2582 input: serde_json::json!({}),
2583 mock_response: None,
2584 visitor: None,
2585 assertions: vec![],
2586 source: String::new(),
2587 http: Some(HttpFixture {
2588 handler: HttpHandler {
2589 route: "/v1/test".to_string(),
2590 method: "POST".to_string(),
2591 body_schema: None,
2592 parameters: HashMap::new(),
2593 middleware: None,
2594 },
2595 request: HttpRequest {
2596 method: "POST".to_string(),
2597 path: "/v1/test".to_string(),
2598 headers: HashMap::new(),
2599 query_params: HashMap::new(),
2600 cookies: HashMap::new(),
2601 body: None,
2602 content_type: None,
2603 },
2604 expected_response: HttpExpectedResponse {
2605 status_code: 200,
2606 body: None,
2607 body_partial: None,
2608 headers: HashMap::new(),
2609 validation_errors: None,
2610 },
2611 }),
2612 };
2613
2614 let e2e_config = E2eConfig {
2615 call: CallConfig::default(),
2616 ..E2eConfig::default()
2617 };
2618 let out_android = render_test_file_inner(
2620 "configuration",
2621 &[&http_fixture],
2622 "",
2623 "",
2624 "dev.kreuzberg.literllm.android",
2625 "result",
2626 &[],
2627 None,
2628 false,
2629 &e2e_config,
2630 &HashMap::new(),
2631 true,
2632 );
2633 assert!(
2634 out_android.contains("import com.fasterxml.jackson.module.kotlin.registerKotlinModule"),
2635 "kotlin_android with ObjectMapper must import registerKotlinModule, got:\n{out_android}"
2636 );
2637 assert!(
2638 out_android.contains(".registerKotlinModule()"),
2639 "kotlin_android MAPPER must call .registerKotlinModule(), got:\n{out_android}"
2640 );
2641
2642 let out_jvm = render_test_file_inner(
2644 "configuration",
2645 &[&http_fixture],
2646 "",
2647 "",
2648 "dev.kreuzberg.literllm.android",
2649 "result",
2650 &[],
2651 None,
2652 false,
2653 &e2e_config,
2654 &HashMap::new(),
2655 false,
2656 );
2657 assert!(
2658 !out_jvm.contains("registerKotlinModule"),
2659 "non-android MAPPER must NOT reference registerKotlinModule, got:\n{out_jvm}"
2660 );
2661 }
2662}