use std::fmt::Write as FmtWrite;
use crate::e2e::codegen::field_skip::FieldSkip;
use crate::e2e::field_access::{FieldResolver, JsonNavStep};
use crate::e2e::fixture::Assertion;
use super::values::escape_swift;
pub(super) fn render_json_bridged_navigated_assertion(
out: &mut String,
assertion: &Assertion,
field_resolver: &FieldResolver,
result_var: &str,
) -> bool {
let Some(field) = assertion.field.as_deref().filter(|f| !f.is_empty()) else {
return false;
};
let Some((leaf_field, steps)) = field_resolver.swift_json_bridged_navigation(field) else {
return false;
};
let leaf_expr = field_resolver.accessor(&leaf_field, "swift", result_var);
let value_expr = navigated_value_expr(&leaf_expr, &steps);
let local = json_nav_local_name(field, &assertion.assertion_type);
let Some(body) = build_assertion_lines(assertion, &local) else {
let _ = writeln!(
out,
" // skipped: {}; assertion type '{}' has no Swift renderer over decoded JSON yet",
FieldSkip::NavigatedJsonBridgedAssertionTypeNotSupportedInSwift.message(field),
assertion.assertion_type
);
return true;
};
let _ = writeln!(out, " let {local}: Any? = {value_expr}");
out.push_str(&body);
true
}
fn navigated_value_expr(leaf_expr: &str, steps: &[JsonNavStep]) -> String {
let json_text_expr = if leaf_expr.contains("?.") {
format!("({leaf_expr}?.toString() ?? \"null\")")
} else {
format!("{leaf_expr}.toString()")
};
let mut expr = format!("(try? JSONSerialization.jsonObject(with: Data({json_text_expr}.utf8)))");
for step in steps {
expr = match step {
JsonNavStep::Index(index) => {
format!("(({expr}) as? [Any]).flatMap {{ $0.dropFirst({index}).first }}")
}
JsonNavStep::Key(key) => format!("(({expr}) as? [String: Any])?[\"{}\"]", escape_swift(key)),
};
}
expr
}
fn json_nav_local_name(field: &str, assertion_type: &str) -> String {
use std::hash::{Hash, Hasher};
let mut hasher = std::collections::hash_map::DefaultHasher::new();
field.hash(&mut hasher);
assertion_type.hash(&mut hasher);
format!("_json_nav_{:x}", hasher.finish() & 0xffff_ffff)
}
fn build_assertion_lines(assertion: &Assertion, local: &str) -> Option<String> {
match assertion.assertion_type.as_str() {
"equals" => build_equals(assertion, local),
"not_empty" => Some(build_presence(local, true)),
"is_empty" => Some(build_presence(local, false)),
"count_min" | "count_equals" => build_count(assertion, local),
"greater_than" | "greater_than_or_equal" => build_numeric_compare(assertion, local),
"contains" => build_contains(assertion, local),
"contains_all" => build_contains_all(assertion, local),
"min_length" => build_min_length(assertion, local),
_ => None,
}
}
fn build_equals(assertion: &Assertion, local: &str) -> Option<String> {
let expected = assertion.value.as_ref()?;
if let Some(s) = expected.as_str() {
let escaped = escape_swift(s);
Some(format!(
" XCTAssertEqual(({local} as? String) ?? \"\", \"{escaped}\")\n"
))
} else if let Some(b) = expected.as_bool() {
Some(format!(" XCTAssertEqual(({local} as? Bool) ?? false, {b})\n"))
} else {
let n = expected.as_i64()?;
Some(format!(
" XCTAssertEqual(({local} as? NSNumber)?.intValue ?? 0, {n})\n"
))
}
}
fn build_presence(local: &str, expect_non_empty: bool) -> String {
let is_empty_predicate =
format!("(({local} as? [Any])?.isEmpty ?? true) && (({local} as? String)?.isEmpty ?? true)");
if expect_non_empty {
format!(" XCTAssertFalse({is_empty_predicate}, \"expected non-empty value\")\n")
} else {
format!(" XCTAssertTrue({is_empty_predicate}, \"expected empty value\")\n")
}
}
fn build_count(assertion: &Assertion, local: &str) -> Option<String> {
let n = assertion.value.as_ref()?.as_u64()?;
let count_expr = format!("(({local} as? [Any])?.count ?? 0)");
Some(if assertion.assertion_type == "count_min" {
format!(" XCTAssertGreaterThanOrEqual({count_expr}, {n})\n")
} else {
format!(" XCTAssertEqual({count_expr}, {n})\n")
})
}
fn build_numeric_compare(assertion: &Assertion, local: &str) -> Option<String> {
let n = assertion.value.as_ref()?.as_i64()?;
let num_expr = format!("(({local} as? NSNumber)?.intValue ?? 0)");
Some(if assertion.assertion_type == "greater_than" {
format!(" XCTAssertGreaterThan({num_expr}, {n})\n")
} else {
format!(" XCTAssertGreaterThanOrEqual({num_expr}, {n})\n")
})
}
fn build_contains(assertion: &Assertion, local: &str) -> Option<String> {
let s = assertion.value.as_ref()?.as_str()?;
let escaped = escape_swift(s);
Some(format!(
" XCTAssertTrue((({local} as? String) ?? \"\").contains(\"{escaped}\"), \"expected to contain: \
{escaped}\")\n"
))
}
fn build_min_length(assertion: &Assertion, local: &str) -> Option<String> {
let n = assertion.value.as_ref()?.as_u64()?;
Some(format!(
" XCTAssertGreaterThanOrEqual((({local} as? String) ?? \"\").count, {n})\n"
))
}
fn build_contains_all(assertion: &Assertion, local: &str) -> Option<String> {
let values = assertion.values.as_ref()?;
let mut lines = String::new();
for value in values {
let s = value.as_str()?;
let escaped = escape_swift(s);
let _ = writeln!(
lines,
" XCTAssertTrue((({local} as? [Any])?.contains(where: {{ ($0 as? String) == \"{escaped}\" }}) ?? \
false), \"expected to contain: {escaped}\")"
);
}
Some(lines)
}
#[cfg(test)]
mod tests {
use super::render_json_bridged_navigated_assertion;
use crate::e2e::field_access::{FieldResolver, SwiftFirstClassMap};
use crate::e2e::fixture::Assertion;
use std::collections::{HashMap, HashSet};
fn resolver_with_json_bridged_field(field_name: &str) -> FieldResolver {
let swift_first_class_map = SwiftFirstClassMap {
json_bridged_field_names: HashSet::from([field_name.to_string()]),
..SwiftFirstClassMap::default()
};
FieldResolver::new_with_swift_first_class(
&HashMap::new(),
&HashSet::new(),
&HashSet::new(),
&HashSet::from([field_name.to_string()]),
&HashSet::new(),
&HashMap::new(),
swift_first_class_map,
)
}
fn assertion(assertion_type: &str, field: &str, value: Option<serde_json::Value>) -> Assertion {
Assertion {
assertion_type: assertion_type.to_string(),
field: Some(field.to_string()),
value,
..Assertion::default()
}
}
#[test]
fn equals_on_an_indexed_element_renders_a_real_decode_and_compare() {
let resolver = resolver_with_json_bridged_field("detected_languages");
let mut out = String::new();
let rendered = render_json_bridged_navigated_assertion(
&mut out,
&assertion(
"equals",
"results[0].detected_languages[0]",
Some(serde_json::json!("eng")),
),
&resolver,
"result",
);
assert!(rendered, "an indexed element after a bridged leaf must be handled");
assert!(
out.contains("JSONSerialization.jsonObject"),
"must decode the bridged leaf's JSON, got:\n{out}"
);
assert!(
out.contains("as? [Any]).flatMap { $0.dropFirst(0).first }"),
"must index element 0 of the decoded array bounds-safely, got:\n{out}"
);
assert!(
out.contains("XCTAssertEqual(") && out.contains("\"eng\""),
"must compare against the fixture's expected value, got:\n{out}"
);
}
#[test]
fn equals_on_an_un_indexed_dotted_projection_renders_a_real_decode_and_compare() {
let resolver = resolver_with_json_bridged_field("metadata");
let mut out = String::new();
let rendered = render_json_bridged_navigated_assertion(
&mut out,
&assertion(
"equals",
"results[0].metadata.output_format",
Some(serde_json::json!("markdown")),
),
&resolver,
"result",
);
assert!(rendered, "a dotted projection after a bridged leaf must be handled");
assert!(
out.contains("as? [String: Any])?[\"output_format\"]"),
"must key into the decoded object, got:\n{out}"
);
assert!(out.contains("\"markdown\""), "got:\n{out}");
}
#[test]
fn not_empty_through_an_index_and_nested_keys_still_renders() {
let resolver = resolver_with_json_bridged_field("pages");
let mut out = String::new();
let rendered = render_json_bridged_navigated_assertion(
&mut out,
&assertion("not_empty", "results[0].pages[0].hierarchy.blocks", None),
&resolver,
"result",
);
assert!(rendered, "a not_empty over a navigated field must be handled");
assert!(out.contains("XCTAssertFalse("), "got:\n{out}");
}
#[test]
fn count_min_on_a_nested_array_renders_a_count_comparison() {
let resolver = resolver_with_json_bridged_field("metadata");
let mut out = String::new();
let rendered = render_json_bridged_navigated_assertion(
&mut out,
&assertion(
"count_min",
"results[0].metadata.format.html.headers",
Some(serde_json::json!(2)),
),
&resolver,
"result",
);
assert!(rendered);
assert!(
out.contains("XCTAssertGreaterThanOrEqual(") && out.contains(".count ?? 0), 2)"),
"got:\n{out}"
);
}
#[test]
fn min_length_on_a_nested_string_renders_a_character_count_comparison() {
let resolver = resolver_with_json_bridged_field("chunks");
let mut out = String::new();
let rendered = render_json_bridged_navigated_assertion(
&mut out,
&assertion("min_length", "results[0].chunks[0].content", Some(serde_json::json!(9))),
&resolver,
"result",
);
assert!(rendered, "min_length over a navigated field must be handled");
assert!(
out.contains("XCTAssertGreaterThanOrEqual(((") && out.contains("as? String) ?? \"\").count, 9)"),
"must compare the decoded value's character count against the fixture's expected minimum, got:\n{out}"
);
}
#[test]
fn an_assertion_type_with_no_renderer_still_emits_a_visible_skip() {
let resolver = resolver_with_json_bridged_field("metadata");
let mut out = String::new();
let rendered = render_json_bridged_navigated_assertion(
&mut out,
&assertion(
"not_contains",
"results[0].metadata.output_format",
Some(serde_json::json!("html")),
),
&resolver,
"result",
);
assert!(
rendered,
"a navigated-but-unrenderable assertion type must still write something"
);
assert!(
out.contains("skipped:") && out.contains("navigated JSON-bridged field"),
"must use the GeneratorGap wording, not silence, got:\n{out}"
);
assert!(
out.contains("not_contains"),
"the skip should name which assertion type has no renderer, got:\n{out}"
);
}
#[test]
fn a_non_bridged_field_is_left_untouched() {
let resolver = resolver_with_json_bridged_field("metadata");
let mut out = String::new();
let rendered = render_json_bridged_navigated_assertion(
&mut out,
&assertion(
"equals",
"results[0].mime_type",
Some(serde_json::json!("application/pdf")),
),
&resolver,
"result",
);
assert!(!rendered, "got:\n{out}");
assert!(out.is_empty(), "got:\n{out}");
}
}