"""The log follower: chunked appends, partial lines, MTGA replacing the log, and no lingering handle.""" import os import time from pathlib import Path import pytest from PySide6.QtCore import QCoreApplication from mtga_assistant.logtail import LogFollower FIXTURE = Path(__file__).parent / "fixtures" / "bo3_match.log" @pytest.fixture(scope="module") def app(): return QCoreApplication.instance() or QCoreApplication([]) def wait_for(app, cond, timeout=5.0): end = time.monotonic() + timeout while time.monotonic() < end: app.processEvents() if cond(): return True time.sleep(0.02) return False def open_handles_to(path: Path) -> int: """Number of this process's file descriptors pointing at path (Linux /proc).""" fd_dir = Path("/proc/self/fd") if not fd_dir.exists(): pytest.skip("needs /proc") count = 0 for fd in fd_dir.iterdir(): try: if Path(os.readlink(fd)) == path: count += 1 except OSError: pass return count def test_follow_append_replace_and_no_open_handle(app, tmp_path): src = FIXTURE.read_bytes() log = tmp_path / "Player.log" log.write_bytes(b"") got, resets = [], [] f = LogFollower(log) f.events.connect(lambda evs: got.extend(evs)) f.reset.connect(lambda: resets.append(1)) f.start() try: cut = len(src) * 3 // 4 # mid-line with open(log, "ab") as fh: fh.write(src[:cut]) assert wait_for(app, lambda: len(got) >= 11) with open(log, "ab") as fh: fh.write(src[cut:]) assert wait_for(app, lambda: len(got) == 22) # Between polls the follower must not keep Player.log open (Windows would then block # MTGA from renaming it). time.sleep(0.3) assert open_handles_to(log.resolve()) == 0 # MTGA restarts: Player.log is replaced by a new file. new = tmp_path / "Player.new" new.write_bytes(src[:cut]) os.replace(new, log) assert wait_for(app, lambda: resets and len(got) >= 33) finally: f.stop() f.wait(3000)