test(sync): 在真实客户端间验证人设与布局
This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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};
|
||||
|
||||
Reference in New Issue
Block a user