Mike 3 месяцев назад
Родитель
Сommit
1bf3e699b4
3 измененных файлов с 73 добавлено и 74 удалено
  1. 9 9
      server/toc/cmd_client.go
  2. 25 26
      state/feedbag_list.go
  3. 39 39
      state/feedbag_list_test.go

+ 9 - 9
server/toc/cmd_client.go

@@ -1426,7 +1426,7 @@ func (s OSCARProxy) SetPDMode(ctx context.Context, me *state.SessionInstance, ar
 		return s.runtimeErr(ctx, fmt.Errorf("FeedbagManager.Feedbag: %w", err))
 	}
 
-	fl := newFeedbagList(fb, s.RandIntn)
+	fl := state.NewFeedbagList(fb, s.RandIntn)
 	fl.SetMode(uint8(mode))
 
 	if pending := fl.PendingUpdates(); len(pending) > 0 {
@@ -1467,7 +1467,7 @@ func (s OSCARProxy) NewGroup(ctx context.Context, me *state.SessionInstance, arg
 		return s.runtimeErr(ctx, fmt.Errorf("FeedbagManager.Feedbag: %w", err))
 	}
 
-	fl := newFeedbagList(fb, s.RandIntn)
+	fl := state.NewFeedbagList(fb, s.RandIntn)
 	fl.AddGroup(groupName)
 
 	if pending := fl.PendingUpdates(); len(pending) > 0 {
@@ -1507,7 +1507,7 @@ func (s OSCARProxy) DelGroup(ctx context.Context, me *state.SessionInstance, arg
 		return s.runtimeErr(ctx, fmt.Errorf("FeedbagManager.Feedbag: %w", err))
 	}
 
-	fl := newFeedbagList(fb, s.RandIntn)
+	fl := state.NewFeedbagList(fb, s.RandIntn)
 	fl.DeleteGroup(groupName)
 
 	if pending := fl.PendingDeletes(); len(pending) > 0 {
@@ -1569,7 +1569,7 @@ func (s OSCARProxy) NewBuddies(ctx context.Context, me *state.SessionInstance, a
 		return s.runtimeErr(ctx, fmt.Errorf("FeedbagManager.Feedbag: %w", err))
 	}
 
-	fl := newFeedbagList(fb, s.RandIntn)
+	fl := state.NewFeedbagList(fb, s.RandIntn)
 
 	var replies []string
 
@@ -1671,7 +1671,7 @@ func (s OSCARProxy) RemoveBuddy2(ctx context.Context, me *state.SessionInstance,
 		return s.runtimeErr(ctx, fmt.Errorf("FeedbagManager.Feedbag: %w", err))
 	}
 
-	fl := newFeedbagList(fb, s.RandIntn)
+	fl := state.NewFeedbagList(fb, s.RandIntn)
 
 	groupName := params[len(params)-1]
 	screenNames := params[:len(params)-1]
@@ -1730,7 +1730,7 @@ func (s OSCARProxy) AddPermit2(ctx context.Context, me *state.SessionInstance, a
 		return s.runtimeErr(ctx, fmt.Errorf("FeedbagManager.Feedbag: %w", err))
 	}
 
-	fl := newFeedbagList(fb, s.RandIntn)
+	fl := state.NewFeedbagList(fb, s.RandIntn)
 
 	for _, sn := range screenNames {
 		fl.PermitUser(sn)
@@ -1773,7 +1773,7 @@ func (s OSCARProxy) RemovePermit2(ctx context.Context, me *state.SessionInstance
 		return s.runtimeErr(ctx, fmt.Errorf("FeedbagManager.Feedbag: %w", err))
 	}
 
-	fl := newFeedbagList(fb, s.RandIntn)
+	fl := state.NewFeedbagList(fb, s.RandIntn)
 
 	for _, sn := range screenNames {
 		fl.DeletePermit(sn)
@@ -1818,7 +1818,7 @@ func (s OSCARProxy) AddDeny2(ctx context.Context, me *state.SessionInstance, arg
 		return s.runtimeErr(ctx, fmt.Errorf("FeedbagManager.Feedbag: %w", err))
 	}
 
-	fl := newFeedbagList(fb, s.RandIntn)
+	fl := state.NewFeedbagList(fb, s.RandIntn)
 
 	for _, sn := range screenNames {
 		fl.DenyUser(sn)
@@ -1861,7 +1861,7 @@ func (s OSCARProxy) RemoveDeny2(ctx context.Context, me *state.SessionInstance,
 		return s.runtimeErr(ctx, fmt.Errorf("FeedbagManager.Feedbag: %w", err))
 	}
 
-	fl := newFeedbagList(fb, s.RandIntn)
+	fl := state.NewFeedbagList(fb, s.RandIntn)
 
 	for _, sn := range screenNames {
 		fl.DeleteDeny(sn)

+ 25 - 26
server/toc/feedbag_list.go → state/feedbag_list.go

@@ -1,40 +1,39 @@
-package toc
+package state
 
 import (
 	"fmt"
 	"math"
 	"slices"
 
-	"github.com/mk6i/open-oscar-server/state"
 	"github.com/mk6i/open-oscar-server/wire"
 )
 
-// feedbagList provides operations for manipulating a collection of feedbag
+// FeedbagList provides operations for manipulating a collection of feedbag
 // items. It supports lookups by class/name/group, item insertion with
 // automatic ID generation, and transparent root group management.
-type feedbagList struct {
+type FeedbagList struct {
 	items          []*wire.FeedbagItem
 	randInt        func(int) int
 	pendingUpdates []*wire.FeedbagItem
 	pendingDeletes []*wire.FeedbagItem
 }
 
-// newFeedbagList creates a feedbagList from the given items. The randInt
+// NewFeedbagList creates a FeedbagList from the given items. The randInt
 // function is used for generating unique item/group IDs; inject a
 // deterministic function in tests to assert exact feedbag item slices.
-func newFeedbagList(items []wire.FeedbagItem, randInt func(int) int) *feedbagList {
+func NewFeedbagList(items []wire.FeedbagItem, randInt func(int) int) *FeedbagList {
 	ptrs := make([]*wire.FeedbagItem, len(items))
 	for i := range items {
 		ptrs[i] = &items[i]
 	}
-	return &feedbagList{
+	return &FeedbagList{
 		items:   ptrs,
 		randInt: randInt,
 	}
 }
 
 // SetMode upserts the permit/deny mode item.
-func (f *feedbagList) SetMode(mode uint8) {
+func (f *FeedbagList) SetMode(mode uint8) {
 	f.upsertItem(wire.FeedbagItem{
 		ClassID: wire.FeedbagClassIdPdinfo,
 		TLVLBlock: wire.TLVLBlock{
@@ -49,7 +48,7 @@ func (f *feedbagList) SetMode(mode uint8) {
 // one with an auto-generated GroupID. When a new group is created, the root
 // group's order TLV is updated to include it. Call PendingUpdates to retrieve
 // new or modified items for persistence. Returns the group item.
-func (f *feedbagList) AddGroup(name string) wire.FeedbagItem {
+func (f *FeedbagList) AddGroup(name string) wire.FeedbagItem {
 	if g := f.groupByName(name); g != nil {
 		return *g
 	}
@@ -83,7 +82,7 @@ func (f *feedbagList) AddGroup(name string) wire.FeedbagItem {
 
 // DeleteGroup marks a group item for deletion by name. If the group exists
 // and is not the root group, the root group's order TLV is updated.
-func (f *feedbagList) DeleteGroup(groupName string) {
+func (f *FeedbagList) DeleteGroup(groupName string) {
 	deleted, found := f.deleteItem(wire.FeedbagItem{
 		Name:    groupName,
 		ClassID: wire.FeedbagClassIdGroup,
@@ -100,7 +99,7 @@ func (f *feedbagList) DeleteGroup(groupName string) {
 
 // AddBuddy upserts a buddy item in the given group (by name), optionally
 // attaching alias and note attributes. Returns true if a new buddy was inserted.
-func (f *feedbagList) AddBuddy(groupName, screenName, alias, note string) (bool, error) {
+func (f *FeedbagList) AddBuddy(groupName, screenName, alias, note string) (bool, error) {
 	group := f.groupByName(groupName)
 	if group == nil {
 		return false, fmt.Errorf("group %q not found", groupName)
@@ -126,7 +125,7 @@ func (f *feedbagList) AddBuddy(groupName, screenName, alias, note string) (bool,
 
 // DeleteBuddy marks a buddy item for deletion in the given group (by name).
 // The parent group's order TLV is updated to remove the buddy.
-func (f *feedbagList) DeleteBuddy(groupName, buddyName string) error {
+func (f *FeedbagList) DeleteBuddy(groupName, buddyName string) error {
 	group := f.groupByName(groupName)
 	if group == nil {
 		return fmt.Errorf("group %q not found", groupName)
@@ -144,7 +143,7 @@ func (f *feedbagList) DeleteBuddy(groupName, buddyName string) error {
 }
 
 // PermitUser upserts a permit-list entry for the given screen name.
-func (f *feedbagList) PermitUser(screenName string) {
+func (f *FeedbagList) PermitUser(screenName string) {
 	f.upsertItem(wire.FeedbagItem{
 		ClassID: wire.FeedbagClassIDPermit,
 		Name:    screenName,
@@ -152,7 +151,7 @@ func (f *feedbagList) PermitUser(screenName string) {
 }
 
 // DenyUser upserts a deny-list entry for the given screen name.
-func (f *feedbagList) DenyUser(screenName string) {
+func (f *FeedbagList) DenyUser(screenName string) {
 	f.upsertItem(wire.FeedbagItem{
 		ClassID: wire.FeedbagClassIDDeny,
 		Name:    screenName,
@@ -160,7 +159,7 @@ func (f *feedbagList) DenyUser(screenName string) {
 }
 
 // DeletePermit marks a permit-list entry for deletion.
-func (f *feedbagList) DeletePermit(screenName string) {
+func (f *FeedbagList) DeletePermit(screenName string) {
 	f.deleteItem(wire.FeedbagItem{
 		ClassID: wire.FeedbagClassIDPermit,
 		Name:    screenName,
@@ -168,7 +167,7 @@ func (f *feedbagList) DeletePermit(screenName string) {
 }
 
 // DeleteDeny marks a deny-list entry for deletion.
-func (f *feedbagList) DeleteDeny(screenName string) {
+func (f *FeedbagList) DeleteDeny(screenName string) {
 	f.deleteItem(wire.FeedbagItem{
 		ClassID: wire.FeedbagClassIDDeny,
 		Name:    screenName,
@@ -179,7 +178,7 @@ func (f *feedbagList) DeleteDeny(screenName string) {
 // and items that were implicitly created or modified as side effects of other
 // operations (e.g., group order updates from upsertItem, root group updates
 // from AddGroup). The pending list is cleared after each call.
-func (f *feedbagList) PendingUpdates() []wire.FeedbagItem {
+func (f *FeedbagList) PendingUpdates() []wire.FeedbagItem {
 	var result []wire.FeedbagItem
 	for _, p := range f.pendingUpdates {
 		result = append(result, *p)
@@ -193,7 +192,7 @@ func (f *feedbagList) PendingUpdates() []wire.FeedbagItem {
 
 // PendingDeletes returns items marked for deletion via deleteItem.
 // The pending list is cleared after each call.
-func (f *feedbagList) PendingDeletes() []wire.FeedbagItem {
+func (f *FeedbagList) PendingDeletes() []wire.FeedbagItem {
 	var result []wire.FeedbagItem
 	for _, p := range f.pendingDeletes {
 		result = append(result, *p)
@@ -203,7 +202,7 @@ func (f *feedbagList) PendingDeletes() []wire.FeedbagItem {
 }
 
 // groupByName returns the group item with the given name, or nil if not found.
-func (f *feedbagList) groupByName(name string) *wire.FeedbagItem {
+func (f *FeedbagList) groupByName(name string) *wire.FeedbagItem {
 	for _, item := range f.items {
 		if item.ClassID == wire.FeedbagClassIdGroup && item.Name == name {
 			return item
@@ -213,7 +212,7 @@ func (f *feedbagList) groupByName(name string) *wire.FeedbagItem {
 }
 
 // trackUpdate adds item to the pending-updates list if not already present.
-func (f *feedbagList) trackUpdate(item *wire.FeedbagItem) {
+func (f *FeedbagList) trackUpdate(item *wire.FeedbagItem) {
 	if slices.Contains(f.pendingUpdates, item) {
 		return // already tracked
 	}
@@ -224,13 +223,13 @@ func (f *feedbagList) trackUpdate(item *wire.FeedbagItem) {
 // upsert/delete (buddy: ClassID, Name, GroupID; others: ClassID and Name).
 // Stored items are assumed to have normalized names; the input (b) name is
 // normalized for comparison when the class is buddy, permit, or deny.
-func (f *feedbagList) itemsMatch(a, b *wire.FeedbagItem) bool {
+func (f *FeedbagList) itemsMatch(a, b *wire.FeedbagItem) bool {
 	if a.ClassID != b.ClassID {
 		return false
 	}
 	var nameMatch bool
 	if hasScreenName(a.ClassID) {
-		nameMatch = a.Name == state.NewIdentScreenName(b.Name).String()
+		nameMatch = a.Name == NewIdentScreenName(b.Name).String()
 	} else {
 		nameMatch = a.Name == b.Name
 	}
@@ -246,7 +245,7 @@ func (f *feedbagList) itemsMatch(a, b *wire.FeedbagItem) bool {
 // deleteItem removes the first item matching the same criteria as upsertItem:
 // buddy items by ClassID, Name, and GroupID; other items by ClassID and Name.
 // Returns the deleted item and true if found, or a zero item and false otherwise.
-func (f *feedbagList) deleteItem(item wire.FeedbagItem) (wire.FeedbagItem, bool) {
+func (f *FeedbagList) deleteItem(item wire.FeedbagItem) (wire.FeedbagItem, bool) {
 	for i, existing := range f.items {
 		if f.itemsMatch(existing, &item) {
 			f.pendingDeletes = append(f.pendingDeletes, existing)
@@ -264,9 +263,9 @@ func (f *feedbagList) deleteItem(item wire.FeedbagItem) (wire.FeedbagItem, bool)
 // an auto-generated ItemID. Names for buddy, permit, and deny items are
 // normalized before storage. Returns the stored item and true if a new item
 // was inserted, or the existing item and false if it was updated/unchanged.
-func (f *feedbagList) upsertItem(item wire.FeedbagItem) (wire.FeedbagItem, bool) {
+func (f *FeedbagList) upsertItem(item wire.FeedbagItem) (wire.FeedbagItem, bool) {
 	if hasScreenName(item.ClassID) {
-		item.Name = state.NewIdentScreenName(item.Name).String() // normalize name
+		item.Name = NewIdentScreenName(item.Name).String() // normalize name
 	}
 	for _, existing := range f.items {
 		if f.itemsMatch(existing, &item) {
@@ -287,7 +286,7 @@ func (f *feedbagList) upsertItem(item wire.FeedbagItem) (wire.FeedbagItem, bool)
 
 // genID generates a unique ID that does not conflict with any existing ItemID
 // or GroupID in the list.
-func (f *feedbagList) genID() uint16 {
+func (f *FeedbagList) genID() uint16 {
 	num := uint16(f.randInt(math.MaxUint16))
 	for itemID := num; itemID != num-1; itemID++ {
 		if itemID == 0 {

+ 39 - 39
server/toc/feedbag_list_test.go → state/feedbag_list_test.go

@@ -1,4 +1,4 @@
-package toc
+package state
 
 import (
 	"math"
@@ -10,7 +10,7 @@ import (
 
 func TestFeedbagList_upsertItem(t *testing.T) {
 	t.Run("generates unique ItemID", func(t *testing.T) {
-		fl := newFeedbagList([]wire.FeedbagItem{
+		fl := NewFeedbagList([]wire.FeedbagItem{
 			{Name: "Buddies", ClassID: wire.FeedbagClassIdGroup, GroupID: 1},
 		}, func(n int) int { return 42 })
 
@@ -27,7 +27,7 @@ func TestFeedbagList_upsertItem(t *testing.T) {
 	})
 
 	t.Run("subsequent insert avoids collision", func(t *testing.T) {
-		fl := newFeedbagList([]wire.FeedbagItem{
+		fl := NewFeedbagList([]wire.FeedbagItem{
 			{Name: "Buddies", ClassID: wire.FeedbagClassIdGroup, GroupID: 1},
 		}, func(n int) int { return 42 })
 
@@ -46,7 +46,7 @@ func TestFeedbagList_upsertItem(t *testing.T) {
 	})
 
 	t.Run("non-buddy item does not update group order", func(t *testing.T) {
-		fl := newFeedbagList(nil, func(n int) int { return 5 })
+		fl := NewFeedbagList(nil, func(n int) int { return 5 })
 		fl.upsertItem(wire.FeedbagItem{
 			Name:    "alice",
 			ClassID: wire.FeedbagClassIDPermit,
@@ -57,7 +57,7 @@ func TestFeedbagList_upsertItem(t *testing.T) {
 	})
 
 	t.Run("updates existing non-buddy item in place", func(t *testing.T) {
-		fl := newFeedbagList([]wire.FeedbagItem{
+		fl := NewFeedbagList([]wire.FeedbagItem{
 			{
 				Name:    "alice",
 				ClassID: wire.FeedbagClassIDPermit,
@@ -90,7 +90,7 @@ func TestFeedbagList_upsertItem(t *testing.T) {
 	})
 
 	t.Run("updates existing buddy item matched by group", func(t *testing.T) {
-		fl := newFeedbagList([]wire.FeedbagItem{
+		fl := NewFeedbagList([]wire.FeedbagItem{
 			{Name: "Group1", ClassID: wire.FeedbagClassIdGroup, GroupID: 1},
 			{Name: "Group2", ClassID: wire.FeedbagClassIdGroup, GroupID: 2},
 			{Name: "alice", ClassID: wire.FeedbagClassIdBuddy, GroupID: 1, ItemID: 10},
@@ -120,7 +120,7 @@ func TestFeedbagList_upsertItem(t *testing.T) {
 	})
 
 	t.Run("skips update when existing item is identical", func(t *testing.T) {
-		fl := newFeedbagList([]wire.FeedbagItem{
+		fl := NewFeedbagList([]wire.FeedbagItem{
 			{
 				Name:    "alice",
 				ClassID: wire.FeedbagClassIDPermit,
@@ -149,7 +149,7 @@ func TestFeedbagList_upsertItem(t *testing.T) {
 	})
 
 	t.Run("normalized screen name: buddy stored with lowercase name", func(t *testing.T) {
-		fl := newFeedbagList([]wire.FeedbagItem{
+		fl := NewFeedbagList([]wire.FeedbagItem{
 			{Name: "Buddies", ClassID: wire.FeedbagClassIdGroup, GroupID: 1},
 		}, func(n int) int { return 10 })
 
@@ -163,7 +163,7 @@ func TestFeedbagList_upsertItem(t *testing.T) {
 	})
 
 	t.Run("normalized screen name: buddy upsert with different case matches existing", func(t *testing.T) {
-		fl := newFeedbagList([]wire.FeedbagItem{
+		fl := NewFeedbagList([]wire.FeedbagItem{
 			{Name: "Buddies", ClassID: wire.FeedbagClassIdGroup, GroupID: 1},
 			{Name: "alice", ClassID: wire.FeedbagClassIdBuddy, GroupID: 1, ItemID: 99},
 		}, nil)
@@ -179,7 +179,7 @@ func TestFeedbagList_upsertItem(t *testing.T) {
 	})
 
 	t.Run("normalized screen name: permit stored with lowercase name and spaces stripped", func(t *testing.T) {
-		fl := newFeedbagList(nil, func(n int) int { return 1 })
+		fl := NewFeedbagList(nil, func(n int) int { return 1 })
 
 		result, _ := fl.upsertItem(wire.FeedbagItem{
 			Name:    " Bob Smith ",
@@ -189,7 +189,7 @@ func TestFeedbagList_upsertItem(t *testing.T) {
 	})
 
 	t.Run("normalized screen name: permit upsert with different case matches existing", func(t *testing.T) {
-		fl := newFeedbagList([]wire.FeedbagItem{
+		fl := NewFeedbagList([]wire.FeedbagItem{
 			{Name: "bob", ClassID: wire.FeedbagClassIDPermit, ItemID: 5},
 		}, nil)
 
@@ -203,7 +203,7 @@ func TestFeedbagList_upsertItem(t *testing.T) {
 	})
 
 	t.Run("normalized screen name: deny upsert with different case matches existing", func(t *testing.T) {
-		fl := newFeedbagList([]wire.FeedbagItem{
+		fl := NewFeedbagList([]wire.FeedbagItem{
 			{Name: "charlie", ClassID: wire.FeedbagClassIDDeny, ItemID: 3},
 		}, nil)
 
@@ -219,7 +219,7 @@ func TestFeedbagList_upsertItem(t *testing.T) {
 
 func TestFeedbagList_deleteItem(t *testing.T) {
 	t.Run("non-buddy item does not update group order", func(t *testing.T) {
-		fl := newFeedbagList([]wire.FeedbagItem{
+		fl := NewFeedbagList([]wire.FeedbagItem{
 			{Name: "alice", ClassID: wire.FeedbagClassIDPermit, ItemID: 5},
 		}, nil)
 
@@ -231,7 +231,7 @@ func TestFeedbagList_deleteItem(t *testing.T) {
 	})
 
 	t.Run("removes item from items list", func(t *testing.T) {
-		fl := newFeedbagList([]wire.FeedbagItem{
+		fl := NewFeedbagList([]wire.FeedbagItem{
 			{Name: "alice", ClassID: wire.FeedbagClassIDPermit, ItemID: 1},
 			{Name: "bob", ClassID: wire.FeedbagClassIDPermit, ItemID: 2},
 			{Name: "charlie", ClassID: wire.FeedbagClassIDPermit, ItemID: 3},
@@ -246,7 +246,7 @@ func TestFeedbagList_deleteItem(t *testing.T) {
 	})
 
 	t.Run("multiple deletes accumulate", func(t *testing.T) {
-		fl := newFeedbagList([]wire.FeedbagItem{
+		fl := NewFeedbagList([]wire.FeedbagItem{
 			{Name: "alice", ClassID: wire.FeedbagClassIDDeny, ItemID: 1},
 			{Name: "bob", ClassID: wire.FeedbagClassIDDeny, ItemID: 2},
 		}, nil)
@@ -259,7 +259,7 @@ func TestFeedbagList_deleteItem(t *testing.T) {
 	})
 
 	t.Run("normalized screen name: delete buddy by different case", func(t *testing.T) {
-		fl := newFeedbagList([]wire.FeedbagItem{
+		fl := NewFeedbagList([]wire.FeedbagItem{
 			{Name: "Buddies", ClassID: wire.FeedbagClassIdGroup, GroupID: 1},
 			{Name: "alice", ClassID: wire.FeedbagClassIdBuddy, GroupID: 1, ItemID: 50},
 		}, nil)
@@ -275,7 +275,7 @@ func TestFeedbagList_deleteItem(t *testing.T) {
 	})
 
 	t.Run("normalized screen name: delete permit by different case", func(t *testing.T) {
-		fl := newFeedbagList([]wire.FeedbagItem{
+		fl := NewFeedbagList([]wire.FeedbagItem{
 			{Name: "bob", ClassID: wire.FeedbagClassIDPermit, ItemID: 7},
 		}, nil)
 
@@ -285,7 +285,7 @@ func TestFeedbagList_deleteItem(t *testing.T) {
 	})
 
 	t.Run("normalized screen name: delete deny by different case", func(t *testing.T) {
-		fl := newFeedbagList([]wire.FeedbagItem{
+		fl := NewFeedbagList([]wire.FeedbagItem{
 			{Name: "charlie", ClassID: wire.FeedbagClassIDDeny, ItemID: 8},
 		}, nil)
 
@@ -398,7 +398,7 @@ func TestFeedbagList_genID(t *testing.T) {
 
 	for _, tc := range tt {
 		t.Run(tc.name, func(t *testing.T) {
-			fl := newFeedbagList(tc.items, tc.randInt)
+			fl := NewFeedbagList(tc.items, tc.randInt)
 			got := fl.genID()
 			assert.Equal(t, tc.want, got)
 		})
@@ -407,7 +407,7 @@ func TestFeedbagList_genID(t *testing.T) {
 
 func TestFeedbagList_AddGroup(t *testing.T) {
 	t.Run("creates group and root group when none exists", func(t *testing.T) {
-		fl := newFeedbagList(nil, func(n int) int { return 5 })
+		fl := NewFeedbagList(nil, func(n int) int { return 5 })
 
 		group := fl.AddGroup("Buddies")
 
@@ -439,7 +439,7 @@ func TestFeedbagList_AddGroup(t *testing.T) {
 			{Name: "Buddies", ClassID: wire.FeedbagClassIdGroup, GroupID: 1},
 		}
 		callCount := 0
-		fl := newFeedbagList(existing, func(n int) int {
+		fl := NewFeedbagList(existing, func(n int) int {
 			callCount++
 			return callCount + 1
 		})
@@ -455,7 +455,7 @@ func TestFeedbagList_AddGroup(t *testing.T) {
 
 	t.Run("multiple AddGroup calls accumulate in root order", func(t *testing.T) {
 		callCount := 0
-		fl := newFeedbagList(nil, func(n int) int {
+		fl := NewFeedbagList(nil, func(n int) int {
 			callCount++
 			return callCount * 10
 		})
@@ -473,7 +473,7 @@ func TestFeedbagList_AddGroup(t *testing.T) {
 
 func TestFeedbagList_DeleteGroup(t *testing.T) {
 	t.Run("updates root group order", func(t *testing.T) {
-		fl := newFeedbagList([]wire.FeedbagItem{
+		fl := NewFeedbagList([]wire.FeedbagItem{
 			{
 				Name:    "",
 				ClassID: wire.FeedbagClassIdGroup,
@@ -507,7 +507,7 @@ func TestFeedbagList_DeleteGroup(t *testing.T) {
 
 func TestFeedbagList_AddBuddy(t *testing.T) {
 	t.Run("updates parent group order", func(t *testing.T) {
-		fl := newFeedbagList([]wire.FeedbagItem{
+		fl := NewFeedbagList([]wire.FeedbagItem{
 			{Name: "Buddies", ClassID: wire.FeedbagClassIdGroup, GroupID: 1},
 		}, func(n int) int { return 50 })
 
@@ -524,7 +524,7 @@ func TestFeedbagList_AddBuddy(t *testing.T) {
 	})
 
 	t.Run("multiple buddies accumulate in parent group order", func(t *testing.T) {
-		fl := newFeedbagList([]wire.FeedbagItem{
+		fl := NewFeedbagList([]wire.FeedbagItem{
 			{
 				Name:    "Buddies",
 				ClassID: wire.FeedbagClassIdGroup,
@@ -550,13 +550,13 @@ func TestFeedbagList_AddBuddy(t *testing.T) {
 	})
 
 	t.Run("returns error when parent group does not exist", func(t *testing.T) {
-		fl := newFeedbagList(nil, func(n int) int { return 5 })
+		fl := NewFeedbagList(nil, func(n int) int { return 5 })
 		_, err := fl.AddBuddy("Nonexistent", "alice", "", "")
 		assert.ErrorContains(t, err, "group \"Nonexistent\" not found")
 	})
 
 	t.Run("normalized screen name: stores buddy with lowercase name", func(t *testing.T) {
-		fl := newFeedbagList([]wire.FeedbagItem{
+		fl := NewFeedbagList([]wire.FeedbagItem{
 			{Name: "Buddies", ClassID: wire.FeedbagClassIdGroup, GroupID: 1},
 		}, func(n int) int { return 1 })
 
@@ -577,7 +577,7 @@ func TestFeedbagList_AddBuddy(t *testing.T) {
 	})
 
 	t.Run("normalized screen name: second AddBuddy with different case does not insert duplicate", func(t *testing.T) {
-		fl := newFeedbagList([]wire.FeedbagItem{
+		fl := NewFeedbagList([]wire.FeedbagItem{
 			{Name: "Buddies", ClassID: wire.FeedbagClassIdGroup, GroupID: 1},
 		}, func(n int) int { return 1 })
 
@@ -591,7 +591,7 @@ func TestFeedbagList_AddBuddy(t *testing.T) {
 	})
 
 	t.Run("normalized screen name: DeleteBuddy finds buddy by different case", func(t *testing.T) {
-		fl := newFeedbagList([]wire.FeedbagItem{
+		fl := NewFeedbagList([]wire.FeedbagItem{
 			{Name: "Buddies", ClassID: wire.FeedbagClassIdGroup, GroupID: 1},
 		}, func(n int) int { return 1 })
 		_, err := fl.AddBuddy("Buddies", "alice", "", "")
@@ -608,7 +608,7 @@ func TestFeedbagList_AddBuddy(t *testing.T) {
 
 func TestFeedbagList_DeleteBuddy(t *testing.T) {
 	t.Run("updates parent group order", func(t *testing.T) {
-		fl := newFeedbagList([]wire.FeedbagItem{
+		fl := NewFeedbagList([]wire.FeedbagItem{
 			{
 				Name:    "Buddies",
 				ClassID: wire.FeedbagClassIdGroup,
@@ -640,7 +640,7 @@ func TestFeedbagList_DeleteBuddy(t *testing.T) {
 	})
 
 	t.Run("removes only buddy in specified group when same screen name in two groups", func(t *testing.T) {
-		fl := newFeedbagList([]wire.FeedbagItem{
+		fl := NewFeedbagList([]wire.FeedbagItem{
 			{
 				Name:    "Buddies",
 				ClassID: wire.FeedbagClassIdGroup,
@@ -678,11 +678,11 @@ func TestFeedbagList_DeleteBuddy(t *testing.T) {
 
 func TestFeedbagList_PendingUpdates(t *testing.T) {
 	t.Run("empty when nothing inserted", func(t *testing.T) {
-		fl := newFeedbagList(nil, func(n int) int { return 1 })
+		fl := NewFeedbagList(nil, func(n int) int { return 1 })
 		assert.Nil(t, fl.PendingUpdates())
 	})
 	t.Run("includes inserts and updates", func(t *testing.T) {
-		fl := newFeedbagList([]wire.FeedbagItem{
+		fl := NewFeedbagList([]wire.FeedbagItem{
 			{Name: "Buddies", ClassID: wire.FeedbagClassIdGroup, GroupID: 1},
 		}, func(n int) int { return 50 })
 		_, err := fl.AddBuddy("Buddies", "alice", "", "")
@@ -696,7 +696,7 @@ func TestFeedbagList_PendingUpdates(t *testing.T) {
 		assert.Equal(t, wire.FeedbagClassIdBuddy, upserts[2].ClassID)
 	})
 	t.Run("clears after retrieval", func(t *testing.T) {
-		fl := newFeedbagList(nil, func(n int) int { return 5 })
+		fl := NewFeedbagList(nil, func(n int) int { return 5 })
 		fl.AddGroup("Buddies")
 		assert.Len(t, fl.PendingUpdates(), 2)
 		assert.Nil(t, fl.PendingUpdates())
@@ -705,12 +705,12 @@ func TestFeedbagList_PendingUpdates(t *testing.T) {
 
 func TestFeedbagList_PendingDeletes(t *testing.T) {
 	t.Run("empty when nothing deleted", func(t *testing.T) {
-		fl := newFeedbagList(nil, nil)
+		fl := NewFeedbagList(nil, nil)
 		assert.Nil(t, fl.PendingDeletes())
 	})
 
 	t.Run("clears after retrieval", func(t *testing.T) {
-		fl := newFeedbagList([]wire.FeedbagItem{
+		fl := NewFeedbagList([]wire.FeedbagItem{
 			{Name: "alice", ClassID: wire.FeedbagClassIDPermit, ItemID: 1},
 		}, nil)
 		fl.deleteItem(wire.FeedbagItem{Name: "alice", ClassID: wire.FeedbagClassIDPermit, ItemID: 1})
@@ -721,7 +721,7 @@ func TestFeedbagList_PendingDeletes(t *testing.T) {
 
 func TestFeedbagList_PendingUpdates_upsertsOnly(t *testing.T) {
 	t.Run("tracks upserted items", func(t *testing.T) {
-		fl := newFeedbagList(nil, func(n int) int { return 5 })
+		fl := NewFeedbagList(nil, func(n int) int { return 5 })
 		result, inserted := fl.upsertItem(wire.FeedbagItem{ClassID: wire.FeedbagClassIDPermit, Name: "alice"})
 		assert.True(t, inserted)
 
@@ -732,14 +732,14 @@ func TestFeedbagList_PendingUpdates_upsertsOnly(t *testing.T) {
 	})
 
 	t.Run("clears after retrieval", func(t *testing.T) {
-		fl := newFeedbagList(nil, func(n int) int { return 5 })
+		fl := NewFeedbagList(nil, func(n int) int { return 5 })
 		fl.upsertItem(wire.FeedbagItem{ClassID: wire.FeedbagClassIDPermit})
 		assert.Len(t, fl.PendingUpdates(), 1)
 		assert.Nil(t, fl.PendingUpdates())
 	})
 
 	t.Run("multiple inserts accumulate", func(t *testing.T) {
-		fl := newFeedbagList(nil, func(n int) int { return 10 })
+		fl := NewFeedbagList(nil, func(n int) int { return 10 })
 		fl.upsertItem(wire.FeedbagItem{ClassID: wire.FeedbagClassIDPermit, Name: "alice"})
 		fl.upsertItem(wire.FeedbagItem{ClassID: wire.FeedbagClassIDPermit, Name: "bob"})