Ctrl K
ComunidadGuías…

tests/test_jobs.py

Rama master · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
import os
import sqlite3
import signal
import tempfile
import threading
import time
import unittest
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import patch

from peach.jobs import (
    BackgroundJob,
    DiskGuard,
    DiskSpaceDenied,
    JobAlreadyRunning,
    MeteredSourceDenied,
    PidFileLock,
    SourceAccessPolicy,
    job_main,
    require_free_space,
)


class JobPolicyTests(unittest.TestCase):
    def test_metered_source_requires_explicit_authorization(self):
        policy = SourceAccessPolicy()
        with self.assertRaises(MeteredSourceDenied):
            policy.sql_filter("pikpak", allow_metered=False)

    def test_unmetered_query_uses_parameters_and_excludes_metered_sources(self):
        sql, parameters = SourceAccessPolicy().sql_filter("115", allow_metered=False)
        with sqlite3.connect(':memory:') as connection:
            connection.execute('CREATE TABLE asset(location TEXT,path TEXT)')
            connection.executemany('INSERT INTO asset VALUES(?,?)',
                                   [('115', r'B:\movie.mp4'), ('online', ''), ('pikpak', r'A:\movie.mp4')])
            self.assertEqual(connection.execute('SELECT location FROM asset WHERE 1=1' + sql,
                                                parameters).fetchall(), [('115',)])

    def test_root_choices_allow_pikpak_and_protect_a_different_root_of_the_same_source(self):
        config = SimpleNamespace(locations={'pikpak': (r'A:\Normal', r'A:\Proxy', r'A:\Proxy\Free')},
                                 media_costs={r'A:\Normal': 'free', r'A:\Proxy': 'metered',
                                              r'A:\Proxy\Free': 'free'})
        with patch('peach.settings_file.active', return_value=config):
            sql, parameters = SourceAccessPolicy().sql_filter('pikpak', False, 'a.location')
        with sqlite3.connect(':memory:') as connection:
            connection.execute('CREATE TABLE asset(location TEXT,path TEXT)')
            connection.executemany('INSERT INTO asset VALUES(?,?)', [('pikpak', path) for path in
                (r'A:\Normal\one.mp4', r'A:\Proxy\two.mp4', r'A:\Proxy\Free\three.mp4', r'A:\NormalExtra\four.mp4')])
            self.assertEqual(connection.execute('SELECT path FROM asset a WHERE 1=1' + sql,
                                                parameters).fetchall(),
                             [(r'A:\Normal\one.mp4',), (r'A:\Proxy\Free\three.mp4',)])

    def test_normal_source_can_require_budget_when_its_root_is_protected(self):
        config = SimpleNamespace(locations={'115': (r'B:\Media',)}, media_costs={r'B:\Media': 'metered'})
        with patch('peach.settings_file.active', return_value=config):
            with self.assertRaises(MeteredSourceDenied):
                SourceAccessPolicy().sql_filter('115', False)

    def test_authorized_metered_query_is_scoped_to_requested_source(self):
        sql, parameters = SourceAccessPolicy().sql_filter("pikpak", allow_metered=True)
        self.assertEqual(sql, " AND location=?")
        self.assertEqual(parameters, ("pikpak",))

    def test_disk_guard_fails_closed_when_usage_is_unavailable(self):
        with patch("peach.jobs.shutil.disk_usage", side_effect=OSError("offline")):
            with self.assertRaises(DiskSpaceDenied):
                require_free_space(Path("C:/"), 40)

    def test_disk_guard_accepts_real_temporary_volume(self):
        with tempfile.TemporaryDirectory() as tmp:
            self.assertGreaterEqual(require_free_space(tmp, 0), 0)

    def test_runtime_guard_stops_a_job_that_started_with_room(self):
        """2026-08-15 事故:起跑时 C: 有几百 GB,跑到 0 字节也没人再看一眼。"""
        guard = DiskGuard(Path("C:/"), minimum_gb=40, interval_secs=0)
        free = 500 * 1024**3
        with patch("peach.jobs.shutil.disk_usage", return_value=type("U", (), {"free": free})):
            self.assertAlmostEqual(guard.check(force=True), 500, places=3)
        # 同一次运行中盘被第三方缓存吃光,闸门必须在运行期触发,而不是等下次启动。
        with patch("peach.jobs.shutil.disk_usage", return_value=type("U", (), {"free": 1024**3})):
            with self.assertRaises(DiskSpaceDenied):
                guard.check(force=True)

    def test_runtime_guard_throttles_and_fails_closed(self):
        guard = DiskGuard(Path("C:/"), minimum_gb=40, interval_secs=3600)
        with patch("peach.jobs.shutil.disk_usage", return_value=type("U", (), {"free": 1024**3})):
            with self.assertRaises(DiskSpaceDenied):
                guard.check(force=True)
            # 节流窗口内不重复读盘,返回 None 表示本轮未检查——不能被误当成"通过"。
            self.assertIsNone(guard.check())
        with patch("peach.jobs.shutil.disk_usage", side_effect=OSError("offline")):
            with self.assertRaises(DiskSpaceDenied):
                guard.check(force=True)

    def test_stale_lock_from_a_killed_job_does_not_crash_the_next_run(self):
        """被强杀留下的锁必须被清理后继续,而不是把后续所有运行直接崩掉。

        2026-08-15 实际发生过。取一个几乎不可能存在的 PID 走真实的存活探测。
        """
        with tempfile.TemporaryDirectory() as tmp:
            path = Path(tmp) / "job.lock"
            path.write_text("4294967294", encoding="ascii")
            with PidFileLock(path):
                self.assertEqual(path.read_text(encoding="ascii").strip(), str(os.getpid()))
            self.assertFalse(path.exists())

    @unittest.skipUnless(os.name == "nt", "只在 Windows 上成立")
    def test_liveness_probe_never_signals_its_own_console(self):
        """Windows 上 `signal.CTRL_C_EVENT == 0`。

        于是 Unix 探测存活的经典写法 `os.kill(pid, 0)` 实际会调用
        `GenerateConsoleCtrlEvent(CTRL_C_EVENT, ...)`,把 Ctrl+C 发给整个控制台进程组。
        锁里写的又是自己的 PID,「检查锁是否还活着」就会当场把自己打断——在真实控制台里
        跑批处理或测试时表现为毫无征兆的 KeyboardInterrupt,而重定向/无控制台的环境
        不复现,因为控制台事件无处投递。这条守住「探测存活不得发信号」。
        """
        self.assertEqual(int(signal.CTRL_C_EVENT), 0, "前提:CTRL_C_EVENT 就是 0")
        with patch("peach.jobs.os.kill", side_effect=AssertionError("存活探测不得调用 os.kill")):
            self.assertTrue(PidFileLock._running(os.getpid()))
            self.assertFalse(PidFileLock._running(4294967294))

    def test_pid_lock_rejects_live_owner_and_cleans_up(self):
        with tempfile.TemporaryDirectory() as tmp:
            path = Path(tmp) / "job.lock"
            with PidFileLock(path):
                self.assertTrue(path.is_file())
                with self.assertRaises(JobAlreadyRunning):
                    PidFileLock(path).acquire()
            self.assertFalse(path.exists())


