Ctrl K
ComunidadGuías…

tests/test_runtime_prepare.py

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

Archivo original ↗Preguntar a KiBot
from __future__ import annotations

import json
import os
import sqlite3
import subprocess
import sys
import tempfile
import unittest
from pathlib import Path

from peach import runtime_prepare
from peach.runtime_prepare import (
    UV_SYNC, Dependencies, Migrations, RestartPreparation, Step, find_uv, ledger_writer,
    read_record, record, task_gate,
)
from peach.sync import marker_path


def completed(returncode: int = 0, stdout: str = "", stderr: str = ""):
    return subprocess.CompletedProcess([], returncode, stdout, stderr)


OUTDATED = (
    "Would use project environment at: .venv\n"
    "Would install 1 package\n"
    " + grpcio==1.84.0\n"
    "The environment is outdated; run `uv sync` to update the environment\n"
)


class FakeRun:
    """按调用顺序回放结果,同时记下每次的命令与关键字参数。"""

    def __init__(self, *results):
        self.results = list(results)
        self.calls: list[tuple[list[str], dict]] = []

    def __call__(self, command, **kwargs):
        self.calls.append((list(command), kwargs))
        result = self.results.pop(0)
        if isinstance(result, BaseException):
            raise result
        return result


class ProjectTree:
    """临时项目根:`uv.lock` 与 `.venv/pyvenv.cfg` 都只是占位文件。"""

    def __init__(self, directory: Path, *, lock: bool = True, venv: bool = True):
        self.root = directory / "peach-app"
        self.root.mkdir()
        if lock:
            (self.root / "uv.lock").write_text("version = 1\n", encoding="utf-8")
        if venv:
            (self.root / ".venv").mkdir()
            (self.root / ".venv" / "pyvenv.cfg").write_text("home = x\n", encoding="utf-8")


class DependencyTests(unittest.TestCase):
    def setUp(self):
        self.directory = tempfile.TemporaryDirectory()
        self.temp = Path(self.directory.name)
        self.tree = ProjectTree(self.temp)

    def tearDown(self):
        self.directory.cleanup()

    def dependencies(self, run, **kwargs):
        options = {"which": lambda _name: "C:/tools/uv.exe",
                   "environ": {"VIRTUAL_ENV": "C:/elsewhere/.venv", "PATH": ""},
                   "frozen": False, "clock": iter((10.0, 12.5)).__next__}
        options.update(kwargs)
        return Dependencies(self.tree.root, run=run, **options)

    def test_a_venv_that_matches_the_lock_is_left_alone(self):
        run = FakeRun(completed(0, "Would make no changes\n"))
        step = self.dependencies(run).sync()
        self.assertEqual(step.state, "current")
        self.assertEqual(len(run.calls), 1)
        command, kwargs = run.calls[0]
        self.assertEqual(command[1:1 + len(UV_SYNC)], list(UV_SYNC))
        self.assertIn("--check", command)
        self.assertEqual(command[-2:], ["--project", str(self.tree.root)])
        # 调用方自己的 venv 不能顶替项目的 `.venv`。
        self.assertNotIn("VIRTUAL_ENV", kwargs["env"])

    def test_an_outdated_venv_is_synced_without_touching_the_editable_project(self):
        run = FakeRun(completed(1, OUTDATED), completed(0, "Installed 1 package\n"))
        step = self.dependencies(run).sync()
        self.assertEqual(step.state, "synced")
        self.assertIn("grpcio==1.84.0", step.message)
        self.assertIn("2.5 秒", step.message)
        sync_command = run.calls[1][0]
        self.assertNotIn("--check", sync_command)
        self.assertIn("--no-install-project", sync_command)
        self.assertIn("--inexact", sync_command)

    def test_check_alone_reports_stale_and_never_installs(self):
        run = FakeRun(completed(1, OUTDATED))
        step = self.dependencies(run).check()
        self.assertEqual(step.state, "stale")
        self.assertTrue(step.problem)
        self.assertEqual(len(run.calls), 1)

    def test_a_failed_sync_is_reported_with_the_tail_of_its_output(self):
        run = FakeRun(completed(1, OUTDATED),
                      completed(2, "", "error: failed to remove file numpy\\_core.pyd"))
        step = self.dependencies(run).sync()
        self.assertTrue(step.failed)
        self.assertIn("_core.pyd", step.message)

    def test_a_check_that_errors_for_another_reason_is_a_failure_not_stale(self):
        run = FakeRun(completed(2, "", "error: The lockfile needs to be updated"))
        step = self.dependencies(run).check()
        self.assertTrue(step.failed)
        self.assertIn("lockfile", step.message)

    def test_without_uv_nothing_runs(self):
        run = FakeRun()
        step = self.dependencies(run, which=lambda _name: None,
                                 environ={"LOCALAPPDATA": str(self.temp)}).sync()
        self.assertEqual(step.state, "skipped")
        self.assertIn("未取得 uv", step.message)
        self.assertEqual(run.calls, [])

    def test_uv_is_found_in_the_winget_package_directory_when_path_lacks_it(self):
        package = self.temp / "Microsoft" / "WinGet" / "Packages" / "astral-sh.uv_Microsoft.Winget.Source_x"
        package.mkdir(parents=True)
        (package / "uv.exe").write_bytes(b"")
        found = find_uv(which=lambda _name: None, environ={"LOCALAPPDATA": str(self.temp)})
        self.assertEqual(found, package / "uv.exe")

    def test_frozen_processes_and_trees_without_a_lock_or_venv_skip(self):
        run = FakeRun()
        self.assertEqual(self.dependencies(run, frozen=True).check().state, "skipped")
        (self.temp / "bare").mkdir()
        bare = ProjectTree(self.temp / "bare", lock=False)
        self.assertEqual(Dependencies(bare.root, run=run, frozen=False,
                                      which=lambda _name: "uv").check().state, "skipped")
        self.assertEqual(run.calls, [])


