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_java, sanitize_filename};
8use crate::field_access::FieldResolver;
9use crate::fixture::{Assertion, Fixture, FixtureGroup};
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            .unwrap_or_else(|| "0.1.0".to_string());
74        let kotlin_pkg_id = alef_config.kotlin_package();
75
76        // Generate build.gradle.kts.
77        files.push(GeneratedFile {
78            path: output_base.join("build.gradle.kts"),
79            content: render_build_gradle(&pkg_name, &kotlin_pkg_id, &kotlin_version, e2e_config.dep_mode),
80            generated_header: false,
81        });
82
83        // Generate test files per category. Path mirrors the configured Kotlin
84        // package so the package declaration in each test file matches its
85        // filesystem location.
86        let mut test_base = output_base.join("src").join("test").join("kotlin");
87        for segment in kotlin_pkg_id.split('.') {
88            test_base = test_base.join(segment);
89        }
90        let test_base = test_base.join("e2e");
91
92        // Resolve options_type from override.
93        let options_type = overrides.and_then(|o| o.options_type.clone());
94        let field_resolver = FieldResolver::new(
95            &e2e_config.fields,
96            &e2e_config.fields_optional,
97            &e2e_config.result_fields,
98            &e2e_config.fields_array,
99        );
100
101        for group in groups {
102            let active: Vec<&Fixture> = group
103                .fixtures
104                .iter()
105                .filter(|f| f.skip.as_ref().is_none_or(|s| !s.should_skip(lang)))
106                .collect();
107
108            if active.is_empty() {
109                continue;
110            }
111
112            let class_file_name = format!("{}Test.kt", sanitize_filename(&group.category).to_upper_camel_case());
113            let content = render_test_file(
114                &group.category,
115                &active,
116                &class_name,
117                &function_name,
118                &kotlin_pkg_id,
119                result_var,
120                &e2e_config.call.args,
121                options_type.as_deref(),
122                &field_resolver,
123                result_is_simple,
124                &e2e_config.fields_enum,
125                e2e_config,
126            );
127            files.push(GeneratedFile {
128                path: test_base.join(class_file_name),
129                content,
130                generated_header: true,
131            });
132        }
133
134        Ok(files)
135    }
136
137    fn language_name(&self) -> &'static str {
138        "kotlin"
139    }
140}
141
142// ---------------------------------------------------------------------------
143// Rendering
144// ---------------------------------------------------------------------------
145
146fn render_build_gradle(
147    pkg_name: &str,
148    kotlin_pkg_id: &str,
149    pkg_version: &str,
150    dep_mode: crate::config::DependencyMode,
151) -> String {
152    let dep_block = match dep_mode {
153        crate::config::DependencyMode::Registry => {
154            format!(r#"    testImplementation("{pkg_name}:{pkg_version}")"#)
155        }
156        crate::config::DependencyMode::Local => {
157            // Local mode: reference local JAR or Maven build output.
158            format!(r#"    testImplementation(files("../../packages/kotlin/build/libs/{pkg_name}-{pkg_version}.jar"))"#)
159        }
160    };
161
162    let kotlin_plugin = maven::KOTLIN_JVM_PLUGIN;
163    let junit = maven::JUNIT;
164    let jackson = maven::JACKSON_E2E;
165    let jvm_target = toolchain::JVM_TARGET;
166    format!(
167        r#"import org.jetbrains.kotlin.gradle.dsl.JvmTarget
168
169plugins {{
170    kotlin("jvm") version "{kotlin_plugin}"
171}}
172
173group = "{kotlin_pkg_id}"
174version = "0.1.0"
175
176java {{
177    sourceCompatibility = JavaVersion.VERSION_{jvm_target}
178    targetCompatibility = JavaVersion.VERSION_{jvm_target}
179}}
180
181kotlin {{
182    compilerOptions {{
183        jvmTarget.set(JvmTarget.JVM_{jvm_target})
184    }}
185}}
186
187repositories {{
188    mavenCentral()
189}}
190
191dependencies {{
192{dep_block}
193    testImplementation("org.junit.jupiter:junit-jupiter-api:{junit}")
194    testImplementation("org.junit.jupiter:junit-jupiter-engine:{junit}")
195    testImplementation("com.fasterxml.jackson.core:jackson-databind:{jackson}")
196    testImplementation("com.fasterxml.jackson.datatype:jackson-datatype-jdk8:{jackson}")
197}}
198
199tasks.test {{
200    useJUnitPlatform()
201    environment("java.library.path", "../../target/release")
202}}
203"#
204    )
205}
206
207#[allow(clippy::too_many_arguments)]
208fn render_test_file(
209    category: &str,
210    fixtures: &[&Fixture],
211    class_name: &str,
212    function_name: &str,
213    kotlin_pkg_id: &str,
214    result_var: &str,
215    args: &[crate::config::ArgMapping],
216    options_type: Option<&str>,
217    field_resolver: &FieldResolver,
218    result_is_simple: bool,
219    enum_fields: &HashSet<String>,
220    e2e_config: &E2eConfig,
221) -> String {
222    let mut out = String::new();
223    out.push_str(&hash::header(CommentStyle::DoubleSlash));
224    let test_class_name = format!("{}Test", sanitize_filename(category).to_upper_camel_case());
225
226    // If the class_name is fully qualified (contains '.'), import it and use
227    // only the simple name for method calls. Otherwise use it as-is.
228    let (import_path, simple_class) = if class_name.contains('.') {
229        let simple = class_name.rsplit('.').next().unwrap_or(class_name);
230        (class_name, simple)
231    } else {
232        ("", class_name)
233    };
234
235    let _ = writeln!(out, "package {kotlin_pkg_id}.e2e");
236    let _ = writeln!(out);
237
238    // Check if any fixture uses a json_object arg with options_type (needs ObjectMapper).
239    let needs_object_mapper_for_options = options_type.is_some()
240        && fixtures.iter().any(|f| {
241            args.iter().any(|arg| {
242                let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
243                arg.arg_type == "json_object" && f.input.get(field).is_some_and(|v| !v.is_null())
244            })
245        });
246    // Also need ObjectMapper when a handle arg has a non-null config.
247    let needs_object_mapper_for_handle = fixtures.iter().any(|f| {
248        args.iter().filter(|a| a.arg_type == "handle").any(|a| {
249            let v = f.input.get(&a.field).unwrap_or(&serde_json::Value::Null);
250            !(v.is_null() || v.is_object() && v.as_object().is_some_and(|o| o.is_empty()))
251        })
252    });
253    let needs_object_mapper = needs_object_mapper_for_options || needs_object_mapper_for_handle;
254
255    let _ = writeln!(out, "import org.junit.jupiter.api.Test");
256    let _ = writeln!(out, "import kotlin.test.assertEquals");
257    let _ = writeln!(out, "import kotlin.test.assertTrue");
258    let _ = writeln!(out, "import kotlin.test.assertFalse");
259    let _ = writeln!(out, "import kotlin.test.assertFailsWith");
260    if !import_path.is_empty() {
261        let _ = writeln!(out, "import {import_path}");
262    }
263    if needs_object_mapper {
264        let _ = writeln!(out, "import com.fasterxml.jackson.databind.ObjectMapper");
265        let _ = writeln!(out, "import com.fasterxml.jackson.datatype.jdk8.Jdk8Module");
266    }
267    // Import the options type if tests use it (it's in the same package as the main class).
268    if let Some(opts_type) = options_type {
269        if needs_object_mapper {
270            // Derive the fully-qualified name from the main class import path.
271            let opts_package = if !import_path.is_empty() {
272                let pkg = import_path.rsplit_once('.').map(|(p, _)| p).unwrap_or("");
273                format!("{pkg}.{opts_type}")
274            } else {
275                opts_type.to_string()
276            };
277            let _ = writeln!(out, "import {opts_package}");
278        }
279    }
280    // Import CrawlConfig when handle args need JSON deserialization.
281    if needs_object_mapper_for_handle && !import_path.is_empty() {
282        let pkg = import_path.rsplit_once('.').map(|(p, _)| p).unwrap_or("");
283        let _ = writeln!(out, "import {pkg}.CrawlConfig");
284    }
285    let _ = writeln!(out);
286
287    let _ = writeln!(out, "/** E2e tests for category: {category}. */");
288    let _ = writeln!(out, "class {test_class_name} {{");
289
290    if needs_object_mapper {
291        let _ = writeln!(out);
292        let _ = writeln!(out, "    companion object {{");
293        let _ = writeln!(
294            out,
295            "        private val MAPPER = ObjectMapper().registerModule(Jdk8Module())"
296        );
297        let _ = writeln!(out, "    }}");
298    }
299
300    for fixture in fixtures {
301        render_test_method(
302            &mut out,
303            fixture,
304            simple_class,
305            function_name,
306            result_var,
307            args,
308            options_type,
309            field_resolver,
310            result_is_simple,
311            enum_fields,
312            e2e_config,
313        );
314        let _ = writeln!(out);
315    }
316
317    let _ = writeln!(out, "}}");
318    out
319}
320
321#[allow(clippy::too_many_arguments)]
322fn render_test_method(
323    out: &mut String,
324    fixture: &Fixture,
325    class_name: &str,
326    _function_name: &str,
327    _result_var: &str,
328    _args: &[crate::config::ArgMapping],
329    options_type: Option<&str>,
330    field_resolver: &FieldResolver,
331    result_is_simple: bool,
332    enum_fields: &HashSet<String>,
333    e2e_config: &E2eConfig,
334) {
335    // Resolve per-fixture call config (supports named calls via fixture.call field).
336    let call_config = e2e_config.resolve_call(fixture.call.as_deref());
337    let lang = "kotlin";
338    let call_overrides = call_config.overrides.get(lang);
339    let effective_function_name = call_overrides
340        .and_then(|o| o.function.as_ref())
341        .cloned()
342        .unwrap_or_else(|| call_config.function.to_lower_camel_case());
343    let effective_result_var = &call_config.result_var;
344    let effective_args = &call_config.args;
345    let function_name = effective_function_name.as_str();
346    let result_var = effective_result_var.as_str();
347    let args: &[crate::config::ArgMapping] = effective_args.as_slice();
348
349    let method_name = fixture.id.to_upper_camel_case();
350    let description = &fixture.description;
351    let expects_error = fixture.assertions.iter().any(|a| a.assertion_type == "error");
352
353    // Check if this test needs ObjectMapper deserialization for json_object args.
354    let needs_deser = options_type.is_some()
355        && args
356            .iter()
357            .any(|arg| arg.arg_type == "json_object" && fixture.input.get(&arg.field).is_some_and(|v| !v.is_null()));
358
359    let _ = writeln!(out, "    @Test");
360    let _ = writeln!(out, "    fun test{method_name}() {{");
361    let _ = writeln!(out, "        // {description}");
362
363    // Emit ObjectMapper deserialization bindings for json_object args.
364    if let (true, Some(opts_type)) = (needs_deser, options_type) {
365        for arg in args {
366            if arg.arg_type == "json_object" {
367                let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
368                if let Some(val) = fixture.input.get(field) {
369                    if !val.is_null() {
370                        let normalized = super::normalize_json_keys_to_snake_case(val);
371                        let json_str = serde_json::to_string(&normalized).unwrap_or_default();
372                        let var_name = &arg.name;
373                        let _ = writeln!(
374                            out,
375                            "        val {var_name} = MAPPER.readValue(\"{}\", {opts_type}::class.java)",
376                            escape_java(&json_str)
377                        );
378                    }
379                }
380            }
381        }
382    }
383
384    let (setup_lines, args_str) = build_args_and_setup(&fixture.input, args, class_name, options_type, &fixture.id);
385
386    for line in &setup_lines {
387        let _ = writeln!(out, "        {line}");
388    }
389
390    if expects_error {
391        let _ = writeln!(
392            out,
393            "        assertFailsWith<Exception> {{ {class_name}.{function_name}({args_str}) }}"
394        );
395        let _ = writeln!(out, "    }}");
396        return;
397    }
398
399    let _ = writeln!(
400        out,
401        "        val {result_var} = {class_name}.{function_name}({args_str})"
402    );
403
404    for assertion in &fixture.assertions {
405        render_assertion(
406            out,
407            assertion,
408            result_var,
409            class_name,
410            field_resolver,
411            result_is_simple,
412            enum_fields,
413        );
414    }
415
416    let _ = writeln!(out, "    }}");
417}
418
419/// Build setup lines and the argument list for the function call.
420///
421/// Returns `(setup_lines, args_string)`.
422fn build_args_and_setup(
423    input: &serde_json::Value,
424    args: &[crate::config::ArgMapping],
425    class_name: &str,
426    options_type: Option<&str>,
427    fixture_id: &str,
428) -> (Vec<String>, String) {
429    if args.is_empty() {
430        return (Vec::new(), String::new());
431    }
432
433    let mut setup_lines: Vec<String> = Vec::new();
434    let mut parts: Vec<String> = Vec::new();
435
436    for arg in args {
437        if arg.arg_type == "mock_url" {
438            setup_lines.push(format!(
439                "val {} = System.getenv(\"MOCK_SERVER_URL\") + \"/fixtures/{fixture_id}\"",
440                arg.name,
441            ));
442            parts.push(arg.name.clone());
443            continue;
444        }
445
446        if arg.arg_type == "handle" {
447            let constructor_name = format!("create{}", arg.name.to_upper_camel_case());
448            let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
449            let config_value = input.get(field).unwrap_or(&serde_json::Value::Null);
450            if config_value.is_null()
451                || config_value.is_object() && config_value.as_object().is_some_and(|o| o.is_empty())
452            {
453                setup_lines.push(format!("val {} = {class_name}.{constructor_name}(null)", arg.name,));
454            } else {
455                let json_str = serde_json::to_string(config_value).unwrap_or_default();
456                let name = &arg.name;
457                setup_lines.push(format!(
458                    "val {name}Config = MAPPER.readValue(\"{}\", CrawlConfig::class.java)",
459                    escape_java(&json_str),
460                ));
461                setup_lines.push(format!(
462                    "val {} = {class_name}.{constructor_name}({name}Config)",
463                    arg.name,
464                    name = name,
465                ));
466            }
467            parts.push(arg.name.clone());
468            continue;
469        }
470
471        let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
472        let val = input.get(field);
473        match val {
474            None | Some(serde_json::Value::Null) if arg.optional => {
475                continue;
476            }
477            None | Some(serde_json::Value::Null) => {
478                let default_val = match arg.arg_type.as_str() {
479                    "string" => "\"\"".to_string(),
480                    "int" | "integer" => "0".to_string(),
481                    "float" | "number" => "0.0".to_string(),
482                    "bool" | "boolean" => "false".to_string(),
483                    _ => "null".to_string(),
484                };
485                parts.push(default_val);
486            }
487            Some(v) => {
488                // For json_object args with options_type, use the pre-deserialized variable.
489                if arg.arg_type == "json_object" && options_type.is_some() {
490                    parts.push(arg.name.clone());
491                    continue;
492                }
493                // bytes args must be passed as ByteArray.
494                if arg.arg_type == "bytes" {
495                    let val = json_to_kotlin(v);
496                    parts.push(format!("{val}.toByteArray()"));
497                    continue;
498                }
499                parts.push(json_to_kotlin(v));
500            }
501        }
502    }
503
504    (setup_lines, parts.join(", "))
505}
506
507fn render_assertion(
508    out: &mut String,
509    assertion: &Assertion,
510    result_var: &str,
511    _class_name: &str,
512    field_resolver: &FieldResolver,
513    result_is_simple: bool,
514    enum_fields: &HashSet<String>,
515) {
516    // Skip assertions on fields that don't exist on the result type.
517    if let Some(f) = &assertion.field {
518        if !f.is_empty() && !field_resolver.is_valid_for_result(f) {
519            let _ = writeln!(out, "        // skipped: field '{{f}}' not available on result type");
520            return;
521        }
522    }
523
524    // Determine if this field is an enum type.
525    let field_is_enum = assertion
526        .field
527        .as_deref()
528        .is_some_and(|f| enum_fields.contains(f) || enum_fields.contains(field_resolver.resolve(f)));
529
530    let field_expr = if result_is_simple {
531        result_var.to_string()
532    } else {
533        match &assertion.field {
534            Some(f) if !f.is_empty() => {
535                let accessor = field_resolver.accessor(f, "kotlin", result_var);
536                let resolved = field_resolver.resolve(f);
537                // In Kotlin, use .orEmpty() for Optional<String> fields.
538                if field_resolver.is_optional(resolved) && !field_resolver.has_map_access(f) {
539                    format!("{accessor}.orEmpty()")
540                } else {
541                    accessor
542                }
543            }
544            _ => result_var.to_string(),
545        }
546    };
547
548    // For enum fields, use .getValue() to get the string value.
549    let string_expr = if field_is_enum {
550        format!("{field_expr}.getValue()")
551    } else {
552        field_expr.clone()
553    };
554
555    match assertion.assertion_type.as_str() {
556        "equals" => {
557            if let Some(expected) = &assertion.value {
558                let kotlin_val = json_to_kotlin(expected);
559                if expected.is_string() {
560                    let _ = writeln!(out, "        assertEquals({kotlin_val}, {string_expr}.trim())");
561                } else {
562                    let _ = writeln!(out, "        assertEquals({kotlin_val}, {field_expr})");
563                }
564            }
565        }
566        "contains" => {
567            if let Some(expected) = &assertion.value {
568                let kotlin_val = json_to_kotlin(expected);
569                let _ = writeln!(
570                    out,
571                    "        assertTrue({string_expr}.contains({kotlin_val}), \"expected to contain: \" + {kotlin_val})"
572                );
573            }
574        }
575        "contains_all" => {
576            if let Some(values) = &assertion.values {
577                for val in values {
578                    let kotlin_val = json_to_kotlin(val);
579                    let _ = writeln!(
580                        out,
581                        "        assertTrue({string_expr}.contains({kotlin_val}), \"expected to contain: \" + {kotlin_val})"
582                    );
583                }
584            }
585        }
586        "not_contains" => {
587            if let Some(expected) = &assertion.value {
588                let kotlin_val = json_to_kotlin(expected);
589                let _ = writeln!(
590                    out,
591                    "        assertFalse({string_expr}.contains({kotlin_val}), \"expected NOT to contain: \" + {kotlin_val})"
592                );
593            }
594        }
595        "not_empty" => {
596            let _ = writeln!(
597                out,
598                "        assertFalse({field_expr}.isEmpty(), \"expected non-empty value\")"
599            );
600        }
601        "is_empty" => {
602            let _ = writeln!(
603                out,
604                "        assertTrue({field_expr}.isEmpty(), \"expected empty value\")"
605            );
606        }
607        "contains_any" => {
608            if let Some(values) = &assertion.values {
609                let checks: Vec<String> = values
610                    .iter()
611                    .map(|v| {
612                        let kotlin_val = json_to_kotlin(v);
613                        format!("{string_expr}.contains({kotlin_val})")
614                    })
615                    .collect();
616                let joined = checks.join(" || ");
617                let _ = writeln!(
618                    out,
619                    "        assertTrue({joined}, \"expected to contain at least one of the specified values\")"
620                );
621            }
622        }
623        "greater_than" => {
624            if let Some(val) = &assertion.value {
625                let kotlin_val = json_to_kotlin(val);
626                let _ = writeln!(
627                    out,
628                    "        assertTrue({field_expr} > {kotlin_val}, \"expected > {{kotlin_val}}\")"
629                );
630            }
631        }
632        "less_than" => {
633            if let Some(val) = &assertion.value {
634                let kotlin_val = json_to_kotlin(val);
635                let _ = writeln!(
636                    out,
637                    "        assertTrue({field_expr} < {kotlin_val}, \"expected < {{kotlin_val}}\")"
638                );
639            }
640        }
641        "greater_than_or_equal" => {
642            if let Some(val) = &assertion.value {
643                let kotlin_val = json_to_kotlin(val);
644                let _ = writeln!(
645                    out,
646                    "        assertTrue({field_expr} >= {kotlin_val}, \"expected >= {{kotlin_val}}\")"
647                );
648            }
649        }
650        "less_than_or_equal" => {
651            if let Some(val) = &assertion.value {
652                let kotlin_val = json_to_kotlin(val);
653                let _ = writeln!(
654                    out,
655                    "        assertTrue({field_expr} <= {kotlin_val}, \"expected <= {{kotlin_val}}\")"
656                );
657            }
658        }
659        "starts_with" => {
660            if let Some(expected) = &assertion.value {
661                let kotlin_val = json_to_kotlin(expected);
662                let _ = writeln!(
663                    out,
664                    "        assertTrue({string_expr}.startsWith({kotlin_val}), \"expected to start with: \" + {kotlin_val})"
665                );
666            }
667        }
668        "ends_with" => {
669            if let Some(expected) = &assertion.value {
670                let kotlin_val = json_to_kotlin(expected);
671                let _ = writeln!(
672                    out,
673                    "        assertTrue({string_expr}.endsWith({kotlin_val}), \"expected to end with: \" + {kotlin_val})"
674                );
675            }
676        }
677        "min_length" => {
678            if let Some(val) = &assertion.value {
679                if let Some(n) = val.as_u64() {
680                    let _ = writeln!(
681                        out,
682                        "        assertTrue({field_expr}.length >= {n}, \"expected length >= {n}\")"
683                    );
684                }
685            }
686        }
687        "max_length" => {
688            if let Some(val) = &assertion.value {
689                if let Some(n) = val.as_u64() {
690                    let _ = writeln!(
691                        out,
692                        "        assertTrue({field_expr}.length <= {n}, \"expected length <= {n}\")"
693                    );
694                }
695            }
696        }
697        "count_min" => {
698            if let Some(val) = &assertion.value {
699                if let Some(n) = val.as_u64() {
700                    let _ = writeln!(
701                        out,
702                        "        assertTrue({field_expr}.size >= {n}, \"expected at least {n} elements\")"
703                    );
704                }
705            }
706        }
707        "count_equals" => {
708            if let Some(val) = &assertion.value {
709                if let Some(n) = val.as_u64() {
710                    let _ = writeln!(
711                        out,
712                        "        assertEquals({n}, {field_expr}.size, \"expected exactly {n} elements\")"
713                    );
714                }
715            }
716        }
717        "is_true" => {
718            let _ = writeln!(out, "        assertTrue({field_expr}, \"expected true\")");
719        }
720        "is_false" => {
721            let _ = writeln!(out, "        assertFalse({field_expr}, \"expected false\")");
722        }
723        "matches_regex" => {
724            if let Some(expected) = &assertion.value {
725                let kotlin_val = json_to_kotlin(expected);
726                let _ = writeln!(
727                    out,
728                    "        assertTrue(Regex({kotlin_val}).containsMatchIn({string_expr}), \"expected value to match regex: \" + {kotlin_val})"
729                );
730            }
731        }
732        "not_error" => {
733            // Already handled by the call succeeding without exception.
734        }
735        "error" => {
736            // Handled at the test method level.
737        }
738        "method_result" => {
739            // Placeholder: Kotlin support for method_result would need tree-sitter integration.
740            let _ = writeln!(
741                out,
742                "        // method_result assertions not yet implemented for Kotlin"
743            );
744        }
745        other => {
746            panic!("Kotlin e2e generator: unsupported assertion type: {other}");
747        }
748    }
749}
750
751/// Convert a `serde_json::Value` to a Kotlin literal string.
752fn json_to_kotlin(value: &serde_json::Value) -> String {
753    match value {
754        serde_json::Value::String(s) => format!("\"{}\"", escape_java(s)),
755        serde_json::Value::Bool(b) => b.to_string(),
756        serde_json::Value::Number(n) => {
757            if n.is_f64() {
758                format!("{}d", n)
759            } else {
760                n.to_string()
761            }
762        }
763        serde_json::Value::Null => "null".to_string(),
764        serde_json::Value::Array(arr) => {
765            let items: Vec<String> = arr.iter().map(json_to_kotlin).collect();
766            format!("listOf({})", items.join(", "))
767        }
768        serde_json::Value::Object(_) => {
769            let json_str = serde_json::to_string(value).unwrap_or_default();
770            format!("\"{}\"", escape_java(&json_str))
771        }
772    }
773}