From 837275fa92dbbc61f02b4719fac897c65f1a9701 Mon Sep 17 00:00:00 2001 From: sneak Date: Mon, 2 Nov 2026 00:20:25 +0000 Subject: [PATCH] Show the neoirc-cli screen without waiting at start-up (closes #226) Run added the start-up status bar and welcome lines through SetStatus and AddStatus before it started the tview event loop. Both wait for that loop, so neoirc-cli stopped before it showed its screen. They now run on a goroutine, as typed lines do. Run now passes a new UI to run, which holds the start-up path and returns its error. TestStartUp calls run with a simulation screen and fails if the status window does not get the welcome lines within five seconds. Model: opus-5-5 --- internal/cli/app.go | 50 ++++++++++++++--------- internal/cli/app_internal_test.go | 66 ++++++++++++++++++++++++++++++- 2 files changed, 97 insertions(+), 19 deletions(-) diff --git a/internal/cli/app.go b/internal/cli/app.go index c8a1fb4..fbed5d6 100644 --- a/internal/cli/app.go +++ b/internal/cli/app.go @@ -34,30 +34,44 @@ type App struct { // Run creates and runs the CLI application. func Run() { - app := &App{ //nolint:exhaustruct - ui: NewUI(), - nick: "guest", - } - - app.ui.OnInput(app.handleInput) - app.ui.SetStatus(app.nick, "", "disconnected") - - app.ui.AddStatus( - "Welcome to neoirc-cli — an IRC-style client", - ) - app.ui.AddStatus( - "Type [yellow]/connect " + - "[white] to begin, " + - "or [yellow]/help[white] for commands", - ) - - err := app.ui.Run() + err := run(NewUI()) if err != nil { fmt.Fprintf(os.Stderr, "Error: %v\n", err) os.Exit(1) } } +// run starts the client on ui and returns when ui stops. Tests +// call it with ui on a simulation screen. +func run(ui *UI) error { + app := &App{ //nolint:exhaustruct + ui: ui, + nick: "guest", + } + + app.ui.OnInput(app.handleInput) + + // SetStatus and AddStatus wait for the event loop, which + // app.ui.Run starts below, so they run on a goroutine. The + // nick is read first, as a typed /nick may change app.nick. + nick := app.nick + + go func() { + app.ui.SetStatus(nick, "", "disconnected") + + app.ui.AddStatus( + "Welcome to neoirc-cli — an IRC-style client", + ) + app.ui.AddStatus( + "Type [yellow]/connect " + + "[white] to begin, " + + "or [yellow]/help[white] for commands", + ) + }() + + return app.ui.Run() +} + func (a *App) handleInput(text string) { if strings.HasPrefix(text, "/") { a.handleCommand(text) diff --git a/internal/cli/app_internal_test.go b/internal/cli/app_internal_test.go index 3f133a5..a49674f 100644 --- a/internal/cli/app_internal_test.go +++ b/internal/cli/app_internal_test.go @@ -22,13 +22,22 @@ const ( ) // eventLoopTimeout is how long a test waits for a typed line to -// be handled, or for the UI to stop, before it fails. +// 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() @@ -348,6 +357,61 @@ func TestLinesKeepOrder(t *testing.T) { 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 the welcome lines. +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 they are there. Each read waits for + // the event loop, so the reads run on a goroutine too, which + // this test stops waiting for after eventLoopTimeout. + shown := make(chan struct{}) + + go func() { + for { + lines, _ := bufferState(ui, "(status)") + if lines == welcomeLines { + close(shown) + + return + } + + time.Sleep(checkInterval) + } + }() + + select { + case <-shown: + case <-time.After(eventLoopTimeout): + t.Fatalf("the status window did not get the welcome lines within %v", + eventLoopTimeout) + } +} + // 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.