Files
MailUI4Agents/server/internal/repo/mail_status_readers_test.go
JianFeeeee eeb8f277fd test(mails.status): "正当"落成可判约束(只许比 archived);欠账补一条"还清即转绿"的判据
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 步出现手势代码时立即建,此前是**登记的欠账**。
2026-09-14 17:11:36 +08:00

197 lines
8.5 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package repo
import (
"fmt"
"os"
"path/filepath"
"regexp"
"sort"
"strings"
"testing"
)
/*
判定 `mails.status` 这个**行级**字段的读者清册(pi 2026-09-14 裁定 §1)。
背景:多读者场景里"某人读过"被写成整封"读过了",与按读者派生的
`CountUnread`/`ListInbox` 给出**另一个答案**。两种语义长期并存,迟早有人按行级那个来判。
所以不能只写一句"不要读它" —— 靠人记得的约束都会失效(这轮已用
`|| 'workspace'`、`catch {}`、`fillGap` 反复确认过)。
这条判据的形状:**新增一处读取 `mails.status` ⇒ 红**。
白名单里每一项都要写明**为什么它是正当的**;将来要么按同一个理由登记,要么迁移到
`mail_reads`(`readStateFor`/`unreadFor` 就是那个正确形状)。
行级字段目前**唯一正当的语义是 `archived`**(归档是全局的,不是按读者的)。
'read' 那个值**不再作为判据**(见 repo.go 里 unreadFor/readStateFor 的说明)。
*/
func TestMailsStatusReadersAreRegistered(t *testing.T) {
/*
* 清册:**文件名 → 允许出现的次数**(+ 为什么正当)。
*
* 记**次数**而不是"这个文件里有没有":后者挡不住"在已经允许的文件里再新增一处读取"
* —— 而 pi 的要求是"**新增一处**读取 → 红"。次数变了就必须来改这张清册,
* 于是每新增一处读取都要**当场面对"这处读取合不合规"**这个问题。
* 代价是合法改动也要来改数字:这是有意的(要的就是这个停顿)。
*/
registered := map[string]int{
// archived 判定(2 处:unreadFor / readStateFor)+ GetMailByID 等查询带出行级列
"repo.go": 13,
"thread.go": 1,
"migrate.go": 4, // 一次性回填:历史 'read' → 折进 mail_reads
}
reasons := map[string]string{
"repo.go": "unreadFor/readStateFor 用 m.status 判 archived(正当);其余是查询带出行级列(欠账:应按读者派生)",
"thread.go": "GetThread 带出 m.status(同一处欠账)",
"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`)
// 从包目录往上看**整个 internal/ 树**(repo / db / handler 都要在同一张清册里,
// 否则"新增读者"可以躲在别的包里不被发现)
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
}
b, err := os.ReadFile(path)
if err != nil {
return err
}
src := string(b)
base := filepath.Base(path)
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) {
writes = append(writes, base+":"+fmt.Sprint(i+1)+" "+strings.TrimSpace(line))
}
}
}
return nil
})
if err != nil {
t.Fatal(err)
}
var problems []string
for f, n := range found {
want, ok := registered[f]
if !ok {
problems = append(problems, f+"(新增文件,"+fmt.Sprint(n)+" 处读取)")
continue
}
if n != want {
dir := "多了"
if n < want {
dir = "少了"
}
problems = append(problems, fmt.Sprintf("%s:清册记 %d 处,实际 %d 处(%s %d)—— 为什么:%s",
f, want, n, dir, abs(n-want), reasons[f]))
}
}
for f := range registered {
if _, ok := found[f]; !ok {
problems = append(problems, f+"(清册里有、实际已不用 ⇒ 清册过期,删掉该条)")
}
}
sort.Strings(problems)
if len(problems) > 0 {
t.Fatalf("`mails.status` 的读取清册对不上:\n %s\n\n"+
"行级字段唯一的正当语义是 `archived`(全局);'read' 必须按读者算(readStateFor/unreadFor)。\n"+
"新增一处读取就要**当场面对**这个问题:要么迁到 mail_reads,要么改清册的数字并写明理由。",
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 "))
}
/*
* 写入点:**登记清册**(不是"允许它存在",而是"多一处就红")。
*
* 第一次跑这条判据就抓到了第二处 —— `MarkAllInboxReadForSession`(整会话批量已读)
* 里还有一句 `UPDATE mails SET status = 'read'`,我原先只看到 `MarkMailRead` 那一处。
* 这正是"新增即红"的价值:靠人 grep 会漏,靠判据不会。
*
* 两处都**已登记为欠账**(见下面 debt 注释):行级 'read' 与按读者的派生是两种语义,
* 收口方向是迁到 `mail_reads`(`markReadFor` 已经是那个正确形状),
* 但在"详情/线程仍返回行级 status"这两处读者迁移之前不能只删写入
* —— 那会让 JSON 里的 status 永远停在 unread,是另一种错误事实。
*/
registeredWrites := map[string]string{
"repo.go": "MarkMailRead(单封已读)+ MarkAllInboxReadForSession(整会话批量已读):行级 'read' 冗余,欠账待收口",
}
var unregisteredWrites []string
for _, w := range writes {
base := strings.SplitN(w, ":", 2)[0]
if _, ok := registeredWrites[base]; !ok {
unregisteredWrites = append(unregisteredWrites, w)
}
}
if len(unregisteredWrites) > 0 {
t.Fatalf("新增了 `UPDATE mails SET status` 的写入点,但没登记:\n %s\n"+
"行级 status 与按读者的 `mail_reads` 是**两种语义**:写它的地方每多一处,"+
"\"某人读过\"被写成\"读过了\"的机会就多一处。要么迁到 markReadFor,要么登记理由。",
strings.Join(unregisteredWrites, "\n "))
}
if len(writes) < 2 {
t.Fatalf("清册里登记了两处写入,实际只找到 %d 处 —— 是不是有人已经收口了?"+
"那就把这条清册和 docs 里的欠账一起更新(别让登记变成过期解释)", len(writes))
}
}
func abs(n int) int {
if n < 0 {
return -n
}
return n
}