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