Ctrl K
ComunidadGuías…

tests/test_config.py

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

Archivo original ↗Preguntar a KiBot
import importlib
import os
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch

from peach import config, settings_file
from peach.settings_file import SettingsFileError


class TempTree:
    """`<tmp>/peach-data` 加一个并列的 `<tmp>/app` 当项目根。

    临时目录一律先 `.resolve()`:CI runner 的临时路径都是别名(macOS `/var` 软链到
    `/private/var`,Windows 短名 `RUNNER~1` 展开成 `runneradmin`),不 resolve 的路径
    在开发机上过、在 CI 上红。
    """

    def __init__(self, with_data_root: bool = True):
        self._temp = tempfile.TemporaryDirectory()
        self.root = Path(self._temp.name).resolve()
        self.project = self.root / "app"
        self.project.mkdir()
        self.data_root = self.root / "peach-data"
        if with_data_root:
            self.data_root.mkdir()

    def write(self, text: str) -> Path:
        path = self.data_root / settings_file.SETTINGS_FILENAME
        path.parent.mkdir(parents=True, exist_ok=True)
        path.write_text(text, encoding="utf-8")
        return path

    def load(self, environ: dict[str, str] | None = None, **kwargs):
        return settings_file.load_config(
            project_root=self.project, environ=environ or {}, **kwargs)

    def __enter__(self):
        return self

    def __exit__(self, *exc):
        self._temp.cleanup()


class ProjectRootTests(unittest.TestCase):
    def test_project_root_uses_pyinstaller_resource_directory_without_a_src_layer(self):
        # 锚点必须在当前平台上真的是绝对路径。`C:/repo` 在 POSIX 上是**相对**路径,
        # `_project_root` 里的 `.resolve()` 会给它前置 cwd,断言于是只在 Windows 成立。
        root = Path(tempfile.gettempdir()).resolve() / "repo"
        source = root / "src" / "peach" / "settings_file.py"
        self.assertEqual(settings_file._project_root(str(source)), root)
        # bundle_root 不经过 resolve,原样构造,两个平台比较结果一致。
        self.assertEqual(
            settings_file._project_root(str(source), "C:/bundle/_MEI123"),
            Path("C:/bundle/_MEI123"),
        )


class DataRootDiscoveryTests(unittest.TestCase):
    def test_saved_data_root_locations_reject_invalid_and_cyclic_paths(self):
        with TempTree() as tree:
            state = tree.data_root / "state"
            state.mkdir()
            locator = state / settings_file.DATA_ROOT_LOCATION
            for destination in ("", "relative", str(tree.data_root)):
                with self.subTest(destination=destination):
                    locator.write_text(destination, encoding="utf-8")
                    with self.assertRaises(SettingsFileError):
                        tree.load()

    def test_a_missing_selected_root_stays_selected(self):
        with TempTree() as tree:
            selected = tree.root / "custom-data"
            state = tree.data_root / "state"
            state.mkdir()
            (state / settings_file.DATA_ROOT_LOCATION).write_text(str(selected), encoding="utf-8")
            loaded = tree.load()
            self.assertEqual(loaded.data_root, selected)
            self.assertFalse(loaded.present)

    def test_environment_variable_wins_over_the_sibling_directory(self):
        with TempTree() as tree:
            elsewhere = tree.root / "elsewhere"
            found = settings_file.discover_data_root(
                tree.project, {"PEACH_DATA_ROOT": str(elsewhere)})
            self.assertEqual(found, (elsewhere, True))

    def test_sibling_peach_data_is_found_from_the_project_root(self):
        with TempTree() as tree:
            self.assertEqual(
                settings_file.discover_data_root(tree.project, {}),
                (tree.data_root, True),
            )

    def test_nested_checkout_still_finds_the_shared_data_root(self):
        """隔离工作树和打包产物都比项目根深一层以上,向上找必须仍然命中。"""
        with TempTree() as tree:
            nested = tree.root / "worktrees" / "task"
            nested.mkdir(parents=True)
            self.assertEqual(
                settings_file.discover_data_root(nested, {}), (tree.data_root, True))

    def test_frozen_build_searches_from_the_executable_directory(self):
        """单文件包的 `_MEIPASS` 是临时目录,起点必须是 EXE 所在目录才找得到 Peach 数据目录。"""
        with TempTree() as tree:
            executable = tree.project / "dist" / "Peach" / "Peach.exe"
            executable.parent.mkdir(parents=True)
            executable.write_bytes(b"")
            anchor = settings_file._search_anchor(
                tree.root / "temp" / "_MEI123", frozen=True, executable=str(executable))
            self.assertEqual(anchor, executable.parent)
            self.assertEqual(
                settings_file.discover_data_root(anchor, {}), (tree.data_root, True))

    def test_source_tree_searches_from_the_project_root(self):
        with TempTree() as tree:
            anchor = settings_file._search_anchor(
                tree.project, frozen=False, executable=str(tree.root / "python.exe"))
            self.assertEqual(anchor, tree.project)

    def test_missing_data_root_is_an_explicit_unconfigured_state(self):
        with TempTree(with_data_root=False) as tree:
            path, found = settings_file.discover_data_root(tree.project, {})
            self.assertFalse(found)
            # 找不到也要给出落点:`peach init` 按它建目录,页面按它显示提示。
            self.assertEqual(path, tree.root / "peach-data")


