Files
MailUI4Agents/server/internal/repo/mail_status_readers_test.go
JianFeeeee b7f624bc45 fix(已读/手势): 读侧也拒绝空读者;mails.status 读者/写者登记成"新增即红";阈值契约显式选 (b)
pi 2026-09-14 的三条裁定,逐条落地。

1. **§2 读侧守卫**(他只钉了写侧,读侧是另一半,而且更隐蔽):`reader` 在查询里是**过滤条件**,
   空串不写坏数据也不报错,只会**算出一个错误的数** —— `unreadFor('')` 的
   `NOT EXISTS(... reader_name = '')` 恒真 ⇒ `CountUnread(ctx,"")` 把**所有**邮件算成未读
   (用户看到"全都没读"),`ListInbox(...,"read")` 恒空。
   二选一里选 **①报错**(当前没有任何调用方需要"汇总"语义;选②就要立刻定义"汇总"是什么,
   而那是个还不存在的需求 —— 将来要就新增一个名字里带汇总的函数,别让空串偷偷兼职)。
   `requireReader` 装到 `ListInboxScoped`/`CountUnread`/`CountUnreadInSession`,
   判据两条:空 reader 三个入口都**必须报错**且**不返回数**;正例防止写成"一律拒绝"。
   变异验证:撤掉 `CountUnread` 的守卫 → `CountUnread("") 必须报错,实际返回 0`。

2. **§1 `mails.status` 收口**:先做他要求的第 1 步(枚举读者,他没有 shell)。
   枚举结果:**没有**"零功能读者"那条路 —— 行级值仍会进邮件 JSON(`GetMailByID`、
   `GetThread` 都 select 它),`migrate.go` 的两处是**一次性回填**(正当),
   `unreadFor`/`readStateFor` 只用它判 `archived`(正当,且已注明"不再作为未读判据")。
   所以走第三步:**登记欠账 + 增量判据**。
   - 「只为兼容保留」**没有**当结语:`mail_status_readers_test.go` 是一张**机器检查的清册**,
     按**文件 + 次数**登记(repo.go 13 / thread.go 1 / migrate.go 4),
     **新增一处读取就红**并强制当场回答"这处合不合规";写入点同样登记
     (多一处 `UPDATE mails SET status` 就红)。
   - **第一次跑就抓到第二处写入**:`MarkAllInboxReadForSession`(整会话批量已读)里还有一句
     `UPDATE mails SET status='read'`,我原先只看到 `MarkMailRead` 那一处 ——
     这正是"新增即红"的价值:靠人 grep 会漏,靠判据不会。
   - 收口路径(写进判据注释):行级 `'read'` 迁到 `markReadFor`;
     但在"详情/线程仍返回行级 status"两处读者迁移**之前**不能只删写入
     —— 那会让 JSON 里的 status 永远是 unread,是另一种错误事实。
   变异验证:在已允许的文件里新增一处读取 → 清册对不上,红。

3. **§3 阈值契约显式选 (b)**:不要求数值一致(`40px`/`600ms` 在触摸屏与鼠标、手机与大屏上
   的人体工学合理值天然不同,强行同值会两边都不舒服),契约改钉**语义层**(左滑/右滑=什么、
   边界是否回弹、"快滑"是感知档)。并写清推论:选了 (b) ⇒ **鸿蒙不得引用 WebUI 的三个数**
   (引了就等于偷偷选了 (a),还是"我抄的那一份"那种)。
2026-09-14 17:08:31 +08:00

151 lines
6.0 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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": "一次性回填 mail_reads(历史 'read' → 按读者记账),跑过即结束",
}
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
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
}
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 "))
}
/*
* 写入点:**登记清册**(不是"允许它存在",而是"多一处就红")。
*
* 第一次跑这条判据就抓到了第二处 —— `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
}