package cli import ( "io" "net/http" "net/http/httptest" "slices" "strings" "testing" "time" "github.com/gdamore/tcell/v2" api "sneak.berlin/go/neoirc/internal/cli/api" "sneak.berlin/go/neoirc/pkg/irc" ) // The user's nick, a channel it is in, another user's nick and // a message text, for the tests below. const ( testNick = "erin" testChannel = "#chan" testOtherNick = "bob" testText = "hello" ) // eventLoopTimeout is how long a test waits for a typed line to // be handled, for the welcome lines, or for the UI to stop, before // it fails. const eventLoopTimeout = 5 * time.Second // holdTime is how long TestLinesKeepOrder holds the first line, // which gives the second line time to start too early. const holdTime = 200 * time.Millisecond // welcomeLines is the number of lines run adds to the status // window when the client starts. const welcomeLines = 2 // checkInterval is how often TestStartUp reads the status window // while it waits for the welcome lines. const checkInterval = 10 * time.Millisecond func TestMemberPrefix(t *testing.T) { t.Parallel() tests := []struct { name string member api.Member want string }{ { name: "operator", member: api.Member{Nick: "alice", IsOperator: true}, want: "@", }, { name: "voiced member", member: api.Member{Nick: "bob", IsVoiced: true}, want: "+", }, { name: "voiced operator", member: api.Member{ Nick: "Carol", IsOperator: true, IsVoiced: true, }, want: "@", }, { name: "plain member", member: api.Member{Nick: "dave"}, want: "", }, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { t.Parallel() got := memberPrefix(test.member) if got != test.want { t.Fatalf("got %q, want %q", got, test.want) } }) } } // TestPartEvent checks that a PART event adds a line to the // channel's buffer and marks it unread, except that the // user's own part does not mark it unread. func TestPartEvent(t *testing.T) { t.Parallel() tests := []struct { name string from string wantUnread int }{ {name: "own part", from: testNick, wantUnread: 0}, {name: "other member", from: testOtherNick, wantUnread: 1}, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { t.Parallel() app := &App{ui: NewUI(), nick: testNick} runUI(t, app.ui) app.handleServerMessage(&api.Message{ Command: irc.CmdPart, From: test.from, To: testChannel, }) lines, unread := bufferState(app.ui, testChannel) if lines != 1 || unread != test.wantUnread { t.Fatalf("got %d lines and %d unread, want 1 and %d", lines, unread, test.wantUnread) } }) } } // TestOwnCommandShownOnce checks that a message, join or // part made from neoirc-cli shows as one line in the // channel's buffer once the server's event for it arrives, // and does not mark the channel unread. func TestOwnCommandShownOnce(t *testing.T) { t.Parallel() tests := []struct { name string send func(app *App) event api.Message }{ { name: "message", send: func(app *App) { app.handleInput(testText) }, event: api.Message{ Command: irc.CmdPrivmsg, From: testNick, To: testChannel, Body: []string{testText}, }, }, { name: "join", send: func(app *App) { app.cmdJoin(testChannel) }, event: api.Message{ Command: irc.CmdJoin, From: testNick, To: testChannel, }, }, { name: "part", send: func(app *App) { app.cmdPart(testChannel) }, event: api.Message{ Command: irc.CmdPart, From: testNick, To: testChannel, }, }, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { t.Parallel() // The server accepts every command. Only the response // to a JOIN is read, and it says the session joined. server := newServer(t, `{"status": "joined"}`) app := &App{ ui: NewUI(), client: api.NewClient(server.URL), nick: testNick, target: testChannel, connected: true, } runUI(t, app.ui) app.ui.SwitchToBuffer(testChannel) test.send(app) app.handleServerMessage(&test.event) lines, unread := bufferState(app.ui, testChannel) if lines != 1 || unread != 0 { t.Fatalf("got %d lines and %d unread, want 1 and 0", lines, unread) } }) } } // TestJoinResponse checks that /join switches to the channel's // buffer and makes it the target only when the server answers // that the session joined. func TestJoinResponse(t *testing.T) { t.Parallel() tests := []struct { name string response string // The buffer shown and the target after the join. want string }{ { name: "joined", response: `{"status": "joined", "channel": "#chan"}`, want: testChannel, }, { name: "refused", response: `{"status": "error"}`, want: testOtherNick, }, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { t.Parallel() app := &App{ ui: NewUI(), client: api.NewClient(newServer(t, test.response).URL), nick: testNick, target: testOtherNick, connected: true, } runUI(t, app.ui) app.ui.SwitchToBuffer(testOtherNick) app.cmdJoin(testChannel) var shown string app.ui.app.QueueUpdate(func() { shown = app.ui.buffers[app.ui.currentBuffer].Name }) if shown != test.want || app.target != test.want { t.Fatalf("got buffer %s and target %s, want %s", shown, app.target, test.want) } }) } } // TestAltDigit checks that Alt and a digit shows that window and // makes its channel or user the target, as /window does. Window // 0, the status window, leaves no target. func TestAltDigit(t *testing.T) { t.Parallel() tests := []struct { name string digit rune // The buffer shown and the target after the key. wantBuffer string wantTarget string }{ { name: "channel window", digit: '1', wantBuffer: testChannel, wantTarget: testChannel, }, { name: "status window", digit: '0', wantBuffer: "(status)", wantTarget: "", }, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { t.Parallel() app := &App{ui: NewUI(), nick: testNick, target: testOtherNick} handled := handledLines(app) runUI(t, app.ui) // Window 1 is the channel. Window 2, the one shown, is // the other user's. app.ui.SwitchToBuffer(testChannel) app.ui.SwitchToBuffer(testOtherNick) key := tcell.NewEventKey(tcell.KeyRune, test.digit, tcell.ModAlt) app.ui.app.QueueEvent(key) waitHandled(t, handled, "/window "+string(test.digit)) var shown string app.ui.app.QueueUpdate(func() { shown = app.ui.buffers[app.ui.currentBuffer].Name }) if shown != test.wantBuffer || app.target != test.wantTarget { t.Fatalf("got buffer %s and target %q, want %s and %q", shown, app.target, test.wantBuffer, test.wantTarget) } }) } } // TestEventLoopKeepsRunning sends Alt and 1, then a typed line and // Enter, to a running UI. It checks that both are handled, so the // event loop did not stop on either. func TestEventLoopKeepsRunning(t *testing.T) { t.Parallel() app := &App{ui: NewUI(), nick: testNick} handled := handledLines(app) runUI(t, app.ui) // Window 1 is the channel, and window 0, the status window, is // shown. The typed line adds a line to the status window, as // the client is not connected. app.ui.SwitchToBuffer(testChannel) app.ui.SwitchBuffer(0) app.ui.app.QueueEvent(tcell.NewEventKey(tcell.KeyRune, '1', tcell.ModAlt)) for _, r := range testText { app.ui.app.QueueEvent(tcell.NewEventKey(tcell.KeyRune, r, tcell.ModNone)) } app.ui.app.QueueEvent(tcell.NewEventKey(tcell.KeyEnter, 0, tcell.ModNone)) waitHandled(t, handled, "/window 1") waitHandled(t, handled, testText) } // TestLinesKeepOrder holds the handling of a first line until a // second line has been passed in. It fails if the second line is // handled while the first is still held. func TestLinesKeepOrder(t *testing.T) { t.Parallel() const firstLine, secondLine = "one", "two" ui := NewUI() handled := make(chan string) release := make(chan struct{}) ui.OnInput(func(text string) { if text == firstLine { <-release } handled <- text }) ui.callOnInput(firstLine) ui.callOnInput(secondLine) select { case got := <-handled: t.Fatalf("handled %q while the first line was held", got) case <-time.After(holdTime): } close(release) waitHandled(t, handled, firstLine) waitHandled(t, handled, secondLine) } // TestStartUp runs the client's start-up path on a simulation // screen. It fails unless the event loop starts and the status // window gets two lines: first the one that holds "Welcome to // neoirc-cli", then the /connect hint. func TestStartUp(t *testing.T) { t.Parallel() ui := NewUI() ui.app.SetScreen(tcell.NewSimulationScreen("")) // One slot, so that run's goroutine can end even after the // cleanup below has stopped waiting for it. done := make(chan error, 1) go func() { done <- run(ui) }() t.Cleanup(func() { ui.Stop() select { case err := <-done: if err != nil { t.Errorf("run: %v", err) } case <-time.After(eventLoopTimeout): t.Error("run did not return after the UI was stopped") } }) // run adds the welcome lines on a goroutine, so the status // window is read until it holds two lines. Each read waits for // the event loop, so the reads run on a goroutine too, which // this test stops waiting for after eventLoopTimeout. The // channel has one slot, so that goroutine can still end. shown := make(chan []string, 1) go func() { for { var lines []string ui.app.QueueUpdate(func() { lines = slices.Clone(ui.getOrCreateBuffer("(status)").Lines) }) if len(lines) == welcomeLines { shown <- lines return } time.Sleep(checkInterval) } }() var lines []string select { case lines = <-shown: case <-time.After(eventLoopTimeout): t.Fatalf("the status window did not get the welcome lines within %v", eventLoopTimeout) } if !strings.Contains(lines[0], "Welcome to neoirc-cli") { t.Errorf("first status line is %q, want the welcome line", lines[0]) } if !strings.Contains(lines[1], "/connect ") { t.Errorf("second status line is %q, want the /connect hint", lines[1]) } } // TestOwnEventFromOtherClient checks that a message, notice, // join or part that the user's session makes from another // client shows as one line in the buffer it belongs to. func TestOwnEventFromOtherClient(t *testing.T) { t.Parallel() tests := []struct { name string event api.Message buffer string }{ { name: "channel message", event: api.Message{ Command: irc.CmdPrivmsg, From: testNick, To: testChannel, Body: []string{testText}, }, buffer: testChannel, }, { name: "direct message", event: api.Message{ Command: irc.CmdPrivmsg, From: testNick, To: testOtherNick, Body: []string{testText}, }, buffer: testOtherNick, }, { name: "notice", event: api.Message{ Command: irc.CmdNotice, From: testNick, To: testChannel, Body: []string{testText}, }, buffer: "(status)", }, { name: "join", event: api.Message{ Command: irc.CmdJoin, From: testNick, To: testChannel, }, buffer: testChannel, }, { name: "part", event: api.Message{ Command: irc.CmdPart, From: testNick, To: testChannel, }, buffer: testChannel, }, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { t.Parallel() app := &App{ui: NewUI(), nick: testNick} runUI(t, app.ui) app.handleServerMessage(&test.event) lines, _ := bufferState(app.ui, test.buffer) if lines != 1 { t.Fatalf("got %d lines in %s, want 1", lines, test.buffer) } }) } } // bufferState returns the number of lines in the named // buffer and how many of them are unread. func bufferState(ui *UI, name string) (int, int) { var lines, unread int ui.app.QueueUpdate(func() { buf := ui.getOrCreateBuffer(name) lines = len(buf.Lines) unread = buf.Unread }) return lines, unread } // handledLines makes app.ui pass each typed line to // app.handleInput, as Run does, and returns a channel that // receives each line once it has been handled. func handledLines(app *App) <-chan string { handled := make(chan string) app.ui.OnInput(func(text string) { app.handleInput(text) handled <- text }) return handled } // waitHandled fails the test unless want is the next line handled, // within eventLoopTimeout. func waitHandled(t *testing.T, handled <-chan string, want string) { t.Helper() select { case got := <-handled: if got != want { t.Fatalf("handled %q, want %q", got, want) } case <-time.After(eventLoopTimeout): t.Fatalf("%q was not handled within %v", want, eventLoopTimeout) } } // newServer starts a server that answers every request with // body until the test ends. func newServer(t *testing.T, body string) *httptest.Server { t.Helper() server := httptest.NewServer(http.HandlerFunc( func(writer http.ResponseWriter, _ *http.Request) { _, _ = io.WriteString(writer, body) }, )) t.Cleanup(server.Close) return server } // runUI runs ui on a simulated screen until the test ends. func runUI(t *testing.T, ui *UI) { t.Helper() ui.app.SetScreen(tcell.NewSimulationScreen("")) // One slot, so that Run's goroutine can end even after the // cleanup below has stopped waiting for it. done := make(chan error, 1) go func() { done <- ui.Run() }() t.Cleanup(func() { ui.Stop() select { case err := <-done: if err != nil { t.Errorf("run ui: %v", err) } case <-time.After(eventLoopTimeout): t.Error("the UI did not stop: its event loop stopped responding") } }) }