补充 UI 文本字体引用校验

拒绝 Text 组件引用不存在的 font_assets 条目

新增无效字体引用持久化回归测试

同步 UI State 内部引用校验技术方案
This commit is contained in:
2026-08-17 18:47:34 +08:00
parent fb7dbb7f92
commit 5c031f2590
2 changed files with 81 additions and 8 deletions
@@ -532,13 +532,24 @@ fn validate_node(
));
}
for component in &node.components {
if let Component::Image(image) = component {
if image
.target_graphic
.as_ref()
.is_some_and(|id| !state.sprite_assets.contains_key(id))
{
return Err("Image 组件引用了不存在的独立素材".to_string());
match component {
Component::Image(image) => {
if image
.target_graphic
.as_ref()
.is_some_and(|id| !state.sprite_assets.contains_key(id))
{
return Err("Image 组件引用了不存在的独立素材".to_string());
}
}
Component::Text(text) => {
if text
.font
.as_ref()
.is_some_and(|id| !state.font_assets.contains_key(id))
{
return Err("Text 组件引用了不存在的字体素材".to_string());
}
}
}
}
@@ -774,4 +785,66 @@ mod tests {
})
.is_err());
}
#[test]
fn rejects_text_component_with_missing_font_asset() {
let (directory, asset_id) = fixture();
let state: State = serde_json::from_value(serde_json::json!({
"ui_trees": [{
"src_ui_design": "page",
"root": {
"id": "root",
"transform": {
"anchor_min": [0.0, 0.0],
"anchor_max": [1.0, 1.0],
"offset_min": [0.0, 0.0],
"offset_max": [0.0, 0.0]
},
"metadata": {
"name": "根节点",
"description": "",
"layout_status": "Pending",
"components_status": "Pending",
"allow_llm_edit_layout": false,
"allow_llm_edit_component": false,
"source": "System"
},
"components": [{
"Text": {
"content": "标题",
"font": "missing-font",
"font_style": "Normal",
"font_sizing": {"Fixed": 14},
"color": [255, 255, 255, 255],
"alignment": "UpperLeft",
"horizontal_overflow": "Wrap",
"vertical_overflow": "Truncate",
"line_spacing": 1.0
}
}],
"children": []
}
}],
"ui_design_images": {
"page": {
"metadata": {
"name": "主界面",
"description": "",
"role": "Page",
"slave_to": null
},
"path": "assets/page.png",
"pixel_size": [1280.0, 720.0],
"pixels_per_unit": 1.0
}
},
"sprite_assets": {},
"font_assets": {}
}))
.expect("deserialize state with missing font reference");
let error = save_ui_design_state_at(input(directory.path(), &asset_id, 0, state))
.expect_err("missing Text font reference must be rejected");
assert!(error.contains("Text 组件引用了不存在的字体素材"));
}
}
@@ -1076,7 +1076,7 @@ game-project/
## 2026-08-17 UI Editor 项目资源 State 持久化
- UI 编辑器复用现有 manifest `kind: "UI"``mediaType: "application/json"` 资源,不增加平行 asset kind。资源文件固定为严格 `game-creator-ui-design-state.v1` JSON envelope`projectId``assetId`、每资源 `revision` 和 Rust 唯一源 `State`;旧空对象、未知字段、身份错配、超限、无效内部引用和不安全相对路径均失败关闭。
- UI 编辑器复用现有 manifest `kind: "UI"``mediaType: "application/json"` 资源,不增加平行 asset kind。资源文件固定为严格 `game-creator-ui-design-state.v1` JSON envelope`projectId``assetId`、每资源 `revision` 和 Rust 唯一源 `State`;旧空对象、未知字段、身份错配、超限、无效内部引用和不安全相对路径均失败关闭。内部引用校验同时覆盖 `Image.target_graphic -> sprite_assets``Text.font -> font_assets`,可选引用非空时必须命中同一 State 内已登记资源。
- Tauri 专用 load/save command 只接受项目路径、期望项目 ID、manifest asset ID 和(保存时)资源 revisionRust 按 manifest 解析受控本地路径并在项目写锁内做 CAS。相同 `State` 返回 unchanged 且不推进 project revision;不同内容安装并回读一致后才推进 revision,后续推进失败返回 `reconciliation-required`,不伪装为完整保存。
- UI State 原子安装保留最近一个有效 `.previous` 恢复副本。主文件损坏时仅在恢复副本通过同一严格校验后恢复;恢复安装与保存共用项目写锁,并在持锁后重新读取主文件,已有并发保存的有效新版本时直接返回而不安装旧副本。任一候选均不可信则停在加载错误,前端禁编辑和保存。新建 UI 资源先登记并安装合法 envelope,任一步失败补偿 manifest/文件,避免把空 JSON 留给资源卡。
- 图片路径只需是安全项目相对路径,不要求外部图片仍存在或已登记为 manifest asset;缺失媒体只导致 preview 占位。`imageOrder`、当前选择、缩放、面板开关和 preview URL 不写入 State,加载后由 State 派生。保存冻结提交快照,保存期间的新编辑继续保持 dirty;AI state lock 和加载期间禁保存。