feat: 测试交付 Q1 混沌压测骨架与 Q4 镜像骨架

This commit is contained in:
Nixevol
2026-09-30 06:59:44 +08:00
parent 0e068c6ade
commit 8dde564517
21 changed files with 1268 additions and 16 deletions
+61
View File
@@ -0,0 +1,61 @@
# 混沌 / 弱网测试辅助(Q 线)
用 toxiproxy 官方镜像做延迟和断开;Linux 丢包用容器内 netem。所有 Docker 资源名带 `q` 前缀,用完删除。
## 启动 toxiproxy
在仓库根目录(或本目录)执行:
```powershell
docker compose -p q-chaos -f test/chaos/compose.yml up -d
```
API 默认映射到本机随机/固定端口见 compose 注释。查看实际端口:
```powershell
docker compose -p q-chaos -f test/chaos/compose.yml port toxiproxy 8474
```
清理:
```powershell
docker compose -p q-chaos -f test/chaos/compose.yml down -v
```
## 对运行中的服务注入故障
不要写死 `7443`。上游地址取自当前实例的实际监听端口(例如测试 harness 写入的 `listen.addr`,或你启动时打印的地址)。
1. 本机起好 NixMsg(或任意 TCP 服务),记下 `HOST:PORT`。
2. 在 Windows 上,容器访问本机服务用 `host.docker.internal:PORT`。
3. 用本包客户端或 curl 建代理(listen 用 `0.0.0.0:0` 让 toxiproxy 分配端口):
```go
c := chaos.NewClient("http://127.0.0.1:<api端口>")
p, err := c.CreateProxy("q-nixmsg", "0.0.0.0:0", "host.docker.internal:"+strconv.Itoa(port))
// 客户端改连 p.Listen(把 0.0.0.0 换成 127.0.0.1)
_, _ = c.AddLatency("q-nixmsg", "lag", 200, 50, "")
_, _ = c.AddResetPeer("q-nixmsg", "rst", 0, "")
```
等价 curl:
```bash
curl -s -X POST http://127.0.0.1:<api>/proxies \
-H 'Content-Type: application/json' \
-d '{"name":"q-nixmsg","listen":"0.0.0.0:0","upstream":"host.docker.internal:<PORT>","enabled":true}'
```
延迟:`POST .../proxies/q-nixmsg/toxics`,`type=latency`,`attributes.latency` / `jitter`(毫秒)。
断开:`type=reset_peer`,`attributes.timeout`(毫秒,0 表示尽快 RST)。
## 单元测试
```powershell
go test ./test/chaos/ -count=1
```
## netem 丢包
见 [netem/README.md](./netem/README.md)。脚本必须在 Linux 容器内执行(本机是 Windows)。
+15
View File
@@ -0,0 +1,15 @@
# toxiproxy:延迟与断开。项目名用 -p q-chaos,容器名带 q 前缀。
# API 与代理端口映射到本机,具体宿主机端口用 `docker compose port` 查询,不要写死业务端口。
services:
toxiproxy:
image: ghcr.io/shopify/toxiproxy:2.12.0
container_name: q-toxiproxy
# 8474=API;8475 起可手工映射代理端口,或在 CreateProxy 时用 0.0.0.0:0 再 docker port 查询
ports:
- "8474"
- "8475"
- "8476"
# 允许代理到本机服务(Docker Desktop / Windows)
extra_hosts:
- "host.docker.internal:host-gateway"
+192
View File
@@ -0,0 +1,192 @@
// Package chaos 提供基于 toxiproxy 的弱网故障注入辅助。
// 上游地址由调用方传入,不写死端口。
package chaos
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
)
// Client 调用 toxiproxy HTTP API。
type Client struct {
BaseURL string
HTTPClient *http.Client
}
// NewClient 创建客户端。baseURL 形如 http://127.0.0.1:8474。
func NewClient(baseURL string) *Client {
return &Client{
BaseURL: strings.TrimRight(baseURL, "/"),
HTTPClient: &http.Client{
Timeout: 10 * time.Second,
},
}
}
// Proxy 描述一个 toxiproxy 代理。
type Proxy struct {
Name string `json:"name"`
Listen string `json:"listen"`
Upstream string `json:"upstream"`
Enabled bool `json:"enabled"`
}
// Toxic 描述一条故障规则。
type Toxic struct {
Name string `json:"name"`
Type string `json:"type"`
Stream string `json:"stream,omitempty"`
Toxicity float32 `json:"toxicity,omitempty"`
Attributes map[string]any `json:"attributes,omitempty"`
}
// CreateProxy 创建或覆盖同名代理。listen 可用 host:0 让 toxiproxy 分配端口。
func (c *Client) CreateProxy(name, listen, upstream string) (*Proxy, error) {
if name == "" {
return nil, fmt.Errorf("proxy name required")
}
if listen == "" {
return nil, fmt.Errorf("listen required")
}
if upstream == "" {
return nil, fmt.Errorf("upstream required")
}
body := Proxy{
Name: name,
Listen: listen,
Upstream: upstream,
Enabled: true,
}
var out Proxy
if err := c.doJSON(http.MethodPost, "/proxies", body, &out); err != nil {
return nil, err
}
return &out, nil
}
// DeleteProxy 删除代理;不存在时忽略。
func (c *Client) DeleteProxy(name string) error {
req, err := http.NewRequest(http.MethodDelete, c.BaseURL+"/proxies/"+name, nil)
if err != nil {
return err
}
resp, err := c.HTTPClient.Do(req)
if err != nil {
return err
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode == http.StatusNotFound || resp.StatusCode == http.StatusNoContent || resp.StatusCode == http.StatusOK {
return nil
}
b, _ := io.ReadAll(resp.Body)
return fmt.Errorf("delete proxy: %s: %s", resp.Status, strings.TrimSpace(string(b)))
}
// GetProxy 读取代理(含实际 listen 地址)。
func (c *Client) GetProxy(name string) (*Proxy, error) {
var out Proxy
if err := c.doJSON(http.MethodGet, "/proxies/"+name, nil, &out); err != nil {
return nil, err
}
return &out, nil
}
// AddLatency 注入下行/上行延迟(毫秒)。stream 为空时默认 downstream。
func (c *Client) AddLatency(proxyName, toxicName string, latencyMs, jitterMs int, stream string) (*Toxic, error) {
if stream == "" {
stream = "downstream"
}
t := Toxic{
Name: toxicName,
Type: "latency",
Stream: stream,
Toxicity: 1,
Attributes: map[string]any{
"latency": latencyMs,
"jitter": jitterMs,
},
}
return c.addToxic(proxyName, t)
}
// AddResetPeer 在连接上注入 TCP RST(断开)。timeoutMs 为触发前等待。
func (c *Client) AddResetPeer(proxyName, toxicName string, timeoutMs int, stream string) (*Toxic, error) {
if stream == "" {
stream = "downstream"
}
t := Toxic{
Name: toxicName,
Type: "reset_peer",
Stream: stream,
Toxicity: 1,
Attributes: map[string]any{
"timeout": timeoutMs,
},
}
return c.addToxic(proxyName, t)
}
// RemoveToxic 删除一条 toxic。
func (c *Client) RemoveToxic(proxyName, toxicName string) error {
req, err := http.NewRequest(http.MethodDelete, c.BaseURL+"/proxies/"+proxyName+"/toxics/"+toxicName, nil)
if err != nil {
return err
}
resp, err := c.HTTPClient.Do(req)
if err != nil {
return err
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode == http.StatusNotFound || resp.StatusCode == http.StatusNoContent || resp.StatusCode == http.StatusOK {
return nil
}
b, _ := io.ReadAll(resp.Body)
return fmt.Errorf("remove toxic: %s: %s", resp.Status, strings.TrimSpace(string(b)))
}
func (c *Client) addToxic(proxyName string, t Toxic) (*Toxic, error) {
var out Toxic
if err := c.doJSON(http.MethodPost, "/proxies/"+proxyName+"/toxics", t, &out); err != nil {
return nil, err
}
return &out, nil
}
func (c *Client) doJSON(method, path string, in any, out any) error {
var body io.Reader
if in != nil {
b, err := json.Marshal(in)
if err != nil {
return err
}
body = bytes.NewReader(b)
}
req, err := http.NewRequest(method, c.BaseURL+path, body)
if err != nil {
return err
}
if in != nil {
req.Header.Set("Content-Type", "application/json")
}
resp, err := c.HTTPClient.Do(req)
if err != nil {
return err
}
defer func() { _ = resp.Body.Close() }()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return err
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("%s %s: %s: %s", method, path, resp.Status, strings.TrimSpace(string(respBody)))
}
if out == nil || len(respBody) == 0 {
return nil
}
return json.Unmarshal(respBody, out)
}
+113
View File
@@ -0,0 +1,113 @@
package chaos
import (
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestClientCreateLatencyAndReset(t *testing.T) {
t.Parallel()
toxics := map[string][]Toxic{}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == http.MethodPost && r.URL.Path == "/proxies":
var p Proxy
if err := json.NewDecoder(r.Body).Decode(&p); err != nil {
t.Errorf("decode proxy: %v", err)
http.Error(w, err.Error(), 400)
return
}
if p.Listen == "0.0.0.0:0" {
p.Listen = "0.0.0.0:18080"
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(p)
case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/toxics"):
var toxic Toxic
if err := json.NewDecoder(r.Body).Decode(&toxic); err != nil {
http.Error(w, err.Error(), 400)
return
}
name := strings.TrimSuffix(strings.TrimPrefix(r.URL.Path, "/proxies/"), "/toxics")
toxics[name] = append(toxics[name], toxic)
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(toxic)
case r.Method == http.MethodDelete:
w.WriteHeader(http.StatusNoContent)
default:
http.NotFound(w, r)
}
}))
t.Cleanup(srv.Close)
c := NewClient(srv.URL)
p, err := c.CreateProxy("q-demo", "0.0.0.0:0", "127.0.0.1:19000")
if err != nil {
t.Fatalf("CreateProxy: %v", err)
}
if p.Upstream != "127.0.0.1:19000" {
t.Fatalf("upstream = %q", p.Upstream)
}
if p.Listen != "0.0.0.0:18080" {
t.Fatalf("listen = %q", p.Listen)
}
lat, err := c.AddLatency("q-demo", "lag", 200, 50, "")
if err != nil {
t.Fatalf("AddLatency: %v", err)
}
if lat.Type != "latency" {
t.Fatalf("type = %q", lat.Type)
}
if _, err := c.AddResetPeer("q-demo", "drop", 0, ""); err != nil {
t.Fatalf("AddResetPeer: %v", err)
}
if got := toxics["q-demo"]; len(got) != 2 {
t.Fatalf("toxics len = %d", len(got))
}
if got := toxics["q-demo"][0]; got.Type != "latency" {
t.Fatalf("first toxic type = %q", got.Type)
}
if err := c.RemoveToxic("q-demo", "lag"); err != nil {
t.Fatalf("RemoveToxic: %v", err)
}
if err := c.DeleteProxy("q-demo"); err != nil {
t.Fatalf("DeleteProxy: %v", err)
}
}
func TestClientRejectsEmptyFields(t *testing.T) {
t.Parallel()
c := NewClient("http://127.0.0.1:1")
if _, err := c.CreateProxy("", "0.0.0.0:0", "127.0.0.1:1"); err == nil {
t.Fatal("expected error for empty name")
}
if _, err := c.CreateProxy("x", "", "127.0.0.1:1"); err == nil {
t.Fatal("expected error for empty listen")
}
if _, err := c.CreateProxy("x", "0.0.0.0:0", ""); err == nil {
t.Fatal("expected error for empty upstream")
}
}
func TestCreateProxyErrorStatus(t *testing.T) {
t.Parallel()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusConflict)
_, _ = io.WriteString(w, "already exists")
}))
t.Cleanup(srv.Close)
c := NewClient(srv.URL)
if _, err := c.CreateProxy("q", "0.0.0.0:0", "127.0.0.1:9"); err == nil {
t.Fatal("expected error")
}
}
+31
View File
@@ -0,0 +1,31 @@
# Linux 容器内用 netem 做丢包(本机 Windows 不能直接跑 tc)
## 用法
对已经在同一网络里的目标容器网卡注入 20% 丢包(需要 `NET_ADMIN`):
```powershell
# 示例:起一个带 net-tools/iproute2 的旁路容器,对网卡 eth0 丢包
docker run --rm --name q-netem --cap-add=NET_ADMIN --network container:q-toxiproxy `
nicolaka/netshoot `
bash -lc "tc qdisc replace dev eth0 root netem loss 20%"
```
或把本目录脚本挂进去:
```powershell
docker run --rm --name q-netem --cap-add=NET_ADMIN --network container:<目标容器名> `
-v ${PWD}/test/chaos/netem:/scripts:ro `
nicolaka/netshoot `
bash /scripts/apply-loss.sh eth0 20
```
清除:
```powershell
docker run --rm --name q-netem-clear --cap-add=NET_ADMIN --network container:<目标容器名> `
nicolaka/netshoot `
bash -lc "tc qdisc del dev eth0 root 2>/dev/null || true"
```
用完删除容器(上面 `--rm` 已自动删)。不要把 NixMsg 业务端口写死进脚本。
+15
View File
@@ -0,0 +1,15 @@
#!/usr/bin/env bash
# 在 Linux 容器内执行:对指定网卡注入丢包。用法:apply-loss.sh <iface> <loss_percent>
set -euo pipefail
IFACE="${1:-eth0}"
LOSS="${2:-20}"
if ! command -v tc >/dev/null 2>&1; then
echo "tc not found; use an image with iproute2 (e.g. nicolaka/netshoot)" >&2
exit 1
fi
tc qdisc replace dev "$IFACE" root netem loss "${LOSS}%"
echo "netem: iface=${IFACE} loss=${LOSS}%"
tc qdisc show dev "$IFACE"
+40
View File
@@ -0,0 +1,40 @@
package main
import (
"flag"
"fmt"
"os"
"os/signal"
"syscall"
"time"
"git.asio.asia/nixevol/NixMsg/test/load"
)
// 压测客户端骨架:连上 N 个 MQTT 连接并打印连接数。完整 1000 连接 / 10 分钟压测留给 Q3。
func main() {
addr := flag.String("addr", "", "MQTT broker host:port(必填,不要写死业务端口)")
n := flag.Int("n", 10, "连接数")
prefix := flag.String("prefix", "q-bench-", "client id 前缀")
hold := flag.Duration("hold", 3*time.Second, "保持连接时长")
flag.Parse()
if *addr == "" {
fmt.Fprintln(os.Stderr, "usage: mqttbench -addr host:port [-n 10]")
os.Exit(2)
}
b := &load.MQTTBench{Addr: *addr, ClientIDPrefix: *prefix, Timeout: 5 * time.Second}
defer b.Close()
if err := b.ConnectN(*n, os.Stdout); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
fmt.Printf("held %d connections for %s\n", b.Alive(), hold.String())
ch := make(chan os.Signal, 1)
signal.Notify(ch, os.Interrupt, syscall.SIGTERM)
select {
case <-time.After(*hold):
case <-ch:
}
}
+145
View File
@@ -0,0 +1,145 @@
// Package load 提供 MQTT 压测客户端骨架:先连上并统计连接数。
package load
import (
"encoding/binary"
"fmt"
"io"
"net"
"sync"
"sync/atomic"
"time"
)
// MQTTBench 保持多个 MQTT 3.1.1 TCP 连接(仅 CONNECT/CONNACK)。
type MQTTBench struct {
Addr string
ClientIDPrefix string
Timeout time.Duration
mu sync.Mutex
conns []net.Conn
alive atomic.Int64
}
// ConnectN 建立 n 条连接;成功一条 alive+1,并打印当前连接数到 w(可为 nil)。
func (b *MQTTBench) ConnectN(n int, w io.Writer) error {
if b.Addr == "" {
return fmt.Errorf("addr required")
}
if n <= 0 {
return fmt.Errorf("n must be > 0")
}
timeout := b.Timeout
if timeout <= 0 {
timeout = 5 * time.Second
}
prefix := b.ClientIDPrefix
if prefix == "" {
prefix = "q-bench-"
}
for i := 0; i < n; i++ {
conn, err := net.DialTimeout("tcp", b.Addr, timeout)
if err != nil {
return fmt.Errorf("dial %d: %w", i, err)
}
_ = conn.SetDeadline(time.Now().Add(timeout))
cid := fmt.Sprintf("%s%d", prefix, i)
if err := mqttConnect(conn, cid); err != nil {
_ = conn.Close()
return fmt.Errorf("connect %d: %w", i, err)
}
_ = conn.SetDeadline(time.Time{})
b.mu.Lock()
b.conns = append(b.conns, conn)
b.mu.Unlock()
cur := b.alive.Add(1)
if w != nil {
_, _ = fmt.Fprintf(w, "mqtt connections: %d\n", cur)
}
}
return nil
}
// Alive 当前仍打开的连接数。
func (b *MQTTBench) Alive() int64 {
return b.alive.Load()
}
// Close 关闭全部连接。
func (b *MQTTBench) Close() {
b.mu.Lock()
defer b.mu.Unlock()
for _, c := range b.conns {
_ = c.Close()
}
b.conns = nil
b.alive.Store(0)
}
func mqttConnect(conn net.Conn, clientID string) error {
pkt := buildConnect(clientID)
if _, err := conn.Write(pkt); err != nil {
return err
}
header := make([]byte, 4)
if _, err := io.ReadFull(conn, header[:2]); err != nil {
return err
}
if header[0] != 0x20 {
return fmt.Errorf("unexpected packet type 0x%02x", header[0])
}
// remaining length 对 CONNACK 固定为 2
if header[1] != 2 {
return fmt.Errorf("unexpected remaining length %d", header[1])
}
if _, err := io.ReadFull(conn, header[2:4]); err != nil {
return err
}
if header[3] != 0 {
return fmt.Errorf("CONNACK rc=%d", header[3])
}
return nil
}
func buildConnect(clientID string) []byte {
// Variable header: protocol name MQTT, level 4, flags 0, keepalive 60
vh := []byte{
0x00, 0x04, 'M', 'Q', 'T', 'T',
0x04,
0x00, // clean session=0 flags for skeleton; brokers may still accept
0x00, 0x3c,
}
// Actually clean session bit should be set for simple benches
vh[7] = 0x02 // Clean Session
id := []byte(clientID)
payload := make([]byte, 2+len(id))
binary.BigEndian.PutUint16(payload[0:2], uint16(len(id)))
copy(payload[2:], id)
remaining := len(vh) + len(payload)
pkt := make([]byte, 0, 2+remaining)
pkt = append(pkt, 0x10)
pkt = append(pkt, encodeRemainingLength(remaining)...)
pkt = append(pkt, vh...)
pkt = append(pkt, payload...)
return pkt
}
func encodeRemainingLength(n int) []byte {
var out []byte
for {
encoded := byte(n % 128)
n /= 128
if n > 0 {
encoded |= 0x80
}
out = append(out, encoded)
if n == 0 {
break
}
}
return out
}
+75
View File
@@ -0,0 +1,75 @@
package load
import (
"bytes"
"io"
"net"
"testing"
"time"
)
// 极简 MQTT broker:读 CONNECT,回 CONNACK accepted,保持连接。
func startFakeMQTTBroker(t *testing.T) (addr string, closeFn func()) {
t.Helper()
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen: %v", err)
}
done := make(chan struct{})
go func() {
for {
conn, err := ln.Accept()
if err != nil {
select {
case <-done:
return
default:
return
}
}
go func(c net.Conn) {
defer func() { _ = c.Close() }()
_ = c.SetDeadline(time.Now().Add(5 * time.Second))
buf := make([]byte, 256)
n, err := c.Read(buf)
if err != nil || n < 2 || buf[0] != 0x10 {
return
}
// CONNACK: type 0x20, remaining 2, flags 0, rc 0
_, _ = c.Write([]byte{0x20, 0x02, 0x00, 0x00})
_ = c.SetDeadline(time.Time{})
_, _ = io.Copy(io.Discard, c)
}(conn)
}
}()
return ln.Addr().String(), func() {
close(done)
_ = ln.Close()
}
}
func TestMQTTBenchConnectN(t *testing.T) {
addr, stop := startFakeMQTTBroker(t)
defer stop()
var out bytes.Buffer
b := &MQTTBench{Addr: addr, ClientIDPrefix: "q-t-", Timeout: 2 * time.Second}
defer b.Close()
if err := b.ConnectN(3, &out); err != nil {
t.Fatalf("ConnectN: %v", err)
}
if b.Alive() != 3 {
t.Fatalf("alive=%d", b.Alive())
}
got := out.String()
if !bytes.Contains(out.Bytes(), []byte("mqtt connections: 3")) {
t.Fatalf("output missing count: %q", got)
}
}
func TestMQTTBenchRequiresAddr(t *testing.T) {
b := &MQTTBench{}
if err := b.ConnectN(1, nil); err == nil {
t.Fatal("expected error")
}
}
+39
View File
@@ -0,0 +1,39 @@
package main
import (
"fmt"
"os"
"git.asio.asia/nixevol/NixMsg/test/report"
)
func main() {
if len(os.Args) < 2 {
fmt.Fprintln(os.Stderr, "usage: genreport <results.json> [out.md]")
os.Exit(2)
}
r, err := report.LoadResults(os.Args[1])
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
items, err := report.Normalize(r)
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
out := os.Stdout
if len(os.Args) >= 3 {
f, err := os.Create(os.Args[2])
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
defer func() { _ = f.Close() }()
out = f
}
if err := report.WriteMarkdown(out, items); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
+172
View File
@@ -0,0 +1,172 @@
// 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"
)
// Item 单条验收结果。
type Item struct {
ID string `json:"id"`
Status Status `json:"status"`
Note string `json:"note,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
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
}
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 StatusUntested, "未测", "skip", "skipped", "":
return StatusUntested, nil
default:
return "", fmt.Errorf("unknown status %q", s)
}
}
func statusCN(s Status) string {
switch s {
case StatusPass:
return "通过"
case StatusFail:
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\n\n", summary[StatusPass], summary[StatusFail], summary[StatusUntested]); err != nil {
return err
}
if _, err := fmt.Fprintf(w, "| 编号 | 一句话 | 结果 | 备注 |\n|---|---|---|---|\n"); err != nil {
return err
}
for _, f := range Features {
it := byID[f.ID]
note := it.Note
if note == "" {
note = "—"
}
if _, err := fmt.Fprintf(w, "| %s | %s | %s | %s |\n", f.ID, f.Summary, statusCN(it.Status), note); err != nil {
return err
}
}
return nil
}
// 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,
}
}
+89
View File
@@ -0,0 +1,89 @@
package report
import (
"bytes"
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
)
func TestNormalizeFillsMissingAsUntested(t *testing.T) {
t.Parallel()
items, err := Normalize(Results{Items: []Item{
{ID: "F01", Status: StatusPass, Note: "ok"},
{ID: "f08", Status: "失败", Note: "flaky"},
}})
if err != nil {
t.Fatal(err)
}
if len(items) != 23 {
t.Fatalf("len=%d", len(items))
}
if items[0].Status != StatusPass || items[0].Note != "ok" {
t.Fatalf("F01=%+v", items[0])
}
if items[7].Status != StatusFail {
t.Fatalf("F08=%+v", items[7])
}
if items[22].Status != StatusUntested {
t.Fatalf("F23=%+v", items[22])
}
}
func TestWriteMarkdownSample(t *testing.T) {
t.Parallel()
path := filepath.Join("testdata", "sample_results.json")
r, err := LoadResults(path)
if err != nil {
t.Fatal(err)
}
items, err := Normalize(r)
if err != nil {
t.Fatal(err)
}
var buf bytes.Buffer
if err := WriteMarkdown(&buf, items); err != nil {
t.Fatal(err)
}
out := buf.String()
for _, id := range []string{"F01", "F12", "F23"} {
if !strings.Contains(out, id) {
t.Fatalf("missing %s in:\n%s", id, out)
}
}
if !strings.Contains(out, "未测") {
t.Fatalf("expected 未测:\n%s", out)
}
if !strings.Contains(out, "通过 0,失败 0,未测 23") {
t.Fatalf("summary mismatch:\n%s", out)
}
}
func TestEmptyResultsRoundTrip(t *testing.T) {
t.Parallel()
dir := t.TempDir()
path := filepath.Join(dir, "empty.json")
raw, err := json.MarshalIndent(EmptyResults(), "", " ")
if err != nil {
t.Fatal(err)
}
if writeErr := os.WriteFile(path, raw, 0o644); writeErr != nil {
t.Fatal(writeErr)
}
r, err := LoadResults(path)
if err != nil {
t.Fatal(err)
}
if len(r.Items) != 23 {
t.Fatalf("items=%d", len(r.Items))
}
}
func TestParseStatusRejectsUnknown(t *testing.T) {
t.Parallel()
if _, err := Normalize(Results{Items: []Item{{ID: "F01", Status: "maybe"}}}); err == nil {
t.Fatal("expected error")
}
}
+28
View File
@@ -0,0 +1,28 @@
{
"generated_at": "2026-09-30T00:00:00Z",
"items": [
{"id": "F01", "status": "untested", "note": ""},
{"id": "F02", "status": "untested", "note": ""},
{"id": "F03", "status": "untested", "note": ""},
{"id": "F04", "status": "untested", "note": ""},
{"id": "F05", "status": "untested", "note": ""},
{"id": "F06", "status": "untested", "note": ""},
{"id": "F07", "status": "untested", "note": ""},
{"id": "F08", "status": "untested", "note": ""},
{"id": "F09", "status": "untested", "note": ""},
{"id": "F10", "status": "untested", "note": ""},
{"id": "F11", "status": "untested", "note": ""},
{"id": "F12", "status": "untested", "note": ""},
{"id": "F13", "status": "untested", "note": ""},
{"id": "F14", "status": "untested", "note": ""},
{"id": "F15", "status": "untested", "note": ""},
{"id": "F16", "status": "untested", "note": ""},
{"id": "F17", "status": "untested", "note": ""},
{"id": "F18", "status": "untested", "note": ""},
{"id": "F19", "status": "untested", "note": ""},
{"id": "F20", "status": "untested", "note": ""},
{"id": "F21", "status": "untested", "note": ""},
{"id": "F22", "status": "untested", "note": ""},
{"id": "F23", "status": "untested", "note": ""}
]
}