class WriterTests(unittest.TestCase):
    def setUp(self):
        self.directory = tempfile.TemporaryDirectory()
        root = Path(self.directory.name)
        self.db = root / "local" / "ledger.db"
        self.shared = root / "shared" / "ledger.db"
        self.state = root / "state"
        for path in (self.db.parent, self.shared.parent, self.state):
            path.mkdir(parents=True)

    def tearDown(self):
        self.directory.cleanup()

    def mark(self, db: Path, device: str, generation: int = 1):
        marker_path(db).write_text(json.dumps({"generation": generation, "device": device}),
                                   encoding="utf-8")

    def writer(self, enabled=True):
        return ledger_writer(replication_enabled=enabled, db=self.db, shared_db=self.shared,
                             state_dir=self.state)

    def test_a_single_machine_is_its_own_writer(self):
        self.assertTrue(self.writer(enabled=False)[0])

    def test_a_reader_never_counts_as_writer(self):
        (self.state / "device-id").write_text("mac-1", encoding="utf-8")
        self.mark(self.shared, "win-1")
        writer, why = self.writer()
        self.assertFalse(writer)
        self.assertIn("win-1", why)

    def test_the_marked_device_is_the_writer(self):
        (self.state / "device-id").write_text("win-1", encoding="utf-8")
        self.mark(self.shared, "win-1")
        self.assertTrue(self.writer()[0])

    def test_no_device_id_means_undecided_and_none_is_written(self):
        self.mark(self.shared, "win-1")
        self.assertFalse(self.writer()[0])
        self.assertFalse((self.state / "device-id").exists())


