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" {
1591 let val = json_to_kotlin(v);
1592 if kotlin_android_style {
1593 parts.push(val);
1595 } else {
1596 parts.push(format!("java.nio.file.Path.of({val})"));
1598 }
1599 continue;
1600 }
1601 parts.push(json_to_kotlin(v));
1602 }
1603 }
1604 }
1605
1606 (setup_lines, parts.join(", "))
1607}
1608
1609fn emit_kotlin_batch_item_array(arr: &serde_json::Value, elem_type: &str) -> String {
1613 let Some(items) = arr.as_array() else {
1614 return "emptyList()".to_string();
1615 };
1616 let parts: Vec<String> = items
1617 .iter()
1618 .filter_map(|item| {
1619 let obj = item.as_object()?;
1620 match elem_type {
1621 "BatchBytesItem" => {
1622 let mime_type = obj.get("mime_type").and_then(|v| v.as_str()).unwrap_or("text/plain");
1623 let content_code = obj
1624 .get("content")
1625 .and_then(|v| v.as_array())
1626 .map(|arr| {
1627 let bytes: Vec<String> =
1628 arr.iter().filter_map(|v| v.as_u64().map(|n| format!("{n}"))).collect();
1629 format!("byteArrayOf({})", bytes.join(", "))
1630 })
1631 .unwrap_or_else(|| "byteArrayOf()".to_string());
1632 Some(format!("{elem_type}({content_code}, \"{mime_type}\", null)"))
1633 }
1634 "BatchFileItem" => {
1635 let path = obj.get("path").and_then(|v| v.as_str()).unwrap_or("");
1636 Some(format!("{elem_type}(java.nio.file.Paths.get(\"{path}\"), null)"))
1637 }
1638 _ => None,
1639 }
1640 })
1641 .collect();
1642 format!("listOf({})", parts.join(", "))
1643}
1644
1645#[allow(clippy::too_many_arguments)]
1646fn render_assertion(
1647 out: &mut String,
1648 assertion: &Assertion,
1649 result_var: &str,
1650 _class_name: &str,
1651 field_resolver: &FieldResolver,
1652 result_is_simple: bool,
1653 result_is_option: bool,
1654 enum_fields: &HashSet<String>,
1655 fields_c_types: &std::collections::HashMap<String, String>,
1656 is_streaming: bool,
1657 kotlin_android_style: bool,
1658) {
1659 if is_streaming {
1664 if let Some(f) = &assertion.field {
1665 if f == "usage" || f.starts_with("usage.") {
1666 let stream_lang = if kotlin_android_style {
1667 "kotlin_android"
1668 } else {
1669 "kotlin"
1670 };
1671 let base_expr = crate::codegen::streaming_assertions::StreamingFieldResolver::accessor(
1672 "usage",
1673 stream_lang,
1674 "chunks",
1675 )
1676 .unwrap_or_else(|| {
1677 if kotlin_android_style {
1678 "(if (chunks.isEmpty()) null else chunks.last().usage)".to_string()
1679 } else {
1680 "(if (chunks.isEmpty()) null else chunks.last().usage())".to_string()
1681 }
1682 });
1683
1684 let expr = if let Some(tail) = f.strip_prefix("usage.") {
1687 use heck::ToLowerCamelCase;
1688 if kotlin_android_style {
1689 tail.split('.')
1691 .fold(base_expr, |acc, seg| format!("{acc}?.{}", seg.to_lower_camel_case()))
1692 } else {
1693 tail.split('.')
1695 .fold(base_expr, |acc, seg| format!("{acc}?.{}()", seg.to_lower_camel_case()))
1696 }
1697 } else {
1698 base_expr
1699 };
1700
1701 let field_is_long = fields_c_types
1703 .get(f.as_str())
1704 .is_some_and(|t| matches!(t.as_str(), "uint64_t" | "int64_t"));
1705
1706 let line = match assertion.assertion_type.as_str() {
1707 "equals" => {
1708 if let Some(expected) = &assertion.value {
1709 let kotlin_val = if field_is_long && expected.is_number() && !expected.is_f64() {
1710 format!("{}L", expected)
1711 } else {
1712 json_to_kotlin(expected)
1713 };
1714 format!(" assertEquals({kotlin_val}, {expr}!!)\n")
1715 } else {
1716 String::new()
1717 }
1718 }
1719 _ => String::new(),
1720 };
1721 if !line.is_empty() {
1722 out.push_str(&line);
1723 }
1724 return;
1725 }
1726 }
1727 }
1728
1729 if let Some(f) = &assertion.field {
1732 if !f.is_empty() && crate::codegen::streaming_assertions::is_streaming_virtual_field(f) {
1733 let stream_lang = if kotlin_android_style {
1734 "kotlin_android"
1735 } else {
1736 "kotlin"
1737 };
1738 if let Some(expr) =
1739 crate::codegen::streaming_assertions::StreamingFieldResolver::accessor(f, stream_lang, "chunks")
1740 {
1741 let line = match assertion.assertion_type.as_str() {
1742 "count_min" => {
1743 if let Some(n) = assertion.value.as_ref().and_then(|v| v.as_u64()) {
1744 format!(" assertTrue({expr}.size >= {n}, \"expected >= {n} chunks\")\n")
1745 } else {
1746 String::new()
1747 }
1748 }
1749 "count_equals" => {
1750 if let Some(n) = assertion.value.as_ref().and_then(|v| v.as_u64()) {
1751 format!(
1752 " assertEquals({n}.toLong(), {expr}.size.toLong(), \"expected exactly {n} elements\")\n"
1753 )
1754 } else {
1755 String::new()
1756 }
1757 }
1758 "equals" => {
1759 if let Some(serde_json::Value::String(s)) = &assertion.value {
1760 let escaped = escape_kotlin(s);
1761 format!(" assertEquals(\"{escaped}\", {expr})\n")
1762 } else if let Some(b) = assertion.value.as_ref().and_then(|v| v.as_bool()) {
1763 format!(" assertEquals({b}, {expr})\n")
1764 } else {
1765 String::new()
1766 }
1767 }
1768 "not_empty" => {
1769 format!(" assertFalse({expr}.isEmpty(), \"expected non-empty\")\n")
1770 }
1771 "is_empty" => {
1772 format!(" assertTrue({expr}.isEmpty(), \"expected empty\")\n")
1773 }
1774 "is_true" => {
1775 format!(" assertTrue({expr}, \"expected true\")\n")
1776 }
1777 "is_false" => {
1778 format!(" assertFalse({expr}, \"expected false\")\n")
1779 }
1780 "greater_than" => {
1781 if let Some(n) = assertion.value.as_ref().and_then(|v| v.as_u64()) {
1782 format!(" assertTrue({expr} > {n}, \"expected > {n}\")\n")
1783 } else {
1784 String::new()
1785 }
1786 }
1787 "contains" => {
1788 if let Some(serde_json::Value::String(s)) = &assertion.value {
1789 let escaped = escape_kotlin(s);
1790 format!(
1791 " assertTrue({expr}.contains(\"{escaped}\"), \"expected to contain: {escaped}\")\n"
1792 )
1793 } else {
1794 String::new()
1795 }
1796 }
1797 _ => format!(
1798 " // streaming field '{f}': assertion type '{}' not rendered\n",
1799 assertion.assertion_type
1800 ),
1801 };
1802 if !line.is_empty() {
1803 out.push_str(&line);
1804 }
1805 }
1806 return;
1807 }
1808 }
1809
1810 if let Some(f) = &assertion.field {
1812 if !f.is_empty() && !field_resolver.is_valid_for_result(f) {
1813 let _ = writeln!(out, " // skipped: field '{f}' not available on result type");
1814 return;
1815 }
1816 }
1817
1818 let field_is_enum = assertion
1820 .field
1821 .as_deref()
1822 .is_some_and(|f| enum_fields.contains(f) || enum_fields.contains(field_resolver.resolve(f)));
1823
1824 let accessor_lang = if kotlin_android_style {
1828 "kotlin_android"
1829 } else {
1830 "kotlin"
1831 };
1832 let field_expr = if result_is_simple {
1833 result_var.to_string()
1834 } else {
1835 match &assertion.field {
1836 Some(f) if !f.is_empty() => field_resolver.accessor(f, accessor_lang, result_var),
1837 _ => result_var.to_string(),
1838 }
1839 };
1840
1841 let field_is_optional = !result_is_simple
1851 && (field_expr.contains("?.")
1852 || assertion.field.as_deref().filter(|f| !f.is_empty()).is_some_and(|f| {
1853 let resolved = field_resolver.resolve(f);
1854 if field_resolver.has_map_access(f) {
1855 return kotlin_android_style;
1864 }
1865 if field_resolver.is_optional(resolved) {
1867 return true;
1868 }
1869 let mut prefix = String::new();
1872 for part in resolved.split('.') {
1873 let key = part.split('[').next().unwrap_or(part);
1875 if !prefix.is_empty() {
1876 prefix.push('.');
1877 }
1878 prefix.push_str(key);
1879 if field_resolver.is_optional(&prefix) {
1880 return true;
1881 }
1882 }
1883 false
1884 }));
1885
1886 let string_field_expr = if field_is_optional {
1892 format!("{field_expr}.orEmpty()")
1893 } else {
1894 field_expr.clone()
1895 };
1896
1897 let nonnull_field_expr = if field_is_optional {
1900 format!("{field_expr}!!")
1901 } else {
1902 field_expr.clone()
1903 };
1904
1905 let string_expr = if kotlin_android_style {
1917 match (field_is_enum, field_is_optional) {
1918 (true, true) => format!("{field_expr}?.name?.lowercase().orEmpty()"),
1919 (true, false) => format!("{field_expr}.name.lowercase()"),
1920 (false, _) => string_field_expr.clone(),
1921 }
1922 } else {
1923 match (field_is_enum, field_is_optional) {
1924 (true, true) => format!("{field_expr}?.getValue().orEmpty()"),
1925 (true, false) => format!("{field_expr}.getValue()"),
1926 (false, _) => string_field_expr.clone(),
1927 }
1928 };
1929
1930 let field_is_long = assertion.field.as_deref().filter(|f| !f.is_empty()).is_some_and(|f| {
1934 let resolved = field_resolver.resolve(f);
1935 matches!(
1936 fields_c_types.get(resolved).map(String::as_str),
1937 Some("uint64_t") | Some("int64_t")
1938 )
1939 });
1940
1941 match assertion.assertion_type.as_str() {
1942 "equals" => {
1943 if let Some(expected) = &assertion.value {
1944 let kotlin_val = if field_is_long && expected.is_number() && !expected.is_f64() {
1948 format!("{}L", expected)
1949 } else {
1950 json_to_kotlin(expected)
1951 };
1952 if expected.is_string() {
1953 let _ = writeln!(out, " assertEquals({kotlin_val}, {string_expr}.trim())");
1954 } else {
1955 let _ = writeln!(out, " assertEquals({kotlin_val}, {nonnull_field_expr})");
1956 }
1957 }
1958 }
1959 "contains" => {
1960 if let Some(expected) = &assertion.value {
1961 let kotlin_val = json_to_kotlin(expected);
1962 let _ = writeln!(
1963 out,
1964 " assertTrue({string_expr}.contains({kotlin_val}), \"expected to contain: \" + {kotlin_val})"
1965 );
1966 }
1967 }
1968 "contains_all" => {
1969 if let Some(values) = &assertion.values {
1970 for val in values {
1971 let kotlin_val = json_to_kotlin(val);
1972 let _ = writeln!(
1973 out,
1974 " assertTrue({string_expr}.contains({kotlin_val}), \"expected to contain: \" + {kotlin_val})"
1975 );
1976 }
1977 }
1978 }
1979 "not_contains" => {
1980 if let Some(expected) = &assertion.value {
1981 let kotlin_val = json_to_kotlin(expected);
1982 let _ = writeln!(
1983 out,
1984 " assertFalse({string_expr}.contains({kotlin_val}), \"expected NOT to contain: \" + {kotlin_val})"
1985 );
1986 }
1987 }
1988 "not_empty" => {
1989 let bare_result_is_option =
2002 result_is_option && assertion.field.as_deref().filter(|f| !f.is_empty()).is_none();
2003 if bare_result_is_option && !kotlin_android_style {
2004 let _ = writeln!(
2005 out,
2006 " assertTrue({field_expr}.isPresent, \"expected non-empty value\")"
2007 );
2008 } else if bare_result_is_option || field_is_optional {
2009 let _ = writeln!(
2010 out,
2011 " assertTrue({field_expr} != null, \"expected non-empty value\")"
2012 );
2013 } else {
2014 let _ = writeln!(
2015 out,
2016 " assertFalse({string_field_expr}.isEmpty(), \"expected non-empty value\")"
2017 );
2018 }
2019 }
2020 "is_empty" => {
2021 let bare_result_is_option =
2022 result_is_option && assertion.field.as_deref().filter(|f| !f.is_empty()).is_none();
2023 if bare_result_is_option && !kotlin_android_style {
2024 let _ = writeln!(
2025 out,
2026 " assertTrue({field_expr}.isEmpty, \"expected empty value\")"
2027 );
2028 } else if bare_result_is_option || field_is_optional {
2029 let _ = writeln!(
2030 out,
2031 " assertTrue({field_expr} == null, \"expected empty value\")"
2032 );
2033 } else {
2034 let _ = writeln!(
2035 out,
2036 " assertTrue({string_field_expr}.isEmpty(), \"expected empty value\")"
2037 );
2038 }
2039 }
2040 "contains_any" => {
2041 if let Some(values) = &assertion.values {
2042 let checks: Vec<String> = values
2043 .iter()
2044 .map(|v| {
2045 let kotlin_val = json_to_kotlin(v);
2046 format!("{string_expr}.contains({kotlin_val})")
2047 })
2048 .collect();
2049 let joined = checks.join(" || ");
2050 let _ = writeln!(
2051 out,
2052 " assertTrue({joined}, \"expected to contain at least one of the specified values\")"
2053 );
2054 }
2055 }
2056 "greater_than" => {
2057 if let Some(val) = &assertion.value {
2058 let kotlin_val = json_to_kotlin(val);
2059 let _ = writeln!(
2060 out,
2061 " assertTrue({nonnull_field_expr} > {kotlin_val}, \"expected > {kotlin_val}\")"
2062 );
2063 }
2064 }
2065 "less_than" => {
2066 if let Some(val) = &assertion.value {
2067 let kotlin_val = json_to_kotlin(val);
2068 let _ = writeln!(
2069 out,
2070 " assertTrue({nonnull_field_expr} < {kotlin_val}, \"expected < {kotlin_val}\")"
2071 );
2072 }
2073 }
2074 "greater_than_or_equal" => {
2075 if let Some(val) = &assertion.value {
2076 let kotlin_val = json_to_kotlin(val);
2077 let _ = writeln!(
2078 out,
2079 " assertTrue({nonnull_field_expr} >= {kotlin_val}, \"expected >= {kotlin_val}\")"
2080 );
2081 }
2082 }
2083 "less_than_or_equal" => {
2084 if let Some(val) = &assertion.value {
2085 let kotlin_val = json_to_kotlin(val);
2086 let _ = writeln!(
2087 out,
2088 " assertTrue({nonnull_field_expr} <= {kotlin_val}, \"expected <= {kotlin_val}\")"
2089 );
2090 }
2091 }
2092 "starts_with" => {
2093 if let Some(expected) = &assertion.value {
2094 let kotlin_val = json_to_kotlin(expected);
2095 let _ = writeln!(
2096 out,
2097 " assertTrue({string_expr}.startsWith({kotlin_val}), \"expected to start with: \" + {kotlin_val})"
2098 );
2099 }
2100 }
2101 "ends_with" => {
2102 if let Some(expected) = &assertion.value {
2103 let kotlin_val = json_to_kotlin(expected);
2104 let _ = writeln!(
2105 out,
2106 " assertTrue({string_expr}.endsWith({kotlin_val}), \"expected to end with: \" + {kotlin_val})"
2107 );
2108 }
2109 }
2110 "min_length" => {
2111 if let Some(val) = &assertion.value {
2112 if let Some(n) = val.as_u64() {
2113 let _ = writeln!(
2114 out,
2115 " assertTrue({string_field_expr}.length >= {n}, \"expected length >= {n}\")"
2116 );
2117 }
2118 }
2119 }
2120 "max_length" => {
2121 if let Some(val) = &assertion.value {
2122 if let Some(n) = val.as_u64() {
2123 let _ = writeln!(
2124 out,
2125 " assertTrue({string_field_expr}.length <= {n}, \"expected length <= {n}\")"
2126 );
2127 }
2128 }
2129 }
2130 "count_min" => {
2131 if let Some(val) = &assertion.value {
2132 if let Some(n) = val.as_u64() {
2133 let _ = writeln!(
2134 out,
2135 " assertTrue({nonnull_field_expr}.size >= {n}, \"expected at least {n} elements\")"
2136 );
2137 }
2138 }
2139 }
2140 "count_equals" => {
2141 if let Some(val) = &assertion.value {
2142 if let Some(n) = val.as_u64() {
2143 let _ = writeln!(
2144 out,
2145 " assertEquals({n}, {nonnull_field_expr}.size, \"expected exactly {n} elements\")"
2146 );
2147 }
2148 }
2149 }
2150 "is_true" => {
2151 let _ = writeln!(out, " assertTrue({field_expr}, \"expected true\")");
2152 }
2153 "is_false" => {
2154 let _ = writeln!(out, " assertFalse({field_expr}, \"expected false\")");
2155 }
2156 "matches_regex" => {
2157 if let Some(expected) = &assertion.value {
2158 let kotlin_val = json_to_kotlin(expected);
2159 let _ = writeln!(
2160 out,
2161 " assertTrue(Regex({kotlin_val}).containsMatchIn({string_expr}), \"expected value to match regex: \" + {kotlin_val})"
2162 );
2163 }
2164 }
2165 "not_error" => {
2166 }
2168 "error" => {
2169 }
2171 "method_result" => {
2172 let _ = writeln!(
2174 out,
2175 " // method_result assertions not yet implemented for Kotlin"
2176 );
2177 }
2178 other => {
2179 panic!("Kotlin e2e generator: unsupported assertion type: {other}");
2180 }
2181 }
2182}
2183
2184fn json_to_kotlin(value: &serde_json::Value) -> String {
2186 match value {
2187 serde_json::Value::String(s) => format!("\"{}\"", escape_kotlin(s)),
2188 serde_json::Value::Bool(b) => b.to_string(),
2189 serde_json::Value::Number(n) => {
2190 if n.is_f64() {
2191 let s = n.to_string();
2194 if s.contains('.') || s.contains('e') || s.contains('E') {
2195 s
2196 } else {
2197 format!("{s}.0")
2198 }
2199 } else {
2200 n.to_string()
2201 }
2202 }
2203 serde_json::Value::Null => "null".to_string(),
2204 serde_json::Value::Array(arr) => {
2205 let items: Vec<String> = arr.iter().map(json_to_kotlin).collect();
2206 format!("listOf({})", items.join(", "))
2207 }
2208 serde_json::Value::Object(_) => {
2209 let json_str = serde_json::to_string(value).unwrap_or_default();
2210 format!("\"{}\"", escape_kotlin(&json_str))
2211 }
2212 }
2213}
2214
2215#[cfg(test)]
2216mod tests {
2217 use super::*;
2218 use std::collections::HashMap;
2219
2220 fn make_resolver_for_finish_reason() -> FieldResolver {
2221 let mut optional = HashSet::new();
2225 optional.insert("choices.finish_reason".to_string());
2226 let mut arrays = HashSet::new();
2227 arrays.insert("choices".to_string());
2228 FieldResolver::new(&HashMap::new(), &optional, &HashSet::new(), &arrays, &HashSet::new())
2229 }
2230
2231 #[test]
2235 fn assertion_enum_optional_uses_safe_get_value_then_or_empty() {
2236 let resolver = make_resolver_for_finish_reason();
2237 let mut enum_fields = HashSet::new();
2238 enum_fields.insert("choices.finish_reason".to_string());
2239 let assertion = Assertion {
2240 assertion_type: "equals".to_string(),
2241 field: Some("choices.finish_reason".to_string()),
2242 value: Some(serde_json::Value::String("stop".to_string())),
2243 values: None,
2244 method: None,
2245 check: None,
2246 args: None,
2247 return_type: None,
2248 };
2249 let mut out = String::new();
2250 render_assertion(
2251 &mut out,
2252 &assertion,
2253 "result",
2254 "",
2255 &resolver,
2256 false,
2257 false,
2258 &enum_fields,
2259 &HashMap::new(),
2260 false,
2261 false,
2262 );
2263 assert!(
2264 out.contains("result.choices().first().finishReason()?.getValue().orEmpty().trim()"),
2265 "expected enum-optional safe-call pattern, got: {out}"
2266 );
2267 assert!(
2268 !out.contains(".finishReason().orEmpty().getValue()"),
2269 "must not emit .orEmpty().getValue() on a nullable enum: {out}"
2270 );
2271 }
2272
2273 #[test]
2276 fn assertion_enum_non_optional_uses_plain_get_value() {
2277 let mut arrays = HashSet::new();
2278 arrays.insert("choices".to_string());
2279 let resolver = FieldResolver::new(
2280 &HashMap::new(),
2281 &HashSet::new(),
2282 &HashSet::new(),
2283 &arrays,
2284 &HashSet::new(),
2285 );
2286 let mut enum_fields = HashSet::new();
2287 enum_fields.insert("choices.finish_reason".to_string());
2288 let assertion = Assertion {
2289 assertion_type: "equals".to_string(),
2290 field: Some("choices.finish_reason".to_string()),
2291 value: Some(serde_json::Value::String("stop".to_string())),
2292 values: None,
2293 method: None,
2294 check: None,
2295 args: None,
2296 return_type: None,
2297 };
2298 let mut out = String::new();
2299 render_assertion(
2300 &mut out,
2301 &assertion,
2302 "result",
2303 "",
2304 &resolver,
2305 false,
2306 false,
2307 &enum_fields,
2308 &HashMap::new(),
2309 false,
2310 false,
2311 );
2312 assert!(
2313 out.contains("result.choices().first().finishReason().getValue().trim()"),
2314 "expected plain .getValue() for non-optional enum, got: {out}"
2315 );
2316 }
2317
2318 #[test]
2324 fn per_call_enum_field_override_routes_through_get_value() {
2325 let resolver = FieldResolver::new(
2327 &HashMap::new(),
2328 &HashSet::new(),
2329 &HashSet::new(),
2330 &HashSet::new(),
2331 &HashSet::new(),
2332 );
2333 let global_enum_fields: HashSet<String> = HashSet::new();
2335 let mut per_call_enum_fields: HashSet<String> = global_enum_fields.clone();
2337 per_call_enum_fields.insert("status".to_string());
2338
2339 let assertion = Assertion {
2340 assertion_type: "equals".to_string(),
2341 field: Some("status".to_string()),
2342 value: Some(serde_json::Value::String("validating".to_string())),
2343 values: None,
2344 method: None,
2345 check: None,
2346 args: None,
2347 return_type: None,
2348 };
2349
2350 let mut out_no_merge = String::new();
2352 render_assertion(
2353 &mut out_no_merge,
2354 &assertion,
2355 "result",
2356 "",
2357 &resolver,
2358 false,
2359 false,
2360 &global_enum_fields,
2361 &HashMap::new(),
2362 false,
2363 false,
2364 );
2365 assert!(
2366 !out_no_merge.contains(".getValue()"),
2367 "global-only set must not emit .getValue() for unregistered status: {out_no_merge}"
2368 );
2369
2370 let mut out_merged = String::new();
2372 render_assertion(
2373 &mut out_merged,
2374 &assertion,
2375 "result",
2376 "",
2377 &resolver,
2378 false,
2379 false,
2380 &per_call_enum_fields,
2381 &HashMap::new(),
2382 false,
2383 false,
2384 );
2385 assert!(
2386 out_merged.contains(".getValue()"),
2387 "merged per-call set must emit .getValue() for status: {out_merged}"
2388 );
2389 }
2390
2391 #[test]
2396 fn auto_detected_enum_fields_from_type_defs_route_through_get_value() {
2397 use alef_core::ir::{CoreWrapper, FieldDef, TypeDef, TypeRef};
2398
2399 let batch_object_def = TypeDef {
2401 name: "BatchObject".to_string(),
2402 rust_path: "liter_llm::BatchObject".to_string(),
2403 original_rust_path: String::new(),
2404 fields: vec![
2405 FieldDef {
2406 name: "id".to_string(),
2407 ty: TypeRef::String,
2408 optional: false,
2409 default: None,
2410 doc: String::new(),
2411 sanitized: false,
2412 is_boxed: false,
2413 type_rust_path: None,
2414 cfg: None,
2415 typed_default: None,
2416 core_wrapper: CoreWrapper::None,
2417 vec_inner_core_wrapper: CoreWrapper::None,
2418 newtype_wrapper: None,
2419 serde_rename: None,
2420 serde_flatten: false,
2421 binding_excluded: false,
2422 binding_exclusion_reason: None,
2423 original_type: None,
2424 },
2425 FieldDef {
2426 name: "status".to_string(),
2427 ty: TypeRef::Named("BatchStatus".to_string()),
2428 optional: false,
2429 default: None,
2430 doc: String::new(),
2431 sanitized: false,
2432 is_boxed: false,
2433 type_rust_path: None,
2434 cfg: None,
2435 typed_default: None,
2436 core_wrapper: CoreWrapper::None,
2437 vec_inner_core_wrapper: CoreWrapper::None,
2438 newtype_wrapper: None,
2439 serde_rename: None,
2440 serde_flatten: false,
2441 binding_excluded: false,
2442 binding_exclusion_reason: None,
2443 original_type: None,
2444 },
2445 ],
2446 methods: vec![],
2447 is_opaque: false,
2448 is_clone: true,
2449 is_copy: false,
2450 doc: String::new(),
2451 cfg: None,
2452 is_trait: false,
2453 has_default: false,
2454 has_stripped_cfg_fields: false,
2455 is_return_type: true,
2456 serde_rename_all: None,
2457 has_serde: true,
2458 super_traits: vec![],
2459 binding_excluded: false,
2460 binding_exclusion_reason: None,
2461 };
2462
2463 let type_defs = [batch_object_def];
2465 let struct_names: HashSet<&str> = type_defs.iter().map(|td| td.name.as_str()).collect();
2466
2467 let status_ty = TypeRef::Named("BatchStatus".to_string());
2469 assert!(
2470 is_enum_typed(&status_ty, &struct_names),
2471 "BatchStatus (not a known struct) should be detected as enum-typed"
2472 );
2473 let id_ty = TypeRef::String;
2474 assert!(
2475 !is_enum_typed(&id_ty, &struct_names),
2476 "String field should NOT be detected as enum-typed"
2477 );
2478
2479 let type_enum_fields: std::collections::HashMap<String, HashSet<String>> = type_defs
2481 .iter()
2482 .filter_map(|td| {
2483 let enum_field_names: HashSet<String> = td
2484 .fields
2485 .iter()
2486 .filter(|field| is_enum_typed(&field.ty, &struct_names))
2487 .map(|field| field.name.clone())
2488 .collect();
2489 if enum_field_names.is_empty() {
2490 None
2491 } else {
2492 Some((td.name.clone(), enum_field_names))
2493 }
2494 })
2495 .collect();
2496
2497 let batch_enum_fields = type_enum_fields
2498 .get("BatchObject")
2499 .expect("BatchObject should have enum fields");
2500 assert!(
2501 batch_enum_fields.contains("status"),
2502 "BatchObject.status should be auto-detected as enum-typed, got: {batch_enum_fields:?}"
2503 );
2504 assert!(
2505 !batch_enum_fields.contains("id"),
2506 "BatchObject.id (String) must not be in enum fields"
2507 );
2508
2509 let resolver = FieldResolver::new(
2511 &HashMap::new(),
2512 &HashSet::new(),
2513 &HashSet::new(),
2514 &HashSet::new(),
2515 &HashSet::new(),
2516 );
2517 let assertion = Assertion {
2518 assertion_type: "equals".to_string(),
2519 field: Some("status".to_string()),
2520 value: Some(serde_json::Value::String("validating".to_string())),
2521 values: None,
2522 method: None,
2523 check: None,
2524 args: None,
2525 return_type: None,
2526 };
2527 let mut out = String::new();
2528 render_assertion(
2529 &mut out,
2530 &assertion,
2531 "result",
2532 "",
2533 &resolver,
2534 false,
2535 false,
2536 batch_enum_fields,
2537 &HashMap::new(),
2538 false,
2539 false,
2540 );
2541 assert!(
2542 out.contains(".getValue()"),
2543 "auto-detected enum field must route through .getValue(), got: {out}"
2544 );
2545 }
2546
2547 #[test]
2551 fn kotlin_android_streaming_fixture_emits_flow_to_list_import() {
2552 use crate::fixture::MockResponse;
2553 use alef_core::config::e2e::CallConfig;
2554
2555 let streaming_fixture = Fixture {
2557 id: "smoke_stream".to_string(),
2558 category: None,
2559 description: "streaming test".to_string(),
2560 tags: vec![],
2561 skip: None,
2562 env: None,
2563 call: None,
2564 input: serde_json::json!({}),
2565 mock_response: Some(MockResponse {
2566 status: 200,
2567 body: None,
2568 stream_chunks: Some(vec![serde_json::json!({"delta": "hi"})]),
2569 headers: HashMap::new(),
2570 }),
2571 visitor: None,
2572 assertions: vec![],
2573 source: String::new(),
2574 http: None,
2575 };
2576
2577 let e2e_config = E2eConfig {
2578 call: CallConfig::default(),
2579 ..E2eConfig::default()
2580 };
2581 let out_android = render_test_file_inner(
2583 "streaming",
2584 &[&streaming_fixture],
2585 "LlmClient",
2586 "chatStream",
2587 "dev.kreuzberg.literllm.android",
2588 "result",
2589 &[],
2590 None,
2591 false,
2592 &e2e_config,
2593 &HashMap::new(),
2594 true,
2595 );
2596 assert!(
2597 out_android.contains("import kotlinx.coroutines.flow.toList"),
2598 "kotlin_android streaming file must import flow.toList, got:\n{out_android}"
2599 );
2600
2601 let out_jvm = render_test_file_inner(
2603 "streaming",
2604 &[&streaming_fixture],
2605 "LlmClient",
2606 "chatStream",
2607 "dev.kreuzberg.literllm.android",
2608 "result",
2609 &[],
2610 None,
2611 false,
2612 &e2e_config,
2613 &HashMap::new(),
2614 false,
2615 );
2616 assert!(
2617 !out_jvm.contains("import kotlinx.coroutines.flow.toList"),
2618 "non-android streaming file must NOT import flow.toList, got:\n{out_jvm}"
2619 );
2620 }
2621
2622 #[test]
2627 fn kotlin_android_object_mapper_emits_register_kotlin_module() {
2628 use crate::fixture::{HttpExpectedResponse, HttpFixture, HttpHandler, HttpRequest};
2629 use alef_core::config::e2e::CallConfig;
2630
2631 let http_fixture = Fixture {
2633 id: "http_test".to_string(),
2634 category: None,
2635 description: "http test".to_string(),
2636 tags: vec![],
2637 skip: None,
2638 env: None,
2639 call: None,
2640 input: serde_json::json!({}),
2641 mock_response: None,
2642 visitor: None,
2643 assertions: vec![],
2644 source: String::new(),
2645 http: Some(HttpFixture {
2646 handler: HttpHandler {
2647 route: "/v1/test".to_string(),
2648 method: "POST".to_string(),
2649 body_schema: None,
2650 parameters: HashMap::new(),
2651 middleware: None,
2652 },
2653 request: HttpRequest {
2654 method: "POST".to_string(),
2655 path: "/v1/test".to_string(),
2656 headers: HashMap::new(),
2657 query_params: HashMap::new(),
2658 cookies: HashMap::new(),
2659 body: None,
2660 content_type: None,
2661 },
2662 expected_response: HttpExpectedResponse {
2663 status_code: 200,
2664 body: None,
2665 body_partial: None,
2666 headers: HashMap::new(),
2667 validation_errors: None,
2668 },
2669 }),
2670 };
2671
2672 let e2e_config = E2eConfig {
2673 call: CallConfig::default(),
2674 ..E2eConfig::default()
2675 };
2676 let out_android = render_test_file_inner(
2678 "configuration",
2679 &[&http_fixture],
2680 "",
2681 "",
2682 "dev.kreuzberg.literllm.android",
2683 "result",
2684 &[],
2685 None,
2686 false,
2687 &e2e_config,
2688 &HashMap::new(),
2689 true,
2690 );
2691 assert!(
2692 out_android.contains("import com.fasterxml.jackson.module.kotlin.registerKotlinModule"),
2693 "kotlin_android with ObjectMapper must import registerKotlinModule, got:\n{out_android}"
2694 );
2695 assert!(
2696 out_android.contains(".registerKotlinModule()"),
2697 "kotlin_android MAPPER must call .registerKotlinModule(), got:\n{out_android}"
2698 );
2699
2700 let out_jvm = render_test_file_inner(
2702 "configuration",
2703 &[&http_fixture],
2704 "",
2705 "",
2706 "dev.kreuzberg.literllm.android",
2707 "result",
2708 &[],
2709 None,
2710 false,
2711 &e2e_config,
2712 &HashMap::new(),
2713 false,
2714 );
2715 assert!(
2716 !out_jvm.contains("registerKotlinModule"),
2717 "non-android MAPPER must NOT reference registerKotlinModule, got:\n{out_jvm}"
2718 );
2719 }
2720}