use heck::{ToLowerCamelCase, ToUpperCamelCase};
use std::fmt::Write as FmtWrite;
use crate::e2e::codegen::assertion_type_skip::AssertionTypeSkip;
use crate::e2e::codegen::field_skip::FieldSkip;
use crate::e2e::escape::escape_kotlin;
use crate::e2e::field_access::FieldResolver;
use crate::e2e::fixture::Assertion;
use super::values::json_to_kotlin;
pub(super) fn parse_discriminated_union_access(field: &str) -> Option<(String, String)> {
let field = field.split_once("].").map(|(_, rest)| rest).unwrap_or(field);
let parts: Vec<&str> = field.split('.').collect();
if !(parts.len() == 3 || parts.len() == 4) {
return None;
}
if parts[0] != "metadata" || parts[1] != "format" {
return None;
}
let variant_name = parts[2];
let known_variants = [
"pdf",
"docx",
"excel",
"email",
"pptx",
"archive",
"image",
"xml",
"text",
"html",
"ocr",
"csv",
"bibtex",
"citation",
"fiction_book",
"dbf",
"jats",
"epub",
"pst",
"code",
];
if !known_variants.contains(&variant_name) {
return None;
}
let variant_pascal = variant_name.to_upper_camel_case();
let inner_field = if parts.len() == 4 {
parts[3].to_string()
} else {
String::new()
};
Some((variant_pascal, inner_field))
}
pub(super) fn render_discriminated_union_assertion(
out: &mut String,
assertion: &Assertion,
variant_var: &str,
payload_field: &str,
inner_field: &str,
field_is_collection: bool,
) {
if inner_field.is_empty() {
render_bare_variant_payload_assertion(out, assertion, variant_var, payload_field, field_is_collection);
return;
}
let field_camel = inner_field.to_lower_camel_case();
let field_expr = format!("{variant_var}.{payload_field}.{field_camel}!!");
render_discriminated_scalar_assertion(out, assertion, &field_expr, field_is_collection);
}
fn render_bare_variant_payload_assertion(
out: &mut String,
assertion: &Assertion,
variant_var: &str,
payload_field: &str,
field_is_collection: bool,
) {
if assertion.assertion_type == "count_min" && field_is_collection {
if let Some(count) = assertion.value.as_ref().and_then(serde_json::Value::as_u64) {
let payload_expr = format!("{variant_var}.{payload_field}!!");
let _ = writeln!(
out,
" assertTrue({payload_expr}.size >= {count}, \"expected count >= {count}\")"
);
} else {
render_unsupported_assertion(out, assertion);
}
} else {
render_unsupported_assertion(out, assertion);
}
}
fn render_discriminated_scalar_assertion(
out: &mut String,
assertion: &Assertion,
field_expr: &str,
field_is_collection: bool,
) {
match assertion.assertion_type.as_str() {
"equals" => render_discriminated_equals(out, assertion, field_expr),
"greater_than_or_equal" => render_discriminated_greater_than_or_equal(out, assertion, field_expr),
"less_than_or_equal" => render_discriminated_less_than_or_equal(out, assertion, field_expr),
"greater_than" => render_discriminated_greater_than(out, assertion, field_expr),
"less_than" => render_discriminated_less_than(out, assertion, field_expr),
"contains" => render_discriminated_contains(out, assertion, field_expr),
"contains_all" => render_discriminated_contains_all(out, assertion, field_expr),
"not_empty" => {
let _ = writeln!(
out,
" assertTrue({field_expr}.toString().isNotEmpty(), \"expected non-empty value\")"
);
}
"is_empty" => {
let _ = writeln!(
out,
" assertTrue({field_expr}.toString().isEmpty(), \"expected empty value\")"
);
}
"count_min" if field_is_collection => render_discriminated_count_min(out, assertion, field_expr),
_ => {
render_unsupported_assertion(out, assertion);
}
}
}
fn render_discriminated_equals(out: &mut String, assertion: &Assertion, field_expr: &str) {
if let Some(expected) = &assertion.value {
let kt_val = json_to_kotlin(expected);
if expected.is_string() {
let _ = writeln!(
out,
" assertEquals({kt_val}, {field_expr}.trim(), \"expected: {}\")",
escape_kotlin(expected.as_str().unwrap_or(""))
);
} else if expected.as_bool() == Some(true) {
let _ = writeln!(
out,
" assertTrue({field_expr} == true, \"expected true\")"
);
} else if expected.as_bool() == Some(false) {
let _ = writeln!(
out,
" assertTrue({field_expr} == false, \"expected false\")"
);
} else {
let _ = writeln!(
out,
" assertEquals({kt_val}, {field_expr}, \"expected: {kt_val}\")"
);
}
}
}
fn render_discriminated_greater_than_or_equal(out: &mut String, assertion: &Assertion, field_expr: &str) {
if let Some(val) = &assertion.value {
let kt_val = json_to_kotlin(val);
let _ = writeln!(
out,
" assertTrue({field_expr} >= {kt_val}, \"expected >= {kt_val}\")"
);
}
}
fn render_discriminated_less_than_or_equal(out: &mut String, assertion: &Assertion, field_expr: &str) {
if let Some(val) = &assertion.value {
let kt_val = json_to_kotlin(val);
let _ = writeln!(
out,
" assertTrue({field_expr} <= {kt_val}, \"expected <= {kt_val}\")"
);
}
}
fn render_discriminated_greater_than(out: &mut String, assertion: &Assertion, field_expr: &str) {
if let Some(val) = &assertion.value {
let kt_val = json_to_kotlin(val);
let _ = writeln!(
out,
" assertTrue({field_expr} > {kt_val}, \"expected > {kt_val}\")"
);
}
}
fn render_discriminated_less_than(out: &mut String, assertion: &Assertion, field_expr: &str) {
if let Some(val) = &assertion.value {
let kt_val = json_to_kotlin(val);
let _ = writeln!(
out,
" assertTrue({field_expr} < {kt_val}, \"expected < {kt_val}\")"
);
}
}
fn render_discriminated_contains(out: &mut String, assertion: &Assertion, field_expr: &str) {
if let Some(expected) = &assertion.value
&& let Some(s) = expected.as_str()
{
let lower = s.to_lowercase();
let _ = writeln!(
out,
" assertTrue({field_expr}.orEmpty().toString().lowercase().contains(\"{}\".lowercase()), \"expected to contain: {}\")",
escape_kotlin(&lower),
escape_kotlin(s)
);
}
}
fn render_discriminated_contains_all(out: &mut String, assertion: &Assertion, field_expr: &str) {
if let Some(values) = &assertion.values {
for val in values {
if let Some(s) = val.as_str() {
let lower = s.to_lowercase();
let _ = writeln!(
out,
" assertTrue({field_expr}.orEmpty().toString().lowercase().contains(\"{}\".lowercase()), \"expected to contain: {}\")",
escape_kotlin(&lower),
escape_kotlin(s)
);
}
}
}
}
fn render_discriminated_count_min(out: &mut String, assertion: &Assertion, field_expr: &str) {
if let Some(count) = assertion.value.as_ref().and_then(serde_json::Value::as_u64) {
let _ = writeln!(
out,
" assertTrue({field_expr}.size >= {count}, \"expected count >= {count}\")"
);
} else {
render_unsupported_assertion(out, assertion);
}
}
fn render_unsupported_assertion(out: &mut String, assertion: &Assertion) {
let reason = AssertionTypeSkip::DiscriminatedUnionAssertionTypeNotSupported.message(&assertion.assertion_type);
let _ = writeln!(out, " // skipped: {reason}");
}
fn resolve_union_field_is_collection(
field_resolver: &FieldResolver,
prefix: &str,
union_type: &str,
variant: &str,
suffix: &str,
) -> bool {
if suffix.is_empty() {
field_resolver.union_variant_payload_is_collection(union_type, variant)
} else {
field_resolver.union_variant_field_is_collection(prefix, variant, suffix)
}
}
fn render_union_traversal_not_implemented_skip(out: &mut String, f: &str) {
let _ = writeln!(
out,
" // skipped: {}",
FieldSkip::UnionTraversalNotImplementedForKotlin.message(f)
);
}
fn resolve_kotlin_union_variant_binding(
field_resolver: &FieldResolver,
prefix: &str,
variant_pascal: &str,
payload_field_name: &str,
payload_type: &str,
result_var: &str,
kotlin_android_style: bool,
) -> (String, String, String) {
let style = if kotlin_android_style {
"kotlin_android"
} else {
"kotlin"
};
let variant_var = format!("union{variant_pascal}");
let container = field_resolver.accessor(prefix, style, result_var);
let payload_field = crate::backends::kotlin::kotlin_field_name_with_type(
payload_field_name,
0,
Some(payload_type),
variant_pascal,
1,
);
(variant_var, container, payload_field)
}
pub(super) fn try_render_generic_union_assertion(
out: &mut String,
assertion: &Assertion,
field_resolver: &FieldResolver,
result_var: &str,
kotlin_android_style: bool,
f: &str,
) -> bool {
let Some((prefix, union_type, variant_pascal, suffix)) = field_resolver.ir_tagged_union_split(f) else {
return false;
};
let Some((payload_field_name, payload_type)) = field_resolver.union_variant_payload(&union_type, &variant_pascal)
else {
render_union_traversal_not_implemented_skip(out, f);
return true;
};
let payload_field_name = payload_field_name.to_string();
let payload_type = payload_type.to_string();
let (variant_var, container, payload_field) = resolve_kotlin_union_variant_binding(
field_resolver,
&prefix,
&variant_pascal,
&payload_field_name,
&payload_type,
result_var,
kotlin_android_style,
);
let _ = writeln!(out, " when (val {variant_var} = {container}) {{");
let _ = writeln!(out, " is {union_type}.{variant_pascal} -> {{");
let field_is_collection =
resolve_union_field_is_collection(field_resolver, &prefix, &union_type, &variant_pascal, &suffix);
render_discriminated_union_assertion(
out,
assertion,
&variant_var,
&payload_field,
&suffix,
field_is_collection,
);
let _ = writeln!(out, " }}");
let _ = writeln!(
out,
" else -> kotlin.test.assertTrue(false, \"Expected {variant_pascal} variant\")"
);
let _ = writeln!(out, " }}");
true
}
#[cfg(test)]
mod tests {
use super::*;
use crate::core::ir::{EnumDef, EnumVariant, FieldDef, TypeDef, TypeRef};
fn field(name: &str, ty: TypeRef) -> FieldDef {
FieldDef {
name: name.to_string(),
ty,
..FieldDef::default()
}
}
fn resolver() -> FieldResolver {
let types = vec![
TypeDef {
name: "Envelope".to_string(),
fields: vec![field("details", TypeRef::Named("DetailUnion".to_string()))],
..TypeDef::default()
},
TypeDef {
name: "WebPayload".to_string(),
fields: vec![
field("entries", TypeRef::Vec(Box::new(TypeRef::String))),
field("label", TypeRef::String),
],
..TypeDef::default()
},
];
let enums = vec![EnumDef {
name: "DetailUnion".to_string(),
variants: vec![
EnumVariant {
name: "Web".to_string(),
fields: vec![field("payload", TypeRef::Named("WebPayload".to_string()))],
..EnumVariant::default()
},
EnumVariant {
name: "Empty".to_string(),
..EnumVariant::default()
},
EnumVariant {
name: "Pair".to_string(),
fields: vec![field("left", TypeRef::String), field("right", TypeRef::String)],
..EnumVariant::default()
},
EnumVariant {
name: "Found".to_string(),
fields: vec![field(
"_0",
TypeRef::Vec(Box::new(TypeRef::Named("FoundEntry".to_string()))),
)],
..EnumVariant::default()
},
EnumVariant {
name: "Numbers".to_string(),
fields: vec![field("_0", TypeRef::Vec(Box::new(TypeRef::String)))],
..EnumVariant::default()
},
],
..EnumDef::default()
}];
FieldResolver::new(
&std::collections::HashMap::new(),
&std::collections::HashSet::new(),
&std::collections::HashSet::new(),
&std::collections::HashSet::new(),
&std::collections::HashSet::new(),
)
.with_ir_enum_map(
FieldResolver::ir_enum_fields(&types, &enums),
Some("Envelope".to_string()),
)
.with_ir_collection_map(
FieldResolver::ir_collection_fields(&types),
Some("Envelope".to_string()),
)
}
#[test]
fn count_min_on_a_union_payload_collection_renders_a_size_assertion() {
let assertion = Assertion {
assertion_type: "count_min".to_string(),
field: Some("details.web.entries".to_string()),
value: Some(serde_json::json!(2)),
..Assertion::default()
};
let mut out = String::new();
render_discriminated_union_assertion(
&mut out,
&assertion,
"webVariant",
"payload",
"entries",
resolver().union_variant_field_is_collection("details", "web", "entries"),
);
assert_eq!(
out,
" assertTrue(webVariant.payload.entries!!.size >= 2, \"expected count >= 2\")\n"
);
}
#[test]
fn count_min_on_a_union_payload_scalar_stays_an_explicit_skip() {
let assertion = Assertion {
assertion_type: "count_min".to_string(),
value: Some(serde_json::json!(2)),
..Assertion::default()
};
let mut out = String::new();
render_discriminated_union_assertion(
&mut out,
&assertion,
"webVariant",
"payload",
"label",
resolver().union_variant_field_is_collection("details", "web", "label"),
);
assert_eq!(
out,
" // skipped: assertion type 'count_min' not yet supported for discriminated union fields\n"
);
}
#[test]
fn count_min_uses_ir_tagged_union_path_without_method_call_config() {
let assertion = Assertion {
assertion_type: "count_min".to_string(),
field: Some("details.web.entries".to_string()),
value: Some(serde_json::json!(2)),
..Assertion::default()
};
let mut out = String::new();
assert!(try_render_generic_union_assertion(
&mut out,
&assertion,
&resolver(),
"result",
true,
"details.web.entries",
));
assert!(out.contains("DetailUnion.Web"));
assert!(out.contains("payload.entries!!.size >= 2"));
assert!(!out.contains("skipped:"));
}
#[test]
fn count_min_on_a_bare_union_variant_collection_payload_renders_a_size_assertion() {
let assertion = Assertion {
assertion_type: "count_min".to_string(),
field: Some("details.found".to_string()),
value: Some(serde_json::json!(2)),
..Assertion::default()
};
let mut out = String::new();
assert!(try_render_generic_union_assertion(
&mut out,
&assertion,
&resolver(),
"result",
true,
"details.found",
));
assert_eq!(
out,
concat!(
" when (val unionFound = result.details) {\n",
" is DetailUnion.Found -> {\n",
" assertTrue(unionFound.entry!!.size >= 2, \"expected count >= 2\")\n",
" }\n",
" else -> kotlin.test.assertTrue(false, \"Expected Found variant\")\n",
" }\n",
)
);
}
#[test]
fn count_min_on_a_bare_union_variant_scalar_payload_stays_an_explicit_skip() {
let assertion = Assertion {
assertion_type: "count_min".to_string(),
field: Some("details.web".to_string()),
value: Some(serde_json::json!(2)),
..Assertion::default()
};
let mut out = String::new();
assert!(try_render_generic_union_assertion(
&mut out,
&assertion,
&resolver(),
"result",
true,
"details.web",
));
assert_eq!(
out,
concat!(
" when (val unionWeb = result.details) {\n",
" is DetailUnion.Web -> {\n",
" // skipped: assertion type 'count_min' not yet supported ",
"for discriminated union fields\n",
" }\n",
" else -> kotlin.test.assertTrue(false, \"Expected Web variant\")\n",
" }\n",
)
);
}
#[test]
fn bare_union_variant_with_primitive_vec_payload_stays_a_registered_skip_not_silence() {
let assertion = Assertion {
assertion_type: "count_min".to_string(),
field: Some("details.numbers".to_string()),
value: Some(serde_json::json!(2)),
..Assertion::default()
};
let mut out = String::new();
assert!(try_render_generic_union_assertion(
&mut out,
&assertion,
&resolver(),
"result",
true,
"details.numbers",
));
assert!(!out.is_empty(), "must never render nothing at all");
assert_eq!(
out,
concat!(
" // skipped: field 'details.numbers' crosses a tagged-union variant ",
"boundary alef does not yet lower for this variant shape in Kotlin\n",
)
);
}
#[test]
fn unsupported_union_shapes_stay_registered_skips() {
for field in [
"details.unknown.entries",
"details.empty.entries",
"details.pair.entries",
"details.web.entries.value",
"details.web.entries[0]",
] {
let assertion = Assertion {
assertion_type: "count_min".to_string(),
field: Some(field.to_string()),
value: Some(serde_json::json!(2)),
..Assertion::default()
};
let mut out = String::new();
assert!(try_render_generic_union_assertion(
&mut out,
&assertion,
&resolver(),
"result",
true,
field,
));
assert!(out.contains("skipped:"));
}
}
}