fix(agent): preserve compact avatar media during cleanup

This commit is contained in:
Fini 2026-07-23 01:16:23 +08:00
parent 29fc8cae95
commit 9dc2bafe40
2 changed files with 72 additions and 0 deletions

View file

@ -271,6 +271,37 @@ fn cleanup_deletes_only_empty_decorated_small_frame_stubs() {
assert!(find_by_name(&root, "Spacer").is_some());
}
#[test]
fn cleanup_preserves_image_filled_avatar_leaf_in_header() {
let mut sink = insert_root(json!({
"type":"frame","id":"root","name":"Root","width":390,"height":160,"layout":"vertical","children":[
{"type":"frame","id":"top-bar","name":"Top Bar","width":"fill_container","height":52,
"layout":"horizontal","justifyContent":"space_between","children":[
{"type":"text","id":"title","name":"Title","content":"Hearth"},
{"type":"frame","id":"avatar","name":"User Portrait","width":40,"height":40,
"cornerRadius":20,"fill":[{"type":"image","url":"data:image/png;base64,AAAA","mode":"crop"}],"children":[]},
{"type":"frame","id":"avatar-fallback","name":"Profile Avatar","role":"avatar","width":36,"height":36,
"cornerRadius":18,"fill":[{"type":"solid","color":"#334155"}],"children":[]}
]}
]
}));
run_cleanup(&mut sink);
let root = active_root_json(&sink);
let top_bar = find_by_name(&root, "Top Bar").expect("top bar survives");
assert_eq!(
top_bar
.get("children")
.and_then(Value::as_array)
.map(Vec::len),
Some(3),
"cleanup must not delete compact image-filled or semantic avatar leaves"
);
assert!(find_by_name(&root, "User Portrait").is_some());
assert!(find_by_name(&root, "Profile Avatar").is_some());
}
#[test]
fn geometry_diagnostics_reports_empty_decorated_frame_stub() {
let sink = insert_root(json!({

View file

@ -74,12 +74,53 @@ fn is_empty_decorated_stub(v: &Value, resolved_size: Option<(f64, f64)>) -> bool
&& v.get("x").is_none()
&& v.get("y").is_none()
&& children(v).is_empty()
// A frame whose paint is an image is already meaningful content.
// Compact avatar/photo frames are commonly authored this way, with
// no child image node, so treating them as empty stubs destroys the
// asset before the later avatar-repair pass can inspect it.
&& !is_meaningful_media_leaf(v)
&& has_visible_paint(v)
&& (padding_positive(v) || numeric(v, "cornerRadius").is_some_and(|r| r > 0.0))
&& width.is_some_and(|w| w > 0.0 && w < 80.0)
&& height.is_some_and(|h| h > 0.0 && h < 60.0)
}
fn is_meaningful_media_leaf(v: &Value) -> bool {
has_image_fill(v) || has_avatar_semantics(v)
}
fn has_image_fill(v: &Value) -> bool {
match v.get("fill") {
Some(Value::Array(fills)) => fills.iter().any(is_image_paint),
Some(fill) => is_image_paint(fill),
None => false,
}
}
fn has_avatar_semantics(v: &Value) -> bool {
let role = v.get("role").and_then(Value::as_str).unwrap_or_default();
if matches!(
role.to_ascii_lowercase().as_str(),
"avatar" | "user-avatar" | "profile-avatar" | "profile-photo"
) {
return true;
}
let name = v
.get("name")
.and_then(Value::as_str)
.unwrap_or_default()
.to_ascii_lowercase();
name.contains("avatar") || name.contains("profile photo") || name.contains("user photo")
}
fn is_image_paint(paint: &Value) -> bool {
paint
.get("type")
.and_then(Value::as_str)
.is_some_and(|kind| kind.eq_ignore_ascii_case("image"))
}
fn resolved_sizes(state: &op_editor_core::EditorState) -> HashMap<String, (f64, f64)> {
let scene = op_pen_loader::editor_state_to_layout_scene(state);
let mut out = HashMap::new();