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 步出现手势代码时立即建,此前是**登记的欠账**。
This commit is contained in:
2026-09-14 17:11:36 +08:00
parent 12a45af9ee
commit eeb8f277fd
3 changed files with 129 additions and 1 deletions

View File

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