diff --git a/internal/app/app.go b/internal/app/app.go index da54845..1102d2c 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -17,6 +17,7 @@ import ( "parasocial/internal/auth" "parasocial/internal/config" "parasocial/internal/gql" + "parasocial/internal/irc" "parasocial/internal/tui" "parasocial/internal/twitch" ) @@ -68,13 +69,15 @@ func Run(ctx context.Context) error { StartResolve: func(state *auth.State, ch chan<- tui.StreamerUpdate) { go func() { defer close(ch) + ircManager := newIRCManager(ch) + defer ircManager.Close() service, err := newTwitchService(httpClient, state) if err != nil { ch <- tui.StreamerUpdate{Err: err, Done: true} return } - if err := resolveStreamerEntries(ctx, service, cfg.Streamers, func(update tui.StreamerUpdate) { + if err := resolveStreamerEntries(ctx, service, state, cfg.Streamers, ircManager, func(update tui.StreamerUpdate) { ch <- update }); err != nil { if errors.Is(err, context.Canceled) { @@ -103,6 +106,10 @@ type streamerService interface { PlaybackAccessToken(context.Context, string) (*twitch.PlaybackToken, error) } +type ircSyncer interface { + Sync(context.Context, string, string, []irc.Target) +} + // newTwitchService builds a Twitch service from the authenticated session state. func newTwitchService(httpClient *http.Client, state *auth.State) (*twitch.Service, error) { client := &gql.Client{ @@ -120,28 +127,48 @@ func newTwitchService(httpClient *http.Client, state *auth.State) (*twitch.Servi return &twitch.Service{GQL: client}, nil } -// resolveStreamerEntries streams viewer and streamer resolution results into the TUI. -func resolveStreamerEntries(ctx context.Context, service streamerService, logins []string, send func(tui.StreamerUpdate)) error { - return resolveStreamerEntriesWithSleep(ctx, service, logins, send, streamerRefreshInterval, sleepContext) +func newIRCManager(ch chan<- tui.StreamerUpdate) *irc.Manager { + return &irc.Manager{ + Addr: irc.DefaultAddr, + Events: func(event irc.Event) { + ch <- tui.StreamerUpdate{ + IRC: &tui.IRCUpdate{ + Login: event.Streamer, + State: tui.IRCState(event.State), + Line: event.Line, + }, + } + }, + } } -func resolveStreamerEntriesWithSleep(ctx context.Context, service streamerService, logins []string, send func(tui.StreamerUpdate), interval time.Duration, sleep func(context.Context, time.Duration) error) error { +// resolveStreamerEntries streams viewer and streamer resolution results into the TUI. +func resolveStreamerEntries(ctx context.Context, service streamerService, state *auth.State, logins []string, syncer ircSyncer, send func(tui.StreamerUpdate)) error { + return resolveStreamerEntriesWithSleep(ctx, service, state, logins, syncer, send, streamerRefreshInterval, sleepContext) +} + +func resolveStreamerEntriesWithSleep(ctx context.Context, service streamerService, state *auth.State, logins []string, syncer ircSyncer, send func(tui.StreamerUpdate), interval time.Duration, sleep func(context.Context, time.Duration) error) error { viewer, err := service.CurrentUser(ctx) if err != nil { return fmt.Errorf("resolve current user: %w", err) } send(tui.StreamerUpdate{Viewer: viewer}) + entries := twitch.LoadingStreamerEntries(logins) + syncWatchedChannels(ctx, syncer, state, entries) + for { for index, login := range logins { entry, err := resolveStreamerEntry(ctx, service, login) if errors.Is(err, context.Canceled) { return err } + entries[index] = *entry send(tui.StreamerUpdate{ Index: index, Entry: entry, }) + syncWatchedChannels(ctx, syncer, state, entries) } if err := sleep(ctx, interval); err != nil { return err @@ -201,3 +228,24 @@ func sleepContext(ctx context.Context, duration time.Duration) error { return nil } } + +func syncWatchedChannels(ctx context.Context, syncer ircSyncer, state *auth.State, entries []twitch.StreamerEntry) { + if syncer == nil || state == nil { + return + } + syncer.Sync(ctx, state.Login, state.AccessToken, watchedIRCTargets(entries)) +} + +func watchedIRCTargets(entries []twitch.StreamerEntry) []irc.Target { + targets := make([]irc.Target, 0, 2) + for _, entry := range entries { + if entry.Status != twitch.StreamerReady || !entry.Live || entry.Login == "" { + continue + } + targets = append(targets, irc.Target{Login: entry.Login}) + if len(targets) == 2 { + break + } + } + return targets +} diff --git a/internal/app/app_test.go b/internal/app/app_test.go index 0ec5d5a..8e61a51 100644 --- a/internal/app/app_test.go +++ b/internal/app/app_test.go @@ -6,9 +6,12 @@ package app import ( "context" "errors" + "reflect" "testing" "time" + "parasocial/internal/auth" + "parasocial/internal/irc" "parasocial/internal/tui" "parasocial/internal/twitch" ) @@ -73,6 +76,18 @@ func (f *fakeStreamerService) PlaybackAccessToken(_ context.Context, login strin return &twitch.PlaybackToken{Signature: "sig", Value: "token"}, nil } +type fakeIRCSyncer struct { + calls [][]string +} + +func (f *fakeIRCSyncer) Sync(_ context.Context, _, _ string, targets []irc.Target) { + logins := make([]string, 0, len(targets)) + for _, target := range targets { + logins = append(logins, target.Login) + } + f.calls = append(f.calls, logins) +} + // TestResolveStreamerEntries verifies that app resolution emits viewer, success, and error updates. func TestResolveStreamerEntries(t *testing.T) { t.Parallel() @@ -91,7 +106,7 @@ func TestResolveStreamerEntries(t *testing.T) { } var updates []tui.StreamerUpdate - err := resolveStreamerEntriesWithSleep(context.Background(), service, []string{"alpha", "beta"}, func(update tui.StreamerUpdate) { + err := resolveStreamerEntriesWithSleep(context.Background(), service, &auth.State{Login: "viewer", AccessToken: "token"}, []string{"alpha", "beta"}, nil, func(update tui.StreamerUpdate) { updates = append(updates, update) }, 0, func(context.Context, time.Duration) error { return context.Canceled @@ -130,7 +145,7 @@ func TestResolveStreamerEntriesPlaybackFailureStillMarksLive(t *testing.T) { } var updates []tui.StreamerUpdate - err := resolveStreamerEntriesWithSleep(context.Background(), service, []string{"alpha"}, func(update tui.StreamerUpdate) { + err := resolveStreamerEntriesWithSleep(context.Background(), service, &auth.State{Login: "viewer", AccessToken: "token"}, []string{"alpha"}, nil, func(update tui.StreamerUpdate) { updates = append(updates, update) }, 0, func(context.Context, time.Duration) error { return context.Canceled @@ -165,7 +180,7 @@ func TestResolveStreamerEntriesRefreshesLiveState(t *testing.T) { var updates []tui.StreamerUpdate sleepCalls := 0 - err := resolveStreamerEntriesWithSleep(context.Background(), service, []string{"alpha"}, func(update tui.StreamerUpdate) { + err := resolveStreamerEntriesWithSleep(context.Background(), service, &auth.State{Login: "viewer", AccessToken: "token"}, []string{"alpha"}, nil, func(update tui.StreamerUpdate) { updates = append(updates, update) }, 0, func(context.Context, time.Duration) error { sleepCalls++ @@ -187,3 +202,87 @@ func TestResolveStreamerEntriesRefreshesLiveState(t *testing.T) { t.Fatalf("second pass = %#v", updates[2]) } } + +func TestResolveStreamerEntriesSyncsTopTwoLiveChannels(t *testing.T) { + t.Parallel() + + service := &fakeStreamerService{ + viewer: &twitch.Viewer{ID: "7", Login: "viewer"}, + channels: map[string]*twitch.Channel{ + "alpha": {ID: "1", Login: "alpha_live"}, + "beta": {ID: "2", Login: "beta_live"}, + "gamma": {ID: "3", Login: "gamma_live"}, + }, + streams: map[string][]streamResult{ + "1": {{info: &twitch.StreamInfo{Online: true}}}, + "2": {{info: &twitch.StreamInfo{Online: true}}}, + "3": {{info: &twitch.StreamInfo{Online: true}}}, + }, + } + syncer := &fakeIRCSyncer{} + + err := resolveStreamerEntriesWithSleep(context.Background(), service, &auth.State{Login: "viewer", AccessToken: "token"}, []string{"alpha", "beta", "gamma"}, syncer, func(tui.StreamerUpdate) { + }, 0, func(context.Context, time.Duration) error { + return context.Canceled + }) + if !errors.Is(err, context.Canceled) { + t.Fatal(err) + } + + want := [][]string{ + {}, + {"alpha_live"}, + {"alpha_live", "beta_live"}, + {"alpha_live", "beta_live"}, + } + if !reflect.DeepEqual(syncer.calls, want) { + t.Fatalf("sync calls = %#v, want %#v", syncer.calls, want) + } +} + +func TestResolveStreamerEntriesRefreshUpdatesWatchedChannels(t *testing.T) { + t.Parallel() + + service := &fakeStreamerService{ + viewer: &twitch.Viewer{ID: "7", Login: "viewer"}, + channels: map[string]*twitch.Channel{ + "alpha": {ID: "1", Login: "alpha_live"}, + "beta": {ID: "2", Login: "beta_live"}, + }, + streams: map[string][]streamResult{ + "1": { + {info: &twitch.StreamInfo{Online: true}}, + {info: &twitch.StreamInfo{Online: false}}, + }, + "2": { + {info: &twitch.StreamInfo{Online: false}}, + {info: &twitch.StreamInfo{Online: true}}, + }, + }, + } + syncer := &fakeIRCSyncer{} + + sleepCalls := 0 + err := resolveStreamerEntriesWithSleep(context.Background(), service, &auth.State{Login: "viewer", AccessToken: "token"}, []string{"alpha", "beta"}, syncer, func(tui.StreamerUpdate) { + }, 0, func(context.Context, time.Duration) error { + sleepCalls++ + if sleepCalls == 1 { + return nil + } + return context.Canceled + }) + if !errors.Is(err, context.Canceled) { + t.Fatal(err) + } + + want := [][]string{ + {}, + {"alpha_live"}, + {"alpha_live"}, + {}, + {"beta_live"}, + } + if !reflect.DeepEqual(syncer.calls, want) { + t.Fatalf("sync calls = %#v, want %#v", syncer.calls, want) + } +} diff --git a/internal/irc/client.go b/internal/irc/client.go new file mode 100644 index 0000000..4f4bc45 --- /dev/null +++ b/internal/irc/client.go @@ -0,0 +1,252 @@ +// Package irc manages Twitch IRC chat connections for watched streamers. +package irc + +import ( + "bufio" + "context" + "errors" + "fmt" + "io" + "net" + "strings" + "sync" + "time" +) + +const DefaultAddr = "irc.chat.twitch.tv:6667" + +// ConnectionState describes the lifecycle state of one IRC connection. +type ConnectionState string + +const ( + StatePending ConnectionState = "pending" + StateJoined ConnectionState = "joined" + StateDisconnected ConnectionState = "disconnected" +) + +// Event carries one IRC lifecycle change or log line for a streamer connection. +type Event struct { + Streamer string + State ConnectionState + Line string +} + +// EventSink consumes IRC events emitted by a client or manager. +type EventSink func(Event) + +// Client maintains one Twitch IRC connection for a single streamer channel. +type Client struct { + Addr string + Login string + Token string + Streamer string + Once bool + Debug bool + Out io.Writer + Events EventSink + DialContext func(context.Context, string, string) (net.Conn, error) +} + +// Run connects, authenticates, joins the configured streamer channel, and stays connected. +func (c *Client) Run(ctx context.Context) error { + if c.Login == "" { + return errors.New("missing Twitch login") + } + if c.Token == "" { + return errors.New("missing OAuth access token") + } + if c.Streamer == "" { + return errors.New("missing streamer") + } + + addr := c.Addr + if addr == "" { + addr = DefaultAddr + } + + c.status("Connecting to %s\n", addr) + conn, err := c.dial(ctx, addr) + if err != nil { + return fmt.Errorf("connect to Twitch IRC: %w", err) + } + + session := &session{ + conn: conn, + out: c.Out, + debug: c.Debug, + } + defer session.close() + + done := make(chan struct{}) + defer close(done) + go func() { + select { + case <-ctx.Done(): + _ = session.send("QUIT :parasocial shutting down", false) + session.close() + case <-done: + } + }() + + if err := session.send("PASS oauth:"+c.Token, true); err != nil { + return err + } + c.status("Sent PASS\n") + if err := session.send("NICK "+c.Login, false); err != nil { + return err + } + c.status("Sent NICK %s\n", c.Login) + if err := session.send("JOIN #"+c.Streamer, false); err != nil { + return err + } + c.status("Sent JOIN #%s\n", c.Streamer) + + reader := bufio.NewReader(conn) + joined := false + for { + line, err := reader.ReadString('\n') + if err != nil { + if ctx.Err() != nil { + return nil + } + if joined { + return fmt.Errorf("network interruption after join: %w", err) + } + return fmt.Errorf("IRC connection closed before join confirmation: %w", err) + } + + line = strings.TrimRight(line, "\r\n") + session.debugIn(line) + + if payload, ok := pingPayload(line); ok { + if err := session.send("PONG "+payload, false); err != nil { + return err + } + c.status("Responded to PING\n") + continue + } + + if isAuthFailure(line) { + return fmt.Errorf("IRC authentication failure: %s", line) + } + if isJoinDenied(line, c.Streamer) { + return fmt.Errorf("IRC join denied: %s", line) + } + if isWelcome(line) { + c.status("IRC authentication accepted\n") + continue + } + if isJoinConfirmation(line, c.Login, c.Streamer) { + joinedLine := fmt.Sprintf("Joined #%s as %s", c.Streamer, c.Login) + c.status("%s\n", joinedLine) + c.emit(StateJoined, joinedLine) + joined = true + if c.Once { + if err := session.send("QUIT :parasocial --once complete", false); err != nil { + return err + } + return nil + } + continue + } + if joined { + c.status("%s\n", line) + c.emit("", line) + } + } +} + +type session struct { + conn net.Conn + out io.Writer + debug bool + mu sync.Mutex + once sync.Once +} + +func (s *session) send(line string, redact bool) error { + s.mu.Lock() + defer s.mu.Unlock() + + debugLine := line + if redact { + debugLine = "PASS oauth:" + } + if s.debug && s.out != nil { + fmt.Fprintf(s.out, "> %s\n", debugLine) + } + _, err := fmt.Fprintf(s.conn, "%s\r\n", line) + return err +} + +func (s *session) debugIn(line string) { + if s.debug && s.out != nil { + fmt.Fprintf(s.out, "< %s\n", line) + } +} + +func (s *session) close() { + s.once.Do(func() { + _ = s.conn.SetDeadline(time.Now()) + _ = s.conn.Close() + }) +} + +func (c *Client) dial(ctx context.Context, addr string) (net.Conn, error) { + if c.DialContext != nil { + return c.DialContext(ctx, "tcp", addr) + } + dialer := &net.Dialer{Timeout: 30 * time.Second} + return dialer.DialContext(ctx, "tcp", addr) +} + +func (c *Client) status(format string, args ...any) { + if c.Out == nil { + return + } + fmt.Fprintf(c.Out, format, args...) +} + +func (c *Client) emit(state ConnectionState, line string) { + if c.Events == nil { + return + } + c.Events(Event{ + Streamer: c.Streamer, + State: state, + Line: line, + }) +} + +func pingPayload(line string) (string, bool) { + if strings.HasPrefix(line, "PING ") { + return strings.TrimSpace(strings.TrimPrefix(line, "PING ")), true + } + return "", false +} + +func isWelcome(line string) bool { + return strings.Contains(line, " 001 ") +} + +func isAuthFailure(line string) bool { + lower := strings.ToLower(line) + return strings.Contains(lower, "login authentication failed") || + strings.Contains(lower, "improperly formatted auth") || + strings.Contains(lower, "error logging in") +} + +func isJoinDenied(line, streamer string) bool { + lower := strings.ToLower(line) + channel := "#" + strings.ToLower(streamer) + return (strings.Contains(lower, " 403 ") && strings.Contains(lower, channel)) || + (strings.Contains(lower, "notice "+channel) && strings.Contains(lower, "banned")) || + (strings.Contains(lower, "notice "+channel) && strings.Contains(lower, "not permitted")) +} + +func isJoinConfirmation(line, login, streamer string) bool { + lower := strings.ToLower(line) + channel := "#" + strings.ToLower(streamer) + return strings.Contains(lower, " join "+channel) && + (strings.HasPrefix(lower, ":"+strings.ToLower(login)+"!") || strings.Contains(lower, "!")) +} diff --git a/internal/irc/client_test.go b/internal/irc/client_test.go new file mode 100644 index 0000000..de542a8 --- /dev/null +++ b/internal/irc/client_test.go @@ -0,0 +1,223 @@ +package irc + +import ( + "bufio" + "context" + "io" + "net" + "reflect" + "strings" + "testing" + "time" +) + +func TestRunOnceSendsAuthJoinAndRespondsToPing(t *testing.T) { + t.Parallel() + + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + defer listener.Close() + + lines := make(chan string, 10) + serverErr := make(chan error, 1) + + go func() { + conn, err := listener.Accept() + if err != nil { + serverErr <- err + return + } + defer conn.Close() + + reader := bufio.NewReader(conn) + for { + line, err := reader.ReadString('\n') + if err != nil { + if err == io.EOF { + serverErr <- nil + return + } + serverErr <- err + return + } + line = strings.TrimRight(line, "\r\n") + lines <- line + switch line { + case "NICK viewer": + _, _ = conn.Write([]byte(":tmi.twitch.tv 001 viewer :Welcome\r\n")) + case "JOIN #streamer": + _, _ = conn.Write([]byte("PING :tmi.twitch.tv\r\n")) + case "PONG :tmi.twitch.tv": + _, _ = conn.Write([]byte(":viewer!viewer@viewer.tmi.twitch.tv JOIN #streamer\r\n")) + case "QUIT :parasocial --once complete": + serverErr <- nil + return + } + } + }() + + client := &Client{ + Addr: listener.Addr().String(), + Login: "viewer", + Token: "token", + Streamer: "streamer", + Once: true, + Out: io.Discard, + } + if err := client.Run(context.Background()); err != nil { + t.Fatal(err) + } + + want := []string{ + "PASS oauth:token", + "NICK viewer", + "JOIN #streamer", + "PONG :tmi.twitch.tv", + "QUIT :parasocial --once complete", + } + for _, expected := range want { + select { + case got := <-lines: + if got != expected { + t.Fatalf("line = %q, want %q", got, expected) + } + case <-time.After(time.Second): + t.Fatalf("timed out waiting for %q", expected) + } + } + + select { + case err := <-serverErr: + if err != nil { + t.Fatal(err) + } + case <-time.After(time.Second): + t.Fatal("server did not finish") + } +} + +func TestRunEmitsJoinAndPostJoinEvents(t *testing.T) { + t.Parallel() + + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + defer listener.Close() + + events := make(chan Event, 4) + serverErr := make(chan error, 1) + + go func() { + conn, err := listener.Accept() + if err != nil { + serverErr <- err + return + } + defer conn.Close() + + reader := bufio.NewReader(conn) + for { + line, err := reader.ReadString('\n') + if err != nil { + serverErr <- err + return + } + line = strings.TrimRight(line, "\r\n") + switch line { + case "NICK viewer": + _, _ = conn.Write([]byte(":tmi.twitch.tv 001 viewer :Welcome\r\n")) + case "JOIN #streamer": + _, _ = conn.Write([]byte(":viewer!viewer@viewer.tmi.twitch.tv JOIN #streamer\r\n")) + _, _ = conn.Write([]byte(":someone!someone@someone.tmi.twitch.tv PRIVMSG #streamer :hello\r\n")) + _, _ = conn.Write([]byte("PING :tmi.twitch.tv\r\n")) + case "PONG :tmi.twitch.tv": + serverErr <- nil + return + } + } + }() + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + client := &Client{ + Addr: listener.Addr().String(), + Login: "viewer", + Token: "token", + Streamer: "streamer", + Events: func(event Event) { + events <- event + }, + Out: io.Discard, + } + + done := make(chan error, 1) + go func() { + done <- client.Run(ctx) + }() + + var got []Event + for len(got) < 2 { + select { + case event := <-events: + got = append(got, event) + if event.Line == ":someone!someone@someone.tmi.twitch.tv PRIVMSG #streamer :hello" { + cancel() + } + case <-time.After(time.Second): + t.Fatal("timed out waiting for IRC events") + } + } + + if err := <-done; err != nil { + t.Fatal(err) + } + if err := <-serverErr; err != nil && err != io.EOF { + t.Fatal(err) + } + + want := []Event{ + {Streamer: "streamer", State: StateJoined, Line: "Joined #streamer as viewer"}, + {Streamer: "streamer", Line: ":someone!someone@someone.tmi.twitch.tv PRIVMSG #streamer :hello"}, + } + if !reflect.DeepEqual(got[:2], want) { + t.Fatalf("events = %#v, want %#v", got[:2], want) + } +} + +func TestAuthFailureReturnsError(t *testing.T) { + t.Parallel() + + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + defer listener.Close() + + go func() { + conn, err := listener.Accept() + if err != nil { + return + } + defer conn.Close() + _, _ = conn.Write([]byte(":tmi.twitch.tv NOTICE * :Login authentication failed\r\n")) + }() + + client := &Client{ + Addr: listener.Addr().String(), + Login: "viewer", + Token: "bad-token", + Streamer: "streamer", + Out: io.Discard, + } + err = client.Run(context.Background()) + if err == nil { + t.Fatal("expected auth failure") + } + if !strings.Contains(err.Error(), "authentication failure") { + t.Fatalf("err = %v", err) + } +} diff --git a/internal/irc/manager.go b/internal/irc/manager.go new file mode 100644 index 0000000..e3eb21a --- /dev/null +++ b/internal/irc/manager.go @@ -0,0 +1,146 @@ +package irc + +import ( + "context" + "io" + "net" + "strings" + "sync" +) + +// Target identifies one streamer channel that should have an active IRC connection. +type Target struct { + Login string +} + +// Manager reconciles the active IRC connections against the desired watched set. +type Manager struct { + Addr string + Debug bool + Out io.Writer + Events EventSink + DialContext func(context.Context, string, string) (net.Conn, error) + RunClient func(context.Context, *Client) error + + mu sync.Mutex + active map[string]*managedClient + wg sync.WaitGroup +} + +type managedClient struct { + cancel context.CancelFunc +} + +// Sync applies the desired IRC target set using the provided viewer credentials. +func (m *Manager) Sync(ctx context.Context, viewerLogin, accessToken string, targets []Target) { + orderedTargets, desired := normalizeTargets(targets) + + m.mu.Lock() + defer m.mu.Unlock() + + if m.active == nil { + m.active = make(map[string]*managedClient) + } + + for login, client := range m.active { + if _, ok := desired[login]; ok { + continue + } + client.cancel() + delete(m.active, login) + } + + if viewerLogin == "" || accessToken == "" { + return + } + + for _, login := range orderedTargets { + if _, ok := m.active[login]; ok { + continue + } + + runCtx, cancel := context.WithCancel(ctx) + client := &managedClient{cancel: cancel} + m.active[login] = client + + m.wg.Add(1) + go func(streamer string, current *managedClient) { + defer m.wg.Done() + m.runClient(runCtx, viewerLogin, accessToken, streamer, current) + }(login, client) + } +} + +func (m *Manager) runClient(ctx context.Context, viewerLogin, accessToken, streamer string, client *managedClient) { + run := m.RunClient + if run == nil { + run = func(ctx context.Context, conn *Client) error { + return conn.Run(ctx) + } + } + + m.emit(Event{Streamer: streamer, State: StatePending}) + + _ = run(ctx, &Client{ + Addr: m.addr(), + Login: viewerLogin, + Token: accessToken, + Streamer: streamer, + Debug: m.Debug, + Out: m.Out, + Events: m.Events, + DialContext: m.DialContext, + }) + m.emit(Event{Streamer: streamer, State: StateDisconnected}) + + m.mu.Lock() + defer m.mu.Unlock() + if current, ok := m.active[streamer]; ok && current == client { + delete(m.active, streamer) + } +} + +// Close cancels every active connection and waits for their goroutines to exit. +func (m *Manager) Close() { + m.mu.Lock() + for login, client := range m.active { + client.cancel() + delete(m.active, login) + } + m.mu.Unlock() + m.wg.Wait() +} + +func (m *Manager) addr() string { + if m.Addr != "" { + return m.Addr + } + return DefaultAddr +} + +func (m *Manager) emit(event Event) { + if m.Events == nil { + return + } + m.Events(event) +} + +func normalizeTargets(targets []Target) ([]string, map[string]struct{}) { + ordered := make([]string, 0, 2) + normalized := make(map[string]struct{}, len(targets)) + for _, target := range targets { + login := strings.ToLower(strings.TrimSpace(target.Login)) + if login == "" { + continue + } + if _, ok := normalized[login]; ok { + continue + } + normalized[login] = struct{}{} + ordered = append(ordered, login) + if len(normalized) == 2 { + break + } + } + return ordered, normalized +} diff --git a/internal/irc/manager_test.go b/internal/irc/manager_test.go new file mode 100644 index 0000000..834ed82 --- /dev/null +++ b/internal/irc/manager_test.go @@ -0,0 +1,223 @@ +package irc + +import ( + "context" + "errors" + "reflect" + "sort" + "sync" + "testing" + "time" +) + +func TestManagerSyncStartsAtMostTwoTargets(t *testing.T) { + t.Parallel() + + started := make(chan string, 3) + manager := &Manager{ + RunClient: func(ctx context.Context, client *Client) error { + started <- client.Streamer + <-ctx.Done() + return ctx.Err() + }, + } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + manager.Sync(ctx, "viewer", "token", []Target{ + {Login: "alpha"}, + {Login: "beta"}, + {Login: "gamma"}, + }) + + assertStartSet(t, started, "alpha", "beta") + assertNoStart(t, started) +} + +func TestManagerSyncKeepsExistingTargetsConnected(t *testing.T) { + t.Parallel() + + started := make(chan string, 3) + manager := &Manager{ + RunClient: func(ctx context.Context, client *Client) error { + started <- client.Streamer + <-ctx.Done() + return ctx.Err() + }, + } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + manager.Sync(ctx, "viewer", "token", []Target{{Login: "alpha"}}) + assertStartSet(t, started, "alpha") + + manager.Sync(ctx, "viewer", "token", []Target{{Login: "alpha"}}) + assertNoStart(t, started) +} + +func TestManagerSyncCancelsRemovedTargets(t *testing.T) { + t.Parallel() + + started := make(chan string, 4) + canceled := make(chan string, 4) + manager := &Manager{ + RunClient: func(ctx context.Context, client *Client) error { + started <- client.Streamer + <-ctx.Done() + canceled <- client.Streamer + return ctx.Err() + }, + } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + manager.Sync(ctx, "viewer", "token", []Target{{Login: "alpha"}, {Login: "beta"}}) + assertStartSet(t, started, "alpha", "beta") + + manager.Sync(ctx, "viewer", "token", []Target{{Login: "beta"}, {Login: "gamma"}}) + assertStartSet(t, started, "gamma") + assertCancels(t, canceled, "alpha") + assertNoStart(t, started) +} + +func TestManagerSyncRestartsTargetAfterFailure(t *testing.T) { + t.Parallel() + + started := make(chan string, 2) + var mu sync.Mutex + attempts := map[string]int{} + + manager := &Manager{ + RunClient: func(ctx context.Context, client *Client) error { + mu.Lock() + attempts[client.Streamer]++ + attempt := attempts[client.Streamer] + mu.Unlock() + + started <- client.Streamer + if attempt == 1 { + return errors.New("boom") + } + <-ctx.Done() + return ctx.Err() + }, + } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + manager.Sync(ctx, "viewer", "token", []Target{{Login: "alpha"}}) + assertStartSet(t, started, "alpha") + + waitFor(t, func() bool { + manager.mu.Lock() + defer manager.mu.Unlock() + _, ok := manager.active["alpha"] + return !ok + }) + + manager.Sync(ctx, "viewer", "token", []Target{{Login: "alpha"}}) + assertStartSet(t, started, "alpha") +} + +func TestManagerEmitsPendingAndDisconnectedEvents(t *testing.T) { + t.Parallel() + + events := make(chan Event, 4) + manager := &Manager{ + Events: func(event Event) { + events <- event + }, + RunClient: func(ctx context.Context, client *Client) error { + <-ctx.Done() + return ctx.Err() + }, + } + + ctx, cancel := context.WithCancel(context.Background()) + manager.Sync(ctx, "viewer", "token", []Target{{Login: "alpha"}}) + cancel() + manager.Close() + + var got []Event + for len(got) < 2 { + select { + case event := <-events: + got = append(got, event) + case <-time.After(time.Second): + t.Fatal("timed out waiting for manager events") + } + } + + want := []Event{ + {Streamer: "alpha", State: StatePending}, + {Streamer: "alpha", State: StateDisconnected}, + } + if !reflect.DeepEqual(got[:2], want) { + t.Fatalf("events = %#v, want %#v", got[:2], want) + } +} + +func assertStartSet(t *testing.T, started <-chan string, want ...string) { + t.Helper() + + got := make([]string, 0, len(want)) + for range want { + select { + case login := <-started: + got = append(got, login) + case <-time.After(time.Second): + t.Fatalf("timed out waiting for %d starts", len(want)) + } + } + + sort.Strings(got) + sortedWant := append([]string(nil), want...) + sort.Strings(sortedWant) + for index := range sortedWant { + if got[index] != sortedWant[index] { + t.Fatalf("starts = %#v, want %#v", got, sortedWant) + } + } +} + +func assertCancels(t *testing.T, canceled <-chan string, want ...string) { + t.Helper() + + for _, expected := range want { + select { + case got := <-canceled: + if got != expected { + t.Fatalf("cancel = %q, want %q", got, expected) + } + case <-time.After(time.Second): + t.Fatalf("timed out waiting for cancel %q", expected) + } + } +} + +func assertNoStart(t *testing.T, started <-chan string) { + t.Helper() + + select { + case got := <-started: + t.Fatalf("unexpected start %q", got) + case <-time.After(100 * time.Millisecond): + } +} + +func waitFor(t *testing.T, condition func() bool) { + t.Helper() + + deadline := time.Now().Add(time.Second) + for time.Now().Before(deadline) { + if condition() { + return + } + time.Sleep(10 * time.Millisecond) + } + t.Fatal("condition not met before timeout") +} diff --git a/internal/tui/model.go b/internal/tui/model.go index 999ee5c..2696614 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -14,7 +14,8 @@ import ( "parasocial/internal/twitch" ) -const liveDot = "\x1b[32m●\x1b[0m" +const defaultViewWidth = 80 +const greenDot = "\x1b[32m●\x1b[0m" type viewMode int @@ -23,6 +24,15 @@ const ( streamerView ) +// IRCState describes the current IRC join lifecycle for one streamer row. +type IRCState string + +const ( + IRCPending IRCState = "pending" + IRCJoined IRCState = "joined" + IRCDisconnected IRCState = "disconnected" +) + // StartAuthFunc begins the asynchronous Twitch login flow for the model. type StartAuthFunc func(chan<- AuthUpdate) @@ -41,11 +51,19 @@ type AuthUpdate struct { type StreamerUpdate struct { Viewer *twitch.Viewer Entry *twitch.StreamerEntry + IRC *IRCUpdate Index int Err error Done bool } +// IRCUpdate carries one IRC connection state or log line into the TUI. +type IRCUpdate struct { + Login string + State IRCState + Line string +} + // Options configures the initial streamer list, auth state, and worker starter hooks. type Options struct { Streamers []twitch.StreamerEntry @@ -77,6 +95,19 @@ type Model struct { resolveErr error viewer *twitch.Viewer mode viewMode + width int + height int + selectedConfig string + ircDetails map[string]ircDetail +} + +type ircDetail struct { + joined bool +} + +type streamerRow struct { + index int + entry twitch.StreamerEntry } // New returns a Bubble Tea model for auth and streamer display. @@ -88,10 +119,12 @@ func New(options Options) Model { startResolve: options.StartResolve, authLogs: []string{}, mode: authView, + ircDetails: make(map[string]ircDetail), } if options.AuthState != nil { model.mode = streamerView } + model.ensureSelection() return model } @@ -128,6 +161,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.viewer = nil m.resolveErr = nil m.streamers = loadingEntries(m.streamers) + m.ircDetails = make(map[string]ircDetail) + m.selectedConfig = "" + m.ensureSelection() return m, startStreamerResolution(m.startResolve, m.authState) } return m, nil @@ -143,17 +179,33 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if msg.Entry != nil && msg.Index >= 0 && msg.Index < len(m.streamers) { m.streamers[msg.Index] = *msg.Entry } + if msg.IRC != nil { + m.applyIRCUpdate(*msg.IRC) + } if msg.Err != nil { m.resolveErr = msg.Err } + m.ensureSelection() if msg.Done { return m, nil } if m.streamerUpdates != nil { return m, waitForStreamerUpdate(m.streamerUpdates) } + case tea.WindowSizeMsg: + m.width = msg.Width + m.height = msg.Height + m.ensureSelection() case tea.KeyMsg: switch msg.String() { + case "up", "k": + if m.mode == streamerView { + m.moveSelection(-1) + } + case "down", "j": + if m.mode == streamerView { + m.moveSelection(1) + } case "ctrl+c", "esc", "q": return m, tea.Quit } @@ -164,8 +216,8 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // View renders either the login log screen or the authenticated streamer list. func (m Model) View() string { - var builder strings.Builder if m.mode == authView { + var builder strings.Builder builder.WriteString("Twitch Login\n") if len(m.authLogs) == 0 { builder.WriteString("Starting authentication...\n") @@ -182,36 +234,317 @@ func (m Model) View() string { return builder.String() } + return m.renderStreamerView() +} + +func (m Model) renderStreamerView() string { + var builder strings.Builder switch { case m.viewer != nil: - fmt.Fprintf(&builder, "Logged in as %s (%s)\n\n", m.viewer.Login, m.viewer.ID) + fmt.Fprintf(&builder, "Logged in as %s (%s)\n", m.viewer.Login, m.viewer.ID) case m.authState != nil && m.authState.Login != "": fmt.Fprintf(&builder, "Logged in as %s\n", m.authState.Login) if m.resolveErr != nil { - fmt.Fprintf(&builder, "Viewer lookup failed: %v\n\n", m.resolveErr) + fmt.Fprintf(&builder, "Viewer lookup failed: %v\n", m.resolveErr) } else { - builder.WriteString("Resolving viewer identity...\n\n") + builder.WriteString("Resolving viewer identity...\n") } } - builder.WriteString("Streamers\n") - for i, streamer := range m.streamers { - switch streamer.Status { - case twitch.StreamerReady: - label := streamer.Login - if streamer.Live { - label += " " + liveDot - } - fmt.Fprintf(&builder, "%d. %s (%s)\n", i+1, label, streamer.ChannelID) - case twitch.StreamerError: - fmt.Fprintf(&builder, "%d. %s [error: %s]\n", i+1, streamer.ConfigLogin, streamer.Error) - default: - fmt.Fprintf(&builder, "%d. %s [loading]\n", i+1, streamer.ConfigLogin) + fmt.Fprintf(&builder, "Watching: %s\n", watchingSummary(m.streamers)) + + rows := m.orderedRows() + selectedIndex := m.selectedRowIndex(rows) + leftPane := m.leftPaneLines(rows) + rightPane := m.rightPaneLines(rows, selectedIndex) + + if m.height > 0 { + available := m.height - 3 + if available > 0 { + leftPane = clipListLines(leftPane, available, selectedIndex+1) + rightPane = clipTailLines(rightPane, available) } } + + bodyWidth := m.width + if bodyWidth <= 0 { + bodyWidth = defaultViewWidth + } + builder.WriteString("\n") + for _, line := range renderColumns(leftPane, rightPane, bodyWidth) { + builder.WriteString(line) + builder.WriteByte('\n') + } + builder.WriteByte('\n') return builder.String() } +func watchingSummary(streamers []twitch.StreamerEntry) string { + live := make([]string, 0, 2) + for _, streamer := range streamers { + if streamer.Status != twitch.StreamerReady || !streamer.Live { + continue + } + name := streamer.Login + if name == "" { + name = streamer.ConfigLogin + } + live = append(live, name) + if len(live) == 2 { + break + } + } + if len(live) == 0 { + return "no live streamers" + } + return strings.Join(live, ", ") +} + +func (m *Model) applyIRCUpdate(update IRCUpdate) { + login := normalizeKey(update.Login) + if login == "" { + return + } + + detail := m.ircDetails[login] + switch update.State { + case IRCJoined: + detail.joined = true + case IRCPending, IRCDisconnected: + detail.joined = false + } + if update.Line != "" { + } + m.ircDetails[login] = detail +} + +func (m *Model) ensureSelection() { + rows := m.orderedRows() + if len(rows) == 0 { + m.selectedConfig = "" + return + } + if m.selectedConfig == "" { + m.selectedConfig = rows[0].entry.ConfigLogin + return + } + for _, row := range rows { + if row.entry.ConfigLogin == m.selectedConfig { + return + } + } + m.selectedConfig = rows[0].entry.ConfigLogin +} + +func (m *Model) moveSelection(delta int) { + rows := m.orderedRows() + if len(rows) == 0 { + m.selectedConfig = "" + return + } + m.ensureSelection() + + selected := m.selectedRowIndex(rows) + if selected < 0 { + selected = 0 + } + selected += delta + if selected < 0 { + selected = 0 + } + if selected >= len(rows) { + selected = len(rows) - 1 + } + m.selectedConfig = rows[selected].entry.ConfigLogin +} + +func (m Model) orderedRows() []streamerRow { + rows := make([]streamerRow, 0, len(m.streamers)) + for index, entry := range m.streamers { + if isActive(entry) { + rows = append(rows, streamerRow{index: index, entry: entry}) + } + } + for index, entry := range m.streamers { + if isActive(entry) { + continue + } + rows = append(rows, streamerRow{index: index, entry: entry}) + } + return rows +} + +func (m Model) selectedRowIndex(rows []streamerRow) int { + for index, row := range rows { + if row.entry.ConfigLogin == m.selectedConfig { + return index + } + } + if len(rows) == 0 { + return -1 + } + return 0 +} + +func (m Model) leftPaneLines(rows []streamerRow) []string { + lines := []string{"Streamers"} + for _, row := range rows { + prefix := " " + if row.entry.ConfigLogin == m.selectedConfig { + prefix = "> " + } + + status := "inactive" + if isActive(row.entry) { + status = "active" + } + lines = append(lines, prefix+streamerName(row.entry)+" ["+status+"]") + } + if len(rows) == 0 { + lines = append(lines, " none") + } + return lines +} + +func (m Model) rightPaneLines(rows []streamerRow, selected int) []string { + lines := []string{"IRC Chat"} + if selected < 0 || selected >= len(rows) { + return append(lines, "") + } + + row := rows[selected].entry + if !isActive(row) { + return append(lines, "inactive") + } + + detail := m.ircDetails[normalizeKey(row.Login)] + if !detail.joined { + return append(lines, "not joined") + } + return append(lines, greenDot) +} + +func isActive(entry twitch.StreamerEntry) bool { + return entry.Status == twitch.StreamerReady && entry.Live +} + +func streamerName(entry twitch.StreamerEntry) string { + if entry.Login != "" { + return entry.Login + } + return entry.ConfigLogin +} + +func normalizeKey(value string) string { + return strings.ToLower(strings.TrimSpace(value)) +} + +func renderColumns(left, right []string, totalWidth int) []string { + if totalWidth <= 0 { + totalWidth = defaultViewWidth + } + + leftWidth, rightWidth := splitWidths(totalWidth) + height := len(left) + if len(right) > height { + height = len(right) + } + + lines := make([]string, 0, height) + for index := 0; index < height; index++ { + leftLine := "" + if index < len(left) { + leftLine = left[index] + } + rightLine := "" + if index < len(right) { + rightLine = right[index] + } + lines = append(lines, fitWidth(leftLine, leftWidth)+" │ "+fitWidth(rightLine, rightWidth)) + } + return lines +} + +func splitWidths(totalWidth int) (int, int) { + if totalWidth < 8 { + return max(1, totalWidth-4), 1 + } + + leftWidth := totalWidth / 3 + if leftWidth < 20 { + leftWidth = 20 + } + if leftWidth > 32 { + leftWidth = 32 + } + + rightWidth := totalWidth - leftWidth - 3 + if rightWidth < 8 { + rightWidth = 8 + leftWidth = totalWidth - rightWidth - 3 + if leftWidth < 1 { + leftWidth = 1 + rightWidth = max(1, totalWidth-leftWidth-3) + } + } + return leftWidth, rightWidth +} + +func fitWidth(value string, width int) string { + if width <= 0 { + return "" + } + + runes := []rune(value) + if len(runes) > width { + return string(runes[:width]) + } + return value + strings.Repeat(" ", width-len(runes)) +} + +func clipListLines(lines []string, height, selectedLine int) []string { + if height <= 0 || len(lines) <= height { + return lines + } + if height == 1 { + return lines[:1] + } + + rows := lines[1:] + visibleRows := height - 1 + selectedRow := selectedLine - 1 + if selectedRow < 0 { + selectedRow = 0 + } + start := 0 + if selectedRow >= visibleRows { + start = selectedRow - visibleRows + 1 + } + if start+visibleRows > len(rows) { + start = len(rows) - visibleRows + } + if start < 0 { + start = 0 + } + return append([]string{lines[0]}, rows[start:start+visibleRows]...) +} + +func clipTailLines(lines []string, height int) []string { + if height <= 0 || len(lines) <= height { + return lines + } + if height == 1 { + return lines[:1] + } + + body := lines[1:] + visibleRows := height - 1 + if len(body) > visibleRows { + body = body[len(body)-visibleRows:] + } + return append([]string{lines[0]}, body...) +} + // startAuthSession starts the background auth worker and returns its update channel to Bubble Tea. func startAuthSession(start StartAuthFunc) tea.Cmd { return func() tea.Msg { diff --git a/internal/tui/model_test.go b/internal/tui/model_test.go index cbf6b48..7f4125a 100644 --- a/internal/tui/model_test.go +++ b/internal/tui/model_test.go @@ -1,26 +1,38 @@ package tui import ( + "strings" "testing" + tea "github.com/charmbracelet/bubbletea" "parasocial/internal/auth" "parasocial/internal/twitch" ) -func TestViewDisplaysStreamers(t *testing.T) { +func TestViewDisplaysSplitPaneWithSelectedStreamerDetails(t *testing.T) { model := New(Options{ Streamers: []twitch.StreamerEntry{ {ConfigLogin: "alpha", Login: "alpha_live", ChannelID: "1", Live: true, Status: twitch.StreamerReady}, - {ConfigLogin: "beta", Status: twitch.StreamerLoading}, + {ConfigLogin: "beta", Login: "beta_live", ChannelID: "2", Live: true, Status: twitch.StreamerReady}, + {ConfigLogin: "gamma", Status: twitch.StreamerLoading}, }, }) model.mode = streamerView model.viewer = &twitch.Viewer{ID: "7", Login: "viewer"} + model.width = 80 got := model.View() - want := "Logged in as viewer (7)\n\nStreamers\n1. alpha_live \x1b[32m●\x1b[0m (1)\n2. beta [loading]\n\n" - if got != want { - t.Fatalf("View() = %q, want %q", got, want) + if !strings.Contains(got, "Watching: alpha_live, beta_live\n\n") { + t.Fatalf("View() missing watching summary:\n%s", got) + } + if !strings.Contains(got, "> alpha_live [active]") { + t.Fatalf("View() missing selected active row:\n%s", got) + } + if !strings.Contains(got, "gamma [inactive]") { + t.Fatalf("View() missing inactive row:\n%s", got) + } + if !strings.Contains(got, "IRC Chat") || !strings.Contains(got, "not joined") { + t.Fatalf("View() missing detail pane:\n%s", got) } } @@ -68,9 +80,11 @@ func TestAuthSuccessSwitchesToStreamerViewAndStartsResolution(t *testing.T) { next := updated.(Model) got := next.View() - want := "Logged in as viewer\nResolving viewer identity...\n\nStreamers\n1. alpha [loading]\n\n" - if got != want { - t.Fatalf("View() = %q, want %q", got, want) + if !strings.Contains(got, "Watching: no live streamers") { + t.Fatalf("View() = %q", got) + } + if next.selectedConfig != "alpha" { + t.Fatalf("selectedConfig = %q, want alpha", next.selectedConfig) } } @@ -121,14 +135,12 @@ func TestStreamerUpdateAppliesEntry(t *testing.T) { } next := updated.(Model) - got := next.View() - want := "Logged in as viewer (7)\n\nStreamers\n1. alpha_live \x1b[32m●\x1b[0m (1)\n\n" - if got != want { - t.Fatalf("View() = %q, want %q", got, want) + if next.selectedConfig != "alpha" { + t.Fatalf("selectedConfig = %q, want alpha", next.selectedConfig) } } -func TestViewDisplaysOfflineReadyStreamerWithoutMarker(t *testing.T) { +func TestViewDisplaysInactiveDetailForOfflineStreamer(t *testing.T) { model := New(Options{ Streamers: []twitch.StreamerEntry{ {ConfigLogin: "alpha", Login: "alpha_live", ChannelID: "1", Status: twitch.StreamerReady}, @@ -136,10 +148,132 @@ func TestViewDisplaysOfflineReadyStreamerWithoutMarker(t *testing.T) { }) model.mode = streamerView model.viewer = &twitch.Viewer{ID: "7", Login: "viewer"} + model.width = 80 got := model.View() - want := "Logged in as viewer (7)\n\nStreamers\n1. alpha_live (1)\n\n" - if got != want { - t.Fatalf("View() = %q, want %q", got, want) + if !strings.Contains(got, "inactive") { + t.Fatalf("View() = %q", got) + } +} + +func TestActiveStreamersRenderBeforeInactiveInConfigOrder(t *testing.T) { + model := New(Options{ + Streamers: []twitch.StreamerEntry{ + {ConfigLogin: "alpha", Login: "alpha_live", Status: twitch.StreamerReady}, + {ConfigLogin: "beta", Login: "beta_live", Live: true, Status: twitch.StreamerReady}, + {ConfigLogin: "gamma", Login: "gamma_live", Live: true, Status: twitch.StreamerReady}, + {ConfigLogin: "delta", Status: twitch.StreamerLoading}, + }, + }) + + rows := model.orderedRows() + got := []string{rows[0].entry.ConfigLogin, rows[1].entry.ConfigLogin, rows[2].entry.ConfigLogin, rows[3].entry.ConfigLogin} + want := []string{"beta", "gamma", "alpha", "delta"} + for index := range want { + if got[index] != want[index] { + t.Fatalf("row order = %#v, want %#v", got, want) + } + } +} + +func TestSelectionTracksSameStreamerAcrossReorder(t *testing.T) { + model := New(Options{ + Streamers: []twitch.StreamerEntry{ + {ConfigLogin: "alpha", Login: "alpha_live", Status: twitch.StreamerReady}, + {ConfigLogin: "beta", Login: "beta_live", Status: twitch.StreamerReady}, + }, + AuthState: &auth.State{Login: "viewer"}, + }) + model.mode = streamerView + model.selectedConfig = "beta" + + updated, _ := model.Update(StreamerUpdate{ + Index: 1, + Entry: &twitch.StreamerEntry{ + ConfigLogin: "beta", + Login: "beta_live", + Live: true, + Status: twitch.StreamerReady, + }, + }) + next := updated.(Model) + + if next.selectedConfig != "beta" { + t.Fatalf("selectedConfig = %q, want beta", next.selectedConfig) + } + if next.selectedRowIndex(next.orderedRows()) != 0 { + t.Fatalf("selected row index = %d, want 0", next.selectedRowIndex(next.orderedRows())) + } +} + +func TestUpDownNavigationMovesSelectedStreamer(t *testing.T) { + model := New(Options{ + Streamers: []twitch.StreamerEntry{ + {ConfigLogin: "alpha", Login: "alpha_live", Live: true, Status: twitch.StreamerReady}, + {ConfigLogin: "beta", Login: "beta_live", Status: twitch.StreamerReady}, + }, + AuthState: &auth.State{Login: "viewer"}, + }) + model.mode = streamerView + model.selectedConfig = "alpha" + + updated, _ := model.Update(tea.KeyMsg{Type: tea.KeyDown}) + next := updated.(Model) + if next.selectedConfig != "beta" { + t.Fatalf("selectedConfig after down = %q, want beta", next.selectedConfig) + } + + updated, _ = next.Update(tea.KeyMsg{Type: tea.KeyUp}) + next = updated.(Model) + if next.selectedConfig != "alpha" { + t.Fatalf("selectedConfig after up = %q, want alpha", next.selectedConfig) + } +} + +func TestIRCUpdatesShowJoinedStatusOnly(t *testing.T) { + model := New(Options{ + Streamers: []twitch.StreamerEntry{ + {ConfigLogin: "alpha", Login: "alpha_live", Live: true, Status: twitch.StreamerReady}, + }, + AuthState: &auth.State{Login: "viewer"}, + }) + model.mode = streamerView + model.width = 80 + + updated, _ := model.Update(StreamerUpdate{ + IRC: &IRCUpdate{ + Login: "alpha_live", + State: IRCJoined, + }, + }) + next := updated.(Model) + + detail := next.ircDetails["alpha_live"] + if !detail.joined { + t.Fatal("expected joined detail") + } + if !strings.Contains(next.View(), greenDot) { + t.Fatalf("View() missing green status dot:\n%s", next.View()) + } +} + +func TestWindowSizeClipsListAroundSelection(t *testing.T) { + model := New(Options{ + Streamers: []twitch.StreamerEntry{ + {ConfigLogin: "alpha", Status: twitch.StreamerReady}, + {ConfigLogin: "beta", Status: twitch.StreamerReady}, + {ConfigLogin: "gamma", Status: twitch.StreamerReady}, + {ConfigLogin: "delta", Status: twitch.StreamerReady}, + }, + AuthState: &auth.State{Login: "viewer"}, + }) + model.mode = streamerView + model.selectedConfig = "delta" + + updated, _ := model.Update(tea.WindowSizeMsg{Width: 60, Height: 6}) + next := updated.(Model) + got := next.View() + if !strings.Contains(got, "> delta [inactive]") { + t.Fatalf("View() missing selected row after clipping:\n%s", got) } }