391 lines
11 KiB
Go
391 lines
11 KiB
Go
package main
|
|
|
|
import (
|
|
"fmt"
|
|
"io"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
"unicode/utf8"
|
|
|
|
"git.stormux.org/storm/barnard/gumble/gumble"
|
|
"git.stormux.org/storm/barnard/gumble/gumbleopenal"
|
|
)
|
|
|
|
// Regression: HTML escaping left terminal control sequences in server text,
|
|
// allowing ANSI/OSC sequences to alter the terminal that rendered it.
|
|
// Regression: a capture-device open error left the application alive with no
|
|
// usable microphone. These errors are fatal and use the post-TUI stderr path.
|
|
func TestFatalAudioOpenError(t *testing.T) {
|
|
for _, err := range []error{gumbleopenal.ErrMic, gumbleopenal.ErrInputDevice, gumbleopenal.ErrOutputDevice, fmt.Errorf("wrapped: %w", gumbleopenal.ErrMic)} {
|
|
if !fatalAudioOpenError(err) {
|
|
t.Fatalf("%v was not fatal", err)
|
|
}
|
|
}
|
|
if fatalAudioOpenError(gumbleopenal.ErrState) {
|
|
t.Fatal("state error should remain recoverable")
|
|
}
|
|
}
|
|
|
|
func TestEscRemovesTerminalControlSequences(t *testing.T) {
|
|
got := esc("name\x1b]0;spoof\a\x7f\u202e")
|
|
if got != "name]0;spoof" {
|
|
t.Fatalf("unsafe terminal text %q", got)
|
|
}
|
|
}
|
|
|
|
type testReadCloser struct{ io.Reader }
|
|
|
|
func (testReadCloser) Close() error { return nil }
|
|
|
|
// Regression: an EOF from the FIFO was ignored and caused an unbounded busy
|
|
// loop. The reader must deliver a final command then close its output.
|
|
func TestReadFIFOStopsOnEOF(t *testing.T) {
|
|
out := make(chan string)
|
|
go readFIFO(testReadCloser{strings.NewReader("command\n")}, out)
|
|
if got := <-out; got != "command" {
|
|
t.Fatalf("got %q", got)
|
|
}
|
|
if _, ok := <-out; ok {
|
|
t.Fatal("FIFO output remained open after EOF")
|
|
}
|
|
}
|
|
|
|
// Regression: sequential substitutions re-expanded placeholders embedded in
|
|
// server-provided fields, and a slow notifier blocked callback goroutines.
|
|
func TestNotificationExpansionIsSinglePassAndNotifyDoesNotBlock(t *testing.T) {
|
|
got := expandNotification("%event %what", []string{"event", "who", "%event"})
|
|
if got != "event %event" {
|
|
t.Fatalf("unexpected expansion %q", got)
|
|
}
|
|
b := &Barnard{notifyChannel: make(chan []string, 1)}
|
|
b.Notify("one", "", "")
|
|
done := make(chan struct{})
|
|
go func() { b.Notify("two", "", ""); close(done) }()
|
|
select {
|
|
case <-done:
|
|
case <-time.After(time.Second):
|
|
t.Fatal("Notify blocked on a full queue")
|
|
}
|
|
}
|
|
|
|
func TestAudioIntervalDuration(t *testing.T) {
|
|
for _, milliseconds := range []int{10, 20, 40, 60} {
|
|
got, err := audioIntervalDuration(milliseconds)
|
|
if err != nil {
|
|
t.Errorf("audioIntervalDuration(%d): %v", milliseconds, err)
|
|
continue
|
|
}
|
|
if got != time.Duration(milliseconds)*time.Millisecond {
|
|
t.Errorf("audioIntervalDuration(%d) = %v", milliseconds, got)
|
|
}
|
|
}
|
|
if _, err := audioIntervalDuration(30); err == nil {
|
|
t.Fatal("audioIntervalDuration accepted unsupported duration")
|
|
}
|
|
}
|
|
|
|
func TestJitterBufferDuration(t *testing.T) {
|
|
for _, milliseconds := range []int{0, 20, 40, 60} {
|
|
got, err := jitterBufferDuration(milliseconds)
|
|
if err != nil {
|
|
t.Errorf("jitterBufferDuration(%d): %v", milliseconds, err)
|
|
continue
|
|
}
|
|
if got != time.Duration(milliseconds)*time.Millisecond {
|
|
t.Errorf("jitterBufferDuration(%d) = %v", milliseconds, got)
|
|
}
|
|
}
|
|
if _, err := jitterBufferDuration(10); err == nil {
|
|
t.Fatal("jitterBufferDuration accepted unsupported duration")
|
|
}
|
|
}
|
|
|
|
func TestServerAddressDefaultsPortWithoutBreakingIPv6(t *testing.T) {
|
|
for input, want := range map[string]string{
|
|
"server": "server:64738",
|
|
"server:64739": "server:64739",
|
|
"::1": "[::1]:64738",
|
|
"[2001:db8::1]": "[2001:db8::1]:64738",
|
|
"[2001:db8::1]:9": "[2001:db8::1]:9",
|
|
} {
|
|
if got := serverAddress(input); got != want {
|
|
t.Errorf("serverAddress(%q) = %q, want %q", input, got, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestConcurrentConnectionStateAccess(t *testing.T) {
|
|
b := &Barnard{}
|
|
var wg sync.WaitGroup
|
|
for i := 0; i < 20; i++ {
|
|
wg.Add(1)
|
|
go func(value bool) {
|
|
defer wg.Done()
|
|
b.setConnected(value)
|
|
b.setTransmitting(value)
|
|
_ = b.isConnected()
|
|
_ = b.isTransmitting()
|
|
}(i%2 == 0)
|
|
}
|
|
wg.Wait()
|
|
}
|
|
|
|
func TestConcurrentSelectedUserAccess(t *testing.T) {
|
|
b := &Barnard{}
|
|
user := &gumble.User{Session: 1}
|
|
var wg sync.WaitGroup
|
|
for i := 0; i < 20; i++ {
|
|
wg.Add(1)
|
|
go func(user *gumble.User) {
|
|
defer wg.Done()
|
|
b.setSelectedUserValue(user)
|
|
_ = b.selectedUserValue()
|
|
}(user)
|
|
}
|
|
wg.Wait()
|
|
}
|
|
|
|
func TestConcurrentMutedChannelAccess(t *testing.T) {
|
|
b := &Barnard{MutedChannels: make(map[uint32]bool)}
|
|
var wg sync.WaitGroup
|
|
for i := 0; i < 20; i++ {
|
|
wg.Add(1)
|
|
go func(i int) {
|
|
defer wg.Done()
|
|
b.setChannelMuted(uint32(i%3), i%2 == 0)
|
|
_ = b.isChannelMuted(uint32((i + 1) % 3))
|
|
}(i)
|
|
}
|
|
wg.Wait()
|
|
}
|
|
|
|
func TestPublicTextMessageTargetsChannelIDsAndTrees(t *testing.T) {
|
|
root := &gumble.Channel{ID: 1, Name: "Root"}
|
|
current := &gumble.Channel{ID: 2, Name: "Room", Parent: root}
|
|
b := &Barnard{Client: &gumble.Client{Self: &gumble.User{Channel: current}}}
|
|
|
|
if !b.isPublicTextMessage(&gumble.TextMessageEvent{TextMessage: gumble.TextMessage{Trees: []*gumble.Channel{root}}}) {
|
|
t.Fatal("recursive message to an ancestor was not public")
|
|
}
|
|
other := &gumble.Channel{ID: 3, Name: "Room"}
|
|
if b.isPublicTextMessage(&gumble.TextMessageEvent{TextMessage: gumble.TextMessage{Channels: []*gumble.Channel{other}}}) {
|
|
t.Fatal("message to a different channel with the same name was public")
|
|
}
|
|
}
|
|
|
|
func TestPublicServerMessageDoesNotPanic(t *testing.T) {
|
|
channel := &gumble.Channel{ID: 1, Name: "Current"}
|
|
b := &Barnard{
|
|
Client: &gumble.Client{Self: &gumble.User{Channel: channel}},
|
|
notifyChannel: make(chan []string, 1),
|
|
}
|
|
|
|
b.OnTextMessage(&gumble.TextMessageEvent{
|
|
Client: b.Client,
|
|
TextMessage: gumble.TextMessage{
|
|
Channels: []*gumble.Channel{channel},
|
|
Message: "server announcement",
|
|
},
|
|
})
|
|
|
|
got := <-b.notifyChannel
|
|
want := []string{"msg", "Server", "server announcement"}
|
|
if len(got) != len(want) || got[0] != want[0] || got[1] != want[1] || got[2] != want[2] {
|
|
t.Fatalf("notification = %#v, want %#v", got, want)
|
|
}
|
|
}
|
|
|
|
// Regression: reconnect replaced Stream without destroying the old capture
|
|
// and renderer resources. Cleanup must be safe for repeated disconnects.
|
|
// Regression: user and channel names in the navigation tree bypassed message
|
|
// escaping and could still carry terminal control characters.
|
|
// Regression: status truncation used byte indexes and could create invalid
|
|
// UTF-8 when a non-ASCII user or channel name exceeded the display limit.
|
|
func TestTruncateInputStatusPreservesUTF8(t *testing.T) {
|
|
got := truncateInputStatus(strings.Repeat("é", 21))
|
|
if !utf8.ValidString(got) || utf8.RuneCountInString(got) != 21 {
|
|
t.Fatalf("invalid truncation %q", got)
|
|
}
|
|
}
|
|
|
|
func TestTreeItemSanitizesServerNames(t *testing.T) {
|
|
item := TreeItem{Channel: &gumble.Channel{Name: "\x1b[2Jroom"}}
|
|
if got := item.String(); got != "#[2Jroom" {
|
|
t.Fatalf("got %q", got)
|
|
}
|
|
}
|
|
|
|
func TestCleanupConnectionAudioIsIdempotent(t *testing.T) {
|
|
b := &Barnard{}
|
|
b.cleanupConnectionAudio()
|
|
b.cleanupConnectionAudio()
|
|
}
|
|
|
|
// Tone test mode intentionally does not create an OpenAL stream. Tree
|
|
// controls must therefore keep local mute state without trying to update one.
|
|
func TestReconnectCancellationIsSafeBeforeStartup(t *testing.T) {
|
|
b := &Barnard{}
|
|
b.stopReconnects()
|
|
if b.reconnectCanceled() {
|
|
t.Fatal("nil reconnect channel should not report cancellation")
|
|
}
|
|
}
|
|
|
|
func TestReconnectCancellationStopsWaiters(t *testing.T) {
|
|
b := &Barnard{reconnectStop: make(chan struct{})}
|
|
b.stopReconnects()
|
|
if !b.reconnectCanceled() {
|
|
t.Fatal("expected reconnect cancellation")
|
|
}
|
|
b.stopReconnects() // repeated shutdown must not panic
|
|
}
|
|
|
|
func TestUpdateUserGainAllowsToneTestWithoutStream(t *testing.T) {
|
|
(&Barnard{ToneTest: true}).updateUserGain(&gumble.User{})
|
|
}
|
|
|
|
func TestToneTestRejectsFilePlayback(t *testing.T) {
|
|
(&Barnard{ToneTest: true, Connected: true}).CommandPlayFile(nil, "https://example.invalid/audio")
|
|
}
|
|
|
|
func TestUserChangeNotification(t *testing.T) {
|
|
current := &gumble.Channel{ID: 1, Name: "Current"}
|
|
other := &gumble.Channel{ID: 2, Name: "Other"}
|
|
self := &gumble.User{Session: 1, Name: "Username", Channel: current}
|
|
|
|
tests := []struct {
|
|
name string
|
|
user *gumble.User
|
|
previous *gumble.Channel
|
|
change gumble.UserChangeType
|
|
want userChangeNotification
|
|
wantOK bool
|
|
}{
|
|
{
|
|
name: "connected to current channel",
|
|
user: &gumble.User{Session: 2, Name: "Guest", Channel: current},
|
|
change: gumble.UserChangeConnected,
|
|
want: userChangeNotification{
|
|
event: "join",
|
|
who: "Guest",
|
|
what: "Current",
|
|
line: "Guest joined Current",
|
|
},
|
|
wantOK: true,
|
|
},
|
|
{
|
|
name: "disconnected from current channel",
|
|
user: &gumble.User{Session: 2, Name: "Guest", Channel: current},
|
|
previous: current,
|
|
change: gumble.UserChangeDisconnected,
|
|
want: userChangeNotification{
|
|
event: "leave",
|
|
who: "Guest",
|
|
what: "Current",
|
|
line: "Guest left Current",
|
|
},
|
|
wantOK: true,
|
|
},
|
|
{
|
|
name: "moved into current channel",
|
|
user: &gumble.User{Session: 2, Name: "Guest", Channel: current},
|
|
previous: other,
|
|
change: gumble.UserChangeChannel,
|
|
want: userChangeNotification{
|
|
event: "join",
|
|
who: "Guest",
|
|
what: "Current",
|
|
line: "Guest joined Current",
|
|
},
|
|
wantOK: true,
|
|
},
|
|
{
|
|
name: "moved out of current channel",
|
|
user: &gumble.User{Session: 2, Name: "Guest", Channel: other},
|
|
previous: current,
|
|
change: gumble.UserChangeChannel,
|
|
want: userChangeNotification{
|
|
event: "leave",
|
|
who: "Guest",
|
|
what: "Current",
|
|
line: "Guest left Current",
|
|
},
|
|
wantOK: true,
|
|
},
|
|
{
|
|
name: "connected to other channel",
|
|
user: &gumble.User{Session: 2, Name: "Guest", Channel: other},
|
|
change: gumble.UserChangeConnected,
|
|
},
|
|
{
|
|
name: "moved between other channels",
|
|
user: &gumble.User{Session: 2, Name: "Guest", Channel: other},
|
|
previous: &gumble.Channel{ID: 3, Name: "Elsewhere"},
|
|
change: gumble.UserChangeChannel,
|
|
},
|
|
{
|
|
name: "self channel move",
|
|
user: self,
|
|
previous: other,
|
|
change: gumble.UserChangeChannel,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
b := &Barnard{
|
|
Client: &gumble.Client{Self: self},
|
|
}
|
|
if tt.previous != nil {
|
|
b.userChannels = map[uint32]*gumble.Channel{tt.user.Session: tt.previous}
|
|
}
|
|
|
|
got, ok := b.userChangeNotification(&gumble.UserChangeEvent{
|
|
Client: b.Client,
|
|
Type: tt.change,
|
|
User: tt.user,
|
|
})
|
|
if ok != tt.wantOK {
|
|
t.Fatalf("expected ok %v, got %v", tt.wantOK, ok)
|
|
}
|
|
if got != tt.want {
|
|
t.Fatalf("expected %#v, got %#v", tt.want, got)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestUpdateUserChannel(t *testing.T) {
|
|
current := &gumble.Channel{ID: 1, Name: "Current"}
|
|
other := &gumble.Channel{ID: 2, Name: "Other"}
|
|
user := &gumble.User{Session: 2, Name: "Guest", Channel: current}
|
|
b := &Barnard{}
|
|
|
|
b.updateUserChannel(&gumble.UserChangeEvent{
|
|
Type: gumble.UserChangeConnected,
|
|
User: user,
|
|
})
|
|
if got := b.previousUserChannel(user); got != current {
|
|
t.Fatalf("expected current channel to be remembered, got %#v", got)
|
|
}
|
|
|
|
user.Channel = other
|
|
b.updateUserChannel(&gumble.UserChangeEvent{
|
|
Type: gumble.UserChangeChannel,
|
|
User: user,
|
|
})
|
|
if got := b.previousUserChannel(user); got != other {
|
|
t.Fatalf("expected other channel to be remembered, got %#v", got)
|
|
}
|
|
|
|
b.updateUserChannel(&gumble.UserChangeEvent{
|
|
Type: gumble.UserChangeDisconnected,
|
|
User: user,
|
|
})
|
|
if got := b.previousUserChannel(user); got != nil {
|
|
t.Fatalf("expected disconnected user channel to be removed, got %#v", got)
|
|
}
|
|
}
|