Files
neoirc/internal/cli/app_internal_test.go
T
clawbot 6387d68786
check / check (push) Waiting to run
Show the connection state in the neoirc-cli status bar (closes #229)
The status bar had colour tags turned on, so tview read "[connected]"
and "[disconnected]" as tags and dropped them. It dropped all or part
of a nick, a target or a window name holding square brackets the same
way.

The status bar now has colour tags turned off. It wrote none of its
own, so every bracket shows as it is. TestStartUp checks that the
status bar shows [disconnected] at start-up. TestStatusBar checks
[connected], and checks nicks and a /query target that hold brackets.

Model: opus-5-5
2026-11-02 02:53:10 +01:00

671 lines
16 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, 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. 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)
}
})
}
}
// 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
}
// 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")
}
})
}