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 干净。
233 lines
7.9 KiB
Go
233 lines
7.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"
|
||
"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)
|
||
}
|
||
}
|