package paneldecl import ( "errors" "fmt" "reflect" "slices" "sync" "testing" "git.yeij.top/AskaEth/Cyrene_v2/internal/auth" "git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk" ) // 本文件锁定目录的四条不变量:生命周期重算、稳定序、按面板拒绝、返回值是拷贝。 // 并发那条交给 `go test -race` 去证。 // newTestCatalog 造一个不报错的目录(夹具里构造失败说明夹具坏了,直接 Fatal)。 func newTestCatalog(t *testing.T, resolve Resolver) *Catalog { t.Helper() cat, err := NewCatalog(resolve) if err != nil { t.Fatalf("NewCatalog: %v", err) } return cat } // mustRegister 注册并断言全部面板都被接受。 func mustRegister(t *testing.T, cat *Catalog, pluginID string, panels ...sdk.Panel) { t.Helper() accepted, err := cat.Register(pluginID, panels) if err != nil { t.Fatalf("Register(%s): %v", pluginID, err) } if len(accepted) != len(panels) { t.Fatalf("Register(%s) 接受 %d 个面板,期望 %d", pluginID, len(accepted), len(panels)) } } // TestNewCatalog_RejectsNilResolver 锁定「事实来源必须显式接入」:给 nil resolver 一个 // 「全都显示」的默认值会让漏接线表现为看起来正常,所以构造函数直接拒。 func TestNewCatalog_RejectsNilResolver(t *testing.T) { t.Parallel() if _, err := NewCatalog(nil); !errors.Is(err, ErrNilResolver) { t.Fatalf("NewCatalog(nil) err = %v,期望 ErrNilResolver", err) } } // TestCatalog_WhenGatesEntrance 锁定 §5.2.1 nav.item 与 §5.6.1:when 不满足 ⇒ 入口不出现。 func TestCatalog_WhenGatesEntrance(t *testing.T) { t.Parallel() enabled := map[string]bool{"web-search": true} cat := newTestCatalog(t, func(id string) Context { return Context{Enabled: enabled[id]} }) always := samplePanel() always.ID = "always" always.Order = 1 always.When = nil onWhenEnabled := samplePanel() onWhenEnabled.ID = "on" onWhenEnabled.Order = 2 onWhenEnabled.When = &sdk.When{Enabled: boolPtr(true)} whenDisabled := samplePanel() whenDisabled.ID = "off" whenDisabled.Order = 3 whenDisabled.When = &sdk.When{Enabled: boolPtr(false)} mustRegister(t, cat, "web-search", always, onWhenEnabled, whenDisabled) entries, err := cat.List(auth.RoleOwner) if err != nil { t.Fatalf("List: %v", err) } if got := mustPanelIDs(t, entries); !slices.Equal(got, []string{"always", "on"}) { t.Fatalf("插件启用时的入口 = %v,期望 [always on]", got) } enabled["web-search"] = false entries, err = cat.List(auth.RoleOwner) if err != nil { t.Fatalf("List: %v", err) } if got := mustPanelIDs(t, entries); !slices.Equal(got, []string{"always", "off"}) { t.Fatalf("插件停用时的入口 = %v,期望 [always off]", got) } } // TestCatalog_StableOrder 锁定稳定序:乱序注册也得到固定输出,且反复查询逐字相同。 // // 「逐字相同」不是洁癖:List 内部要用 map 收集各插件的面板,如果排序键不含登记序号, // 同 group 同 order 的条目顺序就会随 map 迭代抖动 —— 前端导航跟着抖。 func TestCatalog_StableOrder(t *testing.T) { t.Parallel() cat := newTestCatalog(t, alwaysEnabled) late := samplePanel() late.ID = "z-late" late.Order = 30 first := samplePanel() first.ID = "z-first" first.Order = 5 // 同一个插件内**先声明 order 大的**,锁定「同 order 时按声明顺序」与「order 优先」两件事。 mustRegister(t, cat, "zz", late, first) same := samplePanel() same.ID = "a-same-order" same.Order = 5 mustRegister(t, cat, "aa", same) want := []string{"z-first", "a-same-order", "z-late"} for i := 0; i < 100; i++ { entries, err := cat.List(auth.RoleOwner) if err != nil { t.Fatalf("第 %d 次 List: %v", i, err) } if got := mustPanelIDs(t, entries); !slices.Equal(got, want) { t.Fatalf("第 %d 次 List = %v,期望 %v(排序必须稳定)", i, got, want) } } } // TestCatalog_RegisterReplacesAndRemoveRecalculates 锁定 §5.2.1 的生命周期口径: // 重装是**整体替换**,卸载后集合立即重算。 func TestCatalog_RegisterReplacesAndRemoveRecalculates(t *testing.T) { t.Parallel() cat := newTestCatalog(t, alwaysEnabled) mustRegister(t, cat, "web-search", samplePanel()) // 重装只带一个新的面板 ⇒ 旧面板必须消失(不是叠加)。 replacement := statusPanel() replacement.ID = "chain" mustRegister(t, cat, "web-search", replacement) entries, err := cat.List(auth.RoleOwner) if err != nil { t.Fatalf("List: %v", err) } if got := mustPanelIDs(t, entries); !slices.Equal(got, []string{"chain"}) { t.Fatalf("重装后的入口 = %v,期望 [chain]", got) } cat.Remove("web-search") entries, err = cat.List(auth.RoleOwner) if err != nil { t.Fatalf("List: %v", err) } if len(entries) != 0 { t.Fatalf("卸载后仍有 %d 条入口", len(entries)) } // 重复卸载是幂等空操作。 cat.Remove("web-search") if ids := cat.PluginIDs(); len(ids) != 0 { t.Fatalf("卸载后仍登记着 %v", ids) } } // TestCatalog_RegisterRejectsPanelOnly 锁定拒绝粒度(§5.2.1「该面板不注册 + 显式报错」): // 坏面板被拒、坏面板所在插件的好面板照常注册,且错误可分类。 func TestCatalog_RegisterRejectsPanelOnly(t *testing.T) { t.Parallel() cat := newTestCatalog(t, alwaysEnabled) good := samplePanel() bad := statusPanel() bad.ID = "broken" // 形状错误:只读事实面不该有 source。 bad.Sections[0].Source = &sdk.Source{Action: "plugin.chain"} accepted, err := cat.Register("web-search", []sdk.Panel{good, bad}) if !errors.Is(err, ErrSectionShape) { t.Fatalf("err = %v,期望命中 ErrSectionShape", err) } if !slices.Equal(accepted, []string{"providers"}) { t.Fatalf("accepted = %v,期望 [providers](坏面板不注册)", accepted) } entries, lerr := cat.List(auth.RoleOwner) if lerr != nil { t.Fatalf("List: %v", lerr) } if got := mustPanelIDs(t, entries); !slices.Equal(got, []string{"providers"}) { t.Fatalf("目录内容 = %v,期望 [providers]", got) } // 整份声明都坏 ⇒ 该插件被登记成「零面板」(重算语义),而不是保留旧集合。 accepted, err = cat.Register("web-search", []sdk.Panel{bad}) if !errors.Is(err, ErrSectionShape) { t.Fatalf("err = %v,期望命中 ErrSectionShape", err) } if len(accepted) != 0 { t.Fatalf("accepted = %v,期望空", accepted) } entries, lerr = cat.List(auth.RoleOwner) if lerr != nil { t.Fatalf("List: %v", lerr) } if len(entries) != 0 { t.Fatalf("全坏声明之后仍有 %d 条入口", len(entries)) } // 「已装、未声明任何页面」仍要能被插件列表页查到(§5.2.1 红线③)。 if ids := cat.PluginIDs(); !slices.Equal(ids, []string{"web-search"}) { t.Fatalf("PluginIDs = %v,期望 [web-search]", ids) } } // TestCatalog_RegisterRejectsBadPluginID 锁定另一种拒绝口径:插件 id 非法 ⇒ 整插件拒绝 // (目录按插件 id 分命名空间,没有合法命名空间就无从挂载)。 func TestCatalog_RegisterRejectsBadPluginID(t *testing.T) { t.Parallel() cat := newTestCatalog(t, alwaysEnabled) accepted, err := cat.Register("Web Search", []sdk.Panel{samplePanel()}) if !errors.Is(err, ErrInvalidPluginID) { t.Fatalf("err = %v,期望命中 ErrInvalidPluginID", err) } if accepted != nil { t.Fatalf("accepted = %v,期望 nil", accepted) } entries, lerr := cat.List(auth.RoleOwner) if lerr != nil { t.Fatalf("List: %v", lerr) } if len(entries) != 0 || len(cat.PluginIDs()) != 0 { t.Fatalf("非法插件 id 不该留下任何痕迹:entries=%v ids=%v", entries, cat.PluginIDs()) } } // TestCatalog_ListReturnsCopy 锁定「返回值必须是拷贝」的两个方向:调用方改结果改不动目录, // 注册之后改自己那份声明也改不动目录(后者同样重要 —— 插件管理器常常复用一份声明)。 func TestCatalog_ListReturnsCopy(t *testing.T) { t.Parallel() cat := newTestCatalog(t, alwaysEnabled) mine := samplePanel() mustRegister(t, cat, "web-search", mine) entries, err := cat.List(auth.RoleOwner) if err != nil { t.Fatalf("List: %v", err) } if !reflect.DeepEqual(entries[0].Panel, samplePanel()) { t.Fatalf("目录里的面板与声明不一致:%+v", entries[0].Panel) } // 方向一:改 List 的返回值。 entries[0].Panel.Title = "被我改了" entries[0].Panel.Sections[0].Title = "被我改了" entries[0].Panel.Sections[0].Fields[0].Key = "hacked" entries[0].Panel.Sections[0].Fields[0].Min = floatPtr(1) entries[0].Panel.Sections[1].Columns[0].Label = "被我改了" entries[0].Panel.Sections[1].RowActions[0].Label = "被我改了" entries[0].PluginID = "hacked" *entries[0].Panel.When.Enabled = false entries[0].Panel.Sections = append(entries[0].Panel.Sections, sdk.Section{ID: "extra", Type: sdk.SectionStatus}) // 方向二:改注册时用的那份声明。 mine.Title = "也改了" mine.Sections[1].Columns[1].Label = "也改了" again, err := cat.List(auth.RoleOwner) if err != nil { t.Fatalf("List: %v", err) } if got := mustPanelIDs(t, again); !slices.Equal(got, []string{"providers"}) { t.Fatalf("入口 id 被外部改动影响:%v", got) } if again[0].PluginID != "web-search" { t.Fatalf("PluginID 被改坏:%q", again[0].PluginID) } if !reflect.DeepEqual(again[0].Panel, samplePanel()) { t.Fatalf("目录内部状态被调用方改坏了:%+v", again[0].Panel) } } // TestCatalog_ListRejectsUnknownRole 锁定角色门是 fail-closed:认不出的角色不给任何面板, // 也不猜一个默认角色放行。 func TestCatalog_ListRejectsUnknownRole(t *testing.T) { t.Parallel() cat := newTestCatalog(t, alwaysEnabled) mustRegister(t, cat, "web-search", samplePanel()) if _, err := cat.List(auth.Role("superuser")); !errors.Is(err, ErrUnknownRole) { t.Fatalf("err = %v,期望 ErrUnknownRole", err) } if _, err := cat.Groups(auth.Role("")); !errors.Is(err, ErrUnknownRole) { t.Fatalf("Groups err = %v,期望 ErrUnknownRole", err) } } // TestCatalog_Groups 锁定分组视图与 List 的一致性(§5.2.1 nav.group 一级)。 // // 本期分组是闭集(只有 plugins),所以这里能验的只有「同一分组内条目连续且顺序与 List 一致」; // 跨分组排序按最小 order 的规则要等分组闭集扩容后才能端到端测(见 PLG1-report.md)。 func TestCatalog_Groups(t *testing.T) { t.Parallel() cat := newTestCatalog(t, alwaysEnabled) later := samplePanel() later.ID = "later" later.Order = 30 earlier := statusPanel() earlier.ID = "earlier" earlier.Order = 10 mustRegister(t, cat, "web-search", later, earlier) groups, err := cat.Groups(auth.RoleOwner) if err != nil { t.Fatalf("Groups: %v", err) } if len(groups) != 1 { t.Fatalf("分组数 = %d,期望 1(本期闭集只有 plugins)", len(groups)) } if groups[0].ID != sdk.GroupPlugins { t.Fatalf("分组 id = %q,期望 %q", groups[0].ID, sdk.GroupPlugins) } if got := mustPanelIDs(t, groups[0].Panels); !slices.Equal(got, []string{"earlier", "later"}) { t.Fatalf("分组内顺序 = %v,期望 [earlier later]", got) } } // TestCatalog_ConcurrentRegisterList 配合 `go test -race` 证明目录在并发 // 注册 / 查询 / 卸载 下无数据竞争、无 panic。 // // 负载刻意**限核限时**(8 个 goroutine × 40 次,毫秒级):按仓库压测纪律,验证竞态不需要 // 堆进程风暴,注入受控并发足够。 func TestCatalog_ConcurrentRegisterList(t *testing.T) { t.Parallel() cat := newTestCatalog(t, alwaysEnabled) const workers = 8 const rounds = 40 var wg sync.WaitGroup for w := 0; w < workers; w++ { wg.Add(1) go func(w int) { defer wg.Done() pluginID := fmt.Sprintf("plugin-%d", w) for r := 0; r < rounds; r++ { p := samplePanel() p.ID = fmt.Sprintf("panel-%d-%d", w, r%3) p.Order = r % 5 if _, err := cat.Register(pluginID, []sdk.Panel{p}); err != nil { t.Errorf("Register(%s): %v", pluginID, err) return } entries, err := cat.List(auth.RoleViewer) if err != nil { t.Errorf("List: %v", err) return } for _, e := range entries { if e.PluginID == "" || e.Panel.ID == "" { t.Errorf("快照里出现空标识:%+v", e) return } } if _, err := cat.Groups(auth.RoleAdmin); err != nil { t.Errorf("Groups: %v", err) return } _ = cat.PluginIDs() if r%11 == 0 { cat.Remove(pluginID) } } cat.Remove(pluginID) }(w) } wg.Wait() entries, err := cat.List(auth.RoleOwner) if err != nil { t.Fatalf("收尾 List: %v", err) } if len(entries) != 0 { t.Fatalf("全部卸载后仍有 %d 条入口", len(entries)) } }