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) =
1291 build_args_and_setup(fixture, &fixture.input, args, class_name, options_type, &fixture.id);
1292
1293 if let Some(factory) = client_factory {
1298 let fixture_id = &fixture.id;
1299 let mock_url_expr = format!(
1304 "System.getProperty(\"mockServer.{fixture_id}\", System.getProperty(\"mockServerUrl\", System.getenv(\"MOCK_SERVER_URL\") ?: \"\") + \"/fixtures/{fixture_id}\")"
1305 );
1306 for line in &setup_lines {
1307 let _ = writeln!(out, " {line}");
1308 }
1309 let _ = writeln!(
1310 out,
1311 " val client = {class_name}.{factory}(apiKey = \"test-key\", baseUrl = {mock_url_expr})"
1312 );
1313 if expects_error {
1314 let _ = writeln!(out, " assertFailsWith<Exception> {{");
1315 let _ = writeln!(out, " client.{function_name}({args_str})");
1316 let _ = writeln!(out, " }}");
1317 let _ = writeln!(out, " client.close()");
1318 let _ = writeln!(out, " }}");
1319 return;
1320 }
1321 let _ = writeln!(out, " val {result_var} = client.{function_name}({args_str})");
1322 if !collect_snippet.is_empty() {
1323 let _ = writeln!(out, " {collect_snippet}");
1324 }
1325 for assertion in &fixture.assertions {
1326 render_assertion(
1327 out,
1328 assertion,
1329 result_var,
1330 class_name,
1331 field_resolver,
1332 result_is_simple,
1333 result_is_option,
1334 enum_fields,
1335 e2e_config.effective_fields_c_types(call_config),
1336 is_streaming,
1337 kotlin_android_style,
1338 );
1339 }
1340 let _ = writeln!(out, " client.close()");
1341 let _ = writeln!(out, " }}");
1342 return;
1343 }
1344
1345 if expects_error {
1347 let _ = writeln!(out, " assertFailsWith<Exception> {{");
1350 for line in &setup_lines {
1351 let _ = writeln!(out, " {line}");
1352 }
1353 let _ = writeln!(out, " {class_name}.{function_name}({args_str})");
1354 let _ = writeln!(out, " }}");
1355 let _ = writeln!(out, " }}");
1356 return;
1357 }
1358
1359 for line in &setup_lines {
1360 let _ = writeln!(out, " {line}");
1361 }
1362
1363 let _ = writeln!(
1364 out,
1365 " val {result_var} = {class_name}.{function_name}({args_str})"
1366 );
1367
1368 if !collect_snippet.is_empty() {
1369 let _ = writeln!(out, " {collect_snippet}");
1370 }
1371
1372 for assertion in &fixture.assertions {
1373 render_assertion(
1374 out,
1375 assertion,
1376 result_var,
1377 class_name,
1378 field_resolver,
1379 result_is_simple,
1380 result_is_option,
1381 enum_fields,
1382 &e2e_config.fields_c_types,
1383 is_streaming,
1384 kotlin_android_style,
1385 );
1386 }
1387
1388 let _ = writeln!(out, " }}");
1389}
1390
1391fn build_args_and_setup(
1395 fixture: &Fixture,
1396 input: &serde_json::Value,
1397 args: &[crate::config::ArgMapping],
1398 class_name: &str,
1399 options_type: Option<&str>,
1400 fixture_id: &str,
1401) -> (Vec<String>, String) {
1402 if args.is_empty() {
1403 return (Vec::new(), String::new());
1404 }
1405
1406 let mut setup_lines: Vec<String> = Vec::new();
1407 let mut parts: Vec<String> = Vec::new();
1408
1409 for arg in args {
1410 if arg.arg_type == "mock_url" {
1411 if fixture.has_host_root_route() {
1412 setup_lines.push(format!(
1413 "val {} = System.getProperty(\"mockServer.{fixture_id}\", System.getProperty(\"mockServerUrl\", System.getenv(\"MOCK_SERVER_URL\")) + \"/fixtures/{fixture_id}\")",
1414 arg.name,
1415 ));
1416 } else {
1417 setup_lines.push(format!(
1418 "val {} = System.getProperty(\"mockServerUrl\", System.getenv(\"MOCK_SERVER_URL\")) + \"/fixtures/{fixture_id}\"",
1419 arg.name,
1420 ));
1421 }
1422 parts.push(arg.name.clone());
1423 continue;
1424 }
1425
1426 if arg.arg_type == "handle" {
1427 let constructor_name = format!("create{}", arg.name.to_upper_camel_case());
1428 let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
1429 let config_value = input.get(field).unwrap_or(&serde_json::Value::Null);
1430 if config_value.is_null()
1431 || config_value.is_object() && config_value.as_object().is_some_and(|o| o.is_empty())
1432 {
1433 setup_lines.push(format!("val {} = {class_name}.{constructor_name}(null)", arg.name,));
1434 } else {
1435 let json_str = serde_json::to_string(config_value).unwrap_or_default();
1436 let name = &arg.name;
1437 setup_lines.push(format!(
1438 "val {name}Config = MAPPER.readValue(\"{}\", CrawlConfig::class.java)",
1439 escape_kotlin(&json_str),
1440 ));
1441 setup_lines.push(format!(
1442 "val {} = {class_name}.{constructor_name}({name}Config)",
1443 arg.name,
1444 name = name,
1445 ));
1446 }
1447 parts.push(arg.name.clone());
1448 continue;
1449 }
1450
1451 let val_resolved = super::resolve_field(input, &arg.field);
1453 let val: Option<&serde_json::Value> = if val_resolved.is_null() {
1454 None
1455 } else {
1456 Some(val_resolved)
1457 };
1458 match val {
1459 None | Some(serde_json::Value::Null) if arg.optional => {
1460 if arg.arg_type == "json_object" {
1465 if let Some(opts_type) = options_type {
1466 parts.push(format!("{opts_type}.builder().build()"));
1467 } else {
1468 parts.push("null".to_string());
1469 }
1470 } else {
1471 parts.push("null".to_string());
1472 }
1473 }
1474 None | Some(serde_json::Value::Null) => {
1475 let default_val = match arg.arg_type.as_str() {
1476 "string" => "\"\"".to_string(),
1477 "int" | "integer" => "0".to_string(),
1478 "float" | "number" => "0.0".to_string(),
1479 "bool" | "boolean" => "false".to_string(),
1480 _ => "null".to_string(),
1481 };
1482 parts.push(default_val);
1483 }
1484 Some(v) => {
1485 if arg.arg_type == "json_object" && v.is_array() {
1490 if let Some(elem) = &arg.element_type {
1491 if elem == "BatchBytesItem" || elem == "BatchFileItem" {
1492 parts.push(emit_kotlin_batch_item_array(v, elem));
1493 continue;
1494 }
1495 let items: Vec<String> = v
1497 .as_array()
1498 .map(|arr| arr.iter().map(json_to_kotlin).collect())
1499 .unwrap_or_default();
1500 parts.push(format!("listOf({})", items.join(", ")));
1501 continue;
1502 }
1503 }
1504 if arg.arg_type == "json_object" && options_type.is_some() {
1506 parts.push(arg.name.clone());
1507 continue;
1508 }
1509 if arg.arg_type == "bytes" {
1513 let val = json_to_kotlin(v);
1514 parts.push(format!(
1515 "java.nio.file.Files.readAllBytes(java.nio.file.Path.of({val}))"
1516 ));
1517 continue;
1518 }
1519 if arg.arg_type == "file_path" {
1523 let val = json_to_kotlin(v);
1524 parts.push(format!("java.nio.file.Path.of({val})"));
1525 continue;
1526 }
1527 parts.push(json_to_kotlin(v));
1528 }
1529 }
1530 }
1531
1532 (setup_lines, parts.join(", "))
1533}
1534
1535fn emit_kotlin_batch_item_array(arr: &serde_json::Value, elem_type: &str) -> String {
1539 let Some(items) = arr.as_array() else {
1540 return "emptyList()".to_string();
1541 };
1542 let parts: Vec<String> = items
1543 .iter()
1544 .filter_map(|item| {
1545 let obj = item.as_object()?;
1546 match elem_type {
1547 "BatchBytesItem" => {
1548 let mime_type = obj.get("mime_type").and_then(|v| v.as_str()).unwrap_or("text/plain");
1549 let content_code = obj
1550 .get("content")
1551 .and_then(|v| v.as_array())
1552 .map(|arr| {
1553 let bytes: Vec<String> =
1554 arr.iter().filter_map(|v| v.as_u64().map(|n| format!("{n}"))).collect();
1555 format!("byteArrayOf({})", bytes.join(", "))
1556 })
1557 .unwrap_or_else(|| "byteArrayOf()".to_string());
1558 Some(format!("{elem_type}({content_code}, \"{mime_type}\", null)"))
1559 }
1560 "BatchFileItem" => {
1561 let path = obj.get("path").and_then(|v| v.as_str()).unwrap_or("");
1562 Some(format!("{elem_type}(java.nio.file.Paths.get(\"{path}\"), null)"))
1563 }
1564 _ => None,
1565 }
1566 })
1567 .collect();
1568 format!("listOf({})", parts.join(", "))
1569}
1570
1571#[allow(clippy::too_many_arguments)]
1572fn render_assertion(
1573 out: &mut String,
1574 assertion: &Assertion,
1575 result_var: &str,
1576 _class_name: &str,
1577 field_resolver: &FieldResolver,
1578 result_is_simple: bool,
1579 result_is_option: bool,
1580 enum_fields: &HashSet<String>,
1581 fields_c_types: &std::collections::HashMap<String, String>,
1582 is_streaming: bool,
1583 kotlin_android_style: bool,
1584) {
1585 if is_streaming {
1590 if let Some(f) = &assertion.field {
1591 if f == "usage" || f.starts_with("usage.") {
1592 let stream_lang = if kotlin_android_style {
1593 "kotlin_android"
1594 } else {
1595 "kotlin"
1596 };
1597 let base_expr = crate::codegen::streaming_assertions::StreamingFieldResolver::accessor(
1598 "usage",
1599 stream_lang,
1600 "chunks",
1601 )
1602 .unwrap_or_else(|| {
1603 if kotlin_android_style {
1604 "(if (chunks.isEmpty()) null else chunks.last().usage)".to_string()
1605 } else {
1606 "(if (chunks.isEmpty()) null else chunks.last().usage())".to_string()
1607 }
1608 });
1609
1610 let expr = if let Some(tail) = f.strip_prefix("usage.") {
1613 use heck::ToLowerCamelCase;
1614 if kotlin_android_style {
1615 tail.split('.')
1617 .fold(base_expr, |acc, seg| format!("{acc}?.{}", seg.to_lower_camel_case()))
1618 } else {
1619 tail.split('.')
1621 .fold(base_expr, |acc, seg| format!("{acc}?.{}()", seg.to_lower_camel_case()))
1622 }
1623 } else {
1624 base_expr
1625 };
1626
1627 let field_is_long = fields_c_types
1629 .get(f.as_str())
1630 .is_some_and(|t| matches!(t.as_str(), "uint64_t" | "int64_t"));
1631
1632 let line = match assertion.assertion_type.as_str() {
1633 "equals" => {
1634 if let Some(expected) = &assertion.value {
1635 let kotlin_val = if field_is_long && expected.is_number() && !expected.is_f64() {
1636 format!("{}L", expected)
1637 } else {
1638 json_to_kotlin(expected)
1639 };
1640 format!(" assertEquals({kotlin_val}, {expr}!!)\n")
1641 } else {
1642 String::new()
1643 }
1644 }
1645 _ => String::new(),
1646 };
1647 if !line.is_empty() {
1648 out.push_str(&line);
1649 }
1650 return;
1651 }
1652 }
1653 }
1654
1655 if let Some(f) = &assertion.field {
1658 if !f.is_empty() && crate::codegen::streaming_assertions::is_streaming_virtual_field(f) {
1659 let stream_lang = if kotlin_android_style {
1660 "kotlin_android"
1661 } else {
1662 "kotlin"
1663 };
1664 if let Some(expr) =
1665 crate::codegen::streaming_assertions::StreamingFieldResolver::accessor(f, stream_lang, "chunks")
1666 {
1667 let line = match assertion.assertion_type.as_str() {
1668 "count_min" => {
1669 if let Some(n) = assertion.value.as_ref().and_then(|v| v.as_u64()) {
1670 format!(" assertTrue({expr}.size >= {n}, \"expected >= {n} chunks\")\n")
1671 } else {
1672 String::new()
1673 }
1674 }
1675 "count_equals" => {
1676 if let Some(n) = assertion.value.as_ref().and_then(|v| v.as_u64()) {
1677 format!(
1678 " assertEquals({n}.toLong(), {expr}.size.toLong(), \"expected exactly {n} elements\")\n"
1679 )
1680 } else {
1681 String::new()
1682 }
1683 }
1684 "equals" => {
1685 if let Some(serde_json::Value::String(s)) = &assertion.value {
1686 let escaped = escape_kotlin(s);
1687 format!(" assertEquals(\"{escaped}\", {expr})\n")
1688 } else if let Some(b) = assertion.value.as_ref().and_then(|v| v.as_bool()) {
1689 format!(" assertEquals({b}, {expr})\n")
1690 } else {
1691 String::new()
1692 }
1693 }
1694 "not_empty" => {
1695 format!(" assertFalse({expr}.isEmpty(), \"expected non-empty\")\n")
1696 }
1697 "is_empty" => {
1698 format!(" assertTrue({expr}.isEmpty(), \"expected empty\")\n")
1699 }
1700 "is_true" => {
1701 format!(" assertTrue({expr}, \"expected true\")\n")
1702 }
1703 "is_false" => {
1704 format!(" assertFalse({expr}, \"expected false\")\n")
1705 }
1706 "greater_than" => {
1707 if let Some(n) = assertion.value.as_ref().and_then(|v| v.as_u64()) {
1708 format!(" assertTrue({expr} > {n}, \"expected > {n}\")\n")
1709 } else {
1710 String::new()
1711 }
1712 }
1713 "contains" => {
1714 if let Some(serde_json::Value::String(s)) = &assertion.value {
1715 let escaped = escape_kotlin(s);
1716 format!(
1717 " assertTrue({expr}.contains(\"{escaped}\"), \"expected to contain: {escaped}\")\n"
1718 )
1719 } else {
1720 String::new()
1721 }
1722 }
1723 _ => format!(
1724 " // streaming field '{f}': assertion type '{}' not rendered\n",
1725 assertion.assertion_type
1726 ),
1727 };
1728 if !line.is_empty() {
1729 out.push_str(&line);
1730 }
1731 }
1732 return;
1733 }
1734 }
1735
1736 if let Some(f) = &assertion.field {
1738 if !f.is_empty() && !field_resolver.is_valid_for_result(f) {
1739 let _ = writeln!(out, " // skipped: field '{f}' not available on result type");
1740 return;
1741 }
1742 }
1743
1744 let field_is_enum = assertion
1746 .field
1747 .as_deref()
1748 .is_some_and(|f| enum_fields.contains(f) || enum_fields.contains(field_resolver.resolve(f)));
1749
1750 let accessor_lang = if kotlin_android_style {
1754 "kotlin_android"
1755 } else {
1756 "kotlin"
1757 };
1758 let field_expr = if result_is_simple {
1759 result_var.to_string()
1760 } else {
1761 match &assertion.field {
1762 Some(f) if !f.is_empty() => field_resolver.accessor(f, accessor_lang, result_var),
1763 _ => result_var.to_string(),
1764 }
1765 };
1766
1767 let field_is_optional = !result_is_simple
1777 && (field_expr.contains("?.")
1778 || assertion.field.as_deref().filter(|f| !f.is_empty()).is_some_and(|f| {
1779 let resolved = field_resolver.resolve(f);
1780 if field_resolver.has_map_access(f) {
1781 return false;
1782 }
1783 if field_resolver.is_optional(resolved) {
1785 return true;
1786 }
1787 let mut prefix = String::new();
1790 for part in resolved.split('.') {
1791 let key = part.split('[').next().unwrap_or(part);
1793 if !prefix.is_empty() {
1794 prefix.push('.');
1795 }
1796 prefix.push_str(key);
1797 if field_resolver.is_optional(&prefix) {
1798 return true;
1799 }
1800 }
1801 false
1802 }));
1803
1804 let string_field_expr = if field_is_optional {
1810 format!("{field_expr}.orEmpty()")
1811 } else {
1812 field_expr.clone()
1813 };
1814
1815 let nonnull_field_expr = if field_is_optional {
1818 format!("{field_expr}!!")
1819 } else {
1820 field_expr.clone()
1821 };
1822
1823 let string_expr = if kotlin_android_style {
1835 match (field_is_enum, field_is_optional) {
1836 (true, true) => format!("{field_expr}?.name?.lowercase().orEmpty()"),
1837 (true, false) => format!("{field_expr}.name.lowercase()"),
1838 (false, _) => string_field_expr.clone(),
1839 }
1840 } else {
1841 match (field_is_enum, field_is_optional) {
1842 (true, true) => format!("{field_expr}?.getValue().orEmpty()"),
1843 (true, false) => format!("{field_expr}.getValue()"),
1844 (false, _) => string_field_expr.clone(),
1845 }
1846 };
1847
1848 let field_is_long = assertion.field.as_deref().filter(|f| !f.is_empty()).is_some_and(|f| {
1852 let resolved = field_resolver.resolve(f);
1853 matches!(
1854 fields_c_types.get(resolved).map(String::as_str),
1855 Some("uint64_t") | Some("int64_t")
1856 )
1857 });
1858
1859 match assertion.assertion_type.as_str() {
1860 "equals" => {
1861 if let Some(expected) = &assertion.value {
1862 let kotlin_val = if field_is_long && expected.is_number() && !expected.is_f64() {
1866 format!("{}L", expected)
1867 } else {
1868 json_to_kotlin(expected)
1869 };
1870 if expected.is_string() {
1871 let _ = writeln!(out, " assertEquals({kotlin_val}, {string_expr}.trim())");
1872 } else {
1873 let _ = writeln!(out, " assertEquals({kotlin_val}, {nonnull_field_expr})");
1874 }
1875 }
1876 }
1877 "contains" => {
1878 if let Some(expected) = &assertion.value {
1879 let kotlin_val = json_to_kotlin(expected);
1880 let _ = writeln!(
1881 out,
1882 " assertTrue({string_expr}.contains({kotlin_val}), \"expected to contain: \" + {kotlin_val})"
1883 );
1884 }
1885 }
1886 "contains_all" => {
1887 if let Some(values) = &assertion.values {
1888 for val in values {
1889 let kotlin_val = json_to_kotlin(val);
1890 let _ = writeln!(
1891 out,
1892 " assertTrue({string_expr}.contains({kotlin_val}), \"expected to contain: \" + {kotlin_val})"
1893 );
1894 }
1895 }
1896 }
1897 "not_contains" => {
1898 if let Some(expected) = &assertion.value {
1899 let kotlin_val = json_to_kotlin(expected);
1900 let _ = writeln!(
1901 out,
1902 " assertFalse({string_expr}.contains({kotlin_val}), \"expected NOT to contain: \" + {kotlin_val})"
1903 );
1904 }
1905 }
1906 "not_empty" => {
1907 let bare_result_is_option =
1920 result_is_option && assertion.field.as_deref().filter(|f| !f.is_empty()).is_none();
1921 if bare_result_is_option && !kotlin_android_style {
1922 let _ = writeln!(
1923 out,
1924 " assertTrue({field_expr}.isPresent, \"expected non-empty value\")"
1925 );
1926 } else if bare_result_is_option || field_is_optional {
1927 let _ = writeln!(
1928 out,
1929 " assertTrue({field_expr} != null, \"expected non-empty value\")"
1930 );
1931 } else {
1932 let _ = writeln!(
1933 out,
1934 " assertFalse({string_field_expr}.isEmpty(), \"expected non-empty value\")"
1935 );
1936 }
1937 }
1938 "is_empty" => {
1939 let bare_result_is_option =
1940 result_is_option && assertion.field.as_deref().filter(|f| !f.is_empty()).is_none();
1941 if bare_result_is_option && !kotlin_android_style {
1942 let _ = writeln!(
1943 out,
1944 " assertTrue({field_expr}.isEmpty, \"expected empty value\")"
1945 );
1946 } else if bare_result_is_option || field_is_optional {
1947 let _ = writeln!(
1948 out,
1949 " assertTrue({field_expr} == null, \"expected empty value\")"
1950 );
1951 } else {
1952 let _ = writeln!(
1953 out,
1954 " assertTrue({string_field_expr}.isEmpty(), \"expected empty value\")"
1955 );
1956 }
1957 }
1958 "contains_any" => {
1959 if let Some(values) = &assertion.values {
1960 let checks: Vec<String> = values
1961 .iter()
1962 .map(|v| {
1963 let kotlin_val = json_to_kotlin(v);
1964 format!("{string_expr}.contains({kotlin_val})")
1965 })
1966 .collect();
1967 let joined = checks.join(" || ");
1968 let _ = writeln!(
1969 out,
1970 " assertTrue({joined}, \"expected to contain at least one of the specified values\")"
1971 );
1972 }
1973 }
1974 "greater_than" => {
1975 if let Some(val) = &assertion.value {
1976 let kotlin_val = json_to_kotlin(val);
1977 let _ = writeln!(
1978 out,
1979 " assertTrue({nonnull_field_expr} > {kotlin_val}, \"expected > {kotlin_val}\")"
1980 );
1981 }
1982 }
1983 "less_than" => {
1984 if let Some(val) = &assertion.value {
1985 let kotlin_val = json_to_kotlin(val);
1986 let _ = writeln!(
1987 out,
1988 " assertTrue({nonnull_field_expr} < {kotlin_val}, \"expected < {kotlin_val}\")"
1989 );
1990 }
1991 }
1992 "greater_than_or_equal" => {
1993 if let Some(val) = &assertion.value {
1994 let kotlin_val = json_to_kotlin(val);
1995 let _ = writeln!(
1996 out,
1997 " assertTrue({nonnull_field_expr} >= {kotlin_val}, \"expected >= {kotlin_val}\")"
1998 );
1999 }
2000 }
2001 "less_than_or_equal" => {
2002 if let Some(val) = &assertion.value {
2003 let kotlin_val = json_to_kotlin(val);
2004 let _ = writeln!(
2005 out,
2006 " assertTrue({nonnull_field_expr} <= {kotlin_val}, \"expected <= {kotlin_val}\")"
2007 );
2008 }
2009 }
2010 "starts_with" => {
2011 if let Some(expected) = &assertion.value {
2012 let kotlin_val = json_to_kotlin(expected);
2013 let _ = writeln!(
2014 out,
2015 " assertTrue({string_expr}.startsWith({kotlin_val}), \"expected to start with: \" + {kotlin_val})"
2016 );
2017 }
2018 }
2019 "ends_with" => {
2020 if let Some(expected) = &assertion.value {
2021 let kotlin_val = json_to_kotlin(expected);
2022 let _ = writeln!(
2023 out,
2024 " assertTrue({string_expr}.endsWith({kotlin_val}), \"expected to end with: \" + {kotlin_val})"
2025 );
2026 }
2027 }
2028 "min_length" => {
2029 if let Some(val) = &assertion.value {
2030 if let Some(n) = val.as_u64() {
2031 let _ = writeln!(
2032 out,
2033 " assertTrue({string_field_expr}.length >= {n}, \"expected length >= {n}\")"
2034 );
2035 }
2036 }
2037 }
2038 "max_length" => {
2039 if let Some(val) = &assertion.value {
2040 if let Some(n) = val.as_u64() {
2041 let _ = writeln!(
2042 out,
2043 " assertTrue({string_field_expr}.length <= {n}, \"expected length <= {n}\")"
2044 );
2045 }
2046 }
2047 }
2048 "count_min" => {
2049 if let Some(val) = &assertion.value {
2050 if let Some(n) = val.as_u64() {
2051 let _ = writeln!(
2052 out,
2053 " assertTrue({nonnull_field_expr}.size >= {n}, \"expected at least {n} elements\")"
2054 );
2055 }
2056 }
2057 }
2058 "count_equals" => {
2059 if let Some(val) = &assertion.value {
2060 if let Some(n) = val.as_u64() {
2061 let _ = writeln!(
2062 out,
2063 " assertEquals({n}, {nonnull_field_expr}.size, \"expected exactly {n} elements\")"
2064 );
2065 }
2066 }
2067 }
2068 "is_true" => {
2069 let _ = writeln!(out, " assertTrue({field_expr}, \"expected true\")");
2070 }
2071 "is_false" => {
2072 let _ = writeln!(out, " assertFalse({field_expr}, \"expected false\")");
2073 }
2074 "matches_regex" => {
2075 if let Some(expected) = &assertion.value {
2076 let kotlin_val = json_to_kotlin(expected);
2077 let _ = writeln!(
2078 out,
2079 " assertTrue(Regex({kotlin_val}).containsMatchIn({string_expr}), \"expected value to match regex: \" + {kotlin_val})"
2080 );
2081 }
2082 }
2083 "not_error" => {
2084 }
2086 "error" => {
2087 }
2089 "method_result" => {
2090 let _ = writeln!(
2092 out,
2093 " // method_result assertions not yet implemented for Kotlin"
2094 );
2095 }
2096 other => {
2097 panic!("Kotlin e2e generator: unsupported assertion type: {other}");
2098 }
2099 }
2100}
2101
2102fn json_to_kotlin(value: &serde_json::Value) -> String {
2104 match value {
2105 serde_json::Value::String(s) => format!("\"{}\"", escape_kotlin(s)),
2106 serde_json::Value::Bool(b) => b.to_string(),
2107 serde_json::Value::Number(n) => {
2108 if n.is_f64() {
2109 let s = n.to_string();
2112 if s.contains('.') || s.contains('e') || s.contains('E') {
2113 s
2114 } else {
2115 format!("{s}.0")
2116 }
2117 } else {
2118 n.to_string()
2119 }
2120 }
2121 serde_json::Value::Null => "null".to_string(),
2122 serde_json::Value::Array(arr) => {
2123 let items: Vec<String> = arr.iter().map(json_to_kotlin).collect();
2124 format!("listOf({})", items.join(", "))
2125 }
2126 serde_json::Value::Object(_) => {
2127 let json_str = serde_json::to_string(value).unwrap_or_default();
2128 format!("\"{}\"", escape_kotlin(&json_str))
2129 }
2130 }
2131}
2132
2133#[cfg(test)]
2134mod tests {
2135 use super::*;
2136 use std::collections::HashMap;
2137
2138 fn make_resolver_for_finish_reason() -> FieldResolver {
2139 let mut optional = HashSet::new();
2143 optional.insert("choices.finish_reason".to_string());
2144 let mut arrays = HashSet::new();
2145 arrays.insert("choices".to_string());
2146 FieldResolver::new(&HashMap::new(), &optional, &HashSet::new(), &arrays, &HashSet::new())
2147 }
2148
2149 #[test]
2153 fn assertion_enum_optional_uses_safe_get_value_then_or_empty() {
2154 let resolver = make_resolver_for_finish_reason();
2155 let mut enum_fields = HashSet::new();
2156 enum_fields.insert("choices.finish_reason".to_string());
2157 let assertion = Assertion {
2158 assertion_type: "equals".to_string(),
2159 field: Some("choices.finish_reason".to_string()),
2160 value: Some(serde_json::Value::String("stop".to_string())),
2161 values: None,
2162 method: None,
2163 check: None,
2164 args: None,
2165 return_type: None,
2166 };
2167 let mut out = String::new();
2168 render_assertion(
2169 &mut out,
2170 &assertion,
2171 "result",
2172 "",
2173 &resolver,
2174 false,
2175 false,
2176 &enum_fields,
2177 &HashMap::new(),
2178 false,
2179 false,
2180 );
2181 assert!(
2182 out.contains("result.choices().first().finishReason()?.getValue().orEmpty().trim()"),
2183 "expected enum-optional safe-call pattern, got: {out}"
2184 );
2185 assert!(
2186 !out.contains(".finishReason().orEmpty().getValue()"),
2187 "must not emit .orEmpty().getValue() on a nullable enum: {out}"
2188 );
2189 }
2190
2191 #[test]
2194 fn assertion_enum_non_optional_uses_plain_get_value() {
2195 let mut arrays = HashSet::new();
2196 arrays.insert("choices".to_string());
2197 let resolver = FieldResolver::new(
2198 &HashMap::new(),
2199 &HashSet::new(),
2200 &HashSet::new(),
2201 &arrays,
2202 &HashSet::new(),
2203 );
2204 let mut enum_fields = HashSet::new();
2205 enum_fields.insert("choices.finish_reason".to_string());
2206 let assertion = Assertion {
2207 assertion_type: "equals".to_string(),
2208 field: Some("choices.finish_reason".to_string()),
2209 value: Some(serde_json::Value::String("stop".to_string())),
2210 values: None,
2211 method: None,
2212 check: None,
2213 args: None,
2214 return_type: None,
2215 };
2216 let mut out = String::new();
2217 render_assertion(
2218 &mut out,
2219 &assertion,
2220 "result",
2221 "",
2222 &resolver,
2223 false,
2224 false,
2225 &enum_fields,
2226 &HashMap::new(),
2227 false,
2228 false,
2229 );
2230 assert!(
2231 out.contains("result.choices().first().finishReason().getValue().trim()"),
2232 "expected plain .getValue() for non-optional enum, got: {out}"
2233 );
2234 }
2235
2236 #[test]
2242 fn per_call_enum_field_override_routes_through_get_value() {
2243 let resolver = FieldResolver::new(
2245 &HashMap::new(),
2246 &HashSet::new(),
2247 &HashSet::new(),
2248 &HashSet::new(),
2249 &HashSet::new(),
2250 );
2251 let global_enum_fields: HashSet<String> = HashSet::new();
2253 let mut per_call_enum_fields: HashSet<String> = global_enum_fields.clone();
2255 per_call_enum_fields.insert("status".to_string());
2256
2257 let assertion = Assertion {
2258 assertion_type: "equals".to_string(),
2259 field: Some("status".to_string()),
2260 value: Some(serde_json::Value::String("validating".to_string())),
2261 values: None,
2262 method: None,
2263 check: None,
2264 args: None,
2265 return_type: None,
2266 };
2267
2268 let mut out_no_merge = String::new();
2270 render_assertion(
2271 &mut out_no_merge,
2272 &assertion,
2273 "result",
2274 "",
2275 &resolver,
2276 false,
2277 false,
2278 &global_enum_fields,
2279 &HashMap::new(),
2280 false,
2281 false,
2282 );
2283 assert!(
2284 !out_no_merge.contains(".getValue()"),
2285 "global-only set must not emit .getValue() for unregistered status: {out_no_merge}"
2286 );
2287
2288 let mut out_merged = String::new();
2290 render_assertion(
2291 &mut out_merged,
2292 &assertion,
2293 "result",
2294 "",
2295 &resolver,
2296 false,
2297 false,
2298 &per_call_enum_fields,
2299 &HashMap::new(),
2300 false,
2301 false,
2302 );
2303 assert!(
2304 out_merged.contains(".getValue()"),
2305 "merged per-call set must emit .getValue() for status: {out_merged}"
2306 );
2307 }
2308
2309 #[test]
2314 fn auto_detected_enum_fields_from_type_defs_route_through_get_value() {
2315 use alef_core::ir::{CoreWrapper, FieldDef, TypeDef, TypeRef};
2316
2317 let batch_object_def = TypeDef {
2319 name: "BatchObject".to_string(),
2320 rust_path: "liter_llm::BatchObject".to_string(),
2321 original_rust_path: String::new(),
2322 fields: vec![
2323 FieldDef {
2324 name: "id".to_string(),
2325 ty: TypeRef::String,
2326 optional: false,
2327 default: None,
2328 doc: String::new(),
2329 sanitized: false,
2330 is_boxed: false,
2331 type_rust_path: None,
2332 cfg: None,
2333 typed_default: None,
2334 core_wrapper: CoreWrapper::None,
2335 vec_inner_core_wrapper: CoreWrapper::None,
2336 newtype_wrapper: None,
2337 serde_rename: None,
2338 serde_flatten: false,
2339 binding_excluded: false,
2340 binding_exclusion_reason: None,
2341 original_type: None,
2342 },
2343 FieldDef {
2344 name: "status".to_string(),
2345 ty: TypeRef::Named("BatchStatus".to_string()),
2346 optional: false,
2347 default: None,
2348 doc: String::new(),
2349 sanitized: false,
2350 is_boxed: false,
2351 type_rust_path: None,
2352 cfg: None,
2353 typed_default: None,
2354 core_wrapper: CoreWrapper::None,
2355 vec_inner_core_wrapper: CoreWrapper::None,
2356 newtype_wrapper: None,
2357 serde_rename: None,
2358 serde_flatten: false,
2359 binding_excluded: false,
2360 binding_exclusion_reason: None,
2361 original_type: None,
2362 },
2363 ],
2364 methods: vec![],
2365 is_opaque: false,
2366 is_clone: true,
2367 is_copy: false,
2368 doc: String::new(),
2369 cfg: None,
2370 is_trait: false,
2371 has_default: false,
2372 has_stripped_cfg_fields: false,
2373 is_return_type: true,
2374 serde_rename_all: None,
2375 has_serde: true,
2376 super_traits: vec![],
2377 binding_excluded: false,
2378 binding_exclusion_reason: None,
2379 };
2380
2381 let type_defs = [batch_object_def];
2383 let struct_names: HashSet<&str> = type_defs.iter().map(|td| td.name.as_str()).collect();
2384
2385 let status_ty = TypeRef::Named("BatchStatus".to_string());
2387 assert!(
2388 is_enum_typed(&status_ty, &struct_names),
2389 "BatchStatus (not a known struct) should be detected as enum-typed"
2390 );
2391 let id_ty = TypeRef::String;
2392 assert!(
2393 !is_enum_typed(&id_ty, &struct_names),
2394 "String field should NOT be detected as enum-typed"
2395 );
2396
2397 let type_enum_fields: std::collections::HashMap<String, HashSet<String>> = type_defs
2399 .iter()
2400 .filter_map(|td| {
2401 let enum_field_names: HashSet<String> = td
2402 .fields
2403 .iter()
2404 .filter(|field| is_enum_typed(&field.ty, &struct_names))
2405 .map(|field| field.name.clone())
2406 .collect();
2407 if enum_field_names.is_empty() {
2408 None
2409 } else {
2410 Some((td.name.clone(), enum_field_names))
2411 }
2412 })
2413 .collect();
2414
2415 let batch_enum_fields = type_enum_fields
2416 .get("BatchObject")
2417 .expect("BatchObject should have enum fields");
2418 assert!(
2419 batch_enum_fields.contains("status"),
2420 "BatchObject.status should be auto-detected as enum-typed, got: {batch_enum_fields:?}"
2421 );
2422 assert!(
2423 !batch_enum_fields.contains("id"),
2424 "BatchObject.id (String) must not be in enum fields"
2425 );
2426
2427 let resolver = FieldResolver::new(
2429 &HashMap::new(),
2430 &HashSet::new(),
2431 &HashSet::new(),
2432 &HashSet::new(),
2433 &HashSet::new(),
2434 );
2435 let assertion = Assertion {
2436 assertion_type: "equals".to_string(),
2437 field: Some("status".to_string()),
2438 value: Some(serde_json::Value::String("validating".to_string())),
2439 values: None,
2440 method: None,
2441 check: None,
2442 args: None,
2443 return_type: None,
2444 };
2445 let mut out = String::new();
2446 render_assertion(
2447 &mut out,
2448 &assertion,
2449 "result",
2450 "",
2451 &resolver,
2452 false,
2453 false,
2454 batch_enum_fields,
2455 &HashMap::new(),
2456 false,
2457 false,
2458 );
2459 assert!(
2460 out.contains(".getValue()"),
2461 "auto-detected enum field must route through .getValue(), got: {out}"
2462 );
2463 }
2464
2465 #[test]
2469 fn kotlin_android_streaming_fixture_emits_flow_to_list_import() {
2470 use crate::fixture::MockResponse;
2471 use alef_core::config::e2e::CallConfig;
2472
2473 let streaming_fixture = Fixture {
2475 id: "smoke_stream".to_string(),
2476 category: None,
2477 description: "streaming test".to_string(),
2478 tags: vec![],
2479 skip: None,
2480 env: None,
2481 call: None,
2482 input: serde_json::json!({}),
2483 mock_response: Some(MockResponse {
2484 status: 200,
2485 body: None,
2486 stream_chunks: Some(vec![serde_json::json!({"delta": "hi"})]),
2487 headers: HashMap::new(),
2488 }),
2489 visitor: None,
2490 assertions: vec![],
2491 source: String::new(),
2492 http: None,
2493 };
2494
2495 let e2e_config = E2eConfig {
2496 call: CallConfig::default(),
2497 ..E2eConfig::default()
2498 };
2499 let out_android = render_test_file_inner(
2501 "streaming",
2502 &[&streaming_fixture],
2503 "LlmClient",
2504 "chatStream",
2505 "dev.kreuzberg.literllm.android",
2506 "result",
2507 &[],
2508 None,
2509 false,
2510 &e2e_config,
2511 &HashMap::new(),
2512 true,
2513 );
2514 assert!(
2515 out_android.contains("import kotlinx.coroutines.flow.toList"),
2516 "kotlin_android streaming file must import flow.toList, got:\n{out_android}"
2517 );
2518
2519 let out_jvm = render_test_file_inner(
2521 "streaming",
2522 &[&streaming_fixture],
2523 "LlmClient",
2524 "chatStream",
2525 "dev.kreuzberg.literllm.android",
2526 "result",
2527 &[],
2528 None,
2529 false,
2530 &e2e_config,
2531 &HashMap::new(),
2532 false,
2533 );
2534 assert!(
2535 !out_jvm.contains("import kotlinx.coroutines.flow.toList"),
2536 "non-android streaming file must NOT import flow.toList, got:\n{out_jvm}"
2537 );
2538 }
2539
2540 #[test]
2545 fn kotlin_android_object_mapper_emits_register_kotlin_module() {
2546 use crate::fixture::{HttpExpectedResponse, HttpFixture, HttpHandler, HttpRequest};
2547 use alef_core::config::e2e::CallConfig;
2548
2549 let http_fixture = Fixture {
2551 id: "http_test".to_string(),
2552 category: None,
2553 description: "http test".to_string(),
2554 tags: vec![],
2555 skip: None,
2556 env: None,
2557 call: None,
2558 input: serde_json::json!({}),
2559 mock_response: None,
2560 visitor: None,
2561 assertions: vec![],
2562 source: String::new(),
2563 http: Some(HttpFixture {
2564 handler: HttpHandler {
2565 route: "/v1/test".to_string(),
2566 method: "POST".to_string(),
2567 body_schema: None,
2568 parameters: HashMap::new(),
2569 middleware: None,
2570 },
2571 request: HttpRequest {
2572 method: "POST".to_string(),
2573 path: "/v1/test".to_string(),
2574 headers: HashMap::new(),
2575 query_params: HashMap::new(),
2576 cookies: HashMap::new(),
2577 body: None,
2578 content_type: None,
2579 },
2580 expected_response: HttpExpectedResponse {
2581 status_code: 200,
2582 body: None,
2583 body_partial: None,
2584 headers: HashMap::new(),
2585 validation_errors: None,
2586 },
2587 }),
2588 };
2589
2590 let e2e_config = E2eConfig {
2591 call: CallConfig::default(),
2592 ..E2eConfig::default()
2593 };
2594 let out_android = render_test_file_inner(
2596 "configuration",
2597 &[&http_fixture],
2598 "",
2599 "",
2600 "dev.kreuzberg.literllm.android",
2601 "result",
2602 &[],
2603 None,
2604 false,
2605 &e2e_config,
2606 &HashMap::new(),
2607 true,
2608 );
2609 assert!(
2610 out_android.contains("import com.fasterxml.jackson.module.kotlin.registerKotlinModule"),
2611 "kotlin_android with ObjectMapper must import registerKotlinModule, got:\n{out_android}"
2612 );
2613 assert!(
2614 out_android.contains(".registerKotlinModule()"),
2615 "kotlin_android MAPPER must call .registerKotlinModule(), got:\n{out_android}"
2616 );
2617
2618 let out_jvm = 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 false,
2632 );
2633 assert!(
2634 !out_jvm.contains("registerKotlinModule"),
2635 "non-android MAPPER must NOT reference registerKotlinModule, got:\n{out_jvm}"
2636 );
2637 }
2638}