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