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 let mut deser_lines: Vec<String> = Vec::new();
1271 if needs_deser {
1272 for arg in args {
1273 if arg.arg_type != "json_object" {
1274 continue;
1275 }
1276 let val = super::resolve_field(&fixture.input, &arg.field);
1277 if val.is_null() {
1278 continue;
1279 }
1280 if val.is_array() && arg.element_type.is_some() {
1283 continue;
1284 }
1285 let Some(opts_type) = options_type else { continue };
1286 let normalized = super::transform_json_keys_for_language(val, "snake_case");
1287 let json_str = serde_json::to_string(&normalized).unwrap_or_default();
1288 let var_name = &arg.name;
1289 deser_lines.push(format!(
1290 "val {var_name} = MAPPER.readValue(\"{}\", {opts_type}::class.java)",
1291 escape_kotlin(&json_str)
1292 ));
1293 }
1294 }
1295 if !expects_error {
1296 for line in &deser_lines {
1297 let _ = writeln!(out, " {line}");
1298 }
1299 }
1300
1301 let (setup_lines, args_str) = build_args_and_setup(
1302 fixture,
1303 &fixture.input,
1304 args,
1305 class_name,
1306 options_type,
1307 &fixture.id,
1308 kotlin_android_style,
1309 );
1310
1311 if let Some(factory) = client_factory {
1316 let fixture_id = &fixture.id;
1317 let mock_url_expr = format!(
1322 "System.getProperty(\"mockServer.{fixture_id}\", System.getProperty(\"mockServerUrl\", System.getenv(\"MOCK_SERVER_URL\") ?: \"\") + \"/fixtures/{fixture_id}\")"
1323 );
1324 if expects_error {
1325 let call_expr = if is_streaming {
1332 let collect_suffix = if kotlin_android_style {
1333 ".toList()"
1334 } else {
1335 ".asSequence().toList()"
1336 };
1337 format!("client.{function_name}({args_str}){collect_suffix}")
1338 } else {
1339 format!("client.{function_name}({args_str})")
1340 };
1341 let _ = writeln!(out, " assertFailsWith<Exception> {{");
1342 for line in &deser_lines {
1343 let _ = writeln!(out, " {line}");
1344 }
1345 for line in &setup_lines {
1346 let _ = writeln!(out, " {line}");
1347 }
1348 let _ = writeln!(
1349 out,
1350 " val client = {class_name}.{factory}(apiKey = \"test-key\", baseUrl = {mock_url_expr})"
1351 );
1352 let _ = writeln!(out, " {call_expr}");
1353 let _ = writeln!(out, " client.close()");
1354 let _ = writeln!(out, " }}");
1355 let _ = writeln!(out, " Unit");
1361 let _ = writeln!(out, " }}");
1362 return;
1363 }
1364 for line in &setup_lines {
1365 let _ = writeln!(out, " {line}");
1366 }
1367 let _ = writeln!(
1368 out,
1369 " val client = {class_name}.{factory}(apiKey = \"test-key\", baseUrl = {mock_url_expr})"
1370 );
1371 let _ = writeln!(out, " val {result_var} = client.{function_name}({args_str})");
1372 if !collect_snippet.is_empty() {
1373 let _ = writeln!(out, " {collect_snippet}");
1374 }
1375 for assertion in &fixture.assertions {
1376 render_assertion(
1377 out,
1378 assertion,
1379 result_var,
1380 class_name,
1381 field_resolver,
1382 result_is_simple,
1383 result_is_option,
1384 enum_fields,
1385 e2e_config.effective_fields_c_types(call_config),
1386 is_streaming,
1387 kotlin_android_style,
1388 );
1389 }
1390 let _ = writeln!(out, " client.close()");
1391 let _ = writeln!(out, " }}");
1392 return;
1393 }
1394
1395 if expects_error {
1397 let _ = writeln!(out, " assertFailsWith<Exception> {{");
1400 for line in &deser_lines {
1401 let _ = writeln!(out, " {line}");
1402 }
1403 for line in &setup_lines {
1404 let _ = writeln!(out, " {line}");
1405 }
1406 let _ = writeln!(out, " {class_name}.{function_name}({args_str})");
1407 let _ = writeln!(out, " }}");
1408 let _ = writeln!(out, " Unit");
1410 let _ = writeln!(out, " }}");
1411 return;
1412 }
1413
1414 for line in &setup_lines {
1415 let _ = writeln!(out, " {line}");
1416 }
1417
1418 let _ = writeln!(
1419 out,
1420 " val {result_var} = {class_name}.{function_name}({args_str})"
1421 );
1422
1423 if !collect_snippet.is_empty() {
1424 let _ = writeln!(out, " {collect_snippet}");
1425 }
1426
1427 for assertion in &fixture.assertions {
1428 render_assertion(
1429 out,
1430 assertion,
1431 result_var,
1432 class_name,
1433 field_resolver,
1434 result_is_simple,
1435 result_is_option,
1436 enum_fields,
1437 &e2e_config.fields_c_types,
1438 is_streaming,
1439 kotlin_android_style,
1440 );
1441 }
1442
1443 let _ = writeln!(out, " }}");
1444}
1445
1446fn build_args_and_setup(
1459 fixture: &Fixture,
1460 input: &serde_json::Value,
1461 args: &[crate::config::ArgMapping],
1462 class_name: &str,
1463 options_type: Option<&str>,
1464 fixture_id: &str,
1465 kotlin_android_style: bool,
1466) -> (Vec<String>, String) {
1467 if args.is_empty() {
1468 return (Vec::new(), String::new());
1469 }
1470
1471 let mut setup_lines: Vec<String> = Vec::new();
1472 let mut parts: Vec<String> = Vec::new();
1473
1474 for arg in args {
1475 if arg.arg_type == "mock_url" {
1476 if fixture.has_host_root_route() {
1477 setup_lines.push(format!(
1478 "val {} = System.getProperty(\"mockServer.{fixture_id}\", System.getProperty(\"mockServerUrl\", System.getenv(\"MOCK_SERVER_URL\")) + \"/fixtures/{fixture_id}\")",
1479 arg.name,
1480 ));
1481 } else {
1482 setup_lines.push(format!(
1483 "val {} = System.getProperty(\"mockServerUrl\", System.getenv(\"MOCK_SERVER_URL\")) + \"/fixtures/{fixture_id}\"",
1484 arg.name,
1485 ));
1486 }
1487 parts.push(arg.name.clone());
1488 continue;
1489 }
1490
1491 if arg.arg_type == "handle" {
1492 let constructor_name = format!("create{}", arg.name.to_upper_camel_case());
1493 let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
1494 let config_value = input.get(field).unwrap_or(&serde_json::Value::Null);
1495 if config_value.is_null()
1496 || config_value.is_object() && config_value.as_object().is_some_and(|o| o.is_empty())
1497 {
1498 setup_lines.push(format!("val {} = {class_name}.{constructor_name}(null)", arg.name,));
1499 } else {
1500 let json_str = serde_json::to_string(config_value).unwrap_or_default();
1501 let name = &arg.name;
1502 setup_lines.push(format!(
1503 "val {name}Config = MAPPER.readValue(\"{}\", CrawlConfig::class.java)",
1504 escape_kotlin(&json_str),
1505 ));
1506 setup_lines.push(format!(
1507 "val {} = {class_name}.{constructor_name}({name}Config)",
1508 arg.name,
1509 name = name,
1510 ));
1511 }
1512 parts.push(arg.name.clone());
1513 continue;
1514 }
1515
1516 let val_resolved = super::resolve_field(input, &arg.field);
1518 let val: Option<&serde_json::Value> = if val_resolved.is_null() {
1519 None
1520 } else {
1521 Some(val_resolved)
1522 };
1523 match val {
1524 None | Some(serde_json::Value::Null) if arg.optional => {
1525 if arg.arg_type == "json_object" {
1533 if let Some(opts_type) = options_type {
1534 if kotlin_android_style {
1535 parts.push("null".to_string());
1536 } else {
1537 parts.push(format!("{opts_type}.builder().build()"));
1538 }
1539 } else {
1540 parts.push("null".to_string());
1541 }
1542 } else {
1543 parts.push("null".to_string());
1544 }
1545 }
1546 None | Some(serde_json::Value::Null) => {
1547 let default_val = match arg.arg_type.as_str() {
1548 "string" => "\"\"".to_string(),
1549 "int" | "integer" => "0".to_string(),
1550 "float" | "number" => "0.0".to_string(),
1551 "bool" | "boolean" => "false".to_string(),
1552 _ => "null".to_string(),
1553 };
1554 parts.push(default_val);
1555 }
1556 Some(v) => {
1557 if arg.arg_type == "json_object" && v.is_array() {
1562 if let Some(elem) = &arg.element_type {
1563 if elem == "BatchBytesItem" || elem == "BatchFileItem" {
1564 parts.push(emit_kotlin_batch_item_array(v, elem));
1565 continue;
1566 }
1567 let items: Vec<String> = v
1569 .as_array()
1570 .map(|arr| arr.iter().map(json_to_kotlin).collect())
1571 .unwrap_or_default();
1572 parts.push(format!("listOf({})", items.join(", ")));
1573 continue;
1574 }
1575 }
1576 if arg.arg_type == "json_object" && options_type.is_some() {
1578 parts.push(arg.name.clone());
1579 continue;
1580 }
1581 if arg.arg_type == "bytes" {
1584 let val = json_to_kotlin(v);
1585 parts.push(val);
1586 continue;
1587 }
1588 if arg.arg_type == "file_path" {
1592 let val = json_to_kotlin(v);
1593 parts.push(format!("java.nio.file.Path.of({val})"));
1594 continue;
1595 }
1596 parts.push(json_to_kotlin(v));
1597 }
1598 }
1599 }
1600
1601 (setup_lines, parts.join(", "))
1602}
1603
1604fn emit_kotlin_batch_item_array(arr: &serde_json::Value, elem_type: &str) -> String {
1608 let Some(items) = arr.as_array() else {
1609 return "emptyList()".to_string();
1610 };
1611 let parts: Vec<String> = items
1612 .iter()
1613 .filter_map(|item| {
1614 let obj = item.as_object()?;
1615 match elem_type {
1616 "BatchBytesItem" => {
1617 let mime_type = obj.get("mime_type").and_then(|v| v.as_str()).unwrap_or("text/plain");
1618 let content_code = obj
1619 .get("content")
1620 .and_then(|v| v.as_array())
1621 .map(|arr| {
1622 let bytes: Vec<String> =
1623 arr.iter().filter_map(|v| v.as_u64().map(|n| format!("{n}"))).collect();
1624 format!("byteArrayOf({})", bytes.join(", "))
1625 })
1626 .unwrap_or_else(|| "byteArrayOf()".to_string());
1627 Some(format!("{elem_type}({content_code}, \"{mime_type}\", null)"))
1628 }
1629 "BatchFileItem" => {
1630 let path = obj.get("path").and_then(|v| v.as_str()).unwrap_or("");
1631 Some(format!("{elem_type}(java.nio.file.Paths.get(\"{path}\"), null)"))
1632 }
1633 _ => None,
1634 }
1635 })
1636 .collect();
1637 format!("listOf({})", parts.join(", "))
1638}
1639
1640#[allow(clippy::too_many_arguments)]
1641fn render_assertion(
1642 out: &mut String,
1643 assertion: &Assertion,
1644 result_var: &str,
1645 _class_name: &str,
1646 field_resolver: &FieldResolver,
1647 result_is_simple: bool,
1648 result_is_option: bool,
1649 enum_fields: &HashSet<String>,
1650 fields_c_types: &std::collections::HashMap<String, String>,
1651 is_streaming: bool,
1652 kotlin_android_style: bool,
1653) {
1654 if is_streaming {
1659 if let Some(f) = &assertion.field {
1660 if f == "usage" || f.starts_with("usage.") {
1661 let stream_lang = if kotlin_android_style {
1662 "kotlin_android"
1663 } else {
1664 "kotlin"
1665 };
1666 let base_expr = crate::codegen::streaming_assertions::StreamingFieldResolver::accessor(
1667 "usage",
1668 stream_lang,
1669 "chunks",
1670 )
1671 .unwrap_or_else(|| {
1672 if kotlin_android_style {
1673 "(if (chunks.isEmpty()) null else chunks.last().usage)".to_string()
1674 } else {
1675 "(if (chunks.isEmpty()) null else chunks.last().usage())".to_string()
1676 }
1677 });
1678
1679 let expr = if let Some(tail) = f.strip_prefix("usage.") {
1682 use heck::ToLowerCamelCase;
1683 if kotlin_android_style {
1684 tail.split('.')
1686 .fold(base_expr, |acc, seg| format!("{acc}?.{}", seg.to_lower_camel_case()))
1687 } else {
1688 tail.split('.')
1690 .fold(base_expr, |acc, seg| format!("{acc}?.{}()", seg.to_lower_camel_case()))
1691 }
1692 } else {
1693 base_expr
1694 };
1695
1696 let field_is_long = fields_c_types
1698 .get(f.as_str())
1699 .is_some_and(|t| matches!(t.as_str(), "uint64_t" | "int64_t"));
1700
1701 let line = match assertion.assertion_type.as_str() {
1702 "equals" => {
1703 if let Some(expected) = &assertion.value {
1704 let kotlin_val = if field_is_long && expected.is_number() && !expected.is_f64() {
1705 format!("{}L", expected)
1706 } else {
1707 json_to_kotlin(expected)
1708 };
1709 format!(" assertEquals({kotlin_val}, {expr}!!)\n")
1710 } else {
1711 String::new()
1712 }
1713 }
1714 _ => String::new(),
1715 };
1716 if !line.is_empty() {
1717 out.push_str(&line);
1718 }
1719 return;
1720 }
1721 }
1722 }
1723
1724 if let Some(f) = &assertion.field {
1727 if !f.is_empty() && crate::codegen::streaming_assertions::is_streaming_virtual_field(f) {
1728 let stream_lang = if kotlin_android_style {
1729 "kotlin_android"
1730 } else {
1731 "kotlin"
1732 };
1733 if let Some(expr) =
1734 crate::codegen::streaming_assertions::StreamingFieldResolver::accessor(f, stream_lang, "chunks")
1735 {
1736 let line = match assertion.assertion_type.as_str() {
1737 "count_min" => {
1738 if let Some(n) = assertion.value.as_ref().and_then(|v| v.as_u64()) {
1739 format!(" assertTrue({expr}.size >= {n}, \"expected >= {n} chunks\")\n")
1740 } else {
1741 String::new()
1742 }
1743 }
1744 "count_equals" => {
1745 if let Some(n) = assertion.value.as_ref().and_then(|v| v.as_u64()) {
1746 format!(
1747 " assertEquals({n}.toLong(), {expr}.size.toLong(), \"expected exactly {n} elements\")\n"
1748 )
1749 } else {
1750 String::new()
1751 }
1752 }
1753 "equals" => {
1754 if let Some(serde_json::Value::String(s)) = &assertion.value {
1755 let escaped = escape_kotlin(s);
1756 format!(" assertEquals(\"{escaped}\", {expr})\n")
1757 } else if let Some(b) = assertion.value.as_ref().and_then(|v| v.as_bool()) {
1758 format!(" assertEquals({b}, {expr})\n")
1759 } else {
1760 String::new()
1761 }
1762 }
1763 "not_empty" => {
1764 format!(" assertFalse({expr}.isEmpty(), \"expected non-empty\")\n")
1765 }
1766 "is_empty" => {
1767 format!(" assertTrue({expr}.isEmpty(), \"expected empty\")\n")
1768 }
1769 "is_true" => {
1770 format!(" assertTrue({expr}, \"expected true\")\n")
1771 }
1772 "is_false" => {
1773 format!(" assertFalse({expr}, \"expected false\")\n")
1774 }
1775 "greater_than" => {
1776 if let Some(n) = assertion.value.as_ref().and_then(|v| v.as_u64()) {
1777 format!(" assertTrue({expr} > {n}, \"expected > {n}\")\n")
1778 } else {
1779 String::new()
1780 }
1781 }
1782 "contains" => {
1783 if let Some(serde_json::Value::String(s)) = &assertion.value {
1784 let escaped = escape_kotlin(s);
1785 format!(
1786 " assertTrue({expr}.contains(\"{escaped}\"), \"expected to contain: {escaped}\")\n"
1787 )
1788 } else {
1789 String::new()
1790 }
1791 }
1792 _ => format!(
1793 " // streaming field '{f}': assertion type '{}' not rendered\n",
1794 assertion.assertion_type
1795 ),
1796 };
1797 if !line.is_empty() {
1798 out.push_str(&line);
1799 }
1800 }
1801 return;
1802 }
1803 }
1804
1805 if let Some(f) = &assertion.field {
1807 if !f.is_empty() && !field_resolver.is_valid_for_result(f) {
1808 let _ = writeln!(out, " // skipped: field '{f}' not available on result type");
1809 return;
1810 }
1811 }
1812
1813 let field_is_enum = assertion
1815 .field
1816 .as_deref()
1817 .is_some_and(|f| enum_fields.contains(f) || enum_fields.contains(field_resolver.resolve(f)));
1818
1819 let accessor_lang = if kotlin_android_style {
1823 "kotlin_android"
1824 } else {
1825 "kotlin"
1826 };
1827 let field_expr = if result_is_simple {
1828 result_var.to_string()
1829 } else {
1830 match &assertion.field {
1831 Some(f) if !f.is_empty() => field_resolver.accessor(f, accessor_lang, result_var),
1832 _ => result_var.to_string(),
1833 }
1834 };
1835
1836 let field_is_optional = !result_is_simple
1846 && (field_expr.contains("?.")
1847 || assertion.field.as_deref().filter(|f| !f.is_empty()).is_some_and(|f| {
1848 let resolved = field_resolver.resolve(f);
1849 if field_resolver.has_map_access(f) {
1850 return kotlin_android_style;
1859 }
1860 if field_resolver.is_optional(resolved) {
1862 return true;
1863 }
1864 let mut prefix = String::new();
1867 for part in resolved.split('.') {
1868 let key = part.split('[').next().unwrap_or(part);
1870 if !prefix.is_empty() {
1871 prefix.push('.');
1872 }
1873 prefix.push_str(key);
1874 if field_resolver.is_optional(&prefix) {
1875 return true;
1876 }
1877 }
1878 false
1879 }));
1880
1881 let string_field_expr = if field_is_optional {
1887 format!("{field_expr}.orEmpty()")
1888 } else {
1889 field_expr.clone()
1890 };
1891
1892 let nonnull_field_expr = if field_is_optional {
1895 format!("{field_expr}!!")
1896 } else {
1897 field_expr.clone()
1898 };
1899
1900 let string_expr = if kotlin_android_style {
1912 match (field_is_enum, field_is_optional) {
1913 (true, true) => format!("{field_expr}?.name?.lowercase().orEmpty()"),
1914 (true, false) => format!("{field_expr}.name.lowercase()"),
1915 (false, _) => string_field_expr.clone(),
1916 }
1917 } else {
1918 match (field_is_enum, field_is_optional) {
1919 (true, true) => format!("{field_expr}?.getValue().orEmpty()"),
1920 (true, false) => format!("{field_expr}.getValue()"),
1921 (false, _) => string_field_expr.clone(),
1922 }
1923 };
1924
1925 let field_is_long = assertion.field.as_deref().filter(|f| !f.is_empty()).is_some_and(|f| {
1929 let resolved = field_resolver.resolve(f);
1930 matches!(
1931 fields_c_types.get(resolved).map(String::as_str),
1932 Some("uint64_t") | Some("int64_t")
1933 )
1934 });
1935
1936 match assertion.assertion_type.as_str() {
1937 "equals" => {
1938 if let Some(expected) = &assertion.value {
1939 let kotlin_val = if field_is_long && expected.is_number() && !expected.is_f64() {
1943 format!("{}L", expected)
1944 } else {
1945 json_to_kotlin(expected)
1946 };
1947 if expected.is_string() {
1948 let _ = writeln!(out, " assertEquals({kotlin_val}, {string_expr}.trim())");
1949 } else {
1950 let _ = writeln!(out, " assertEquals({kotlin_val}, {nonnull_field_expr})");
1951 }
1952 }
1953 }
1954 "contains" => {
1955 if let Some(expected) = &assertion.value {
1956 let kotlin_val = json_to_kotlin(expected);
1957 let _ = writeln!(
1958 out,
1959 " assertTrue({string_expr}.contains({kotlin_val}), \"expected to contain: \" + {kotlin_val})"
1960 );
1961 }
1962 }
1963 "contains_all" => {
1964 if let Some(values) = &assertion.values {
1965 for val in values {
1966 let kotlin_val = json_to_kotlin(val);
1967 let _ = writeln!(
1968 out,
1969 " assertTrue({string_expr}.contains({kotlin_val}), \"expected to contain: \" + {kotlin_val})"
1970 );
1971 }
1972 }
1973 }
1974 "not_contains" => {
1975 if let Some(expected) = &assertion.value {
1976 let kotlin_val = json_to_kotlin(expected);
1977 let _ = writeln!(
1978 out,
1979 " assertFalse({string_expr}.contains({kotlin_val}), \"expected NOT to contain: \" + {kotlin_val})"
1980 );
1981 }
1982 }
1983 "not_empty" => {
1984 let bare_result_is_option =
1997 result_is_option && assertion.field.as_deref().filter(|f| !f.is_empty()).is_none();
1998 if bare_result_is_option && !kotlin_android_style {
1999 let _ = writeln!(
2000 out,
2001 " assertTrue({field_expr}.isPresent, \"expected non-empty value\")"
2002 );
2003 } else if bare_result_is_option || field_is_optional {
2004 let _ = writeln!(
2005 out,
2006 " assertTrue({field_expr} != null, \"expected non-empty value\")"
2007 );
2008 } else {
2009 let _ = writeln!(
2010 out,
2011 " assertFalse({string_field_expr}.isEmpty(), \"expected non-empty value\")"
2012 );
2013 }
2014 }
2015 "is_empty" => {
2016 let bare_result_is_option =
2017 result_is_option && assertion.field.as_deref().filter(|f| !f.is_empty()).is_none();
2018 if bare_result_is_option && !kotlin_android_style {
2019 let _ = writeln!(
2020 out,
2021 " assertTrue({field_expr}.isEmpty, \"expected empty value\")"
2022 );
2023 } else if bare_result_is_option || field_is_optional {
2024 let _ = writeln!(
2025 out,
2026 " assertTrue({field_expr} == null, \"expected empty value\")"
2027 );
2028 } else {
2029 let _ = writeln!(
2030 out,
2031 " assertTrue({string_field_expr}.isEmpty(), \"expected empty value\")"
2032 );
2033 }
2034 }
2035 "contains_any" => {
2036 if let Some(values) = &assertion.values {
2037 let checks: Vec<String> = values
2038 .iter()
2039 .map(|v| {
2040 let kotlin_val = json_to_kotlin(v);
2041 format!("{string_expr}.contains({kotlin_val})")
2042 })
2043 .collect();
2044 let joined = checks.join(" || ");
2045 let _ = writeln!(
2046 out,
2047 " assertTrue({joined}, \"expected to contain at least one of the specified values\")"
2048 );
2049 }
2050 }
2051 "greater_than" => {
2052 if let Some(val) = &assertion.value {
2053 let kotlin_val = json_to_kotlin(val);
2054 let _ = writeln!(
2055 out,
2056 " assertTrue({nonnull_field_expr} > {kotlin_val}, \"expected > {kotlin_val}\")"
2057 );
2058 }
2059 }
2060 "less_than" => {
2061 if let Some(val) = &assertion.value {
2062 let kotlin_val = json_to_kotlin(val);
2063 let _ = writeln!(
2064 out,
2065 " assertTrue({nonnull_field_expr} < {kotlin_val}, \"expected < {kotlin_val}\")"
2066 );
2067 }
2068 }
2069 "greater_than_or_equal" => {
2070 if let Some(val) = &assertion.value {
2071 let kotlin_val = json_to_kotlin(val);
2072 let _ = writeln!(
2073 out,
2074 " assertTrue({nonnull_field_expr} >= {kotlin_val}, \"expected >= {kotlin_val}\")"
2075 );
2076 }
2077 }
2078 "less_than_or_equal" => {
2079 if let Some(val) = &assertion.value {
2080 let kotlin_val = json_to_kotlin(val);
2081 let _ = writeln!(
2082 out,
2083 " assertTrue({nonnull_field_expr} <= {kotlin_val}, \"expected <= {kotlin_val}\")"
2084 );
2085 }
2086 }
2087 "starts_with" => {
2088 if let Some(expected) = &assertion.value {
2089 let kotlin_val = json_to_kotlin(expected);
2090 let _ = writeln!(
2091 out,
2092 " assertTrue({string_expr}.startsWith({kotlin_val}), \"expected to start with: \" + {kotlin_val})"
2093 );
2094 }
2095 }
2096 "ends_with" => {
2097 if let Some(expected) = &assertion.value {
2098 let kotlin_val = json_to_kotlin(expected);
2099 let _ = writeln!(
2100 out,
2101 " assertTrue({string_expr}.endsWith({kotlin_val}), \"expected to end with: \" + {kotlin_val})"
2102 );
2103 }
2104 }
2105 "min_length" => {
2106 if let Some(val) = &assertion.value {
2107 if let Some(n) = val.as_u64() {
2108 let _ = writeln!(
2109 out,
2110 " assertTrue({string_field_expr}.length >= {n}, \"expected length >= {n}\")"
2111 );
2112 }
2113 }
2114 }
2115 "max_length" => {
2116 if let Some(val) = &assertion.value {
2117 if let Some(n) = val.as_u64() {
2118 let _ = writeln!(
2119 out,
2120 " assertTrue({string_field_expr}.length <= {n}, \"expected length <= {n}\")"
2121 );
2122 }
2123 }
2124 }
2125 "count_min" => {
2126 if let Some(val) = &assertion.value {
2127 if let Some(n) = val.as_u64() {
2128 let _ = writeln!(
2129 out,
2130 " assertTrue({nonnull_field_expr}.size >= {n}, \"expected at least {n} elements\")"
2131 );
2132 }
2133 }
2134 }
2135 "count_equals" => {
2136 if let Some(val) = &assertion.value {
2137 if let Some(n) = val.as_u64() {
2138 let _ = writeln!(
2139 out,
2140 " assertEquals({n}, {nonnull_field_expr}.size, \"expected exactly {n} elements\")"
2141 );
2142 }
2143 }
2144 }
2145 "is_true" => {
2146 let _ = writeln!(out, " assertTrue({field_expr}, \"expected true\")");
2147 }
2148 "is_false" => {
2149 let _ = writeln!(out, " assertFalse({field_expr}, \"expected false\")");
2150 }
2151 "matches_regex" => {
2152 if let Some(expected) = &assertion.value {
2153 let kotlin_val = json_to_kotlin(expected);
2154 let _ = writeln!(
2155 out,
2156 " assertTrue(Regex({kotlin_val}).containsMatchIn({string_expr}), \"expected value to match regex: \" + {kotlin_val})"
2157 );
2158 }
2159 }
2160 "not_error" => {
2161 }
2163 "error" => {
2164 }
2166 "method_result" => {
2167 let _ = writeln!(
2169 out,
2170 " // method_result assertions not yet implemented for Kotlin"
2171 );
2172 }
2173 other => {
2174 panic!("Kotlin e2e generator: unsupported assertion type: {other}");
2175 }
2176 }
2177}
2178
2179fn json_to_kotlin(value: &serde_json::Value) -> String {
2181 match value {
2182 serde_json::Value::String(s) => format!("\"{}\"", escape_kotlin(s)),
2183 serde_json::Value::Bool(b) => b.to_string(),
2184 serde_json::Value::Number(n) => {
2185 if n.is_f64() {
2186 let s = n.to_string();
2189 if s.contains('.') || s.contains('e') || s.contains('E') {
2190 s
2191 } else {
2192 format!("{s}.0")
2193 }
2194 } else {
2195 n.to_string()
2196 }
2197 }
2198 serde_json::Value::Null => "null".to_string(),
2199 serde_json::Value::Array(arr) => {
2200 let items: Vec<String> = arr.iter().map(json_to_kotlin).collect();
2201 format!("listOf({})", items.join(", "))
2202 }
2203 serde_json::Value::Object(_) => {
2204 let json_str = serde_json::to_string(value).unwrap_or_default();
2205 format!("\"{}\"", escape_kotlin(&json_str))
2206 }
2207 }
2208}
2209
2210#[cfg(test)]
2211mod tests {
2212 use super::*;
2213 use std::collections::HashMap;
2214
2215 fn make_resolver_for_finish_reason() -> FieldResolver {
2216 let mut optional = HashSet::new();
2220 optional.insert("choices.finish_reason".to_string());
2221 let mut arrays = HashSet::new();
2222 arrays.insert("choices".to_string());
2223 FieldResolver::new(&HashMap::new(), &optional, &HashSet::new(), &arrays, &HashSet::new())
2224 }
2225
2226 #[test]
2230 fn assertion_enum_optional_uses_safe_get_value_then_or_empty() {
2231 let resolver = make_resolver_for_finish_reason();
2232 let mut enum_fields = HashSet::new();
2233 enum_fields.insert("choices.finish_reason".to_string());
2234 let assertion = Assertion {
2235 assertion_type: "equals".to_string(),
2236 field: Some("choices.finish_reason".to_string()),
2237 value: Some(serde_json::Value::String("stop".to_string())),
2238 values: None,
2239 method: None,
2240 check: None,
2241 args: None,
2242 return_type: None,
2243 };
2244 let mut out = String::new();
2245 render_assertion(
2246 &mut out,
2247 &assertion,
2248 "result",
2249 "",
2250 &resolver,
2251 false,
2252 false,
2253 &enum_fields,
2254 &HashMap::new(),
2255 false,
2256 false,
2257 );
2258 assert!(
2259 out.contains("result.choices().first().finishReason()?.getValue().orEmpty().trim()"),
2260 "expected enum-optional safe-call pattern, got: {out}"
2261 );
2262 assert!(
2263 !out.contains(".finishReason().orEmpty().getValue()"),
2264 "must not emit .orEmpty().getValue() on a nullable enum: {out}"
2265 );
2266 }
2267
2268 #[test]
2271 fn assertion_enum_non_optional_uses_plain_get_value() {
2272 let mut arrays = HashSet::new();
2273 arrays.insert("choices".to_string());
2274 let resolver = FieldResolver::new(
2275 &HashMap::new(),
2276 &HashSet::new(),
2277 &HashSet::new(),
2278 &arrays,
2279 &HashSet::new(),
2280 );
2281 let mut enum_fields = HashSet::new();
2282 enum_fields.insert("choices.finish_reason".to_string());
2283 let assertion = Assertion {
2284 assertion_type: "equals".to_string(),
2285 field: Some("choices.finish_reason".to_string()),
2286 value: Some(serde_json::Value::String("stop".to_string())),
2287 values: None,
2288 method: None,
2289 check: None,
2290 args: None,
2291 return_type: None,
2292 };
2293 let mut out = String::new();
2294 render_assertion(
2295 &mut out,
2296 &assertion,
2297 "result",
2298 "",
2299 &resolver,
2300 false,
2301 false,
2302 &enum_fields,
2303 &HashMap::new(),
2304 false,
2305 false,
2306 );
2307 assert!(
2308 out.contains("result.choices().first().finishReason().getValue().trim()"),
2309 "expected plain .getValue() for non-optional enum, got: {out}"
2310 );
2311 }
2312
2313 #[test]
2319 fn per_call_enum_field_override_routes_through_get_value() {
2320 let resolver = FieldResolver::new(
2322 &HashMap::new(),
2323 &HashSet::new(),
2324 &HashSet::new(),
2325 &HashSet::new(),
2326 &HashSet::new(),
2327 );
2328 let global_enum_fields: HashSet<String> = HashSet::new();
2330 let mut per_call_enum_fields: HashSet<String> = global_enum_fields.clone();
2332 per_call_enum_fields.insert("status".to_string());
2333
2334 let assertion = Assertion {
2335 assertion_type: "equals".to_string(),
2336 field: Some("status".to_string()),
2337 value: Some(serde_json::Value::String("validating".to_string())),
2338 values: None,
2339 method: None,
2340 check: None,
2341 args: None,
2342 return_type: None,
2343 };
2344
2345 let mut out_no_merge = String::new();
2347 render_assertion(
2348 &mut out_no_merge,
2349 &assertion,
2350 "result",
2351 "",
2352 &resolver,
2353 false,
2354 false,
2355 &global_enum_fields,
2356 &HashMap::new(),
2357 false,
2358 false,
2359 );
2360 assert!(
2361 !out_no_merge.contains(".getValue()"),
2362 "global-only set must not emit .getValue() for unregistered status: {out_no_merge}"
2363 );
2364
2365 let mut out_merged = String::new();
2367 render_assertion(
2368 &mut out_merged,
2369 &assertion,
2370 "result",
2371 "",
2372 &resolver,
2373 false,
2374 false,
2375 &per_call_enum_fields,
2376 &HashMap::new(),
2377 false,
2378 false,
2379 );
2380 assert!(
2381 out_merged.contains(".getValue()"),
2382 "merged per-call set must emit .getValue() for status: {out_merged}"
2383 );
2384 }
2385
2386 #[test]
2391 fn auto_detected_enum_fields_from_type_defs_route_through_get_value() {
2392 use alef_core::ir::{CoreWrapper, FieldDef, TypeDef, TypeRef};
2393
2394 let batch_object_def = TypeDef {
2396 name: "BatchObject".to_string(),
2397 rust_path: "liter_llm::BatchObject".to_string(),
2398 original_rust_path: String::new(),
2399 fields: vec![
2400 FieldDef {
2401 name: "id".to_string(),
2402 ty: TypeRef::String,
2403 optional: false,
2404 default: None,
2405 doc: String::new(),
2406 sanitized: false,
2407 is_boxed: false,
2408 type_rust_path: None,
2409 cfg: None,
2410 typed_default: None,
2411 core_wrapper: CoreWrapper::None,
2412 vec_inner_core_wrapper: CoreWrapper::None,
2413 newtype_wrapper: None,
2414 serde_rename: None,
2415 serde_flatten: false,
2416 binding_excluded: false,
2417 binding_exclusion_reason: None,
2418 original_type: None,
2419 },
2420 FieldDef {
2421 name: "status".to_string(),
2422 ty: TypeRef::Named("BatchStatus".to_string()),
2423 optional: false,
2424 default: None,
2425 doc: String::new(),
2426 sanitized: false,
2427 is_boxed: false,
2428 type_rust_path: None,
2429 cfg: None,
2430 typed_default: None,
2431 core_wrapper: CoreWrapper::None,
2432 vec_inner_core_wrapper: CoreWrapper::None,
2433 newtype_wrapper: None,
2434 serde_rename: None,
2435 serde_flatten: false,
2436 binding_excluded: false,
2437 binding_exclusion_reason: None,
2438 original_type: None,
2439 },
2440 ],
2441 methods: vec![],
2442 is_opaque: false,
2443 is_clone: true,
2444 is_copy: false,
2445 doc: String::new(),
2446 cfg: None,
2447 is_trait: false,
2448 has_default: false,
2449 has_stripped_cfg_fields: false,
2450 is_return_type: true,
2451 serde_rename_all: None,
2452 has_serde: true,
2453 super_traits: vec![],
2454 binding_excluded: false,
2455 binding_exclusion_reason: None,
2456 };
2457
2458 let type_defs = [batch_object_def];
2460 let struct_names: HashSet<&str> = type_defs.iter().map(|td| td.name.as_str()).collect();
2461
2462 let status_ty = TypeRef::Named("BatchStatus".to_string());
2464 assert!(
2465 is_enum_typed(&status_ty, &struct_names),
2466 "BatchStatus (not a known struct) should be detected as enum-typed"
2467 );
2468 let id_ty = TypeRef::String;
2469 assert!(
2470 !is_enum_typed(&id_ty, &struct_names),
2471 "String field should NOT be detected as enum-typed"
2472 );
2473
2474 let type_enum_fields: std::collections::HashMap<String, HashSet<String>> = type_defs
2476 .iter()
2477 .filter_map(|td| {
2478 let enum_field_names: HashSet<String> = td
2479 .fields
2480 .iter()
2481 .filter(|field| is_enum_typed(&field.ty, &struct_names))
2482 .map(|field| field.name.clone())
2483 .collect();
2484 if enum_field_names.is_empty() {
2485 None
2486 } else {
2487 Some((td.name.clone(), enum_field_names))
2488 }
2489 })
2490 .collect();
2491
2492 let batch_enum_fields = type_enum_fields
2493 .get("BatchObject")
2494 .expect("BatchObject should have enum fields");
2495 assert!(
2496 batch_enum_fields.contains("status"),
2497 "BatchObject.status should be auto-detected as enum-typed, got: {batch_enum_fields:?}"
2498 );
2499 assert!(
2500 !batch_enum_fields.contains("id"),
2501 "BatchObject.id (String) must not be in enum fields"
2502 );
2503
2504 let resolver = FieldResolver::new(
2506 &HashMap::new(),
2507 &HashSet::new(),
2508 &HashSet::new(),
2509 &HashSet::new(),
2510 &HashSet::new(),
2511 );
2512 let assertion = Assertion {
2513 assertion_type: "equals".to_string(),
2514 field: Some("status".to_string()),
2515 value: Some(serde_json::Value::String("validating".to_string())),
2516 values: None,
2517 method: None,
2518 check: None,
2519 args: None,
2520 return_type: None,
2521 };
2522 let mut out = String::new();
2523 render_assertion(
2524 &mut out,
2525 &assertion,
2526 "result",
2527 "",
2528 &resolver,
2529 false,
2530 false,
2531 batch_enum_fields,
2532 &HashMap::new(),
2533 false,
2534 false,
2535 );
2536 assert!(
2537 out.contains(".getValue()"),
2538 "auto-detected enum field must route through .getValue(), got: {out}"
2539 );
2540 }
2541
2542 #[test]
2546 fn kotlin_android_streaming_fixture_emits_flow_to_list_import() {
2547 use crate::fixture::MockResponse;
2548 use alef_core::config::e2e::CallConfig;
2549
2550 let streaming_fixture = Fixture {
2552 id: "smoke_stream".to_string(),
2553 category: None,
2554 description: "streaming test".to_string(),
2555 tags: vec![],
2556 skip: None,
2557 env: None,
2558 call: None,
2559 input: serde_json::json!({}),
2560 mock_response: Some(MockResponse {
2561 status: 200,
2562 body: None,
2563 stream_chunks: Some(vec![serde_json::json!({"delta": "hi"})]),
2564 headers: HashMap::new(),
2565 }),
2566 visitor: None,
2567 assertions: vec![],
2568 source: String::new(),
2569 http: None,
2570 };
2571
2572 let e2e_config = E2eConfig {
2573 call: CallConfig::default(),
2574 ..E2eConfig::default()
2575 };
2576 let out_android = render_test_file_inner(
2578 "streaming",
2579 &[&streaming_fixture],
2580 "LlmClient",
2581 "chatStream",
2582 "dev.kreuzberg.literllm.android",
2583 "result",
2584 &[],
2585 None,
2586 false,
2587 &e2e_config,
2588 &HashMap::new(),
2589 true,
2590 );
2591 assert!(
2592 out_android.contains("import kotlinx.coroutines.flow.toList"),
2593 "kotlin_android streaming file must import flow.toList, got:\n{out_android}"
2594 );
2595
2596 let out_jvm = render_test_file_inner(
2598 "streaming",
2599 &[&streaming_fixture],
2600 "LlmClient",
2601 "chatStream",
2602 "dev.kreuzberg.literllm.android",
2603 "result",
2604 &[],
2605 None,
2606 false,
2607 &e2e_config,
2608 &HashMap::new(),
2609 false,
2610 );
2611 assert!(
2612 !out_jvm.contains("import kotlinx.coroutines.flow.toList"),
2613 "non-android streaming file must NOT import flow.toList, got:\n{out_jvm}"
2614 );
2615 }
2616
2617 #[test]
2622 fn kotlin_android_object_mapper_emits_register_kotlin_module() {
2623 use crate::fixture::{HttpExpectedResponse, HttpFixture, HttpHandler, HttpRequest};
2624 use alef_core::config::e2e::CallConfig;
2625
2626 let http_fixture = Fixture {
2628 id: "http_test".to_string(),
2629 category: None,
2630 description: "http test".to_string(),
2631 tags: vec![],
2632 skip: None,
2633 env: None,
2634 call: None,
2635 input: serde_json::json!({}),
2636 mock_response: None,
2637 visitor: None,
2638 assertions: vec![],
2639 source: String::new(),
2640 http: Some(HttpFixture {
2641 handler: HttpHandler {
2642 route: "/v1/test".to_string(),
2643 method: "POST".to_string(),
2644 body_schema: None,
2645 parameters: HashMap::new(),
2646 middleware: None,
2647 },
2648 request: HttpRequest {
2649 method: "POST".to_string(),
2650 path: "/v1/test".to_string(),
2651 headers: HashMap::new(),
2652 query_params: HashMap::new(),
2653 cookies: HashMap::new(),
2654 body: None,
2655 content_type: None,
2656 },
2657 expected_response: HttpExpectedResponse {
2658 status_code: 200,
2659 body: None,
2660 body_partial: None,
2661 headers: HashMap::new(),
2662 validation_errors: None,
2663 },
2664 }),
2665 };
2666
2667 let e2e_config = E2eConfig {
2668 call: CallConfig::default(),
2669 ..E2eConfig::default()
2670 };
2671 let out_android = render_test_file_inner(
2673 "configuration",
2674 &[&http_fixture],
2675 "",
2676 "",
2677 "dev.kreuzberg.literllm.android",
2678 "result",
2679 &[],
2680 None,
2681 false,
2682 &e2e_config,
2683 &HashMap::new(),
2684 true,
2685 );
2686 assert!(
2687 out_android.contains("import com.fasterxml.jackson.module.kotlin.registerKotlinModule"),
2688 "kotlin_android with ObjectMapper must import registerKotlinModule, got:\n{out_android}"
2689 );
2690 assert!(
2691 out_android.contains(".registerKotlinModule()"),
2692 "kotlin_android MAPPER must call .registerKotlinModule(), got:\n{out_android}"
2693 );
2694
2695 let out_jvm = render_test_file_inner(
2697 "configuration",
2698 &[&http_fixture],
2699 "",
2700 "",
2701 "dev.kreuzberg.literllm.android",
2702 "result",
2703 &[],
2704 None,
2705 false,
2706 &e2e_config,
2707 &HashMap::new(),
2708 false,
2709 );
2710 assert!(
2711 !out_jvm.contains("registerKotlinModule"),
2712 "non-android MAPPER must NOT reference registerKotlinModule, got:\n{out_jvm}"
2713 );
2714 }
2715}