Ctrl K
ComunidadGuías…

tests/test_organize.py

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

Archivo original ↗Preguntar a KiBot
"""按模板整理:模板渲染、逃逸防护、计划生成、执行与回滚(ADR-0039)。"""
from __future__ import annotations

import sqlite3
import json
import tempfile
import unittest
from contextlib import closing
from pathlib import Path, PureWindowsPath
from unittest import mock

from peach import organize
from peach.organize_templates import (
    TemplateError, render, sanitise_component, validate_template, clean_filename,
)
from support.ledger import fresh_ledger

ROOT_DECLARATION = "R:\\media"
DECLARED = {"local": (ROOT_DECLARATION,)}

VALUES = {
    "code": "ABC-123", "title": "标题", "studio": "厂牌", "series": "系列",
    "year": "2024", "actors": "甲、乙", "cd": "2", "mosaic": "无码",
    "definition": "1080p",
}


class TemplateRenderTests(unittest.TestCase):
    CASES = [
        # (模板, 取值覆盖, 期望)
        ("{code}", {}, "ABC-123"),
        ("{code}[ {title}]", {}, "ABC-123 标题"),
        ("{number}", {}, "ABC-123"),
        ("{number} {title}", {}, "ABC-123 标题"),
        ("{number}[ {title}]", {"title": ""}, "ABC-123"),
        ("{number}[ {title}]", {}, "ABC-123 标题"),
        ("{number}[ CD{cd}]", {"cd": ""}, "ABC-123"),
        ("{number}[ CD{cd}]", {}, "ABC-123 CD2"),
        ("[{mosaic} ]{number}", {"mosaic": ""}, "ABC-123"),
        ("[{studio} ][{series} ]{number}", {"series": ""}, "厂牌 ABC-123"),
        ("{number} [{definition}]", {}, "ABC-123 1080p"),
        # 方括号是分组记号,不进结果;一个占位符都没有的分组就是一段普通字面量。
        ("{number} [完整版]", {}, "ABC-123 完整版"),
        # 转义写法给出字面的括号。
        ("{number} {{x}}", {}, "ABC-123 {x}"),
        ("{number} [[x]]", {}, "ABC-123 [x]"),
        # 出演者与年份是最常见的两组可选信息。
        ("{number}[ ({year})][ {actors}]", {"year": ""}, "ABC-123 甲、乙"),
    ]

    def test_render_table(self):
        for template, overrides, expected in self.CASES:
            with self.subTest(template=template, overrides=overrides):
                values = {**VALUES, **overrides}
                text, _missing = render(template, values)
                self.assertEqual(text, expected)

    def test_missing_required_field_is_reported(self):
        text, missing = render("{number} {title}", {**VALUES, "title": ""})
        self.assertEqual(missing, frozenset({"title"}))
        self.assertEqual(text, "ABC-123")

    def test_optional_field_is_not_required(self):
        _text, missing = render("{number}[ {title}]", {**VALUES, "title": ""})
        self.assertEqual(missing, frozenset())

    def test_directory_template_keeps_layers(self):
        text, _missing = render("{studio}/{number}", VALUES, directory=True)
        self.assertEqual(text, "厂牌/ABC-123")

    def test_presets_render_the_displayed_example_path(self):
        for preset in organize.PRESETS:
            with self.subTest(preset=preset["label"]):
                filename, missing = render(preset["file"], VALUES)
                directory, directory_missing = render(preset["dir"], VALUES, directory=True)
                self.assertFalse(missing | directory_missing)
                self.assertEqual("/".join(filter(None, (directory, filename + ".mp4"))), preset["example"])