class DefaultsAreGenericTests(unittest.TestCase):
    """内建默认必须对一个全新用户成立,不能是某台机器的坐标。"""

    def test_fresh_machine_gets_neutral_defaults(self):
        with TempTree() as tree:
            loaded = tree.load()
        self.assertEqual(loaded.server.host, "127.0.0.1")
        self.assertEqual(loaded.server.port, 8900)
        self.assertEqual(loaded.server.mdns_name, "peach")
        self.assertEqual(loaded.server.review_writer_origin, "")
        self.assertEqual(loaded.server.review_writer_proxy, "")
        # 一台新机器只使用用户主动添加的目录。
        self.assertEqual(loaded.locations, {})
        self.assertEqual(loaded.mounts, {})
        self.assertEqual(loaded.replication.smb_host, "")
        self.assertEqual(loaded.replication.smb_user, "")

    def test_replication_defaults_off_for_a_single_machine(self):
        with TempTree() as tree:
            self.assertFalse(tree.load().replication.enabled)

    def test_shared_root_defaults_beside_the_data_root(self):
        with TempTree() as tree:
            loaded = tree.load()
            self.assertEqual(loaded.shared_root, tree.root / "peach-sync")
            self.assertEqual(loaded.smb_share, "peach-sync")

    def test_directories_default_to_their_own_names_under_the_data_root(self):
        with TempTree() as tree:
            loaded = tree.load()
            for key in settings_file.DIRECTORY_KEYS:
                self.assertEqual(loaded.directory(key), tree.data_root / key)


class PriorityTests(unittest.TestCase):
    """环境变量 > 设置文件 > 内建默认。三层都要有断言,不然改错顺序没人发现。"""

    FILE = """
[server]
port = 9100
mdns_name = 'from-file'
[replication]
enabled = true
smb_host = 'file-host'
"""

    def test_file_overrides_builtin_defaults(self):
        with TempTree() as tree:
            tree.write(self.FILE)
            loaded = tree.load()
        self.assertEqual(loaded.server.port, 9100)
        self.assertEqual(loaded.server.mdns_name, "from-file")
        self.assertTrue(loaded.replication.enabled)
        # 文件里没写的项仍然取内建默认,不会因为文件存在就变空。
        self.assertEqual(loaded.server.host, "127.0.0.1")

    def test_environment_overrides_the_file(self):
        with TempTree() as tree:
            tree.write(self.FILE)
            loaded = tree.load({
                "PEACH_MDNS_NAME": "from-env",
                "PEACH_SHARED_SMB_HOST": "env-host",
            })
        self.assertEqual(loaded.server.mdns_name, "from-env")
        self.assertEqual(loaded.replication.smb_host, "env-host")
        self.assertEqual(loaded.server.port, 9100)

    def test_empty_environment_variable_does_not_blank_the_file_value(self):
        # 空字符串是「没设」,不是「设成空」:LaunchAgent 里留个空变量很常见。
        with TempTree() as tree:
            tree.write(self.FILE)
            loaded = tree.load({"PEACH_MDNS_NAME": ""})
        self.assertEqual(loaded.server.mdns_name, "from-file")

    def test_absolute_directory_moves_that_data_out_of_the_data_root(self):
        with TempTree() as tree:
            other = tree.root / "elsewhere" / "db"
            tree.write(f"[directories]\ndatabase = '{other.as_posix()}'\n")
            loaded = tree.load()
        self.assertEqual(loaded.directory("database"), other)
        self.assertEqual(loaded.directory("state"), tree.data_root / "state")


