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) } }