class SanitiseTests(unittest.TestCase):
    def test_filename_symbols_and_invalid_unicode_are_removed(self):
        cases = {
            '01_First Part⧹⧹Second Part⧹⧹Last Scene.mp4':
                '01_First Part Second Part Last Scene.mp4',
            '★ 标题💎👑✨ → 片段 �.mp4': '标题 片段.mp4',
            '标题\\片段﹨补充/附录.mp4': '标题 片段 补充 附录.mp4',
            '标题\ue519\u200b\ufeff\x00片段.mp4': '标题片段.mp4',
            '👩🏽\u200d💻标题❤️\ufe0f.mp4': '标题.mp4',
            '【中日文】 café ガール 168㎝ 1920×1080 (2).mp4':
                '【中日文】 café ガール 168㎝ 1920×1080 (2).mp4',
            '★💎.mp4': '★💎.mp4',
            '★CON.mp4': '_CON.mp4',
        }
        for original, expected in cases.items():
            with self.subTest(original=original):
                self.assertEqual(clean_filename(original), expected)
                self.assertEqual(clean_filename(expected), expected)

    def test_illegal_characters_become_underscore(self):
        self.assertEqual(sanitise_component('a<b>c:d"e/f\\g|h?i*j'),
                         "a_b_c_d_e_f_g_h_i_j")

    def test_trailing_dot_and_space_are_dropped(self):
        self.assertEqual(sanitise_component("名字. "), "名字")

    def test_reserved_device_name_is_prefixed(self):
        self.assertEqual(sanitise_component("CON"), "_CON")
        self.assertEqual(sanitise_component("nul.mp4"), "_nul.mp4")

    def test_long_value_is_truncated_from_the_middle(self):
        value = sanitise_component("甲" * 400)
        self.assertLessEqual(len(value), 120)
        self.assertIn("…", value)


class TemplateEscapeTests(unittest.TestCase):
    REJECTED = [
        "..\\{number}",
        "../{number}",
        "C:\\{number}",
        "/{number}",
        "{number}/{title}",          # 文件名模板不带分隔符
        "{unknown}",
        "{number",
        "{number}]",
        "[{number}",
    ]

    def test_escaping_templates_are_rejected(self):
        for template in self.REJECTED:
            with self.subTest(template=template):
                with self.assertRaises(TemplateError):
                    validate_template(template)

    def test_directory_template_may_layer_but_not_escape(self):
        self.assertEqual(validate_template("{studio}\\{number}", directory=True),
                         "{studio}/{number}")
        with self.assertRaises(TemplateError):
            validate_template("{studio}/../{number}", directory=True)

    def test_value_cannot_introduce_a_layer(self):
        text, _missing = render("{title}", {"title": "a/../b"})
        self.assertEqual(text, "a_.._b")


def _seed(db_path: Path, rows: list[dict]) -> None:
    with closing(sqlite3.connect(db_path)) as connection:
        for row in rows:
            connection.execute(
                "INSERT INTO asset(id,location,path,name,medium,code,catalog_title,"
                "studio,series,release_date,width,height) "
                "VALUES(:id,'local',:path,:name,'video',:code,:title,:studio,NULL,"
                ":release_date,:width,:height)",
                {"series": None, "code": None, "title": None, "studio": None,
                 "release_date": None, "width": None, "height": None, **row})
        connection.commit()


def _plan(db_path: Path, **kwargs) -> dict:
    with closing(sqlite3.connect(db_path)) as connection:
        return organize.build_plan(connection, location="local", roots=DECLARED,
                                   online=True, **kwargs)