class BadFileTests(unittest.TestCase):
    def test_syntax_error_names_the_file_and_the_line(self):
        with TempTree() as tree:
            path = tree.write("[server]\nport = = 3\n")
            with self.assertRaises(SettingsFileError) as caught:
                tree.load()
        message = str(caught.exception)
        self.assertIn(str(path), message)
        # tomllib 的消息自带行号;没有行号的报错等于让人在几十行里自己找。
        self.assertIn("line 2", message)

    def test_wrong_type_names_the_key(self):
        with TempTree() as tree:
            tree.write("[server]\nport = 'eight thousand'\n")
            with self.assertRaises(SettingsFileError) as caught:
                tree.load()
        self.assertIn("server.port", str(caught.exception))

    def test_boolean_is_not_accepted_as_a_port(self):
        with TempTree() as tree:
            tree.write("[server]\nport = true\n")
            with self.assertRaises(SettingsFileError):
                tree.load()

    def test_unknown_directory_key_is_rejected_instead_of_ignored(self):
        # 打错一个键名却静默忽略,等于那份数据默默落回默认目录。
        with TempTree() as tree:
            tree.write("[directories]\ndatabses = 'db'\n")
            with self.assertRaises(SettingsFileError) as caught:
                tree.load()
        self.assertIn("databses", str(caught.exception))

    def test_phase_one_drive_letter_keys_are_rejected_with_an_upgrade_hint(self):
        """第一阶段的 `[media] R = ...` 是盘符键;静默忽略等于整台机器安静地脱盘。"""
        with TempTree() as tree:
            tree.write("[media]\nR = '/Volumes/Media'\n")
            with self.assertRaises(SettingsFileError) as caught:
                tree.load()
        message = str(caught.exception)
        self.assertIn("R", message)
        self.assertIn("media.mounts", message)

    def test_mount_for_an_undeclared_location_is_rejected(self):
        # 打错一个来源 ID 同样是安静地脱盘,只是范围小一点。
        with TempTree() as tree:
            tree.write("[media.mounts]\nlocla = '/mnt/res'\n")
            with self.assertRaises(SettingsFileError) as caught:
                tree.load()
        self.assertIn("locla", str(caught.exception))

    def test_mounts_are_keyed_by_location_id(self):
        with TempTree() as tree:
            tree.write("[media.locations]\nlocal = 'D:\\Videos'\n[media.mounts]\nlocal = '/mnt/res/media'\n")
            loaded = tree.load()
        self.assertEqual(loaded.mounts, {"local": ("/mnt/res/media",)})

    def test_a_newly_declared_location_can_be_mounted(self):
        with TempTree() as tree:
            tree.write(
                "[media.locations]\nnas = 'N:/'\n[media.mounts]\nnas = '/mnt/nas'\n")
            loaded = tree.load()
        self.assertEqual(loaded.locations["nas"], ("N:/",))
        self.assertEqual(loaded.mounts, {"nas": ("/mnt/nas",)})

    def test_a_declared_locations_table_is_authoritative(self):
        """`peach init` 的问答只声明 local;内建默认的示例盘符不得从旁边混进来。"""
        with TempTree() as tree:
            tree.write("[media.locations]\nlocal = 'D:\\Videos'\n")
            loaded = tree.load()
        self.assertEqual(loaded.locations, {"local": (r"D:\Videos",)})

    def test_a_location_may_declare_several_roots_with_matching_mounts(self):
        """两块硬盘都是 `local`:数据库路径前缀与媒体目录均写数组,按顺序对应,保存时保留。"""
        with TempTree() as tree:
            tree.write("[media.locations]\nlocal = ['D:\\Videos', 'E:\\Movies']\n"
                       "[media.mounts]\nlocal = ['/mnt/a', '/mnt/b']\n")
            loaded = tree.load()
            self.assertEqual(loaded.locations, {"local": (r"D:\Videos", r"E:\Movies")})
            self.assertEqual(loaded.mounts, {"local": ("/mnt/a", "/mnt/b")})
            rendered = settings_file.render(loaded)
            self.assertIn("local = ['D:\\Videos', 'E:\\Movies']", rendered)
            self.assertIn("local = ['/mnt/a', '/mnt/b']", rendered)
            tree.write(rendered)
            self.assertEqual(tree.load().locations, loaded.locations)

    def test_mount_count_must_match_the_declared_roots(self):
        # 媒体目录按顺序对应数据库路径前缀,数目不一致时拒绝保存。
        with TempTree() as tree:
            tree.write("[media.locations]\nlocal = ['D:\\Videos', 'E:\\Movies']\n"
                       "[media.mounts]\nlocal = '/mnt/a'\n")
            with self.assertRaises(SettingsFileError) as caught:
                tree.load()
        self.assertIn("2 个数据库路径前缀", str(caught.exception))
        with TempTree() as tree:
            tree.write("[media.locations]\nlocal = ['D:\\Videos', 'D:\\Videos']\n")
            with self.assertRaises(SettingsFileError) as caught:
                tree.load()
        self.assertIn("重复", str(caught.exception))
        with TempTree() as tree:
            tree.write("[media.locations]\nlocal = []\n")
            with self.assertRaises(SettingsFileError) as caught:
                tree.load()
        self.assertIn("没有配置目录前缀", str(caught.exception))

    def test_without_a_locations_table_no_media_roots_are_declared(self):
        with TempTree() as tree:
            tree.write("[server]\nport = 9100\n")
            loaded = tree.load()
        self.assertEqual(loaded.locations, {})

    def test_empty_media_roots_survive_config_roundtrip(self):
        with TempTree() as tree:
            tree.write(settings_file.render(tree.load()))
            self.assertEqual(tree.load().locations, {})

    def test_lenient_load_falls_back_to_builtin_defaults(self):
        """坏文件不能让进程连 Peach 数据目录都不知道——发现顺序不看文件内容。"""
        with TempTree() as tree:
            tree.write("nonsense = =\n")
            loaded = tree.load(strict=False)
        self.assertEqual(loaded.data_root, tree.data_root)
        self.assertEqual(loaded.server.port, 8900)
        self.assertFalse(loaded.present)

    def test_lenient_load_also_survives_a_merge_time_rejection(self):
        """语法没错、内容被拒的文件同样得退得下来。

        `peach init --force` 是坏文件的唯一自救入口,它走的就是 `strict=False`。
        合并期的拒绝漏在退路之外的话,`[media] R = ...` 这种写法会把自救入口
        本身打崩——语法完全正确,抛错的是键空间校验。
        """
        for text in ("[media]\nR = '/mnt/res'\n",
                     "[media.mounts]\nlocla = '/mnt/res'\n",
                     "[directories]\ndatabses = 'db'\n"):
            with self.subTest(text=text):
                with TempTree() as tree:
                    tree.write(text)
                    with self.assertRaises(SettingsFileError):
                        tree.load()
                    loaded = tree.load(strict=False)
                self.assertEqual(loaded.data_root, tree.data_root)
                self.assertEqual(loaded.server.port, 8900)
                self.assertEqual(loaded.mounts, {})
                self.assertFalse(loaded.present)