class MigrationTests(unittest.TestCase):
    def setUp(self):
        self.directory = tempfile.TemporaryDirectory()
        self.db = Path(self.directory.name) / "ledger.db"
        self.db.write_bytes(b"")

    def tearDown(self):
        self.directory.cleanup()

    def migrations(self, run, *, writer=(True, "本机是写入端")):
        return Migrations(self.db, peach=lambda: Path("C:/peach/.venv/Scripts/peach.exe"),
                          writer=lambda: writer, run=run,
                          environ=lambda: {"PEACH_DATA_ROOT": "C:/peach-data"})

    def test_a_reader_never_migrates(self):
        run = FakeRun()
        step = self.migrations(run, writer=(False, "写入端是 win-1,本机是只读端")).apply()
        self.assertEqual(step.state, "skipped")
        self.assertIn("只读端", step.message)
        self.assertEqual(run.calls, [])

    def test_a_missing_ledger_is_skipped(self):
        self.db.unlink()
        run = FakeRun()
        self.assertEqual(self.migrations(run).apply().state, "skipped")
        self.assertEqual(run.calls, [])

    def test_nothing_pending_runs_only_the_status(self):
        run = FakeRun(completed(0, f"database: {self.db}\nmigrations: 43, pending: 0\n"))
        step = self.migrations(run).apply()
        self.assertEqual(step.state, "current")
        self.assertEqual(len(run.calls), 1)
        command, kwargs = run.calls[0]
        self.assertEqual(command[1:], ["migrate", "status", "--db", str(self.db)])
        self.assertEqual(kwargs["env"]["PYTHONIOENCODING"], "utf-8")
        self.assertEqual(kwargs["env"]["PEACH_DATA_ROOT"], "C:/peach-data")

    def test_pending_migrations_are_applied_with_the_backup_reported(self):
        backup = self.db.with_name("ledger.pre-migrate-20261002-101500.db")
        run = FakeRun(
            completed(0, "migrations: 43, pending: 2\nPENDING 0042 want\nPENDING 0043 x\n"),
            completed(0, f"database: {self.db}\nbackup: {backup}\napplied: 0042, 0043\n"),
        )
        step = self.migrations(run).apply()
        self.assertEqual(step.state, "applied")
        self.assertIn("0042, 0043", step.message)
        self.assertIn(str(backup), step.message)
        self.assertEqual(run.calls[1][0][1:],
                         ["migrate", "upgrade", "--yes", "--db", str(self.db)])

    def test_a_failed_upgrade_is_reported_not_swallowed(self):
        run = FakeRun(completed(0, "migrations: 43, pending: 1\n"),
                      completed(1, "", "sqlite3.OperationalError: database is locked"))
        step = self.migrations(run).apply()
        self.assertTrue(step.failed)
        self.assertIn("database is locked", step.message)
        self.assertIn("备份", step.message)

    def test_a_status_that_cannot_run_is_a_failure(self):
        step = self.migrations(FakeRun(OSError("peach.exe missing"))).apply()
        self.assertTrue(step.failed)
        self.assertIn("peach.exe missing", step.message)


class RecordTests(unittest.TestCase):
    def test_the_record_is_read_back_only_for_the_tray_that_wrote_it(self):
        with tempfile.TemporaryDirectory() as directory:
            state = Path(directory) / "state"
            written = record(state, [Step("migrations", "applied", "已执行迁移 0042"),
                                     Step("readiness", "current", "服务已就绪")], pid=4242)
            self.assertTrue(written["ok"])
            self.assertEqual(read_record(state, pid=4242)["steps"][0]["state"], "applied")
            self.assertIsNone(read_record(state, pid=1))

    def test_stale_dependencies_make_the_record_not_ok(self):
        with tempfile.TemporaryDirectory() as directory:
            written = record(Path(directory), [Step("dependencies", "stale", "不一致")], pid=1)
            self.assertFalse(written["ok"])

    def test_pending_readiness_is_incomplete_without_being_a_failure(self):
        with tempfile.TemporaryDirectory() as directory:
            step = Step("readiness", "pending", "服务仍在启动")
            written = record(Path(directory), [step], pid=1)
            self.assertFalse(written["ok"])
            self.assertFalse(step.problem)


class TaskGateTests(unittest.TestCase):
    HOST = "peach-win"

    def setUp(self):
        self.directory = tempfile.TemporaryDirectory()
        self.db = Path(self.directory.name) / "ledger.db"
        with sqlite3.connect(self.db) as connection:
            connection.execute(
                "CREATE TABLE task_run(id INTEGER PRIMARY KEY, task_key TEXT, trigger TEXT, "
                "status TEXT, pid INTEGER, host TEXT, followup_key TEXT)")
            connection.executemany(
                "INSERT INTO task_run(id, task_key, trigger, status, pid, host, followup_key) "
                "VALUES (?, ?, ?, ?, ?, ?, ?)",
                [
                    (1, "entity-avatar", "manual", "pending", None, self.HOST, "avatar:7"),
                    (2, "follow-check", "scheduled", "running", 100, self.HOST, None),
                    (3, "library-processing", "manual", "running", 100, self.HOST, None),
                    (4, "scrape-codes", "cli", "running", 200, self.HOST, None),
                    (5, "media-repair", "manual", "running", 300, "peach-mac", None),
                    (6, "taste-refresh", "manual", "succeeded", 100, self.HOST, None),
                ])
        connection.close()

    def tearDown(self):
        self.directory.cleanup()

    def test_runs_are_split_into_resumable_blocking_and_stale(self):
        gate = task_gate(self.db, host=self.HOST, alive=lambda pid: pid == 100)
        self.assertEqual([run.id for run in gate.resumable], [1, 2])
        self.assertEqual([run.id for run in gate.blocking], [3])
        # CLI 那条的进程已经不在:重启伤不到它,不该拦。
        self.assertEqual([run.id for run in gate.stale], [4])
        self.assertTrue(gate.refused)
        self.assertIn("library-processing", gate.refusal())
        self.assertIn("--force", gate.refusal())

    def test_a_live_cli_run_blocks_too(self):
        gate = task_gate(self.db, host=self.HOST, alive=lambda _pid: True)
        self.assertEqual([run.task_key for run in gate.blocking],
                         ["library-processing", "scrape-codes"])

    def test_only_resumable_work_lets_the_restart_through(self):
        with sqlite3.connect(self.db) as connection:
            connection.execute("DELETE FROM task_run WHERE id IN (3, 4)")
        connection.close()
        gate = task_gate(self.db, host=self.HOST, alive=lambda _pid: True)
        self.assertFalse(gate.refused)
        self.assertEqual(len(gate.as_dict()["resumable"]), 2)

    def test_a_ledger_without_the_table_or_without_a_file_does_not_block(self):
        self.assertFalse(task_gate(self.db.with_name("none.db"), host=self.HOST,
                                   alive=lambda _pid: True).refused)
        empty = self.db.with_name("empty.db")
        sqlite3.connect(empty).close()
        gate = task_gate(empty, host=self.HOST, alive=lambda _pid: True)
        self.assertFalse(gate.refused)
        self.assertIn("task_run", gate.note)


