From 3a06f0790b143d4a210f36298436020f79742188 Mon Sep 17 00:00:00 2001 From: yuu1111 Date: Sat, 9 May 2026 15:58:32 +0900 Subject: [PATCH 1/3] =?UTF-8?q?fix:=20Title/GameID=E4=B8=A1=E6=96=B9?= =?UTF-8?q?=E7=A9=BA=E3=81=AE=E3=82=B0=E3=83=AA=E3=83=83=E3=83=81=E6=99=82?= =?UTF-8?q?=E3=81=AE=E8=AA=A4=E9=80=9A=E7=9F=A5=E3=82=92=E9=98=B2=E6=AD=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Twitch APIが一過性で空のtitle/gameIDを返した場合に空フィールドの通知が 発射される問題を修正。両方空になった配信者は1ポーリング保留し、次回も 同じ状態が継続したら確定として通知する。 - Poller.emptyPendingで両方空の配信者を一時保留 - 単一フィールド空(意図的なクリア)は従来通り即通知 - TwitchAPI interface追加でmockテストを可能に - detector/poller/stateの単体テストを追加 --- internal/monitor/detector_test.go | 254 ++++++++++++++ internal/monitor/poller.go | 29 +- internal/monitor/poller_test.go | 562 ++++++++++++++++++++++++++++++ internal/monitor/state_test.go | 133 +++++++ 4 files changed, 976 insertions(+), 2 deletions(-) create mode 100644 internal/monitor/detector_test.go create mode 100644 internal/monitor/poller_test.go create mode 100644 internal/monitor/state_test.go 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..08d6975 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), } } @@ -318,6 +330,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..519eafe --- /dev/null +++ b/internal/monitor/poller_test.go @@ -0,0 +1,562 @@ +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, "", " ") + os.WriteFile(statePath, data, 0644) + + 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, "", " ") + os.WriteFile(configPath, data, 0644) + + 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, "", " ") + os.WriteFile(configPath, data, 0644) + + // 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, "", " ") + os.WriteFile(configPath, data, 0644) + + 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") + } +} From 160bb5bee56b3af99c2214a98e35aece9895c000 Mon Sep 17 00:00:00 2001 From: yuu1111 Date: Sat, 9 May 2026 16:00:52 +0900 Subject: [PATCH 2/3] =?UTF-8?q?fix:=20=E9=85=8D=E4=BF=A1=E8=80=85=E5=89=8A?= =?UTF-8?q?=E9=99=A4=E6=99=82=E3=81=ABemptyPending=E3=82=A8=E3=83=B3?= =?UTF-8?q?=E3=83=88=E3=83=AA=E3=82=82=E5=89=8A=E9=99=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/monitor/poller.go | 1 + 1 file changed, 1 insertion(+) diff --git a/internal/monitor/poller.go b/internal/monitor/poller.go index 08d6975..2cf8e8c 100644 --- a/internal/monitor/poller.go +++ b/internal/monitor/poller.go @@ -169,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) } From e8a468e35f76e02770c3eb17270f1a17e212a32b Mon Sep 17 00:00:00 2001 From: yuu1111 Date: Sat, 9 May 2026 16:05:06 +0900 Subject: [PATCH 3/3] =?UTF-8?q?fix:=20=E3=83=86=E3=82=B9=E3=83=88=E3=81=A7?= =?UTF-8?q?os.WriteFile=E3=81=AE=E3=82=A8=E3=83=A9=E3=83=BC=E6=88=BB?= =?UTF-8?q?=E3=82=8A=E5=80=A4=E3=82=92=E3=83=81=E3=82=A7=E3=83=83=E3=82=AF?= =?UTF-8?q?=20(errcheck)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/monitor/poller_test.go | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/internal/monitor/poller_test.go b/internal/monitor/poller_test.go index 519eafe..1598ef5 100644 --- a/internal/monitor/poller_test.go +++ b/internal/monitor/poller_test.go @@ -293,7 +293,9 @@ func TestPoller_Poll_RestoresStateOnRestart(t *testing.T) { }, } data, _ := json.MarshalIndent(previousState, "", " ") - os.WriteFile(statePath, data, 0644) + 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"} @@ -337,7 +339,9 @@ func TestPoller_ConfigReload_AddsAndRemovesStreamers(t *testing.T) { // 初期config: streamer1のみ initialCfg := newTestConfig("streamer1") data, _ := json.MarshalIndent(initialCfg, "", " ") - os.WriteFile(configPath, data, 0644) + 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"} @@ -355,7 +359,9 @@ func TestPoller_ConfigReload_AddsAndRemovesStreamers(t *testing.T) { updatedCfg := newTestConfig("streamer2") updatedCfg.Polling.IntervalSeconds = 20 data, _ = json.MarshalIndent(updatedCfg, "", " ") - os.WriteFile(configPath, data, 0644) + if err := os.WriteFile(configPath, data, 0644); err != nil { + t.Fatalf("WriteFile failed: %v", err) + } // lastConfigModを過去に設定してリロードを強制 poller.lastConfigMod = time.Time{} @@ -394,7 +400,9 @@ func TestPoller_ConfigReload_NoChangeWhenFileUnmodified(t *testing.T) { cfg := newTestConfig("streamer1") data, _ := json.MarshalIndent(cfg, "", " ") - os.WriteFile(configPath, data, 0644) + if err := os.WriteFile(configPath, data, 0644); err != nil { + t.Fatalf("WriteFile failed: %v", err) + } api := newMockAPI()