class ConfiguredStateTests(unittest.TestCase):
    def test_existing_data_root_counts_as_configured_without_a_settings_file(self):
        # 现有部署还没生成过设置文件,但它们的账本在跑,不能被判成「未配置」。
        with TempTree() as tree:
            loaded = tree.load()
        self.assertFalse(loaded.present)
        self.assertTrue(loaded.configured)

    def test_fresh_clone_is_unconfigured(self):
        with TempTree(with_data_root=False) as tree:
            loaded = tree.load()
        self.assertFalse(loaded.configured)
        self.assertEqual(loaded.path.name, settings_file.SETTINGS_FILENAME)


class SerialisationTests(unittest.TestCase):
    def test_render_then_load_returns_the_same_values(self):
        with TempTree() as tree:
            tree.write("[media.locations]\nlocal = 'D:\\media'\n'115' = 'Q:\\Cloud'\n")
            captured = settings_file.capture_existing(
                tree.load(),
                overrides={
                    "mdns_name": "peach-two", "port": 9443,
                    "review_writer_origin": "https://192.0.2.5",
                    "smb_host": "peach-writer.local", "smb_user": "someone",
                },
                # 反斜杠和 Windows 盘符必须原样活过一次往返:序列化器要是把 `\m`
                # 当转义处理,挂载点就会变成另一个目录。
                mounts={"local": (r"D:\media",), "115": ("/mnt/115",)},
            )
            settings_file.write(captured, force=True)
            reloaded = tree.load()
        self.assertEqual(reloaded.server, captured.server)
        self.assertEqual(reloaded.replication, captured.replication)
        self.assertEqual(reloaded.mounts, {"local": (r"D:\media",), "115": ("/mnt/115",)})
        self.assertEqual(reloaded.locations, captured.locations)
        self.assertEqual(reloaded.directories, captured.directories)
        self.assertTrue(reloaded.present)

    def test_values_with_quotes_survive_the_round_trip(self):
        with TempTree() as tree:
            captured = settings_file.capture_existing(
                tree.load(), overrides={"smb_user": "it's me"})
            settings_file.write(captured)
            self.assertEqual(tree.load().replication.smb_user, "it's me")

    def test_write_refuses_to_overwrite_without_force(self):
        with TempTree() as tree:
            loaded = tree.load()
            settings_file.write(loaded)
            with self.assertRaises(SettingsFileError):
                settings_file.write(loaded)
            self.assertEqual(settings_file.write(loaded, force=True), loaded.path)

    def test_capture_keeps_existing_deployments_replicating(self):
        """第三阶段接上开关时,现有双机部署读到的必须还是 true。"""
        with TempTree() as tree:
            captured = settings_file.capture_existing(tree.load())
            self.assertTrue(captured.replication.enabled)
            # 共享目录和共享名落成显式值,别让人去猜默认规则。
            self.assertEqual(captured.replication.shared_root,
                             str(tree.root / "peach-sync"))
            self.assertEqual(captured.replication.smb_share, "peach-sync")

    def test_fresh_capture_leaves_replication_off(self):
        with TempTree() as tree:
            captured = settings_file.capture_existing(
                tree.load(), replication_enabled=False)
        self.assertFalse(captured.replication.enabled)


