fix(欠账): Skip 进余额且条件必须是测量;豁免按文件+次数;三笔欠账合成一处可读余额

pi 2026-09-14 三条(他接受了我对"恒红=相位错"的反驳,但指出 Skip 带来的两处漏洞)。

1. **Skip 必须进余额、条件必须是测量**:
   - **条件**:跳过与否由 `measureMailStatusDebt` **实测**(详情路径的 status 是否真的
     等于按读者派生),不是常量、不是"我们还没迁完"这种没人会更新的事实;
   - **余额**:`docs/DEBTS.json` 是**唯一登记**,Go 侧判据 `TestDebtLedgerMatchesMeasurement`
     **自己测量**后与登记比对(第一版我让余额由另一条判据写入 ⇒ **排序依赖**,
     Go 同包内按源文件顺序跑,登记那条先跑就读到 0 —— 排序依赖是隐蔽的假绿,已抽成自足函数);
   - **可见性**:`go test` 跑通时**不打印包的输出**,我第一版把余额打在 TestMain 里,
     常态运行一个字都看不见 —— 正是 pi 说的"不显形"。所以常态可见的那份打在
     electron 套件的 RESULT 行:`RESULT phase=install static=5 debts=7
     (static-criteria:5,mails-status-derived:1,gesture-semantics:1) probe=ok`。

2. **豁免从"按文件"改成"按文件 + 次数"**:`migrate.go` 这类比较**上限 2 处**(附理由),
   多一处即红。我在读侧清册上自己修过这个洞,豁免那格却退了一格 —— pi 指出得对。

3. **三笔欠账合成一处**:原先各自表达(`RESULT static=5` / `t.Skip` 无余额 /
   文档里的到期前提无余额),**没有一处能一眼看全**。现在统一登记在 `docs/DEBTS.json`
   (id / 余额 / 到期前提 / 判据位置),两端读同一份:Go 侧比对实测,electron 侧打进 RESULT 行。
   还清那天:登记要跟着清 —— 不清则由 `TestDebtLedgerMatchesMeasurement` 报
   "**欠账已还清**,但登记还记着 N"(还清是可测事件,这正是那条判据存在的意义)。
This commit is contained in:
2026-09-14 17:16:22 +08:00
parent f5c4f56682
commit d25770ea2f
7 changed files with 273 additions and 43 deletions

View File

