feat: add miner event log tab
This commit is contained in:
@@ -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()
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
+89
-17
@@ -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,18 +458,22 @@ 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
|
||||
}
|
||||
}
|
||||
|
||||
func (m Model) isStreamersFocused() bool {
|
||||
return m.focus == focusStreamers
|
||||
}
|
||||
|
||||
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 {
|
||||
|
||||
+131
-10
@@ -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 {
|
||||
|
||||
+16
-1
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user