Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
254 changes: 254 additions & 0 deletions internal/monitor/detector_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,254 @@
package monitor

import (
"testing"

"github.com/yuu1111/StreamNotifier/internal/config"
)

func TestDetectChanges_OfflineTitleChange_NoNotification(t *testing.T) {
oldState := &StreamerState{
Username: "test",
IsLive: false,
Title: "Old Title",
GameID: "100",
GameName: "Old Game",
}
newState := StreamerState{
Username: "test",
IsLive: false,
Title: "New Title",
GameID: "200",
GameName: "New Game",
}

changes := DetectChanges(oldState, newState)

for _, c := range changes {
if c.Type == config.ChangeTitleChange {
t.Error("should not detect title change when both states are offline")
}
if c.Type == config.ChangeGameChange {
t.Error("should not detect game change when both states are offline")
}
}
}

func TestDetectChanges_LiveToOffline_NoTitleChange(t *testing.T) {
oldState := &StreamerState{
Username: "test",
IsLive: true,
Title: "Live Title",
GameID: "100",
GameName: "Game",
StartedAt: "2026-03-29T10:00:00Z",
}
newState := StreamerState{
Username: "test",
IsLive: false,
Title: "Changed Offline Title",
GameID: "200",
GameName: "New Game",
}

changes := DetectChanges(oldState, newState)

hasOffline := false
for _, c := range changes {
switch c.Type {
case config.ChangeOffline:
hasOffline = true
case config.ChangeTitleChange:
t.Error("should not detect title change on live→offline transition")
case config.ChangeGameChange:
t.Error("should not detect game change on live→offline transition")
}
}

if !hasOffline {
t.Error("should detect offline event")
}
}

func TestDetectChanges_LiveTitleChange(t *testing.T) {
oldState := &StreamerState{
Username: "test",
IsLive: true,
Title: "Old Title",
GameID: "100",
GameName: "Game",
}
newState := StreamerState{
Username: "test",
IsLive: true,
Title: "New Title",
GameID: "100",
GameName: "Game",
}

changes := DetectChanges(oldState, newState)

if len(changes) != 1 {
t.Fatalf("expected 1 change, got %d", len(changes))
}
if changes[0].Type != config.ChangeTitleChange {
t.Errorf("expected titleChange, got %s", changes[0].Type)
}
if changes[0].OldValue != "Old Title" {
t.Errorf("expected old value 'Old Title', got '%s'", changes[0].OldValue)
}
if changes[0].NewValue != "New Title" {
t.Errorf("expected new value 'New Title', got '%s'", changes[0].NewValue)
}
}

func TestDetectChanges_LiveGameChange(t *testing.T) {
oldState := &StreamerState{
Username: "test",
IsLive: true,
GameID: "100",
GameName: "Old Game",
}
newState := StreamerState{
Username: "test",
IsLive: true,
GameID: "200",
GameName: "New Game",
}

changes := DetectChanges(oldState, newState)

if len(changes) != 1 {
t.Fatalf("expected 1 change, got %d", len(changes))
}
if changes[0].Type != config.ChangeGameChange {
t.Errorf("expected gameChange, got %s", changes[0].Type)
}
}

func TestDetectChanges_Online(t *testing.T) {
oldState := &StreamerState{Username: "test", IsLive: false}
newState := StreamerState{Username: "test", IsLive: true}

changes := DetectChanges(oldState, newState)

if len(changes) != 1 {
t.Fatalf("expected 1 change, got %d", len(changes))
}
if changes[0].Type != config.ChangeOnline {
t.Errorf("expected online, got %s", changes[0].Type)
}
}

func TestDetectChanges_Offline(t *testing.T) {
oldState := &StreamerState{
Username: "test",
IsLive: true,
StartedAt: "2026-03-29T10:00:00Z",
}
newState := StreamerState{Username: "test", IsLive: false}

changes := DetectChanges(oldState, newState)

if len(changes) != 1 {
t.Fatalf("expected 1 change, got %d", len(changes))
}
if changes[0].Type != config.ChangeOffline {
t.Errorf("expected offline, got %s", changes[0].Type)
}
}

func TestDetectChanges_NilOldState(t *testing.T) {
newState := StreamerState{Username: "test", IsLive: true}

changes := DetectChanges(nil, newState)

if changes != nil {
t.Errorf("expected nil for nil oldState, got %v", changes)
}
}