class ModuleConstantTests(unittest.TestCase):
    """`config` 的模块常量被大量 import;名字和相对关系都是契约。"""

    def test_paths_derive_from_the_active_data_root(self):
        self.assertEqual(config.DATABASE_PATH, config.DATA_ROOT / "database" / "ledger.db")
        self.assertEqual(config.DATABASE_PATH.parent, config.DATABASE_DIR)
        self.assertEqual(config.FFMPEG_DIR, config.TOOLS_DIR / "ffmpeg")
        self.assertEqual(config.COVER_DIR, config.GENERATED_DIR / "covers")
        # 迁移随代码走,不随数据走。
        self.assertEqual(config.MIGRATIONS_DIR, config.PROJECT_ROOT / "migrations")

    def test_mdns_hostname_follows_the_configured_name(self):
        self.assertEqual(config.MDNS_HOSTNAME, f"{config.MDNS_NAME}.local")

    def test_share_name_defaults_to_the_shared_directory_name(self):
        # 挂载点是 `/Volumes/<共享名>`:两者分开写就会挂上一个没人去读的路径。
        self.assertEqual(config.SHARED_SMB_SHARE, config.SHARED_DATA_ROOT.name)

    def test_location_declarations_stay_in_ledger_shape(self):
        # `asset.path` 与配置中的路径前缀均使用 Windows 格式,供目录访问权限检查匹配。
        with TempTree() as tree:
            tree.write("[media.locations]\nlocal = ['P:\\Library']\npikpak = ['Q:\\Cloud']\n")
            try:
                with patch.dict(os.environ, {"PEACH_DATA_ROOT": str(tree.data_root)}):
                    settings_file.reset_cache()
                    reloaded = importlib.reload(config)
                    self.assertEqual(reloaded.LOCATION_ROOT_DECLARATIONS,
                                     {"local": (r"P:\Library",), "pikpak": (r"Q:\Cloud",)})
                    self.assertEqual(reloaded.MEDIA_ROOT_DECLARATIONS,
                                     (r"P:\Library", r"Q:\Cloud"))
            finally:
                settings_file.reset_cache()
                importlib.reload(config)

    def test_settings_dataclass_reports_the_configured_state(self):
        from peach.config import PeachSettings

        self.assertEqual(PeachSettings().configured, config.CONFIGURED)

    def test_share_coordinates_can_be_overridden_without_editing_source(self):
        # 主机名换了、共享改名、账号换一个,都不该要求改源码再发一次版。
        overrides = {
            "PEACH_SHARED_SMB_HOST": "198.51.100.9",
            "PEACH_SHARED_SMB_SHARE": "ledger-drop",
            "PEACH_SHARED_SMB_USER": "someone",
        }
        try:
            with patch.dict(os.environ, overrides):
                settings_file.reset_cache()
                reloaded = importlib.reload(config)
                self.assertEqual(reloaded.SHARED_SMB_HOST, "198.51.100.9")
                self.assertEqual(reloaded.SHARED_SMB_SHARE, "ledger-drop")
                self.assertEqual(reloaded.SHARED_SMB_USER, "someone")
        finally:
            settings_file.reset_cache()
            importlib.reload(config)
        self.assertNotEqual(config.SHARED_SMB_HOST, "198.51.100.9")


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