263 lines
8.4 KiB
Go
263 lines
8.4 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"
|
|
ningenEmoji "github.com/ayn2op/ningen/v3/states/emoji"
|
|
"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)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestModelEmojiSuggest(t *testing.T) {
|
|
c, _ := newTestModel(t)
|
|
channel := discord.Channel{ID: 1, Type: discord.DirectMessage}
|
|
c.SetChannel(&channel)
|
|
c.setText(":smi", 4)
|
|
c.tabSuggest()
|
|
if got := c.emojisList.ItemCount(); got == 0 {
|
|
t.Fatal("expected emoji suggestions for ':smi', got none")
|
|
}
|
|
if !c.emojisListVisible() {
|
|
t.Fatal("expected emojisListVisible to be true")
|
|
}
|
|
if c.mentionsListVisible() {
|
|
t.Fatal("expected mentionsListVisible to be false")
|
|
}
|
|
}
|
|
|
|
func TestModelEmojiComplete(t *testing.T) {
|
|
c, _ := newTestModel(t)
|
|
channel := discord.Channel{ID: 1, Type: discord.DirectMessage}
|
|
c.SetChannel(&channel)
|
|
c.setText("hello :smile", 12)
|
|
c.tabSuggest()
|
|
if !c.emojisListVisible() {
|
|
t.Fatal("expected emojis list visible")
|
|
}
|
|
c.tabComplete()
|
|
if got := c.text; got != "hello 😄 " {
|
|
t.Fatalf("text after completion = %q, want 'hello 😄 '", got)
|
|
}
|
|
if c.emojisListVisible() {
|
|
t.Fatal("expected emojis list to be hidden after completion")
|
|
}
|
|
}
|
|
|
|
func TestModelCustomEmojiComplete(t *testing.T) {
|
|
c, _ := newTestModel(t)
|
|
guildID := discord.GuildID(100)
|
|
channel := discord.Channel{ID: 10, GuildID: guildID, Type: discord.GuildText}
|
|
c.state.EmojiState = ningenEmoji.NewState(c.state.Cabinet)
|
|
_ = c.state.Cabinet.GuildSet(&discord.Guild{ID: guildID}, false)
|
|
_ = c.state.Cabinet.EmojiSet(guildID, []discord.Emoji{
|
|
{ID: 555, Name: "custom_pepe"},
|
|
}, false)
|
|
|
|
c.SetChannel(&channel)
|
|
c.setText(":custom_pepe", 12)
|
|
c.tabSuggest()
|
|
if !c.emojisListVisible() {
|
|
t.Fatal("expected emojis list visible")
|
|
}
|
|
c.tabComplete()
|
|
if got := c.text; got != "<:custom_pepe:555> " {
|
|
t.Fatalf("text after completion = %q, want '<:custom_pepe:555> '", got)
|
|
}
|
|
if c.emojisListVisible() {
|
|
t.Fatal("expected emojis list to be hidden after completion")
|
|
}
|
|
}
|