Skip to main content

alef_e2e/codegen/
kotlin.rs

1//! Kotlin e2e test generator using kotlin.test and JUnit 5.
2//!
3//! Generates `packages/kotlin/src/test/kotlin/<package>/<Name>Test.kt` files
4//! from JSON fixtures, driven entirely by `E2eConfig` and `CallConfig`.
5
6use 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
23/// Kotlin e2e code generator.
24pub 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    ) -> Result<Vec<GeneratedFile>> {
34        let lang = self.language_name();
35        let output_base = PathBuf::from(e2e_config.effective_output()).join(lang);
36
37        let mut files = Vec::new();
38
39        // Resolve call config with overrides.
40        let call = &e2e_config.call;
41        let overrides = call.overrides.get(lang);
42        let _module_path = overrides
43            .and_then(|o| o.module.as_ref())
44            .cloned()
45            .unwrap_or_else(|| call.module.clone());
46        let function_name = overrides
47            .and_then(|o| o.function.as_ref())
48            .cloned()
49            .unwrap_or_else(|| call.function.clone());
50        let class_name = overrides
51            .and_then(|o| o.class.as_ref())
52            .cloned()
53            .unwrap_or_else(|| config.name.to_upper_camel_case());
54        let result_is_simple = overrides.is_some_and(|o| o.result_is_simple);
55        let result_var = &call.result_var;
56
57        // Resolve package config.
58        let kotlin_pkg = e2e_config.resolve_package("kotlin");
59        let pkg_name = kotlin_pkg
60            .as_ref()
61            .and_then(|p| p.name.as_ref())
62            .cloned()
63            .unwrap_or_else(|| config.name.clone());
64
65        // Resolve Kotlin package for generated tests.
66        let _kotlin_pkg_path = kotlin_pkg
67            .as_ref()
68            .and_then(|p| p.path.as_ref())
69            .cloned()
70            .unwrap_or_else(|| "../../packages/kotlin".to_string());
71        let kotlin_version = kotlin_pkg
72            .as_ref()
73            .and_then(|p| p.version.as_ref())
74            .cloned()
75            .or_else(|| config.resolved_version())
76            .unwrap_or_else(|| "0.1.0".to_string());
77        let kotlin_pkg_id = config.kotlin_package();
78
79        // Detect whether any fixture needs the mock-server (HTTP fixtures or
80        // fixtures with a mock_response/mock_responses). When present, emit a
81        // JUnit Platform LauncherSessionListener that spawns the mock-server
82        // before any test runs and a META-INF/services SPI manifest registering
83        // it. Mirrors the Java e2e pattern exactly.
84        let needs_mock_server = groups
85            .iter()
86            .flat_map(|g| g.fixtures.iter())
87            .any(|f| f.needs_mock_server());
88
89        // Generate build.gradle.kts.
90        files.push(GeneratedFile {
91            path: output_base.join("build.gradle.kts"),
92            content: render_build_gradle(
93                &pkg_name,
94                &kotlin_pkg_id,
95                &kotlin_version,
96                e2e_config.dep_mode,
97                needs_mock_server,
98            ),
99            generated_header: false,
100        });
101
102        // Generate test files per category. Path mirrors the configured Kotlin
103        // package so the package declaration in each test file matches its
104        // filesystem location.
105        let mut test_base = output_base.join("src").join("test").join("kotlin");
106        for segment in kotlin_pkg_id.split('.') {
107            test_base = test_base.join(segment);
108        }
109        let test_base = test_base.join("e2e");
110
111        if needs_mock_server {
112            files.push(GeneratedFile {
113                path: test_base.join("MockServerListener.kt"),
114                content: render_mock_server_listener_kt(&kotlin_pkg_id),
115                generated_header: true,
116            });
117            files.push(GeneratedFile {
118                path: output_base
119                    .join("src")
120                    .join("test")
121                    .join("resources")
122                    .join("META-INF")
123                    .join("services")
124                    .join("org.junit.platform.launcher.LauncherSessionListener"),
125                content: format!("{kotlin_pkg_id}.e2e.MockServerListener\n"),
126                generated_header: false,
127            });
128        }
129
130        // Resolve options_type from override.
131        let options_type = overrides.and_then(|o| o.options_type.clone());
132        let field_resolver = FieldResolver::new(
133            &e2e_config.fields,
134            &e2e_config.fields_optional,
135            &e2e_config.result_fields,
136            &e2e_config.fields_array,
137            &HashSet::new(),
138        );
139
140        // Build a map from TypeDef name → set of field names whose Rust type
141        // is a `Named(T)` reference where `T` is NOT itself a known struct.
142        // Those fields are enum-typed and should route through `.getValue()` in
143        // generated assertions automatically, even without an explicit per-call
144        // `enum_fields` override in the alef.toml.
145        let struct_names: HashSet<&str> = type_defs.iter().map(|td| td.name.as_str()).collect();
146        let type_enum_fields: std::collections::HashMap<String, HashSet<String>> = type_defs
147            .iter()
148            .filter_map(|td| {
149                let enum_field_names: HashSet<String> = td
150                    .fields
151                    .iter()
152                    .filter(|field| is_enum_typed(&field.ty, &struct_names))
153                    .map(|field| field.name.clone())
154                    .collect();
155                if enum_field_names.is_empty() {
156                    None
157                } else {
158                    Some((td.name.clone(), enum_field_names))
159                }
160            })
161            .collect();
162
163        for group in groups {
164            let active: Vec<&Fixture> = group
165                .fixtures
166                .iter()
167                .filter(|f| super::should_include_fixture(f, lang, e2e_config))
168                .collect();
169
170            if active.is_empty() {
171                continue;
172            }
173
174            let class_file_name = format!("{}Test.kt", sanitize_filename(&group.category).to_upper_camel_case());
175            let content = render_test_file(
176                &group.category,
177                &active,
178                &class_name,
179                &function_name,
180                &kotlin_pkg_id,
181                result_var,
182                &e2e_config.call.args,
183                options_type.as_deref(),
184                &field_resolver,
185                result_is_simple,
186                &e2e_config.fields_enum,
187                e2e_config,
188                &type_enum_fields,
189            );
190            files.push(GeneratedFile {
191                path: test_base.join(class_file_name),
192                content,
193                generated_header: true,
194            });
195        }
196
197        Ok(files)
198    }
199
200    fn language_name(&self) -> &'static str {
201        "kotlin"
202    }
203}
204
205// ---------------------------------------------------------------------------
206// Helpers
207// ---------------------------------------------------------------------------
208
209/// Returns true when `ty` is a `Named(T)` reference (or `Optional<Named(T)>`)
210/// where `T` is **not** a known struct name. Such fields are enum-typed and
211/// must route through `.getValue()` in generated assertions.
212fn is_enum_typed(ty: &alef_core::ir::TypeRef, struct_names: &HashSet<&str>) -> bool {
213    use alef_core::ir::TypeRef;
214    match ty {
215        TypeRef::Named(name) => !struct_names.contains(name.as_str()),
216        TypeRef::Optional(inner) => {
217            matches!(inner.as_ref(), TypeRef::Named(name) if !struct_names.contains(name.as_str()))
218        }
219        _ => false,
220    }
221}
222
223// ---------------------------------------------------------------------------
224// Rendering
225// ---------------------------------------------------------------------------
226
227pub(crate) fn render_build_gradle(
228    pkg_name: &str,
229    kotlin_pkg_id: &str,
230    pkg_version: &str,
231    dep_mode: crate::config::DependencyMode,
232    needs_mock_server: bool,
233) -> String {
234    let dep_block = match dep_mode {
235        crate::config::DependencyMode::Registry => {
236            // Registry mode: maven central with group:artifact:version
237            format!(r#"    testImplementation("{kotlin_pkg_id}:{pkg_name}:{pkg_version}")"#)
238        }
239        crate::config::DependencyMode::Local => {
240            // Local mode: reference the kotlin binding's built jar. The Kotlin
241            // module is produced by `gradle build` under
242            // `packages/kotlin/build/libs/<jar_name>-<version>.jar`, not by cargo.
243            // We must also pull in the binding's runtime dependencies (JNA,
244            // Jackson, jspecify, kotlinx-coroutines) since `files()` does not
245            // resolve transitive metadata.
246            let jar_name = pkg_name.rsplit(':').next().unwrap_or(pkg_name).replace('-', "_");
247            let jna = maven::JNA;
248            let jackson = maven::JACKSON_E2E;
249            let jspecify = maven::JSPECIFY;
250            let coroutines = maven::KOTLINX_COROUTINES_CORE;
251            format!(
252                r#"    testImplementation(files("../../packages/kotlin/build/libs/{jar_name}-{pkg_version}.jar"))
253    testImplementation("net.java.dev.jna:jna:{jna}")
254    testImplementation("com.fasterxml.jackson.core:jackson-annotations:{jackson}")
255    testImplementation("com.fasterxml.jackson.core:jackson-databind:{jackson}")
256    testImplementation("com.fasterxml.jackson.datatype:jackson-datatype-jdk8:{jackson}")
257    testImplementation("org.jspecify:jspecify:{jspecify}")
258    testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:{coroutines}")"#
259            )
260        }
261    };
262
263    let kotlin_plugin = maven::KOTLIN_JVM_PLUGIN;
264    let junit = maven::JUNIT;
265    let jackson = maven::JACKSON_E2E;
266    let jvm_target = toolchain::KOTLIN_JVM_TARGET;
267    let launcher_dep = if needs_mock_server {
268        format!(r#"    testImplementation("org.junit.platform:junit-platform-launcher:{junit}")"#)
269    } else {
270        String::new()
271    };
272    format!(
273        r#"import org.jetbrains.kotlin.gradle.dsl.JvmTarget
274
275plugins {{
276    kotlin("jvm") version "{kotlin_plugin}"
277    java
278}}
279
280group = "{kotlin_pkg_id}"
281version = "0.1.0"
282
283java {{
284    sourceCompatibility = JavaVersion.VERSION_{jvm_target}
285    targetCompatibility = JavaVersion.VERSION_{jvm_target}
286}}
287
288kotlin {{
289    compilerOptions {{
290        jvmTarget.set(JvmTarget.JVM_{jvm_target})
291    }}
292}}
293
294repositories {{
295    mavenCentral()
296}}
297
298dependencies {{
299{dep_block}
300    testImplementation("org.junit.jupiter:junit-jupiter-api:{junit}")
301    testImplementation("org.junit.jupiter:junit-jupiter-engine:{junit}")
302{launcher_dep}
303    testImplementation("com.fasterxml.jackson.core:jackson-databind:{jackson}")
304    testImplementation("com.fasterxml.jackson.datatype:jackson-datatype-jdk8:{jackson}")
305    testImplementation(kotlin("test"))
306}}
307
308tasks.test {{
309    useJUnitPlatform()
310    val libPath = System.getProperty("native.lib.path") ?: "${{rootDir}}/../../target/release"
311    systemProperty("java.library.path", libPath)
312    systemProperty("jna.library.path", libPath)
313    // Resolve fixture paths (e.g. "docx/fake.docx") against test_documents/.
314    workingDir = file("${{rootDir}}/../../test_documents")
315}}
316"#
317    )
318}
319
320/// Render the JUnit Platform `LauncherSessionListener` that spawns the
321/// mock-server binary once per launcher session and tears it down on close.
322///
323/// Mirrors the Java `MockServerListener.java` — same logic, idiomatic Kotlin.
324/// The URL is exposed via `System.setProperty("mockServerUrl", url)`;
325/// generated test bodies read `System.getenv("MOCK_SERVER_URL")` (which the
326/// listener also honours to skip spawning when the caller already has the
327/// server running).
328pub(crate) fn render_mock_server_listener_kt(kotlin_pkg_id: &str) -> String {
329    let header = hash::header(CommentStyle::DoubleSlash);
330    format!(
331        r#"{header}package {kotlin_pkg_id}.e2e
332
333import java.io.BufferedReader
334import java.io.IOException
335import java.io.InputStreamReader
336import java.nio.charset.StandardCharsets
337import java.nio.file.Path
338import java.nio.file.Paths
339import java.util.regex.Pattern
340import org.junit.platform.launcher.LauncherSession
341import org.junit.platform.launcher.LauncherSessionListener
342
343/**
344 * Spawns the mock-server binary once per JUnit launcher session and
345 * exposes its URL as the `mockServerUrl` system property. Generated
346 * test bodies read the property (with `MOCK_SERVER_URL` env-var
347 * fallback) so tests can run via plain `./gradlew test` without any
348 * external mock-server orchestration. Mirrors the Ruby spec_helper /
349 * Python conftest spawn pattern. Honors a pre-set MOCK_SERVER_URL by
350 * skipping the spawn entirely.
351 */
352class MockServerListener : LauncherSessionListener {{
353    private var mockServer: Process? = null
354
355    override fun launcherSessionOpened(session: LauncherSession) {{
356        val preset = System.getenv("MOCK_SERVER_URL")
357        if (!preset.isNullOrEmpty()) {{
358            System.setProperty("mockServerUrl", preset)
359            return
360        }}
361        val repoRoot = locateRepoRoot()
362            ?: error("MockServerListener: could not locate repo root (looked for fixtures/ in ancestors of ${{System.getProperty("user.dir")}})")
363        val binName = if (System.getProperty("os.name", "").lowercase().contains("win")) "mock-server.exe" else "mock-server"
364        val bin = repoRoot.resolve("e2e").resolve("rust").resolve("target").resolve("release").resolve(binName).toFile()
365        val fixturesDir = repoRoot.resolve("fixtures").toFile()
366        check(bin.exists()) {{
367            "MockServerListener: mock-server binary not found at $bin — run: cargo build --manifest-path e2e/rust/Cargo.toml --bin mock-server --release"
368        }}
369        val pb = ProcessBuilder(bin.absolutePath, fixturesDir.absolutePath)
370            .redirectErrorStream(false)
371        val server = try {{
372            pb.start()
373        }} catch (e: IOException) {{
374            throw IllegalStateException("MockServerListener: failed to start mock-server", e)
375        }}
376        mockServer = server
377        // Read until we see MOCK_SERVER_URL= and optionally MOCK_SERVERS=.
378        // Cap the loop so a misbehaving mock-server cannot block indefinitely.
379        val stdout = BufferedReader(InputStreamReader(server.inputStream, StandardCharsets.UTF_8))
380        var url: String? = null
381        try {{
382            for (i in 0 until 16) {{
383                val line = stdout.readLine() ?: break
384                when {{
385                    line.startsWith("MOCK_SERVER_URL=") -> {{
386                        url = line.removePrefix("MOCK_SERVER_URL=").trim()
387                    }}
388                    line.startsWith("MOCK_SERVERS=") -> {{
389                        val jsonVal = line.removePrefix("MOCK_SERVERS=").trim()
390                        System.setProperty("mockServers", jsonVal)
391                        // Parse JSON map of fixture_id -> url and expose as system properties.
392                        val p = Pattern.compile(""""([^"]+)":"([^"]+)"""")
393                        val matcher = p.matcher(jsonVal)
394                        while (matcher.find()) {{
395                            System.setProperty("mockServer.${{matcher.group(1)}}", matcher.group(2))
396                        }}
397                        break
398                    }}
399                    url != null -> break
400                }}
401            }}
402        }} catch (e: IOException) {{
403            server.destroyForcibly()
404            throw IllegalStateException("MockServerListener: failed to read mock-server stdout", e)
405        }}
406        if (url.isNullOrEmpty()) {{
407            server.destroyForcibly()
408            error("MockServerListener: mock-server did not emit MOCK_SERVER_URL")
409        }}
410        // TCP-readiness probe: ensure axum::serve is accepting before tests start.
411        // The mock-server binds the TcpListener synchronously then prints the URL
412        // before tokio::spawn(axum::serve(...)) is polled, so under Gradle parallel
413        // mode tests can race startup. Poll-connect (max 5s, 50ms backoff) until success.
414        val healthUri = java.net.URI.create(url)
415        val host = healthUri.host
416        val port = healthUri.port
417        val deadline = System.nanoTime() + 5_000_000_000L
418        while (System.nanoTime() < deadline) {{
419            try {{
420                java.net.Socket().use {{ s ->
421                    s.connect(java.net.InetSocketAddress(host, port), 100)
422                    break
423                }}
424            }} catch (_: java.io.IOException) {{
425                try {{ Thread.sleep(50) }} catch (ie: InterruptedException) {{ Thread.currentThread().interrupt(); break }}
426            }}
427        }}
428        System.setProperty("mockServerUrl", url)
429        // Drain remaining stdout/stderr in daemon threads so a full pipe
430        // does not block the child.
431        Thread {{ drain(stdout) }}.also {{ it.isDaemon = true }}.start()
432        Thread {{ drain(BufferedReader(InputStreamReader(server.errorStream, StandardCharsets.UTF_8))) }}.also {{ it.isDaemon = true }}.start()
433    }}
434
435    override fun launcherSessionClosed(session: LauncherSession) {{
436        val server = mockServer ?: return
437        try {{ server.outputStream.close() }} catch (_: IOException) {{}}
438        try {{
439            if (!server.waitFor(2, java.util.concurrent.TimeUnit.SECONDS)) {{
440                server.destroyForcibly()
441            }}
442        }} catch (ie: InterruptedException) {{
443            Thread.currentThread().interrupt()
444            server.destroyForcibly()
445        }}
446    }}
447
448    companion object {{
449        private fun locateRepoRoot(): Path? {{
450            var dir: Path? = Paths.get("").toAbsolutePath()
451            while (dir != null) {{
452                if (dir.resolve("fixtures").toFile().isDirectory
453                    && dir.resolve("e2e").toFile().isDirectory) {{
454                    return dir
455                }}
456                dir = dir.parent
457            }}
458            return null
459        }}
460
461        private fun drain(reader: BufferedReader) {{
462            try {{
463                val buf = CharArray(1024)
464                while (reader.read(buf) >= 0) {{ /* drain */ }}
465            }} catch (_: IOException) {{}}
466        }}
467    }}
468}}
469"#
470    )
471}
472
473#[allow(clippy::too_many_arguments)]
474pub(crate) fn render_test_file(
475    category: &str,
476    fixtures: &[&Fixture],
477    class_name: &str,
478    function_name: &str,
479    kotlin_pkg_id: &str,
480    result_var: &str,
481    args: &[crate::config::ArgMapping],
482    options_type: Option<&str>,
483    field_resolver: &FieldResolver,
484    result_is_simple: bool,
485    enum_fields: &HashSet<String>,
486    e2e_config: &E2eConfig,
487    type_enum_fields: &std::collections::HashMap<String, HashSet<String>>,
488) -> String {
489    render_test_file_inner(
490        category,
491        fixtures,
492        class_name,
493        function_name,
494        kotlin_pkg_id,
495        result_var,
496        args,
497        options_type,
498        field_resolver,
499        result_is_simple,
500        enum_fields,
501        e2e_config,
502        type_enum_fields,
503        false,
504    )
505}
506
507/// Variant of [`render_test_file`] used by the kotlin_android backend.
508///
509/// `kotlin_android_style = true` shifts two emission decisions:
510///
511/// 1. Every emitted `@Test` body is wrapped in `runBlocking { ... }` so the
512///    suspend-only public API (the kotlin_android AAR exposes most
513///    extraction entry points as `suspend fun`) can be invoked from
514///    JUnit's non-suspend `@Test` methods. JVM Kotlin tests keep the
515///    previous behaviour and only wrap when a `client_factory` is in play.
516/// 2. Option-returning APIs are treated as Kotlin nullable `T?` (the
517///    kotlin-android wrapper unwraps Java `Optional<T>` to `T?` at the
518///    boundary), so `is_empty` / `not_empty` assertions on a bare option
519///    result emit `== null` / `!= null` instead of `.isEmpty` /
520///    `.isPresent`.
521#[allow(clippy::too_many_arguments)]
522pub(crate) fn render_test_file_android(
523    category: &str,
524    fixtures: &[&Fixture],
525    class_name: &str,
526    function_name: &str,
527    kotlin_pkg_id: &str,
528    result_var: &str,
529    args: &[crate::config::ArgMapping],
530    options_type: Option<&str>,
531    field_resolver: &FieldResolver,
532    result_is_simple: bool,
533    enum_fields: &HashSet<String>,
534    e2e_config: &E2eConfig,
535    type_enum_fields: &std::collections::HashMap<String, HashSet<String>>,
536) -> String {
537    render_test_file_inner(
538        category,
539        fixtures,
540        class_name,
541        function_name,
542        kotlin_pkg_id,
543        result_var,
544        args,
545        options_type,
546        field_resolver,
547        result_is_simple,
548        enum_fields,
549        e2e_config,
550        type_enum_fields,
551        true,
552    )
553}
554
555#[allow(clippy::too_many_arguments)]
556fn render_test_file_inner(
557    category: &str,
558    fixtures: &[&Fixture],
559    class_name: &str,
560    function_name: &str,
561    kotlin_pkg_id: &str,
562    result_var: &str,
563    args: &[crate::config::ArgMapping],
564    options_type: Option<&str>,
565    field_resolver: &FieldResolver,
566    result_is_simple: bool,
567    enum_fields: &HashSet<String>,
568    e2e_config: &E2eConfig,
569    type_enum_fields: &std::collections::HashMap<String, HashSet<String>>,
570    kotlin_android_style: bool,
571) -> String {
572    let mut out = String::new();
573    out.push_str(&hash::header(CommentStyle::DoubleSlash));
574    let test_class_name = format!("{}Test", sanitize_filename(category).to_upper_camel_case());
575
576    // If the class_name is fully qualified (contains '.'), import it and use
577    // only the simple name for method calls. Otherwise use it as-is.
578    let (import_path, simple_class) = if class_name.contains('.') {
579        let simple = class_name.rsplit('.').next().unwrap_or(class_name);
580        (class_name, simple)
581    } else {
582        ("", class_name)
583    };
584
585    let _ = writeln!(out, "package {kotlin_pkg_id}.e2e");
586    let _ = writeln!(out);
587
588    // Detect if any fixture in this group is an HTTP server test.
589    let has_http_fixtures = fixtures.iter().any(|f| f.is_http_test());
590
591    // Detect if any non-HTTP fixture uses a client_factory (coroutine-based client).
592    // When true, test functions must use `= runBlocking { ... }` to call suspend fns.
593    let has_client_factory_fixtures = fixtures.iter().any(|f| {
594        if f.is_http_test() {
595            return false;
596        }
597        let cc = e2e_config.resolve_call_for_fixture(f.call.as_deref(), &f.input);
598        let per_call_factory = cc.overrides.get("kotlin").and_then(|o| o.client_factory.as_deref());
599        let global_factory = e2e_config
600            .call
601            .overrides
602            .get("kotlin")
603            .and_then(|o| o.client_factory.as_deref());
604        per_call_factory.or(global_factory).is_some()
605    });
606
607    // Collect every (per-call) options_type referenced by fixtures in this file.
608    // Per-call kotlin overrides win over the file-level options_type passed in.
609    // Each entry is a json_object arg's options_type — we need to import each one.
610    let mut per_fixture_options_types: HashSet<String> = HashSet::new();
611    for f in fixtures.iter() {
612        let cc = e2e_config.resolve_call_for_fixture(f.call.as_deref(), &f.input);
613        let call_overrides = cc.overrides.get("kotlin");
614        let effective_opts: Option<String> = call_overrides
615            .and_then(|o| o.options_type.clone())
616            .or_else(|| options_type.map(|s| s.to_string()))
617            .or_else(|| {
618                for cand in ["csharp", "c", "go", "php", "python"] {
619                    if let Some(o) = cc.overrides.get(cand) {
620                        if let Some(t) = &o.options_type {
621                            return Some(t.clone());
622                        }
623                    }
624                }
625                None
626            });
627        if let Some(opts) = effective_opts {
628            // Prefer the per-call args (which carry the correct arg_type + field for the
629            // resolved call); fall back to the file-level args only when the call has none.
630            let fixture_args = if cc.args.is_empty() { args } else { cc.args.as_slice() };
631            // Import the options type if the fixture either supplies a json_object value
632            // (deserialised via ObjectMapper) OR has an *optional* json_object arg with
633            // no value — the generator emits `OptionsType.builder().build()` in that
634            // case to keep the call arity correct.
635            let needs_opts_type = fixture_args.iter().any(|arg| {
636                if arg.arg_type != "json_object" {
637                    return false;
638                }
639                let v = super::resolve_field(&f.input, &arg.field);
640                !v.is_null() || arg.optional
641            });
642            if needs_opts_type {
643                per_fixture_options_types.insert(opts.to_string());
644            }
645        }
646    }
647    let needs_object_mapper_for_options = !per_fixture_options_types.is_empty();
648    // Also need ObjectMapper when a handle arg has a non-null config.
649    let needs_object_mapper_for_handle = fixtures.iter().any(|f| {
650        args.iter().filter(|a| a.arg_type == "handle").any(|a| {
651            let v = f.input.get(&a.field).unwrap_or(&serde_json::Value::Null);
652            !(v.is_null() || v.is_object() && v.as_object().is_some_and(|o| o.is_empty()))
653        })
654    });
655    // HTTP fixtures always need ObjectMapper for JSON body comparison.
656    let needs_object_mapper = needs_object_mapper_for_options || needs_object_mapper_for_handle || has_http_fixtures;
657
658    let _ = writeln!(out, "import org.junit.jupiter.api.Test");
659    let _ = writeln!(out, "import kotlin.test.assertEquals");
660    let _ = writeln!(out, "import kotlin.test.assertTrue");
661    let _ = writeln!(out, "import kotlin.test.assertFalse");
662    let _ = writeln!(out, "import kotlin.test.assertFailsWith");
663    if has_client_factory_fixtures || kotlin_android_style {
664        let _ = writeln!(out, "import kotlinx.coroutines.runBlocking");
665    }
666    // Effective binding package for FQN imports. When the binding `class_name` is
667    // not fully-qualified, fall back to `kotlin_pkg_id` — the kotlin binding emits
668    // top-level typealiases at that package (e.g. `package com.github.kreuzberg_dev`)
669    // while the test files live at `<kotlin_pkg_id>.e2e`. Child packages do NOT
670    // import their parent's symbols implicitly, so explicit imports are required.
671    let binding_pkg_for_imports: String = if !import_path.is_empty() {
672        import_path
673            .rsplit_once('.')
674            .map(|(p, _)| p.to_string())
675            .unwrap_or_else(|| kotlin_pkg_id.to_string())
676    } else {
677        kotlin_pkg_id.to_string()
678    };
679    // Only import the binding class when there are non-HTTP fixtures that call it.
680    let has_call_fixtures = fixtures.iter().any(|f| !f.is_http_test());
681    if has_call_fixtures {
682        if !import_path.is_empty() {
683            let _ = writeln!(out, "import {import_path}");
684        } else if !class_name.is_empty() {
685            let _ = writeln!(out, "import {binding_pkg_for_imports}.{class_name}");
686        }
687    }
688    if needs_object_mapper {
689        let _ = writeln!(out, "import com.fasterxml.jackson.databind.ObjectMapper");
690        let _ = writeln!(out, "import com.fasterxml.jackson.datatype.jdk8.Jdk8Module");
691    }
692    // Import every options type referenced by per-call kotlin overrides in this file.
693    // Options-type imports are needed for both ObjectMapper deserialisation and for
694    // optional-arg defaults emitted as `OptionsType.builder().build()`.
695    if has_call_fixtures {
696        let mut sorted_opts: Vec<&String> = per_fixture_options_types.iter().collect();
697        sorted_opts.sort();
698        for opts_type in sorted_opts {
699            let _ = writeln!(out, "import {binding_pkg_for_imports}.{opts_type}");
700        }
701    }
702    // Import CrawlConfig when handle args need JSON deserialization.
703    if needs_object_mapper_for_handle {
704        let _ = writeln!(out, "import {binding_pkg_for_imports}.CrawlConfig");
705    }
706    // Import BatchBytesItem / BatchFileItem when any fixture has a batch-item
707    // array arg (element_type) — the test code constructs these directly.
708    let mut batch_elem_imports: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
709    for f in fixtures.iter() {
710        let cc = e2e_config.resolve_call_for_fixture(f.call.as_deref(), &f.input);
711        let fixture_args = if cc.args.is_empty() { args } else { cc.args.as_slice() };
712        for arg in fixture_args.iter() {
713            if arg.arg_type != "json_object" {
714                continue;
715            }
716            let v = super::resolve_field(&f.input, &arg.field);
717            if !v.is_array() {
718                continue;
719            }
720            if let Some(elem) = &arg.element_type {
721                if elem == "BatchBytesItem" || elem == "BatchFileItem" {
722                    batch_elem_imports.insert(elem.clone());
723                }
724            }
725        }
726    }
727    for elem in &batch_elem_imports {
728        let _ = writeln!(out, "import {binding_pkg_for_imports}.{elem}");
729    }
730    let _ = writeln!(out);
731
732    let _ = writeln!(out, "/** E2e tests for category: {category}. */");
733    let _ = writeln!(out, "class {test_class_name} {{");
734
735    if needs_object_mapper {
736        let _ = writeln!(out);
737        let _ = writeln!(out, "    companion object {{");
738        let _ = writeln!(
739            out,
740            "        private val MAPPER = ObjectMapper().registerModule(Jdk8Module()).setPropertyNamingStrategy(com.fasterxml.jackson.databind.PropertyNamingStrategies.SNAKE_CASE)"
741        );
742        let _ = writeln!(out, "    }}");
743    }
744
745    for fixture in fixtures {
746        render_test_method(
747            &mut out,
748            fixture,
749            simple_class,
750            function_name,
751            result_var,
752            args,
753            options_type,
754            field_resolver,
755            result_is_simple,
756            enum_fields,
757            e2e_config,
758            type_enum_fields,
759            kotlin_android_style,
760        );
761        let _ = writeln!(out);
762    }
763
764    let _ = writeln!(out, "}}");
765    out
766}
767
768// ---------------------------------------------------------------------------
769// HTTP server test rendering — TestClientRenderer impl + thin driver wrapper
770// ---------------------------------------------------------------------------
771
772/// Renderer that emits JUnit 5 `@Test fun testFoo()` blocks using
773/// `java.net.http.HttpClient` against `System.getenv("MOCK_SERVER_URL")`.
774pub(crate) struct KotlinTestClientRenderer;
775
776impl client::TestClientRenderer for KotlinTestClientRenderer {
777    fn language_name(&self) -> &'static str {
778        "kotlin"
779    }
780
781    fn sanitize_test_name(&self, id: &str) -> String {
782        sanitize_ident(id).to_upper_camel_case()
783    }
784
785    fn render_test_open(&self, out: &mut String, fn_name: &str, description: &str, skip_reason: Option<&str>) {
786        let _ = writeln!(out, "    @Test");
787        let _ = writeln!(out, "    fun test{fn_name}() {{");
788        let _ = writeln!(out, "        // {description}");
789        if let Some(reason) = skip_reason {
790            let escaped = escape_kotlin(reason);
791            let _ = writeln!(
792                out,
793                "        org.junit.jupiter.api.Assumptions.assumeTrue(false, \"{escaped}\")"
794            );
795        }
796    }
797
798    fn render_test_close(&self, out: &mut String) {
799        let _ = writeln!(out, "    }}");
800    }
801
802    fn render_call(&self, out: &mut String, ctx: &client::CallCtx<'_>) {
803        let method = ctx.method.to_uppercase();
804        let fixture_path = ctx.path;
805
806        // Java's HttpClient restricts certain headers that cannot be set programmatically.
807        const JAVA_RESTRICTED_HEADERS: &[&str] = &["connection", "content-length", "expect", "host", "upgrade"];
808
809        let _ = writeln!(
810            out,
811            "        val baseUrl = System.getenv(\"MOCK_SERVER_URL\") ?: \"http://localhost:8080\""
812        );
813        let _ = writeln!(out, "        val uri = java.net.URI.create(\"$baseUrl{fixture_path}\")");
814
815        let body_publisher = if let Some(body) = ctx.body {
816            let json = serde_json::to_string(body).unwrap_or_default();
817            let escaped = escape_kotlin(&json);
818            format!("java.net.http.HttpRequest.BodyPublishers.ofString(\"{escaped}\")")
819        } else {
820            "java.net.http.HttpRequest.BodyPublishers.noBody()".to_string()
821        };
822
823        let _ = writeln!(out, "        val builder = java.net.http.HttpRequest.newBuilder(uri)");
824        let _ = writeln!(out, "            .method(\"{method}\", {body_publisher})");
825
826        // Content-Type header when there is a body.
827        if ctx.body.is_some() {
828            let content_type = ctx.content_type.unwrap_or("application/json");
829            let _ = writeln!(out, "            .header(\"Content-Type\", \"{content_type}\")");
830        }
831
832        // Explicit request headers (sorted for deterministic output).
833        let mut header_pairs: Vec<(&String, &String)> = ctx.headers.iter().collect();
834        header_pairs.sort_by_key(|(k, _)| k.as_str());
835        for (name, value) in &header_pairs {
836            if JAVA_RESTRICTED_HEADERS.contains(&name.to_lowercase().as_str()) {
837                continue;
838            }
839            let escaped_name = escape_kotlin(name);
840            let escaped_value = escape_kotlin(value);
841            let _ = writeln!(out, "            .header(\"{escaped_name}\", \"{escaped_value}\")");
842        }
843
844        // Cookies as a single Cookie header.
845        if !ctx.cookies.is_empty() {
846            let mut cookie_pairs: Vec<(&String, &String)> = ctx.cookies.iter().collect();
847            cookie_pairs.sort_by_key(|(k, _)| k.as_str());
848            let cookie_str: Vec<String> = cookie_pairs.iter().map(|(k, v)| format!("{k}={v}")).collect();
849            let cookie_header = escape_kotlin(&cookie_str.join("; "));
850            let _ = writeln!(out, "            .header(\"Cookie\", \"{cookie_header}\")");
851        }
852
853        let _ = writeln!(
854            out,
855            "        val {} = java.net.http.HttpClient.newHttpClient()",
856            ctx.response_var
857        );
858        let _ = writeln!(
859            out,
860            "            .send(builder.build(), java.net.http.HttpResponse.BodyHandlers.ofString())"
861        );
862    }
863
864    fn render_assert_status(&self, out: &mut String, response_var: &str, status: u16) {
865        let _ = writeln!(
866            out,
867            "        assertEquals({status}, {response_var}.statusCode(), \"status code mismatch\")"
868        );
869    }
870
871    fn render_assert_header(&self, out: &mut String, response_var: &str, name: &str, expected: &str) {
872        let escaped_name = escape_kotlin(name);
873        match expected {
874            "<<present>>" => {
875                let _ = writeln!(
876                    out,
877                    "        assertTrue({response_var}.headers().firstValue(\"{escaped_name}\").isPresent, \"header {escaped_name} should be present\")"
878                );
879            }
880            "<<absent>>" => {
881                let _ = writeln!(
882                    out,
883                    "        assertFalse({response_var}.headers().firstValue(\"{escaped_name}\").isPresent, \"header {escaped_name} should be absent\")"
884                );
885            }
886            "<<uuid>>" => {
887                let _ = writeln!(
888                    out,
889                    "        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\")"
890                );
891            }
892            exact => {
893                let escaped_value = escape_kotlin(exact);
894                let _ = writeln!(
895                    out,
896                    "        assertTrue({response_var}.headers().firstValue(\"{escaped_name}\").orElse(\"\").contains(\"{escaped_value}\"), \"header {escaped_name} mismatch\")"
897                );
898            }
899        }
900    }
901
902    fn render_assert_json_body(&self, out: &mut String, response_var: &str, expected: &serde_json::Value) {
903        match expected {
904            serde_json::Value::Object(_) | serde_json::Value::Array(_) => {
905                let json_str = serde_json::to_string(expected).unwrap_or_default();
906                let escaped = escape_kotlin(&json_str);
907                let _ = writeln!(out, "        val bodyJson = MAPPER.readTree({response_var}.body())");
908                let _ = writeln!(out, "        val expectedJson = MAPPER.readTree(\"{escaped}\")");
909                let _ = writeln!(out, "        assertEquals(expectedJson, bodyJson, \"body mismatch\")");
910            }
911            serde_json::Value::String(s) => {
912                let escaped = escape_kotlin(s);
913                let _ = writeln!(
914                    out,
915                    "        assertEquals(\"{escaped}\", {response_var}.body().trim(), \"body mismatch\")"
916                );
917            }
918            other => {
919                let escaped = escape_kotlin(&other.to_string());
920                let _ = writeln!(
921                    out,
922                    "        assertEquals(\"{escaped}\", {response_var}.body().trim(), \"body mismatch\")"
923                );
924            }
925        }
926    }
927
928    fn render_assert_partial_body(&self, out: &mut String, response_var: &str, expected: &serde_json::Value) {
929        if let Some(obj) = expected.as_object() {
930            let _ = writeln!(out, "        val _partialTree = MAPPER.readTree({response_var}.body())");
931            for (key, val) in obj {
932                let escaped_key = escape_kotlin(key);
933                match val {
934                    serde_json::Value::String(s) => {
935                        let escaped_val = escape_kotlin(s);
936                        let _ = writeln!(
937                            out,
938                            "        assertEquals(\"{escaped_val}\", _partialTree.path(\"{escaped_key}\").asText(), \"partial body field '{escaped_key}' mismatch\")"
939                        );
940                    }
941                    serde_json::Value::Bool(b) => {
942                        let _ = writeln!(
943                            out,
944                            "        assertEquals({b}, _partialTree.path(\"{escaped_key}\").asBoolean(), \"partial body field '{escaped_key}' mismatch\")"
945                        );
946                    }
947                    serde_json::Value::Number(n) => {
948                        let _ = writeln!(
949                            out,
950                            "        assertEquals({n}, _partialTree.path(\"{escaped_key}\").numberValue(), \"partial body field '{escaped_key}' mismatch\")"
951                        );
952                    }
953                    other => {
954                        let json_str = serde_json::to_string(other).unwrap_or_default();
955                        let escaped_val = escape_kotlin(&json_str);
956                        let _ = writeln!(
957                            out,
958                            "        assertEquals(MAPPER.readTree(\"{escaped_val}\"), _partialTree.path(\"{escaped_key}\"), \"partial body field '{escaped_key}' mismatch\")"
959                        );
960                    }
961                }
962            }
963        }
964    }
965
966    fn render_assert_validation_errors(
967        &self,
968        out: &mut String,
969        response_var: &str,
970        errors: &[ValidationErrorExpectation],
971    ) {
972        let _ = writeln!(out, "        val _veTree = MAPPER.readTree({response_var}.body())");
973        let _ = writeln!(out, "        val _veErrors = _veTree.path(\"errors\")");
974        for ve in errors {
975            let escaped_msg = escape_kotlin(&ve.msg);
976            let _ = writeln!(
977                out,
978                "        assertTrue((0 until _veErrors.size()).any {{ _veErrors.get(it).path(\"msg\").asText().contains(\"{escaped_msg}\") }}, \"expected validation error containing: {escaped_msg}\")"
979            );
980        }
981    }
982}
983
984/// Render a JUnit 5 `@Test` method for an HTTP server fixture via the shared driver.
985///
986/// HTTP 101 (WebSocket upgrade) is emitted as a skip stub because Java's
987/// `HttpClient` cannot handle protocol-switch responses (throws `EOFException`).
988fn render_http_test_method(out: &mut String, fixture: &Fixture, http: &HttpFixture) {
989    // HTTP 101 (WebSocket upgrade) — java.net.http.HttpClient cannot handle upgrade responses.
990    if http.expected_response.status_code == 101 {
991        let method_name = sanitize_ident(&fixture.id).to_upper_camel_case();
992        let description = &fixture.description;
993        let _ = writeln!(out, "    @Test");
994        let _ = writeln!(out, "    fun test{method_name}() {{");
995        let _ = writeln!(out, "        // {description}");
996        let _ = writeln!(
997            out,
998            "        org.junit.jupiter.api.Assumptions.assumeTrue(false, \"Skipped: Java HttpClient cannot handle 101 Switching Protocols responses\")"
999        );
1000        let _ = writeln!(out, "    }}");
1001        return;
1002    }
1003
1004    client::http_call::render_http_test(out, &KotlinTestClientRenderer, fixture);
1005}
1006
1007#[allow(clippy::too_many_arguments)]
1008fn render_test_method(
1009    out: &mut String,
1010    fixture: &Fixture,
1011    class_name: &str,
1012    _function_name: &str,
1013    _result_var: &str,
1014    _args: &[crate::config::ArgMapping],
1015    options_type: Option<&str>,
1016    field_resolver: &FieldResolver,
1017    result_is_simple: bool,
1018    enum_fields: &HashSet<String>,
1019    e2e_config: &E2eConfig,
1020    type_enum_fields: &std::collections::HashMap<String, HashSet<String>>,
1021    kotlin_android_style: bool,
1022) {
1023    // Delegate HTTP fixtures to the HTTP-specific renderer.
1024    if let Some(http) = &fixture.http {
1025        render_http_test_method(out, fixture, http);
1026        return;
1027    }
1028
1029    // Resolve per-fixture call config (supports named calls via fixture.call field).
1030    let call_config = e2e_config.resolve_call_for_fixture(fixture.call.as_deref(), &fixture.input);
1031    let lang = "kotlin";
1032    let call_overrides = call_config.overrides.get(lang);
1033
1034    // Check for client_factory — when set, use instance-method call style.
1035    // Falls back to the global `[e2e.call.overrides.kotlin]` `client_factory` when
1036    // a per-call override is absent, matching the dart/swift renderers.
1037    let client_factory = call_overrides.and_then(|o| o.client_factory.as_deref()).or_else(|| {
1038        e2e_config
1039            .call
1040            .overrides
1041            .get(lang)
1042            .and_then(|o| o.client_factory.as_deref())
1043    });
1044
1045    let effective_function_name = call_overrides
1046        .and_then(|o| o.function.as_ref())
1047        .cloned()
1048        .unwrap_or_else(|| call_config.function.to_lower_camel_case());
1049    let effective_result_var = &call_config.result_var;
1050    let effective_args = &call_config.args;
1051    let function_name = effective_function_name.as_str();
1052    let result_var = effective_result_var.as_str();
1053    let args: &[crate::config::ArgMapping] = effective_args.as_slice();
1054    // Resolve per-fixture options_type: prefer the kotlin call override, fall back
1055    // to class-level, then to any other language's options_type for the same call.
1056    // The Kotlin module re-exports Java facade types unchanged, so a type name declared
1057    // by csharp/c/go/php/python applies equally to Kotlin without an explicit override.
1058    let effective_options_type: Option<String> = call_overrides
1059        .and_then(|o| o.options_type.clone())
1060        .or_else(|| options_type.map(|s| s.to_string()))
1061        .or_else(|| {
1062            for cand in ["csharp", "c", "go", "php", "python"] {
1063                if let Some(o) = call_config.overrides.get(cand) {
1064                    if let Some(t) = &o.options_type {
1065                        return Some(t.clone());
1066                    }
1067                }
1068            }
1069            None
1070        });
1071    let options_type = effective_options_type.as_deref();
1072
1073    // Resolve per-fixture result_is_simple: prefer the kotlin override, then the
1074    // class-level default, then any sibling language override (java/csharp/go).
1075    // The Kotlin facade shares its return-type shape with the Java facade, so a
1076    // declaration in any of those bindings applies to Kotlin too.
1077    let effective_result_is_simple = call_overrides.is_some_and(|o| o.result_is_simple)
1078        || call_config.result_is_simple
1079        || result_is_simple
1080        || ["java", "csharp", "go"]
1081            .iter()
1082            .any(|cand| call_config.overrides.get(*cand).is_some_and(|o| o.result_is_simple));
1083    let result_is_simple = effective_result_is_simple;
1084
1085    // Resolve per-fixture result_is_option: prefer the kotlin override, then the
1086    // call-level default. When set the function returns `T?` and bare-result
1087    // emptiness assertions must use a null-check instead of `.isEmpty()`.
1088    let result_is_option = call_overrides.is_some_and(|o| o.result_is_option) || call_config.result_is_option;
1089
1090    let method_name = fixture.id.to_upper_camel_case();
1091    let description = &fixture.description;
1092    let expects_error = fixture.assertions.iter().any(|a| a.assertion_type == "error");
1093
1094    // Streaming detection (call-level `streaming` opt-out is honored).
1095    let is_streaming = crate::codegen::streaming_assertions::resolve_is_streaming(fixture, call_config.streaming);
1096    let collect_snippet = if is_streaming && !expects_error {
1097        crate::codegen::streaming_assertions::StreamingFieldResolver::collect_snippet("kotlin", result_var, "chunks")
1098            .unwrap_or_default()
1099    } else {
1100        String::new()
1101    };
1102
1103    // Check if this test needs ObjectMapper deserialization for json_object args.
1104    // Uses `resolve_field` so that `field = "input"` resolves to the whole fixture
1105    // input (and not a nested key called "input"), matching dart/swift behavior.
1106    let needs_deser = options_type.is_some()
1107        && args
1108            .iter()
1109            .any(|arg| arg.arg_type == "json_object" && !super::resolve_field(&fixture.input, &arg.field).is_null());
1110
1111    // Merge per-call kotlin enum_fields (HashMap key = field path, value = enum type name)
1112    // into the global fields_enum set so that call-specific enum-typed result fields
1113    // (e.g. `status` on BatchObject) route through `.getValue()` in assertions even
1114    // when absent from the global `fields_enum` list.  Mirrors the Java codegen at
1115    // codegen/java.rs where per-call overrides are merged before assertion rendering.
1116    //
1117    // Additionally, auto-detect enum-typed fields by looking up the call's result type
1118    // in `type_enum_fields` (built from the IR TypeDef list). This handles the common
1119    // case where a field's Rust type is a `Named(EnumName)` that was never explicitly
1120    // listed in the alef.toml `enum_fields` table.
1121    let effective_enum_fields: std::borrow::Cow<HashSet<String>> = {
1122        // Resolve the result type name for this call. Prefer the kotlin override, then
1123        // java, then c — the Kotlin facade re-exports Java facade types unchanged.
1124        let result_type_name: Option<&str> = call_overrides
1125            .and_then(|co| co.result_type.as_deref())
1126            .or_else(|| call_config.overrides.get("java").and_then(|o| o.result_type.as_deref()))
1127            .or_else(|| call_config.overrides.get("c").and_then(|o| o.result_type.as_deref()));
1128        let auto_enum_fields: Option<&HashSet<String>> = result_type_name.and_then(|name| type_enum_fields.get(name));
1129        let has_per_call = call_overrides.is_some_and(|co| !co.enum_fields.is_empty());
1130        let has_auto = auto_enum_fields.is_some_and(|f| !f.is_empty());
1131        if has_per_call || has_auto {
1132            let mut merged = enum_fields.clone();
1133            if let Some(co) = call_overrides {
1134                merged.extend(co.enum_fields.keys().cloned());
1135            }
1136            if let Some(auto_fields) = auto_enum_fields {
1137                merged.extend(auto_fields.iter().cloned());
1138            }
1139            std::borrow::Cow::Owned(merged)
1140        } else {
1141            std::borrow::Cow::Borrowed(enum_fields)
1142        }
1143    };
1144    let enum_fields: &HashSet<String> = &effective_enum_fields;
1145
1146    let _ = writeln!(out, "    @Test");
1147    if client_factory.is_some() || kotlin_android_style {
1148        let _ = writeln!(out, "    fun test{method_name}() = runBlocking {{");
1149    } else {
1150        let _ = writeln!(out, "    fun test{method_name}() {{");
1151    }
1152    let _ = writeln!(out, "        // {description}");
1153
1154    // Emit ObjectMapper deserialization bindings for json_object args.
1155    // Object args use the configured `options_type`. Array args carrying
1156    // `element_type = BatchBytesItem | BatchFileItem` are emitted as inline
1157    // List<T> constructors below (build_args_and_setup) — no deser binding is
1158    // needed because the array is materialised directly in source.
1159    if needs_deser {
1160        for arg in args {
1161            if arg.arg_type != "json_object" {
1162                continue;
1163            }
1164            let val = super::resolve_field(&fixture.input, &arg.field);
1165            if val.is_null() {
1166                continue;
1167            }
1168            // Skip arrays that we materialise inline (batch items + primitive
1169            // lists like List<String>) rather than deserialising via Jackson.
1170            if val.is_array() && arg.element_type.is_some() {
1171                continue;
1172            }
1173            let Some(opts_type) = options_type else { continue };
1174            let normalized = super::transform_json_keys_for_language(val, "snake_case");
1175            let json_str = serde_json::to_string(&normalized).unwrap_or_default();
1176            let var_name = &arg.name;
1177            let _ = writeln!(
1178                out,
1179                "        val {var_name} = MAPPER.readValue(\"{}\", {opts_type}::class.java)",
1180                escape_kotlin(&json_str)
1181            );
1182        }
1183    }
1184
1185    let (setup_lines, args_str) =
1186        build_args_and_setup(fixture, &fixture.input, args, class_name, options_type, &fixture.id);
1187
1188    // When client_factory is set, emit client-object instantiation + instance method call.
1189    // The factory name is a function on the Kotlin facade object (e.g. `LiterLlm.createClient`)
1190    // that constructs the coroutine-friendly Kotlin client wrapper from the
1191    // raw apiKey + baseUrl pair the test owns.
1192    if let Some(factory) = client_factory {
1193        let fixture_id = &fixture.id;
1194        // Prefer system properties set by MockServerListener (which spawns the
1195        // mock-server in-process when MOCK_SERVER_URL isn't pre-set). The
1196        // per-fixture property holds the full URL; fall back to the base URL
1197        // (mockServerUrl or env var) with the /fixtures/<id> suffix appended.
1198        let mock_url_expr = format!(
1199            "System.getProperty(\"mockServer.{fixture_id}\", System.getProperty(\"mockServerUrl\", System.getenv(\"MOCK_SERVER_URL\") ?: \"\") + \"/fixtures/{fixture_id}\")"
1200        );
1201        for line in &setup_lines {
1202            let _ = writeln!(out, "        {line}");
1203        }
1204        let _ = writeln!(
1205            out,
1206            "        val client = {class_name}.{factory}(apiKey = \"test-key\", baseUrl = {mock_url_expr})"
1207        );
1208        if expects_error {
1209            let _ = writeln!(out, "        assertFailsWith<Exception> {{");
1210            let _ = writeln!(out, "            client.{function_name}({args_str})");
1211            let _ = writeln!(out, "        }}");
1212            let _ = writeln!(out, "        client.close()");
1213            let _ = writeln!(out, "    }}");
1214            return;
1215        }
1216        let _ = writeln!(out, "        val {result_var} = client.{function_name}({args_str})");
1217        if !collect_snippet.is_empty() {
1218            let _ = writeln!(out, "        {collect_snippet}");
1219        }
1220        for assertion in &fixture.assertions {
1221            render_assertion(
1222                out,
1223                assertion,
1224                result_var,
1225                class_name,
1226                field_resolver,
1227                result_is_simple,
1228                result_is_option,
1229                enum_fields,
1230                &e2e_config.fields_c_types,
1231                is_streaming,
1232                kotlin_android_style,
1233            );
1234        }
1235        let _ = writeln!(out, "        client.close()");
1236        let _ = writeln!(out, "    }}");
1237        return;
1238    }
1239
1240    // Flat-function call style (no client_factory).
1241    if expects_error {
1242        // Wrap setup + call in assertFailsWith so validation errors thrown
1243        // during engine creation are also caught (mirrors Java's assertThrows).
1244        let _ = writeln!(out, "        assertFailsWith<Exception> {{");
1245        for line in &setup_lines {
1246            let _ = writeln!(out, "            {line}");
1247        }
1248        let _ = writeln!(out, "            {class_name}.{function_name}({args_str})");
1249        let _ = writeln!(out, "        }}");
1250        let _ = writeln!(out, "    }}");
1251        return;
1252    }
1253
1254    for line in &setup_lines {
1255        let _ = writeln!(out, "        {line}");
1256    }
1257
1258    let _ = writeln!(
1259        out,
1260        "        val {result_var} = {class_name}.{function_name}({args_str})"
1261    );
1262
1263    if !collect_snippet.is_empty() {
1264        let _ = writeln!(out, "        {collect_snippet}");
1265    }
1266
1267    for assertion in &fixture.assertions {
1268        render_assertion(
1269            out,
1270            assertion,
1271            result_var,
1272            class_name,
1273            field_resolver,
1274            result_is_simple,
1275            result_is_option,
1276            enum_fields,
1277            &e2e_config.fields_c_types,
1278            is_streaming,
1279            kotlin_android_style,
1280        );
1281    }
1282
1283    let _ = writeln!(out, "    }}");
1284}
1285
1286/// Build setup lines and the argument list for the function call.
1287///
1288/// Returns `(setup_lines, args_string)`.
1289fn build_args_and_setup(
1290    fixture: &Fixture,
1291    input: &serde_json::Value,
1292    args: &[crate::config::ArgMapping],
1293    class_name: &str,
1294    options_type: Option<&str>,
1295    fixture_id: &str,
1296) -> (Vec<String>, String) {
1297    if args.is_empty() {
1298        return (Vec::new(), String::new());
1299    }
1300
1301    let mut setup_lines: Vec<String> = Vec::new();
1302    let mut parts: Vec<String> = Vec::new();
1303
1304    for arg in args {
1305        if arg.arg_type == "mock_url" {
1306            if fixture.has_host_root_route() {
1307                setup_lines.push(format!(
1308                    "val {} = System.getProperty(\"mockServer.{fixture_id}\", System.getProperty(\"mockServerUrl\", System.getenv(\"MOCK_SERVER_URL\")) + \"/fixtures/{fixture_id}\")",
1309                    arg.name,
1310                ));
1311            } else {
1312                setup_lines.push(format!(
1313                    "val {} = System.getProperty(\"mockServerUrl\", System.getenv(\"MOCK_SERVER_URL\")) + \"/fixtures/{fixture_id}\"",
1314                    arg.name,
1315                ));
1316            }
1317            parts.push(arg.name.clone());
1318            continue;
1319        }
1320
1321        if arg.arg_type == "handle" {
1322            let constructor_name = format!("create{}", arg.name.to_upper_camel_case());
1323            let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
1324            let config_value = input.get(field).unwrap_or(&serde_json::Value::Null);
1325            if config_value.is_null()
1326                || config_value.is_object() && config_value.as_object().is_some_and(|o| o.is_empty())
1327            {
1328                setup_lines.push(format!("val {} = {class_name}.{constructor_name}(null)", arg.name,));
1329            } else {
1330                let json_str = serde_json::to_string(config_value).unwrap_or_default();
1331                let name = &arg.name;
1332                setup_lines.push(format!(
1333                    "val {name}Config = MAPPER.readValue(\"{}\", CrawlConfig::class.java)",
1334                    escape_kotlin(&json_str),
1335                ));
1336                setup_lines.push(format!(
1337                    "val {} = {class_name}.{constructor_name}({name}Config)",
1338                    arg.name,
1339                    name = name,
1340                ));
1341            }
1342            parts.push(arg.name.clone());
1343            continue;
1344        }
1345
1346        // Use resolve_field so field = "input" resolves to the whole fixture input.
1347        let val_resolved = super::resolve_field(input, &arg.field);
1348        let val: Option<&serde_json::Value> = if val_resolved.is_null() {
1349            None
1350        } else {
1351            Some(val_resolved)
1352        };
1353        match val {
1354            None | Some(serde_json::Value::Null) if arg.optional => {
1355                // Optional arg with no fixture value: emit positional default so the
1356                // call has the right arity for the Java facade. For json_object
1357                // optional args with a configured options_type, construct an empty
1358                // default builder instead of passing raw null.
1359                if arg.arg_type == "json_object" {
1360                    if let Some(opts_type) = options_type {
1361                        parts.push(format!("{opts_type}.builder().build()"));
1362                    } else {
1363                        parts.push("null".to_string());
1364                    }
1365                } else {
1366                    parts.push("null".to_string());
1367                }
1368            }
1369            None | Some(serde_json::Value::Null) => {
1370                let default_val = match arg.arg_type.as_str() {
1371                    "string" => "\"\"".to_string(),
1372                    "int" | "integer" => "0".to_string(),
1373                    "float" | "number" => "0.0".to_string(),
1374                    "bool" | "boolean" => "false".to_string(),
1375                    _ => "null".to_string(),
1376                };
1377                parts.push(default_val);
1378            }
1379            Some(v) => {
1380                // Typed arrays carry `element_type`. Batch item arrays
1381                // (BatchBytesItem/BatchFileItem) need typed constructors; all
1382                // other typed lists (e.g. List<String>) are materialised as a
1383                // plain `listOf(...)` of the JSON literals.
1384                if arg.arg_type == "json_object" && v.is_array() {
1385                    if let Some(elem) = &arg.element_type {
1386                        if elem == "BatchBytesItem" || elem == "BatchFileItem" {
1387                            parts.push(emit_kotlin_batch_item_array(v, elem));
1388                            continue;
1389                        }
1390                        // Generic typed list — emit literal Kotlin `listOf(...)`.
1391                        let items: Vec<String> = v
1392                            .as_array()
1393                            .map(|arr| arr.iter().map(json_to_kotlin).collect())
1394                            .unwrap_or_default();
1395                        parts.push(format!("listOf({})", items.join(", ")));
1396                        continue;
1397                    }
1398                }
1399                // For json_object args with options_type, use the pre-deserialized variable.
1400                if arg.arg_type == "json_object" && options_type.is_some() {
1401                    parts.push(arg.name.clone());
1402                    continue;
1403                }
1404                // bytes args carry a relative file path (e.g. "docx/fake.docx") that the
1405                // e2e harness resolves against test_documents/. Read the file at runtime
1406                // instead of converting the path string to its UTF-8 bytes.
1407                if arg.arg_type == "bytes" {
1408                    let val = json_to_kotlin(v);
1409                    parts.push(format!(
1410                        "java.nio.file.Files.readAllBytes(java.nio.file.Path.of({val}))"
1411                    ));
1412                    continue;
1413                }
1414                // file_path args must be wrapped in java.nio.file.Path.of(),
1415                // since the Kotlin module re-exports the Java facade signatures
1416                // which take Path rather than String for file-path parameters.
1417                if arg.arg_type == "file_path" {
1418                    let val = json_to_kotlin(v);
1419                    parts.push(format!("java.nio.file.Path.of({val})"));
1420                    continue;
1421                }
1422                parts.push(json_to_kotlin(v));
1423            }
1424        }
1425    }
1426
1427    (setup_lines, parts.join(", "))
1428}
1429
1430/// Emit a Kotlin `listOf(...)` expression of `BatchBytesItem` or
1431/// `BatchFileItem` constructors. Mirrors `emit_java_batch_item_array` so the
1432/// Kotlin tests build the same typed lists the Java facade expects.
1433fn emit_kotlin_batch_item_array(arr: &serde_json::Value, elem_type: &str) -> String {
1434    let Some(items) = arr.as_array() else {
1435        return "emptyList()".to_string();
1436    };
1437    let parts: Vec<String> = items
1438        .iter()
1439        .filter_map(|item| {
1440            let obj = item.as_object()?;
1441            match elem_type {
1442                "BatchBytesItem" => {
1443                    let mime_type = obj.get("mime_type").and_then(|v| v.as_str()).unwrap_or("text/plain");
1444                    let content_code = obj
1445                        .get("content")
1446                        .and_then(|v| v.as_array())
1447                        .map(|arr| {
1448                            let bytes: Vec<String> =
1449                                arr.iter().filter_map(|v| v.as_u64().map(|n| format!("{n}"))).collect();
1450                            format!("byteArrayOf({})", bytes.join(", "))
1451                        })
1452                        .unwrap_or_else(|| "byteArrayOf()".to_string());
1453                    Some(format!("{elem_type}({content_code}, \"{mime_type}\", null)"))
1454                }
1455                "BatchFileItem" => {
1456                    let path = obj.get("path").and_then(|v| v.as_str()).unwrap_or("");
1457                    Some(format!("{elem_type}(java.nio.file.Paths.get(\"{path}\"), null)"))
1458                }
1459                _ => None,
1460            }
1461        })
1462        .collect();
1463    format!("listOf({})", parts.join(", "))
1464}
1465
1466#[allow(clippy::too_many_arguments)]
1467fn render_assertion(
1468    out: &mut String,
1469    assertion: &Assertion,
1470    result_var: &str,
1471    _class_name: &str,
1472    field_resolver: &FieldResolver,
1473    result_is_simple: bool,
1474    result_is_option: bool,
1475    enum_fields: &HashSet<String>,
1476    fields_c_types: &std::collections::HashMap<String, String>,
1477    is_streaming: bool,
1478    kotlin_android_style: bool,
1479) {
1480    // In streaming context, `usage` and `usage.*` fields must be read from the
1481    // last collected chunk, not from the stream iterator (which has no `usage()` method).
1482    // Route them through `StreamingFieldResolver::accessor("usage", ...)` + deep-tail
1483    // rendering, using `chunks.last().usage()` as the base expression.
1484    if is_streaming {
1485        if let Some(f) = &assertion.field {
1486            if f == "usage" || f.starts_with("usage.") {
1487                let base_expr =
1488                    crate::codegen::streaming_assertions::StreamingFieldResolver::accessor("usage", "kotlin", "chunks")
1489                        .unwrap_or_else(|| "(if (chunks.isEmpty()) null else chunks.last().usage())".to_string());
1490
1491                // For a deep path like `usage.total_tokens`, render the tail `.total_tokens`
1492                // in a Kotlin-idiomatic style (safe-call + camelCase method).
1493                let expr = if let Some(tail) = f.strip_prefix("usage.") {
1494                    use heck::ToLowerCamelCase;
1495                    // Each segment in the tail is a field accessor using `?.` (nullable base).
1496                    tail.split('.')
1497                        .fold(base_expr, |acc, seg| format!("{acc}?.{}()", seg.to_lower_camel_case()))
1498                } else {
1499                    base_expr
1500                };
1501
1502                // Determine if the field maps to a 64-bit C type requiring `L` suffix.
1503                let field_is_long = fields_c_types
1504                    .get(f.as_str())
1505                    .is_some_and(|t| matches!(t.as_str(), "uint64_t" | "int64_t"));
1506
1507                let line = match assertion.assertion_type.as_str() {
1508                    "equals" => {
1509                        if let Some(expected) = &assertion.value {
1510                            let kotlin_val = if field_is_long && expected.is_number() && !expected.is_f64() {
1511                                format!("{}L", expected)
1512                            } else {
1513                                json_to_kotlin(expected)
1514                            };
1515                            format!("        assertEquals({kotlin_val}, {expr}!!)\n")
1516                        } else {
1517                            String::new()
1518                        }
1519                    }
1520                    _ => String::new(),
1521                };
1522                if !line.is_empty() {
1523                    out.push_str(&line);
1524                }
1525                return;
1526            }
1527        }
1528    }
1529
1530    // Streaming virtual fields resolve against the `chunks` collected-list variable.
1531    // Intercept before is_valid_for_result so they are never skipped.
1532    if let Some(f) = &assertion.field {
1533        if !f.is_empty() && crate::codegen::streaming_assertions::is_streaming_virtual_field(f) {
1534            if let Some(expr) =
1535                crate::codegen::streaming_assertions::StreamingFieldResolver::accessor(f, "kotlin", "chunks")
1536            {
1537                let line = match assertion.assertion_type.as_str() {
1538                    "count_min" => {
1539                        if let Some(n) = assertion.value.as_ref().and_then(|v| v.as_u64()) {
1540                            format!("        assertTrue({expr}.size >= {n}, \"expected >= {n} chunks\")\n")
1541                        } else {
1542                            String::new()
1543                        }
1544                    }
1545                    "count_equals" => {
1546                        if let Some(n) = assertion.value.as_ref().and_then(|v| v.as_u64()) {
1547                            format!(
1548                                "        assertEquals({n}.toLong(), {expr}.size.toLong(), \"expected exactly {n} elements\")\n"
1549                            )
1550                        } else {
1551                            String::new()
1552                        }
1553                    }
1554                    "equals" => {
1555                        if let Some(serde_json::Value::String(s)) = &assertion.value {
1556                            let escaped = escape_kotlin(s);
1557                            format!("        assertEquals(\"{escaped}\", {expr})\n")
1558                        } else if let Some(b) = assertion.value.as_ref().and_then(|v| v.as_bool()) {
1559                            format!("        assertEquals({b}, {expr})\n")
1560                        } else {
1561                            String::new()
1562                        }
1563                    }
1564                    "not_empty" => {
1565                        format!("        assertFalse({expr}.isEmpty(), \"expected non-empty\")\n")
1566                    }
1567                    "is_empty" => {
1568                        format!("        assertTrue({expr}.isEmpty(), \"expected empty\")\n")
1569                    }
1570                    "is_true" => {
1571                        format!("        assertTrue({expr}, \"expected true\")\n")
1572                    }
1573                    "is_false" => {
1574                        format!("        assertFalse({expr}, \"expected false\")\n")
1575                    }
1576                    "greater_than" => {
1577                        if let Some(n) = assertion.value.as_ref().and_then(|v| v.as_u64()) {
1578                            format!("        assertTrue({expr} > {n}, \"expected > {n}\")\n")
1579                        } else {
1580                            String::new()
1581                        }
1582                    }
1583                    "contains" => {
1584                        if let Some(serde_json::Value::String(s)) = &assertion.value {
1585                            let escaped = escape_kotlin(s);
1586                            format!(
1587                                "        assertTrue({expr}.contains(\"{escaped}\"), \"expected to contain: {escaped}\")\n"
1588                            )
1589                        } else {
1590                            String::new()
1591                        }
1592                    }
1593                    _ => format!(
1594                        "        // streaming field '{f}': assertion type '{}' not rendered\n",
1595                        assertion.assertion_type
1596                    ),
1597                };
1598                if !line.is_empty() {
1599                    out.push_str(&line);
1600                }
1601            }
1602            return;
1603        }
1604    }
1605
1606    // Skip assertions on fields that don't exist on the result type.
1607    if let Some(f) = &assertion.field {
1608        if !f.is_empty() && !field_resolver.is_valid_for_result(f) {
1609            let _ = writeln!(out, "        // skipped: field '{f}' not available on result type");
1610            return;
1611        }
1612    }
1613
1614    // Determine if this field is an enum type.
1615    let field_is_enum = assertion
1616        .field
1617        .as_deref()
1618        .is_some_and(|f| enum_fields.contains(f) || enum_fields.contains(field_resolver.resolve(f)));
1619
1620    // Raw field accessor — may end with nullable type if field is optional.
1621    let field_expr = if result_is_simple {
1622        result_var.to_string()
1623    } else {
1624        match &assertion.field {
1625            Some(f) if !f.is_empty() => field_resolver.accessor(f, "kotlin", result_var),
1626            _ => result_var.to_string(),
1627        }
1628    };
1629
1630    // Whether the accessor may return a nullable type in Kotlin. This is true
1631    // when the leaf field OR any intermediate segment in the path is optional
1632    // (the `?.` safe-call propagates null through the whole chain).
1633    //
1634    // Additionally, if the generated accessor expression itself contains `?.`
1635    // then the return type is `T?` regardless of what the path-resolver says —
1636    // sticky nullability means any `?.` in the chain makes the whole expression
1637    // nullable. This handles cases like `toolCalls()?.first()?.function()?.name()`
1638    // where the `is_optional` prefix lookup misses due to index notation mismatch.
1639    let field_is_optional = !result_is_simple
1640        && (field_expr.contains("?.")
1641            || assertion.field.as_deref().filter(|f| !f.is_empty()).is_some_and(|f| {
1642                let resolved = field_resolver.resolve(f);
1643                if field_resolver.has_map_access(f) {
1644                    return false;
1645                }
1646                // Check the leaf field itself.
1647                if field_resolver.is_optional(resolved) {
1648                    return true;
1649                }
1650                // Also check every prefix segment: if any intermediate field is
1651                // optional the ?.  chain propagates null to the final result.
1652                let mut prefix = String::new();
1653                for part in resolved.split('.') {
1654                    // Strip array notation for the lookup key.
1655                    let key = part.split('[').next().unwrap_or(part);
1656                    if !prefix.is_empty() {
1657                        prefix.push('.');
1658                    }
1659                    prefix.push_str(key);
1660                    if field_resolver.is_optional(&prefix) {
1661                        return true;
1662                    }
1663                }
1664                false
1665            }));
1666
1667    // String-context expression: append .orEmpty() for nullable string fields so
1668    // string operations (contains, trim) don't require a safe-call chain.
1669    // Note: this is only sound when the leaf type is `String?`. For enum-typed
1670    // optional fields (`T?` where `T` is an enum class), `.orEmpty()` is undefined;
1671    // the enum branch below handles those by going through `?.getValue()` first.
1672    let string_field_expr = if field_is_optional {
1673        format!("{field_expr}.orEmpty()")
1674    } else {
1675        field_expr.clone()
1676    };
1677
1678    // Non-null expression: use !! to assert presence for numeric comparisons where
1679    // the fixture guarantees the value is non-null.
1680    let nonnull_field_expr = if field_is_optional {
1681        format!("{field_expr}!!")
1682    } else {
1683        field_expr.clone()
1684    };
1685
1686    // For enum fields, use .getValue() to get the string value. When the enum
1687    // field (or any intermediate segment in its path) is optional, use a safe
1688    // call before `.getValue()` and then `.orEmpty()` to coerce `String?` back
1689    // to `String` — this matches the Java codegen's
1690    // `Optional.ofNullable(...).map(v -> v.getValue()).orElse("")` pattern.
1691    let string_expr = match (field_is_enum, field_is_optional) {
1692        (true, true) => format!("{field_expr}?.getValue().orEmpty()"),
1693        (true, false) => format!("{field_expr}.getValue()"),
1694        (false, _) => string_field_expr.clone(),
1695    };
1696
1697    // Determine if this assertion field maps to a 64-bit C type (uint64_t / int64_t),
1698    // which corresponds to Kotlin `Long`. When true, integer literals must be suffixed
1699    // with `L` to avoid a type mismatch between Kotlin `Int` and `Long`.
1700    let field_is_long = assertion.field.as_deref().filter(|f| !f.is_empty()).is_some_and(|f| {
1701        let resolved = field_resolver.resolve(f);
1702        matches!(
1703            fields_c_types.get(resolved).map(String::as_str),
1704            Some("uint64_t") | Some("int64_t")
1705        )
1706    });
1707
1708    match assertion.assertion_type.as_str() {
1709        "equals" => {
1710            if let Some(expected) = &assertion.value {
1711                // Suffix integer literals with `L` when the target field is a Java `long`
1712                // (uint64_t / int64_t in C FFI terms). Without the suffix, Kotlin infers
1713                // the literal as `Int`, causing a type mismatch with `Long` at runtime.
1714                let kotlin_val = if field_is_long && expected.is_number() && !expected.is_f64() {
1715                    format!("{}L", expected)
1716                } else {
1717                    json_to_kotlin(expected)
1718                };
1719                if expected.is_string() {
1720                    let _ = writeln!(out, "        assertEquals({kotlin_val}, {string_expr}.trim())");
1721                } else {
1722                    let _ = writeln!(out, "        assertEquals({kotlin_val}, {nonnull_field_expr})");
1723                }
1724            }
1725        }
1726        "contains" => {
1727            if let Some(expected) = &assertion.value {
1728                let kotlin_val = json_to_kotlin(expected);
1729                let _ = writeln!(
1730                    out,
1731                    "        assertTrue({string_expr}.contains({kotlin_val}), \"expected to contain: \" + {kotlin_val})"
1732                );
1733            }
1734        }
1735        "contains_all" => {
1736            if let Some(values) = &assertion.values {
1737                for val in values {
1738                    let kotlin_val = json_to_kotlin(val);
1739                    let _ = writeln!(
1740                        out,
1741                        "        assertTrue({string_expr}.contains({kotlin_val}), \"expected to contain: \" + {kotlin_val})"
1742                    );
1743                }
1744            }
1745        }
1746        "not_contains" => {
1747            if let Some(expected) = &assertion.value {
1748                let kotlin_val = json_to_kotlin(expected);
1749                let _ = writeln!(
1750                    out,
1751                    "        assertFalse({string_expr}.contains({kotlin_val}), \"expected NOT to contain: \" + {kotlin_val})"
1752                );
1753            }
1754        }
1755        "not_empty" => {
1756            // For optional fields, the field type may be a non-String object
1757            // (e.g. DocumentStructure) for which `.orEmpty()` is undefined. A
1758            // null-check is the safe primitive: it works for any reference type
1759            // and matches the Java codegen's `Optional.ofNullable(...).isEmpty()`.
1760            // When the bare result is `T?` (result_is_option) the same null-check
1761            // applies, because `.isEmpty()` is undefined on arbitrary nullable types.
1762            // The JVM Kotlin e2e tests call the Java facade class which returns
1763            // `java.util.Optional<T>` for option results — use `.isPresent` rather
1764            // than `!= null` so the assertion semantics match the JVM return type.
1765            // The kotlin-android wrapper unwraps `Optional<T>` to Kotlin's `T?`
1766            // at the boundary, so its bare-option result is a nullable reference
1767            // and must use `!= null` instead.
1768            let bare_result_is_option =
1769                result_is_option && assertion.field.as_deref().filter(|f| !f.is_empty()).is_none();
1770            if bare_result_is_option && !kotlin_android_style {
1771                let _ = writeln!(
1772                    out,
1773                    "        assertTrue({field_expr}.isPresent, \"expected non-empty value\")"
1774                );
1775            } else if bare_result_is_option || field_is_optional {
1776                let _ = writeln!(
1777                    out,
1778                    "        assertTrue({field_expr} != null, \"expected non-empty value\")"
1779                );
1780            } else {
1781                let _ = writeln!(
1782                    out,
1783                    "        assertFalse({string_field_expr}.isEmpty(), \"expected non-empty value\")"
1784                );
1785            }
1786        }
1787        "is_empty" => {
1788            let bare_result_is_option =
1789                result_is_option && assertion.field.as_deref().filter(|f| !f.is_empty()).is_none();
1790            if bare_result_is_option && !kotlin_android_style {
1791                let _ = writeln!(
1792                    out,
1793                    "        assertTrue({field_expr}.isEmpty, \"expected empty value\")"
1794                );
1795            } else if bare_result_is_option || field_is_optional {
1796                let _ = writeln!(
1797                    out,
1798                    "        assertTrue({field_expr} == null, \"expected empty value\")"
1799                );
1800            } else {
1801                let _ = writeln!(
1802                    out,
1803                    "        assertTrue({string_field_expr}.isEmpty(), \"expected empty value\")"
1804                );
1805            }
1806        }
1807        "contains_any" => {
1808            if let Some(values) = &assertion.values {
1809                let checks: Vec<String> = values
1810                    .iter()
1811                    .map(|v| {
1812                        let kotlin_val = json_to_kotlin(v);
1813                        format!("{string_expr}.contains({kotlin_val})")
1814                    })
1815                    .collect();
1816                let joined = checks.join(" || ");
1817                let _ = writeln!(
1818                    out,
1819                    "        assertTrue({joined}, \"expected to contain at least one of the specified values\")"
1820                );
1821            }
1822        }
1823        "greater_than" => {
1824            if let Some(val) = &assertion.value {
1825                let kotlin_val = json_to_kotlin(val);
1826                let _ = writeln!(
1827                    out,
1828                    "        assertTrue({nonnull_field_expr} > {kotlin_val}, \"expected > {kotlin_val}\")"
1829                );
1830            }
1831        }
1832        "less_than" => {
1833            if let Some(val) = &assertion.value {
1834                let kotlin_val = json_to_kotlin(val);
1835                let _ = writeln!(
1836                    out,
1837                    "        assertTrue({nonnull_field_expr} < {kotlin_val}, \"expected < {kotlin_val}\")"
1838                );
1839            }
1840        }
1841        "greater_than_or_equal" => {
1842            if let Some(val) = &assertion.value {
1843                let kotlin_val = json_to_kotlin(val);
1844                let _ = writeln!(
1845                    out,
1846                    "        assertTrue({nonnull_field_expr} >= {kotlin_val}, \"expected >= {kotlin_val}\")"
1847                );
1848            }
1849        }
1850        "less_than_or_equal" => {
1851            if let Some(val) = &assertion.value {
1852                let kotlin_val = json_to_kotlin(val);
1853                let _ = writeln!(
1854                    out,
1855                    "        assertTrue({nonnull_field_expr} <= {kotlin_val}, \"expected <= {kotlin_val}\")"
1856                );
1857            }
1858        }
1859        "starts_with" => {
1860            if let Some(expected) = &assertion.value {
1861                let kotlin_val = json_to_kotlin(expected);
1862                let _ = writeln!(
1863                    out,
1864                    "        assertTrue({string_expr}.startsWith({kotlin_val}), \"expected to start with: \" + {kotlin_val})"
1865                );
1866            }
1867        }
1868        "ends_with" => {
1869            if let Some(expected) = &assertion.value {
1870                let kotlin_val = json_to_kotlin(expected);
1871                let _ = writeln!(
1872                    out,
1873                    "        assertTrue({string_expr}.endsWith({kotlin_val}), \"expected to end with: \" + {kotlin_val})"
1874                );
1875            }
1876        }
1877        "min_length" => {
1878            if let Some(val) = &assertion.value {
1879                if let Some(n) = val.as_u64() {
1880                    let _ = writeln!(
1881                        out,
1882                        "        assertTrue({string_field_expr}.length >= {n}, \"expected length >= {n}\")"
1883                    );
1884                }
1885            }
1886        }
1887        "max_length" => {
1888            if let Some(val) = &assertion.value {
1889                if let Some(n) = val.as_u64() {
1890                    let _ = writeln!(
1891                        out,
1892                        "        assertTrue({string_field_expr}.length <= {n}, \"expected length <= {n}\")"
1893                    );
1894                }
1895            }
1896        }
1897        "count_min" => {
1898            if let Some(val) = &assertion.value {
1899                if let Some(n) = val.as_u64() {
1900                    let _ = writeln!(
1901                        out,
1902                        "        assertTrue({nonnull_field_expr}.size >= {n}, \"expected at least {n} elements\")"
1903                    );
1904                }
1905            }
1906        }
1907        "count_equals" => {
1908            if let Some(val) = &assertion.value {
1909                if let Some(n) = val.as_u64() {
1910                    let _ = writeln!(
1911                        out,
1912                        "        assertEquals({n}, {nonnull_field_expr}.size, \"expected exactly {n} elements\")"
1913                    );
1914                }
1915            }
1916        }
1917        "is_true" => {
1918            let _ = writeln!(out, "        assertTrue({field_expr}, \"expected true\")");
1919        }
1920        "is_false" => {
1921            let _ = writeln!(out, "        assertFalse({field_expr}, \"expected false\")");
1922        }
1923        "matches_regex" => {
1924            if let Some(expected) = &assertion.value {
1925                let kotlin_val = json_to_kotlin(expected);
1926                let _ = writeln!(
1927                    out,
1928                    "        assertTrue(Regex({kotlin_val}).containsMatchIn({string_expr}), \"expected value to match regex: \" + {kotlin_val})"
1929                );
1930            }
1931        }
1932        "not_error" => {
1933            // Already handled by the call succeeding without exception.
1934        }
1935        "error" => {
1936            // Handled at the test method level.
1937        }
1938        "method_result" => {
1939            // Placeholder: Kotlin support for method_result would need tree-sitter integration.
1940            let _ = writeln!(
1941                out,
1942                "        // method_result assertions not yet implemented for Kotlin"
1943            );
1944        }
1945        other => {
1946            panic!("Kotlin e2e generator: unsupported assertion type: {other}");
1947        }
1948    }
1949}
1950
1951/// Convert a `serde_json::Value` to a Kotlin literal string.
1952fn json_to_kotlin(value: &serde_json::Value) -> String {
1953    match value {
1954        serde_json::Value::String(s) => format!("\"{}\"", escape_kotlin(s)),
1955        serde_json::Value::Bool(b) => b.to_string(),
1956        serde_json::Value::Number(n) => {
1957            if n.is_f64() {
1958                // Kotlin Double literals use no suffix (or `.0` if integer-shaped).
1959                // `0.9d` would parse as identifier `d` following a malformed literal.
1960                let s = n.to_string();
1961                if s.contains('.') || s.contains('e') || s.contains('E') {
1962                    s
1963                } else {
1964                    format!("{s}.0")
1965                }
1966            } else {
1967                n.to_string()
1968            }
1969        }
1970        serde_json::Value::Null => "null".to_string(),
1971        serde_json::Value::Array(arr) => {
1972            let items: Vec<String> = arr.iter().map(json_to_kotlin).collect();
1973            format!("listOf({})", items.join(", "))
1974        }
1975        serde_json::Value::Object(_) => {
1976            let json_str = serde_json::to_string(value).unwrap_or_default();
1977            format!("\"{}\"", escape_kotlin(&json_str))
1978        }
1979    }
1980}
1981
1982#[cfg(test)]
1983mod tests {
1984    use super::*;
1985    use std::collections::HashMap;
1986
1987    fn make_resolver_for_finish_reason() -> FieldResolver {
1988        // Resolver for `choices[0].finish_reason` where:
1989        //   - `choices` is a registered array field (default index 0)
1990        //   - `choices.finish_reason` is optional (`@Nullable`)
1991        let mut optional = HashSet::new();
1992        optional.insert("choices.finish_reason".to_string());
1993        let mut arrays = HashSet::new();
1994        arrays.insert("choices".to_string());
1995        FieldResolver::new(&HashMap::new(), &optional, &HashSet::new(), &arrays, &HashSet::new())
1996    }
1997
1998    /// Regression: enum-typed optional fields must route through `?.getValue()`
1999    /// before falling back via `.orEmpty()`. Emitting `.orEmpty().getValue()`
2000    /// is invalid Kotlin because `T?.orEmpty()` is only defined for `String?`.
2001    #[test]
2002    fn assertion_enum_optional_uses_safe_get_value_then_or_empty() {
2003        let resolver = make_resolver_for_finish_reason();
2004        let mut enum_fields = HashSet::new();
2005        enum_fields.insert("choices.finish_reason".to_string());
2006        let assertion = Assertion {
2007            assertion_type: "equals".to_string(),
2008            field: Some("choices.finish_reason".to_string()),
2009            value: Some(serde_json::Value::String("stop".to_string())),
2010            values: None,
2011            method: None,
2012            check: None,
2013            args: None,
2014            return_type: None,
2015        };
2016        let mut out = String::new();
2017        render_assertion(
2018            &mut out,
2019            &assertion,
2020            "result",
2021            "",
2022            &resolver,
2023            false,
2024            false,
2025            &enum_fields,
2026            &HashMap::new(),
2027            false,
2028            false,
2029        );
2030        assert!(
2031            out.contains("result.choices().first().finishReason()?.getValue().orEmpty().trim()"),
2032            "expected enum-optional safe-call pattern, got: {out}"
2033        );
2034        assert!(
2035            !out.contains(".finishReason().orEmpty().getValue()"),
2036            "must not emit .orEmpty().getValue() on a nullable enum: {out}"
2037        );
2038    }
2039
2040    /// Non-optional enum field should call `.getValue()` directly without
2041    /// safe-call or fallback (no need to handle null).
2042    #[test]
2043    fn assertion_enum_non_optional_uses_plain_get_value() {
2044        let mut arrays = HashSet::new();
2045        arrays.insert("choices".to_string());
2046        let resolver = FieldResolver::new(
2047            &HashMap::new(),
2048            &HashSet::new(),
2049            &HashSet::new(),
2050            &arrays,
2051            &HashSet::new(),
2052        );
2053        let mut enum_fields = HashSet::new();
2054        enum_fields.insert("choices.finish_reason".to_string());
2055        let assertion = Assertion {
2056            assertion_type: "equals".to_string(),
2057            field: Some("choices.finish_reason".to_string()),
2058            value: Some(serde_json::Value::String("stop".to_string())),
2059            values: None,
2060            method: None,
2061            check: None,
2062            args: None,
2063            return_type: None,
2064        };
2065        let mut out = String::new();
2066        render_assertion(
2067            &mut out,
2068            &assertion,
2069            "result",
2070            "",
2071            &resolver,
2072            false,
2073            false,
2074            &enum_fields,
2075            &HashMap::new(),
2076            false,
2077            false,
2078        );
2079        assert!(
2080            out.contains("result.choices().first().finishReason().getValue().trim()"),
2081            "expected plain .getValue() for non-optional enum, got: {out}"
2082        );
2083    }
2084
2085    /// Regression: per-call `enum_fields` overrides (e.g. `status = "BatchStatus"`) must be
2086    /// merged into the effective enum-field set before rendering assertions.  Previously the
2087    /// kotlin codegen only consulted the global `fields_enum` set, so `status` on `BatchObject`
2088    /// was treated as a plain `String` and `.trim()` was emitted directly instead of
2089    /// `.getValue().trim()`, causing a Kotlin compile error ("BatchStatus has no method trim").
2090    #[test]
2091    fn per_call_enum_field_override_routes_through_get_value() {
2092        // Simulate `status` field on a non-optional result with no global enum registration.
2093        let resolver = FieldResolver::new(
2094            &HashMap::new(),
2095            &HashSet::new(),
2096            &HashSet::new(),
2097            &HashSet::new(),
2098            &HashSet::new(),
2099        );
2100        // `status` is NOT in the global enum_fields set...
2101        let global_enum_fields: HashSet<String> = HashSet::new();
2102        // ...but a per-call override registers it.
2103        let mut per_call_enum_fields: HashSet<String> = global_enum_fields.clone();
2104        per_call_enum_fields.insert("status".to_string());
2105
2106        let assertion = Assertion {
2107            assertion_type: "equals".to_string(),
2108            field: Some("status".to_string()),
2109            value: Some(serde_json::Value::String("validating".to_string())),
2110            values: None,
2111            method: None,
2112            check: None,
2113            args: None,
2114            return_type: None,
2115        };
2116
2117        // Without the merge (global only): must NOT emit .getValue()
2118        let mut out_no_merge = String::new();
2119        render_assertion(
2120            &mut out_no_merge,
2121            &assertion,
2122            "result",
2123            "",
2124            &resolver,
2125            false,
2126            false,
2127            &global_enum_fields,
2128            &HashMap::new(),
2129            false,
2130            false,
2131        );
2132        assert!(
2133            !out_no_merge.contains(".getValue()"),
2134            "global-only set must not emit .getValue() for unregistered status: {out_no_merge}"
2135        );
2136
2137        // With the merge (per-call included): must emit .getValue()
2138        let mut out_merged = String::new();
2139        render_assertion(
2140            &mut out_merged,
2141            &assertion,
2142            "result",
2143            "",
2144            &resolver,
2145            false,
2146            false,
2147            &per_call_enum_fields,
2148            &HashMap::new(),
2149            false,
2150            false,
2151        );
2152        assert!(
2153            out_merged.contains(".getValue()"),
2154            "merged per-call set must emit .getValue() for status: {out_merged}"
2155        );
2156    }
2157
2158    /// Auto-detection: fields whose Rust type is `Named(T)` where `T` is NOT a
2159    /// known struct should be treated as enum-typed without any explicit per-call
2160    /// `enum_fields` override. The `type_enum_fields` map (built in `generate()`)
2161    /// pre-computes these sets so `render_test_method` can merge them.
2162    #[test]
2163    fn auto_detected_enum_fields_from_type_defs_route_through_get_value() {
2164        use alef_core::ir::{CoreWrapper, FieldDef, TypeDef, TypeRef};
2165
2166        // Simulate a `BatchObject` type with `status: BatchStatus` (Named, not a struct).
2167        let batch_object_def = TypeDef {
2168            name: "BatchObject".to_string(),
2169            rust_path: "liter_llm::BatchObject".to_string(),
2170            original_rust_path: String::new(),
2171            fields: vec![
2172                FieldDef {
2173                    name: "id".to_string(),
2174                    ty: TypeRef::String,
2175                    optional: false,
2176                    default: None,
2177                    doc: String::new(),
2178                    sanitized: false,
2179                    is_boxed: false,
2180                    type_rust_path: None,
2181                    cfg: None,
2182                    typed_default: None,
2183                    core_wrapper: CoreWrapper::None,
2184                    vec_inner_core_wrapper: CoreWrapper::None,
2185                    newtype_wrapper: None,
2186                    serde_rename: None,
2187                    serde_flatten: false,
2188                },
2189                FieldDef {
2190                    name: "status".to_string(),
2191                    ty: TypeRef::Named("BatchStatus".to_string()),
2192                    optional: false,
2193                    default: None,
2194                    doc: String::new(),
2195                    sanitized: false,
2196                    is_boxed: false,
2197                    type_rust_path: None,
2198                    cfg: None,
2199                    typed_default: None,
2200                    core_wrapper: CoreWrapper::None,
2201                    vec_inner_core_wrapper: CoreWrapper::None,
2202                    newtype_wrapper: None,
2203                    serde_rename: None,
2204                    serde_flatten: false,
2205                },
2206            ],
2207            methods: vec![],
2208            is_opaque: false,
2209            is_clone: true,
2210            is_copy: false,
2211            doc: String::new(),
2212            cfg: None,
2213            is_trait: false,
2214            has_default: false,
2215            has_stripped_cfg_fields: false,
2216            is_return_type: true,
2217            serde_rename_all: None,
2218            has_serde: true,
2219            super_traits: vec![],
2220        };
2221
2222        // `BatchObject` is the only struct — `BatchStatus` is not in struct_names.
2223        let type_defs = [batch_object_def];
2224        let struct_names: HashSet<&str> = type_defs.iter().map(|td| td.name.as_str()).collect();
2225
2226        // Verify is_enum_typed correctly identifies `status` as enum-typed.
2227        let status_ty = TypeRef::Named("BatchStatus".to_string());
2228        assert!(
2229            is_enum_typed(&status_ty, &struct_names),
2230            "BatchStatus (not a known struct) should be detected as enum-typed"
2231        );
2232        let id_ty = TypeRef::String;
2233        assert!(
2234            !is_enum_typed(&id_ty, &struct_names),
2235            "String field should NOT be detected as enum-typed"
2236        );
2237
2238        // Verify the type_enum_fields map is built correctly.
2239        let type_enum_fields: std::collections::HashMap<String, HashSet<String>> = type_defs
2240            .iter()
2241            .filter_map(|td| {
2242                let enum_field_names: HashSet<String> = td
2243                    .fields
2244                    .iter()
2245                    .filter(|field| is_enum_typed(&field.ty, &struct_names))
2246                    .map(|field| field.name.clone())
2247                    .collect();
2248                if enum_field_names.is_empty() {
2249                    None
2250                } else {
2251                    Some((td.name.clone(), enum_field_names))
2252                }
2253            })
2254            .collect();
2255
2256        let batch_enum_fields = type_enum_fields
2257            .get("BatchObject")
2258            .expect("BatchObject should have enum fields");
2259        assert!(
2260            batch_enum_fields.contains("status"),
2261            "BatchObject.status should be auto-detected as enum-typed, got: {batch_enum_fields:?}"
2262        );
2263        assert!(
2264            !batch_enum_fields.contains("id"),
2265            "BatchObject.id (String) must not be in enum fields"
2266        );
2267
2268        // Verify render_assertion produces `.getValue()` when `status` is in enum_fields.
2269        let resolver = FieldResolver::new(
2270            &HashMap::new(),
2271            &HashSet::new(),
2272            &HashSet::new(),
2273            &HashSet::new(),
2274            &HashSet::new(),
2275        );
2276        let assertion = Assertion {
2277            assertion_type: "equals".to_string(),
2278            field: Some("status".to_string()),
2279            value: Some(serde_json::Value::String("validating".to_string())),
2280            values: None,
2281            method: None,
2282            check: None,
2283            args: None,
2284            return_type: None,
2285        };
2286        let mut out = String::new();
2287        render_assertion(
2288            &mut out,
2289            &assertion,
2290            "result",
2291            "",
2292            &resolver,
2293            false,
2294            false,
2295            batch_enum_fields,
2296            &HashMap::new(),
2297            false,
2298            false,
2299        );
2300        assert!(
2301            out.contains(".getValue()"),
2302            "auto-detected enum field must route through .getValue(), got: {out}"
2303        );
2304    }
2305}