Add tests and multi-platform keybind support (Linux, macOS, Windows)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+268
@@ -0,0 +1,268 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/bwmarrin/discordgo"
|
||||
"golang.design/x/hotkey" //nolint:typecheck — key constants are platform-specific but names are identical
|
||||
)
|
||||
|
||||
// --- parseKeybind ---
|
||||
|
||||
func TestParseKeybind(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
numMods int
|
||||
key hotkey.Key
|
||||
wantErr bool
|
||||
}{
|
||||
{"m", 0, hotkey.KeyM, false},
|
||||
{"f9", 0, hotkey.KeyF9, false},
|
||||
{"ctrl+m", 1, hotkey.KeyM, false},
|
||||
{"ctrl+shift+m", 2, hotkey.KeyM, false},
|
||||
{"ctrl+alt+delete", 2, hotkey.KeyDelete, false},
|
||||
{"alt+f4", 1, hotkey.KeyF4, false},
|
||||
{"CTRL+SHIFT+M", 2, hotkey.KeyM, false},
|
||||
{"ctrl+enter", 1, hotkey.KeyReturn, false},
|
||||
{"ctrl+return", 1, hotkey.KeyReturn, false},
|
||||
{"ctrl+esc", 1, hotkey.KeyEscape, false},
|
||||
{"ctrl+escape", 1, hotkey.KeyEscape, false},
|
||||
{"win+l", 1, hotkey.KeyL, false},
|
||||
{"super+l", 1, hotkey.KeyL, false},
|
||||
{"ctrl+shift+badkey", 0, 0, true},
|
||||
{"badmod+m", 0, 0, true},
|
||||
{"", 0, 0, true},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.input, func(t *testing.T) {
|
||||
mods, key, err := parseKeybind(tt.input)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Fatalf("parseKeybind(%q) error = %v, wantErr = %v", tt.input, err, tt.wantErr)
|
||||
}
|
||||
if tt.wantErr {
|
||||
return
|
||||
}
|
||||
if len(mods) != tt.numMods {
|
||||
t.Errorf("got %d modifier(s), want %d", len(mods), tt.numMods)
|
||||
}
|
||||
if key != tt.key {
|
||||
t.Errorf("key = %v, want %v", key, tt.key)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// --- loadConfig ---
|
||||
|
||||
func TestLoadConfig(t *testing.T) {
|
||||
t.Run("valid full config", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
want := Config{
|
||||
Token: "tok", GuildID: "g1", ChannelID: "c1",
|
||||
Keybind: "ctrl+shift+m", ExemptUsers: []string{"u1", "u2"},
|
||||
}
|
||||
write(t, dir, want)
|
||||
got, err := loadConfig(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.Token != want.Token || got.GuildID != want.GuildID ||
|
||||
got.ChannelID != want.ChannelID || got.Keybind != want.Keybind {
|
||||
t.Errorf("got %+v, want %+v", got, want)
|
||||
}
|
||||
if len(got.ExemptUsers) != 2 || got.ExemptUsers[0] != "u1" || got.ExemptUsers[1] != "u2" {
|
||||
t.Errorf("exempt_users = %v, want [u1 u2]", got.ExemptUsers)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("no exempt_users field", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
os.WriteFile(filepath.Join(dir, "config.json"),
|
||||
[]byte(`{"token":"t","guild_id":"g","channel_id":"c","keybind":"a"}`), 0644)
|
||||
got, err := loadConfig(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got.ExemptUsers) != 0 {
|
||||
t.Errorf("expected empty exempt_users, got %v", got.ExemptUsers)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("invalid json", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
os.WriteFile(filepath.Join(dir, "config.json"), []byte(`{bad`), 0644)
|
||||
if _, err := loadConfig(dir); err == nil {
|
||||
t.Fatal("expected error for invalid JSON")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("file not found", func(t *testing.T) {
|
||||
if _, err := loadConfig(t.TempDir()); err == nil {
|
||||
t.Fatal("expected error when config.json is missing")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("falls back to second dir", func(t *testing.T) {
|
||||
empty := t.TempDir()
|
||||
dir := t.TempDir()
|
||||
write(t, dir, Config{Token: "fallback"})
|
||||
got, err := loadConfig(empty, dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.Token != "fallback" {
|
||||
t.Errorf("token = %q, want \"fallback\"", got.Token)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func write(t *testing.T, dir string, cfg Config) {
|
||||
t.Helper()
|
||||
data, err := json.Marshal(cfg)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "config.json"), data, 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// --- toggleMute ---
|
||||
|
||||
type muteCall struct {
|
||||
userID string
|
||||
muted bool
|
||||
}
|
||||
|
||||
func TestToggleMute(t *testing.T) {
|
||||
var mu sync.Mutex
|
||||
var calls []muteCall
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPatch {
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
// path: /guilds/{guildID}/members/{userID}
|
||||
parts := strings.Split(strings.Trim(r.URL.Path, "/"), "/")
|
||||
if len(parts) < 4 {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
userID := parts[3]
|
||||
var body struct {
|
||||
Mute bool `json:"mute"`
|
||||
}
|
||||
json.NewDecoder(r.Body).Decode(&body)
|
||||
mu.Lock()
|
||||
calls = append(calls, muteCall{userID, body.Mute})
|
||||
mu.Unlock()
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
orig := discordgo.EndpointGuilds
|
||||
discordgo.EndpointGuilds = srv.URL + "/guilds/"
|
||||
defer func() { discordgo.EndpointGuilds = orig }()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
voices []*discordgo.VoiceState
|
||||
exempt []string
|
||||
muted bool
|
||||
wantHit []string
|
||||
wantSkipped []string
|
||||
}{
|
||||
{
|
||||
name: "mutes all users in channel",
|
||||
voices: []*discordgo.VoiceState{
|
||||
{UserID: "u1", ChannelID: "chan1"},
|
||||
{UserID: "u2", ChannelID: "chan1"},
|
||||
},
|
||||
muted: true,
|
||||
wantHit: []string{"u1", "u2"},
|
||||
},
|
||||
{
|
||||
name: "ignores users in other channels",
|
||||
voices: []*discordgo.VoiceState{
|
||||
{UserID: "u1", ChannelID: "chan1"},
|
||||
{UserID: "u2", ChannelID: "other"},
|
||||
},
|
||||
muted: true,
|
||||
wantHit: []string{"u1"},
|
||||
wantSkipped: []string{"u2"},
|
||||
},
|
||||
{
|
||||
name: "skips exempt users",
|
||||
voices: []*discordgo.VoiceState{
|
||||
{UserID: "u1", ChannelID: "chan1"},
|
||||
{UserID: "exempt", ChannelID: "chan1"},
|
||||
},
|
||||
exempt: []string{"exempt"},
|
||||
muted: true,
|
||||
wantHit: []string{"u1"},
|
||||
wantSkipped: []string{"exempt"},
|
||||
},
|
||||
{
|
||||
name: "sends mute=false when unmuting",
|
||||
voices: []*discordgo.VoiceState{
|
||||
{UserID: "u1", ChannelID: "chan1"},
|
||||
},
|
||||
muted: false,
|
||||
wantHit: []string{"u1"},
|
||||
},
|
||||
{
|
||||
name: "empty channel",
|
||||
voices: []*discordgo.VoiceState{},
|
||||
muted: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
mu.Lock()
|
||||
calls = nil
|
||||
mu.Unlock()
|
||||
|
||||
dg, _ := discordgo.New("Bot test")
|
||||
dg.State.GuildAdd(&discordgo.Guild{
|
||||
ID: "guild1",
|
||||
VoiceStates: tt.voices,
|
||||
})
|
||||
|
||||
toggleMute(dg, &Config{
|
||||
GuildID: "guild1",
|
||||
ChannelID: "chan1",
|
||||
ExemptUsers: tt.exempt,
|
||||
}, tt.muted)
|
||||
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
|
||||
hit := make(map[string]bool, len(calls))
|
||||
for _, c := range calls {
|
||||
hit[c.userID] = true
|
||||
if c.muted != tt.muted {
|
||||
t.Errorf("user %s: mute flag = %v, want %v", c.userID, c.muted, tt.muted)
|
||||
}
|
||||
}
|
||||
for _, uid := range tt.wantHit {
|
||||
if !hit[uid] {
|
||||
t.Errorf("expected request for user %s, got none", uid)
|
||||
}
|
||||
}
|
||||
for _, uid := range tt.wantSkipped {
|
||||
if hit[uid] {
|
||||
t.Errorf("user %s should have been skipped but was called", uid)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user