Files

322 lines
9.1 KiB
Go
Raw Permalink 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 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,
}
}