feat: milestone 5a — frontend plugin host, contribution lifecycle, UI shell
- Contribution Registry: ListByPoint, idempotent Register (Unregister-before-add) - Flat ContributionSummary types for frontend (no nested .item.) - Sidebar.svelte: items from ContributionRegistry, sort by position, error boundary - ViewContainer.svelte: declarative placeholder host with error boundary - PluginManager.svelte: settings panels from registry, knoppka only with settingsPanel - PluginCard.svelte: settingsPanels prop, disabled state for Settings button - Error boundary: ViewContainer + PluginManager catch errors, shell stays stable - ReloadPlugins: Unregister before Register contributions (no duplicates) - Smoke: -test-contributions flag, enable/disable/reload lifecycle verification - Build: global_update() — pull all repos, build official plugins, install to desktop
This commit is contained in:
+83
-21
@@ -93,17 +93,88 @@ func (a *App) GetPermissions() []permissions.Entry {
|
||||
return entries
|
||||
}
|
||||
|
||||
// GetContributions returns all registered contributions.
|
||||
func (a *App) GetContributions() ContributionSummary {
|
||||
return ContributionSummary{
|
||||
Views: a.contribRegistry.Views(),
|
||||
Commands: a.contribRegistry.Commands(),
|
||||
SettingsPanels: a.contribRegistry.SettingsPanels(),
|
||||
SidebarItems: a.contribRegistry.SidebarItems(),
|
||||
FileActions: a.contribRegistry.FileActions(),
|
||||
NoteActions: a.contribRegistry.NoteActions(),
|
||||
SearchProviders: a.contribRegistry.SearchProviders(),
|
||||
// ─── Flat contribution types for frontend ─────────────────
|
||||
|
||||
// FlatSidebarItem is a flattened sidebar item for the frontend.
|
||||
type FlatSidebarItem struct {
|
||||
PluginID string `json:"pluginId"`
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Icon string `json:"icon,omitempty"`
|
||||
View string `json:"view"`
|
||||
Position int `json:"position,omitempty"`
|
||||
}
|
||||
|
||||
// FlatView is a flattened view contribution for the frontend.
|
||||
type FlatView struct {
|
||||
PluginID string `json:"pluginId"`
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Icon string `json:"icon,omitempty"`
|
||||
Component string `json:"component"`
|
||||
}
|
||||
|
||||
// FlatSettingsPanel is a flattened settings panel for the frontend.
|
||||
type FlatSettingsPanel struct {
|
||||
PluginID string `json:"pluginId"`
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Icon string `json:"icon,omitempty"`
|
||||
Component string `json:"component"`
|
||||
}
|
||||
|
||||
// FlatCommand is a flattened command contribution for the frontend.
|
||||
type FlatCommand struct {
|
||||
PluginID string `json:"pluginId"`
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Icon string `json:"icon,omitempty"`
|
||||
Handler string `json:"handler,omitempty"`
|
||||
}
|
||||
|
||||
// ContributionSummary aggregates all contribution types for the frontend.
|
||||
type ContributionSummary struct {
|
||||
Views []FlatView `json:"views"`
|
||||
Commands []FlatCommand `json:"commands"`
|
||||
SettingsPanels []FlatSettingsPanel `json:"settingsPanels"`
|
||||
SidebarItems []FlatSidebarItem `json:"sidebarItems"`
|
||||
}
|
||||
|
||||
// buildContributionSummary creates a ContributionSummary from the registry.
|
||||
func buildContributionSummary(r *contribution.Registry) ContributionSummary {
|
||||
if r == nil {
|
||||
return ContributionSummary{}
|
||||
}
|
||||
regViews := r.Views()
|
||||
regCmds := r.Commands()
|
||||
regPanels := r.SettingsPanels()
|
||||
regSidebar := r.SidebarItems()
|
||||
|
||||
views := make([]FlatView, len(regViews))
|
||||
for i, v := range regViews {
|
||||
views[i] = FlatView{PluginID: v.PluginID, ID: v.Item.ID, Title: v.Item.Title, Icon: v.Item.Icon, Component: v.Item.Component}
|
||||
}
|
||||
cmds := make([]FlatCommand, len(regCmds))
|
||||
for i, v := range regCmds {
|
||||
cmds[i] = FlatCommand{PluginID: v.PluginID, ID: v.Item.ID, Title: v.Item.Title, Icon: v.Item.Icon, Handler: v.Item.Handler}
|
||||
}
|
||||
panels := make([]FlatSettingsPanel, len(regPanels))
|
||||
for i, v := range regPanels {
|
||||
panels[i] = FlatSettingsPanel{PluginID: v.PluginID, ID: v.Item.ID, Title: v.Item.Title, Icon: v.Item.Icon, Component: v.Item.Component}
|
||||
}
|
||||
sidebar := make([]FlatSidebarItem, len(regSidebar))
|
||||
for i, v := range regSidebar {
|
||||
sidebar[i] = FlatSidebarItem{PluginID: v.PluginID, ID: v.Item.ID, Title: v.Item.Title, Icon: v.Item.Icon, View: v.Item.View, Position: v.Item.Position}
|
||||
}
|
||||
return ContributionSummary{Views: views, Commands: cmds, SettingsPanels: panels, SidebarItems: sidebar}
|
||||
}
|
||||
|
||||
// GetContributions returns all registered contributions flattened for the frontend.
|
||||
func (a *App) GetContributions() ContributionSummary {
|
||||
if a.contribRegistry == nil {
|
||||
return ContributionSummary{}
|
||||
}
|
||||
return buildContributionSummary(a.contribRegistry)
|
||||
}
|
||||
|
||||
// expandPath resolves "~" to the user's home directory.
|
||||
@@ -203,7 +274,9 @@ func (a *App) ReloadPlugins() (int, string) {
|
||||
p.Status = plugin.StatusLoaded
|
||||
}
|
||||
|
||||
// Register contributions (unregister first to prevent duplicates)
|
||||
if p.Manifest.Contributes != nil {
|
||||
a.contribRegistry.Unregister(p.Manifest.ID)
|
||||
a.contribRegistry.Register(p.Manifest.ID, p.Manifest.Contributes)
|
||||
}
|
||||
|
||||
@@ -638,14 +711,3 @@ func (a *App) SelectVaultForOpen() string {
|
||||
}
|
||||
return selected
|
||||
}
|
||||
|
||||
// ContributionSummary aggregates all contribution types for the frontend.
|
||||
type ContributionSummary struct {
|
||||
Views []contribution.ContributionView `json:"views"`
|
||||
Commands []contribution.ContributionCommand `json:"commands"`
|
||||
SettingsPanels []contribution.ContributionSettingsPanel `json:"settingsPanels"`
|
||||
SidebarItems []contribution.ContributionSidebarItem `json:"sidebarItems"`
|
||||
FileActions []contribution.ContributionAction `json:"fileActions"`
|
||||
NoteActions []contribution.ContributionAction `json:"noteActions"`
|
||||
SearchProviders []contribution.ContributionSearchProvider `json:"searchProviders"`
|
||||
}
|
||||
|
||||
@@ -24,6 +24,73 @@ type Registry struct {
|
||||
statusBarItems []ContributionStatusBarItem
|
||||
}
|
||||
|
||||
// ContributionPointType defines the type of contribution point.
|
||||
type ContributionPointType string
|
||||
|
||||
const (
|
||||
PointViews ContributionPointType = "views"
|
||||
PointCommands ContributionPointType = "commands"
|
||||
PointSettingsPanels ContributionPointType = "settingsPanels"
|
||||
PointSidebarItems ContributionPointType = "sidebarItems"
|
||||
PointFileActions ContributionPointType = "fileActions"
|
||||
PointNoteActions ContributionPointType = "noteActions"
|
||||
PointContextMenus ContributionPointType = "contextMenus"
|
||||
PointSearchProviders ContributionPointType = "searchProviders"
|
||||
PointActivity ContributionPointType = "activityProviders"
|
||||
PointStatusBar ContributionPointType = "statusBarItems"
|
||||
)
|
||||
|
||||
// ListByPoint returns all contributions for a given point type.
|
||||
func (r *Registry) ListByPoint(point ContributionPointType) []interface{} {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
|
||||
var result []interface{}
|
||||
switch point {
|
||||
case PointViews:
|
||||
for _, v := range r.views {
|
||||
result = append(result, v)
|
||||
}
|
||||
case PointCommands:
|
||||
for _, v := range r.commands {
|
||||
result = append(result, v)
|
||||
}
|
||||
case PointSettingsPanels:
|
||||
for _, v := range r.settingsPanels {
|
||||
result = append(result, v)
|
||||
}
|
||||
case PointSidebarItems:
|
||||
for _, v := range r.sidebarItems {
|
||||
result = append(result, v)
|
||||
}
|
||||
case PointFileActions:
|
||||
for _, v := range r.fileActions {
|
||||
result = append(result, v)
|
||||
}
|
||||
case PointNoteActions:
|
||||
for _, v := range r.noteActions {
|
||||
result = append(result, v)
|
||||
}
|
||||
case PointContextMenus:
|
||||
for _, v := range r.contextMenus {
|
||||
result = append(result, v)
|
||||
}
|
||||
case PointSearchProviders:
|
||||
for _, v := range r.searchProviders {
|
||||
result = append(result, v)
|
||||
}
|
||||
case PointActivity:
|
||||
for _, v := range r.activityProviders {
|
||||
result = append(result, v)
|
||||
}
|
||||
case PointStatusBar:
|
||||
for _, v := range r.statusBarItems {
|
||||
result = append(result, v)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
type ContributionView struct {
|
||||
PluginID string `json:"pluginId"`
|
||||
Item plugin.ContributionView `json:"item"`
|
||||
@@ -75,10 +142,24 @@ func NewRegistry() *Registry {
|
||||
}
|
||||
|
||||
// Register adds all contributions from a plugin.
|
||||
// If the plugin already has registered contributions they are replaced
|
||||
// (supports reload without duplicates).
|
||||
func (r *Registry) Register(pluginID string, c *plugin.Contributions) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
// Remove existing contributions for this plugin to prevent duplicates on reload
|
||||
r.views = removeViews(r.views, pluginID)
|
||||
r.commands = removeCommands(r.commands, pluginID)
|
||||
r.settingsPanels = removeSettingsPanels(r.settingsPanels, pluginID)
|
||||
r.sidebarItems = removeSidebarItems(r.sidebarItems, pluginID)
|
||||
r.fileActions = removeActions(r.fileActions, pluginID)
|
||||
r.noteActions = removeActions(r.noteActions, pluginID)
|
||||
r.contextMenus = removeContextMenus(r.contextMenus, pluginID)
|
||||
r.searchProviders = removeSearchProviders(r.searchProviders, pluginID)
|
||||
r.activityProviders = removeActivityProviders(r.activityProviders, pluginID)
|
||||
r.statusBarItems = removeStatusBarItems(r.statusBarItems, pluginID)
|
||||
|
||||
for _, item := range c.Views {
|
||||
r.views = append(r.views, ContributionView{PluginID: pluginID, Item: item})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,301 @@
|
||||
package contribution
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/verstak/verstak-desktop/internal/core/plugin"
|
||||
)
|
||||
|
||||
// TestRegister_AddsContributions registers sidebar, view, command, settings contributions
|
||||
// for plugin "test.plugin" and verifies they appear via Views(), Commands(),
|
||||
// SettingsPanels(), SidebarItems().
|
||||
func TestRegister_AddsContributions(t *testing.T) {
|
||||
r := NewRegistry()
|
||||
|
||||
contribs := &plugin.Contributions{
|
||||
Views: []plugin.ContributionView{
|
||||
{ID: "test.view1", Title: "View 1", Component: "TestComponent"},
|
||||
},
|
||||
Commands: []plugin.ContributionCommand{
|
||||
{ID: "test.cmd1", Title: "Command 1"},
|
||||
},
|
||||
SettingsPanels: []plugin.ContributionSettingsPanel{
|
||||
{ID: "test.settings1", Title: "Settings 1", Component: "SettingsComponent"},
|
||||
},
|
||||
SidebarItems: []plugin.ContributionSidebarItem{
|
||||
{ID: "test.sidebar1", Title: "Sidebar 1", Icon: "icon", View: "test.view1", Position: 1},
|
||||
},
|
||||
}
|
||||
|
||||
r.Register("test.plugin", contribs)
|
||||
|
||||
// Verify counts
|
||||
if got := len(r.Views()); got != 1 {
|
||||
t.Errorf("Views(): got %d, want 1", got)
|
||||
}
|
||||
if got := len(r.Commands()); got != 1 {
|
||||
t.Errorf("Commands(): got %d, want 1", got)
|
||||
}
|
||||
if got := len(r.SettingsPanels()); got != 1 {
|
||||
t.Errorf("SettingsPanels(): got %d, want 1", got)
|
||||
}
|
||||
if got := len(r.SidebarItems()); got != 1 {
|
||||
t.Errorf("SidebarItems(): got %d, want 1", got)
|
||||
}
|
||||
|
||||
// Verify the PluginID is set correctly
|
||||
if r.Views()[0].PluginID != "test.plugin" {
|
||||
t.Errorf("Views()[0].PluginID = %q, want %q", r.Views()[0].PluginID, "test.plugin")
|
||||
}
|
||||
if r.Commands()[0].PluginID != "test.plugin" {
|
||||
t.Errorf("Commands()[0].PluginID = %q, want %q", r.Commands()[0].PluginID, "test.plugin")
|
||||
}
|
||||
if r.SettingsPanels()[0].PluginID != "test.plugin" {
|
||||
t.Errorf("SettingsPanels()[0].PluginID = %q, want %q", r.SettingsPanels()[0].PluginID, "test.plugin")
|
||||
}
|
||||
if r.SidebarItems()[0].PluginID != "test.plugin" {
|
||||
t.Errorf("SidebarItems()[0].PluginID = %q, want %q", r.SidebarItems()[0].PluginID, "test.plugin")
|
||||
}
|
||||
|
||||
// Verify item data is preserved
|
||||
if r.Views()[0].Item.Title != "View 1" {
|
||||
t.Errorf("Views()[0].Item.Title = %q, want %q", r.Views()[0].Item.Title, "View 1")
|
||||
}
|
||||
if r.Commands()[0].Item.Title != "Command 1" {
|
||||
t.Errorf("Commands()[0].Item.Title = %q, want %q", r.Commands()[0].Item.Title, "Command 1")
|
||||
}
|
||||
if r.SettingsPanels()[0].Item.Title != "Settings 1" {
|
||||
t.Errorf("SettingsPanels()[0].Item.Title = %q, want %q", r.SettingsPanels()[0].Item.Title, "Settings 1")
|
||||
}
|
||||
if r.SidebarItems()[0].Item.Title != "Sidebar 1" {
|
||||
t.Errorf("SidebarItems()[0].Item.Title = %q, want %q", r.SidebarItems()[0].Item.Title, "Sidebar 1")
|
||||
}
|
||||
}
|
||||
|
||||
// TestUnregister_RemovesOwnedContributions registers for two plugins, unregisters one,
|
||||
// and verifies only that plugin's contributions are removed.
|
||||
func TestUnregister_RemovesOwnedContributions(t *testing.T) {
|
||||
r := NewRegistry()
|
||||
|
||||
contribA := &plugin.Contributions{
|
||||
Views: []plugin.ContributionView{
|
||||
{ID: "a.view1", Title: "A View", Component: "A"},
|
||||
},
|
||||
Commands: []plugin.ContributionCommand{
|
||||
{ID: "a.cmd1", Title: "A Command"},
|
||||
},
|
||||
SettingsPanels: []plugin.ContributionSettingsPanel{
|
||||
{ID: "a.settings1", Title: "A Settings", Component: "A"},
|
||||
},
|
||||
SidebarItems: []plugin.ContributionSidebarItem{
|
||||
{ID: "a.sidebar1", Title: "A Sidebar", View: "a.view1"},
|
||||
},
|
||||
}
|
||||
|
||||
contribB := &plugin.Contributions{
|
||||
Views: []plugin.ContributionView{
|
||||
{ID: "b.view1", Title: "B View", Component: "B"},
|
||||
},
|
||||
Commands: []plugin.ContributionCommand{
|
||||
{ID: "b.cmd1", Title: "B Command"},
|
||||
},
|
||||
SettingsPanels: []plugin.ContributionSettingsPanel{
|
||||
{ID: "b.settings1", Title: "B Settings", Component: "B"},
|
||||
},
|
||||
SidebarItems: []plugin.ContributionSidebarItem{
|
||||
{ID: "b.sidebar1", Title: "B Sidebar", View: "b.view1"},
|
||||
},
|
||||
}
|
||||
|
||||
r.Register("plugin.a", contribA)
|
||||
r.Register("plugin.b", contribB)
|
||||
|
||||
// Unregister plugin.a
|
||||
r.Unregister("plugin.a")
|
||||
|
||||
// Verify plugin.a contributions are removed
|
||||
if got := r.Views(); len(got) != 1 || got[0].PluginID != "plugin.b" {
|
||||
t.Errorf("Views: got %d items (first PluginID=%q), want 1 from plugin.b", len(got), safePluginIDView(got))
|
||||
}
|
||||
if got := r.Commands(); len(got) != 1 || got[0].PluginID != "plugin.b" {
|
||||
t.Errorf("Commands: got %d items (first PluginID=%q), want 1 from plugin.b", len(got), safePluginIDCmd(got))
|
||||
}
|
||||
if got := r.SettingsPanels(); len(got) != 1 || got[0].PluginID != "plugin.b" {
|
||||
t.Errorf("SettingsPanels: got %d items (first PluginID=%q), want 1 from plugin.b", len(got), safePluginIDSettings(got))
|
||||
}
|
||||
if got := r.SidebarItems(); len(got) != 1 || got[0].PluginID != "plugin.b" {
|
||||
t.Errorf("SidebarItems: got %d items (first PluginID=%q), want 1 from plugin.b", len(got), safePluginIDSidebar(got))
|
||||
}
|
||||
|
||||
// Verify plugin.b data is intact
|
||||
if r.Views()[0].Item.ID != "b.view1" {
|
||||
t.Errorf("Remaining View ID: got %q, want %q", r.Views()[0].Item.ID, "b.view1")
|
||||
}
|
||||
if r.Commands()[0].Item.ID != "b.cmd1" {
|
||||
t.Errorf("Remaining Command ID: got %q, want %q", r.Commands()[0].Item.ID, "b.cmd1")
|
||||
}
|
||||
if r.SettingsPanels()[0].Item.ID != "b.settings1" {
|
||||
t.Errorf("Remaining SettingsPanel ID: got %q, want %q", r.SettingsPanels()[0].Item.ID, "b.settings1")
|
||||
}
|
||||
if r.SidebarItems()[0].Item.ID != "b.sidebar1" {
|
||||
t.Errorf("Remaining SidebarItem ID: got %q, want %q", r.SidebarItems()[0].Item.ID, "b.sidebar1")
|
||||
}
|
||||
}
|
||||
|
||||
// safe helpers for error messages when slices are empty
|
||||
func safePluginIDView(items []ContributionView) string {
|
||||
if len(items) == 0 {
|
||||
return "<empty>"
|
||||
}
|
||||
return items[0].PluginID
|
||||
}
|
||||
func safePluginIDCmd(items []ContributionCommand) string {
|
||||
if len(items) == 0 {
|
||||
return "<empty>"
|
||||
}
|
||||
return items[0].PluginID
|
||||
}
|
||||
func safePluginIDSettings(items []ContributionSettingsPanel) string {
|
||||
if len(items) == 0 {
|
||||
return "<empty>"
|
||||
}
|
||||
return items[0].PluginID
|
||||
}
|
||||
func safePluginIDSidebar(items []ContributionSidebarItem) string {
|
||||
if len(items) == 0 {
|
||||
return "<empty>"
|
||||
}
|
||||
return items[0].PluginID
|
||||
}
|
||||
|
||||
// TestListByPoint registers various types and calls ListByPoint for each point type,
|
||||
// verifying correct counts.
|
||||
func TestListByPoint(t *testing.T) {
|
||||
r := NewRegistry()
|
||||
|
||||
contrib := &plugin.Contributions{
|
||||
Views: []plugin.ContributionView{{ID: "v1", Title: "V1", Component: "C"}},
|
||||
Commands: []plugin.ContributionCommand{{ID: "c1", Title: "C1"}},
|
||||
SettingsPanels: []plugin.ContributionSettingsPanel{{ID: "s1", Title: "S1", Component: "C"}},
|
||||
SidebarItems: []plugin.ContributionSidebarItem{{ID: "si1", Title: "SI1", View: "v1"}},
|
||||
FileActions: []plugin.ContributionAction{{ID: "fa1", Label: "FA1"}},
|
||||
NoteActions: []plugin.ContributionAction{{ID: "na1", Label: "NA1"}},
|
||||
ContextMenuEntries: []plugin.ContributionContextMenuEntry{{ID: "cm1", Label: "CM1", Context: "file"}},
|
||||
SearchProviders: []plugin.ContributionSearchProvider{{ID: "sp1", Label: "SP1", Handler: "h"}},
|
||||
ActivityProviders: []plugin.ContributionActivityProvider{{ID: "ap1", Events: []string{"test"}, Handler: "h"}},
|
||||
StatusBarItems: []plugin.ContributionStatusBarItem{{ID: "sb1", Label: "SB1"}},
|
||||
}
|
||||
|
||||
r.Register("test.plugin", contrib)
|
||||
|
||||
tests := []struct {
|
||||
point ContributionPointType
|
||||
want int
|
||||
}{
|
||||
{PointViews, 1},
|
||||
{PointCommands, 1},
|
||||
{PointSettingsPanels, 1},
|
||||
{PointSidebarItems, 1},
|
||||
{PointFileActions, 1},
|
||||
{PointNoteActions, 1},
|
||||
{PointContextMenus, 1},
|
||||
{PointSearchProviders, 1},
|
||||
{PointActivity, 1},
|
||||
{PointStatusBar, 1},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
got := r.ListByPoint(tt.point)
|
||||
if len(got) != tt.want {
|
||||
t.Errorf("ListByPoint(%q): got %d items, want %d", tt.point, len(got), tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestRegister_DuplicatePrevention calls Register twice for the same plugin
|
||||
// (simulating reload) and checks contributions appear only once (no duplicates).
|
||||
// This is the KEY TEST for idempotent re-registration.
|
||||
func TestRegister_DuplicatePrevention(t *testing.T) {
|
||||
r := NewRegistry()
|
||||
|
||||
contrib := &plugin.Contributions{
|
||||
Views: []plugin.ContributionView{
|
||||
{ID: "test.view1", Title: "View 1", Component: "C"},
|
||||
},
|
||||
Commands: []plugin.ContributionCommand{
|
||||
{ID: "test.cmd1", Title: "Cmd 1"},
|
||||
},
|
||||
SettingsPanels: []plugin.ContributionSettingsPanel{
|
||||
{ID: "test.settings1", Title: "Settings 1", Component: "C"},
|
||||
},
|
||||
SidebarItems: []plugin.ContributionSidebarItem{
|
||||
{ID: "test.sidebar1", Title: "Sidebar 1", View: "test.view1"},
|
||||
},
|
||||
}
|
||||
|
||||
// First registration
|
||||
r.Register("test.plugin", contrib)
|
||||
// Second registration — simulates plugin reload
|
||||
r.Register("test.plugin", contrib)
|
||||
|
||||
// Each type should have only 1 entry (no duplicates)
|
||||
if got := len(r.Views()); got != 1 {
|
||||
t.Errorf("Views after double Register: got %d, want 1 (no duplicates)", got)
|
||||
}
|
||||
if got := len(r.Commands()); got != 1 {
|
||||
t.Errorf("Commands after double Register: got %d, want 1 (no duplicates)", got)
|
||||
}
|
||||
if got := len(r.SettingsPanels()); got != 1 {
|
||||
t.Errorf("SettingsPanels after double Register: got %d, want 1 (no duplicates)", got)
|
||||
}
|
||||
if got := len(r.SidebarItems()); got != 1 {
|
||||
t.Errorf("SidebarItems after double Register: got %d, want 1 (no duplicates)", got)
|
||||
}
|
||||
|
||||
// Also verify the item data is preserved correctly
|
||||
if r.Views()[0].Item.ID != "test.view1" {
|
||||
t.Errorf("View ID after reload: got %q, want %q", r.Views()[0].Item.ID, "test.view1")
|
||||
}
|
||||
if r.Commands()[0].Item.ID != "test.cmd1" {
|
||||
t.Errorf("Command ID after reload: got %q, want %q", r.Commands()[0].Item.ID, "test.cmd1")
|
||||
}
|
||||
if r.SettingsPanels()[0].Item.ID != "test.settings1" {
|
||||
t.Errorf("SettingsPanel ID after reload: got %q, want %q", r.SettingsPanels()[0].Item.ID, "test.settings1")
|
||||
}
|
||||
if r.SidebarItems()[0].Item.ID != "test.sidebar1" {
|
||||
t.Errorf("SidebarItem ID after reload: got %q, want %q", r.SidebarItems()[0].Item.ID, "test.sidebar1")
|
||||
}
|
||||
}
|
||||
|
||||
// TestUnregister_NoSideEffects verifies that unregistering a non-existent plugin
|
||||
// doesn't crash or corrupt the registry.
|
||||
func TestUnregister_NoSideEffects(t *testing.T) {
|
||||
r := NewRegistry()
|
||||
|
||||
// Register a plugin
|
||||
contrib := &plugin.Contributions{
|
||||
Views: []plugin.ContributionView{
|
||||
{ID: "v1", Title: "V1", Component: "C"},
|
||||
},
|
||||
}
|
||||
r.Register("existing.plugin", contrib)
|
||||
|
||||
// Unregister a plugin that was never registered — should not panic
|
||||
r.Unregister("nonexistent.plugin")
|
||||
|
||||
// Existing plugin's contributions should still be intact
|
||||
if got := len(r.Views()); got != 1 {
|
||||
t.Errorf("Views after unregistering non-existent: got %d, want 1", got)
|
||||
}
|
||||
if r.Views()[0].PluginID != "existing.plugin" {
|
||||
t.Errorf("PluginID after unregistering non-existent: got %q, want %q", r.Views()[0].PluginID, "existing.plugin")
|
||||
}
|
||||
|
||||
// Unregister with empty string — should not panic
|
||||
r.Unregister("")
|
||||
|
||||
// Still intact
|
||||
if got := len(r.Views()); got != 1 {
|
||||
t.Errorf("Views after unregistering empty string: got %d, want 1", got)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user