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) } }