if __name__ == "__main__":
    unittest.main()


class JobMainTests(unittest.TestCase):
    """长跑批处理的统一入口收尾。

    这段此前在四个脚本里各写了一份。前三份逐字相同;第四份只把异常变量从 `exc`
    改名成 `error`——一个标识符的差别就能让逐字扫描漏掉它,所以指望「看一眼就发现
    重复」是不成立的。
    """

    def _parser(self, lock: Path):
        import argparse
        parser = argparse.ArgumentParser()
        parser.add_argument("--lock", type=Path, default=lock)
        return parser

    def test_returns_the_policy_exit_code_instead_of_raising(self):
        """批处理是无人值守跑的,被读的是退出码,不是栈。"""
        with tempfile.TemporaryDirectory() as tmp:
            lock = Path(tmp) / "job.lock"

            def run(_args):
                raise DiskSpaceDenied("盘满了")

            with patch("builtins.print") as printed:
                code = job_main(lambda: self._parser(lock), run, [])
            self.assertEqual(code, DiskSpaceDenied.exit_code)
            self.assertIn("[stop]", " ".join(str(c) for c in printed.call_args_list))

    def test_already_running_is_not_a_failure(self):
        """`JobAlreadyRunning.exit_code` 是 0:另一份还在跑不算这次失败。

        散成四份手抄时,谁把它当异常向上抛或改成非零,都不会有测试拦住。
        """
        with tempfile.TemporaryDirectory() as tmp:
            lock = Path(tmp) / "job.lock"
            with PidFileLock(lock):
                with patch("builtins.print"):
                    code = job_main(lambda: self._parser(lock),
                                    lambda _args: self.fail("锁没拿住就不该跑 run"), [])
            self.assertEqual(code, 0)

    def test_the_lock_wraps_the_whole_run(self):
        """pid 锁必须包住整个 run,而不是只包住入口。"""
        with tempfile.TemporaryDirectory() as tmp:
            lock = Path(tmp) / "job.lock"
            seen = []

            def run(_args):
                seen.append(lock.exists())
                return 7

            self.assertEqual(job_main(lambda: self._parser(lock), run, []), 7)
            self.assertEqual(seen, [True], "run 执行期间锁文件必须还在")
            self.assertFalse(lock.exists(), "退出后锁要释放")


