From 7b48e41a992222049d47bb8efda92258e65a10b9 Mon Sep 17 00:00:00 2001 From: Masahiro Hiraki Date: Sun, 31 May 2026 18:37:18 +0900 Subject: [PATCH] =?UTF-8?q?fix:=20=E3=82=B2=E3=83=BC=E3=83=A0=E9=96=8B?= =?UTF-8?q?=E5=A7=8B=E6=99=82=E3=81=AB=E3=82=BF=E3=82=A4=E3=83=9E=E3=83=BC?= =?UTF-8?q?=E3=81=A8OBS=E9=8C=B2=E7=94=BB=E3=81=8C=E9=96=8B=E5=A7=8B?= =?UTF-8?q?=E3=81=97=E3=81=AA=E3=81=84=E5=95=8F=E9=A1=8C=E3=82=92=E4=BF=AE?= =?UTF-8?q?=E6=AD=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit active_process_name 未設定のゲーム(大多数)で GameProcessWatcher.tick() の on_active() が永久に呼ばれず、プレイ時間カウントとOBS自動録画が機能しなかった。 v0.8 の active_process_name 配線修正により active_process_names が空集合になり、 is_active が常に False → on_game_active() に到達不能になっていた回帰バグ。 修正: is_running の初回検知時に active_process_names が空なら on_active() を発火。 tests/test_start_watcher.py に ProcessWatcherOnActiveTest(5件)を追加。 132 件全通過。 Co-Authored-By: Claude Opus 4.8 --- playcue/tracking/process_tracker.py | 7 +- tests/test_start_watcher.py | 106 ++++++++++++++++++++++++++++ 2 files changed, 112 insertions(+), 1 deletion(-) diff --git a/playcue/tracking/process_tracker.py b/playcue/tracking/process_tracker.py index 6913a66..a354c3f 100644 --- a/playcue/tracking/process_tracker.py +++ b/playcue/tracking/process_tracker.py @@ -71,7 +71,12 @@ def tick(self) -> None: is_running = is_active if is_running: - self.seen_process = True + if not self.seen_process: + self.seen_process = True + # active_process_name 未設定時は process_name の初回検知で on_active を発火。 + # active_process_name 設定時は上の is_active ブロックで発火するためここでは不要。 + if not self.active_process_names and self.on_active: + self.on_active() self.missing_count = 0 return diff --git a/tests/test_start_watcher.py b/tests/test_start_watcher.py index 571a878..5457141 100644 --- a/tests/test_start_watcher.py +++ b/tests/test_start_watcher.py @@ -141,5 +141,111 @@ def mock_watcher(process_name, on_exit, **kwargs): ) +class ProcessWatcherOnActiveTest(unittest.TestCase): + """GameProcessWatcher.tick() が on_active を正しいタイミングで呼ぶことを検証する。 + + 回帰: v0.8 の active_process_name 配線修正により、active_process_name が空のゲームでは + on_active が永久に呼ばれなくなっていた。これによりタイマー開始と OBS 録画開始が機能しなかった。 + """ + + def _make_watcher(self, process_name, active_process_name="", on_active=None, on_exit=None): + from playcue.tracking.process_tracker import GameProcessWatcher + return GameProcessWatcher( + process_name=process_name, + on_exit=on_exit or mock.MagicMock(), + active_process_name=active_process_name, + on_active=on_active or mock.MagicMock(), + ) + + def _make_proc(self, name: str) -> mock.MagicMock: + p = mock.MagicMock() + p.info = {"name": name, "exe": ""} + return p + + def test_on_active_called_when_no_active_process_name(self): + """active_process_name 未設定: process_name が初回検知されたとき on_active が呼ばれる。""" + import playcue.tracking.process_tracker as mod + + on_active = mock.MagicMock() + watcher = self._make_watcher("game.exe", active_process_name="", on_active=on_active) + proc = self._make_proc("game.exe") + + with mock.patch.object(mod, "psutil") as mock_psutil: + mock_psutil.process_iter.return_value = [proc] + mock_psutil.NoSuchProcess = Exception + mock_psutil.AccessDenied = Exception + watcher.tick() + + on_active.assert_called_once() + + def test_on_active_called_only_once_on_repeated_ticks(self): + """process_name が連続検知されても on_active は1回だけ呼ばれる。""" + import playcue.tracking.process_tracker as mod + + on_active = mock.MagicMock() + watcher = self._make_watcher("game.exe", active_process_name="", on_active=on_active) + proc = self._make_proc("game.exe") + + with mock.patch.object(mod, "psutil") as mock_psutil: + mock_psutil.process_iter.return_value = [proc] + mock_psutil.NoSuchProcess = Exception + mock_psutil.AccessDenied = Exception + watcher.tick() + watcher.tick() + watcher.tick() + + on_active.assert_called_once() + + def test_on_active_not_called_before_process_detected(self): + """プロセスが未検知のうちは on_active は呼ばれない(猶予期間中)。""" + import playcue.tracking.process_tracker as mod + + on_active = mock.MagicMock() + watcher = self._make_watcher("game.exe", active_process_name="", on_active=on_active) + + with mock.patch.object(mod, "psutil") as mock_psutil: + mock_psutil.process_iter.return_value = [] + mock_psutil.NoSuchProcess = Exception + mock_psutil.AccessDenied = Exception + watcher.tick() + + on_active.assert_not_called() + + def test_on_active_via_active_process_name_still_works(self): + """active_process_name 設定時: active_process_name 検知で on_active が呼ばれる(既存動作維持)。""" + import playcue.tracking.process_tracker as mod + + on_active = mock.MagicMock() + watcher = self._make_watcher( + "launcher.exe", active_process_name="game.exe", on_active=on_active + ) + + with mock.patch.object(mod, "psutil") as mock_psutil: + mock_psutil.process_iter.return_value = [self._make_proc("game.exe")] + mock_psutil.NoSuchProcess = Exception + mock_psutil.AccessDenied = Exception + watcher.tick() + + on_active.assert_called_once() + + def test_on_active_not_double_fired_when_process_name_equals_active(self): + """process_name == active_process_name のとき on_active は1回だけ(is_active ブロックで発火)。""" + import playcue.tracking.process_tracker as mod + + on_active = mock.MagicMock() + watcher = self._make_watcher( + "game.exe", active_process_name="game.exe", on_active=on_active + ) + + with mock.patch.object(mod, "psutil") as mock_psutil: + mock_psutil.process_iter.return_value = [self._make_proc("game.exe")] + mock_psutil.NoSuchProcess = Exception + mock_psutil.AccessDenied = Exception + watcher.tick() + watcher.tick() + + on_active.assert_called_once() + + if __name__ == "__main__": unittest.main()