fix(ai): harden node parser for weak-model output
Weak models emit dirty node JSON that previously failed whole subtasks. Re-nest flat `_parent` output into a tree (port of TS parseJsonlToTree; horizontal containers were collapsing to vertical), strip reasoning `<think>` blocks (including truncated ones) so drafts aren't parsed as nodes, resolve documented numeric design tokens (`$type`/`$spacing`/ `$radius`) and wrap bare `fill` color strings that canonical PenNode rejects, and restore per-node tolerance — re-nesting had collapsed the flat array so one bad deep node failed the entire root. Split the test module into sibling parse_tests.rs to keep parse.rs under the 800-line cap; ark_metrics.jsonl is the captured regression.
This commit is contained in:
parent
55a69586a7
commit
70ecfc6f6a
|
|
@ -27,6 +27,11 @@ impl std::fmt::Display for ParseError {
|
|||
/// children 2),于是无需任何 colon/label 启发式,带标签的真数组也不会被误跳。
|
||||
/// 全部候选失败 / 无候选视为错误。
|
||||
pub fn parse_nodes(text: &str) -> Result<Vec<PenNode>, ParseError> {
|
||||
// 推理模型(MiniMax-M3 / DeepSeek-R 系等)先吐 `<think>…</think>` 再给真正
|
||||
// 答案,且 think 里常含**未完成的草稿 JSON**(扁平、带 `//` 注释)。先剥到
|
||||
// 最后一个 `</think>` 之后,避免把草稿误当输出(实测 M3 否则被扫出扁平
|
||||
// depth-2 残骸)。无闭合标签(在 think 中被 max_tokens 截断)则用原文。
|
||||
let text = strip_reasoning(text);
|
||||
let mut best: Option<Vec<PenNode>> = None;
|
||||
let mut best_total = 0usize;
|
||||
let mut last_err: Option<ParseError> = None;
|
||||
|
|
@ -56,6 +61,33 @@ pub fn parse_nodes(text: &str) -> Result<Vec<PenNode>, ParseError> {
|
|||
})
|
||||
}
|
||||
|
||||
/// 剥掉推理模型的 `<think>…</think>`,返回真正答案部分。两步:
|
||||
/// 1. **跳过所有已闭合 think 块**:取最后一个 `</think>` / `</thinking>` 之后的文本。
|
||||
/// 2. 在其余文本里,**截掉任何仍未闭合的 `<think>` 块**(被 `max_tokens` 截断的
|
||||
/// 末尾 think):返回该开标签**之前**的部分。
|
||||
///
|
||||
/// 这样无论 think 在开头、还是"闭合块 + 内容 + 末尾被截断的第二个 think 块"
|
||||
/// (Codex review 指出的二段式),都不会把未完成的 think 草稿当真节点解析。
|
||||
/// 无 think 标签 → 原样返回(非推理模型安全);末尾截断 → 通常返回空 → 解析失败重试。
|
||||
fn strip_reasoning(text: &str) -> &str {
|
||||
// 1) 跳过所有闭合块。
|
||||
let after_closed = ["</think>", "</thinking>"]
|
||||
.iter()
|
||||
.filter_map(|tag| text.rfind(tag).map(|i| i + tag.len()))
|
||||
.max()
|
||||
.map(|i| &text[i..])
|
||||
.unwrap_or(text);
|
||||
// 2) 截掉残留的未闭合 think 块。
|
||||
match ["<think>", "<thinking>"]
|
||||
.iter()
|
||||
.filter_map(|tag| after_closed.find(tag))
|
||||
.min()
|
||||
{
|
||||
Some(start) => &after_closed[..start],
|
||||
None => after_closed,
|
||||
}
|
||||
}
|
||||
|
||||
/// 递归总节点数(含后代),复用 cleanup 的后代计数。用于在候选间取"最完整"
|
||||
/// 的解析结果。
|
||||
fn total_nodes(nodes: &[PenNode]) -> usize {
|
||||
|
|
@ -66,45 +98,189 @@ fn total_nodes(nodes: &[PenNode]) -> usize {
|
|||
.sum::<usize>()
|
||||
}
|
||||
|
||||
/// JSONL / bare-object 收集:把每个顶层 `{...}` 解析为一个 `PenNode`(非节点
|
||||
/// 对象——如 `{"nodes":[…]}` 这种无 `type` 的包裹——被跳过)。返回全部成功
|
||||
/// 解析的顶层节点;由 [`parse_nodes`] 与数组形态按总节点数比较择优。
|
||||
/// JSONL / bare-object 收集:取所有顶层 `{...}`,按 `_parent` 重组成树(扁平
|
||||
/// `_parent` 格式——TS prompt 要求、DeepSeek 用的 JSONL 形态),再逐根反序列化。
|
||||
/// 非节点对象(`{"nodes":[…]}` 包裹、散落的 `{type:solid}` 等)在重组阶段被
|
||||
/// [`is_node_object`] 滤掉。由 [`parse_nodes`] 与数组形态按总节点数比较择优。
|
||||
fn collect_bare_objects(text: &str) -> Vec<PenNode> {
|
||||
let mut nodes = Vec::new();
|
||||
for obj in balanced_spans(text, b'{', b'}') {
|
||||
let Ok(mut value) = serde_json::from_str::<serde_json::Value>(obj) else {
|
||||
continue;
|
||||
};
|
||||
normalize_generated_node_json(&mut value);
|
||||
if let Ok(node) = serde_json::from_value::<PenNode>(value) {
|
||||
nodes.push(node);
|
||||
}
|
||||
}
|
||||
nodes
|
||||
let items: Vec<serde_json::Value> = balanced_spans(text, b'{', b'}')
|
||||
.into_iter()
|
||||
.filter_map(|obj| serde_json::from_str(obj).ok())
|
||||
.collect();
|
||||
deserialize_roots(renest_by_parent(items)).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// 走完整 parse 管线解析单个候选 `[...]`:serde `Value` → normalize →
|
||||
/// **逐元素**反序列化为 `Vec<PenNode>`。
|
||||
///
|
||||
/// 弱模型常在节点数组里混入个别坏元素(例如把 fill 的
|
||||
/// `{"type":"solid"}` 当成节点写进数组)。老的整组
|
||||
/// `from_value::<Vec<PenNode>>` 只要一个元素坏就丢掉**整段子任务**
|
||||
/// (实测 Dashboard charts-row 因此 0 节点、后续 table-section 也被
|
||||
/// 中断)。这里改为逐元素反序列化:保留有效节点、跳过并记录坏的。
|
||||
/// 全有效时结果与老逻辑一致(严格不回退);全坏 / 空数组返回 `Err`
|
||||
/// 以便上层试下一个候选。
|
||||
/// 解析单个候选 `[...]`:serde `Value::Array` → 按 `_parent` 重组成树 →
|
||||
/// 逐根反序列化为 `Vec<PenNode>`。
|
||||
fn try_parse_candidate(json: &str) -> Result<Vec<PenNode>, ParseError> {
|
||||
let mut value: serde_json::Value =
|
||||
let value: serde_json::Value =
|
||||
serde_json::from_str(json).map_err(|e| ParseError(format!("json: {e}")))?;
|
||||
normalize_generated_node_json(&mut value);
|
||||
let serde_json::Value::Array(items) = value else {
|
||||
return Err(ParseError("candidate is not a JSON array".into()));
|
||||
};
|
||||
let total = items.len();
|
||||
deserialize_roots(renest_by_parent(items))
|
||||
}
|
||||
|
||||
/// canonical `PenNode` 的 `type` 标签全集。重组阶段据此过滤掉非节点对象
|
||||
/// (散落的 `{type:solid}` 字段对象、`{"nodes":[…]}` 包裹等)——既是弱模型
|
||||
/// 容错(坏元素不毁整段),也避免把它们当节点重组进树。
|
||||
const NODE_KINDS: &[&str] = &[
|
||||
"frame",
|
||||
"group",
|
||||
"rectangle",
|
||||
"ellipse",
|
||||
"line",
|
||||
"polygon",
|
||||
"path",
|
||||
"text",
|
||||
"text_input",
|
||||
"image",
|
||||
"icon_font",
|
||||
"ref",
|
||||
];
|
||||
|
||||
fn is_node_object(value: &serde_json::Value) -> bool {
|
||||
value
|
||||
.get("type")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.is_some_and(|ty| NODE_KINDS.contains(&ty))
|
||||
}
|
||||
|
||||
/// 把扁平节点列表按 `_parent` 重组成嵌套树(移植 TS
|
||||
/// `design-parser.ts::parseJsonlToTree`)。
|
||||
///
|
||||
/// TS 的 sub-agent prompt 要求**扁平 `_parent` 格式**(每节点一行、`_parent`
|
||||
/// 指父 id、root 为 `null`),TS 解析时据此建树。Rust 此前漏了这步——扁平
|
||||
/// 节点被直接插成兄弟,横向容器的子项全跑到 root 下 → 布局竖排破损。M2.7 /
|
||||
/// DeepSeek 都用这个格式;方舟用嵌套 children。
|
||||
///
|
||||
/// 行为:先用 [`is_node_object`] 滤掉非节点对象;若没有任何 `_parent` 字段,
|
||||
/// 原样返回(嵌套 / 纯 root 形态);否则按出现顺序建 id→对象 + 父→子表,从
|
||||
/// root(`_parent:null` 或父不存在的孤儿)递归装配。已取走的 id 不再装配(防环)。
|
||||
fn renest_by_parent(items: Vec<serde_json::Value>) -> Vec<serde_json::Value> {
|
||||
use std::collections::HashMap;
|
||||
let mut by_id: HashMap<String, serde_json::Value> = HashMap::new();
|
||||
let mut order: Vec<String> = Vec::new();
|
||||
let mut parent_of: HashMap<String, Option<String>> = HashMap::new();
|
||||
let mut seen_parent = false;
|
||||
let mut dropped = 0usize;
|
||||
for mut item in items {
|
||||
if !is_node_object(&item) {
|
||||
continue;
|
||||
}
|
||||
let Some(obj) = item.as_object_mut() else {
|
||||
continue;
|
||||
};
|
||||
let id = match obj.get("id").and_then(serde_json::Value::as_str) {
|
||||
Some(s) if !s.is_empty() => s.to_string(),
|
||||
_ => continue,
|
||||
};
|
||||
let parent_id = match obj.remove("_parent") {
|
||||
Some(serde_json::Value::String(p)) if !p.is_empty() => {
|
||||
seen_parent = true;
|
||||
Some(p)
|
||||
}
|
||||
Some(_) => {
|
||||
seen_parent = true; // null / anything else → root
|
||||
None
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
if by_id.contains_key(&id) {
|
||||
continue; // duplicate id — keep the first
|
||||
}
|
||||
// Per-node tolerance (restored after re-nesting): a node whose OWN
|
||||
// fields still don't deserialize after normalize would otherwise fail
|
||||
// its whole assembled root (re-nesting collapsed the flat array, so the
|
||||
// per-element tolerance the array path used to have was lost). Probe
|
||||
// each as a childless leaf and drop the unsalvageable ones, keeping the
|
||||
// rest of the section instead of losing the entire subtask.
|
||||
let mut probe = item.clone();
|
||||
if let Some(po) = probe.as_object_mut() {
|
||||
po.remove("children");
|
||||
}
|
||||
normalize_generated_node_json(&mut probe);
|
||||
if serde_json::from_value::<PenNode>(probe).is_err() {
|
||||
dropped += 1;
|
||||
continue;
|
||||
}
|
||||
order.push(id.clone());
|
||||
parent_of.insert(id.clone(), parent_id);
|
||||
by_id.insert(id, item);
|
||||
}
|
||||
if dropped > 0 {
|
||||
eprintln!("[parse] renest dropped {dropped} node(s) failing schema validation");
|
||||
}
|
||||
if !seen_parent {
|
||||
// 没有 `_parent`:已是嵌套 / 纯 root 形态,原样返回(保持顺序)。
|
||||
return order
|
||||
.into_iter()
|
||||
.filter_map(|id| by_id.remove(&id))
|
||||
.collect();
|
||||
}
|
||||
let mut children_of: HashMap<String, Vec<String>> = HashMap::new();
|
||||
let mut roots: Vec<String> = Vec::new();
|
||||
for id in &order {
|
||||
match parent_of.get(id).cloned().flatten() {
|
||||
Some(parent) if parent != *id && by_id.contains_key(&parent) => {
|
||||
children_of.entry(parent).or_default().push(id.clone());
|
||||
}
|
||||
_ => roots.push(id.clone()), // root / 孤儿 / 自指
|
||||
}
|
||||
}
|
||||
let mut out: Vec<serde_json::Value> = roots
|
||||
.into_iter()
|
||||
.filter_map(|id| assemble(&id, &mut by_id, &children_of))
|
||||
.collect();
|
||||
// 无丢失保证:若有节点不可从任何 root 到达(纯 `_parent` 环),按原始顺序
|
||||
// 把剩下的提升为 root 装配出来,不静默丢节点(环会被 assemble 的 take 打断)。
|
||||
for id in &order {
|
||||
if by_id.contains_key(id) {
|
||||
if let Some(node) = assemble(id, &mut by_id, &children_of) {
|
||||
out.push(node);
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// 递归装配 `id` 的子树:从 `by_id` 取出,按 `children_of` 把子节点装进
|
||||
/// `children`(保留任何已有的内联 children)。已取走的 id 返回 `None`(防环)。
|
||||
fn assemble(
|
||||
id: &str,
|
||||
by_id: &mut std::collections::HashMap<String, serde_json::Value>,
|
||||
children_of: &std::collections::HashMap<String, Vec<String>>,
|
||||
) -> Option<serde_json::Value> {
|
||||
let mut node = by_id.remove(id)?;
|
||||
if let Some(child_ids) = children_of.get(id) {
|
||||
let mut children: Vec<serde_json::Value> = Vec::new();
|
||||
if let Some(existing) = node
|
||||
.get_mut("children")
|
||||
.and_then(serde_json::Value::as_array_mut)
|
||||
{
|
||||
children.append(existing);
|
||||
}
|
||||
for cid in child_ids {
|
||||
if let Some(child) = assemble(cid, by_id, children_of) {
|
||||
children.push(child);
|
||||
}
|
||||
}
|
||||
if let Some(obj) = node.as_object_mut() {
|
||||
obj.insert("children".to_string(), serde_json::Value::Array(children));
|
||||
}
|
||||
}
|
||||
Some(node)
|
||||
}
|
||||
|
||||
/// 逐根反序列化为 `Vec<PenNode>`(逐根容错:坏根跳过并记录;全有效时与直接
|
||||
/// 反序列化一致)。空 / 全坏返回 `Err` 以便上层试下一个候选。
|
||||
fn deserialize_roots(roots: Vec<serde_json::Value>) -> Result<Vec<PenNode>, ParseError> {
|
||||
let total = roots.len();
|
||||
let mut nodes = Vec::with_capacity(total);
|
||||
let mut last_err: Option<String> = None;
|
||||
for item in items {
|
||||
match serde_json::from_value::<PenNode>(item) {
|
||||
for mut value in roots {
|
||||
normalize_generated_node_json(&mut value);
|
||||
match serde_json::from_value::<PenNode>(value) {
|
||||
Ok(node) => nodes.push(node),
|
||||
Err(e) => last_err = Some(e.to_string()),
|
||||
}
|
||||
|
|
@ -112,12 +288,12 @@ fn try_parse_candidate(json: &str) -> Result<Vec<PenNode>, ParseError> {
|
|||
if nodes.is_empty() {
|
||||
let detail = last_err.map(|e| format!("; last: {e}")).unwrap_or_default();
|
||||
return Err(ParseError(format!(
|
||||
"deserialize: 0/{total} nodes valid{detail}"
|
||||
"deserialize: 0/{total} roots valid{detail}"
|
||||
)));
|
||||
}
|
||||
if nodes.len() < total {
|
||||
eprintln!(
|
||||
"[parse] tolerated {} malformed node(s) of {total}, kept {}",
|
||||
"[parse] tolerated {} malformed root(s) of {total}, kept {}",
|
||||
total - nodes.len(),
|
||||
nodes.len()
|
||||
);
|
||||
|
|
@ -142,14 +318,87 @@ fn normalize_generated_node_json(value: &mut serde_json::Value) {
|
|||
object.insert("src".into(), serde_json::Value::String(String::new()));
|
||||
}
|
||||
|
||||
// `fill` given as a bare color string/ref → wrap into the canonical
|
||||
// `[{ "type":"solid", "color": <string> }]` sequence. PenNode's fill
|
||||
// is a sequence; models commonly shorthand it as a plain color
|
||||
// (实测方舟 `"fill":"$color-success-text"` 否则整 root 报废)。
|
||||
if let Some(serde_json::Value::String(color)) = object.get("fill") {
|
||||
let color = color.clone();
|
||||
object.insert(
|
||||
"fill".into(),
|
||||
serde_json::json!([{ "type": "solid", "color": color }]),
|
||||
);
|
||||
}
|
||||
|
||||
for child in object.values_mut() {
|
||||
normalize_generated_node_json(child);
|
||||
}
|
||||
}
|
||||
serde_json::Value::String(s) => {
|
||||
// 数值型设计 token(`$type-*-size` 等)就地解析成数字 —— canonical
|
||||
// PenNode 的 fontSize/lineHeight/gap/… 是 f64,模型却按 prompt 用这些
|
||||
// `$ref` 字符串,否则反序列化失败、整个 root 报废(实测方舟 metrics)。
|
||||
if let Some(n) = resolve_numeric_design_token(s) {
|
||||
*value = serde_json::json!(n);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// 把**数值型**设计 token 字符串解析成 prompt 文档化的默认数值:
|
||||
/// `$type-{tier}-{size|line-height|letter-spacing}`、`$spacing-{1..5}`、
|
||||
/// `$radius-{sm|md|lg}`。颜色 / weight 等非纯数值 token 返回 `None`(保持字符串
|
||||
/// —— 颜色 `$color-*` 在 fill 里合法,weight 是字符串型按字符串反序列化)。
|
||||
fn resolve_numeric_design_token(s: &str) -> Option<f64> {
|
||||
if let Some(rest) = s.strip_prefix("$type-") {
|
||||
if let Some(t) = rest.strip_suffix("-letter-spacing") {
|
||||
return match t {
|
||||
"display" => Some(-0.5),
|
||||
"uppercase-label" => Some(1.5),
|
||||
_ => None,
|
||||
};
|
||||
}
|
||||
let (tier, prop) = if let Some(t) = rest.strip_suffix("-line-height") {
|
||||
(t, "lh")
|
||||
} else if let Some(t) = rest.strip_suffix("-size") {
|
||||
(t, "size")
|
||||
} else {
|
||||
return None;
|
||||
};
|
||||
// (size, line-height) per the prompt's typography scale.
|
||||
let (size, lh) = match tier {
|
||||
"display" => (64.0, 1.0),
|
||||
"h1" => (24.0, 1.2),
|
||||
"h2" => (20.0, 1.25),
|
||||
"h3" => (16.0, 1.3),
|
||||
"body" => (14.0, 1.5),
|
||||
"caption" => (12.0, 1.4),
|
||||
_ => return None,
|
||||
};
|
||||
return Some(if prop == "size" { size } else { lh });
|
||||
}
|
||||
if let Some(n) = s.strip_prefix("$spacing-") {
|
||||
return match n {
|
||||
"1" => Some(4.0),
|
||||
"2" => Some(8.0),
|
||||
"3" => Some(12.0),
|
||||
"4" => Some(16.0),
|
||||
"5" => Some(24.0),
|
||||
_ => None,
|
||||
};
|
||||
}
|
||||
if let Some(r) = s.strip_prefix("$radius-") {
|
||||
return match r {
|
||||
"sm" => Some(4.0),
|
||||
"md" => Some(8.0),
|
||||
"lg" => Some(12.0),
|
||||
_ => None,
|
||||
};
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// 收集文本里可作为候选的 `open..close` 子串(`[...]` 或 `{...}`),按出现
|
||||
/// 顺序返回。字符串字面量内的括号忽略。两条规则保证对脏输入鲁棒:
|
||||
/// - **独立尝试**每个 `open`:`balanced_end` 未闭合即跳过该位置(+1 前进)。
|
||||
|
|
@ -230,143 +479,5 @@ fn balanced_end(bytes: &[u8], start: usize, open: u8, close: u8) -> Option<usize
|
|||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use op_editor_core::PenNodeExt as _;
|
||||
|
||||
#[test]
|
||||
fn parse_nodes_reads_fenced_pennode_array() {
|
||||
// 一个最小的 frame 节点(canonical schema:`type` 标签 +
|
||||
// base 字段)。
|
||||
let text = r#"Sure, here are the nodes:
|
||||
```json
|
||||
[
|
||||
{ "type": "frame", "id": "hero", "name": "Hero", "x": 0, "y": 0, "width": 1200, "height": 400, "children": [] }
|
||||
]
|
||||
```"#;
|
||||
let nodes = parse_nodes(text).expect("parse");
|
||||
assert_eq!(nodes.len(), 1);
|
||||
assert_eq!(nodes[0].id_str(), "hero");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_nodes_rejects_empty_array() {
|
||||
assert!(parse_nodes("[]").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_nodes_rejects_no_array() {
|
||||
assert!(parse_nodes("the model wrote prose only").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_nodes_defaults_missing_image_src_to_empty_string() {
|
||||
let text = r#"[
|
||||
{ "type": "image", "id": "photo", "name": "Restaurant photo", "x": 0, "y": 0, "width": 240, "height": 160 }
|
||||
]"#;
|
||||
|
||||
let nodes = parse_nodes(text).expect("missing image src should be tolerated");
|
||||
let PenNode::Image(image) = &nodes[0] else {
|
||||
panic!("expected image node");
|
||||
};
|
||||
assert_eq!(image.src, "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_nodes_skips_prose_bracket_before_fenced_array() {
|
||||
// 弱模型在真正 JSON 之前写了带 `[` 的推理散文。老逻辑取第一个
|
||||
// `[` 会抓到 `[step 1]` 报 "expected ident";新逻辑扫描所有平衡
|
||||
// 数组、取节点数最多的有效结果,跳过散文括号。
|
||||
let text = "Let me plan this [step 1]: build the card.\n```json\n[\n { \"type\": \"frame\", \"id\": \"card\", \"name\": \"Card\", \"x\": 0, \"y\": 0, \"width\": 300, \"height\": 200, \"children\": [] }\n]\n```";
|
||||
let nodes = parse_nodes(text).expect("should skip prose bracket and parse real array");
|
||||
assert_eq!(nodes.len(), 1);
|
||||
assert_eq!(nodes[0].id_str(), "card");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_nodes_tolerates_one_bad_node_keeps_rest() {
|
||||
// 弱模型在节点数组里混入一个 `type:"solid"`(fill 类型当节点)。
|
||||
// 老的整组 from_value 会因这一个坏元素丢掉整段;新逻辑逐元素
|
||||
// 反序列化、跳过坏的、保留有效节点(实测 Dashboard charts-row)。
|
||||
let text = r##"[
|
||||
{ "type": "frame", "id": "a", "name": "A", "x": 0, "y": 0, "width": 100, "height": 50, "children": [] },
|
||||
{ "type": "solid", "color": "#06B6D4" },
|
||||
{ "type": "frame", "id": "b", "name": "B", "x": 0, "y": 0, "width": 100, "height": 50, "children": [] }
|
||||
]"##;
|
||||
let nodes = parse_nodes(text).expect("should keep the valid nodes, skip the bad one");
|
||||
assert_eq!(nodes.len(), 2);
|
||||
assert_eq!(nodes[0].id_str(), "a");
|
||||
assert_eq!(nodes[1].id_str(), "b");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_nodes_returns_outer_array_not_nested_children() {
|
||||
// 嵌套格式:外层只有 1 个 root frame,其 children 有 3 个。只收
|
||||
// 顶层数组,返回外层 [root],而不是内层 [a,b,c](Codex review)。
|
||||
let text = r#"[
|
||||
{ "type": "frame", "id": "root", "name": "Root", "x": 0, "y": 0, "width": 300, "height": 200, "children": [
|
||||
{ "type": "frame", "id": "a", "name": "A", "x": 0, "y": 0, "width": 100, "height": 50, "children": [] },
|
||||
{ "type": "frame", "id": "b", "name": "B", "x": 0, "y": 0, "width": 100, "height": 50, "children": [] },
|
||||
{ "type": "frame", "id": "c", "name": "C", "x": 0, "y": 0, "width": 100, "height": 50, "children": [] }
|
||||
] }
|
||||
]"#;
|
||||
let nodes = parse_nodes(text).expect("parse");
|
||||
assert_eq!(nodes.len(), 1);
|
||||
assert_eq!(nodes[0].id_str(), "root");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_nodes_reads_jsonl_bare_objects() {
|
||||
// DeepSeek 输出 JSONL:```json 围栏里每行一个裸对象,无 [ ] 包裹。
|
||||
// 数组路径只看到内层 children:[] 空数组(失败),JSONL 回退按顶层
|
||||
// {...} 逐个解析。
|
||||
let text = "```json\n{\"type\":\"frame\",\"id\":\"a\",\"name\":\"A\",\"x\":0,\"y\":0,\"width\":100,\"height\":50,\"children\":[]}\n{\"type\":\"frame\",\"id\":\"b\",\"name\":\"B\",\"x\":0,\"y\":0,\"width\":100,\"height\":50,\"children\":[]}\n```";
|
||||
let nodes = parse_nodes(text).expect("JSONL fallback should parse bare objects");
|
||||
assert_eq!(nodes.len(), 2);
|
||||
assert_eq!(nodes[0].id_str(), "a");
|
||||
assert_eq!(nodes[1].id_str(), "b");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_nodes_bare_object_returns_top_level_not_nested_children() {
|
||||
// 单个裸对象(无数组包裹)带真实嵌套 children。合并深度判定下,
|
||||
// children:[...] 在 {} 内(深度>0)不被数组路径当节点数组,返回顶层
|
||||
// root 而不是内层 [a,b](Codex review)。
|
||||
let text = r#"{ "type": "frame", "id": "root", "name": "Root", "x": 0, "y": 0, "width": 300, "height": 200, "children": [
|
||||
{ "type": "frame", "id": "a", "name": "A", "x": 0, "y": 0, "width": 100, "height": 50, "children": [] },
|
||||
{ "type": "frame", "id": "b", "name": "B", "x": 0, "y": 0, "width": 100, "height": 50, "children": [] }
|
||||
] }"#;
|
||||
let nodes = parse_nodes(text).expect("parse");
|
||||
assert_eq!(nodes.len(), 1);
|
||||
assert_eq!(nodes[0].id_str(), "root");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_nodes_finds_array_after_unmatched_prose_bracket() {
|
||||
// 散文里有个不闭合的 `[`(如 "options [1, 2 …"),其后才是真数组。
|
||||
// 独立尝试每个 `[` + 不闭合即跳过,后面的真数组不被漏掉(Codex
|
||||
// review;修上一轮 combined-depth 引入的回归)。
|
||||
let text = "Consider options [a and b, then build:\n```json\n[{ \"type\": \"frame\", \"id\": \"hero\", \"name\": \"Hero\", \"x\": 0, \"y\": 0, \"width\": 200, \"height\": 100, \"children\": [] }]\n```";
|
||||
let nodes =
|
||||
parse_nodes(text).expect("should find the real array after an unmatched prose bracket");
|
||||
assert_eq!(nodes.len(), 1);
|
||||
assert_eq!(nodes[0].id_str(), "hero");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_nodes_reads_labelled_wrapper_object() {
|
||||
// 模型把节点数组包进带标签的对象 {"nodes":[...]}。colon-skip 曾把
|
||||
// "nodes":[...] 一并跳掉;现在按总节点数择优——wrapper 无 type 解析
|
||||
// 失败,数组路径取出 [a,b](Codex review)。
|
||||
let text = r#"```json
|
||||
{ "nodes": [
|
||||
{ "type": "frame", "id": "a", "name": "A", "x": 0, "y": 0, "width": 100, "height": 50, "children": [] },
|
||||
{ "type": "frame", "id": "b", "name": "B", "x": 0, "y": 0, "width": 100, "height": 50, "children": [] }
|
||||
] }
|
||||
```"#;
|
||||
let nodes = parse_nodes(text).expect("labelled wrapper array should be parsed");
|
||||
assert_eq!(nodes.len(), 2);
|
||||
assert_eq!(nodes[0].id_str(), "a");
|
||||
assert_eq!(nodes[1].id_str(), "b");
|
||||
}
|
||||
}
|
||||
#[path = "parse_tests.rs"]
|
||||
mod tests;
|
||||
|
|
|
|||
330
crates/op-orchestrator/src/parse_tests.rs
Normal file
330
crates/op-orchestrator/src/parse_tests.rs
Normal file
|
|
@ -0,0 +1,330 @@
|
|||
use super::*;
|
||||
use op_editor_core::PenNodeExt as _;
|
||||
|
||||
#[test]
|
||||
fn parse_nodes_reads_fenced_pennode_array() {
|
||||
// 一个最小的 frame 节点(canonical schema:`type` 标签 +
|
||||
// base 字段)。
|
||||
let text = r#"Sure, here are the nodes:
|
||||
```json
|
||||
[
|
||||
{ "type": "frame", "id": "hero", "name": "Hero", "x": 0, "y": 0, "width": 1200, "height": 400, "children": [] }
|
||||
]
|
||||
```"#;
|
||||
let nodes = parse_nodes(text).expect("parse");
|
||||
assert_eq!(nodes.len(), 1);
|
||||
assert_eq!(nodes[0].id_str(), "hero");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_nodes_rejects_empty_array() {
|
||||
assert!(parse_nodes("[]").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_nodes_rejects_no_array() {
|
||||
assert!(parse_nodes("the model wrote prose only").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_nodes_defaults_missing_image_src_to_empty_string() {
|
||||
let text = r#"[
|
||||
{ "type": "image", "id": "photo", "name": "Restaurant photo", "x": 0, "y": 0, "width": 240, "height": 160 }
|
||||
]"#;
|
||||
|
||||
let nodes = parse_nodes(text).expect("missing image src should be tolerated");
|
||||
let PenNode::Image(image) = &nodes[0] else {
|
||||
panic!("expected image node");
|
||||
};
|
||||
assert_eq!(image.src, "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_nodes_skips_prose_bracket_before_fenced_array() {
|
||||
// 弱模型在真正 JSON 之前写了带 `[` 的推理散文。老逻辑取第一个
|
||||
// `[` 会抓到 `[step 1]` 报 "expected ident";新逻辑扫描所有平衡
|
||||
// 数组、取节点数最多的有效结果,跳过散文括号。
|
||||
let text = "Let me plan this [step 1]: build the card.\n```json\n[\n { \"type\": \"frame\", \"id\": \"card\", \"name\": \"Card\", \"x\": 0, \"y\": 0, \"width\": 300, \"height\": 200, \"children\": [] }\n]\n```";
|
||||
let nodes = parse_nodes(text).expect("should skip prose bracket and parse real array");
|
||||
assert_eq!(nodes.len(), 1);
|
||||
assert_eq!(nodes[0].id_str(), "card");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_nodes_tolerates_one_bad_node_keeps_rest() {
|
||||
// 弱模型在节点数组里混入一个 `type:"solid"`(fill 类型当节点)。
|
||||
// 老的整组 from_value 会因这一个坏元素丢掉整段;新逻辑逐元素
|
||||
// 反序列化、跳过坏的、保留有效节点(实测 Dashboard charts-row)。
|
||||
let text = r##"[
|
||||
{ "type": "frame", "id": "a", "name": "A", "x": 0, "y": 0, "width": 100, "height": 50, "children": [] },
|
||||
{ "type": "solid", "color": "#06B6D4" },
|
||||
{ "type": "frame", "id": "b", "name": "B", "x": 0, "y": 0, "width": 100, "height": 50, "children": [] }
|
||||
]"##;
|
||||
let nodes = parse_nodes(text).expect("should keep the valid nodes, skip the bad one");
|
||||
assert_eq!(nodes.len(), 2);
|
||||
assert_eq!(nodes[0].id_str(), "a");
|
||||
assert_eq!(nodes[1].id_str(), "b");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_nodes_returns_outer_array_not_nested_children() {
|
||||
// 嵌套格式:外层只有 1 个 root frame,其 children 有 3 个。只收
|
||||
// 顶层数组,返回外层 [root],而不是内层 [a,b,c](Codex review)。
|
||||
let text = r#"[
|
||||
{ "type": "frame", "id": "root", "name": "Root", "x": 0, "y": 0, "width": 300, "height": 200, "children": [
|
||||
{ "type": "frame", "id": "a", "name": "A", "x": 0, "y": 0, "width": 100, "height": 50, "children": [] },
|
||||
{ "type": "frame", "id": "b", "name": "B", "x": 0, "y": 0, "width": 100, "height": 50, "children": [] },
|
||||
{ "type": "frame", "id": "c", "name": "C", "x": 0, "y": 0, "width": 100, "height": 50, "children": [] }
|
||||
] }
|
||||
]"#;
|
||||
let nodes = parse_nodes(text).expect("parse");
|
||||
assert_eq!(nodes.len(), 1);
|
||||
assert_eq!(nodes[0].id_str(), "root");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_nodes_reads_jsonl_bare_objects() {
|
||||
// DeepSeek 输出 JSONL:```json 围栏里每行一个裸对象,无 [ ] 包裹。
|
||||
// 数组路径只看到内层 children:[] 空数组(失败),JSONL 回退按顶层
|
||||
// {...} 逐个解析。
|
||||
let text = "```json\n{\"type\":\"frame\",\"id\":\"a\",\"name\":\"A\",\"x\":0,\"y\":0,\"width\":100,\"height\":50,\"children\":[]}\n{\"type\":\"frame\",\"id\":\"b\",\"name\":\"B\",\"x\":0,\"y\":0,\"width\":100,\"height\":50,\"children\":[]}\n```";
|
||||
let nodes = parse_nodes(text).expect("JSONL fallback should parse bare objects");
|
||||
assert_eq!(nodes.len(), 2);
|
||||
assert_eq!(nodes[0].id_str(), "a");
|
||||
assert_eq!(nodes[1].id_str(), "b");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_nodes_bare_object_returns_top_level_not_nested_children() {
|
||||
// 单个裸对象(无数组包裹)带真实嵌套 children。合并深度判定下,
|
||||
// children:[...] 在 {} 内(深度>0)不被数组路径当节点数组,返回顶层
|
||||
// root 而不是内层 [a,b](Codex review)。
|
||||
let text = r#"{ "type": "frame", "id": "root", "name": "Root", "x": 0, "y": 0, "width": 300, "height": 200, "children": [
|
||||
{ "type": "frame", "id": "a", "name": "A", "x": 0, "y": 0, "width": 100, "height": 50, "children": [] },
|
||||
{ "type": "frame", "id": "b", "name": "B", "x": 0, "y": 0, "width": 100, "height": 50, "children": [] }
|
||||
] }"#;
|
||||
let nodes = parse_nodes(text).expect("parse");
|
||||
assert_eq!(nodes.len(), 1);
|
||||
assert_eq!(nodes[0].id_str(), "root");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_nodes_finds_array_after_unmatched_prose_bracket() {
|
||||
// 散文里有个不闭合的 `[`(如 "options [1, 2 …"),其后才是真数组。
|
||||
// 独立尝试每个 `[` + 不闭合即跳过,后面的真数组不被漏掉(Codex
|
||||
// review;修上一轮 combined-depth 引入的回归)。
|
||||
let text = "Consider options [a and b, then build:\n```json\n[{ \"type\": \"frame\", \"id\": \"hero\", \"name\": \"Hero\", \"x\": 0, \"y\": 0, \"width\": 200, \"height\": 100, \"children\": [] }]\n```";
|
||||
let nodes =
|
||||
parse_nodes(text).expect("should find the real array after an unmatched prose bracket");
|
||||
assert_eq!(nodes.len(), 1);
|
||||
assert_eq!(nodes[0].id_str(), "hero");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_nodes_reads_labelled_wrapper_object() {
|
||||
// 模型把节点数组包进带标签的对象 {"nodes":[...]}。colon-skip 曾把
|
||||
// "nodes":[...] 一并跳掉;现在按总节点数择优——wrapper 无 type 解析
|
||||
// 失败,数组路径取出 [a,b](Codex review)。
|
||||
let text = r#"```json
|
||||
{ "nodes": [
|
||||
{ "type": "frame", "id": "a", "name": "A", "x": 0, "y": 0, "width": 100, "height": 50, "children": [] },
|
||||
{ "type": "frame", "id": "b", "name": "B", "x": 0, "y": 0, "width": 100, "height": 50, "children": [] }
|
||||
] }
|
||||
```"#;
|
||||
let nodes = parse_nodes(text).expect("labelled wrapper array should be parsed");
|
||||
assert_eq!(nodes.len(), 2);
|
||||
assert_eq!(nodes[0].id_str(), "a");
|
||||
assert_eq!(nodes[1].id_str(), "b");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_nodes_reads_ark_metrics_jsonl_regression() {
|
||||
// 实采:方舟 ark-code-latest 的 metrics 子任务 —— 31 行合法 JSONL、1 个
|
||||
// root(`_parent:null`)、所有 `_parent` 可解析。实测却 `0/0 roots valid`
|
||||
// 失败(benchmark 矩阵复现)。固化为回归 fixture。
|
||||
let text = include_str!("test_fixtures/ark_metrics.jsonl");
|
||||
let nodes = parse_nodes(text).expect("valid 31-node _parent JSONL must parse");
|
||||
assert_eq!(nodes.len(), 1, "single metrics-root after re-nest");
|
||||
assert_eq!(nodes[0].id_str(), "metrics-root");
|
||||
// 31 行里有 1 个重复 id(方舟自身),去重后 30 个 unique 节点。
|
||||
assert!(
|
||||
total_nodes(&nodes) >= 29,
|
||||
"expected ~30 nodes, got {}",
|
||||
total_nodes(&nodes)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_numeric_design_token_table() {
|
||||
assert_eq!(
|
||||
resolve_numeric_design_token("$type-caption-size"),
|
||||
Some(12.0)
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_numeric_design_token("$type-display-size"),
|
||||
Some(64.0)
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_numeric_design_token("$type-body-line-height"),
|
||||
Some(1.5)
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_numeric_design_token("$type-display-letter-spacing"),
|
||||
Some(-0.5)
|
||||
);
|
||||
assert_eq!(resolve_numeric_design_token("$spacing-3"), Some(12.0));
|
||||
assert_eq!(resolve_numeric_design_token("$radius-md"), Some(8.0));
|
||||
// colors / weight / sizing keywords stay strings (not numeric tokens).
|
||||
assert_eq!(resolve_numeric_design_token("$color-accent"), None);
|
||||
assert_eq!(resolve_numeric_design_token("$type-body-weight"), None);
|
||||
assert_eq!(resolve_numeric_design_token("fill_container"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_resolves_numeric_token_and_wraps_bare_fill() {
|
||||
let mut v = serde_json::json!({
|
||||
"type":"text","id":"x","fontSize":"$type-caption-size","fill":"$color-surface"
|
||||
});
|
||||
normalize_generated_node_json(&mut v);
|
||||
assert_eq!(v["fontSize"], serde_json::json!(12.0));
|
||||
assert_eq!(
|
||||
v["fill"],
|
||||
serde_json::json!([{"type":"solid","color":"$color-surface"}])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_nodes_renests_flat_parent_array() {
|
||||
// 扁平 `_parent` 数组(M2.7 形态):root + 一个横向 row + row 的 3 个
|
||||
// 子项,全是兄弟、靠 `_parent` 指父。移植 TS parseJsonlToTree 后应重组
|
||||
// 成树:root 直接子 = [row],row 子 = [a,b,c],而非 5 个扁平兄弟
|
||||
// (扁平 → 横向容器子项跑到 root 下 → 布局竖排破损,这正是回归点)。
|
||||
let text = r#"[
|
||||
{ "type": "frame", "id": "root", "name": "Root", "x": 0, "y": 0, "width": 300, "height": 200, "layout": "vertical", "_parent": null },
|
||||
{ "type": "frame", "id": "row", "name": "Row", "width": "fill_container", "height": "fit_content", "layout": "horizontal", "_parent": "root" },
|
||||
{ "type": "frame", "id": "a", "name": "A", "width": 80, "height": 50, "_parent": "row" },
|
||||
{ "type": "frame", "id": "b", "name": "B", "width": 80, "height": 50, "_parent": "row" },
|
||||
{ "type": "frame", "id": "c", "name": "C", "width": 80, "height": 50, "_parent": "row" }
|
||||
]"#;
|
||||
let nodes = parse_nodes(text).expect("flat _parent array should re-nest into a tree");
|
||||
assert_eq!(nodes.len(), 1, "single root after re-nest");
|
||||
assert_eq!(nodes[0].id_str(), "root");
|
||||
assert_eq!(
|
||||
crate::cleanup::count_descendants(&nodes[0]),
|
||||
4,
|
||||
"root subtree = row + a + b + c"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_nodes_renests_flat_parent_jsonl() {
|
||||
// DeepSeek 形态:JSONL 裸对象,每个带 `_parent`。重组后 root 含嵌套子树
|
||||
// (2 个直接子),而非 3 个扁平兄弟。
|
||||
let text = "```json\n{\"type\":\"frame\",\"id\":\"root\",\"name\":\"Root\",\"x\":0,\"y\":0,\"width\":300,\"height\":200,\"_parent\":null}\n{\"type\":\"frame\",\"id\":\"a\",\"name\":\"A\",\"width\":80,\"height\":50,\"_parent\":\"root\"}\n{\"type\":\"frame\",\"id\":\"b\",\"name\":\"B\",\"width\":80,\"height\":50,\"_parent\":\"root\"}\n```";
|
||||
let nodes = parse_nodes(text).expect("flat _parent JSONL should re-nest");
|
||||
assert_eq!(nodes.len(), 1);
|
||||
assert_eq!(nodes[0].id_str(), "root");
|
||||
assert_eq!(crate::cleanup::count_descendants(&nodes[0]), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_nodes_renest_orphan_parent_becomes_root() {
|
||||
// `_parent` 指向不存在的 id → 该节点当 root(TS parseJsonlToTree:
|
||||
// "Parent not found, treat as root")。两个节点都成 root。
|
||||
let text = r#"[
|
||||
{ "type": "frame", "id": "a", "name": "A", "width": 80, "height": 50, "_parent": "ghost" },
|
||||
{ "type": "frame", "id": "b", "name": "B", "width": 80, "height": 50, "_parent": null }
|
||||
]"#;
|
||||
let nodes = parse_nodes(text).expect("orphan parent should fall back to root");
|
||||
assert_eq!(nodes.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_nodes_renest_cycle_is_lossless() {
|
||||
// 病态输入:纯 `_parent` 环(a↔b)。两者互为父 → 没有天然 root。
|
||||
// 无丢失保证应把它们装配出来(环被 take 打断),不静默丢节点。
|
||||
let text = r#"[
|
||||
{ "type": "frame", "id": "a", "name": "A", "width": 80, "height": 50, "_parent": "b" },
|
||||
{ "type": "frame", "id": "b", "name": "B", "width": 80, "height": 50, "_parent": "a" }
|
||||
]"#;
|
||||
let nodes = parse_nodes(text).expect("a _parent cycle must not drop all nodes");
|
||||
// a 先序、被提升为 root,b 作为 a 的子;总节点数 2(无丢失)。
|
||||
assert_eq!(total_nodes(&nodes), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_nodes_ignores_draft_inside_think_block() {
|
||||
// 推理模型(MiniMax-M3)在 <think> 里写**未完成的扁平草稿**,`</think>`
|
||||
// 之后才是真答案。应解析真答案(嵌套 root),而不是 think 里的扁平草稿
|
||||
// (否则得到 M3 实测的扁平 depth-2 残骸)。
|
||||
let text = r#"<think>
|
||||
Let me draft the cards: [
|
||||
{"type":"frame","id":"draft-a","name":"A","width":80,"height":50},
|
||||
{"type":"frame","id":"draft-b","name":"B","width":80,"height":50}
|
||||
]
|
||||
and similarly for the rest...
|
||||
</think>
|
||||
[
|
||||
{"type":"frame","id":"root","name":"Root","x":0,"y":0,"width":300,"height":200,"layout":"horizontal","_parent":null},
|
||||
{"type":"frame","id":"a","name":"A","width":80,"height":50,"_parent":"root"},
|
||||
{"type":"frame","id":"b","name":"B","width":80,"height":50,"_parent":"root"}
|
||||
]"#;
|
||||
let nodes = parse_nodes(text).expect("should parse the real answer after </think>");
|
||||
assert_eq!(
|
||||
nodes.len(),
|
||||
1,
|
||||
"real answer is 1 nested root, not the 2-node draft"
|
||||
);
|
||||
assert_eq!(nodes[0].id_str(), "root");
|
||||
assert_eq!(crate::cleanup::count_descendants(&nodes[0]), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strip_reasoning_takes_text_after_last_close_tag() {
|
||||
assert_eq!(strip_reasoning("<think>draft</think>REAL").trim(), "REAL");
|
||||
assert_eq!(strip_reasoning("no tags here"), "no tags here");
|
||||
// 在 think 中被截断(无闭合标签)→ 返回开标签之前(此处空),
|
||||
// 不把未完成草稿当输出。
|
||||
assert_eq!(strip_reasoning("<think>cut off draft"), "");
|
||||
assert_eq!(strip_reasoning("preamble <think>cut off"), "preamble ");
|
||||
// 闭合块 + 内容 + 末尾被截断的第二个 think 块 → 只保留中间内容
|
||||
// (Codex review:截断的第二个 think 块仍被当真节点)。
|
||||
assert_eq!(
|
||||
strip_reasoning("<think>plan</think>REAL<think>redo [{\"id\"").trim(),
|
||||
"REAL"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_nodes_rejects_truncated_think_draft() {
|
||||
// <think> 打开但被 max_tokens 截断(无 </think>),里面是未完成的草稿
|
||||
// JSON。绝不能把草稿当真节点解析(Codex review:truncated think 仍被
|
||||
// 当真节点)——strip 到 <think> 之前(空)→ 解析失败、上层重试。
|
||||
let text = r#"<think>
|
||||
Let me draft the cards: [
|
||||
{"type":"frame","id":"draft","name":"D","width":80,"height":50}
|
||||
"#;
|
||||
assert!(
|
||||
parse_nodes(text).is_err(),
|
||||
"truncated think draft must not parse as real nodes"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_nodes_ignores_draft_in_truncated_second_think() {
|
||||
// 闭合 think#1 + 真答案 + 被截断的 think#2(含草稿)。只解析真答案,
|
||||
// 不碰 think#2 草稿(Codex review:截断的第二个 think 块仍被当真节点)。
|
||||
let text = r#"<think>planning the layout</think>
|
||||
[
|
||||
{"type":"frame","id":"root","name":"Root","x":0,"y":0,"width":300,"height":200,"layout":"horizontal","_parent":null},
|
||||
{"type":"frame","id":"a","name":"A","width":80,"height":50,"_parent":"root"}
|
||||
]
|
||||
<think>wait, let me reconsider and draft more cards: [
|
||||
{"type":"frame","id":"draft-x","name":"X","width":99,"height":99}
|
||||
"#;
|
||||
let nodes = parse_nodes(text).expect("real answer between think blocks must parse");
|
||||
assert_eq!(nodes.len(), 1);
|
||||
assert_eq!(nodes[0].id_str(), "root");
|
||||
// 只有 a;draft-x(在被截断的 think#2 里)不计入。
|
||||
assert_eq!(crate::cleanup::count_descendants(&nodes[0]), 1);
|
||||
}
|
||||
31
crates/op-orchestrator/src/test_fixtures/ark_metrics.jsonl
Normal file
31
crates/op-orchestrator/src/test_fixtures/ark_metrics.jsonl
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
{"_parent":null,"id":"metrics-root","type":"frame","name":"Metric Cards Row","role":"section","width":"fill_container","height":"fit_content","layout":"vertical","gap":0,"padding":[12,0]}
|
||||
{"_parent":"metrics-root","id":"metrics-row","type":"frame","name":"Metrics Cards","role":"row","width":"fill_container","height":156,"layout":"horizontal","gap":20,"padding":0}
|
||||
{"_parent":"metrics-row","id":"metrics-card-revenue","type":"frame","name":"Revenue Card","role":"stat-card","width":"fill_container","height":"fill_container","layout":"vertical","gap":18,"padding":20,"cornerRadius":12,"fill":[{"type":"solid","color":"$color-surface"}],"stroke":{"thickness":1,"fill":[{"type":"solid","color":"$color-border"}]},"effects":[{"type":"shadow","offsetX":0,"offsetY":8,"blur":24,"spread":0,"color":"#0F172A14"}]}
|
||||
{"_parent":"metrics-card-revenue","id":"metrics-revenue-top","type":"frame","name":"Revenue Header","width":"fill_container","height":"fit_content","layout":"horizontal","justifyContent":"space_between","alignItems":"center","gap":12,"padding":0}
|
||||
{"_parent":"metrics-revenue-top","id":"metrics-revenue-label","type":"text","name":"Revenue Label","role":"label","content":"Revenue","fontFamily":"Inter","fontSize":"$type-caption-size","fontWeight":500,"lineHeight":"$type-caption-line-height","letterSpacing":1.2,"width":"fit_content","textGrowth":"auto","fill":[{"type":"solid","color":"$color-text-muted"}]}
|
||||
{"_parent":"metrics-revenue-top","id":"metrics-revenue-badge","type":"frame","name":"Revenue Change Badge","role":"badge","width":"fit_content","height":"fit_content","layout":"horizontal","alignItems":"center","gap":4,"padding":[6,8],"cornerRadius":999,"fill":[{"type":"solid","color":"$color-success-bg"}]}
|
||||
{"_parent":"metrics-revenue-badge","id":"metrics-revenue-trend-icon","type":"icon_font","name":"Revenue Trend Icon","iconFontName":"trending-up","width":14,"height":14,"fill":"$color-success-text"}
|
||||
{"_parent":"metrics-revenue-badge","id":"metrics-revenue-change","type":"text","name":"Revenue Change","content":"+12.5%","fontFamily":"Inter","fontSize":"$type-caption-size","fontWeight":600,"lineHeight":"$type-caption-line-height","width":"fit_content","textGrowth":"auto","fill":[{"type":"solid","color":"$color-success-text"}]}
|
||||
{"_parent":"metrics-card-revenue","id":"metrics-revenue-value","type":"text","name":"Revenue Value","content":"$128.4K","fontFamily":"Inter","fontSize":32,"fontWeight":700,"lineHeight":1.1,"letterSpacing":-0.5,"width":"fit_content","textGrowth":"auto","fill":[{"type":"solid","color":"$color-text-primary"}]}
|
||||
{"_parent":"metrics-card-users","id":"metrics-users-top","type":"frame","name":"Users Header","width":"fill_container","height":"fit_content","layout":"horizontal","justifyContent":"space_between","alignItems":"center","gap":12,"padding":0}
|
||||
{"_parent":"metrics-row","id":"metrics-card-users","type":"frame","name":"Active Users Card","role":"stat-card","width":"fill_container","height":"fill_container","layout":"vertical","gap":18,"padding":20,"cornerRadius":12,"fill":[{"type":"solid","color":"$color-surface"}],"stroke":{"thickness":1,"fill":[{"type":"solid","color":"$color-border"}]},"effects":[{"type":"shadow","offsetX":0,"offsetY":8,"blur":24,"spread":0,"color":"#0F172A14"}]}
|
||||
{"_parent":"metrics-card-users","id":"metrics-users-top","type":"frame","name":"Users Header","width":"fill_container","height":"fit_content","layout":"horizontal","justifyContent":"space_between","alignItems":"center","gap":12,"padding":0}
|
||||
{"_parent":"metrics-users-top","id":"metrics-users-label","type":"text","name":"Users Label","role":"label","content":"Active Users","fontFamily":"Inter","fontSize":"$type-caption-size","fontWeight":500,"lineHeight":"$type-caption-line-height","letterSpacing":1.2,"width":"fit_content","textGrowth":"auto","fill":[{"type":"solid","color":"$color-text-muted"}]}
|
||||
{"_parent":"metrics-users-top","id":"metrics-users-badge","type":"frame","name":"Users Change Badge","role":"badge","width":"fit_content","height":"fit_content","layout":"horizontal","alignItems":"center","gap":4,"padding":[6,8],"cornerRadius":999,"fill":[{"type":"solid","color":"$color-success-bg"}]}
|
||||
{"_parent":"metrics-users-badge","id":"metrics-users-trend-icon","type":"icon_font","name":"Users Trend Icon","iconFontName":"trending-up","width":14,"height":14,"fill":"$color-success-text"}
|
||||
{"_parent":"metrics-users-badge","id":"metrics-users-change","type":"text","name":"Users Change","content":"+8.2%","fontFamily":"Inter","fontSize":"$type-caption-size","fontWeight":600,"lineHeight":"$type-caption-line-height","width":"fit_content","textGrowth":"auto","fill":[{"type":"solid","color":"$color-success-text"}]}
|
||||
{"_parent":"metrics-card-users","id":"metrics-users-value","type":"text","name":"Users Value","content":"24,892","fontFamily":"Inter","fontSize":32,"fontWeight":700,"lineHeight":1.1,"letterSpacing":-0.5,"width":"fit_content","textGrowth":"auto","fill":[{"type":"solid","color":"$color-text-primary"}]}
|
||||
{"_parent":"metrics-row","id":"metrics-card-profit","type":"frame","name":"Profit Card","role":"stat-card","width":"fill_container","height":"fill_container","layout":"vertical","gap":18,"padding":20,"cornerRadius":12,"fill":[{"type":"solid","color":"$color-surface"}],"stroke":{"thickness":1,"fill":[{"type":"solid","color":"$color-border"}]},"effects":[{"type":"shadow","offsetX":0,"offsetY":8,"blur":24,"spread":0,"color":"#0F172A14"}]}
|
||||
{"_parent":"metrics-card-profit","id":"metrics-profit-top","type":"frame","name":"Profit Header","width":"fill_container","height":"fit_content","layout":"horizontal","justifyContent":"space_between","alignItems":"center","gap":12,"padding":0}
|
||||
{"_parent":"metrics-profit-top","id":"metrics-profit-label","type":"text","name":"Profit Label","role":"label","content":"Net Profit","fontFamily":"Inter","fontSize":"$type-caption-size","fontWeight":500,"lineHeight":"$type-caption-line-height","letterSpacing":1.2,"width":"fit_content","textGrowth":"auto","fill":[{"type":"solid","color":"$color-text-muted"}]}
|
||||
{"_parent":"metrics-profit-top","id":"metrics-profit-badge","type":"frame","name":"Profit Change Badge","role":"badge","width":"fit_content","height":"fit_content","layout":"horizontal","alignItems":"center","gap":4,"padding":[6,8],"cornerRadius":999,"fill":[{"type":"solid","color":"$color-danger-bg"}]}
|
||||
{"_parent":"metrics-profit-badge","id":"metrics-profit-trend-icon","type":"icon_font","name":"Profit Trend Icon","iconFontName":"trending-down","width":14,"height":14,"fill":"$color-danger-text"}
|
||||
{"_parent":"metrics-profit-badge","id":"metrics-profit-change","type":"text","name":"Profit Change","content":"-3.1%","fontFamily":"Inter","fontSize":"$type-caption-size","fontWeight":600,"lineHeight":"$type-caption-line-height","width":"fit_content","textGrowth":"auto","fill":[{"type":"solid","color":"$color-danger-text"}]}
|
||||
{"_parent":"metrics-card-profit","id":"metrics-profit-value","type":"text","name":"Profit Value","content":"$38.9K","fontFamily":"Inter","fontSize":32,"fontWeight":700,"lineHeight":1.1,"letterSpacing":-0.5,"width":"fit_content","textGrowth":"auto","fill":[{"type":"solid","color":"$color-text-primary"}]}
|
||||
{"_parent":"metrics-row","id":"metrics-card-conversion","type":"frame","name":"Conversion Card","role":"stat-card","width":"fill_container","height":"fill_container","layout":"vertical","gap":18,"padding":20,"cornerRadius":12,"fill":[{"type":"solid","color":"$color-surface"}],"stroke":{"thickness":1,"fill":[{"type":"solid","color":"$color-border"}]},"effects":[{"type":"shadow","offsetX":0,"offsetY":8,"blur":24,"spread":0,"color":"#0F172A14"}]}
|
||||
{"_parent":"metrics-card-conversion","id":"metrics-conversion-top","type":"frame","name":"Conversion Header","width":"fill_container","height":"fit_content","layout":"horizontal","justifyContent":"space_between","alignItems":"center","gap":12,"padding":0}
|
||||
{"_parent":"metrics-conversion-top","id":"metrics-conversion-label","type":"text","name":"Conversion Label","role":"label","content":"Conversion","fontFamily":"Inter","fontSize":"$type-caption-size","fontWeight":500,"lineHeight":"$type-caption-line-height","letterSpacing":1.2,"width":"fit_content","textGrowth":"auto","fill":[{"type":"solid","color":"$color-text-muted"}]}
|
||||
{"_parent":"metrics-conversion-top","id":"metrics-conversion-badge","type":"frame","name":"Conversion Change Badge","role":"badge","width":"fit_content","height":"fit_content","layout":"horizontal","alignItems":"center","gap":4,"padding":[6,8],"cornerRadius":999,"fill":[{"type":"solid","color":"$color-info-bg"}]}
|
||||
{"_parent":"metrics-conversion-badge","id":"metrics-conversion-trend-icon","type":"icon_font","name":"Conversion Trend Icon","iconFontName":"trending-up","width":14,"height":14,"fill":"$color-info-text"}
|
||||
{"_parent":"metrics-conversion-badge","id":"metrics-conversion-change","type":"text","name":"Conversion Change","content":"+1.8%","fontFamily":"Inter","fontSize":"$type-caption-size","fontWeight":600,"lineHeight":"$type-caption-line-height","width":"fit_content","textGrowth":"auto","fill":[{"type":"solid","color":"$color-info-text"}]}
|
||||
{"_parent":"metrics-card-conversion","id":"metrics-conversion-value","type":"text","name":"Conversion Value","content":"6.84%","fontFamily":"Inter","fontSize":32,"fontWeight":700,"lineHeight":1.1,"letterSpacing":-0.5,"width":"fit_content","textGrowth":"auto","fill":[{"type":"solid","color":"$color-text-primary"}]}
|
||||
Loading…
Reference in a new issue