fix(ai): admit painted childless frames as blank-check content

A childless Frame with explicit stroke or fill is pixel-equivalent to a
childless Rectangle, which the blank check already admits. otp_input's
empty stroked digit slots made perfect manifests read as blank
scaffolding -> retry ladder -> raw fallback degradation (ark emitted
flawless otp manifests twice and got rejected both times). Also
serialize the OPENPENCIL_MANIFEST env tests behind a shared lock --
parallel set/remove races only surfaced in full-suite runs.
This commit is contained in:
Fini 2026-06-13 00:35:34 +08:00
parent fa4130c133
commit 9ffaf5980c
2 changed files with 58 additions and 1 deletions

View file

@ -155,7 +155,22 @@ fn has_content_node(node: &PenNode) -> bool {
// Frame/Group because it carries ContainerProps, which made
// every skeleton-screen design read as "blank" (ab-v9: the
// mobile-loading-skeleton prompt failed on all four models).
_ => !node.is_container() || matches!(node, PenNode::Rectangle(_)),
_ => match node {
PenNode::Rectangle(_) => true,
// A childless frame with explicit paint renders exactly like
// that rectangle — same pixels, different spelling. The
// otp_input builder's await-input slots (stroked empty
// boxes) made the whole manifest read as blank scaffolding,
// forcing a retry into the hand-rolled raw fallback.
PenNode::Frame(f) => {
f.container.stroke.is_some()
|| f.container
.fill
.as_ref()
.is_some_and(|fills| !fills.is_empty())
}
_ => !node.is_container(),
},
}
}
@ -264,6 +279,9 @@ mod tests {
/// `parse_manifest` 返回 `None` 后照常回落 `parse_nodes`。
#[test]
fn run_subtask_manifest_mode_builds_elements_end_to_end() {
let _env = crate::test_support::MANIFEST_ENV_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
std::env::set_var("OPENPENCIL_MANIFEST", "1");
let manifest = concat!(
"{\"el\":\"section\",\"gap\":16,\"role\":\"stats\"}\n",
@ -300,6 +318,41 @@ mod tests {
);
}
/// ab-v9.2 现场:`{"el":"otp_input"}` 全空槽位 → builder 产出一排
/// 带描边的无子 frame。带显式 paint 的无子 frame 与无子矩形像素
/// 等价,必须算内容 —— 此前整树被判"空白容器",manifest 被拒后
/// 重试降级到手搓 raw 路径。
#[test]
fn manifest_element_of_stroked_empty_frames_is_content_not_blank() {
let _env = crate::test_support::MANIFEST_ENV_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
std::env::set_var("OPENPENCIL_MANIFEST", "1");
let manifest = concat!(
"{\"el\":\"section\",\"direction\":\"horizontal\",\"gap\":12}\n",
"{\"el\":\"otp_input\",\"in\":1,\"length\":6,\"focused_index\":0}",
);
let llm = ScriptedLlm::new(vec![ScriptResponse::Text(manifest.into())]);
let mut sink = VecDocSink::new();
let outcome = block_on(run_subtask(
&subtask(),
&plan(),
&req(),
&llm,
&mut sink,
&AbortFlag::new(),
false,
false,
));
std::env::remove_var("OPENPENCIL_MANIFEST");
assert!(outcome.error.is_none(), "{:?}", outcome.error);
assert_eq!(
outcome.node_count, 1,
"one section root with the otp element"
);
}
#[test]
fn run_subtask_zero_node_on_garbage() {
let llm = ScriptedLlm::new(vec![ScriptResponse::Text("the model refused".into())]);

View file

@ -215,3 +215,7 @@ impl LlmClient for CountingLlm {
pub(crate) use crate::stub_providers::{
SkippedPreValidator, SkippedScreenshotProvider, SkippedVisionLlmClient,
};
/// 串行化会改 `OPENPENCIL_MANIFEST` 的测试 —— env 是进程全局,两个
/// set/remove 测试并行会互相拆台(一边刚 set 另一边 remove)。
pub(crate) static MANIFEST_ENV_LOCK: Mutex<()> = Mutex::new(());