discordo/internal/ui/chat/composer/model_test.go
2026-10-15 21:00:02 -04:00

202 lines
6.6 KiB
Go

package composer
import (
"maps"
"path/filepath"
"testing"
"github.com/ayn2op/arikawa/v3/discord"
"github.com/ayn2op/arikawa/v3/gateway"
"github.com/ayn2op/arikawa/v3/state"
"github.com/ayn2op/arikawa/v3/state/store/defaultstore"
"github.com/ayn2op/arikawa/v3/utils/sendpart"
"github.com/ayn2op/discordo/internal/config"
"github.com/ayn2op/ningen/v3"
"github.com/ayn2op/tview"
"github.com/ayn2op/tview/keybind"
"github.com/gdamore/tcell/v3"
)
// newTestModel returns an enabled composer with the default config.
func newTestModel(t *testing.T) (*Model, *config.Config) {
t.Helper()
cfg, err := config.Load(filepath.Join(t.TempDir(), "config.toml"))
if err != nil {
t.Fatal(err)
}
c := NewModel(cfg, ningen.FromState(state.New("")))
c.disabled = false
return &c, cfg
}
func TestModelSetChannel(t *testing.T) {
// Everyone in guild 1 may send in threads, but not in channels.
cfg, err := config.Load(filepath.Join(t.TempDir(), "config.toml"))
if err != nil {
t.Fatal(err)
}
state := ningen.FromState(state.NewWithStore("", defaultstore.New()))
channel := discord.Channel{ID: 10, GuildID: 1}
thread := discord.Channel{ID: 11, GuildID: 1, ParentID: 10, Type: discord.GuildPublicThread}
for _, err := range []error{
state.MyselfSet(discord.User{ID: 3}, false),
state.GuildSet(&discord.Guild{ID: 1}, false),
state.MemberSet(1, &discord.Member{User: discord.User{ID: 3}}, false),
state.RoleSet(1, &discord.Role{ID: 1, Permissions: discord.PermissionSendMessagesInThreads}, false),
state.ChannelSet(&channel, false),
state.ChannelSet(&thread, false),
} {
if err != nil {
t.Fatal(err)
}
}
c := NewModel(cfg, state)
for name, tt := range map[string]struct {
channel discord.Channel
disabled bool
}{
"a channel that cannot be sent in disables it": {channel, true},
"a thread that can be sent in enables it": {thread, false},
} {
t.Run(name, func(t *testing.T) {
if c.SetChannel(&tt.channel); c.Disabled() != tt.disabled {
t.Fatalf("disabled = %v, want %v", c.Disabled(), tt.disabled)
}
})
}
}
func TestModelUpdate(t *testing.T) {
up := tcell.NewEventKey(tcell.KeyUp, "", tcell.ModNone)
for _, tt := range []struct {
name string
setup func(*Model, *config.Config) tview.KeyMsg
want bool
}{
{"empty", func(*Model, *config.Config) tview.KeyMsg { return up }, true},
{"draft", func(c *Model, _ *config.Config) tview.KeyMsg { c.setText("draft", len("draft")); return up }, false},
{"reply", func(c *Model, _ *config.Config) tview.KeyMsg {
c.StartReply(discord.Message{ID: 3}, "name", false)
return up
}, false},
{"attachment", func(c *Model, _ *config.Config) tview.KeyMsg {
c.sendMessageData.Files = []sendpart.File{{Name: "file"}}
return up
}, false},
{"editing", func(c *Model, _ *config.Config) tview.KeyMsg { c.StartEdit(discord.Message{}); return up }, false},
{"disabled", func(c *Model, _ *config.Config) tview.KeyMsg { c.disabled = true; return up }, false},
{"remapped", func(_ *Model, cfg *config.Config) tview.KeyMsg {
cfg.Keybinds.Composer.EditLast.Keybind = keybind.New("ctrl+p")
return tcell.NewEventKey(tcell.KeyCtrlP, "", tcell.ModNone)
}, true},
{"unbound", func(_ *Model, cfg *config.Config) tview.KeyMsg {
cfg.Keybinds.Composer.EditLast.Keybind = keybind.New()
return up
}, false},
} {
t.Run("edit last message: "+tt.name, func(t *testing.T) {
c, cfg := newTestModel(t)
// setup changes c, so it runs before Update copies it.
msg := tt.setup(c, cfg)
var cmd tview.Cmd
*c, cmd = c.Update(msg)
if got := cmd != nil && cmd() == (EditLastMsg{}); got != tt.want {
t.Fatalf("asked to edit the last message = %v, want %v", got, tt.want)
}
})
}
}
func TestModelHandle(t *testing.T) {
c, _ := newTestModel(t)
area := tview.Rectangle{Width: 20, Height: 3}
t.Run("typing edits the text", func(t *testing.T) {
msg := c.View(true).Handle(tcell.NewEventKey(tcell.KeyRune, "a", tcell.ModNone), area)
if _, ok := msg.(editMsg); !ok {
t.Fatalf("got %v, want an editMsg", msg)
}
*c, _ = c.Update(msg)
if c.text != "a" {
t.Fatalf("text = %q", c.text)
}
})
t.Run("the newline keybind inserts a newline", func(t *testing.T) {
msg := c.View(true).Handle(tcell.NewEventKey(tcell.KeyEnter, "", tcell.ModShift), area)
if _, ok := msg.(editMsg); !ok {
t.Fatalf("got %v, want an editMsg", msg)
}
*c, _ = c.Update(msg)
if c.text != "a\n" {
t.Fatalf("text = %q", c.text)
}
})
t.Run("bound keys are left to Update", func(t *testing.T) {
enter := tcell.NewEventKey(tcell.KeyEnter, "", tcell.ModNone)
if got := c.View(true).Handle(enter, area); got != enter {
t.Fatalf("got %v", got)
}
})
}
func TestModelSearchMember(t *testing.T) {
c, _ := newTestModel(t)
c.state.MemberState.SearchLimit = 2
c.CacheMemberSearch(&gateway.GuildMembersChunkEvent{
Nonce: memberSearchNonce + "1 ab",
Members: []discord.Member{{}},
})
if cmd := c.searchMember(1, "ab"); cmd != nil {
t.Fatal("cached query returned a network command")
}
if cmd := c.searchMember(1, "abc"); cmd != nil || c.memberSearchCache["1 abc"] != 1 {
t.Fatal("incomplete prefix results were not reused")
}
c.memberSearchCache["1 a"] = 2
c.memberSearchCache["2 a"] = 2
c.OnGuildMemberRemove(&gateway.GuildMemberRemoveEvent{
GuildID: 1,
User: discord.User{Username: "abc"},
})
want := map[string]uint{"1 ab": 1, "1 abc": 1, "2 a": 2}
if !maps.Equal(c.memberSearchCache, want) {
t.Fatalf("cache after invalidation = %v, want %v", c.memberSearchCache, want)
}
}
func TestModelTabSuggest(t *testing.T) {
c, _ := newTestModel(t)
if err := c.state.MyselfSet(discord.User{ID: 1, Username: "1"}, false); err != nil {
t.Fatal(err)
}
channel := discord.Channel{ID: 1, Type: discord.DirectMessage}
for id, author := range []discord.UserID{2, 3, 2, 1} {
message := discord.Message{ID: discord.MessageID(id + 1), ChannelID: 1, Author: discord.User{ID: author, Username: author.String()}}
if err := c.state.MessageSet(&message, false); err != nil {
t.Fatal(err)
}
}
c.SetChannel(&channel)
c.setText("@", 1)
c.tabSuggest()
if got := c.mentionsList.ItemCount(); got != 2 {
t.Fatalf("suggested %d authors, want 2", got)
}
}
func TestModelProcessText(t *testing.T) {
c, _ := newTestModel(t)
channel := &discord.Channel{Type: discord.DirectMessage, DMRecipients: []discord.User{{ID: 1, Username: "alice"}, {ID: 2, Username: "bob"}}}
for _, tt := range []struct{ name, in, want string }{
{"mentions around code", "@alice `code` @bob", "<@1> `code` <@2>"},
{"mention in code", "@alice `@bob`", "<@1> `@bob`"},
} {
t.Run(tt.name, func(t *testing.T) {
if got := c.processText(channel, []byte(tt.in)); got != tt.want {
t.Fatalf("got %q, want %q", got, tt.want)
}
})
}
}