class PlanTests(unittest.TestCase):
    def setUp(self):
        self.holder = Path(tempfile.mkdtemp(prefix="peach-organize-")).resolve()
        self.addCleanup(lambda: __import__("shutil").rmtree(self.holder, ignore_errors=True))
        self.db_path = fresh_ledger(self.holder)

    def test_online_root_does_not_hide_another_roots_offline_state(self):
        _seed(self.db_path, [
            dict(id=1, path='R:\\media\\a.mp4', name='a.mp4', code='ABC-001'),
            dict(id=2, path='S:\\media\\b.mp4', name='b.mp4', code='ABC-002'),
        ])
        with closing(sqlite3.connect(self.db_path)) as connection, \
                mock.patch.object(organize, 'translate_ledger_path', side_effect=lambda root: root), \
                mock.patch.object(organize, 'root_online', side_effect=lambda root: str(root).startswith('R:')):
            result = organize.build_plan(connection, location='local', roots={'local': ('R:\\media', 'S:\\media')},
                                         file_template='{number}')
        self.assertEqual(result['counts']['change'], 1)
        self.assertEqual(result['counts']['skip'], 1)
        self.assertEqual(result['rows'][1]['reason'], organize.SKIP_OFFLINE)

    def test_rename_in_place_and_move_into_template_directory(self):
        _seed(self.db_path, [
            {"id": 1, "path": "R:\\media\\旧\\[site.com]abc123.mp4",
             "name": "[site.com]abc123.mp4", "code": "ABC-123", "title": "标题",
             "studio": "厂牌"},
        ])
        plan = _plan(self.db_path, file_template="{number}[ {title}]")
        self.assertEqual(plan["counts"]["change"], 1)
        self.assertEqual(plan["rows"][0]["action"], "rename")
        self.assertEqual(plan["rows"][0]["target_path"],
                         "R:\\media\\旧\\ABC-123 标题.mp4")

        plan = _plan(self.db_path, file_template="{number}", dir_template="{studio}")
        self.assertEqual(plan["rows"][0]["action"], "move")
        self.assertEqual(plan["rows"][0]["target_path"], "R:\\media\\厂牌\\ABC-123.mp4")

    def test_directory_scope_includes_descendants_and_excludes_prefix_siblings(self):
        _seed(self.db_path, [
            {'id': 1, 'path': r'R:\media\合集\a.mp4', 'name': 'a.mp4', 'code': 'ABC-001'},
            {'id': 2, 'path': r'R:\media\合集\子目录\b.mp4', 'name': 'b.mp4', 'code': 'ABC-002'},
            {'id': 3, 'path': r'R:\media\合集2\c.mp4', 'name': 'c.mp4', 'code': 'ABC-003'},
        ])
        plan = _plan(self.db_path, file_template='{number}', path='r:/MEDIA/合集/')
        self.assertEqual([row['asset_id'] for row in plan['rows']], [1, 2])
        self.assertEqual(plan['counts']['total'], 2)
        self.assertEqual(_plan(self.db_path, file_template='{number}', path='')['counts']['total'], 3)

    def test_scope_rejects_relative_outside_and_parent_paths(self):
        for path in ('合集', r'S:\media', r'R:\media2', r'R:\media\..\other'):
            with self.subTest(path=path), self.assertRaisesRegex(organize.OrganizeError, '整理路径'):
                _plan(self.db_path, file_template='{number}', path=path)

    def test_directory_template_uses_scope_as_base_and_checks_outside_scope_collisions(self):
        _seed(self.db_path, [
            {'id': 1, 'path': r'R:\media\合集\旧\a.mp4', 'name': 'a.mp4', 'code': 'ABC-001', 'studio': '厂牌'},
            {'id': 2, 'path': r'R:\media\合集\厂牌\ABC-001.mp4', 'name': 'ABC-001.mp4', 'code': 'ABC-001'},
        ])
        plan = _plan(self.db_path, file_template='{number}', dir_template='{studio}', path=r'R:\media\合集', asset_ids=[1])
        self.assertEqual(plan['counts']['skip'], 1)
        self.assertEqual(plan['rows'][0]['reason'], organize.SKIP_TARGET_EXISTS)
        self.assertEqual(plan['rows'][0]['target_path'], r'R:\media\合集\厂牌\ABC-001.mp4')

    def test_local_mount_scope_is_translated_to_ledger_shape(self):
        mount = self.holder / 'mounted'
        mount.mkdir()
        with mock.patch.object(organize, 'location_mounts', return_value={'local': (mount,)}):
            self.assertEqual(organize.scope_path('local', str(mount / '合集'), DECLARED), r'R:\media\合集')

    def test_filename_cleanup_needs_no_template_or_metadata(self):
        _seed(self.db_path, [{'id': 1, 'path': r'R:\media\★标题⧹⧹片段.mp4',
                             'name': '★标题⧹⧹片段.mp4'}])
        plan = _plan(self.db_path)
        self.assertEqual(plan['rows'][0]['target_path'], r'R:\media\标题 片段.mp4')

    def test_template_values_share_filename_symbol_cleanup(self):
        _seed(self.db_path, [{'id': 1, 'path': r'R:\media\old.mp4', 'name': 'old.mp4',
                             'code': 'ABC-123', 'title': '★标题💎'}])
        plan = _plan(self.db_path, file_template='{number} {title}')
        self.assertEqual(plan['rows'][0]['target_path'], r'R:\media\ABC-123 标题.mp4')

    def test_row_already_named_as_asked_is_not_listed(self):
        _seed(self.db_path, [
            {"id": 1, "path": "R:\\media\\ABC-123.mp4", "name": "ABC-123.mp4",
             "code": "ABC-123"},
        ])
        plan = _plan(self.db_path, file_template="{number}")
        self.assertEqual(plan["counts"]["unchanged"], 1)
        self.assertEqual(plan["rows"], [])

    def test_missing_field_skips_the_row(self):
        _seed(self.db_path, [
            {"id": 1, "path": "R:\\media\\x.mp4", "name": "x.mp4", "code": None},
        ])
        plan = _plan(self.db_path, file_template="{number}")
        self.assertEqual(plan["rows"][0]["action"], "skip")
        self.assertEqual(plan["rows"][0]["reason"], organize.SKIP_MISSING_FIELD)

    def test_two_rows_landing_on_one_name_keep_the_first(self):
        _seed(self.db_path, [
            {"id": 1, "path": "R:\\media\\a.mp4", "name": "a.mp4", "code": "ABC-123"},
            {"id": 2, "path": "R:\\media\\b.mp4", "name": "b.mp4", "code": "ABC-123"},
        ])
        plan = _plan(self.db_path, file_template="{number}")
        self.assertEqual(plan["counts"]["change"], 1)
        skipped = [row for row in plan["rows"] if row["action"] == "skip"]
        self.assertEqual(skipped[0]["reason"], organize.SKIP_TARGET_EXISTS)

    def test_scoped_plan_only_contains_requested_assets(self):
        _seed(self.db_path, [
            {"id": 1, "path": "R:\\media\\a.mp4", "name": "a.mp4", "code": "ABC-123"},
            {"id": 2, "path": "R:\\media\\b.mp4", "name": "b.mp4", "code": "DEF-456"},
        ])
        plan = _plan(self.db_path, file_template="{number}", asset_ids=[2])
        self.assertEqual(plan["counts"], {"total": 1, "unchanged": 0, "change": 1, "skip": 0})
        self.assertEqual([row["asset_id"] for row in plan["rows"]], [2])

    def test_offline_source_skips_every_row(self):
        _seed(self.db_path, [
            {"id": 1, "path": "R:\\media\\a.mp4", "name": "a.mp4", "code": "ABC-123"},
        ])
        with closing(sqlite3.connect(self.db_path)) as connection:
            plan = organize.build_plan(connection, location="local", roots=DECLARED,
                                       online=False, file_template="{number}")
        self.assertEqual(plan["counts"]["skip"], 1)
        self.assertEqual(plan["reasons"], {organize.SKIP_OFFLINE: 1})

    def test_path_outside_the_declared_root_is_skipped(self):
        _seed(self.db_path, [
            {"id": 1, "path": "B:\\其它\\a.mp4", "name": "a.mp4", "code": "ABC-123"},
        ])
        plan = _plan(self.db_path, file_template="{number}")
        self.assertEqual(plan["rows"][0]["reason"], organize.SKIP_OUTSIDE_ROOT)

    def test_plan_csv_carries_every_column(self):
        _seed(self.db_path, [
            {"id": 1, "path": "R:\\media\\a.mp4", "name": "a.mp4", "code": "ABC-123"},
        ])
        plan = _plan(self.db_path, file_template="{number}")
        path = organize.write_plan(self.holder / "plan.csv", plan["rows"])
        header = path.read_text(encoding="utf-8-sig").splitlines()[0]
        self.assertEqual(header.split(","), organize.PLAN_FIELDS)