class BackgroundJobTests(unittest.TestCase):
    def test_operation_errors_are_queryable_terminal_receipts(self):
        job = BackgroundJob('test-operation')
        started = job.start_result(lambda: {'ok': False, 'error': 'expired'})
        job.thread.join(2)
        state = job.snapshot()
        self.assertEqual(state['job_id'], started['job_id'])
        self.assertEqual(state['status'], 'failed')
        self.assertEqual(state['error'], 'expired')

    def test_operation_does_not_replay_while_running(self):
        job = BackgroundJob('test-operation')
        entered, release = threading.Event(), threading.Event()
        calls = []
        def work():
            calls.append(1)
            entered.set()
            release.wait(3)
            return {'ok': True, 'removed': 2}
        first = job.start_result(work)
        try:
            self.assertTrue(entered.wait(2))
            second = job.start_result(work)
            self.assertEqual(first['job_id'], second['job_id'])
        finally:
            release.set()
            job.thread.join(3)
        self.assertEqual(calls, [1])
        self.assertEqual(job.snapshot()['removed'], 2)

    """服务里的后台任务状态机。

    死链检查和资源对账共用这一份状态机。共用的不只是形状,还有几条容易漏的约定:
    重复点击不许把在跑的那轮丢掉、被顶掉的线程不许再写状态、后台异常必须变成
    可轮询的 failed。这些散在两处的话,下一个照抄的人漏哪条都不会有人发现。
    """

    def _job(self) -> BackgroundJob:
        return BackgroundJob("PeachTestJob", id_key="check_id")

    def test_a_fresh_job_has_no_state_at_all(self):
        """没跑过就是 None,而不是一个假装跑过的空状态。"""
        self.assertIsNone(self._job().snapshot())

    def test_repeated_clicks_do_not_replace_the_round_that_is_running(self):
        job = self._job()
        release = threading.Event()
        started = threading.Event()

        def work(_job_id: str) -> None:
            started.set()
            release.wait(5)

        first = job.start(work)
        started.wait(5)
        try:
            again = job.start(work, restart=True)
            self.assertEqual(again["check_id"], first["check_id"])
            self.assertEqual(again["status"], "running")
        finally:
            release.set()
        job.stop()

    def test_restart_is_required_to_replace_a_finished_round(self):
        job = self._job()
        job.start(lambda job_id: job.update(job_id, status="complete"))
        for _ in range(500):
            if job.snapshot()["status"] == "complete":
                break
            time.sleep(0.01)
        done = job.snapshot()
        self.assertEqual(done["status"], "complete")
        # 不带 restart 只是查现状:一份跑完的结果不该被一次误点清掉。
        self.assertEqual(job.start(lambda _id: None)["check_id"], done["check_id"])
        self.assertNotEqual(
            job.start(lambda _id: None, restart=True)["check_id"], done["check_id"])

    def test_a_superseded_worker_cannot_touch_the_state(self):
        """被顶掉的线程还继续写,前端就会看到新一轮的 id 配旧一轮的进度。"""
        job = self._job()
        stale = job.start(lambda _id: None, initial={"checked": 0})["check_id"]
        job.state = {"check_id": "fresh", "status": "running", "checked": 0}
        self.assertFalse(job.update(stale, checked=99))
        with job.editing(stale) as state:
            self.assertIsNone(state)
        self.assertEqual(job.snapshot()["checked"], 0)

    def test_a_raising_worker_becomes_a_pollable_failure(self):
        job = self._job()

        def work(_job_id: str) -> None:
            raise RuntimeError("上游没了")

        job.start(work)
        for _ in range(500):
            if job.snapshot()["status"] != "running":
                break
            time.sleep(0.01)
        state = job.snapshot()
        self.assertEqual(state["status"], "failed")
        self.assertEqual(state["error"], "RuntimeError: 上游没了")
        self.assertIn("completed_at", state)

    def test_the_snapshot_is_deep_enough_to_project_outside_the_lock(self):
        """公开投影在锁外算,所以快照不能和 worker 共享同一个列表对象。"""
        job = self._job()
        job.state = {"check_id": "x", "status": "running", "gone": [{"id": 1}]}
        snapshot = job.snapshot()
        snapshot["gone"].append({"id": 2})
        snapshot["gone"][0]["id"] = 9
        self.assertEqual(job.state["gone"], [{"id": 1}])

    def test_stop_drops_the_state_and_waits_for_the_worker(self):
        job = self._job()
        release = threading.Event()
        finished = threading.Event()

        def work(_job_id: str) -> None:
            release.wait(5)
            finished.set()

        job.start(work)
        release.set()
        job.stop(timeout=5)
        self.assertTrue(finished.is_set())
        self.assertIsNone(job.snapshot())