|
1 | 1 | //! db_colors.rs 单测(内存库,环境隔离;v0.14 B 视觉系统)。 |
| 2 | +//! |
| 3 | +//! @ai-context: 批 7 T18 追加三组 —— ① `tag_colors` 幂等回填的四点(C2.3 逐字)② 12 色 id |
| 4 | +//! 与前端真源 `colorPalette.ts` 的跨语言对拍 ③ 真 SQLite 文件库上的端到端 |
| 5 | +//! 「加标签 / 设色 / 清色 → 重新读库」往返(唯一未覆盖的一跳是 Tauri IPC 壳)。 |
2 | 6 |
|
3 | 7 | use crate::db::Db; |
| 8 | +use crate::types::NewNote; |
| 9 | + |
| 10 | +use super::{deterministic_tag_color, TAG_COLOR_PALETTE_IDS}; |
4 | 11 |
|
5 | 12 | fn mem_db() -> Db { |
6 | 13 | Db::open(":memory:").expect("open in-memory db") |
7 | 14 | } |
8 | 15 |
|
| 16 | +/// 前端真源(**编译期嵌入**):`app/src/utils/colorPalette.ts`。 |
| 17 | +/// 单边漂移(只有一侧改了色板)在这里直接红,不需要任何外部探针。 |
| 18 | +const FRONTEND_PALETTE_TS: &str = include_str!("../../src/utils/colorPalette.ts"); |
| 19 | + |
| 20 | +/// 从真源 TS 的 `COLOR_PALETTE` 字面量里读键序(独立解析,不复用生产代码)。 |
| 21 | +fn frontend_color_ids() -> Vec<String> { |
| 22 | + let start = FRONTEND_PALETTE_TS |
| 23 | + .find("export const COLOR_PALETTE") |
| 24 | + .expect("真源里找不到 COLOR_PALETTE"); |
| 25 | + let block = &FRONTEND_PALETTE_TS[start..]; |
| 26 | + let end = block.find("};").expect("COLOR_PALETTE 字面量没有收尾"); |
| 27 | + block[..end] |
| 28 | + .lines() |
| 29 | + .filter_map(|line| { |
| 30 | + let (key, rest) = line.trim().split_once(':')?; |
| 31 | + if !rest.trim_start().starts_with('{') { |
| 32 | + return None; |
| 33 | + } |
| 34 | + let key = key.trim(); |
| 35 | + let ok = !key.is_empty() && key.chars().all(|c| c.is_ascii_lowercase()); |
| 36 | + ok.then(|| key.to_string()) |
| 37 | + }) |
| 38 | + .collect() |
| 39 | +} |
| 40 | + |
| 41 | +/// 造一条带标签的笔记(`notes.tags` = JSON 数组文本,即回填的输入面)。 |
| 42 | +fn seed_note(db: &Db, title: &str, tags: &[&str]) -> i64 { |
| 43 | + let note = db |
| 44 | + .create_note(&NewNote { |
| 45 | + title: title.to_string(), |
| 46 | + content: String::new(), |
| 47 | + source: "manual".to_string(), |
| 48 | + session_id: None, |
| 49 | + rule_version: None, |
| 50 | + purify_stats: None, |
| 51 | + tags: Some(serde_json::to_string(tags).expect("tags json")), |
| 52 | + properties: None, |
| 53 | + group_id: None, |
| 54 | + }) |
| 55 | + .expect("create note"); |
| 56 | + note.id |
| 57 | +} |
| 58 | + |
9 | 59 | #[test] |
10 | 60 | fn set_get_roundtrip() { |
11 | 61 | // Arrange |
@@ -62,3 +112,140 @@ fn list_orders_by_tag() { |
62 | 112 | // Assert:按 tag 字典序 |
63 | 113 | assert_eq!(all.iter().map(|t| t.tag.as_str()).collect::<Vec<_>>(), vec!["a", "b"]); |
64 | 114 | } |
| 115 | + |
| 116 | +// ── 批 7 T18 · `tag_colors` 幂等回填(C2.3 的四点)───────────────────────────── |
| 117 | + |
| 118 | +/// ① 空库:`notes.tags` 全空 ⇒ 回填 0 行、表仍空(不许凭空造行)。 |
| 119 | +#[test] |
| 120 | +fn backfill_empty_db_inserts_nothing() { |
| 121 | + // Arrange |
| 122 | + let db = mem_db(); |
| 123 | + // Act |
| 124 | + let inserted = db.backfill_tag_colors().expect("backfill"); |
| 125 | + // Assert |
| 126 | + assert_eq!(inserted, 0); |
| 127 | + assert!(db.list_tag_colors().expect("list").is_empty()); |
| 128 | +} |
| 129 | + |
| 130 | +/// ② 已有标签:2 条笔记共 3 个去重标签 ⇒ 回填 3 行,颜色 = 确定性规则值。 |
| 131 | +#[test] |
| 132 | +fn backfill_seeds_distinct_tags_of_existing_notes() { |
| 133 | + // Arrange:重复标签只算一个("编程" 出现在两条笔记上) |
| 134 | + let db = mem_db(); |
| 135 | + seed_note(&db, "n1", &["化妆", "编程"]); |
| 136 | + seed_note(&db, "n2", &["编程", "乐理"]); |
| 137 | + // Act |
| 138 | + let inserted = db.backfill_tag_colors().expect("backfill"); |
| 139 | + // Assert:恰 3 行、逐个标签的颜色等于确定性规则 |
| 140 | + assert_eq!(inserted, 3); |
| 141 | + let all = db.list_tag_colors().expect("list"); |
| 142 | + assert_eq!(all.len(), 3); |
| 143 | + for row in &all { |
| 144 | + assert_eq!(row.color, deterministic_tag_color(&row.tag), "标签 {} 的颜色不是确定性值", row.tag); |
| 145 | + } |
| 146 | + let mut names: Vec<&str> = all.iter().map(|t| t.tag.as_str()).collect(); |
| 147 | + names.sort_unstable(); |
| 148 | + assert_eq!(names, vec!["乐理", "化妆", "编程"]); |
| 149 | + // 交叉自证:另一个库(另一条连接)对同一标签给出同一颜色 ⇒ 规则不含进程随机源 |
| 150 | + let other = mem_db(); |
| 151 | + seed_note(&other, "n1", &["化妆"]); |
| 152 | + other.backfill_tag_colors().expect("backfill other"); |
| 153 | + assert_eq!(other.get_tag_color("化妆").expect("get").as_deref(), Some(deterministic_tag_color("化妆"))); |
| 154 | +} |
| 155 | + |
| 156 | +/// ③ 用户已设色不被覆盖(等价 `INSERT OR IGNORE`)。 |
| 157 | +#[test] |
| 158 | +fn backfill_does_not_override_user_color() { |
| 159 | + // Arrange:用户先显式设色 |
| 160 | + let db = mem_db(); |
| 161 | + seed_note(&db, "n1", &["化妆"]); |
| 162 | + db.set_tag_color("化妆", "red").expect("user set"); |
| 163 | + // 非空真自证:确定性规则给出的默认色**不是** red(否则本用例红不出差别) |
| 164 | + assert_ne!(deterministic_tag_color("化妆"), "red"); |
| 165 | + // Act |
| 166 | + let inserted = db.backfill_tag_colors().expect("backfill"); |
| 167 | + // Assert:该标签已有行 ⇒ 新增 0,且用户的 red 一字未动 |
| 168 | + assert_eq!(inserted, 0); |
| 169 | + assert_eq!(db.get_tag_color("化妆").expect("get").as_deref(), Some("red")); |
| 170 | +} |
| 171 | + |
| 172 | +/// ④ 重跑幂等:连续两次 ⇒ 第二次返回 0,且表**逐字**不变。 |
| 173 | +#[test] |
| 174 | +fn backfill_is_idempotent_on_rerun() { |
| 175 | + // Arrange |
| 176 | + let db = mem_db(); |
| 177 | + seed_note(&db, "n1", &["化妆", "编程"]); |
| 178 | + assert_eq!(db.backfill_tag_colors().expect("first"), 2); |
| 179 | + let before = db.list_tag_colors().expect("list"); |
| 180 | + // Act:第二次 |
| 181 | + let second = db.backfill_tag_colors().expect("second"); |
| 182 | + // Assert |
| 183 | + assert_eq!(second, 0); |
| 184 | + assert_eq!(db.list_tag_colors().expect("list"), before); |
| 185 | +} |
| 186 | + |
| 187 | +// ── 批 7 T18 · 12 色 id 的跨语言对拍(C2.3 第 ②③ 条)──────────────────────── |
| 188 | + |
| 189 | +/// Rust 侧 12 色 id 与前端真源 `COLOR_IDS` **同序同集**(`include_str!` 直读真源)。 |
| 190 | +#[test] |
| 191 | +fn color_ids_match_frontend_palette() { |
| 192 | + // Act |
| 193 | + let frontend = frontend_color_ids(); |
| 194 | + let rust: Vec<String> = TAG_COLOR_PALETTE_IDS.iter().map(|s| s.to_string()).collect(); |
| 195 | + // Assert:先证明解析器没瞎(两侧都得是 12),再逐字比序 |
| 196 | + assert_eq!(frontend.len(), 12, "前端真源解析出 {} 个 id(解析器或真源变了): {frontend:?}", frontend.len()); |
| 197 | + assert_eq!(rust.len(), 12, "Rust 侧色板不是 12 个: {rust:?}"); |
| 198 | + assert_eq!(rust, frontend, "Rust 侧 12 色 id 与前端真源 COLOR_IDS 不同序 / 不同集"); |
| 199 | +} |
| 200 | + |
| 201 | +/// 确定性取色的**跨实现**钉住:期望值由本仓 node 探针 `tmp/t18/fnv.mjs` 独立算出 |
| 202 | +/// (BigInt FNV-1a 64;与 Rust 侧实现各自独立写),逐字抄在这里。 |
| 203 | +#[test] |
| 204 | +fn deterministic_color_matches_independent_probe() { |
| 205 | + for (tag, expected) in [("化妆", "blue"), ("编程", "red"), ("乐理", "gray"), ("x", "white")] { |
| 206 | + assert_eq!(deterministic_tag_color(tag), expected, "标签 {tag} 的确定性取色漂了"); |
| 207 | + } |
| 208 | + // 边界:空标签 / 单字符也落在色板内(不 panic、不越界) |
| 209 | + for tag in ["", "a", "标签标签标签标签标签标签标签标签标签标签"] { |
| 210 | + assert!(TAG_COLOR_PALETTE_IDS.contains(&deterministic_tag_color(tag)), "标签 {tag} 取色越界"); |
| 211 | + } |
| 212 | +} |
| 213 | + |
| 214 | +// ── 批 7 T18 · 端到端(真 SQLite 文件库 + 重开)───────────────────────────── |
| 215 | + |
| 216 | +/// 验收「标签能写进去」的**真库往返**:加标签 → 关闭并重新读库 → 标签仍在(且开库回填 |
| 217 | +/// 把这两个标签变成 `tag_colors` 行);设色 → 读回 → 色在;清色 → 读回 → 色无。 |
| 218 | +/// |
| 219 | +/// @ai-context: 这是本任务**最强**的一层证据 —— 走的是真实 SQLite 文件(不是 mock), |
| 220 | +/// 唯一未覆盖的一跳是 Tauri IPC 壳与 WebView(真机不可达,C6.4)。 |
| 221 | +#[test] |
| 222 | +fn e2e_tag_writes_survive_reopen() { |
| 223 | + // Arrange:临时文件库(不触碰真实数据目录) |
| 224 | + let path = std::env::temp_dir().join(format!("entropy_t18_tags_{}.db", std::process::id())); |
| 225 | + let _ = std::fs::remove_file(&path); |
| 226 | + let id = { |
| 227 | + let db = Db::open(path.to_str().expect("path utf8")).expect("open file db"); |
| 228 | + let note_id = seed_note(&db, "端到端", &[]); |
| 229 | + // Act ①:加标签(生产写端路径 = `update_note_tags` 命令背后的同一数据层方法) |
| 230 | + assert!(db.update_note_tags(note_id, r#"["化妆","编程"]"#).expect("update tags")); |
| 231 | + note_id |
| 232 | + }; // ← db 在此 drop:连接关闭(等价「关掉应用」) |
| 233 | + // Act ②:重新读库 |
| 234 | + let db = Db::open(path.to_str().expect("path utf8")).expect("reopen file db"); |
| 235 | + let note = db.get_note(id).expect("get note").expect("note 仍在"); |
| 236 | + // Assert ①:标签仍在(逐字) |
| 237 | + assert_eq!(note.tags, r#"["化妆","编程"]"#); |
| 238 | + // Assert ②:重开时的幂等回填把这两个标签变成 tag_colors 行(此前该表零种子) |
| 239 | + let seeded = db.list_tag_colors().expect("list after reopen"); |
| 240 | + assert_eq!(seeded.len(), 2, "重开未按 notes.tags 回填: {seeded:?}"); |
| 241 | + // Act/Assert ③:设色 → 读回 → 色在 |
| 242 | + db.set_tag_color("化妆", "purple").expect("set color"); |
| 243 | + assert_eq!(db.get_tag_color("化妆").expect("get").as_deref(), Some("purple")); |
| 244 | + // Act/Assert ④:清色 → 读回 → 色无 |
| 245 | + db.reset_tag_color("化妆").expect("reset color"); |
| 246 | + assert_eq!(db.get_tag_color("化妆").expect("get"), None); |
| 247 | + // 清理 |
| 248 | + drop(db); |
| 249 | + let _ = std::fs::remove_file(&path); |
| 250 | +} |
| 251 | + |
0 commit comments