class ApplyTests(unittest.TestCase):
    """执行与回滚都真的动文件:`translate_ledger_path` 指到临时目录,两个平台一致。"""

    def test_committed_moves_keep_one_journal_and_resume_without_renaming_twice(self):
        for index in (1, 2):
            self._file(f'old/{index}.mp4')
        _seed(self.db_path, [dict(id=index, path=f'R:\\media\\old\\{index}.mp4',
                                  name=f'{index}.mp4', code=f'ABC-00{index}') for index in (1, 2)])
        plan = _plan(self.db_path, file_template='{number}')
        def stop(**fields):
            raise InterruptedError('模拟整理中断')
        with closing(sqlite3.connect(self.db_path)) as connection, self.assertRaises(InterruptedError):
            organize.apply_plan(connection, plan['rows'], generated_root=self.holder, stamp='resume', progress=stop)
        with closing(sqlite3.connect(self.db_path)) as connection:
            result = organize.apply_plan(connection, plan['rows'], generated_root=self.holder, stamp='resume')
        self.assertEqual((result['moved'], result['failed']), (2, 0))
        journal = json.loads(Path(result['log_path']).read_text(encoding='utf-8'))
        self.assertEqual(len(journal['entries']), 2)
        self.assertEqual(len(list(organize.batch_log_dir(self.holder).glob('*.json'))), 1)

    def setUp(self):
        self.holder = Path(tempfile.mkdtemp(prefix="peach-organize-apply-")).resolve()
        self.addCleanup(lambda: __import__("shutil").rmtree(self.holder, ignore_errors=True))
        self.media = self.holder / "media"
        self.media.mkdir()
        self.db_path = fresh_ledger(self.holder)
        patcher = mock.patch.object(organize, "translate_ledger_path", self._translate)
        patcher.start()
        self.addCleanup(patcher.stop)

    def _translate(self, raw) -> Path:
        return self.media.joinpath(*PureWindowsPath(str(raw)).parts[2:])

    def _file(self, relative: str) -> Path:
        path = self.media / relative
        path.parent.mkdir(parents=True, exist_ok=True)
        path.write_text("x", encoding="utf-8")
        return path

    def _apply(self, plan):
        with closing(sqlite3.connect(self.db_path)) as connection:
            return organize.apply_plan(connection, plan["rows"],
                                       generated_root=self.holder)

    def test_apply_moves_the_file_and_updates_the_ledger(self):
        self._file("旧/abc123.mp4")
        _seed(self.db_path, [
            {"id": 1, "path": "R:\\media\\旧\\abc123.mp4", "name": "abc123.mp4",
             "code": "ABC-123", "studio": "厂牌"},
        ])
        plan = _plan(self.db_path, file_template="{number}", dir_template="{studio}")
        result = self._apply(plan)
        self.assertEqual((result["moved"], result["failed"]), (1, 0))
        self.assertTrue((self.media / "厂牌" / "ABC-123.mp4").is_file())
        self.assertFalse((self.media / "旧" / "abc123.mp4").exists())
        with closing(sqlite3.connect(self.db_path)) as connection:
            row = connection.execute("SELECT path,name FROM asset WHERE id=1").fetchone()
        self.assertEqual(row, ("R:\\media\\厂牌\\ABC-123.mp4", "ABC-123.mp4"))

    def test_a_disposed_asset_in_a_saved_plan_is_left_on_disk(self):
        original = self._file('old/title.mp4')
        _seed(self.db_path, [dict(id=1, path=r'R:\media\old\title.mp4', name='title.mp4', code='ABC-123')])
        plan = _plan(self.db_path, file_template='{number}')
        with closing(sqlite3.connect(self.db_path)) as connection:
            connection.execute("UPDATE asset SET disposal='trash' WHERE id=1")
            connection.commit()
        result = self._apply(plan)
        self.assertEqual((result['moved'], result['failed']), (0, 1))
        self.assertTrue(original.is_file())

    def test_case_only_rename_updates_spelling_and_can_be_rolled_back(self):
        self._file('old/abc-123.mp4')
        original = r'R:\media\old\abc-123.mp4'
        target = r'R:\media\old\ABC-123.mp4'
        _seed(self.db_path, [dict(id=1, path=original, name='abc-123.mp4', code='ABC-123')])
        plan = _plan(self.db_path, file_template='{number}')
        result = self._apply(plan)
        self.assertEqual((result['moved'], result['failed']), (1, 0))
        self.assertEqual([path.name for path in (self.media / 'old').iterdir()], ['ABC-123.mp4'])
        with closing(sqlite3.connect(self.db_path)) as connection:
            self.assertEqual(connection.execute('SELECT path,name FROM asset').fetchone(),
                             (target, 'ABC-123.mp4'))
            result = organize.rollback_batch(connection, organize.latest_batch(self.holder))
            self.assertEqual((result['restored'], result['failed']), (1, 0))
            self.assertEqual(connection.execute('SELECT path,name FROM asset').fetchone(),
                             (original, 'abc-123.mp4'))
        self.assertEqual([path.name for path in (self.media / 'old').iterdir()], ['abc-123.mp4'])

    def test_case_only_move_committed_before_journal_is_not_renamed_again(self):
        self._file('old/ABC-123.mp4')
        original = r'R:\media\old\abc-123.mp4'
        target = r'R:\media\old\ABC-123.mp4'
        _seed(self.db_path, [dict(id=1, path=target, name='ABC-123.mp4', code='ABC-123')])
        plan = {'rows': [dict(asset_id=1, action='rename', current_path=original, target_path=target)]}
        with mock.patch.object(organize, '_rename', side_effect=AssertionError('已提交的行不应再次改名')):
            result = self._apply(plan)
        self.assertEqual((result['moved'], result['failed']), (1, 0))

    def test_scoped_symbol_cleanup_and_rollback_preserve_unselected_files(self):
        self._file('合集/★标题⧹⧹片段.mp4')
        self._file('合集2/★标题.mp4')
        _seed(self.db_path, [
            {'id': 1, 'path': r'R:\media\合集\★标题⧹⧹片段.mp4', 'name': '★标题⧹⧹片段.mp4'},
            {'id': 2, 'path': r'R:\media\合集2\★标题.mp4', 'name': '★标题.mp4'},
        ])
        plan = _plan(self.db_path, path=r'R:\media\合集')
        result = self._apply(plan)
        self.assertEqual((result['moved'], result['failed']), (1, 0))
        self.assertTrue((self.media / '合集/标题 片段.mp4').exists())
        self.assertTrue((self.media / '合集2/★标题.mp4').exists())
        with closing(sqlite3.connect(self.db_path)) as connection:
            self.assertEqual(connection.execute('SELECT name FROM asset WHERE id=1').fetchone()[0], '标题 片段.mp4')
            result = organize.rollback_batch(connection, organize.latest_batch(self.holder))
        self.assertEqual((result['restored'], result['failed']), (1, 0))
        self.assertTrue((self.media / '合集/★标题⧹⧹片段.mp4').exists())

    def test_rollback_puts_everything_back(self):
        self._file("旧/abc123.mp4")
        _seed(self.db_path, [
            {"id": 1, "path": "R:\\media\\旧\\abc123.mp4", "name": "abc123.mp4",
             "code": "ABC-123", "studio": "厂牌"},
        ])
        self._apply(_plan(self.db_path, file_template="{number}", dir_template="{studio}"))
        batch = organize.latest_batch(self.holder)
        self.assertIsNotNone(batch)
        with closing(sqlite3.connect(self.db_path)) as connection:
            result = organize.rollback_batch(connection, batch)
        self.assertEqual((result["restored"], result["failed"]), (1, 0))
        self.assertTrue((self.media / "旧" / "abc123.mp4").is_file())
        with closing(sqlite3.connect(self.db_path)) as connection:
            path = connection.execute("SELECT path FROM asset WHERE id=1").fetchone()[0]
        self.assertEqual(path, "R:\\media\\旧\\abc123.mp4")
        # 整批退回去之后它不该再出现在「回滚上一批」里。
        self.assertIsNone(organize.latest_batch(self.holder))

    def test_target_taken_on_disk_fails_only_that_row(self):
        self._file("旧/abc123.mp4")
        self._file("ABC-123.mp4")
        _seed(self.db_path, [
            {"id": 1, "path": "R:\\media\\旧\\abc123.mp4", "name": "abc123.mp4",
             "code": "ABC-123"},
        ])
        plan = {"rows": [{"asset_id": 1, "action": "move",
                          "current_path": "R:\\media\\旧\\abc123.mp4",
                          "target_path": "R:\\media\\ABC-123.mp4"}]}
        result = self._apply(plan)
        self.assertEqual((result["moved"], result["failed"]), (0, 1))
        self.assertEqual(result["failures"][0]["reason"], organize.SKIP_TARGET_EXISTS)
        self.assertTrue((self.media / "旧" / "abc123.mp4").is_file())

    def test_cross_volume_row_is_refused(self):
        self._file("旧/abc123.mp4")
        _seed(self.db_path, [
            {"id": 1, "path": "R:\\media\\旧\\abc123.mp4", "name": "abc123.mp4",
             "code": "ABC-123"},
        ])
        plan = {"rows": [{"asset_id": 1, "action": "move",
                          "current_path": "R:\\media\\旧\\abc123.mp4",
                          "target_path": "B:\\media\\ABC-123.mp4"}]}
        result = self._apply(plan)
        self.assertEqual(result["failures"][0]["reason"], organize.SKIP_CROSS_VOLUME)
        self.assertTrue((self.media / "旧" / "abc123.mp4").is_file())

    def _pikpak_plan(self):
        self._file("旧/abc123.mp4")
        _seed(self.db_path, [
            {"id": 1, "path": "A:\\x\\旧\\abc123.mp4", "name": "abc123.mp4", "code": "ABC-123"},
        ])
        roots = mock.patch.object(organize, "location_roots",
                                  lambda: {"local": (ROOT_DECLARATION,), "pikpak": ("A:\\",)})
        roots.start()
        self.addCleanup(roots.stop)
        return {"rows": [{"asset_id": 1, "action": "rename",
                          "current_path": "A:\\x\\旧\\abc123.mp4",
                          "target_path": "A:\\x\\旧\\ABC-123.mp4"}]}

    def test_pikpak_batch_without_a_verified_rename_is_refused_whole(self):
        plan = self._pikpak_plan()
        with self.assertRaisesRegex(organize.OrganizeError, "pikpak"):
            self._apply(plan)
        self.assertTrue((self.media / "旧" / "abc123.mp4").is_file())
        with self.assertRaises(organize.UnverifiedRename):
            organize._rename("A:\\x\\旧\\abc123.mp4", "A:\\x\\旧\\ABC-123.mp4")
        self.assertTrue((self.media / "旧" / "abc123.mp4").is_file())

    def test_injected_verified_rename_carries_pikpak_rows(self):
        plan = self._pikpak_plan()
        calls = []

        def verified(source, target):
            calls.append((source, target))
            self._translate(source).rename(self._translate(target))

        with organize.verified_renames("pikpak", verified):
            result = self._apply(plan)
        self.assertEqual((result["moved"], result["failed"]), (1, 0))
        self.assertEqual(calls, [("A:\\x\\旧\\abc123.mp4", "A:\\x\\旧\\ABC-123.mp4")])
        self.assertTrue((self.media / "旧" / "ABC-123.mp4").is_file())
        self.assertTrue(organize.unverified_location("pikpak"))

    def test_missing_source_is_reported_not_raised(self):
        _seed(self.db_path, [
            {"id": 1, "path": "R:\\media\\旧\\abc123.mp4", "name": "abc123.mp4",
             "code": "ABC-123"},
        ])
        plan = _plan(self.db_path, file_template="{number}")
        result = self._apply(plan)
        self.assertEqual((result["moved"], result["failed"]), (0, 1))
        self.assertEqual(result["failures"][0]["reason"], "源文件不存在")