@ -0,0 +1,152 @@
package repo
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"testing"
)
/*
欠账的**单一余额**(pi 2026-09-14 裁定 §3)。
背景:到这轮为止已经有三笔不同类型、各自表达方式的欠账 ——
鸿蒙静态判据(`RESULT static=5`,有余额、有到期探针)、
`mails.status` 行级派生化(`t.Skip`,**原先无余额**)、
手势语义契约(登记的到期前提,**原先无余额**)。
三笔都"可判",但**没有一处能一眼看全**;而"欠账不显形就等于没有"。
所以:**登记在一处、可打印**。Go 这一侧打印在 TestMain 收尾,
跨端的净值同时登记在 `docs/DEBTS.json`(两端都能读,见该文件)。
*/
type debt struct {
ID string
Count int
Due string // 到期前提(什么时候该还清)
Where string // 判据在哪
}
var debts = map[string]debt{
"mails-status-derived": {
ID: "mails-status-derived",
Count: 0, // 由 mail_status_derived_test.go **实测**后标 1(不许手写:常量余额 = 永久 Skip 的死法)
Due: "详情/线程改为按读者派生(readStateFor)之后 —— 那时 mail_status_derived_test.go 从 Skip 转实跑",
Where: "server/internal/repo/mail_status_derived_test.go",
},
"static-criteria": {
ID: "static-criteria",
Count: 5,
Due: "本工作区能装、能点设备(探针三值转 true 时自动变红)",
Where: "client/electron/test/run-all.mjs(STATIC_ONLY)",
},
"gesture-semantics": {
ID: "gesture-semantics",
Count: 1,
Due: "P6 第 3 步:鸿蒙侧出现滑动手势代码时立即建(此前建 = 只有一端存在的假判据)",
Where: "docs/HARMONY-ALIGN-PLAN.md P6 段",
},
}
// debtMark 由各条欠账的判据在"确认未清"时调用(余额来自**测量**,不是常量)。
func debtMark(id string) {
d := debts[id]
d.Count = 1
debts[id] = d
}
type debtLedger struct {
Debts []struct {
ID string `json:"id"`
Count int `json:"count"`
Due string `json:"due"`
Where string `json:"where"`
} `json:"debts"`
}
// loadDebts 读**那一份**登记(docs/DEBTS.json):两端的余额必须来自同一处。
func loadDebts(t *testing.T) debtLedger {
t.Helper()
b, err := os.ReadFile(filepath.Join("..", "..", "..", "docs", "DEBTS.json"))
if err != nil {
t.Fatalf("读欠账登记 docs/DEBTS.json 失败:%v(登记丢了 = 欠账不显形)", err)
}
var l debtLedger
if err := json.Unmarshal(b, &l); err != nil {
t.Fatalf("欠账登记不是合法 JSON:%v", err)
}
return l
}
/*
★ 登记与**实测**必须一致(pi 2026-09-14 裁定 §1:Skip 的条件要是测量结果,"跳过"要进余额)。
这条判据挡的是两种死法:
① 条件被写死:余额不是从测量来的,而是某人抄的数字 ⇒ 这里用 debtMark 的实测值比对;
② 跳过不可数:`go test` 对 Skip 是退出码 0、`--- SKIP` 只是一行输出
(而且**跑得通时 go test 根本不打印包的输出** —— 我第一次就把余额打在 TestMain 里,
结果常态运行时一个字都看不见,正是"不显形")。所以可见的那份在
electron 套件的 RESULT 行(它读同一个文件),这里保证两处**同源**。
*/
func TestDebtLedgerMatchesMeasurement(t *testing.T) {
l := loadDebts(t)
seen := map[string]bool{}
for _, d := range l.Debts {
seen[d.ID] = true
if d.Due == "" || d.Where == "" {
t.Fatalf("欠账 %s 必须写明到期前提与判据位置(否则它只是「存在」,不是「欠账」)", d.ID)
}
}
for _, id := range []string{"static-criteria", "mails-status-derived", "gesture-semantics"} {
if !seen[id] {
t.Fatalf("欠账登记里缺 %s —— 三笔必须同处登记,否则审计只会找到一处就当全部", id)
}
}
// 本包能实测的那一笔:**自己测**(不依赖别的测试先跑过 —— 排序依赖是隐蔽的假绿)
var want int
for _, d := range l.Debts {
if d.ID == "mails-status-derived" {
want = d.Count
}
}
outstanding, detail, derived := measureMailStatusDebt(t)
got := 0
if outstanding {
got = 1
}
if got != want {
if got == 0 {
t.Fatalf("**欠账已还清**(详情 %q 已等于按读者派生 %q),但 docs/DEBTS.json 还记着 %d —— "+
"还清是可测事件,登记要跟着清(这正是这条判据存在的意义)", detail, derived, want)
}
t.Fatalf("mails-status-derived 的余额:登记说 %d,实测说 %d —— 登记与测量分叉了", want, got)
}
}
// debtSummary 供 TestMain 打印:一处能看全的余额 + 到期前提。
func debtSummary() string {
ids := make([]string, 0, len(debts))
total := 0
for id, d := range debts {
if d.Count > 0 {
ids = append(ids, id)
total += d.Count
}
}
if len(ids) == 0 {
return " debts=0(全部还清)"
}
sort.Strings(ids)
var b strings.Builder
fmt.Fprintf(&b, "\n======= 欠账余额 debts=%d =======", total)
for _, id := range ids {
d := debts[id]
fmt.Fprintf(&b, "\n · %s ×%d\n 判据:%s\n 到期:%s", d.ID, d.Count, d.Where, d.Due)
}
fmt.Fprintf(&b, "\n(三笔合在一处看全:分散登记时,审计只会找到一处就当全部)")
return b.String()
}

View File

