check / check (push) Canceled after 0s
A line that arrives in a window that is not shown is counted as unread, but refreshStatusBar did nothing, so the window joined the status bar's unread list only at the next SetStatus. UI now keeps the nick, target and connection state last passed to SetStatus, and refreshStatusBar redraws the status bar with them. Model: opus-5-5
992 lines
24 KiB
Go
992 lines
24 KiB
Go
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, for the UI to stop, for a poll
|
|
// or for a poll loop to end, before it fails.
|
|
const eventLoopTimeout = 5 * time.Second
|
|
|
|
// holdTime is how long a test waits to see that something does not
|
|
// happen: the second line handled while the first is held, or a
|
|
// poll from a poll loop that was stopped.
|
|
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)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestWindowConnectionState checks that typing /window 0, or
|
|
// pressing Alt and 0, leaves the status bar showing [connected]
|
|
// only while the client is connected, and [disconnected] otherwise.
|
|
func TestWindowConnectionState(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
const line = "/window 0"
|
|
|
|
typeLine := func(ui *UI) {
|
|
for _, r := range line {
|
|
ui.app.QueueEvent(tcell.NewEventKey(tcell.KeyRune, r, tcell.ModNone))
|
|
}
|
|
|
|
ui.app.QueueEvent(tcell.NewEventKey(tcell.KeyEnter, 0, tcell.ModNone))
|
|
}
|
|
|
|
pressAltZero := func(ui *UI) {
|
|
ui.app.QueueEvent(tcell.NewEventKey(tcell.KeyRune, '0', tcell.ModAlt))
|
|
}
|
|
|
|
tests := []struct {
|
|
name string
|
|
send func(ui *UI)
|
|
connected bool
|
|
want string
|
|
}{
|
|
{
|
|
name: "typed line, not connected", send: typeLine,
|
|
connected: false, want: "[disconnected]",
|
|
},
|
|
{
|
|
name: "Alt and 0, not connected", send: pressAltZero,
|
|
connected: false, want: "[disconnected]",
|
|
},
|
|
{
|
|
name: "typed line, connected", send: typeLine,
|
|
connected: true, want: "[connected]",
|
|
},
|
|
{
|
|
name: "Alt and 0, connected", send: pressAltZero,
|
|
connected: true, want: "[connected]",
|
|
},
|
|
}
|
|
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
app := &App{ui: NewUI(), nick: testNick, connected: test.connected}
|
|
handled := handledLines(app)
|
|
runUI(t, app.ui)
|
|
|
|
test.send(app.ui)
|
|
waitHandled(t, handled, line)
|
|
|
|
status := statusBarText(app.ui)
|
|
if !strings.Contains(status, test.want) {
|
|
t.Fatalf("status bar is %q, want it to show %s", status, test.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
|
|
// TestSecondConnectStopsFirstPollLoop connects to one server, then
|
|
// to a second that holds every poll. Only the second connection's
|
|
// poll loop may poll the second server.
|
|
func TestSecondConnectStopsFirstPollLoop(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
const session = `{"id": 1, "nick": "erin"}`
|
|
|
|
// The first server answers every request at once, so the first
|
|
// poll loop polls it until the second /connect.
|
|
first := newServer(t, session)
|
|
second, polled := newHoldingServer(t, session)
|
|
|
|
app := &App{ui: NewUI(), nick: testNick}
|
|
runUI(t, app.ui)
|
|
|
|
app.cmdConnect(first.URL)
|
|
app.cmdConnect(second.URL)
|
|
waitPoll(t, polled, "the second server")
|
|
|
|
// The second connection's poll loop waits on its held poll, so
|
|
// another poll comes from the first loop.
|
|
select {
|
|
case <-polled:
|
|
t.Fatal("the first poll loop polled again after the second /connect")
|
|
case <-time.After(holdTime):
|
|
}
|
|
|
|
// Stop the second poll loop as /quit does. /quit itself also
|
|
// stops the UI, and runUI's cleanup stopping it again is a data
|
|
// race inside tview.
|
|
app.mu.Lock()
|
|
close(app.stopPoll)
|
|
app.mu.Unlock()
|
|
}
|
|
|
|
// TestSecondConnectIgnoresHeldPoll holds a poll of the earlier
|
|
// connection at its server while a second /connect runs, then
|
|
// answers it with a cursor and a message. It fails if that answer
|
|
// moves the cursor or shows the message.
|
|
func TestSecondConnectIgnoresHeldPoll(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
const session = `{"id": 1, "nick": "erin"}`
|
|
|
|
const answer = `{"messages": [{"command": "PRIVMSG", "from": "bob",
|
|
"to": "#chan", "body": ["hello"]}], "last_id": 42}`
|
|
|
|
// The first server sends on polled when the poll arrives, and
|
|
// answers it once release is closed.
|
|
polled := make(chan struct{})
|
|
release := make(chan struct{})
|
|
first := httptest.NewServer(http.HandlerFunc(
|
|
func(writer http.ResponseWriter, request *http.Request) {
|
|
select {
|
|
case polled <- struct{}{}:
|
|
case <-request.Context().Done():
|
|
return
|
|
}
|
|
|
|
select {
|
|
case <-release:
|
|
_, _ = io.WriteString(writer, answer)
|
|
case <-request.Context().Done():
|
|
}
|
|
},
|
|
))
|
|
t.Cleanup(first.Close)
|
|
t.Cleanup(first.CloseClientConnections)
|
|
|
|
// The second server answers every request at once with the
|
|
// session. A poll answered with it has no cursor and no messages,
|
|
// so the second poll loop never moves the cursor.
|
|
second := newServer(t, session)
|
|
|
|
// The test runs the earlier connection's poll loop itself, so
|
|
// that it can wait for the loop to end.
|
|
stop := make(chan struct{})
|
|
app := &App{
|
|
ui: NewUI(),
|
|
client: api.NewClient(first.URL),
|
|
nick: testNick,
|
|
connected: true,
|
|
stopPoll: stop,
|
|
}
|
|
runUI(t, app.ui)
|
|
|
|
done := make(chan struct{})
|
|
|
|
go func() {
|
|
app.pollLoop(stop)
|
|
close(done)
|
|
}()
|
|
|
|
select {
|
|
case <-polled:
|
|
case <-time.After(eventLoopTimeout):
|
|
t.Fatalf("the first server got no poll within %v", eventLoopTimeout)
|
|
}
|
|
|
|
app.cmdConnect(second.URL)
|
|
close(release)
|
|
|
|
select {
|
|
case <-done:
|
|
case <-time.After(eventLoopTimeout):
|
|
t.Fatalf("the earlier poll loop did not end within %v", eventLoopTimeout)
|
|
}
|
|
|
|
app.mu.Lock()
|
|
cursor := app.lastQID
|
|
app.mu.Unlock()
|
|
|
|
if cursor != 0 {
|
|
t.Errorf("the earlier connection's answer moved the cursor to %d", cursor)
|
|
}
|
|
|
|
lines, _ := bufferState(app.ui, testChannel)
|
|
if lines != 0 {
|
|
t.Errorf("the earlier connection's message was shown in %s", testChannel)
|
|
}
|
|
|
|
// Stop the second poll loop as /quit does, without stopping the UI.
|
|
app.mu.Lock()
|
|
close(app.stopPoll)
|
|
app.mu.Unlock()
|
|
}
|
|
|
|
// TestSecondConnectAfterFailedPoll holds a poll of the earlier
|
|
// connection at its server while a second /connect runs, then
|
|
// fails it. A poll loop waits pollRetry after a failed poll, then
|
|
// polls again. The test fails if the earlier poll loop polls the
|
|
// second server.
|
|
func TestSecondConnectAfterFailedPoll(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
const session = `{"id": 1, "nick": "erin"}`
|
|
|
|
first, firstPolled := newHoldingServer(t, session)
|
|
|
|
// The second server holds the one poll of the second
|
|
// connection's poll loop, so any other poll comes from the
|
|
// earlier loop.
|
|
second, secondPolled := newHoldingServer(t, session)
|
|
|
|
// The test runs the earlier connection's poll loop itself, so
|
|
// that it can wait for the loop to end.
|
|
stop := make(chan struct{})
|
|
app := &App{
|
|
ui: NewUI(),
|
|
client: api.NewClient(first.URL),
|
|
nick: testNick,
|
|
connected: true,
|
|
stopPoll: stop,
|
|
}
|
|
runUI(t, app.ui)
|
|
|
|
done := make(chan struct{})
|
|
|
|
go func() {
|
|
app.pollLoop(stop)
|
|
close(done)
|
|
}()
|
|
|
|
waitPoll(t, firstPolled, "the first server")
|
|
app.cmdConnect(second.URL)
|
|
waitPoll(t, secondPolled, "the second server")
|
|
|
|
// Closing the held poll's connection makes that poll fail.
|
|
first.CloseClientConnections()
|
|
|
|
select {
|
|
case <-done:
|
|
case <-secondPolled:
|
|
t.Fatal("the earlier poll loop polled the second server")
|
|
case <-time.After(eventLoopTimeout):
|
|
t.Fatalf("the earlier poll loop did not end within %v", eventLoopTimeout)
|
|
}
|
|
|
|
// Stop the second poll loop as /quit does, without stopping the UI.
|
|
app.mu.Lock()
|
|
close(app.stopPoll)
|
|
app.mu.Unlock()
|
|
}
|
|
|
|
// 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. The status bar must show
|
|
// [disconnected].
|
|
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 <server-url>") {
|
|
t.Errorf("second status line is %q, want the /connect hint", lines[1])
|
|
}
|
|
|
|
// run sets the status bar before it adds the welcome lines.
|
|
status := statusBarText(ui)
|
|
if !strings.Contains(status, "[disconnected]") {
|
|
t.Errorf("status bar is %q, want it to show [disconnected]", status)
|
|
}
|
|
}
|
|
|
|
// TestStatusBar checks that the status bar shows [connected], and
|
|
// shows a nick, target or window name with square brackets as it
|
|
// is.
|
|
func TestStatusBar(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
tests := []struct {
|
|
name string
|
|
nick string
|
|
target string
|
|
unread string
|
|
want string
|
|
}{
|
|
{
|
|
name: "plain names",
|
|
nick: testNick,
|
|
target: testChannel,
|
|
unread: testOtherNick,
|
|
want: " [connected] erin [1:#chan] #chan [Act: 2:bob(1)]",
|
|
},
|
|
{
|
|
name: "nicks with brackets",
|
|
nick: "erin[away]",
|
|
target: "bob[m]",
|
|
unread: "dave[afk]",
|
|
want: " [connected] erin[away] [1:bob[m]] bob[m] [Act: 2:dave[afk](1)]",
|
|
},
|
|
{
|
|
name: "nicks with brackets and symbols",
|
|
nick: "bob[|[]",
|
|
target: "x[^[]",
|
|
unread: testOtherNick,
|
|
want: " [connected] bob[|[] [1:x[^[]] x[^[] [Act: 2:bob(1)]",
|
|
},
|
|
{
|
|
// /query takes any text as the target.
|
|
name: "query target with brackets",
|
|
nick: testNick,
|
|
target: "[:::y|z]",
|
|
unread: testOtherNick,
|
|
want: " [connected] erin [1:[:::y|z]] [:::y|z] [Act: 2:bob(1)]",
|
|
},
|
|
}
|
|
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ui := NewUI()
|
|
runUI(t, ui)
|
|
|
|
// Window 1, the one shown, is the target's. Window 2
|
|
// gets one unread line.
|
|
ui.SwitchToBuffer(test.target)
|
|
ui.AddLine(test.unread, testText)
|
|
ui.SetStatus(test.nick, test.target, "connected")
|
|
|
|
got := statusBarText(ui)
|
|
if got != test.want {
|
|
t.Fatalf("status bar is %q, want %q", got, test.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestUnreadLine checks that a line added to a window that is not
|
|
// shown puts that window in the status bar's unread list at once,
|
|
// and that the status bar keeps the nick, target and connection
|
|
// state last set.
|
|
func TestUnreadLine(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ui := NewUI()
|
|
runUI(t, ui)
|
|
|
|
// Window 1, the one shown, is the channel's. The line goes to
|
|
// window 2 after SetStatus, so only AddLine can show it in the
|
|
// status bar.
|
|
ui.SwitchToBuffer(testChannel)
|
|
ui.SetStatus(testNick, testChannel, "connected")
|
|
ui.AddLine(testOtherNick, testText)
|
|
|
|
const want = " [connected] erin [1:#chan] #chan [Act: 2:bob(1)]"
|
|
|
|
got := statusBarText(ui)
|
|
if got != want {
|
|
t.Fatalf("status bar is %q, want %q", got, want)
|
|
}
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// statusBarText returns the text the status bar shows.
|
|
// GetText(true) leaves out whatever tview reads as a tag, as
|
|
// drawing does.
|
|
func statusBarText(ui *UI) string {
|
|
var text string
|
|
|
|
ui.app.QueueUpdate(func() {
|
|
text = ui.statusBar.GetText(true)
|
|
})
|
|
|
|
return text
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// newHoldingServer starts a server that sends on the returned
|
|
// channel for each poll and holds the poll until its connection
|
|
// closes. It answers any other request with body.
|
|
func newHoldingServer(
|
|
t *testing.T, body string,
|
|
) (*httptest.Server, <-chan struct{}) {
|
|
t.Helper()
|
|
|
|
polled := make(chan struct{})
|
|
server := httptest.NewServer(http.HandlerFunc(
|
|
func(writer http.ResponseWriter, request *http.Request) {
|
|
if request.Method != http.MethodGet ||
|
|
request.URL.Path != "/api/v1/messages" {
|
|
_, _ = io.WriteString(writer, body)
|
|
|
|
return
|
|
}
|
|
|
|
select {
|
|
case polled <- struct{}{}:
|
|
case <-request.Context().Done():
|
|
}
|
|
|
|
<-request.Context().Done()
|
|
},
|
|
))
|
|
// Close waits for the held polls, which end only once their
|
|
// connections close. Cleanups run last first, so the connections
|
|
// close before Close runs.
|
|
t.Cleanup(server.Close)
|
|
t.Cleanup(server.CloseClientConnections)
|
|
|
|
return server, polled
|
|
}
|
|
|
|
// waitPoll fails the test unless server gets a poll, sent on
|
|
// polled, within eventLoopTimeout.
|
|
func waitPoll(t *testing.T, polled <-chan struct{}, server string) {
|
|
t.Helper()
|
|
|
|
select {
|
|
case <-polled:
|
|
case <-time.After(eventLoopTimeout):
|
|
t.Fatalf("%s got no poll within %v", server, eventLoopTimeout)
|
|
}
|
|
}
|
|
|
|
// 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")
|
|
}
|
|
})
|
|
}
|