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