func TestDetectChanges_LiveTitleAndGameChange(t *testing.T) {
oldState := &StreamerState{
Username: "test",
IsLive: true,
Title: "Old Title",
GameID: "100",
GameName: "Old Game",
}
newState := StreamerState{
Username: "test",
IsLive: true,
Title: "New Title",
GameID: "200",
GameName: "New Game",
}

changes := DetectChanges(oldState, newState)

if len(changes) != 2 {
t.Fatalf("expected 2 changes (title + game), got %d", len(changes))
}

hasTitle, hasGame := false, false
for _, c := range changes {
switch c.Type {
case config.ChangeTitleChange:
hasTitle = true
case config.ChangeGameChange:
hasGame = true
}
}
if !hasTitle {
t.Error("expected title change")
}
if !hasGame {
t.Error("expected game change")
}
}

func TestDetectChanges_NoChange(t *testing.T) {
state := &StreamerState{
Username: "test",
IsLive: true,
Title: "Same Title",
GameID: "100",
GameName: "Same Game",
}
newState := StreamerState{
Username: "test",
IsLive: true,
Title: "Same Title",
GameID: "100",
GameName: "Same Game",
}

changes := DetectChanges(state, newState)

if len(changes) != 0 {
t.Errorf("expected 0 changes, got %d", len(changes))
}
}

func TestDetectChanges_TitleClearedToEmpty_NoNotification(t *testing.T) {
oldState := &StreamerState{
Username: "test",
IsLive: true,
Title: "Some Title",
GameID: "100",
}
newState := StreamerState{
Username: "test",
IsLive: true,
Title: "",
GameID: "100",
}

changes := DetectChanges(oldState, newState)

for _, c := range changes {
if c.Type == config.ChangeTitleChange {
t.Error("should not detect title change when new title is empty")
}
}
}
30 changes: 28 additions & 2 deletions internal/monitor/poller.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,20 +16,31 @@ import (
// ChangeHandler は変更検出時に呼び出されるコールバック型
type ChangeHandler func(changes []DetectedChange, streamerConfig config.StreamerConfig)

// TwitchAPI はPollerが必要とするTwitch APIの操作を定義するinterface
type TwitchAPI interface {
GetUsers(ctx context.Context, logins []string) (map[string]twitch.User, error)
GetStreams(ctx context.Context, userLogins []string) (map[string]twitch.Stream, error)
GetChannels(ctx context.Context, broadcasterIDs []string) (map[string]twitch.Channel, error)
GetLatestVod(ctx context.Context, userID string) (*twitch.Video, error)
}

// Poller は配信者の状態を定期的にポーリングし変更を検出する
type Poller struct {
api *twitch.API
api TwitchAPI
cfg *config.Config
configPath string
statePath string
onChanges ChangeHandler
stateManager *StateManager
userCache map[string]twitch.User
lastConfigMod time.Time
// emptyPending はTitle/GameIDが両方空になった配信者を一時保留する
// (Twitch APIの一過性の空レスポンスを誤検出しないための2回確認用)
emptyPending map[string]bool
}

// NewPoller はPollerインスタンスを作成する
func NewPoller(api *twitch.API, cfg *config.Config, configPath string, statePath string, onChanges ChangeHandler) *Poller {
func NewPoller(api TwitchAPI, cfg *config.Config, configPath string, statePath string, onChanges ChangeHandler) *Poller {
return &Poller{
api: api,
cfg: cfg,
Expand All @@ -38,6 +49,7 @@ func NewPoller(api *twitch.API, cfg *config.Config, configPath string, statePath
onChanges: onChanges,
stateManager: NewStateManager(),
userCache: make(map[string]twitch.User),
emptyPending: make(map[string]bool),
}
}

Expand Down Expand Up @@ -157,6 +169,7 @@ func (p *Poller) checkConfigReload(ctx context.Context) time.Duration {
for name := range oldStreamers {
if !newStreamers[name] {
delete(p.userCache, name)
delete(p.emptyPending, name)
p.stateManager.DeleteState(name)
slog.Info("配信者を削除", "username", name)
}
Expand Down Expand Up @@ -318,6 +331,19 @@ func (p *Poller) processStreamer(
oldState := p.stateManager.GetState(key)
isInitialPoll := oldState == nil

// Title/GameIDが両方空になった場合は一過性のAPIエラーの可能性があるため、
// 次のポーリングで同じ状態が継続したら確定として通知する
if oldState != nil && oldState.IsLive && newState.IsLive &&
newState.Title == "" && newState.GameID == "" &&
(oldState.Title != "" || oldState.GameID != "") {
if !p.emptyPending[key] {
p.emptyPending[key] = true
slog.Info("Title/Gameが両方空、次回ポーリングで確認", "streamer", newState.DisplayName)
return false
}
}
delete(p.emptyPending, key)

if isInitialPoll {
status := "オフライン"
if newState.IsLive {
Expand Down
Loading
Loading