// Package report 按 PRD 第 10 节 F01–F23 生成验收对照表。 package report import ( "encoding/json" "fmt" "io" "os" "strings" "time" ) // Status 验收结果。 type Status string const ( StatusPass Status = "pass" StatusFail Status = "fail" StatusUntested Status = "untested" StatusPartial Status = "partial" ) // SubItem 是 PRD 一句话里的一个子项。 type SubItem struct { ID string `json:"id"` Summary string `json:"summary,omitempty"` Status Status `json:"status"` Evidence string `json:"evidence,omitempty"` Note string `json:"note,omitempty"` } // Item 单条验收结果。 type Item struct { ID string `json:"id"` Status Status `json:"status"` Note string `json:"note,omitempty"` Subs []SubItem `json:"subs,omitempty"` Passed int `json:"passed,omitempty"` Total int `json:"total,omitempty"` } // Results 结果文件格式。 type Results struct { GeneratedAt string `json:"generated_at,omitempty"` Items []Item `json:"items"` } // Feature 验收总表一行。 type Feature struct { ID string Summary string } // Features 是 PRD 第 10 节 F01–F23。 var Features = []Feature{ {ID: "F01", Summary: "批量开通整批校验、停用、删除群主转让、删除后同编号重开不串数据"}, {ID: "F02", Summary: "新设备登录后旧设备自动退出、换 IP 用令牌重连、两种密码锁定、重置密码后被踢、服务器故障不误报密码错误"}, {ID: "F03", Summary: "断开后状态及时变离线,全表可列出"}, {ID: "F04", Summary: "只通知订阅了的端"}, {ID: "F05", Summary: "崩溃不丢已提交消息,消息号去重和冲突,密码门生效,配额生效"}, {ID: "F06", Summary: "群成员收到同一份,入群前不补,发送者不收到自己的"}, {ID: "F07", Summary: "256 KiB 通过,超出拒绝,接收上限生效"}, {ID: "F08", Summary: "弱网最终送达且应用层不重复,重启后续传"}, {ID: "F09", Summary: "保留时间从发送时刻起算,超时过期"}, {ID: "F10", Summary: "短断线送到,长断线丢弃,服务器重启后宽限内重连送到"}, {ID: "F11", Summary: "发送方离线后到点仍发送"}, {ID: "F12", Summary: "延迟窗口内撤回对方收不到"}, {ID: "F13", Summary: "未推送必撤成功;群部分确认得到部分撤回"}, {ID: "F14", Summary: "回执能补送给当时离线的发送方"}, {ID: "F15", Summary: "输一次记住、改密失效、回复免密、进群仍要密码、防多账号轮流猜"}, {ID: "F16", Summary: "群主权限、退出后不再收到、解散后同编号新群不收旧消息"}, {ID: "F17", Summary: "后台管端、管注册、管群、查记录,响应里没有正文;API 令牌可用且不能越权"}, {ID: "F18", Summary: "送达后正文消失;记录天数 0 时连记录消失;防重仍在"}, {ID: "F19", Summary: "四种 SDK 通过同一清单"}, {ID: "F20", Summary: "裸 MQTT 能登录、收、确认、发"}, {ID: "F21", Summary: "默认一个端口提供后台、WebSocket、TCP、注册;后台可分到单独端口"}, {ID: "F22", Summary: "初始化后单文件或 Docker 启动、备份恢复、升级迁移、证书自动重载、指标可抓取"}, {ID: "F23", Summary: "注册开关、安全码校验、换码不影响已注册、输错锁定"}, } // LoadResults 读取结果 JSON;缺项视为未测。 func LoadResults(path string) (Results, error) { data, err := os.ReadFile(path) if err != nil { return Results{}, err } var r Results if err := json.Unmarshal(data, &r); err != nil { return Results{}, fmt.Errorf("parse results: %w", err) } return r, nil } // Normalize 把结果补全为 F01–F23;非法 status 报错。 func Normalize(r Results) ([]Item, error) { byID := make(map[string]Item, len(r.Items)) for _, it := range r.Items { id := strings.ToUpper(strings.TrimSpace(it.ID)) st, err := parseStatus(it.Status) if err != nil { return nil, fmt.Errorf("%s: %w", id, err) } it.ID = id it.Status = st for i, sub := range it.Subs { subSt, subErr := parseStatus(sub.Status) if subErr != nil { return nil, fmt.Errorf("%s/%s: %w", id, sub.ID, subErr) } it.Subs[i].Status = subSt } if len(it.Subs) > 0 { it = Rollup(it) } byID[id] = it } out := make([]Item, 0, len(Features)) for _, f := range Features { if it, ok := byID[f.ID]; ok { out = append(out, it) continue } out = append(out, Item{ID: f.ID, Status: StatusUntested, Note: "结果文件未提供"}) } return out, nil } // Rollup 根据子项计算整项状态:全部通过才算通过,否则部分通过或失败或未测。 func Rollup(it Item) Item { if len(it.Subs) == 0 { return it } pass, fail, untested := 0, 0, 0 for _, s := range it.Subs { switch s.Status { case StatusPass: pass++ case StatusFail: fail++ default: untested++ } } it.Passed = pass it.Total = len(it.Subs) switch { case fail > 0: it.Status = StatusFail case untested == 0 && pass == len(it.Subs): it.Status = StatusPass case pass == 0 && fail == 0: it.Status = StatusUntested default: it.Status = StatusPartial } it.Note = rollupNote(it) return it } func rollupNote(it Item) string { var parts []string for _, s := range it.Subs { switch s.Status { case StatusPass: ev := s.Evidence if ev == "" { ev = "已测" } parts = append(parts, s.ID+" 通过("+ev+")") case StatusFail: msg := s.Note if msg == "" { msg = s.Evidence } parts = append(parts, s.ID+" 失败("+msg+")") default: msg := s.Note if msg == "" { msg = "未测" } parts = append(parts, s.ID+" 未测("+msg+")") } } return strings.Join(parts, ";") } func parseStatus(s Status) (Status, error) { switch Status(strings.ToLower(string(s))) { case StatusPass, "通过", "ok", "passed": return StatusPass, nil case StatusFail, "失败", "failed": return StatusFail, nil case StatusPartial, "部分通过", "partial_pass": return StatusPartial, nil case StatusUntested, "未测", "skip", "skipped", "": return StatusUntested, nil default: return "", fmt.Errorf("unknown status %q", s) } } func statusCN(it Item) string { switch it.Status { case StatusPass: return "通过" case StatusFail: return "失败" case StatusPartial: if it.Total > 0 { return fmt.Sprintf("部分通过 (%d/%d)", it.Passed, it.Total) } return "部分通过" default: return "未测" } } // WriteMarkdown 写出 F01–F23 对照表。 func WriteMarkdown(w io.Writer, items []Item) error { summary := map[Status]int{} byID := make(map[string]Item, len(items)) for _, it := range items { byID[it.ID] = it summary[it.Status]++ } if _, err := fmt.Fprintf(w, "# NixMsg 验收对照表(PRD 第 10 节)\n\n"); err != nil { return err } if _, err := fmt.Fprintf(w, "生成时间:%s\n\n", time.Now().UTC().Format(time.RFC3339)); err != nil { return err } if _, err := fmt.Fprintf(w, "汇总:通过 %d,部分通过 %d,失败 %d,未测 %d\n\n", summary[StatusPass], summary[StatusPartial], summary[StatusFail], summary[StatusUntested]); err != nil { return err } if _, err := fmt.Fprintf(w, "整项「通过」表示该行 PRD 一句话里的子项均有测试证据。部分覆盖不得标通过。\n\n"); err != nil { return err } if _, err := fmt.Fprintf(w, "| 编号 | 一句话 | 结果 | 备注 |\n|---|---|---|---|\n"); err != nil { return err } for _, f := range Features { it := byID[f.ID] note := compactNote(it) if _, err := fmt.Fprintf(w, "| %s | %s | %s | %s |\n", f.ID, f.Summary, statusCN(it), note); err != nil { return err } } if _, err := fmt.Fprintf(w, "\n## 子项\n\n"); err != nil { return err } if _, err := fmt.Fprintf(w, "| 子项 | 摘要 | 结果 | 证据或原因 |\n|---|---|---|---|\n"); err != nil { return err } for _, f := range Features { it := byID[f.ID] for _, s := range it.Subs { detail := s.Evidence if s.Status != StatusPass { if s.Note != "" { detail = s.Note } } if detail == "" { detail = "—" } subStatus := statusCN(Item{Status: s.Status}) if _, err := fmt.Fprintf(w, "| %s | %s | %s | %s |\n", s.ID, s.Summary, subStatus, detail); err != nil { return err } } } return nil } func compactNote(it Item) string { if it.Status == StatusPass { if it.Total > 0 { return fmt.Sprintf("子项 %d/%d 通过", it.Passed, it.Total) } return "通过" } var bits []string for _, s := range it.Subs { if s.Status == StatusPass { continue } label := "未测" if s.Status == StatusFail { label = "失败" } msg := s.Note if msg == "" { msg = s.Evidence } if msg == "" { bits = append(bits, s.ID+" "+label) continue } bits = append(bits, s.ID+" "+label+":"+msg) } if len(bits) == 0 { if it.Note != "" { return it.Note } return "—" } return strings.Join(bits, ";") } // EmptyResults 返回全部未测的样例。 func EmptyResults() Results { items := make([]Item, 0, len(Features)) for _, f := range Features { items = append(items, Item{ID: f.ID, Status: StatusUntested, Note: ""}) } return Results{ GeneratedAt: time.Now().UTC().Format(time.RFC3339), Items: items, } }