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