diff --git a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/persistence.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/persistence.rs index 0d3d5df44..b3e735359 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/persistence.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/persistence.rs @@ -284,8 +284,9 @@ fn read_ui_design_document_path(path: &Path) -> Result 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(); diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 94be7941e..65a2ce712 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -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 仍会默认缩放、主动预览请求饥饿、过滤后媒体继续播放或超深布局反复提交非法坐标。