test(sync): 在真实客户端间验证人设与布局

This commit is contained in:
2026-09-09 06:58:59 +08:00
parent b83e8dfc2d
commit e2dbddb979
3 changed files with 204 additions and 0 deletions
@@ -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/副本/历史恢复矩阵,也不完成旧全局人设明确归属导入或发布版本兼容。完整生产化目标保持未完成。
@@ -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);
}
+115
View File
@@ -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};