Files
Cyrene_v2/internal/contract/enums/enums_test.go
T
AskaEth 89434e4f99
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
fix(contract): enums 的 MODES 漂移守卫改按目录收集,抗前端类型拆分
TestModeTableMatchesFrontendModes 原先写死读 frontend/web/src/lib/types.ts;阶段 0 把该
文件拆成 barrel + lib/types/*.ts 后 MODES 住进了 types/chat.ts ⇒ 测试红。

改为读 barrel + 目录下全部 .ts(新增 frontendTypesDir 常量与 readFrontendTypes 辅助):
「MODES 住哪个文件」不再是本测试的耦合点,以后再搬也不用改它;barrel 缺失
(backend-only checkout)仍按 fs.ErrNotExist 跳过。

验证:go test ./internal/contract/enums/ 绿;全树 go test ./... = 58 包 ok / 0 FAIL;
gofmt -l 干净。
2026-10-04 10:54:07 +08:00

233 lines
7.9 KiB
Go
Raw 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 enums_test
// Anti-drift tests for the shared client enum tables (design/07 §6, requirement
// 4): each table is pinned to the real code that defines its values, so a value
// added to (or renamed in) an adapter, the emotion state machine or the frontend
// mode union without registering it here fails the build rather than silently
// reaching clients as an unmapped string.
//
// The platform test builds the real built-in adapters and asserts the table and
// their Name() values are the same set — not merely that the table holds some
// strings. The adapters are constructed with empty configs, which never dials
// out (Connect does); see cmd/cyrene's factory test for the wiring-level anchor.
import (
"errors"
"io/fs"
"os"
"regexp"
"slices"
"sort"
"strings"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/contract/enums"
"git.yeij.top/AskaEth/Cyrene_v2/internal/persona/emotion"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history"
"git.yeij.top/AskaEth/Cyrene_v2/plugins/channels/kook"
"git.yeij.top/AskaEth/Cyrene_v2/plugins/channels/qq_obv11"
"git.yeij.top/AskaEth/Cyrene_v2/plugins/channels/telegram"
"git.yeij.top/AskaEth/Cyrene_v2/plugins/channels/webhook"
"git.yeij.top/AskaEth/Cyrene_v2/plugins/channels/weixin_ilink"
)
// ids maps a table to its id set.
func ids(items []enums.Item) map[string]bool {
out := make(map[string]bool, len(items))
for _, it := range items {
out[it.ID] = true
}
return out
}
// sortedKeys returns the keys of a set in a stable order for diffs.
func sortedKeys(set map[string]bool) []string {
out := make([]string, 0, len(set))
for k := range set {
out = append(out, k)
}
sort.Strings(out)
return out
}
// adapterPlatformNames returns the Name() of every built-in IM adapter plus the
// Web main-session source. It is the authority the platform table mirrors: if an
// adapter is renamed and the table is not, the two sets diverge and the test
// fails.
func adapterPlatformNames(t *testing.T) []string {
t.Helper()
weixin, err := weixin_ilink.New(weixin_ilink.Config{}, nil, nil)
if err != nil {
t.Fatalf("build weixin-ilink adapter: %v", err)
}
return []string{
history.PlatformWeb,
kook.New(kook.Config{}).Name(),
qq_obv11.NewAdapter(qq_obv11.Config{}).Name(),
weixin.Name(),
telegram.New(telegram.Config{}).Name(),
webhook.New().Name(),
}
}
// TestPlatformTableMatchesAdapterNames asserts the published platform table and
// the adapters the app actually builds are the same set, in both directions: an
// unregistered adapter (missing entry) and a phantom entry (a platform no
// adapter provides) both fail.
func TestPlatformTableMatchesAdapterNames(t *testing.T) {
t.Parallel()
want := make(map[string]bool)
for _, name := range adapterPlatformNames(t) {
want[name] = true
}
got := ids(enums.Platforms())
for _, name := range sortedKeys(want) {
if !got[name] {
t.Errorf("adapter Name() %q is missing from enums.Platforms()", name)
}
}
for _, id := range sortedKeys(got) {
if !want[id] {
t.Errorf("enums.Platforms() lists %q, which no built-in adapter provides", id)
}
}
if len(want) != len(got) {
t.Fatalf("platform count = %d, want %d (table=%v adapters=%v)", len(got), len(want), sortedKeys(got), sortedKeys(want))
}
}
// TestMoodTableMatchesEmotionBuiltins asserts the mood table is exactly the
// emotion state machine's built-in states, referencing the constants rather than
// re-typing the strings, and that every state the default transition table
// mentions is therefore labeled (a transition to an unlabeled state would reach
// clients as a display-raw string).
func TestMoodTableMatchesEmotionBuiltins(t *testing.T) {
t.Parallel()
builtins := []emotion.EmotionState{emotion.Calm, emotion.Happy, emotion.Excited, emotion.Sad, emotion.Angry}
want := make(map[string]bool, len(builtins))
for _, s := range builtins {
want[string(s)] = true
}
got := ids(enums.Moods())
if !slices.Equal(sortedKeys(got), sortedKeys(want)) {
t.Fatalf("mood table ids = %v, want the emotion built-ins %v", sortedKeys(got), sortedKeys(want))
}
for _, entry := range enums.Moods() {
if entry.Label == "" {
t.Errorf("mood %q has no label", entry.ID)
}
}
for from, tos := range emotion.DefaultTransitions() {
if !got[string(from)] {
t.Errorf("default transition source %q is not in the mood table", from)
}
for _, to := range tos {
if !got[string(to)] {
t.Errorf("default transition target %q is not in the mood table", to)
}
}
}
}
// frontendTypesDir 是前端共享类型的目录:barrel(lib/types.ts)+ 按域拆分的
// lib/types/*.ts(阶段 0 的结构拆分,方案见 DevDocs/audit/_plan.md)。
const frontendTypesDir = "../../../frontend/web/src/lib/types"
// readFrontendTypes 汇总 barrel 与目录下全部 .ts 的源码。按目录收集而不是写死单个文件,
// 是为了让「MODES 数组住在哪个文件」不成为本测试的耦合点——以后再搬一次也不用改它。
// barrel 缺失(backend-only checkout)时返回 fs.ErrNotExist,由调用方跳过。
func readFrontendTypes() ([]byte, error) {
paths := []string{frontendTypesDir + ".ts"}
entries, err := os.ReadDir(frontendTypesDir)
if err != nil && !errors.Is(err, fs.ErrNotExist) {
return nil, err
}
for _, e := range entries {
if !e.IsDir() && strings.HasSuffix(e.Name(), ".ts") {
paths = append(paths, frontendTypesDir+"/"+e.Name())
}
}
var all []byte
for _, p := range paths {
raw, err := os.ReadFile(p)
if err != nil {
return nil, err
}
all = append(all, raw...)
all = append(all, '\n')
}
return all, nil
}
// TestModeTableMatchesFrontendModes asserts the mode table matches the frontend's
// Mode union / MODES array, the only code-level definition of the nine modes. This
// is the cross-language drift guard: adding a mode on either side without the other
// fails here.
func TestModeTableMatchesFrontendModes(t *testing.T) {
t.Parallel()
raw, err := readFrontendTypes()
if err != nil {
if errors.Is(err, fs.ErrNotExist) {
t.Skipf("%s not present (backend-only checkout)", frontendTypesDir)
}
t.Fatalf("read frontend types: %v", err)
}
block := regexp.MustCompile(`(?s)export const MODES\s*:[^=]*=\s*\[(.*?)\]`).FindSubmatch(raw)
if block == nil {
t.Fatalf("MODES array not found under %s", frontendTypesDir)
}
quoted := regexp.MustCompile(`"([^"]+)"`).FindAllStringSubmatch(string(block[1]), -1)
frontend := make([]string, 0, len(quoted))
for _, m := range quoted {
frontend = append(frontend, m[1])
}
backend := make([]string, 0, len(enums.Modes()))
for _, m := range enums.Modes() {
backend = append(backend, m.ID)
}
if !slices.Equal(backend, frontend) {
t.Fatalf("mode table = %v, want the frontend MODES %v", backend, frontend)
}
}
// TestTablesAreWellFormed guards the published shape itself: ids are unique and
// non-empty and every entry carries a default label, so a client can always
// render something for a known value.
func TestTablesAreWellFormed(t *testing.T) {
t.Parallel()
for _, table := range []struct {
name string
items []enums.Item
}{
{name: "platforms", items: enums.Platforms()},
{name: "moods", items: enums.Moods()},
{name: "modes", items: enums.Modes()},
} {
if len(table.items) == 0 {
t.Fatalf("%s is empty", table.name)
}
seen := make(map[string]bool, len(table.items))
for _, it := range table.items {
if it.ID == "" || it.Label == "" {
t.Errorf("%s entry %+v must have both id and label", table.name, it)
}
if seen[it.ID] {
t.Errorf("%s id %q is duplicated", table.name, it.ID)
}
seen[it.ID] = true
}
}
}
// TestTablesAreCopies asserts the accessors hand out independent slices, so a
// caller sorting its copy cannot reorder the authority table for everyone else.
func TestTablesAreCopies(t *testing.T) {
t.Parallel()
first := enums.Platforms()
first[0] = enums.Item{ID: "mutated", Label: "mutated"}
second := enums.Platforms()
if second[0].ID != "web" {
t.Fatalf("Platforms() shared state: second call starts with %q", second[0].ID)
}
}