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