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 步出现手势代码时立即建,此前是**登记的欠账**。
197 lines
8.5 KiB
Go
197 lines
8.5 KiB
Go
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"
|
||
// 按文件登记的豁免(带理由);不在这张表里的文件与 '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`)
|
||
|
||
// 从包目录往上看**整个 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 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) {
|
||
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
|
||
}
|