class FakeDependencies:
    def __init__(self, checked: Step, synced: Step | None = None):
        self.checked, self.synced_step = checked, synced
        self.synced = 0

    def check(self):
        return self.checked

    def sync(self):
        self.synced += 1
        return self.synced_step


class RestartPreparationTests(unittest.TestCase):
    STALE = Step("dependencies", "stale", "项目 venv 与 uv.lock 不一致,要装 1 个包:grpcio==1.84.0")

    def test_a_current_venv_neither_refuses_nor_syncs(self):
        dependencies = FakeDependencies(Step("dependencies", "current", "一致"))
        holders = []
        preparation = RestartPreparation(dependencies, holders=lambda pid: holders.append(pid) or ())
        self.assertIsNone(preparation.before_stop(10))
        self.assertEqual(preparation.while_stopped(), (True, "一致"))
        self.assertEqual(dependencies.synced, 0)
        self.assertEqual(holders, [])

    def test_other_venv_users_refuse_the_restart_before_anything_stops(self):
        asked = []
        preparation = RestartPreparation(FakeDependencies(self.STALE),
                                         holders=lambda pid: asked.append(pid) or (48420,))
        refusal = preparation.before_stop(10)
        self.assertIn("48420", refusal)
        self.assertEqual(asked, [10])

    def test_a_stale_venv_is_synced_while_the_tray_is_stopped(self):
        dependencies = FakeDependencies(self.STALE, Step("dependencies", "synced", "已同步"))
        preparation = RestartPreparation(dependencies, holders=lambda _pid: ())
        self.assertIsNone(preparation.before_stop(10))
        self.assertEqual(dependencies.synced, 0)
        self.assertEqual(preparation.while_stopped(), (True, "已同步"))
        self.assertEqual(dependencies.synced, 1)

    def test_a_failed_sync_is_reported_as_not_ready(self):
        dependencies = FakeDependencies(self.STALE, Step("dependencies", "failed", "uv sync 失败"))
        preparation = RestartPreparation(dependencies, holders=lambda _pid: ())
        preparation.before_stop(10)
        self.assertEqual(preparation.while_stopped(), (False, "uv sync 失败"))


class ImportBoundaryTests(unittest.TestCase):
    def test_the_restart_path_loads_no_third_party_module(self):
        """重启脚本要在活着时替换 venv 里的包,被加载的 `.pyd` 在 Windows 上换不掉。"""
        probe = (
            "import sys; import peach.runtime_prepare, peach.windows_restart, peach.jobs, "
            "peach.config\n"
            "names = sorted(name for name, module in sys.modules.items()\n"
            "    if 'site-packages' in (getattr(module, '__file__', '') or '')\n"
            "    and not name.startswith(('_distutils_hack', '_virtualenv')))\n"
            "print(','.join(names))"
        )
        source_root = str(Path(runtime_prepare.__file__).parents[1])
        output = subprocess.run([sys.executable, "-X", "utf8", "-c", probe], check=True,
                                capture_output=True, text=True, encoding="utf-8",
                                env={**os.environ, "PYTHONPATH": source_root})
        self.assertEqual(output.stdout.strip(), "")


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