diff --git a/internal/miner/manager.go b/internal/miner/manager.go index 9583196..bf6e3ee 100644 --- a/internal/miner/manager.go +++ b/internal/miner/manager.go @@ -2,6 +2,7 @@ package miner import ( "context" + "fmt" "sync" "time" @@ -28,11 +29,18 @@ type PubSubSyncer interface { Close() error } +// LogEntry is one miner log line associated with a resolved streamer login. +type LogEntry struct { + Login string + Line string +} + // Manager owns background channel points mining state for the current authenticated user. type Manager struct { ctx context.Context service Service pubsub PubSubSyncer + onLog func(LogEntry) watchInterval time.Duration sleep func(context.Context, time.Duration) error @@ -62,11 +70,12 @@ type streamerState struct { } // NewManager constructs one background miner manager. -func NewManager(ctx context.Context, service Service, pubsub PubSubSyncer) *Manager { +func NewManager(ctx context.Context, service Service, pubsub PubSubSyncer, onLog func(LogEntry)) *Manager { manager := &Manager{ ctx: ctx, service: service, pubsub: pubsub, + onLog: onLog, watchInterval: defaultWatchInterval, sleep: sleepContext, entries: make(map[string]*streamerState), @@ -174,15 +183,21 @@ func (m *Manager) scheduleSeed(configLogin string) { defer m.finishSeeding(configLogin) channelPoints, err := m.service.LoadChannelPointsContext(m.ctx, login) - if err == nil { + if err != nil { + m.logf(login, "channel points seed failed: %v", err) + } else { m.mu.Lock() if current, ok := m.entries[configLogin]; ok && current.ChannelID == channelID { current.ChannelPoints = channelPoints.Balance current.seeded = true } m.mu.Unlock() + m.logf(login, "seeded channel points balance: %d", channelPoints.Balance) if channelPoints.ClaimID != "" { - _ = m.service.ClaimCommunityPoints(m.ctx, channelID, channelPoints.ClaimID) + m.logf(login, "claiming bonus chest: %s", channelPoints.ClaimID) + if err := m.service.ClaimCommunityPoints(m.ctx, channelID, channelPoints.ClaimID); err != nil { + m.logf(login, "claim failed: %v", err) + } } } @@ -217,20 +232,23 @@ func (m *Manager) scheduleRefresh(configLogin string) { spadeURL, err := m.service.FetchSpadeURL(m.ctx, login) if err != nil { + m.logf(login, "metadata refresh failed: fetch spade url: %v", err) return } metadata, err := m.service.StreamMetadata(m.ctx, login) if err != nil { + m.logf(login, "metadata refresh failed: stream metadata: %v", err) return } m.mu.Lock() - defer m.mu.Unlock() if current, ok := m.entries[configLogin]; ok && current.ChannelID == channelID { current.SpadeURL = spadeURL current.Metadata = metadata current.Live = true } + m.mu.Unlock() + m.logf(login, "refreshed playback metadata") }() } @@ -297,14 +315,17 @@ func (m *Manager) watchOnce(ctx context.Context) error { token, err := m.service.PlaybackAccessToken(ctx, candidate.login) if err != nil { + m.logf(candidate.login, "watch failed: playback token: %v", err) continue } if err := m.service.TouchPlayback(ctx, candidate.login, token); err != nil { + m.logf(candidate.login, "watch failed: touch playback: %v", err) continue } payload := twitch.BuildMinuteWatchedPayload(viewer.ID, candidate.channelID, candidate.login, candidate.metadata) if err := m.service.SendMinuteWatched(ctx, candidate.spadeURL, payload); err != nil { + m.logf(candidate.login, "watch failed: minute watched: %v", err) continue } @@ -323,6 +344,8 @@ func (m *Manager) handlePubSubEvent(event Event) { return } + m.logEvent(state.Login, event) + switch event.MessageType { case "points-earned", "points-spent": m.mu.Lock() @@ -332,7 +355,10 @@ func (m *Manager) handlePubSubEvent(event Event) { m.mu.Unlock() case "claim-available": if event.ClaimID != "" { - _ = m.service.ClaimCommunityPoints(m.ctx, state.ChannelID, event.ClaimID) + m.logf(state.Login, "claiming bonus chest: %s", event.ClaimID) + if err := m.service.ClaimCommunityPoints(m.ctx, state.ChannelID, event.ClaimID); err != nil { + m.logf(state.Login, "claim failed: %v", err) + } } case "stream-up": m.mu.Lock() @@ -359,6 +385,39 @@ func (m *Manager) handlePubSubEvent(event Event) { } } +func (m *Manager) logEvent(login string, event Event) { + switch event.MessageType { + case "points-earned": + m.logf(login, "pubsub points earned: balance=%d", event.Balance) + case "points-spent": + m.logf(login, "pubsub points spent: balance=%d", event.Balance) + case "claim-available": + if event.ClaimID != "" { + m.logf(login, "pubsub claim available: %s", event.ClaimID) + return + } + m.logf(login, "pubsub claim available") + case "stream-up": + m.logf(login, "pubsub stream up") + case "stream-down": + m.logf(login, "pubsub stream down") + case "viewcount": + m.logf(login, "pubsub viewcount heartbeat") + default: + m.logf(login, "pubsub %s", event.MessageType) + } +} + +func (m *Manager) logf(login, format string, args ...any) { + if m.onLog == nil || login == "" { + return + } + m.onLog(LogEntry{ + Login: login, + Line: fmt.Sprintf(format, args...), + }) +} + func (m *Manager) streamerForChannel(channelID string) (string, *streamerState) { m.mu.Lock() defer m.mu.Unlock() diff --git a/internal/miner/manager_test.go b/internal/miner/manager_test.go index 4e13d5f..f6a6be4 100644 --- a/internal/miner/manager_test.go +++ b/internal/miner/manager_test.go @@ -16,6 +16,7 @@ type fakeService struct { metadata map[string]*twitch.StreamMetadata spadeURLs map[string]string playbackErr map[string]error + claimErr map[string]error claimed []string watched []string @@ -31,6 +32,9 @@ func (f *fakeService) LoadChannelPointsContext(_ context.Context, login string) func (f *fakeService) ClaimCommunityPoints(_ context.Context, channelID, claimID string) error { f.claimed = append(f.claimed, channelID+":"+claimID) + if err, ok := f.claimErr[channelID+":"+claimID]; ok { + return err + } return nil } @@ -90,7 +94,7 @@ func TestManagerSyncSeedsAndClaims(t *testing.T) { }, } pubsub := &fakePubSub{} - manager := NewManager(context.Background(), service, pubsub) + manager := NewManager(context.Background(), service, pubsub, nil) defer manager.Close() manager.sleep = cancelSleep @@ -125,7 +129,7 @@ func TestManagerWatchOnceUsesTopTwoLiveStreamers(t *testing.T) { "gamma_live": {Balance: 30}, }, } - manager := NewManager(context.Background(), service, &fakePubSub{}) + manager := NewManager(context.Background(), service, &fakePubSub{}, nil) defer manager.Close() manager.sleep = cancelSleep @@ -156,7 +160,7 @@ func TestManagerWatchOnceUsesTopTwoLiveStreamers(t *testing.T) { func TestHandlePubSubEventUpdatesBalanceAndClaims(t *testing.T) { service := &fakeService{} - manager := NewManager(context.Background(), service, &fakePubSub{}) + manager := NewManager(context.Background(), service, &fakePubSub{}, nil) defer manager.Close() manager.entries["alpha"] = &streamerState{ConfigLogin: "alpha", ChannelID: "1", Login: "alpha_live", Live: true} @@ -173,6 +177,32 @@ func TestHandlePubSubEventUpdatesBalanceAndClaims(t *testing.T) { } } +func TestManagerLogsPubSubEventsAndFailures(t *testing.T) { + service := &fakeService{ + claimErr: map[string]error{ + "1:claim-2": errors.New("claim rejected"), + }, + } + var logs []LogEntry + manager := NewManager(context.Background(), service, &fakePubSub{}, func(entry LogEntry) { + logs = append(logs, entry) + }) + defer manager.Close() + manager.entries["alpha"] = &streamerState{ConfigLogin: "alpha", ChannelID: "1", Login: "alpha_live", Live: true} + + manager.handlePubSubEvent(Event{MessageType: "points-earned", ChannelID: "1", Balance: 555, Timestamp: "t1", Topic: "community-points-user-v1"}) + manager.handlePubSubEvent(Event{MessageType: "claim-available", ChannelID: "1", ClaimID: "claim-2", Timestamp: "t2", Topic: "community-points-user-v1"}) + + if got, want := logs, []LogEntry{ + {Login: "alpha_live", Line: "pubsub points earned: balance=555"}, + {Login: "alpha_live", Line: "pubsub claim available: claim-2"}, + {Login: "alpha_live", Line: "claiming bonus chest: claim-2"}, + {Login: "alpha_live", Line: "claim failed: claim rejected"}, + }; !reflect.DeepEqual(got, want) { + t.Fatalf("logs = %#v, want %#v", got, want) + } +} + func cancelSleep(context.Context, time.Duration) error { return context.Canceled } func waitFor(t *testing.T, condition func() bool) { diff --git a/internal/tui/messages.go b/internal/tui/messages.go index 0736af9..5e5fd1c 100644 --- a/internal/tui/messages.go +++ b/internal/tui/messages.go @@ -27,6 +27,7 @@ type StreamerUpdate struct { Viewer *twitch.Viewer Entry *twitch.StreamerEntry IRC *IRCUpdate + Miner *MinerUpdate Index int Err error Done bool @@ -39,6 +40,12 @@ type IRCUpdate struct { Line string } +// MinerUpdate carries one miner log line into the TUI. +type MinerUpdate struct { + Login string + Line string +} + type authStartedMsg struct { Updates <-chan AuthUpdate } diff --git a/internal/tui/model.go b/internal/tui/model.go index d5d04be..bba2f32 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -26,7 +26,8 @@ type panelFocus int const ( focusStreamers panelFocus = iota focusInfo - focusChat + focusIRC + focusMiner ) type detailTab int @@ -34,6 +35,7 @@ type detailTab int const ( infoTab detailTab = iota ircTab + minerTab ) type modelRuntime interface { @@ -58,8 +60,10 @@ type Model struct { selectedConfig string focus panelFocus ircDetails map[string]ircDetail + minerDetails map[string][]string authViewport viewport.Model ircViewport viewport.Model + minerViewport viewport.Model } type ircDetail struct { @@ -81,18 +85,23 @@ func New(options Options) Model { authLogs: []string{}, mode: authView, ircDetails: make(map[string]ircDetail), + minerDetails: make(map[string][]string), width: defaultViewWidth, height: 24, focus: focusStreamers, authViewport: newAuthViewport(contentWidth(defaultViewWidth), authViewportHeight(24)), ircViewport: newIRCViewport(detailViewportWidth(defaultViewWidth), detailViewportHeight(24)), + minerViewport: newMinerViewport( + detailViewportWidth(defaultViewWidth), + detailViewportHeight(24), + ), } if options.AuthState != nil { model.mode = streamerView } model.ensureSelection() model.syncAuthViewport() - model.syncIRCViewport(true) + model.syncDetailViewports(true) return model } @@ -122,7 +131,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.height = msg.Height m.resizeComponents() m.ensureSelection() - m.syncIRCViewport(false) + m.syncDetailViewports(false) return m, nil case tea.KeyMsg: switch msg.String() { @@ -162,11 +171,16 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.authViewport, cmd = m.authViewport.Update(msg) return m, cmd } - if m.focus == focusChat { + if m.focus == focusIRC { var cmd tea.Cmd m.ircViewport, cmd = m.ircViewport.Update(msg) return m, cmd } + if m.focus == focusMiner { + var cmd tea.Cmd + m.minerViewport, cmd = m.minerViewport.Update(msg) + return m, cmd + } return m, nil } @@ -189,11 +203,12 @@ func (m Model) updateAuth(msg AuthUpdate) (tea.Model, tea.Cmd) { m.resolveErr = nil m.streamers = loadingEntries(m.streamers) m.ircDetails = make(map[string]ircDetail) + m.minerDetails = make(map[string][]string) m.selectedConfig = "" m.focus = focusStreamers m.ensureSelection() m.resizeComponents() - m.syncIRCViewport(true) + m.syncDetailViewports(true) return m, startStreamerResolution(m.runtime, m.authState) } return m, nil @@ -215,12 +230,15 @@ func (m Model) updateStreamer(msg StreamerUpdate) (tea.Model, tea.Cmd) { if msg.IRC != nil { m.applyIRCUpdate(*msg.IRC) } + if msg.Miner != nil { + m.applyMinerUpdate(*msg.Miner) + } if msg.Err != nil { m.resolveErr = msg.Err } m.ensureSelection() m.resizeComponents() - m.syncIRCViewport(false) + m.syncDetailViewports(false) if msg.Done { return m, nil } @@ -244,14 +262,20 @@ func (m *Model) applyIRCUpdate(update IRCUpdate) { detail.joined = false } if message, ok := formatIRCChatLine(update.Line); ok { - detail.messages = append(detail.messages, message) - if len(detail.messages) > maxIRCMessageHistory { - detail.messages = append([]string(nil), detail.messages[len(detail.messages)-maxIRCMessageHistory:]...) - } + detail.messages = appendCappedHistory(detail.messages, message) } m.ircDetails[login] = detail } +func (m *Model) applyMinerUpdate(update MinerUpdate) { + login := normalizeKey(update.Login) + line := strings.TrimSpace(update.Line) + if login == "" || line == "" { + return + } + m.minerDetails[login] = appendCappedHistory(m.minerDetails[login], line) +} + func (m *Model) ensureSelection() { entries := m.orderedStreamers() if len(entries) == 0 { @@ -274,7 +298,7 @@ func (m *Model) moveSelection(delta int) { entries := m.orderedStreamers() if len(entries) == 0 { m.selectedConfig = "" - m.syncIRCViewport(true) + m.syncDetailViewports(true) return } @@ -288,7 +312,7 @@ func (m *Model) moveSelection(delta int) { } m.selectedConfig = entries[selected].ConfigLogin if m.selectedConfig != current { - m.syncIRCViewport(true) + m.syncDetailViewports(true) } } @@ -296,8 +320,10 @@ func (m *Model) moveFocusLeft() { switch m.focus { case focusInfo: m.focus = focusStreamers - case focusChat: + case focusIRC: m.focus = focusInfo + case focusMiner: + m.focus = focusIRC } } @@ -306,7 +332,9 @@ func (m *Model) moveFocusRight() { case focusStreamers: m.focus = focusInfo case focusInfo: - m.focus = focusChat + m.focus = focusIRC + case focusIRC: + m.focus = focusMiner } } @@ -343,6 +371,8 @@ func (m *Model) resizeComponents() { m.authViewport.Height = authViewportHeight(m.height) m.ircViewport.Width = detailViewportWidth(m.width) m.ircViewport.Height = detailViewportHeight(m.height) + m.minerViewport.Width = detailViewportWidth(m.width) + m.minerViewport.Height = detailViewportHeight(m.height) } func (m *Model) syncAuthViewport() { @@ -369,6 +399,23 @@ func (m *Model) syncIRCViewport(forceBottom bool) { } } +func (m *Model) syncMinerViewport(forceBottom bool) { + if m.minerViewport.Width <= 0 || m.minerViewport.Height <= 0 { + return + } + + stickToBottom := forceBottom || m.minerViewport.AtBottom() + m.minerViewport.SetContent(m.minerViewportContent()) + if stickToBottom { + m.minerViewport.GotoBottom() + } +} + +func (m *Model) syncDetailViewports(forceBottom bool) { + m.syncIRCViewport(forceBottom) + m.syncMinerViewport(forceBottom) +} + func (m Model) ircViewportContent() string { entry, ok := m.selectedEntry() if !ok { @@ -388,6 +435,19 @@ func (m Model) ircViewportContent() string { return strings.Join(detail.messages, "\n") } +func (m Model) minerViewportContent() string { + entry, ok := m.selectedEntry() + if !ok { + return "No streamers configured" + } + + logins := m.minerDetails[normalizeKey(entry.Login)] + if len(logins) == 0 { + return "Waiting for miner activity..." + } + return strings.Join(logins, "\n") +} + func (m Model) selectedEntry() (twitch.StreamerEntry, bool) { entries := m.orderedStreamers() selected := m.selectedRowIndex(entries) @@ -398,10 +458,14 @@ func (m Model) selectedEntry() (twitch.StreamerEntry, bool) { } func (m Model) visibleDetailTab() detailTab { - if m.focus == focusChat { + switch m.focus { + case focusIRC: return ircTab + case focusMiner: + return minerTab + default: + return infoTab } - return infoTab } func (m Model) isStreamersFocused() bool { @@ -409,7 +473,7 @@ func (m Model) isStreamersFocused() bool { } func (m Model) isRightPanelFocused() bool { - return m.focus == focusInfo || m.focus == focusChat + return m.focus == focusInfo || m.focus == focusIRC || m.focus == focusMiner } func isActive(entry twitch.StreamerEntry) bool { @@ -450,6 +514,14 @@ func formatIRCChatLine(line string) (string, bool) { return user + ": " + message, true } +func appendCappedHistory(history []string, line string) []string { + history = append(history, line) + if len(history) <= maxIRCMessageHistory { + return history + } + return append([]string(nil), history[len(history)-maxIRCMessageHistory:]...) +} + func loadingEntries(entries []twitch.StreamerEntry) []twitch.StreamerEntry { logins := make([]string, 0, len(entries)) for _, entry := range entries { diff --git a/internal/tui/model_test.go b/internal/tui/model_test.go index 0a556f9..8b4de80 100644 --- a/internal/tui/model_test.go +++ b/internal/tui/model_test.go @@ -37,6 +37,7 @@ func TestViewDisplaysDashboardWithSelectedStreamerDetails(t *testing.T) { "Watching: alpha_live, beta_live", "Info", "IRC", + "Miner", "live | irc idle", "gamma", "loading", @@ -204,20 +205,32 @@ func TestFocusNavigationMovesBetweenPanels(t *testing.T) { updated, _ = next.Update(tea.KeyMsg{Type: tea.KeyRight}) next = updated.(Model) - if next.focus != focusChat || next.visibleDetailTab() != ircTab { - t.Fatalf("focus after second right = %v with tab %v, want %v and %v", next.focus, next.visibleDetailTab(), focusChat, ircTab) + if next.focus != focusIRC || next.visibleDetailTab() != ircTab { + t.Fatalf("focus after second right = %v with tab %v, want %v and %v", next.focus, next.visibleDetailTab(), focusIRC, ircTab) } updated, _ = next.Update(tea.KeyMsg{Type: tea.KeyRight}) next = updated.(Model) - if next.focus != focusChat { - t.Fatalf("focus after right on chat = %v, want %v", next.focus, focusChat) + if next.focus != focusMiner || next.visibleDetailTab() != minerTab { + t.Fatalf("focus after third right = %v with tab %v, want %v and %v", next.focus, next.visibleDetailTab(), focusMiner, minerTab) + } + + updated, _ = next.Update(tea.KeyMsg{Type: tea.KeyRight}) + next = updated.(Model) + if next.focus != focusMiner { + t.Fatalf("focus after right on miner = %v, want %v", next.focus, focusMiner) + } + + updated, _ = next.Update(tea.KeyMsg{Type: tea.KeyLeft}) + next = updated.(Model) + if next.focus != focusIRC { + t.Fatalf("focus after left from miner = %v, want %v", next.focus, focusIRC) } updated, _ = next.Update(tea.KeyMsg{Type: tea.KeyLeft}) next = updated.(Model) if next.focus != focusInfo { - t.Fatalf("focus after left from chat = %v, want %v", next.focus, focusInfo) + t.Fatalf("focus after left from irc = %v, want %v", next.focus, focusInfo) } updated, _ = next.Update(tea.KeyMsg{Type: tea.KeyLeft}) @@ -275,7 +288,7 @@ func TestIRCUpdatesShowJoinedStatusAndFormattedMessages(t *testing.T) { Line: ":someone!someone@someone.tmi.twitch.tv PRIVMSG #alpha_live :hello there", }}) next = updated.(Model) - next.focus = focusChat + next.focus = focusIRC next.syncIRCViewport(true) assertContainsAll(t, next.View(), "someone: hello there") @@ -286,7 +299,7 @@ func TestChatFocusUsesUpDownForViewportScroll(t *testing.T) { twitch.StreamerEntry{ConfigLogin: "alpha", Login: "alpha_live", Live: true, Status: twitch.StreamerReady}, twitch.StreamerEntry{ConfigLogin: "beta", Login: "beta_live", Status: twitch.StreamerReady}, ) - model.focus = focusChat + model.focus = focusIRC model.ircDetails["alpha_live"] = ircDetail{ joined: true, messages: numberedMessages(20), @@ -338,6 +351,56 @@ func TestIRCUpdatesKeepOnlyLast50ChatMessages(t *testing.T) { } } +func TestMinerUpdatesRenderAndKeepOnlyLast50Messages(t *testing.T) { + model := dashboardModel(twitch.StreamerEntry{ + ConfigLogin: "alpha", + Login: "alpha_live", + Live: true, + Status: twitch.StreamerReady, + }) + + next := model + for i := 1; i <= maxIRCMessageHistory+5; i++ { + updated, _ := next.Update(StreamerUpdate{Miner: &MinerUpdate{ + Login: "alpha_live", + Line: fmt.Sprintf("miner message %d", i), + }}) + next = updated.(Model) + } + next.focus = focusMiner + next.syncMinerViewport(true) + + logs := next.minerDetails["alpha_live"] + if len(logs) != maxIRCMessageHistory { + t.Fatalf("miner message count = %d, want %d", len(logs), maxIRCMessageHistory) + } + if logs[0] != "miner message 6" { + t.Fatalf("oldest retained miner message = %q", logs[0]) + } + if logs[len(logs)-1] != "miner message 55" { + t.Fatalf("newest retained miner message = %q", logs[len(logs)-1]) + } + assertContainsAll(t, next.View(), "miner message 55") +} + +func TestMinerTabShowsHistoryForOfflineStreamer(t *testing.T) { + model := dashboardModel(twitch.StreamerEntry{ + ConfigLogin: "alpha", + Login: "alpha_live", + Status: twitch.StreamerReady, + }) + + updated, _ := model.Update(StreamerUpdate{Miner: &MinerUpdate{ + Login: "alpha_live", + Line: "pubsub stream down", + }}) + next := updated.(Model) + next.focus = focusMiner + next.syncMinerViewport(true) + + assertContainsAll(t, next.View(), "pubsub stream down") +} + func TestIRCUpdatesIgnoreNonChatProtocolLines(t *testing.T) { model := dashboardModel(twitch.StreamerEntry{ ConfigLogin: "alpha", @@ -363,7 +426,7 @@ func TestIRCViewportAutoScrollsAtBottom(t *testing.T) { Live: true, Status: twitch.StreamerReady, }) - model.focus = focusChat + model.focus = focusIRC model.ircDetails["alpha_live"] = ircDetail{ joined: true, messages: numberedMessages(20), @@ -391,7 +454,7 @@ func TestIRCViewportPreservesManualScrollPosition(t *testing.T) { Live: true, Status: twitch.StreamerReady, }) - model.focus = focusChat + model.focus = focusIRC model.ircDetails["alpha_live"] = ircDetail{ joined: true, messages: numberedMessages(20), @@ -412,6 +475,56 @@ func TestIRCViewportPreservesManualScrollPosition(t *testing.T) { } } +func TestMinerViewportAutoScrollsAtBottom(t *testing.T) { + model := dashboardModel(twitch.StreamerEntry{ + ConfigLogin: "alpha", + Login: "alpha_live", + Live: true, + Status: twitch.StreamerReady, + }) + model.focus = focusMiner + model.minerDetails["alpha_live"] = numberedMinerMessages(20) + model.syncMinerViewport(true) + + if !model.minerViewport.AtBottom() { + t.Fatal("expected miner viewport to start at bottom") + } + + updated, _ := model.Update(StreamerUpdate{Miner: &MinerUpdate{ + Login: "alpha_live", + Line: "newest miner event", + }}) + next := updated.(Model) + if !next.minerViewport.AtBottom() { + t.Fatal("expected miner viewport to stay at bottom after new message") + } +} + +func TestMinerViewportPreservesManualScrollPosition(t *testing.T) { + model := dashboardModel(twitch.StreamerEntry{ + ConfigLogin: "alpha", + Login: "alpha_live", + Live: true, + Status: twitch.StreamerReady, + }) + model.focus = focusMiner + model.minerDetails["alpha_live"] = numberedMinerMessages(20) + model.syncMinerViewport(true) + model.minerViewport.GotoTop() + + updated, _ := model.Update(StreamerUpdate{Miner: &MinerUpdate{ + Login: "alpha_live", + Line: "newest miner event", + }}) + next := updated.(Model) + if next.minerViewport.YOffset != 0 { + t.Fatalf("miner viewport YOffset = %d, want 0", next.minerViewport.YOffset) + } + if next.minerViewport.AtBottom() { + t.Fatal("expected miner viewport to remain off bottom after manual scroll") + } +} + func TestWindowSizeKeepsSelectionVisible(t *testing.T) { model := dashboardModel( twitch.StreamerEntry{ConfigLogin: "alpha", Status: twitch.StreamerReady}, @@ -432,7 +545,7 @@ func dashboardModel(entries ...twitch.StreamerEntry) Model { model.width = 100 model.height = 28 model.resizeComponents() - model.syncIRCViewport(true) + model.syncDetailViewports(true) return model } @@ -444,6 +557,14 @@ func numberedMessages(count int) []string { return lines } +func numberedMinerMessages(count int) []string { + lines := make([]string, 0, count) + for i := 1; i <= count; i++ { + lines = append(lines, fmt.Sprintf("miner message %d", i)) + } + return lines +} + func assertContainsAll(t *testing.T, got string, wants ...string) { t.Helper() for _, want := range wants { diff --git a/internal/tui/runtime.go b/internal/tui/runtime.go index 998b7a3..81d1a66 100644 --- a/internal/tui/runtime.go +++ b/internal/tui/runtime.go @@ -136,7 +136,7 @@ func (r *runtime) startResolve(state *auth.State, ch chan<- StreamerUpdate) { ch <- StreamerUpdate{Err: err, Done: true} return } - minerManager := miner.NewManager(r.ctx, service, nil) + minerManager := newMinerManager(r.ctx, service, ch) defer minerManager.Close() if err := resolveStreamerEntriesWithSleep(r.ctx, service, state, r.logins, ircManager, minerManager, func(update StreamerUpdate) { @@ -208,6 +208,21 @@ func newIRCManager(ch chan<- StreamerUpdate) *irc.Manager { } } +func newMinerManager(ctx context.Context, service miner.Service, ch chan<- StreamerUpdate) *miner.Manager { + return miner.NewManager(ctx, service, nil, newMinerLogSink(ch)) +} + +func newMinerLogSink(ch chan<- StreamerUpdate) func(miner.LogEntry) { + return func(entry miner.LogEntry) { + ch <- StreamerUpdate{ + Miner: &MinerUpdate{ + Login: entry.Login, + Line: entry.Line, + }, + } + } +} + func resolveStreamerEntries(ctx context.Context, service streamerService, state *auth.State, logins []string, ircSyncer ircSyncer, minerSyncer minerSyncer, send func(StreamerUpdate)) error { return resolveStreamerEntriesWithSleep(ctx, service, state, logins, ircSyncer, minerSyncer, send, streamerRefreshInterval, sleepContext) } diff --git a/internal/tui/runtime_test.go b/internal/tui/runtime_test.go index b17bc68..979078e 100644 --- a/internal/tui/runtime_test.go +++ b/internal/tui/runtime_test.go @@ -9,6 +9,7 @@ import ( "parasocial/internal/auth" "parasocial/internal/irc" + "parasocial/internal/miner" "parasocial/internal/twitch" ) @@ -206,6 +207,23 @@ func TestResolveStreamerEntriesRefreshUpdatesWatchedChannels(t *testing.T) { }) } +func TestNewMinerLogSinkBridgesMinerEntriesIntoStreamerUpdates(t *testing.T) { + ch := make(chan StreamerUpdate, 1) + + newMinerLogSink(ch)(miner.LogEntry{ + Login: "alpha_live", + Line: "pubsub points earned: balance=42", + }) + + update := <-ch + if update.Miner == nil { + t.Fatal("expected miner update") + } + if update.Miner.Login != "alpha_live" || update.Miner.Line != "pubsub points earned: balance=42" { + t.Fatalf("miner update = %#v", update.Miner) + } +} + type serviceOption func(*fakeStreamerService) func streamService(options ...serviceOption) *fakeStreamerService { diff --git a/internal/tui/styles.go b/internal/tui/styles.go index 9321bfa..aafa365 100644 --- a/internal/tui/styles.go +++ b/internal/tui/styles.go @@ -67,3 +67,9 @@ func newIRCViewport(width, height int) viewport.Model { vp.Style = lipgloss.NewStyle() return vp } + +func newMinerViewport(width, height int) viewport.Model { + vp := viewport.New(width, height) + vp.Style = lipgloss.NewStyle() + return vp +} diff --git a/internal/tui/view.go b/internal/tui/view.go index 9a48b9d..de700c9 100644 --- a/internal/tui/view.go +++ b/internal/tui/view.go @@ -85,6 +85,8 @@ func (m Model) renderDetailPanel() string { switch m.visibleDetailTab() { case ircTab: body = m.renderIRCTab() + case minerTab: + body = m.renderMinerTab() default: body = m.renderInfoTab(entry) } @@ -95,6 +97,7 @@ func (m Model) renderDetailTabs() string { tabs := []string{ m.renderDetailTabButton(infoTab, "Info"), m.renderDetailTabButton(ircTab, "IRC"), + m.renderDetailTabButton(minerTab, "Miner"), } return lipgloss.JoinHorizontal(lipgloss.Top, tabs...) } @@ -132,6 +135,10 @@ func (m Model) renderIRCTab() string { return m.ircViewport.View() } +func (m Model) renderMinerTab() string { + return m.minerViewport.View() +} + func (m Model) renderStreamerRows() string { entries := m.visibleStreamers() if len(entries) == 0 {