Files
Cyrene_v2/internal/contract/enums/enums_test.go
T
AskaEth 22e8398110 feat(contract,gateway): B4 枚举权威表 — GET /api/v1/meta/enums + 未知值回落约定
- 新增 internal/contract/enums 叶子包:platform/mood/mode 三表 + UnknownFallback="display-raw";每次调用返回新切片,无包级可变全局
- GET /api/v1/meta/enums(anyAuth 只读、纯投影、无 store/无错误路径)
- 反漂移 4 处:真构造内置适配器断言 Name() 与表双向相等、真工厂 buildAccountAdapter 逐项可建、mood 对齐 emotion 常量 + 默认转移表、mode 对齐前端 MODES(跨语言,文件缺失则 skip)
- Docs/api-client §1.1 枚举权威表 + 行15 + v0.38;api-reference §3.7 + 行12
2026-10-03 16:55:22 +08:00

203 lines
6.9 KiB
Go

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"
"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)
}
}
}
}
// TestModeTableMatchesFrontendModes asserts the mode table matches the frontend's
// Mode union / MODES array (frontend/web/src/lib/types.ts), 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()
const typesPath = "../../../frontend/web/src/lib/types.ts"
raw, err := os.ReadFile(typesPath)
if err != nil {
if errors.Is(err, fs.ErrNotExist) {
t.Skipf("%s not present (backend-only checkout)", typesPath)
}
t.Fatalf("read %s: %v", typesPath, err)
}
block := regexp.MustCompile(`(?s)export const MODES\s*:[^=]*=\s*\[(.*?)\]`).FindSubmatch(raw)
if block == nil {
t.Fatalf("MODES array not found in %s", typesPath)
}
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)
}
}