Files
MailUI4Agents/server/internal/repo/mail_status_readers_test.go
JianFeeeee d25770ea2f fix(欠账): Skip 进余额且条件必须是测量;豁免按文件+次数;三笔欠账合成一处可读余额
pi 2026-09-14 三条(他接受了我对"恒红=相位错"的反驳,但指出 Skip 带来的两处漏洞)。

1. **Skip 必须进余额、条件必须是测量**:
   - **条件**:跳过与否由 `measureMailStatusDebt` **实测**(详情路径的 status 是否真的
     等于按读者派生),不是常量、不是"我们还没迁完"这种没人会更新的事实;
   - **余额**:`docs/DEBTS.json` 是**唯一登记**,Go 侧判据 `TestDebtLedgerMatchesMeasurement`
     **自己测量**后与登记比对(第一版我让余额由另一条判据写入 ⇒ **排序依赖**,
     Go 同包内按源文件顺序跑,登记那条先跑就读到 0 —— 排序依赖是隐蔽的假绿,已抽成自足函数);
   - **可见性**:`go test` 跑通时**不打印包的输出**,我第一版把余额打在 TestMain 里,
     常态运行一个字都看不见 —— 正是 pi 说的"不显形"。所以常态可见的那份打在
     electron 套件的 RESULT 行:`RESULT phase=install static=5 debts=7
     (static-criteria:5,mails-status-derived:1,gesture-semantics:1) probe=ok`。

2. **豁免从"按文件"改成"按文件 + 次数"**:`migrate.go` 这类比较**上限 2 处**(附理由),
   多一处即红。我在读侧清册上自己修过这个洞,豁免那格却退了一格 —— pi 指出得对。

3. **三笔欠账合成一处**:原先各自表达(`RESULT static=5` / `t.Skip` 无余额 /
   文档里的到期前提无余额),**没有一处能一眼看全**。现在统一登记在 `docs/DEBTS.json`
   (id / 余额 / 到期前提 / 判据位置),两端读同一份:Go 侧比对实测,electron 侧打进 RESULT 行。
   还清那天:登记要跟着清 —— 不清则由 `TestDebtLedgerMatchesMeasurement` 报
   "**欠账已还清**,但登记还记着 N"(还清是可测事件,这正是那条判据存在的意义)。
2026-09-14 17:17:37 +08:00

214 lines
9.3 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"
/*
* 豁免必须**按文件 + 次数**(pi 2026-09-14 裁定 §2)。
*
* 我在读侧清册上自己发现并修过这个洞("文件里有没有"挡不住"在已允许的文件里再新增一处"),
* 但豁免这一格又退了一格:只按文件放行 ⇒ 明天有人在同一个迁移文件里新加一处
* `WHERE m.status='read'`,豁免直接吞掉它、没有任何东西会红。
* 所以豁免也记**上限次数**,多一处即红 —— 与读侧清册同形,理由字段照留。
*/
allowedCompareExempt := map[string]struct {
max int
why string
}{
"migrate.go": {2, "迁移回填:把历史 'read' 折进 mail_reads —— 它**必须**读那个遗留值(全新安装/空库路径也要有)。上限 2 处,多一处即红"},
}
exemptSeen := map[string]int{}
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 ex, ok := allowedCompareExempt[base]; ok {
exemptSeen[base]++
if exemptSeen[base] <= ex.max {
continue
}
badCompare = append(badCompare,
fmt.Sprintf("%s:%d **超出豁免上限**(%s 这类比较上限 %d 处,理由:%s)—— %s",
base, i+1, base, ex.max, ex.why, strings.TrimSpace(line)))
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
}