diff --git a/internal/monitor/detector_test.go b/internal/monitor/detector_test.go new file mode 100644 index 0000000..2ffda6c --- /dev/null +++ b/internal/monitor/detector_test.go @@ -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") + } + } +} diff --git a/internal/monitor/poller.go b/internal/monitor/poller.go index 2d3c34c..2cf8e8c 100644 --- a/internal/monitor/poller.go +++ b/internal/monitor/poller.go @@ -16,9 +16,17 @@ 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 @@ -26,10 +34,13 @@ type Poller struct { 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, @@ -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), } } @@ -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) } @@ -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 { diff --git a/internal/monitor/poller_test.go b/internal/monitor/poller_test.go new file mode 100644 index 0000000..1598ef5 --- /dev/null +++ b/internal/monitor/poller_test.go @@ -0,0 +1,570 @@ +package monitor + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "testing" + "time" + + "github.com/yuu1111/StreamNotifier/internal/config" + "github.com/yuu1111/StreamNotifier/internal/twitch" +) + +// mockAPI はTwitchAPIのmock実装 +type mockAPI struct { + users map[string]twitch.User + streams map[string]twitch.Stream + channels map[string]twitch.Channel + vod *twitch.Video +} + +func newMockAPI() *mockAPI { + return &mockAPI{ + users: make(map[string]twitch.User), + streams: make(map[string]twitch.Stream), + channels: make(map[string]twitch.Channel), + } +} + +func (m *mockAPI) GetUsers(_ context.Context, logins []string) (map[string]twitch.User, error) { + result := make(map[string]twitch.User) + for _, login := range logins { + if u, ok := m.users[login]; ok { + result[login] = u + } + } + return result, nil +} + +func (m *mockAPI) GetStreams(_ context.Context, _ []string) (map[string]twitch.Stream, error) { + return m.streams, nil +} + +func (m *mockAPI) GetChannels(_ context.Context, _ []string) (map[string]twitch.Channel, error) { + return m.channels, nil +} + +func (m *mockAPI) GetLatestVod(_ context.Context, _ string) (*twitch.Video, error) { + return m.vod, nil +} + +func newTestConfig(streamers ...string) *config.Config { + var sc []config.StreamerConfig + for _, s := range streamers { + sc = append(sc, config.StreamerConfig{ + Username: s, + Webhooks: []config.WebhookConfig{ + { + URL: "https://discord.com/api/webhooks/test/test", + Notifications: config.NotificationSettings{Online: true, Offline: true, TitleChange: true, GameChange: true}, + }, + }, + }) + } + return &config.Config{ + Twitch: config.TwitchConfig{ClientID: "test", ClientSecret: "test"}, + Polling: config.PollingConfig{IntervalSeconds: 10}, + Streamers: sc, + Log: config.LogConfig{Level: "info"}, + } +} + +func TestPoller_Poll_DetectsOnline(t *testing.T) { + api := newMockAPI() + api.users["streamer1"] = twitch.User{ID: "1", Login: "streamer1", DisplayName: "Streamer1"} + api.streams["streamer1"] = twitch.Stream{ + UserLogin: "streamer1", + Title: "Hello!", + GameID: "100", + GameName: "Game", + StartedAt: time.Now().UTC().Format(time.RFC3339), + } + + cfg := newTestConfig("streamer1") + + var received []DetectedChange + dir := t.TempDir() + + poller := NewPoller(api, cfg, filepath.Join(dir, "config.json"), filepath.Join(dir, "state.json"), + func(changes []DetectedChange, _ config.StreamerConfig) { + received = append(received, changes...) + }) + poller.userCache = api.users + + ctx := context.Background() + poller.poll(ctx) + + // 初回ポーリングではprocessStreamerがonline通知を追加する + hasOnline := false + for _, c := range received { + if c.Type == config.ChangeOnline { + hasOnline = true + } + } + if !hasOnline { + t.Error("expected online notification on first poll with live streamer") + } +} + +func TestPoller_Poll_DetectsOffline(t *testing.T) { + api := newMockAPI() + api.users["streamer1"] = twitch.User{ID: "1", Login: "streamer1", DisplayName: "Streamer1"} + api.channels["streamer1"] = twitch.Channel{BroadcasterLogin: "streamer1", Title: "Offline", GameID: "100"} + + cfg := newTestConfig("streamer1") + + var received []DetectedChange + dir := t.TempDir() + + poller := NewPoller(api, cfg, filepath.Join(dir, "config.json"), filepath.Join(dir, "state.json"), + func(changes []DetectedChange, _ config.StreamerConfig) { + received = append(received, changes...) + }) + poller.userCache = api.users + + // 事前に配信中状態を設定 + poller.stateManager.UpdateState("streamer1", StreamerState{ + UserID: "1", + Username: "streamer1", + IsLive: true, + Title: "Live Stream", + GameID: "100", + }) + + ctx := context.Background() + poller.poll(ctx) + + hasOffline := false + for _, c := range received { + if c.Type == config.ChangeOffline { + hasOffline = true + } + } + if !hasOffline { + t.Error("expected offline notification when streamer goes offline") + } +} + +func TestPoller_Poll_TitleChangeWhileLive(t *testing.T) { + api := newMockAPI() + api.users["streamer1"] = twitch.User{ID: "1", Login: "streamer1", DisplayName: "Streamer1"} + api.streams["streamer1"] = twitch.Stream{ + UserLogin: "streamer1", + Title: "New Title", + GameID: "100", + GameName: "Game", + } + + cfg := newTestConfig("streamer1") + + var received []DetectedChange + dir := t.TempDir() + + poller := NewPoller(api, cfg, filepath.Join(dir, "config.json"), filepath.Join(dir, "state.json"), + func(changes []DetectedChange, _ config.StreamerConfig) { + received = append(received, changes...) + }) + poller.userCache = api.users + + // 事前に配信中状態を設定(旧タイトル) + poller.stateManager.UpdateState("streamer1", StreamerState{ + UserID: "1", + Username: "streamer1", + IsLive: true, + Title: "Old Title", + GameID: "100", + GameName: "Game", + }) + + ctx := context.Background() + poller.poll(ctx) + + hasTitleChange := false + for _, c := range received { + if c.Type == config.ChangeTitleChange { + hasTitleChange = true + if c.OldValue != "Old Title" || c.NewValue != "New Title" { + t.Errorf("unexpected title change values: %s → %s", c.OldValue, c.NewValue) + } + } + } + if !hasTitleChange { + t.Error("expected title change notification") + } +} + +func TestPoller_Poll_NoTitleChangeWhenGoingOffline(t *testing.T) { + api := newMockAPI() + api.users["streamer1"] = twitch.User{ID: "1", Login: "streamer1", DisplayName: "Streamer1"} + api.channels["streamer1"] = twitch.Channel{BroadcasterLogin: "streamer1", Title: "Different Title", GameID: "200"} + + cfg := newTestConfig("streamer1") + + var received []DetectedChange + dir := t.TempDir() + + poller := NewPoller(api, cfg, filepath.Join(dir, "config.json"), filepath.Join(dir, "state.json"), + func(changes []DetectedChange, _ config.StreamerConfig) { + received = append(received, changes...) + }) + poller.userCache = api.users + + poller.stateManager.UpdateState("streamer1", StreamerState{ + UserID: "1", + Username: "streamer1", + IsLive: true, + Title: "Original Title", + GameID: "100", + }) + + ctx := context.Background() + poller.poll(ctx) + + for _, c := range received { + if c.Type == config.ChangeTitleChange { + t.Error("should not detect title change on live→offline transition") + } + if c.Type == config.ChangeGameChange { + t.Error("should not detect game change on live→offline transition") + } + } +} + +func TestPoller_Poll_SavesStateToDisk(t *testing.T) { + api := newMockAPI() + api.users["streamer1"] = twitch.User{ID: "1", Login: "streamer1", DisplayName: "Streamer1"} + api.streams["streamer1"] = twitch.Stream{ + UserLogin: "streamer1", + Title: "Live!", + GameID: "100", + GameName: "Game", + } + + cfg := newTestConfig("streamer1") + + dir := t.TempDir() + statePath := filepath.Join(dir, "state.json") + + poller := NewPoller(api, cfg, filepath.Join(dir, "config.json"), statePath, + func(_ []DetectedChange, _ config.StreamerConfig) {}) + poller.userCache = api.users + + ctx := context.Background() + poller.poll(ctx) + + data, err := os.ReadFile(statePath) + if err != nil { + t.Fatalf("state file should exist after poll: %v", err) + } + + var states map[string]StreamerState + if err := json.Unmarshal(data, &states); err != nil { + t.Fatalf("state file should be valid JSON: %v", err) + } + + s, ok := states["streamer1"] + if !ok { + t.Fatal("streamer1 state not found in saved file") + } + if !s.IsLive { + t.Error("expected streamer1 to be live in saved state") + } + if s.Title != "Live!" { + t.Errorf("expected title 'Live!', got '%s'", s.Title) + } +} + +func TestPoller_Poll_RestoresStateOnRestart(t *testing.T) { + dir := t.TempDir() + statePath := filepath.Join(dir, "state.json") + + // 前回のセッションをシミュレート: 状態ファイルに配信中の状態を書き込む + previousState := map[string]StreamerState{ + "streamer1": { + UserID: "1", + Username: "streamer1", + DisplayName: "Streamer1", + IsLive: true, + Title: "Previous Stream", + GameID: "100", + GameName: "Game", + }, + } + data, _ := json.MarshalIndent(previousState, "", " ") + if err := os.WriteFile(statePath, data, 0644); err != nil { + t.Fatalf("WriteFile failed: %v", err) + } + + api := newMockAPI() + api.users["streamer1"] = twitch.User{ID: "1", Login: "streamer1", DisplayName: "Streamer1"} + api.streams["streamer1"] = twitch.Stream{ + UserLogin: "streamer1", + Title: "Previous Stream", + GameID: "100", + GameName: "Game", + } + + cfg := newTestConfig("streamer1") + + var received []DetectedChange + + poller := NewPoller(api, cfg, filepath.Join(dir, "config.json"), statePath, + func(changes []DetectedChange, _ config.StreamerConfig) { + received = append(received, changes...) + }) + + // 状態を復元 + if err := poller.stateManager.LoadFromFile(statePath); err != nil { + t.Fatalf("LoadFromFile failed: %v", err) + } + poller.userCache = api.users + + ctx := context.Background() + poller.poll(ctx) + + // 状態が復元されていれば、同じ配信に対してonline通知は出ない + for _, c := range received { + if c.Type == config.ChangeOnline { + t.Error("should not fire online notification after state restore for same stream") + } + } +} + +func TestPoller_ConfigReload_AddsAndRemovesStreamers(t *testing.T) { + dir := t.TempDir() + configPath := filepath.Join(dir, "config.json") + + // 初期config: streamer1のみ + initialCfg := newTestConfig("streamer1") + data, _ := json.MarshalIndent(initialCfg, "", " ") + if err := os.WriteFile(configPath, data, 0644); err != nil { + t.Fatalf("WriteFile failed: %v", err) + } + + api := newMockAPI() + api.users["streamer1"] = twitch.User{ID: "1", Login: "streamer1", DisplayName: "Streamer1"} + api.users["streamer2"] = twitch.User{ID: "2", Login: "streamer2", DisplayName: "Streamer2"} + + poller := NewPoller(api, initialCfg, configPath, filepath.Join(dir, "state.json"), + func(_ []DetectedChange, _ config.StreamerConfig) {}) + poller.userCache = map[string]twitch.User{ + "streamer1": api.users["streamer1"], + } + poller.stateManager.UpdateState("streamer1", StreamerState{Username: "streamer1"}) + poller.updateConfigModTime() + + // configを更新: streamer1を削除、streamer2を追加 + updatedCfg := newTestConfig("streamer2") + updatedCfg.Polling.IntervalSeconds = 20 + data, _ = json.MarshalIndent(updatedCfg, "", " ") + if err := os.WriteFile(configPath, data, 0644); err != nil { + t.Fatalf("WriteFile failed: %v", err) + } + + // lastConfigModを過去に設定してリロードを強制 + poller.lastConfigMod = time.Time{} + + ctx := context.Background() + newInterval := poller.checkConfigReload(ctx) + + // streamer1が削除されていること + if poller.stateManager.HasState("streamer1") { + t.Error("streamer1 state should be deleted after config reload") + } + if _, ok := poller.userCache["streamer1"]; ok { + t.Error("streamer1 should be removed from user cache") + } + + // streamer2が追加されていること + if _, ok := poller.userCache["streamer2"]; !ok { + t.Error("streamer2 should be added to user cache") + } + + // ポーリング間隔が変更されていること + expectedInterval := 20 * time.Second + if newInterval != expectedInterval { + t.Errorf("expected new interval %v, got %v", expectedInterval, newInterval) + } + + // configが更新されていること + if len(poller.cfg.Streamers) != 1 || poller.cfg.Streamers[0].Username != "streamer2" { + t.Error("config should be updated to new streamers") + } +} + +func TestPoller_ConfigReload_NoChangeWhenFileUnmodified(t *testing.T) { + dir := t.TempDir() + configPath := filepath.Join(dir, "config.json") + + cfg := newTestConfig("streamer1") + data, _ := json.MarshalIndent(cfg, "", " ") + if err := os.WriteFile(configPath, data, 0644); err != nil { + t.Fatalf("WriteFile failed: %v", err) + } + + api := newMockAPI() + + poller := NewPoller(api, cfg, configPath, filepath.Join(dir, "state.json"), + func(_ []DetectedChange, _ config.StreamerConfig) {}) + poller.updateConfigModTime() + + ctx := context.Background() + newInterval := poller.checkConfigReload(ctx) + + if newInterval != 0 { + t.Errorf("expected 0 interval (no change), got %v", newInterval) + } +} + +func TestPoller_CombineChanges_TitleAndGame(t *testing.T) { + changes := []DetectedChange{ + {Type: config.ChangeTitleChange, OldValue: "OldT", NewValue: "NewT"}, + {Type: config.ChangeGameChange, OldValue: "OldG", NewValue: "NewG"}, + } + + combined := combineChanges(changes) + + if len(combined) != 1 { + t.Fatalf("expected 1 combined change, got %d", len(combined)) + } + if combined[0].Type != config.ChangeTitleAndGame { + t.Errorf("expected titleAndGameChange, got %s", combined[0].Type) + } + if combined[0].OldTitle != "OldT" || combined[0].NewTitle != "NewT" { + t.Error("combined change should preserve title values") + } + if combined[0].OldGame != "OldG" || combined[0].NewGame != "NewG" { + t.Error("combined change should preserve game values") + } +} + +func TestPoller_Poll_EmptyTitleAndGame_HeldThenRecovered(t *testing.T) { + api := newMockAPI() + api.users["streamer1"] = twitch.User{ID: "1", Login: "streamer1", DisplayName: "Streamer1"} + // 1ポーリング目: Twitch APIが空のtitle/gameIDを返す(グリッチ) + api.streams["streamer1"] = twitch.Stream{ + UserLogin: "streamer1", + Title: "", + GameID: "", + GameName: "", + } + + cfg := newTestConfig("streamer1") + + var received []DetectedChange + dir := t.TempDir() + + poller := NewPoller(api, cfg, filepath.Join(dir, "config.json"), filepath.Join(dir, "state.json"), + func(changes []DetectedChange, _ config.StreamerConfig) { + received = append(received, changes...) + }) + poller.userCache = api.users + + poller.stateManager.UpdateState("streamer1", StreamerState{ + UserID: "1", + Username: "streamer1", + IsLive: true, + Title: "Real Title", + GameID: "100", + GameName: "Real Game", + }) + + ctx := context.Background() + poller.poll(ctx) + + if len(received) != 0 { + t.Errorf("first poll with both empty should not notify, got %d changes", len(received)) + } + if !poller.emptyPending["streamer1"] { + t.Error("streamer1 should be in emptyPending after first empty poll") + } + + // 2ポーリング目: 値が復帰 + api.streams["streamer1"] = twitch.Stream{ + UserLogin: "streamer1", + Title: "Real Title", + GameID: "100", + GameName: "Real Game", + } + received = nil + poller.poll(ctx) + + if len(received) != 0 { + t.Errorf("second poll after recovery should not notify, got %d changes", len(received)) + } + if poller.emptyPending["streamer1"] { + t.Error("streamer1 should be cleared from emptyPending after recovery") + } +} + +func TestPoller_Poll_EmptyTitleAndGame_ConfirmedAfterTwoPolls(t *testing.T) { + api := newMockAPI() + api.users["streamer1"] = twitch.User{ID: "1", Login: "streamer1", DisplayName: "Streamer1"} + api.streams["streamer1"] = twitch.Stream{ + UserLogin: "streamer1", + Title: "", + GameID: "", + GameName: "", + } + + cfg := newTestConfig("streamer1") + + var received []DetectedChange + dir := t.TempDir() + + poller := NewPoller(api, cfg, filepath.Join(dir, "config.json"), filepath.Join(dir, "state.json"), + func(changes []DetectedChange, _ config.StreamerConfig) { + received = append(received, changes...) + }) + poller.userCache = api.users + + poller.stateManager.UpdateState("streamer1", StreamerState{ + UserID: "1", + Username: "streamer1", + IsLive: true, + Title: "Real Title", + GameID: "100", + GameName: "Real Game", + }) + + ctx := context.Background() + // 1回目: 保留 + poller.poll(ctx) + if len(received) != 0 { + t.Errorf("first poll should not notify, got %d", len(received)) + } + + // 2回目: 同じく両方空 → 確定して通知 + received = nil + poller.poll(ctx) + + hasGameChange := false + for _, c := range received { + if c.Type == config.ChangeGameChange { + hasGameChange = true + if c.NewValue != "" { + t.Errorf("expected empty new game, got %q", c.NewValue) + } + } + } + if !hasGameChange { + t.Error("second poll with persisting empty values should fire game change") + } +} + +func TestPoller_CombineChanges_OnlyTitle(t *testing.T) { + changes := []DetectedChange{ + {Type: config.ChangeTitleChange, OldValue: "Old", NewValue: "New"}, + } + + combined := combineChanges(changes) + + if len(combined) != 1 { + t.Fatalf("expected 1 change, got %d", len(combined)) + } + if combined[0].Type != config.ChangeTitleChange { + t.Errorf("expected titleChange (not combined), got %s", combined[0].Type) + } +} diff --git a/internal/monitor/state_test.go b/internal/monitor/state_test.go new file mode 100644 index 0000000..7b315c1 --- /dev/null +++ b/internal/monitor/state_test.go @@ -0,0 +1,133 @@ +package monitor + +import ( + "os" + "path/filepath" + "testing" +) + +func TestSaveAndLoadState(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "state.json") + + sm := NewStateManager() + sm.UpdateState("streamer1", StreamerState{ + UserID: "123", + Username: "streamer1", + DisplayName: "Streamer1", + IsLive: true, + Title: "Test Stream", + GameID: "456", + GameName: "Game1", + StartedAt: "2026-03-29T10:00:00Z", + }) + sm.UpdateState("streamer2", StreamerState{ + UserID: "789", + Username: "streamer2", + DisplayName: "Streamer2", + IsLive: false, + Title: "Offline Title", + }) + + if err := sm.SaveToFile(path); err != nil { + t.Fatalf("SaveToFile failed: %v", err) + } + + sm2 := NewStateManager() + if err := sm2.LoadFromFile(path); err != nil { + t.Fatalf("LoadFromFile failed: %v", err) + } + + if sm2.stateCount() != 2 { + t.Errorf("expected 2 states, got %d", sm2.stateCount()) + } + + s1 := sm2.GetState("streamer1") + if s1 == nil { + t.Fatal("streamer1 state not found after load") + } + if s1.DisplayName != "Streamer1" { + t.Errorf("expected DisplayName Streamer1, got %s", s1.DisplayName) + } + if !s1.IsLive { + t.Error("expected streamer1 to be live") + } + if s1.Title != "Test Stream" { + t.Errorf("expected title 'Test Stream', got '%s'", s1.Title) + } + if s1.GameName != "Game1" { + t.Errorf("expected game 'Game1', got '%s'", s1.GameName) + } + + s2 := sm2.GetState("streamer2") + if s2 == nil { + t.Fatal("streamer2 state not found after load") + } + if s2.IsLive { + t.Error("expected streamer2 to be offline") + } +} + +func TestLoadFromFile_NotExists(t *testing.T) { + sm := NewStateManager() + err := sm.LoadFromFile(filepath.Join(t.TempDir(), "nonexistent.json")) + if err != nil { + t.Errorf("expected nil error for nonexistent file, got: %v", err) + } + if sm.stateCount() != 0 { + t.Errorf("expected 0 states, got %d", sm.stateCount()) + } +} + +func TestSaveToFile_Atomic(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "state.json") + + sm := NewStateManager() + sm.UpdateState("test", StreamerState{Username: "test", IsLive: true}) + + if err := sm.SaveToFile(path); err != nil { + t.Fatalf("SaveToFile failed: %v", err) + } + + if _, err := os.Stat(path + ".tmp"); !os.IsNotExist(err) { + t.Error("temp file should not exist after save") + } +} + +func TestDeleteState(t *testing.T) { + sm := NewStateManager() + sm.UpdateState("test", StreamerState{Username: "test"}) + + if !sm.HasState("test") { + t.Fatal("expected state to exist") + } + + sm.DeleteState("test") + + if sm.HasState("test") { + t.Error("expected state to be deleted") + } +} + +func TestStateManager_CaseInsensitive(t *testing.T) { + sm := NewStateManager() + sm.UpdateState("testuser", StreamerState{Username: "testuser", DisplayName: "TestUser"}) + + // GetStateは大文字小文字を区別せずに取得できること + if s := sm.GetState("TESTUSER"); s == nil { + t.Error("expected to find state with uppercase key") + } + if s := sm.GetState("TestUser"); s == nil { + t.Error("expected to find state with mixed case key") + } + + // HasState/DeleteStateも同様 + if !sm.HasState("TESTUSER") { + t.Error("HasState should be case-insensitive") + } + sm.DeleteState("TESTUSER") + if sm.HasState("testuser") { + t.Error("DeleteState should work case-insensitively") + } +}