From e2dbddb97927162fc7df9257e4b9a47b7498df11 Mon Sep 17 00:00:00 2001 From: KiriAky 107 Date: Wed, 9 Sep 2026 06:58:59 +0800 Subject: [PATCH] =?UTF-8?q?test(sync):=20=E5=9C=A8=E7=9C=9F=E5=AE=9E?= =?UTF-8?q?=E5=AE=A2=E6=88=B7=E7=AB=AF=E9=97=B4=E9=AA=8C=E8=AF=81=E4=BA=BA?= =?UTF-8?q?=E8=AE=BE=E4=B8=8E=E5=B8=83=E5=B1=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../OpenNexus生产化实施进度-2026-09-08.md | 9 ++ frontend/src-tauri/tests/core_workspace.rs | 80 ++++++++++++ frontend/src-tauri/tests/sync_push.rs | 115 ++++++++++++++++++ 3 files changed, 204 insertions(+) diff --git a/docs/development/OpenNexus生产化实施进度-2026-09-08.md b/docs/development/OpenNexus生产化实施进度-2026-09-08.md index 620bfcc..00d37ac 100644 --- a/docs/development/OpenNexus生产化实施进度-2026-09-08.md +++ b/docs/development/OpenNexus生产化实施进度-2026-09-08.md @@ -691,3 +691,12 @@ Core 的独立数据目录目前不等于已授权 Vault。Python 旧笔记写 - Python 定向 5 项通过,覆盖全局原有行为、桌面摘要保存、过期摘要映射、Host operation_id、旧全局保留/不自动导入。Host 新测试覆盖真实 Workspace 重开、操作重放、CAS、未知字段和跨 Vault 拒绝。前端新增表单摘要往返与切 Vault 拒绝测试,定向 4 项通过;完整前端此前 526 项通过,类型检查通过,新加测试单独运行通过。 - 该能力仍需真实双设备人设同步/冲突与明确归属旧数据导入验收,不能据此声明全部逻辑数据同步或生产化完成。 - 最终 Python 全套 902 通过、1 项依赖弃用警告,105.26 秒;desktop Rust 全目标 138 通过、12 ignored(库 123、Host 8、其余集成 7),Clippy -D warnings 通过。日志 .build/persona-sync-python.log、.build/persona-sync-rust.log、.build/persona-sync-clippy.log;前端日志 .build/persona-sync-frontend.log 和 .build/persona-sync-types.log。完整目标继续保持未完成。 + + +## 增量:人设与布局的真实集成验收 + +- 扩展 sync_push 实际 HTTP 测试:两个 Rust Workspace 客户端连接 Uvicorn SQLite/DiskObjects 服务。人设和布局先完成初始传输,再各进行 20 轮双方同时修改;人设整数 version 故意相同而内容不同,证明冲突依据文件摘要而非显示版本。 +- 每轮远端提交后,本地另一份内容仍保留,旧摘要解决请求被拒绝;交替使用保留本地/采用远端(各 10 轮),重复同一已持久化选择不增加操作。推拉完成后两端完整记录、hash 和 file_id 相同,sync_next 均为空。最终真实关闭并重开客户端 B,pull 返回 0、push 返回 false,两类记录仍与 A 一致。 +- 扩展 core_workspace 实际 Python Core HTTP + Host 管道测试:读取空人设、保存得到 64 位摘要、同一操作和输入重放 20 次返回相同响应、用空旧摘要重新提交得到 409、不同 Vault 读取被拒绝,正常读取仍等于原回执。Host 待处理操作计数仅增加 1,持久记录 hash 等于 HTTP revision。 +- cargo test --features desktop --test core_workspace --test sync_push:两项集成通过、1 项辅助进程入口 ignored;分别 2.79 秒和 25.44 秒,后者同时包含既有真实 100 MiB 上传断点/重启用例。日志 .build/persona-layout-integration.log。全目标 Clippy -D warnings 通过,日志 .build/persona-layout-integration-clippy.log。本轮仅扩充集成测试,没有重复无关生产代码回归。 +- 证据范围为本机隔离服务与两个 Rust 客户端,并非两台独立硬件上的最终 UI。此处只覆盖同改与两种取舍,不覆盖全部 S-03 的改对删/rename/副本/历史恢复矩阵,也不完成旧全局人设明确归属导入或发布版本兼容。完整生产化目标保持未完成。 diff --git a/frontend/src-tauri/tests/core_workspace.rs b/frontend/src-tauri/tests/core_workspace.rs index edbd84e..f0a0ce2 100644 --- a/frontend/src-tauri/tests/core_workspace.rs +++ b/frontend/src-tauri/tests/core_workspace.rs @@ -289,4 +289,84 @@ async fn real_core_notes_roundtrip_only_through_bound_host_and_confirm_commits() .path() .join("core/unbound-vault/Core fixture.md") .exists()); + // The actual Python HTTP handler must round-trip revision through Host pipes, + // including repeat requests after a successful commit. + let path = "/api/settings/persona"; + let (status, empty) = request( + &mut core, + "GET", + path, + &vault, + &uuid::Uuid::new_v4().to_string(), + None, + ) + .await; + assert_eq!(status, 200, "{empty}"); + assert_eq!(empty["revision"], ""); + let operation = uuid::Uuid::new_v4().to_string(); + let mut body = empty.clone(); + body["system_prompt"] = json!("Host persona fixture"); + let (status, first) = request( + &mut core, + "PUT", + path, + &vault, + &operation, + Some(body.clone()), + ) + .await; + assert_eq!(status, 200, "{first}"); + assert_eq!(first["revision"].as_str().unwrap().len(), 64); + for _ in 0..20 { + let (status, replay) = request( + &mut core, + "PUT", + path, + &vault, + &operation, + Some(body.clone()), + ) + .await; + assert_eq!(status, 200, "{replay}"); + assert_eq!(replay, first); + } + let (status, stale) = request( + &mut core, + "PUT", + path, + &vault, + &uuid::Uuid::new_v4().to_string(), + Some(body.clone()), + ) + .await; + assert_eq!(status, 409, "{stale}"); + let (status, wrong_vault) = request( + &mut core, + "GET", + path, + &uuid::Uuid::new_v4().to_string(), + &uuid::Uuid::new_v4().to_string(), + None, + ) + .await; + assert_eq!(status, 409, "{wrong_vault}"); + let (status, loaded) = request( + &mut core, + "GET", + path, + &vault, + &uuid::Uuid::new_v4().to_string(), + None, + ) + .await; + assert_eq!(status, 200, "{loaded}"); + assert_eq!(loaded, first); + let stored = workspace + .lock() + .unwrap() + .record_get_kind("persona", "default") + .unwrap() + .unwrap(); + assert_eq!(stored["hash"], first["revision"]); + assert_eq!(workspace.lock().unwrap().pending_count().unwrap(), 9); } diff --git a/frontend/src-tauri/tests/sync_push.rs b/frontend/src-tauri/tests/sync_push.rs index d4575a1..d5b069d 100644 --- a/frontend/src-tauri/tests/sync_push.rs +++ b/frontend/src-tauri/tests/sync_push.rs @@ -535,6 +535,121 @@ async fn actual_service_accepts_ordered_push_and_repeat_commit_without_duplicate .unwrap()["record"]["data"]["fontEditorSize"], 24 ); + // Portable records traverse real HTTP, including equal display versions with + // different contents. A numeric persona version cannot replace content CAS. + for (kind, id, data, field) in [ + ( + "persona", + "default", + json!({"version":1,"name":"initial","system_prompt":"Scoped prompt","dialogue_pairs":[]}), + "name", + ), + ( + "layout", + "sidebars", + json!({"primaryExpanded":true,"workspaceWidth":272,"chatWidth":320}), + "workspaceWidth", + ), + ] { + let path = notesagent_host::records::path_for(kind, id).unwrap(); + let record = json!({"schema":1,"kind":kind,"id":id,"data":data}); + workspace + .lock() + .unwrap() + .write(&path, "", &serde_json::to_vec(&record).unwrap(), "local") + .unwrap(); + while client.push_one(&workspace, &binding).await.unwrap() {} + client_b.pull_page(&workspace_b, &binding_b).await.unwrap(); + for round in 0..20 { + let mut hashes = Vec::new(); + for (side, target) in [(0, &workspace), (1, &workspace_b)] { + let mut ws = target.lock().unwrap(); + let current = ws.record_get_kind(kind, id).unwrap().unwrap(); + let mut next = current["record"].clone(); + next["data"][field] = if kind == "persona" { + json!(format!("side-{side}-round-{round}")) + } else { + json!(300 + round * 2 + side) + }; + let entry = ws + .write( + &path, + current["hash"].as_str().unwrap(), + &serde_json::to_vec(&next).unwrap(), + "local", + ) + .unwrap(); + hashes.push(entry.hash); + } + while client.push_one(&workspace, &binding).await.unwrap() {} + client_b.pull_page(&workspace_b, &binding_b).await.unwrap(); + let choice = if round % 2 == 0 { "local" } else { "remote" }; + { + let mut ws = workspace_b.lock().unwrap(); + let conflicts = ws.sync_conflicts(&binding_b.id).unwrap(); + assert_eq!(conflicts.len(), 1); + let sequence = conflicts[0]["sequence"].as_i64().unwrap(); + assert_eq!(ws.read(&path).unwrap().entry.hash, hashes[1]); + assert_eq!( + ws.sync_resolve(&binding_b.id, sequence, choice, "", &hashes[0]) + .unwrap_err() + .code, + "REVISION_CONFLICT" + ); + ws.sync_resolve(&binding_b.id, sequence, choice, "", &hashes[1]) + .unwrap(); + ws.sync_resolve(&binding_b.id, sequence, choice, "", &hashes[1]) + .unwrap(); + } + while client_b.push_one(&workspace_b, &binding_b).await.unwrap() {} + client.pull_page(&workspace, &binding).await.unwrap(); + client_b.pull_page(&workspace_b, &binding_b).await.unwrap(); + let a = workspace + .lock() + .unwrap() + .record_get_kind(kind, id) + .unwrap() + .unwrap(); + let b = workspace_b + .lock() + .unwrap() + .record_get_kind(kind, id) + .unwrap() + .unwrap(); + assert_eq!(a, b); + assert_eq!(a["hash"], hashes[usize::from(choice == "local")]); + assert!(workspace + .lock() + .unwrap() + .sync_next(&binding.id) + .unwrap() + .is_none()); + assert!(workspace_b + .lock() + .unwrap() + .sync_next(&binding_b.id) + .unwrap() + .is_none()); + } + } + drop(workspace_b); + let workspace_b = Arc::new(Mutex::new(Workspace::open(root_b.path()).unwrap())); + assert_eq!( + client_b.pull_page(&workspace_b, &binding_b).await.unwrap(), + 0 + ); + assert!(!client_b.push_one(&workspace_b, &binding_b).await.unwrap()); + for (kind, id) in [("persona", "default"), ("layout", "sidebars")] { + assert_eq!( + workspace.lock().unwrap().record_get_kind(kind, id).unwrap(), + workspace_b + .lock() + .unwrap() + .record_get_kind(kind, id) + .unwrap() + ); + } + // Kill the actual client process after each durable 10 MiB server offset, // before its response reaches the client. The next process must query offset. use sha2::{Digest, Sha256};