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 步出现手势代码时立即建,此前是**登记的欠账**。
71 lines
2.9 KiB
Go
71 lines
2.9 KiB
Go
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)
|
||
}
|
||
}
|