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::AlefConfig;
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        alef_config: &AlefConfig,
32    ) -> Result<Vec<GeneratedFile>> {
33        let lang = self.language_name();
34        let output_base = PathBuf::from(e2e_config.effective_output()).join(lang);
35
36        let mut files = Vec::new();
37
38        // Resolve call config with overrides.
39        let call = &e2e_config.call;
40        let overrides = call.overrides.get(lang);
41        let _module_path = overrides
42            .and_then(|o| o.module.as_ref())
43            .cloned()
44            .unwrap_or_else(|| call.module.clone());
45        let function_name = overrides
46            .and_then(|o| o.function.as_ref())
47            .cloned()
48            .unwrap_or_else(|| call.function.clone());
49        let class_name = overrides
50            .and_then(|o| o.class.as_ref())
51            .cloned()
52            .unwrap_or_else(|| alef_config.crate_config.name.to_upper_camel_case());
53        let result_is_simple = overrides.is_some_and(|o| o.result_is_simple);
54        let result_var = &call.result_var;
55
56        // Resolve package config.
57        let kotlin_pkg = e2e_config.resolve_package("kotlin");
58        let pkg_name = kotlin_pkg
59            .as_ref()
60            .and_then(|p| p.name.as_ref())
61            .cloned()
62            .unwrap_or_else(|| alef_config.crate_config.name.clone());
63
64        // Resolve Kotlin package for generated tests.
65        let _kotlin_pkg_path = kotlin_pkg
66            .as_ref()
67            .and_then(|p| p.path.as_ref())
68            .cloned()
69            .unwrap_or_else(|| "../../packages/kotlin".to_string());
70        let kotlin_version = kotlin_pkg
71            .as_ref()
72            .and_then(|p| p.version.as_ref())
73            .cloned()
74            .or_else(|| alef_config.resolved_version())
75            .unwrap_or_else(|| "0.1.0".to_string());
76        let kotlin_pkg_id = alef_config.kotlin_package();
77
78        // Generate build.gradle.kts.
79        files.push(GeneratedFile {
80            path: output_base.join("build.gradle.kts"),
81            content: render_build_gradle(&pkg_name, &kotlin_pkg_id, &kotlin_version, e2e_config.dep_mode),
82            generated_header: false,
83        });
84
85        // Generate test files per category. Path mirrors the configured Kotlin
86        // package so the package declaration in each test file matches its
87        // filesystem location.
88        let mut test_base = output_base.join("src").join("test").join("kotlin");
89        for segment in kotlin_pkg_id.split('.') {
90            test_base = test_base.join(segment);
91        }
92        let test_base = test_base.join("e2e");
93
94        // Resolve options_type from override.
95        let options_type = overrides.and_then(|o| o.options_type.clone());
96        let field_resolver = FieldResolver::new(
97            &e2e_config.fields,
98            &e2e_config.fields_optional,
99            &e2e_config.result_fields,
100            &e2e_config.fields_array,
101        );
102
103        for group in groups {
104            let active: Vec<&Fixture> = group
105                .fixtures
106                .iter()
107                .filter(|f| f.skip.as_ref().is_none_or(|s| !s.should_skip(lang)))
108                .collect();
109
110            if active.is_empty() {
111                continue;
112            }
113
114            let class_file_name = format!("{}Test.kt", sanitize_filename(&group.category).to_upper_camel_case());
115            let content = render_test_file(
116                &group.category,
117                &active,
118                &class_name,
119                &function_name,
120                &kotlin_pkg_id,
121                result_var,
122                &e2e_config.call.args,
123                options_type.as_deref(),
124                &field_resolver,
125                result_is_simple,
126                &e2e_config.fields_enum,
127                e2e_config,
128            );
129            files.push(GeneratedFile {
130                path: test_base.join(class_file_name),
131                content,
132                generated_header: true,
133            });
134        }
135
136        Ok(files)
137    }
138
139    fn language_name(&self) -> &'static str {
140        "kotlin"
141    }
142}
143
144// ---------------------------------------------------------------------------
145// Rendering
146// ---------------------------------------------------------------------------
147
148fn render_build_gradle(
149    pkg_name: &str,
150    kotlin_pkg_id: &str,
151    pkg_version: &str,
152    dep_mode: crate::config::DependencyMode,
153) -> String {
154    let dep_block = match dep_mode {
155        crate::config::DependencyMode::Registry => {
156            format!(r#"    testImplementation("{pkg_name}:{pkg_version}")"#)
157        }
158        crate::config::DependencyMode::Local => {
159            // Local mode: reference local JAR or Maven build output.
160            format!(r#"    testImplementation(files("../../packages/kotlin/build/libs/{pkg_name}-{pkg_version}.jar"))"#)
161        }
162    };
163
164    let kotlin_plugin = maven::KOTLIN_JVM_PLUGIN;
165    let junit = maven::JUNIT;
166    let jackson = maven::JACKSON_E2E;
167    let jvm_target = toolchain::JVM_TARGET;
168    format!(
169        r#"import org.jetbrains.kotlin.gradle.dsl.JvmTarget
170
171plugins {{
172    kotlin("jvm") version "{kotlin_plugin}"
173}}
174
175group = "{kotlin_pkg_id}"
176version = "0.1.0"
177
178java {{
179    sourceCompatibility = JavaVersion.VERSION_{jvm_target}
180    targetCompatibility = JavaVersion.VERSION_{jvm_target}
181}}
182
183kotlin {{
184    compilerOptions {{
185        jvmTarget.set(JvmTarget.JVM_{jvm_target})
186    }}
187}}
188
189repositories {{
190    mavenCentral()
191}}
192
193dependencies {{
194{dep_block}
195    testImplementation("org.junit.jupiter:junit-jupiter-api:{junit}")
196    testImplementation("org.junit.jupiter:junit-jupiter-engine:{junit}")
197    testImplementation("com.fasterxml.jackson.core:jackson-databind:{jackson}")
198    testImplementation("com.fasterxml.jackson.datatype:jackson-datatype-jdk8:{jackson}")
199    testImplementation(kotlin("test"))
200}}
201
202tasks.test {{
203    useJUnitPlatform()
204    environment("java.library.path", "../../target/release")
205}}
206"#
207    )
208}
209
210#[allow(clippy::too_many_arguments)]
211fn render_test_file(
212    category: &str,
213    fixtures: &[&Fixture],
214    class_name: &str,
215    function_name: &str,
216    kotlin_pkg_id: &str,
217    result_var: &str,
218    args: &[crate::config::ArgMapping],
219    options_type: Option<&str>,
220    field_resolver: &FieldResolver,
221    result_is_simple: bool,
222    enum_fields: &HashSet<String>,
223    e2e_config: &E2eConfig,
224) -> String {
225    let mut out = String::new();
226    out.push_str(&hash::header(CommentStyle::DoubleSlash));
227    let test_class_name = format!("{}Test", sanitize_filename(category).to_upper_camel_case());
228
229    // If the class_name is fully qualified (contains '.'), import it and use
230    // only the simple name for method calls. Otherwise use it as-is.
231    let (import_path, simple_class) = if class_name.contains('.') {
232        let simple = class_name.rsplit('.').next().unwrap_or(class_name);
233        (class_name, simple)
234    } else {
235        ("", class_name)
236    };
237
238    let _ = writeln!(out, "package {kotlin_pkg_id}.e2e");
239    let _ = writeln!(out);
240
241    // Detect if any fixture in this group is an HTTP server test.
242    let has_http_fixtures = fixtures.iter().any(|f| f.is_http_test());
243
244    // Check if any fixture uses a json_object arg with options_type (needs ObjectMapper).
245    let needs_object_mapper_for_options = options_type.is_some()
246        && fixtures.iter().any(|f| {
247            args.iter().any(|arg| {
248                let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
249                arg.arg_type == "json_object" && f.input.get(field).is_some_and(|v| !v.is_null())
250            })
251        });
252    // Also need ObjectMapper when a handle arg has a non-null config.
253    let needs_object_mapper_for_handle = fixtures.iter().any(|f| {
254        args.iter().filter(|a| a.arg_type == "handle").any(|a| {
255            let v = f.input.get(&a.field).unwrap_or(&serde_json::Value::Null);
256            !(v.is_null() || v.is_object() && v.as_object().is_some_and(|o| o.is_empty()))
257        })
258    });
259    // HTTP fixtures always need ObjectMapper for JSON body comparison.
260    let needs_object_mapper = needs_object_mapper_for_options || needs_object_mapper_for_handle || has_http_fixtures;
261
262    let _ = writeln!(out, "import org.junit.jupiter.api.Test");
263    let _ = writeln!(out, "import kotlin.test.assertEquals");
264    let _ = writeln!(out, "import kotlin.test.assertTrue");
265    let _ = writeln!(out, "import kotlin.test.assertFalse");
266    let _ = writeln!(out, "import kotlin.test.assertFailsWith");
267    // Only import the binding class when there are non-HTTP fixtures that call it.
268    let has_call_fixtures = fixtures.iter().any(|f| !f.is_http_test());
269    if has_call_fixtures && !import_path.is_empty() {
270        let _ = writeln!(out, "import {import_path}");
271    }
272    if needs_object_mapper {
273        let _ = writeln!(out, "import com.fasterxml.jackson.databind.ObjectMapper");
274        let _ = writeln!(out, "import com.fasterxml.jackson.datatype.jdk8.Jdk8Module");
275    }
276    // Import the options type if tests use it (it's in the same package as the main class).
277    if let Some(opts_type) = options_type {
278        if needs_object_mapper && has_call_fixtures {
279            // Derive the fully-qualified name from the main class import path.
280            let opts_package = if !import_path.is_empty() {
281                let pkg = import_path.rsplit_once('.').map(|(p, _)| p).unwrap_or("");
282                format!("{pkg}.{opts_type}")
283            } else {
284                opts_type.to_string()
285            };
286            let _ = writeln!(out, "import {opts_package}");
287        }
288    }
289    // Import CrawlConfig when handle args need JSON deserialization.
290    if needs_object_mapper_for_handle && !import_path.is_empty() {
291        let pkg = import_path.rsplit_once('.').map(|(p, _)| p).unwrap_or("");
292        let _ = writeln!(out, "import {pkg}.CrawlConfig");
293    }
294    let _ = writeln!(out);
295
296    let _ = writeln!(out, "/** E2e tests for category: {category}. */");
297    let _ = writeln!(out, "class {test_class_name} {{");
298
299    if needs_object_mapper {
300        let _ = writeln!(out);
301        let _ = writeln!(out, "    companion object {{");
302        let _ = writeln!(
303            out,
304            "        private val MAPPER = ObjectMapper().registerModule(Jdk8Module())"
305        );
306        let _ = writeln!(out, "    }}");
307    }
308
309    for fixture in fixtures {
310        render_test_method(
311            &mut out,
312            fixture,
313            simple_class,
314            function_name,
315            result_var,
316            args,
317            options_type,
318            field_resolver,
319            result_is_simple,
320            enum_fields,
321            e2e_config,
322        );
323        let _ = writeln!(out);
324    }
325
326    let _ = writeln!(out, "}}");
327    out
328}
329
330// ---------------------------------------------------------------------------
331// HTTP server test rendering — TestClientRenderer impl + thin driver wrapper
332// ---------------------------------------------------------------------------
333
334/// Renderer that emits JUnit 5 `@Test fun testFoo()` blocks using
335/// `java.net.http.HttpClient` against `System.getenv("MOCK_SERVER_URL")`.
336struct KotlinTestClientRenderer;
337
338impl client::TestClientRenderer for KotlinTestClientRenderer {
339    fn language_name(&self) -> &'static str {
340        "kotlin"
341    }
342
343    fn sanitize_test_name(&self, id: &str) -> String {
344        sanitize_ident(id).to_upper_camel_case()
345    }
346
347    fn render_test_open(&self, out: &mut String, fn_name: &str, description: &str, skip_reason: Option<&str>) {
348        let _ = writeln!(out, "    @Test");
349        let _ = writeln!(out, "    fun test{fn_name}() {{");
350        let _ = writeln!(out, "        // {description}");
351        if let Some(reason) = skip_reason {
352            let escaped = escape_kotlin(reason);
353            let _ = writeln!(
354                out,
355                "        org.junit.jupiter.api.Assumptions.assumeTrue(false, \"{escaped}\")"
356            );
357        }
358    }
359
360    fn render_test_close(&self, out: &mut String) {
361        let _ = writeln!(out, "    }}");
362    }
363
364    fn render_call(&self, out: &mut String, ctx: &client::CallCtx<'_>) {
365        let method = ctx.method.to_uppercase();
366        let fixture_path = ctx.path;
367
368        // Java's HttpClient restricts certain headers that cannot be set programmatically.
369        const JAVA_RESTRICTED_HEADERS: &[&str] = &["connection", "content-length", "expect", "host", "upgrade"];
370
371        let _ = writeln!(
372            out,
373            "        val baseUrl = System.getenv(\"MOCK_SERVER_URL\") ?: \"http://localhost:8080\""
374        );
375        let _ = writeln!(out, "        val uri = java.net.URI.create(\"$baseUrl{fixture_path}\")");
376
377        let body_publisher = if let Some(body) = ctx.body {
378            let json = serde_json::to_string(body).unwrap_or_default();
379            let escaped = escape_kotlin(&json);
380            format!("java.net.http.HttpRequest.BodyPublishers.ofString(\"{escaped}\")")
381        } else {
382            "java.net.http.HttpRequest.BodyPublishers.noBody()".to_string()
383        };
384
385        let _ = writeln!(out, "        val builder = java.net.http.HttpRequest.newBuilder(uri)");
386        let _ = writeln!(out, "            .method(\"{method}\", {body_publisher})");
387
388        // Content-Type header when there is a body.
389        if ctx.body.is_some() {
390            let content_type = ctx.content_type.unwrap_or("application/json");
391            let _ = writeln!(out, "            .header(\"Content-Type\", \"{content_type}\")");
392        }
393
394        // Explicit request headers (sorted for deterministic output).
395        let mut header_pairs: Vec<(&String, &String)> = ctx.headers.iter().collect();
396        header_pairs.sort_by_key(|(k, _)| k.as_str());
397        for (name, value) in &header_pairs {
398            if JAVA_RESTRICTED_HEADERS.contains(&name.to_lowercase().as_str()) {
399                continue;
400            }
401            let escaped_name = escape_kotlin(name);
402            let escaped_value = escape_kotlin(value);
403            let _ = writeln!(out, "            .header(\"{escaped_name}\", \"{escaped_value}\")");
404        }
405
406        // Cookies as a single Cookie header.
407        if !ctx.cookies.is_empty() {
408            let mut cookie_pairs: Vec<(&String, &String)> = ctx.cookies.iter().collect();
409            cookie_pairs.sort_by_key(|(k, _)| k.as_str());
410            let cookie_str: Vec<String> = cookie_pairs.iter().map(|(k, v)| format!("{k}={v}")).collect();
411            let cookie_header = escape_kotlin(&cookie_str.join("; "));
412            let _ = writeln!(out, "            .header(\"Cookie\", \"{cookie_header}\")");
413        }
414
415        let _ = writeln!(
416            out,
417            "        val {} = java.net.http.HttpClient.newHttpClient()",
418            ctx.response_var
419        );
420        let _ = writeln!(
421            out,
422            "            .send(builder.build(), java.net.http.HttpResponse.BodyHandlers.ofString())"
423        );
424    }
425
426    fn render_assert_status(&self, out: &mut String, response_var: &str, status: u16) {
427        let _ = writeln!(
428            out,
429            "        assertEquals({status}, {response_var}.statusCode(), \"status code mismatch\")"
430        );
431    }
432
433    fn render_assert_header(&self, out: &mut String, response_var: &str, name: &str, expected: &str) {
434        let escaped_name = escape_kotlin(name);
435        match expected {
436            "<<present>>" => {
437                let _ = writeln!(
438                    out,
439                    "        assertTrue({response_var}.headers().firstValue(\"{escaped_name}\").isPresent, \"header {escaped_name} should be present\")"
440                );
441            }
442            "<<absent>>" => {
443                let _ = writeln!(
444                    out,
445                    "        assertFalse({response_var}.headers().firstValue(\"{escaped_name}\").isPresent, \"header {escaped_name} should be absent\")"
446                );
447            }
448            "<<uuid>>" => {
449                let _ = writeln!(
450                    out,
451                    "        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\")"
452                );
453            }
454            exact => {
455                let escaped_value = escape_kotlin(exact);
456                let _ = writeln!(
457                    out,
458                    "        assertTrue({response_var}.headers().firstValue(\"{escaped_name}\").orElse(\"\").contains(\"{escaped_value}\"), \"header {escaped_name} mismatch\")"
459                );
460            }
461        }
462    }
463
464    fn render_assert_json_body(&self, out: &mut String, response_var: &str, expected: &serde_json::Value) {
465        match expected {
466            serde_json::Value::Object(_) | serde_json::Value::Array(_) => {
467                let json_str = serde_json::to_string(expected).unwrap_or_default();
468                let escaped = escape_kotlin(&json_str);
469                let _ = writeln!(out, "        val bodyJson = MAPPER.readTree({response_var}.body())");
470                let _ = writeln!(out, "        val expectedJson = MAPPER.readTree(\"{escaped}\")");
471                let _ = writeln!(out, "        assertEquals(expectedJson, bodyJson, \"body mismatch\")");
472            }
473            serde_json::Value::String(s) => {
474                let escaped = escape_kotlin(s);
475                let _ = writeln!(
476                    out,
477                    "        assertEquals(\"{escaped}\", {response_var}.body().trim(), \"body mismatch\")"
478                );
479            }
480            other => {
481                let escaped = escape_kotlin(&other.to_string());
482                let _ = writeln!(
483                    out,
484                    "        assertEquals(\"{escaped}\", {response_var}.body().trim(), \"body mismatch\")"
485                );
486            }
487        }
488    }
489
490    fn render_assert_partial_body(&self, out: &mut String, response_var: &str, expected: &serde_json::Value) {
491        if let Some(obj) = expected.as_object() {
492            let _ = writeln!(out, "        val _partialTree = MAPPER.readTree({response_var}.body())");
493            for (key, val) in obj {
494                let escaped_key = escape_kotlin(key);
495                match val {
496                    serde_json::Value::String(s) => {
497                        let escaped_val = escape_kotlin(s);
498                        let _ = writeln!(
499                            out,
500                            "        assertEquals(\"{escaped_val}\", _partialTree.path(\"{escaped_key}\").asText(), \"partial body field '{escaped_key}' mismatch\")"
501                        );
502                    }
503                    serde_json::Value::Bool(b) => {
504                        let _ = writeln!(
505                            out,
506                            "        assertEquals({b}, _partialTree.path(\"{escaped_key}\").asBoolean(), \"partial body field '{escaped_key}' mismatch\")"
507                        );
508                    }
509                    serde_json::Value::Number(n) => {
510                        let _ = writeln!(
511                            out,
512                            "        assertEquals({n}, _partialTree.path(\"{escaped_key}\").numberValue(), \"partial body field '{escaped_key}' mismatch\")"
513                        );
514                    }
515                    other => {
516                        let json_str = serde_json::to_string(other).unwrap_or_default();
517                        let escaped_val = escape_kotlin(&json_str);
518                        let _ = writeln!(
519                            out,
520                            "        assertEquals(MAPPER.readTree(\"{escaped_val}\"), _partialTree.path(\"{escaped_key}\"), \"partial body field '{escaped_key}' mismatch\")"
521                        );
522                    }
523                }
524            }
525        }
526    }
527
528    fn render_assert_validation_errors(
529        &self,
530        out: &mut String,
531        response_var: &str,
532        errors: &[ValidationErrorExpectation],
533    ) {
534        let _ = writeln!(out, "        val _veTree = MAPPER.readTree({response_var}.body())");
535        let _ = writeln!(out, "        val _veErrors = _veTree.path(\"errors\")");
536        for ve in errors {
537            let escaped_msg = escape_kotlin(&ve.msg);
538            let _ = writeln!(
539                out,
540                "        assertTrue((0 until _veErrors.size()).any {{ _veErrors.get(it).path(\"msg\").asText().contains(\"{escaped_msg}\") }}, \"expected validation error containing: {escaped_msg}\")"
541            );
542        }
543    }
544}
545
546/// Render a JUnit 5 `@Test` method for an HTTP server fixture via the shared driver.
547///
548/// HTTP 101 (WebSocket upgrade) is emitted as a skip stub because Java's
549/// `HttpClient` cannot handle protocol-switch responses (throws `EOFException`).
550fn render_http_test_method(out: &mut String, fixture: &Fixture, http: &HttpFixture) {
551    // HTTP 101 (WebSocket upgrade) — java.net.http.HttpClient cannot handle upgrade responses.
552    if http.expected_response.status_code == 101 {
553        let method_name = sanitize_ident(&fixture.id).to_upper_camel_case();
554        let description = &fixture.description;
555        let _ = writeln!(out, "    @Test");
556        let _ = writeln!(out, "    fun test{method_name}() {{");
557        let _ = writeln!(out, "        // {description}");
558        let _ = writeln!(
559            out,
560            "        org.junit.jupiter.api.Assumptions.assumeTrue(false, \"Skipped: Java HttpClient cannot handle 101 Switching Protocols responses\")"
561        );
562        let _ = writeln!(out, "    }}");
563        return;
564    }
565
566    client::http_call::render_http_test(out, &KotlinTestClientRenderer, fixture);
567}
568
569#[allow(clippy::too_many_arguments)]
570fn render_test_method(
571    out: &mut String,
572    fixture: &Fixture,
573    class_name: &str,
574    _function_name: &str,
575    _result_var: &str,
576    _args: &[crate::config::ArgMapping],
577    options_type: Option<&str>,
578    field_resolver: &FieldResolver,
579    result_is_simple: bool,
580    enum_fields: &HashSet<String>,
581    e2e_config: &E2eConfig,
582) {
583    // Delegate HTTP fixtures to the HTTP-specific renderer.
584    if let Some(http) = &fixture.http {
585        render_http_test_method(out, fixture, http);
586        return;
587    }
588
589    // Resolve per-fixture call config (supports named calls via fixture.call field).
590    let call_config = e2e_config.resolve_call(fixture.call.as_deref());
591    let lang = "kotlin";
592    let call_overrides = call_config.overrides.get(lang);
593
594    // Emit a compilable stub for non-HTTP fixtures that have no Kotlin-specific call
595    // override — these fixtures call the default function (e.g., `handleRequest`) which
596    // may not exist in the Kotlin binding at this target (e.g., asyncapi, websocket).
597    if call_overrides.is_none() {
598        let method_name = fixture.id.to_upper_camel_case();
599        let description = &fixture.description;
600        let _ = writeln!(out, "    @Test");
601        let _ = writeln!(out, "    fun test{method_name}() {{");
602        let _ = writeln!(out, "        // {description}");
603        let _ = writeln!(
604            out,
605            "        org.junit.jupiter.api.Assumptions.assumeTrue(false, \"TODO: implement Kotlin e2e test for fixture '{}'\")",
606            fixture.id
607        );
608        let _ = writeln!(out, "    }}");
609        return;
610    }
611    let effective_function_name = call_overrides
612        .and_then(|o| o.function.as_ref())
613        .cloned()
614        .unwrap_or_else(|| call_config.function.to_lower_camel_case());
615    let effective_result_var = &call_config.result_var;
616    let effective_args = &call_config.args;
617    let function_name = effective_function_name.as_str();
618    let result_var = effective_result_var.as_str();
619    let args: &[crate::config::ArgMapping] = effective_args.as_slice();
620
621    let method_name = fixture.id.to_upper_camel_case();
622    let description = &fixture.description;
623    let expects_error = fixture.assertions.iter().any(|a| a.assertion_type == "error");
624
625    // Check if this test needs ObjectMapper deserialization for json_object args.
626    let needs_deser = options_type.is_some()
627        && args
628            .iter()
629            .any(|arg| arg.arg_type == "json_object" && fixture.input.get(&arg.field).is_some_and(|v| !v.is_null()));
630
631    let _ = writeln!(out, "    @Test");
632    let _ = writeln!(out, "    fun test{method_name}() {{");
633    let _ = writeln!(out, "        // {description}");
634
635    // Emit ObjectMapper deserialization bindings for json_object args.
636    if let (true, Some(opts_type)) = (needs_deser, options_type) {
637        for arg in args {
638            if arg.arg_type == "json_object" {
639                let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
640                if let Some(val) = fixture.input.get(field) {
641                    if !val.is_null() {
642                        let normalized = super::normalize_json_keys_to_snake_case(val);
643                        let json_str = serde_json::to_string(&normalized).unwrap_or_default();
644                        let var_name = &arg.name;
645                        let _ = writeln!(
646                            out,
647                            "        val {var_name} = MAPPER.readValue(\"{}\", {opts_type}::class.java)",
648                            escape_kotlin(&json_str)
649                        );
650                    }
651                }
652            }
653        }
654    }
655
656    let (setup_lines, args_str) = build_args_and_setup(&fixture.input, args, class_name, options_type, &fixture.id);
657
658    for line in &setup_lines {
659        let _ = writeln!(out, "        {line}");
660    }
661
662    if expects_error {
663        let _ = writeln!(
664            out,
665            "        assertFailsWith<Exception> {{ {class_name}.{function_name}({args_str}) }}"
666        );
667        let _ = writeln!(out, "    }}");
668        return;
669    }
670
671    let _ = writeln!(
672        out,
673        "        val {result_var} = {class_name}.{function_name}({args_str})"
674    );
675
676    for assertion in &fixture.assertions {
677        render_assertion(
678            out,
679            assertion,
680            result_var,
681            class_name,
682            field_resolver,
683            result_is_simple,
684            enum_fields,
685        );
686    }
687
688    let _ = writeln!(out, "    }}");
689}
690
691/// Build setup lines and the argument list for the function call.
692///
693/// Returns `(setup_lines, args_string)`.
694fn build_args_and_setup(
695    input: &serde_json::Value,
696    args: &[crate::config::ArgMapping],
697    class_name: &str,
698    options_type: Option<&str>,
699    fixture_id: &str,
700) -> (Vec<String>, String) {
701    if args.is_empty() {
702        return (Vec::new(), String::new());
703    }
704
705    let mut setup_lines: Vec<String> = Vec::new();
706    let mut parts: Vec<String> = Vec::new();
707
708    for arg in args {
709        if arg.arg_type == "mock_url" {
710            setup_lines.push(format!(
711                "val {} = System.getenv(\"MOCK_SERVER_URL\") + \"/fixtures/{fixture_id}\"",
712                arg.name,
713            ));
714            parts.push(arg.name.clone());
715            continue;
716        }
717
718        if arg.arg_type == "handle" {
719            let constructor_name = format!("create{}", arg.name.to_upper_camel_case());
720            let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
721            let config_value = input.get(field).unwrap_or(&serde_json::Value::Null);
722            if config_value.is_null()
723                || config_value.is_object() && config_value.as_object().is_some_and(|o| o.is_empty())
724            {
725                setup_lines.push(format!("val {} = {class_name}.{constructor_name}(null)", arg.name,));
726            } else {
727                let json_str = serde_json::to_string(config_value).unwrap_or_default();
728                let name = &arg.name;
729                setup_lines.push(format!(
730                    "val {name}Config = MAPPER.readValue(\"{}\", CrawlConfig::class.java)",
731                    escape_kotlin(&json_str),
732                ));
733                setup_lines.push(format!(
734                    "val {} = {class_name}.{constructor_name}({name}Config)",
735                    arg.name,
736                    name = name,
737                ));
738            }
739            parts.push(arg.name.clone());
740            continue;
741        }
742
743        let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
744        let val = input.get(field);
745        match val {
746            None | Some(serde_json::Value::Null) if arg.optional => {
747                continue;
748            }
749            None | Some(serde_json::Value::Null) => {
750                let default_val = match arg.arg_type.as_str() {
751                    "string" => "\"\"".to_string(),
752                    "int" | "integer" => "0".to_string(),
753                    "float" | "number" => "0.0".to_string(),
754                    "bool" | "boolean" => "false".to_string(),
755                    _ => "null".to_string(),
756                };
757                parts.push(default_val);
758            }
759            Some(v) => {
760                // For json_object args with options_type, use the pre-deserialized variable.
761                if arg.arg_type == "json_object" && options_type.is_some() {
762                    parts.push(arg.name.clone());
763                    continue;
764                }
765                // bytes args must be passed as ByteArray.
766                if arg.arg_type == "bytes" {
767                    let val = json_to_kotlin(v);
768                    parts.push(format!("{val}.toByteArray()"));
769                    continue;
770                }
771                parts.push(json_to_kotlin(v));
772            }
773        }
774    }
775
776    (setup_lines, parts.join(", "))
777}
778
779fn render_assertion(
780    out: &mut String,
781    assertion: &Assertion,
782    result_var: &str,
783    _class_name: &str,
784    field_resolver: &FieldResolver,
785    result_is_simple: bool,
786    enum_fields: &HashSet<String>,
787) {
788    // Skip assertions on fields that don't exist on the result type.
789    if let Some(f) = &assertion.field {
790        if !f.is_empty() && !field_resolver.is_valid_for_result(f) {
791            let _ = writeln!(out, "        // skipped: field '{{f}}' not available on result type");
792            return;
793        }
794    }
795
796    // Determine if this field is an enum type.
797    let field_is_enum = assertion
798        .field
799        .as_deref()
800        .is_some_and(|f| enum_fields.contains(f) || enum_fields.contains(field_resolver.resolve(f)));
801
802    let field_expr = if result_is_simple {
803        result_var.to_string()
804    } else {
805        match &assertion.field {
806            Some(f) if !f.is_empty() => {
807                let accessor = field_resolver.accessor(f, "kotlin", result_var);
808                let resolved = field_resolver.resolve(f);
809                // In Kotlin, use .orEmpty() for Optional<String> fields.
810                if field_resolver.is_optional(resolved) && !field_resolver.has_map_access(f) {
811                    format!("{accessor}.orEmpty()")
812                } else {
813                    accessor
814                }
815            }
816            _ => result_var.to_string(),
817        }
818    };
819
820    // For enum fields, use .getValue() to get the string value.
821    let string_expr = if field_is_enum {
822        format!("{field_expr}.getValue()")
823    } else {
824        field_expr.clone()
825    };
826
827    match assertion.assertion_type.as_str() {
828        "equals" => {
829            if let Some(expected) = &assertion.value {
830                let kotlin_val = json_to_kotlin(expected);
831                if expected.is_string() {
832                    let _ = writeln!(out, "        assertEquals({kotlin_val}, {string_expr}.trim())");
833                } else {
834                    let _ = writeln!(out, "        assertEquals({kotlin_val}, {field_expr})");
835                }
836            }
837        }
838        "contains" => {
839            if let Some(expected) = &assertion.value {
840                let kotlin_val = json_to_kotlin(expected);
841                let _ = writeln!(
842                    out,
843                    "        assertTrue({string_expr}.contains({kotlin_val}), \"expected to contain: \" + {kotlin_val})"
844                );
845            }
846        }
847        "contains_all" => {
848            if let Some(values) = &assertion.values {
849                for val in values {
850                    let kotlin_val = json_to_kotlin(val);
851                    let _ = writeln!(
852                        out,
853                        "        assertTrue({string_expr}.contains({kotlin_val}), \"expected to contain: \" + {kotlin_val})"
854                    );
855                }
856            }
857        }
858        "not_contains" => {
859            if let Some(expected) = &assertion.value {
860                let kotlin_val = json_to_kotlin(expected);
861                let _ = writeln!(
862                    out,
863                    "        assertFalse({string_expr}.contains({kotlin_val}), \"expected NOT to contain: \" + {kotlin_val})"
864                );
865            }
866        }
867        "not_empty" => {
868            let _ = writeln!(
869                out,
870                "        assertFalse({field_expr}.isEmpty(), \"expected non-empty value\")"
871            );
872        }
873        "is_empty" => {
874            let _ = writeln!(
875                out,
876                "        assertTrue({field_expr}.isEmpty(), \"expected empty value\")"
877            );
878        }
879        "contains_any" => {
880            if let Some(values) = &assertion.values {
881                let checks: Vec<String> = values
882                    .iter()
883                    .map(|v| {
884                        let kotlin_val = json_to_kotlin(v);
885                        format!("{string_expr}.contains({kotlin_val})")
886                    })
887                    .collect();
888                let joined = checks.join(" || ");
889                let _ = writeln!(
890                    out,
891                    "        assertTrue({joined}, \"expected to contain at least one of the specified values\")"
892                );
893            }
894        }
895        "greater_than" => {
896            if let Some(val) = &assertion.value {
897                let kotlin_val = json_to_kotlin(val);
898                let _ = writeln!(
899                    out,
900                    "        assertTrue({field_expr} > {kotlin_val}, \"expected > {{kotlin_val}}\")"
901                );
902            }
903        }
904        "less_than" => {
905            if let Some(val) = &assertion.value {
906                let kotlin_val = json_to_kotlin(val);
907                let _ = writeln!(
908                    out,
909                    "        assertTrue({field_expr} < {kotlin_val}, \"expected < {{kotlin_val}}\")"
910                );
911            }
912        }
913        "greater_than_or_equal" => {
914            if let Some(val) = &assertion.value {
915                let kotlin_val = json_to_kotlin(val);
916                let _ = writeln!(
917                    out,
918                    "        assertTrue({field_expr} >= {kotlin_val}, \"expected >= {{kotlin_val}}\")"
919                );
920            }
921        }
922        "less_than_or_equal" => {
923            if let Some(val) = &assertion.value {
924                let kotlin_val = json_to_kotlin(val);
925                let _ = writeln!(
926                    out,
927                    "        assertTrue({field_expr} <= {kotlin_val}, \"expected <= {{kotlin_val}}\")"
928                );
929            }
930        }
931        "starts_with" => {
932            if let Some(expected) = &assertion.value {
933                let kotlin_val = json_to_kotlin(expected);
934                let _ = writeln!(
935                    out,
936                    "        assertTrue({string_expr}.startsWith({kotlin_val}), \"expected to start with: \" + {kotlin_val})"
937                );
938            }
939        }
940        "ends_with" => {
941            if let Some(expected) = &assertion.value {
942                let kotlin_val = json_to_kotlin(expected);
943                let _ = writeln!(
944                    out,
945                    "        assertTrue({string_expr}.endsWith({kotlin_val}), \"expected to end with: \" + {kotlin_val})"
946                );
947            }
948        }
949        "min_length" => {
950            if let Some(val) = &assertion.value {
951                if let Some(n) = val.as_u64() {
952                    let _ = writeln!(
953                        out,
954                        "        assertTrue({field_expr}.length >= {n}, \"expected length >= {n}\")"
955                    );
956                }
957            }
958        }
959        "max_length" => {
960            if let Some(val) = &assertion.value {
961                if let Some(n) = val.as_u64() {
962                    let _ = writeln!(
963                        out,
964                        "        assertTrue({field_expr}.length <= {n}, \"expected length <= {n}\")"
965                    );
966                }
967            }
968        }
969        "count_min" => {
970            if let Some(val) = &assertion.value {
971                if let Some(n) = val.as_u64() {
972                    let _ = writeln!(
973                        out,
974                        "        assertTrue({field_expr}.size >= {n}, \"expected at least {n} elements\")"
975                    );
976                }
977            }
978        }
979        "count_equals" => {
980            if let Some(val) = &assertion.value {
981                if let Some(n) = val.as_u64() {
982                    let _ = writeln!(
983                        out,
984                        "        assertEquals({n}, {field_expr}.size, \"expected exactly {n} elements\")"
985                    );
986                }
987            }
988        }
989        "is_true" => {
990            let _ = writeln!(out, "        assertTrue({field_expr}, \"expected true\")");
991        }
992        "is_false" => {
993            let _ = writeln!(out, "        assertFalse({field_expr}, \"expected false\")");
994        }
995        "matches_regex" => {
996            if let Some(expected) = &assertion.value {
997                let kotlin_val = json_to_kotlin(expected);
998                let _ = writeln!(
999                    out,
1000                    "        assertTrue(Regex({kotlin_val}).containsMatchIn({string_expr}), \"expected value to match regex: \" + {kotlin_val})"
1001                );
1002            }
1003        }
1004        "not_error" => {
1005            // Already handled by the call succeeding without exception.
1006        }
1007        "error" => {
1008            // Handled at the test method level.
1009        }
1010        "method_result" => {
1011            // Placeholder: Kotlin support for method_result would need tree-sitter integration.
1012            let _ = writeln!(
1013                out,
1014                "        // method_result assertions not yet implemented for Kotlin"
1015            );
1016        }
1017        other => {
1018            panic!("Kotlin e2e generator: unsupported assertion type: {other}");
1019        }
1020    }
1021}
1022
1023/// Convert a `serde_json::Value` to a Kotlin literal string.
1024fn json_to_kotlin(value: &serde_json::Value) -> String {
1025    match value {
1026        serde_json::Value::String(s) => format!("\"{}\"", escape_kotlin(s)),
1027        serde_json::Value::Bool(b) => b.to_string(),
1028        serde_json::Value::Number(n) => {
1029            if n.is_f64() {
1030                format!("{}d", n)
1031            } else {
1032                n.to_string()
1033            }
1034        }
1035        serde_json::Value::Null => "null".to_string(),
1036        serde_json::Value::Array(arr) => {
1037            let items: Vec<String> = arr.iter().map(json_to_kotlin).collect();
1038            format!("listOf({})", items.join(", "))
1039        }
1040        serde_json::Value::Object(_) => {
1041            let json_str = serde_json::to_string(value).unwrap_or_default();
1042            format!("\"{}\"", escape_kotlin(&json_str))
1043        }
1044    }
1045}