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

View File

@ -43,8 +43,27 @@ func TestMailsStatusReadersAreRegistered(t *testing.T) {
reasons := map[string]string{
"repo.go": "unreadFor/readStateFor 用 m.status 判 archived(正当);其余是查询带出行级列(欠账:应按读者派生)",
"thread.go": "GetThread 带出 m.status(同一处欠账)",
"migrate.go": "一次性回填 mail_reads(历史 'read' → 按读者记账),跑过即结束",
"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`)
@ -53,6 +72,7 @@ func TestMailsStatusReadersAreRegistered(t *testing.T) {
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
@ -66,6 +86,24 @@ func TestMailsStatusReadersAreRegistered(t *testing.T) {
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) {
@ -108,6 +146,14 @@ func TestMailsStatusReadersAreRegistered(t *testing.T) {
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 "))
}
/*
* 写入点:**登记清册**(不是"允许它存在",而是"多一处就红")。
*