Route network and audio callbacks through a bounded UI queue. Protocol handlers, the audio thread, and key handlers all drew to termbox widgets directly, which is a data race against the render loop. postUI is now the only path from a callback to a widget, work is dropped during shutdown, and the queue never blocks the caller. Guard the mutable client state with mutexes. Connection and transmission flags, the selected user, the muted channel set, and the audio stream pointer were each read and written from at least two goroutines. Lookups that walk the client's user and channel maps take the client lock and copy what they need. Snapshot the display string when a tree item is built. Tree items held live pointers and formatted themselves during rendering, so a user removed between rebuild and draw was dereferenced on the render path. Cancel reconnect retries on shutdown and release audio before reconnecting. Quitting during a retry left the goroutine sleeping until its timer expired, and a reconnect built a second OpenAL stream on top of the first. Cleanup is idempotent so repeated disconnect events are safe. Make UI shutdown idempotent and join the event poller. Close could be called more than once, and the polling goroutine could be left trying to deliver an event after Run had returned. Run now also reports a termbox initialization failure instead of returning nil. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
120 lines
3.6 KiB
Go
120 lines
3.6 KiB
Go
package main
|
|
|
|
import (
|
|
"testing"
|
|
|
|
"git.stormux.org/storm/barnard/gumble/gumble"
|
|
"git.stormux.org/storm/barnard/uiterm"
|
|
)
|
|
|
|
// Regression: admin lookup helpers read Client.Users and Client.Channels while
|
|
// TCP handlers could mutate those maps.
|
|
func TestAdminLookupUsesClientSnapshot(t *testing.T) {
|
|
user := &gumble.User{Session: 7, Name: "Guest"}
|
|
channel := &gumble.Channel{ID: 4, Name: "Room"}
|
|
b := &Barnard{Client: &gumble.Client{Users: gumble.Users{7: user}, Channels: gumble.Channels{4: channel}}}
|
|
if b.findUser("guest") != user || b.findUser("7") != user {
|
|
t.Fatal("user lookup failed")
|
|
}
|
|
if b.findChannel("room") != channel || b.findChannel("4") != channel {
|
|
t.Fatal("channel lookup failed")
|
|
}
|
|
}
|
|
|
|
func TestParseToggleState(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
text string
|
|
current bool
|
|
want bool
|
|
wantOK bool
|
|
}{
|
|
{name: "toggle true", text: "toggle", current: true, want: false, wantOK: true},
|
|
{name: "empty toggles", text: "", current: false, want: true, wantOK: true},
|
|
{name: "on", text: "on", current: false, want: true, wantOK: true},
|
|
{name: "off", text: "off", current: true, want: false, wantOK: true},
|
|
{name: "bad", text: "maybe", current: true, wantOK: false},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
got, ok := parseToggleState(tt.text, tt.current)
|
|
if ok != tt.wantOK {
|
|
t.Fatalf("expected ok %v, got %v", tt.wantOK, ok)
|
|
}
|
|
if ok && got != tt.want {
|
|
t.Fatalf("expected %v, got %v", tt.want, got)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestParsePermission(t *testing.T) {
|
|
tests := []struct {
|
|
text string
|
|
want gumble.Permission
|
|
}{
|
|
{text: "write", want: gumble.PermissionWrite},
|
|
{text: "mute_deafen", want: gumble.PermissionMuteDeafen},
|
|
{text: "kick", want: gumble.PermissionKick},
|
|
{text: "register_self", want: gumble.PermissionRegisterSelf},
|
|
}
|
|
for _, tt := range tests {
|
|
got, ok := parsePermission(tt.text)
|
|
if !ok {
|
|
t.Fatalf("expected %s to parse", tt.text)
|
|
}
|
|
if got != tt.want {
|
|
t.Fatalf("expected %v, got %v", tt.want, got)
|
|
}
|
|
}
|
|
if _, ok := parsePermission("bogus"); ok {
|
|
t.Fatal("expected bogus permission to fail")
|
|
}
|
|
}
|
|
|
|
func TestPermissionList(t *testing.T) {
|
|
got := permissionList(gumble.PermissionWrite | gumble.PermissionBan)
|
|
if got != "write,ban" {
|
|
t.Fatalf("expected write,ban, got %q", got)
|
|
}
|
|
}
|
|
|
|
// Regression: negative manual-ban minutes were cast to an unsigned protocol
|
|
// duration, turning a rejected short ban into an extremely long one.
|
|
func TestManualBanDurationRejectsNegativeMinutes(t *testing.T) {
|
|
if _, err := manualBanDuration("-1"); err == nil {
|
|
t.Fatal("negative duration was accepted")
|
|
}
|
|
if _, err := manualBanDuration("9223372036854775807"); err == nil {
|
|
t.Fatal("overflowing duration was accepted")
|
|
}
|
|
got, err := manualBanDuration("15")
|
|
if err != nil || got != 15*60*1000000000 {
|
|
t.Fatalf("got %v, %v", got, err)
|
|
}
|
|
}
|
|
|
|
func TestAdminEscapeInputs(t *testing.T) {
|
|
if !isAdminEscapeKey(uiterm.KeyEsc) {
|
|
t.Fatal("expected escape key to close admin menu")
|
|
}
|
|
if !isAdminEscapeKey(uiterm.KeyAltEsc) {
|
|
t.Fatal("expected alt escape key to close admin menu")
|
|
}
|
|
if !isAdminEscapeKey(uiterm.KeyAltArrowUp) {
|
|
t.Fatal("expected escape-prefixed up arrow to close admin menu")
|
|
}
|
|
if !isAdminEscapeKey(uiterm.KeyAltArrowDown) {
|
|
t.Fatal("expected escape-prefixed down arrow to close admin menu")
|
|
}
|
|
if isAdminEscapeKey(uiterm.KeyEnter) {
|
|
t.Fatal("expected enter key not to close admin menu")
|
|
}
|
|
if !isAdminEscapeCharacter(rune(uiterm.KeyEsc)) {
|
|
t.Fatal("expected escape character to close admin menu")
|
|
}
|
|
if isAdminEscapeCharacter('x') {
|
|
t.Fatal("expected non-escape character not to close admin menu")
|
|
}
|
|
}
|