class EndpointTests(unittest.TestCase):
    """整理端点:预览只读、执行要确认、模板跟着账本走。"""

    def setUp(self):
        from peach.web_state import WebContract

        self.holder = Path(tempfile.mkdtemp(prefix="peach-organize-web-")).resolve()
        self.addCleanup(lambda: __import__("shutil").rmtree(self.holder, ignore_errors=True))
        self.db_path = fresh_ledger(self.holder)
        _seed(self.db_path, [
            {"id": 1, "path": "R:\\media\\旧\\abc123.mp4", "name": "abc123.mp4",
             "code": "ABC-123", "title": "标题"},
        ])
        self.contract = WebContract(self.db_path, candidate_root=self.holder)
        for target, value in (("location_roots", lambda: DECLARED),
                              ("root_online", lambda _root: True)):
            patcher = mock.patch.object(organize, target, value)
            patcher.start()
            self.addCleanup(patcher.stop)

    def test_preview_writes_a_plan_and_counts_changes(self):
        from peach.web_organize import w_organize_preview

        result = w_organize_preview(self.contract, {
            "location": "local", "file_template": "{number}[ {title}]"})
        self.assertEqual(result["counts"]["change"], 1)
        self.assertEqual(result["rows"][0]["target_path"], "R:\\media\\旧\\ABC-123 标题.mp4")
        self.assertTrue(Path(result["plan_csv"]).is_file())

    def test_write_reports_existing_foreign_keys_and_rejects_new_violations(self):
        from peach.web_organize import _run_write
        with closing(sqlite3.connect(self.db_path)) as connection:
            connection.execute("INSERT INTO media_binding(asset_id,backend,external_id) VALUES(99999,'test','old')")
            connection.commit()
        self.assertEqual(_run_write(self.contract, 'test', lambda _: {})['existing_foreign_keys'], 1)
        def introduce(connection):
            connection.execute("INSERT INTO media_binding(asset_id,backend,external_id) VALUES(99998,'test','new')")
            connection.commit()
            return {}
        with self.assertRaisesRegex(RuntimeError, 'new_foreign_keys=1'):
            _run_write(self.contract, 'test-new', introduce)

    def test_apply_without_confirmation_is_refused(self):
        from peach.web_organize import w_organize_apply

        with self.assertRaises(ValueError):
            w_organize_apply(self.contract, {"location": "local",
                                             "file_template": "{number}"})

    def test_preview_and_apply_share_validated_scope_and_recovery_parameters(self):
        from peach.web_organize import w_organize_preview, w_organize_apply

        request = {'location': 'local', 'path': r'R:\media\旧', 'file_template': '{number}', 'dir_template': ''}
        plan = w_organize_preview(self.contract, request)
        self.assertEqual(plan['path'], request['path'])
        self.assertEqual(plan['counts']['total'], 1)
        with mock.patch.object(self.contract.organize_job, 'start', return_value={'status': 'running'}) as start:
            w_organize_apply(self.contract, {**request, 'confirm': True})
        self.assertEqual(start.call_args.kwargs['recovery']['path'], request['path'])
        self.assertEqual(start.call_args.kwargs['initial']['path'], request['path'])
        for body in ({**request, 'path': r'S:\outside'}, {**request, 'path': ['R:\\media']}):
            with self.subTest(body=body), self.assertRaisesRegex(organize.OrganizeError, '整理路径'):
                w_organize_preview(self.contract, body)
            with self.subTest(body=body), self.assertRaisesRegex(organize.OrganizeError, '整理路径'):
                w_organize_apply(self.contract, {**body, 'confirm': True})

    def test_a_scope_outside_the_source_is_a_bad_request_not_a_server_error(self):
        """整理路径填错是参数问题:接口回 400 并带原因,不是 500。"""
        from peach.api import create_app
        from peach.config import PeachSettings
        from support.web import route_client

        app = create_app(PeachSettings(db_path=self.db_path, configured=True, token=""))
        with route_client(app) as client:
            for route in ("/api/organize/preview", "/api/organize/apply"):
                with self.subTest(route=route):
                    response = client.post(route, json={"location": "local", "path": r"S:\outside",
                                                        "file_template": "{number}", "confirm": True})
                    self.assertEqual(response.status_code, 400)
                    self.assertIn("整理路径不在所选来源内", response.json()["error"])

    def test_apply_on_a_source_without_verified_rename_is_refused(self):
        from peach.web_organize import w_organize_apply

        with self.assertRaisesRegex(ValueError, "pikpak"):
            w_organize_apply(self.contract, {"location": "pikpak", "confirm": True,
                                             "file_template": "{number}"})

    def test_rollback_without_a_batch_is_refused(self):
        from peach.web_organize import w_organize_rollback

        with self.assertRaises(ValueError):
            w_organize_rollback(self.contract, {"confirm": True})

    def test_templates_round_trip_through_the_ledger(self):
        from peach.web_settings import organize_templates, w_settings

        w_settings(self.contract, {"organizeTemplates": {
            "115": {"file": "{number}[ {title}]", "dir": "{studio}"}}})
        self.assertEqual(organize_templates(self.contract),
                         {"115": {"file": "{number}[ {title}]", "dir": "{studio}"}})

    def test_escaping_template_is_refused_on_save(self):
        from peach.web_settings import w_settings

        with self.assertRaises(TemplateError):
            w_settings(self.contract, {"organizeTemplates": {
                "115": {"file": "{number}", "dir": "../{studio}"}}})

    def test_status_lists_placeholders_and_presets(self):
        from peach.web_organize import q_organize

        payload = q_organize(self.contract)
        self.assertEqual(payload["status"], "idle")
        self.assertTrue(payload["presets"])
        self.assertIn("code", [item["key"] for item in payload["placeholders"]])
        self.assertNotIn("number", [item["key"] for item in payload["placeholders"]])

    def test_custom_template_example_uses_the_registered_read_route(self):
        from peach.web_router import dispatch_api_get

        output = dispatch_api_get(None, "/api/organize/example", {
            "file_template": "自定义_{code}[_{definition}]", "dir_template": "{year}/{studio}"})
        self.assertEqual(output, {"ok": True, "example": "2024/厂牌/自定义_ABC-123_1080p.mp4"})
        invalid = dispatch_api_get(None, "/api/organize/example", {"file_template": "{code"})
        self.assertFalse(invalid["ok"])
        self.assertEqual(invalid["field"], "file")
        self.assertIn("没有配对", invalid["error"])


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