@ -13,58 +13,69 @@ import (
而列表路径(`CountUnread`/`ListInbox`)给的是**按读者**派生的值 —— 同一个字段两种语义。
收口顺序是"先迁读者、再删写入"(先删写入会让 JSON 里的 status 永远是 unread,是另一种错误事实)。
这条判据的形状(三态,与套件的探针同族):
形状(三态,与套件的探针同族):
- **欠账未清** → `t.Skip`,跳过信息里写明"还欠什么",**不假红也不假绿**;
- **欠账已清** → 开始**实跑**:JSON 里的 `status` 必须由 `mail_reads` 派生
(对给定 reader 的取值等于派生死值),转绿即"这笔欠账还清了"的**可测事件**;
- 迁移做了一半(例如只改了 `GetMailByID`、漏了 `GetThread`)→ 红。
- **欠账未清 ⇒ `t.Skip`**,跳过信息写明还欠什么(**不假红也不假绿**);
- **还清 ⇒ 实跑**并断言"详情路径的 status == 按读者派生的值",**转绿即可测事件**;
- 迁一半(只改 `GetMailByID`、漏 `GetThread`)⇒ 红。
为什么不写成"现在就是红的":**恒红的门 = 挂在错误相位的门**(这轮已经为它付过学费,
见 `client/electron/test/CRITERIA.md` §11)。欠账要有余额、要有到期,也要有"还清即转绿"。
为什么不写成"现在就是红的":**恒红的门 = 挂在错误相位的门** —— 这轮已经为它付过三次学费
(`check-shared-libs`、`packaging`、`HOME`),见 `client/electron/test/CRITERIA.md` §11。
改成 Skip 之后又必须补两件事(pi 2026-09-14 §1):**跳过条件必须是测量结果**(不是常量),
以及**"跳过"必须进可数的余额**(否则"不假红也不假绿"会变成"不显形")——
余额登记在 `docs/DEBTS.json`(两端同源),常态可见的那份打在 electron 套件的 RESULT 行
(`go test` 跑通时不打印包的输出,把余额只打在 TestMain 里等于没人看得见)。
*/
func TestMailStatusInJSONIsDerivedWhenDebtCleared(t *testing.T) {
/*
measureMailStatusDebt:**自足**的测量 —— 谁调用谁测,不依赖"别的测试先跑过"。
第一版把债务标记写在 Skip 那一支、余额由另一条判据去读,于是产生**排序依赖**
(Go 在同一包内按源文件顺序跑,登记那条先跑就读到 0)。排序依赖是隐蔽的假绿来源,
所以把测量抽成一个函数,每条判据各自调用。
返回 outstanding = 详情路径与"按读者派生"是否**实测**不一致(不一致 = 欠账未清)。
*/
func measureMailStatusDebt(t *testing.T) (outstanding bool, detail, derived string) {
t.Helper()
setupTestDB(t)
ctx := context.Background()
id := seedMailTo(t, "alice", "bob")
// 欠账是否还在:看详情路径返回的 status 与"按读者派生"是否可能不同
// 手法:alice 未读时行级值仍是 'unread'(没写过的邮件),所以先让 bob 读掉这封 ——
// 一旦行级写入生效,alice 在详情里就会看到 'read',而列表路径(按读者)说 'unread'。
// 让 bob 读掉:行级写入会把整封标 'read',而 alice 的按读者派生仍是 '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)
list, err := ListInbox(ctx, "alice", "all", 50)
if err != nil {
t.Fatal(err)
}
var listStatus string
for _, x := range perReader {
for _, x := range list {
if x.ID == id {
listStatus = x.Status
derived = x.Status
}
}
if listStatus == "" {
if derived == "" {
t.Fatalf("自检:列表路径里应当能看到这封邮件(id=%s)", id)
}
return m.Status != derived, m.Status, derived
}
// 欠账判定的**唯一依据**是"详情路径是否已按读者派生"——所以先看它是否等于派生死值
if m.Status != listStatus {
// 欠账还在:不假红,明确跳过并写清欠的是什么、以及还清后这条会开始实跑
t.Skipf("欠账未清(本判据在还清那天会自动开始实跑并转绿):详情路径返回行级 status=%q,"+
func TestMailStatusInJSONIsDerivedWhenDebtCleared(t *testing.T) {
outstanding, detail, derived := measureMailStatusDebt(t)
if outstanding {
// 欠账还在:**实测**(不是常量)⇒ 记进余额,然后再跳过
debtMark("mails-status-derived")
t.Skipf("欠账未清(还清那天本判据自动转实跑并转绿):详情路径返回行级 status=%q,"+
"而按读者派生说 %q —— 差在 GetMailByID/GetThread 仍带出行级列;"+
"迁完那两处(改用 readStateFor)后本判据会实跑。收口顺序:先迁读者、再删写入。",
m.Status, listStatus)
"迁完那两处(改用 readStateFor)后本判据会实跑。收口顺序:先迁读者、再删写入。"+
"(余额见 docs/DEBTS.json;常态可见的那份在 electron 套件的 RESULT 行。)", detail, derived)
}
// 已还清:断言更强的性质 —— 详情与列表必须一致(都对同一个 reader 派生)
if !strings.EqualFold(m.Status, listStatus) {
t.Fatalf("详情 %q 与列表 %q 不一致", m.Status, listStatus)
if !strings.EqualFold(detail, derived) {
t.Fatalf("详情 %q 与列表 %q 不一致", detail, derived)
}
}

View File

@ -59,10 +59,21 @@ func TestMailsStatusReadersAreRegistered(t *testing.T) {
*/
compareRe := regexp.MustCompile(`\bm\.status\s*(?:=|<>|!=)\s*'([a-z_]+)'`)
allowedLiteral := "archived"
// 按文件登记的豁免(带理由);不在这张表里的文件与 'archived' 之外的字面量比较 ⇒ 红
allowedCompareExempt := map[string]string{
"migrate.go": "迁移回填:把历史 'read' 折进 mail_reads —— 它**必须**读那个遗留值,这是它的职责",
/*
* 豁免必须**按文件 + 次数**(pi 2026-09-14 裁定 §2)。
*
* 我在读侧清册上自己发现并修过这个洞("文件里有没有"挡不住"在已允许的文件里再新增一处"),
* 但豁免这一格又退了一格:只按文件放行 ⇒ 明天有人在同一个迁移文件里新加一处
* `WHERE m.status='read'`,豁免直接吞掉它、没有任何东西会红。
* 所以豁免也记**上限次数**,多一处即红 —— 与读侧清册同形,理由字段照留。
*/
allowedCompareExempt := map[string]struct {
max int
why string
}{
"migrate.go": {2, "迁移回填:把历史 'read' 折进 mail_reads —— 它**必须**读那个遗留值(全新安装/空库路径也要有)。上限 2 处,多一处即红"},
}
exemptSeen := map[string]int{}
writeRe := regexp.MustCompile(`UPDATE\s+mails\s+SET\s+status`)
readRe := regexp.MustCompile(`\bm\.status\b|mails\.status`)
@ -96,8 +107,14 @@ func TestMailsStatusReadersAreRegistered(t *testing.T) {
* mail_reads),所以这里比 'read' 不但正当,而且是必须的。
* 豁免也做成**可判的**:按文件登记 + 写明理由(不是"凡是迁移都放行")。
*/
if why, ok := allowedCompareExempt[base]; ok {
_ = why
if ex, ok := allowedCompareExempt[base]; ok {
exemptSeen[base]++
if exemptSeen[base] <= ex.max {
continue
}
badCompare = append(badCompare,
fmt.Sprintf("%s:%d **超出豁免上限**(%s 这类比较上限 %d 处,理由:%s)—— %s",
base, i+1, base, ex.max, ex.why, strings.TrimSpace(line)))
continue
}
badCompare = append(badCompare,

View File

@ -20,14 +20,14 @@ import (
//
// `agents` 表这边只剩两样东西:
//
// default_rounds —— 派给这个 Agent 的**新任务**默认多少个来回。
// 不同 Agent 能力不同(跑测试的小工具 vs 重构整个模块),
// 默认值分开设才合理。
// default_rounds —— 派给这个 Agent 的**新任务**默认多少个来回。
// 不同 Agent 能力不同(跑测试的小工具 vs 重构整个模块),
// 默认值分开设才合理。
//
// used_rounds —— 纯统计,累计发信数。**不再拦任何请求。**
// 它原本是「终身额度」:跑满就得管理员手工重置才能再干活,
// 而 Agent 是长期在线的 —— 终身额度是错的工具。
// 保留是因为「这个 Agent 一共发了多少信」本身有观测价值。
// used_rounds —— 纯统计,累计发信数。**不再拦任何请求。**
// 它原本是「终身额度」:跑满就得管理员手工重置才能再干活,
// 而 Agent 是长期在线的 —— 终身额度是错的工具。
// 保留是因为「这个 Agent 一共发了多少信」本身有观测价值。
//
// 防止 Agent 用 `.new` 开一串新会话绕过预算,靠的是**新建会话速率限制**
// (见 sessionRateLimiter),而不是终身额度。
@ -169,7 +169,7 @@ type ForwardSource struct {
}
// LoadForwardSource 读取待转发的邮件,并校验转发者确实参与过该邮件
//(收件人、发件人或被抄送方之一)。防止凭 mail_id 转发别人的邮件。
// (收件人、发件人或被抄送方之一)。防止凭 mail_id 转发别人的邮件。
func LoadForwardSource(ctx context.Context, mailID uuid.UUID, actor string) (*models.Mail, error) {
m, err := GetMailByID(ctx, mailID)
if err != nil {

View File

@ -2,6 +2,7 @@ package repo
import (
"context"
"fmt"
"os"
"path/filepath"
"testing"
@ -121,5 +122,9 @@ func TestListAgentStats(t *testing.T) {
}
func TestMain(m *testing.M) {
os.Exit(m.Run())
code := m.Run()
// 欠账余额打在收尾:Skip 在 `go test` 里只是退出码 0 的一行输出,
// 不进余额就等于"不显形"(pi 2026-09-14 裁定 §1)。
fmt.Println(debtSummary())
os.Exit(code)
}