- 新增 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
203 lines
6.9 KiB
Go
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)
|
|
}
|
|
}
|