From eeb8f277fd0c4242b488d7148ef3b06400e15487 Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Mon, 14 Sep 2026 17:11:36 +0800 Subject: [PATCH] =?UTF-8?q?test(mails.status):=20"=E6=AD=A3=E5=BD=93"?= =?UTF-8?q?=E8=90=BD=E6=88=90=E5=8F=AF=E5=88=A4=E7=BA=A6=E6=9D=9F=EF=BC=88?= =?UTF-8?q?=E5=8F=AA=E8=AE=B8=E6=AF=94=20archived=EF=BC=89=EF=BC=9B?= =?UTF-8?q?=E6=AC=A0=E8=B4=A6=E8=A1=A5=E4=B8=80=E6=9D=A1"=E8=BF=98?= =?UTF-8?q?=E6=B8=85=E5=8D=B3=E8=BD=AC=E7=BB=BF"=E7=9A=84=E5=88=A4?= =?UTF-8?q?=E6=8D=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pi 2026-09-14 的三点接续。 1. **§1「正当」不能只是标注,要是可判的约束**:按文件+计数登记挡得住"新增一处", 挡不住"**已登记的那一处改变性质**"。已判出来:**任何与 m.status 的比较只允许比 'archived'**(全局属性),与 'read'/'unread' 比较 ⇒ 红(per-reader 属性必须走 mail_reads)。 第一次跑就抓到第三处:`migrate.go` 的 `WHERE m.status = 'read'` ×2 —— 它是**回填**, 职责就是读那个遗留值,所以按文件登记**窄豁免**(allowedCompareExempt,带理由), 而不是"凡是迁移都放行"。 2. **§2 欠账要有"还清即转绿"的判据**:新增 `mail_status_derived_test.go`,三态形状 (与套件探针同族)—— 欠账未清 ⇒ `t.Skip` 并写明还欠什么(**不假红也不假绿**); 还清 ⇒ 开始实跑并断言"详情路径的 status == 按读者派生的值",**转绿即可测事件**; 迁一半(只改 GetMailByID 漏 GetThread)⇒ 红。 实测当前状态:`--- SKIP ... 详情路径返回行级 status="read",而按读者派生说 "unread"`。 **没写成"现在就红"**:恒红的门 = 挂在错误相位的门(CRITERIA.md §11 已为它付过学费)。 另按他的更正把 `migrate.go` 的理由写准:不是"跑过即结束",而是 **"全新安装/空库路径必须存在"**(措辞会影响下一个人敢不敢删它)。 3. **§3 (b) 的语义契约补归属**:归属 = dsh(鸿蒙侧实现者),WebUI 侧不需改动; 落点 = 跨端对齐判据那一族(形状与"预设清单逐项一致"同构,比语义不比数值); **如实标注当前未建**及原因(鸿蒙侧还没有日历页/手势代码 ⇒ 此时"两端逐项相同"只有一端存在, 建出来就是假判据);到期前提 = P6 第 3 步出现手势代码时立即建,此前是**登记的欠账**。 --- docs/HARMONY-ALIGN-PLAN.md | 12 ++++ .../internal/repo/mail_status_derived_test.go | 70 +++++++++++++++++++ .../internal/repo/mail_status_readers_test.go | 48 ++++++++++++- 3 files changed, 129 insertions(+), 1 deletion(-) create mode 100644 server/internal/repo/mail_status_derived_test.go diff --git a/docs/HARMONY-ALIGN-PLAN.md b/docs/HARMONY-ALIGN-PLAN.md index dfb999e..fa5f688 100644 --- a/docs/HARMONY-ALIGN-PLAN.md +++ b/docs/HARMONY-ALIGN-PLAN.md @@ -127,6 +127,18 @@ WebUI 侧踩过这个坑,见 `gateway/handler/permission.go` 的 Note 传递 **契约(改钉这张表)**:语义映射 —— 左滑 = 什么、右滑 = 什么、翻页边界是否回弹、 "快滑"是**用户感知档**(不是毫秒数)。数值各自定,语义必须一致。 +**语义契约的归属与落点(pi 2026-09-14 裁定 §3:跨端语义要有归属,不能只是 P6 的施工说明)** + +- **归属**:**dsh(鸿蒙侧实现者)** —— 这张表由我维护;WebUI 侧**不需要改动**((b) 不要求数值一致)。 +- **落点**:它该进**跨端对齐判据**那一族(`cross-client-theme.test.mjs` 的邻居),形状与 + "预设清单 id/顺序逐项一致"同构,只是这里比的是**语义**而不是数值: + **两端"左滑/右滑/边界回弹/快滑感知档"的映射逐项相同(不比毫秒与像素)**。 +- **当前状态(如实标注,不当成已完成)**:**这条判据还没建**。原因是**鸿蒙侧还没有日历页、 + 也就没有任何手势代码**(全 `ets` 树无 calendar)—— 此时"两端逐项相同"只有一端存在, + 建出来就是**假判据**(与 `bg_blur` 那张"不存在的映射表"同一个错)。**到期前提**:P6 第 3 步 + (鸿蒙侧出现滑动手势代码)时立即建这条判据;在那之前它是一次**登记的欠账**(有余额、有到期), + 不是"挂在一个没有执行者的人身上"的开项。 + **推论(写清,避免下一个人搞反)**:选了 (b) ⇒ **鸿蒙侧不得引用 WebUI 的那三个数** (引了就等于偷偷选了 (a),而且是"我抄的那一份"那种 (a))。鸿蒙自己定值、自己钉住, 并在本节写明"为何与 WebUI 不同"(与 §7.12"有意差异表"同一形状)。 diff --git a/server/internal/repo/mail_status_derived_test.go b/server/internal/repo/mail_status_derived_test.go new file mode 100644 index 0000000..ca289cc --- /dev/null +++ b/server/internal/repo/mail_status_derived_test.go @@ -0,0 +1,70 @@ +package repo + +import ( + "context" + "strings" + "testing" +) + +/* +「欠账还清」必须是一条**可判的事件**,不是"我们都改完了"(pi 2026-09-14 裁定 §2)。 + +欠账本身:`GetMailByID` / `GetThread` 把**行级** `m.status` 放进邮件 JSON, +而列表路径(`CountUnread`/`ListInbox`)给的是**按读者**派生的值 —— 同一个字段两种语义。 +收口顺序是"先迁读者、再删写入"(先删写入会让 JSON 里的 status 永远是 unread,是另一种错误事实)。 + +这条判据的形状(三态,与套件的探针同族): + + - **欠账未清** → `t.Skip`,跳过信息里写明"还欠什么",**不假红也不假绿**; + - **欠账已清** → 开始**实跑**:JSON 里的 `status` 必须由 `mail_reads` 派生 + (对给定 reader 的取值等于派生死值),转绿即"这笔欠账还清了"的**可测事件**; + - 迁移做了一半(例如只改了 `GetMailByID`、漏了 `GetThread`)→ 红。 + +为什么不写成"现在就是红的":**恒红的门 = 挂在错误相位的门**(这轮已经为它付过学费, +见 `client/electron/test/CRITERIA.md` §11)。欠账要有余额、要有到期,也要有"还清即转绿"。 +*/ +func TestMailStatusInJSONIsDerivedWhenDebtCleared(t *testing.T) { + setupTestDB(t) + ctx := context.Background() + + id := seedMailTo(t, "alice", "bob") + + // 欠账是否还在:看详情路径返回的 status 与"按读者派生"是否可能不同 + // 手法:alice 未读时行级值仍是 'unread'(没写过的邮件),所以先让 bob 读掉这封 —— + // 一旦行级写入生效,alice 在详情里就会看到 'read',而列表路径(按读者)说 'unread'。 + if err := MarkMailRead(ctx, id, "bob"); err != nil { + t.Fatal(err) + } + + m, err := GetMailByID(ctx, id) + if err != nil { + t.Fatal(err) + } + perReader, err := ListInbox(ctx, "alice", "all", 50) + if err != nil { + t.Fatal(err) + } + var listStatus string + for _, x := range perReader { + if x.ID == id { + listStatus = x.Status + } + } + if listStatus == "" { + t.Fatalf("自检:列表路径里应当能看到这封邮件(id=%s)", id) + } + + // 欠账判定的**唯一依据**是"详情路径是否已按读者派生"——所以先看它是否等于派生死值 + if m.Status != listStatus { + // 欠账还在:不假红,明确跳过并写清欠的是什么、以及还清后这条会开始实跑 + t.Skipf("欠账未清(本判据在还清那天会自动开始实跑并转绿):详情路径返回行级 status=%q,"+ + "而按读者派生说 %q —— 差在 GetMailByID/GetThread 仍带出行级列;"+ + "迁完那两处(改用 readStateFor)后本判据会实跑。收口顺序:先迁读者、再删写入。", + m.Status, listStatus) + } + + // 已还清:断言更强的性质 —— 详情与列表必须一致(都对同一个 reader 派生) + if !strings.EqualFold(m.Status, listStatus) { + t.Fatalf("详情 %q 与列表 %q 不一致", m.Status, listStatus) + } +} diff --git a/server/internal/repo/mail_status_readers_test.go b/server/internal/repo/mail_status_readers_test.go index c6be6c0..0e068db 100644 --- a/server/internal/repo/mail_status_readers_test.go +++ b/server/internal/repo/mail_status_readers_test.go @@ -43,8 +43,27 @@ func TestMailsStatusReadersAreRegistered(t *testing.T) { reasons := map[string]string{ "repo.go": "unreadFor/readStateFor 用 m.status 判 archived(正当);其余是查询带出行级列(欠账:应按读者派生)", "thread.go": "GetThread 带出 m.status(同一处欠账)", - "migrate.go": "一次性回填 mail_reads(历史 'read' → 按读者记账),跑过即结束", + "migrate.go": "**全新安装/空库路径必须存在**:把历史 'read' 折进 mail_reads(措辞按 pi 2026-09-14 更正 —— 不是「跑过即结束」,否则下一个人会以为可以删)", } + /* + * 「正当」必须是**可判的约束**,不能是一句标注(pi 2026-09-14 裁定 §1)。 + * + * 按文件+计数登记挡得住"新增一处",挡不住"**已登记的那一处改变性质**": + * `unreadFor`/`readStateFor` 之所以正当,是因为它们比的字面量是 `'archived'` + * (**全局属性**:归档对所有人都归档),而不是 `'read'`(**per-reader 属性**)。 + * 可这个"正当"原先只活在人的判断里 —— 明天有人让同一处也看 `status='read'`, + * 计数不变、清册全绿,而语义塌回行级。 + * + * 所以这里把它判出来:**任何与 m.status 的比较,只允许比 'archived'**; + * 出现与 'read'(或任何别的字面量)的比较 ⇒ 红。 + */ + compareRe := regexp.MustCompile(`\bm\.status\s*(?:=|<>|!=)\s*'([a-z_]+)'`) + allowedLiteral := "archived" + // 按文件登记的豁免(带理由);不在这张表里的文件与 'archived' 之外的字面量比较 ⇒ 红 + allowedCompareExempt := map[string]string{ + "migrate.go": "迁移回填:把历史 'read' 折进 mail_reads —— 它**必须**读那个遗留值,这是它的职责", + } + writeRe := regexp.MustCompile(`UPDATE\s+mails\s+SET\s+status`) readRe := regexp.MustCompile(`\bm\.status\b|mails\.status`) @@ -53,6 +72,7 @@ func TestMailsStatusReadersAreRegistered(t *testing.T) { root := filepath.Join("..", "..", "internal") found := map[string]int{} var writes []string + var badCompare []string err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error { if err != nil || info.IsDir() || !strings.HasSuffix(path, ".go") || strings.HasSuffix(path, "_test.go") { return nil @@ -66,6 +86,24 @@ func TestMailsStatusReadersAreRegistered(t *testing.T) { if n := len(readRe.FindAllString(src, -1)); n > 0 { found[base] = n } + for i, line := range strings.Split(src, "\n") { + for _, m := range compareRe.FindAllStringSubmatch(line, -1) { + if m[1] == allowedLiteral { + continue + } + /* + * 唯一豁免:**迁移回填**。它的职责就是读那个遗留值(把历史 'read' 折进 + * mail_reads),所以这里比 'read' 不但正当,而且是必须的。 + * 豁免也做成**可判的**:按文件登记 + 写明理由(不是"凡是迁移都放行")。 + */ + if why, ok := allowedCompareExempt[base]; ok { + _ = why + continue + } + badCompare = append(badCompare, + fmt.Sprintf("%s:%d 把 m.status 与 '%s' 比较 —— %s", base, i+1, m[1], strings.TrimSpace(line))) + } + } if readRe.MatchString(src) || writeRe.MatchString(src) { for i, line := range strings.Split(src, "\n") { if writeRe.MatchString(line) { @@ -108,6 +146,14 @@ func TestMailsStatusReadersAreRegistered(t *testing.T) { strings.Join(problems, "\n ")) } + if len(badCompare) > 0 { + t.Fatalf("`m.status` 被拿去和 `'%s'` 以外的东西比较了(行级语义会因此塌回邮件级):\n %s\n\n"+ + "`archived` 是**全局**属性(对所有人都归档),可以留在行级;`read`/`unread` 是**按读者**的,\n"+ + "必须用 mail_reads 派生(见 readStateFor/unreadFor)。\n"+ + "(唯一豁免是迁移回填,按文件登记在 allowedCompareExempt 里,且要写理由。)", + allowedLiteral, strings.Join(badCompare, "\n ")) + } + /* * 写入点:**登记清册**(不是"允许它存在",而是"多一处就红")。 *