修复UI设计状态浮点回读失败

统一 UI State 写入与严格回读的浮点序列化表示

补充拖拽 Transform 与灵体素材保存回归测试

记录 UI State 浮点 JSON 校验排障经验
This commit is contained in:
2026-08-19 13:41:46 +08:00
parent ed092b3688
commit 33fe06b3ac
2 changed files with 143 additions and 2 deletions
@@ -284,7 +284,8 @@ fn read_ui_design_document_path(path: &Path) -> Result<PersistedUiDesignState, S
.map_err(|error| format!("解析 UI 设计 State 失败:{}: {error}", path.display()))?;
let document: PersistedUiDesignState = serde_json::from_value(value.clone())
.map_err(|error| format!("解析 UI 设计 State 契约失败:{}: {error}", path.display()))?;
let canonical = serde_json::to_value(&document)
let canonical: serde_json::Value =
serde_json::from_slice(&serialize_ui_design_document(&document)?)
.map_err(|error| format!("序列化 UI 设计 State 契约失败:{error}"))?;
if value != canonical {
return Err("UI 设计 State 包含不受当前 schema 支持的字段或值".to_string());
@@ -701,6 +702,99 @@ mod tests {
.expect("deserialize valid UI design state")
}
fn state_with_sprite_and_dragged_transform() -> State {
serde_json::from_value(serde_json::json!({
"ui_trees": [{
"src_ui_design": "page",
"root": {
"id": "root",
"layout": {
"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]
},
"custom_minimum_size": [0.0, 0.0],
"size_flags_horizontal": 1,
"size_flags_vertical": 1,
"size_flags_stretch_ratio": 1.0,
"container": "None"
},
"metadata": {
"name": "页面根节点",
"description": "",
"layout_status": "Passed",
"components_status": "Passed",
"allow_llm_edit_layout": true,
"allow_llm_edit_component": true,
"source": "System"
},
"components": [],
"children_display_mode": "Stack",
"children": [{
"id": "dragged-node",
"layout": {
"transform": {
"anchor_min": [0.5, 0.5],
"anchor_max": [0.5, 0.5],
"offset_min": [-248.53181076672104, -104.40456769983687],
"offset_max": [348.53181076672104, 204.40456769983687]
},
"custom_minimum_size": [0.0, 0.0],
"size_flags_horizontal": 1,
"size_flags_vertical": 1,
"size_flags_stretch_ratio": 1.0,
"container": "None"
},
"metadata": {
"name": "拖拽节点",
"description": "",
"layout_status": "Passed",
"components_status": "Passed",
"allow_llm_edit_layout": true,
"allow_llm_edit_component": true,
"source": "Human"
},
"components": [{
"Image": {
"target_graphic": "spirit",
"image_type": { "Simple": { "preserve_aspect": false } }
}
}],
"children_display_mode": "Stack",
"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": {
"spirit": {
"asset_id": "spirit",
"metadata": { "name": "灵体", "asset_type": "角色" },
"path": "assets/spirit.png",
"pixel_size": [128.0, 256.0],
"pixels_per_unit": 1.0,
"border": { "left": 0, "right": 0, "top": 0, "bottom": 0 }
}
},
"font_assets": {}
}))
.expect("deserialize dragged UI design state")
}
#[test]
fn saves_loads_and_compares_per_resource_revisions() {
let (directory, asset_id) = fixture();
@@ -745,6 +839,45 @@ mod tests {
);
}
#[test]
fn preserves_sprite_asset_when_saving_a_dragged_node_transform() {
let (directory, asset_id) = fixture();
let state = state_with_sprite_and_dragged_transform();
let expected_document = PersistedUiDesignState {
schema_version: UI_DESIGN_STATE_SCHEMA_VERSION.to_string(),
project_id: PROJECT_ID.to_string(),
asset_id: asset_id.clone(),
revision: 1,
state: state.clone(),
};
let serialized = serialize_ui_design_document(&expected_document)
.expect("serialize dragged state fixture");
let decoded: PersistedUiDesignState =
serde_json::from_slice(&serialized).expect("deserialize dragged state fixture");
assert_eq!(decoded, expected_document);
let saved = save_ui_design_state_at(input(directory.path(), &asset_id, 0, state.clone()))
.expect("save dragged state with sprite asset");
assert!(matches!(
saved,
SaveUiDesignStateResult::Saved {
revision: 1,
state: ref installed,
..
} if installed == &state
));
let loaded = load_ui_design_state_at(LoadUiDesignStateInput {
project_path: directory.path().to_string_lossy().into_owned(),
expected_project_id: PROJECT_ID.to_string(),
asset_id,
})
.expect("reload dragged state with sprite asset");
assert_eq!(loaded.revision, 1);
assert_eq!(loaded.state, state);
}
#[test]
fn recovers_last_valid_state_when_primary_is_corrupt() {
let (directory, asset_id) = fixture();
@@ -14,6 +14,14 @@
- 关联:相关文件、文档、提交或 Issue
```
## UI 设计 State 的 strict JSON round-trip 不能混用两种浮点序列化表示(2026-08-19)
- 现象:为节点拖拽/缩放生成非整数 Transform 后,保存报“UI 设计 State 安装后回读与待写内容不一致”;由于读取主文件失败关闭后恢复 `.previous`,后续回读表现为刚导入的 spirit/sprite 资产丢失。
- 原因:写入使用 `serde_json::to_vec_pretty`,它对 `f32` 输出短十进制(如 `348.5318`);读取端却以 `serde_json::to_value` 重建 canonical JSON,重新扩展为精确二进制值(如 `348.53179931640625`)。两者作为 `serde_json::Value` 不相等,合法的新主文件被误判为不受支持,然后错误回退到旧恢复副本。
- 处理:严格 envelope 检查必须使用与磁盘写入相同的 `serialize_ui_design_document` 再解析为 `Value`,仍拒绝未知字段/值,却允许合法 `f32` 的稳定文件表示;不可再把 `to_vec_pretty``to_value` 的数字文本直接比较。
- 验证:持久化回归使用带 `spirit` sprite、`Image.target_graphic` 引用及拖拽式非整数 Transform 的完整 State,断言保存返回和后续 load 均完整相等;同时保留旧空对象/未知 envelope 字段拒绝、CAS 和 `.previous` 恢复测试。
- 关联:`apps/ai-game-creator-shell/src-tauri/src/ui_editor/persistence.rs`
## 资源管理第二轮修复后不能继续用第一轮文档和弱测试作为验收合同
- 现象:代码已经改成分区内 SVG plane 和卡内媒体,文档仍要求单全局 Overlay 或中央大图;CSS 正则和浅层 AppSurface 测试保持绿色,但真实 Tauri WebView 仍会默认缩放、主动预览请求饥饿、过滤后媒体继续播放或超深布局反复提交非法坐标。