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:
152
server/internal/repo/debt_registry_test.go
Normal file
152
server/internal/repo/debt_registry_test.go
Normal 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()
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user