Ctrl K
ComunidadGuías…

tests/test_follow_store.py

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

Archivo original ↗Preguntar a KiBot
"""追更订阅与候选落地的隔离测试。全部使用临时数据库,绝不碰真实 ledger。"""
import json
import sqlite3
import tempfile
import unittest
from dataclasses import replace
from datetime import datetime, timedelta, timezone
from pathlib import Path

from peach import follow_providers
from peach.follow import FollowSourceError
from peach.follow_sources import FollowCandidate, IwaraConnector, SourceFetch
from peach.follow_store import (
    FollowStore, ReleaseGroup, author_display_text, f95_author_handles,
    f95_author_name, normalized_author_name,
)
from support.ledger import fresh_ledger


ROOT = Path(__file__).resolve().parents[1]
MOMENT = datetime(2026, 8, 25, 9, 0, tzinfo=timezone.utc)


def _candidate(external_id, title, **kwargs):
    kwargs.setdefault("provider", "rule34video")
    return FollowCandidate(external_id=external_id, title=title, **kwargs)


def _fetch(candidates, *, provider="rule34video", ref="lazyprocrastinator",
           semantics="work", raw=b"<html/>", **kwargs):
    return SourceFetch(
        provider=provider, ref=ref,
        request_url=f"https://{provider}.test/{ref}", semantics=semantics,
        candidates=tuple(candidates), raw_body=raw, **kwargs)


class _StoreCase(unittest.TestCase):
    def setUp(self):
        self.temporary = tempfile.TemporaryDirectory()
        self.addCleanup(self.temporary.cleanup)
        self.root = Path(self.temporary.name).resolve()
        self.connection = sqlite3.connect(fresh_ledger(self.root))
        self.connection.row_factory = sqlite3.Row
        self.addCleanup(self.connection.close)
        self.store = FollowStore(lambda: self.connection,
                                 sources_root=self.root / "sources")

    def _entity(self, name="LazyProcrast", aliases=("LazyProcrastinator",)):
        cursor = self.connection.execute(
            "INSERT INTO entity(kind,canonical_name,normalized_name,created_at,updated_at)"
            " VALUES('creator',?,?,?,?)",
            (name, name.lower(), "2026-08-25T00:00:00Z", "2026-08-25T00:00:00Z"))
        entity_id = int(cursor.lastrowid)
        for alias in aliases:
            self.connection.execute(
                "INSERT INTO entity_alias(entity_id,alias,normalized_alias,source)"
                " VALUES(?,?,?,'test')", (entity_id, alias, alias.lower()))
        return entity_id

    def _source(self, provider="rule34video", ref="lazyprocrastinator", **kwargs):
        kwargs.setdefault("label", "LazyProcrastinator")
        kwargs.setdefault("url", f"https://{provider}.test/{ref}")
        return self.store.register(provider=provider, ref=ref, moment=MOMENT, **kwargs)


class RegistrationTests(_StoreCase):
    def test_register_is_idempotent_and_updates_the_label(self):
        first = self._source()
        second = self._source(label="Lazy P")
        self.assertEqual(first, second)
        rows = self.store.sources()
        self.assertEqual(len(rows), 1)
        self.assertEqual(rows[0]["label"], "Lazy P")

    def test_merging_metadata_keeps_the_keys_it_does_not_mention(self):
        """名片是登记之后才解析出来的,补它不能抹掉登记时写下的 author_key。"""
        source_id = self._source(metadata={"author_key": "mrstrauz"})
        self.store.merge_source_metadata(
            source_id, {"official_links": [{"service": "twitter",
                                            "handle": "Mr_Strauz"}]},
            moment=MOMENT)
        stored = json.loads(self.store.sources()[0]["metadata_json"])
        self.assertEqual(stored["author_key"], "mrstrauz")
        self.assertEqual([link["handle"] for link in stored["official_links"]],
                         ["Mr_Strauz"])

    def test_an_empty_profile_list_is_recorded_as_an_answer(self):
        # 「问过了,他没留主页」要留得下来,否则每次检查都要再问一遍。
        source_id = self._source()
        self.store.merge_source_metadata(source_id, {"official_links": []},
                                         moment=MOMENT)
        stored = json.loads(self.store.sources()[0]["metadata_json"])
        self.assertEqual(stored["official_links"], [])

    def test_rule34_case_variants_are_the_same_source(self):
        first = self._source(provider="rule34xxx", ref="LazyProcrastinator")
        second = self._source(provider="rule34xxx", ref="lazyprocrastinator")
        self.assertEqual(first, second)
        self.assertEqual(len(self.store.sources()), 1)
        self.assertEqual(self.store.sources()[0]["ref"], "lazyprocrastinator")

    def test_register_keeps_an_existing_entity_when_the_update_omits_it(self):
        entity_id = self._entity()
        self._source(entity_id=entity_id)
        self._source(label="again")
        self.assertEqual(self.store.sources()[0]["entity_id"], entity_id)

    def test_enabled_only_filters_disabled_sources(self):
        source_id = self._source()
        self._source(provider="kemono", ref="fanbox/30917150")
        self.store.set_enabled(source_id, False, moment=MOMENT)
        self.assertEqual([row["provider"] for row in self.store.sources(enabled_only=True)],
                         ["kemono"])

    def test_bad_semantics_is_rejected(self):
        with self.assertRaises(FollowSourceError):
            self._source(semantics="whatever")

    def test_creator_aliases_include_canonical_name_and_aliases(self):
        entity_id = self._entity()
        self.assertEqual(self.store.creator_aliases(entity_id),
                         ("LazyProcrast", "LazyProcrastinator"))
        self.assertEqual(self.store.creator_aliases(None), ())


class RecordTests(_StoreCase):
    def test_iwara_general_tags_enter_the_inventory_without_guessing_other_types(self):
        from peach.web_follow import _item_tags, online_tag_inventory

        candidate = IwaraConnector()._candidate({
            "id": "Video123", "title": "Test animation", "tags": [
                {"id": "dance", "type": "general"},
                {"id": "blender", "type": "category"},
                {"id": "series_title", "type": "source"},
                {"id": "pose", "type": "unknown"},
                {"id": "animation"}]}, {"name": "Test Creator"}, "creator")
        source_id = self._source(provider="iwara", ref="creator")
        self.store.record(source_id, _fetch([candidate], provider="iwara", ref="creator"),
                          moment=MOMENT)
        rows = online_tag_inventory(self.connection)
        self.assertEqual([(row["k"], row["cat"]) for row in rows], [("dance", "general")])
        item = self.store.items()[0]
        self.assertEqual(_item_tags(item), ["dance"])
        self.assertEqual(item.metadata["source_tag_types"], {
            "dance": "general", "blender": "category", "series_title": "source", "pose": "unknown"})
        self.assertEqual(item.metadata["tags"], ["dance", "blender", "series_title", "pose", "animation"])

    def test_booru_source_only_becomes_a_profile_when_the_handle_matches(self):
        source_id = self._source(
            provider="rule34xxx", ref="auxtasy", label="Auxtasy",
            metadata={"author_key": "auxtasy", "official_links": [
                {"service": "twitter", "handle": "OldWrongName",
                 "url": "https://x.com/OldWrongName"},
            ]},
        )
        self.store.record(source_id, _fetch([
            _candidate("1", "one", provider="rule34xxx",
                       extra={"source": "https://x.com/Auxtasy/status/1"}),
            _candidate("2", "two", provider="rule34xxx",
                       extra={"source": "https://www.patreon.com/futavr/posts/2"}),
        ], provider="rule34xxx", ref="auxtasy"), moment=MOMENT)

        metadata = json.loads(self.store.sources()[0]["metadata_json"])
        self.assertEqual(
            [(link["service"], link["handle"]) for link in metadata["official_links"]],
            [("twitter", "Auxtasy")],
        )

    def test_booru_source_accepts_a_confirmed_alias_but_not_a_collaborator(self):
        self.store.upsert_author_alias(
            "LazyProcrastinator", "lazyprocrast", source="manual", moment=MOMENT)
        source_id = self._source(
            provider="rule34xxx", ref="lazyprocrastinator",
            metadata={"author_key": "lazyprocrastinator"},
        )
        self.store.record(source_id, _fetch([
            _candidate("1", "one", provider="rule34xxx",
                       extra={"source": "https://lazyprocrast.fanbox.cc/posts/1"}),
            _candidate("2", "two", provider="rule34xxx",
                       extra={"source": "https://x.com/unrelated/status/2"}),
        ], provider="rule34xxx", ref="lazyprocrastinator"), moment=MOMENT)

        metadata = json.loads(self.store.sources()[0]["metadata_json"])
        self.assertEqual([link["handle"] for link in metadata["official_links"]],
                         ["lazyprocrast"])

    def test_backfill_pages_add_to_the_profile_links_the_first_page_found(self):
        """往回抓只并集追加出处:更早那几页看不到近期作品链出去的那些站。"""
        source_id = self._source(
            provider="rule34xxx", ref="auxtasy", label="Auxtasy",
            metadata={"author_key": "auxtasy"},
        )
        self.store.record(source_id, _fetch([
            _candidate("1", "one", provider="rule34xxx",
                       extra={"source": "https://x.com/Auxtasy/status/1"}),
        ], provider="rule34xxx", ref="auxtasy"), moment=MOMENT)
        self.store.record(source_id, _fetch([
            _candidate("9", "nine", provider="rule34xxx",
                       extra={"source": "https://auxtasy.fanbox.cc/posts/9"}),
        ], provider="rule34xxx", ref="auxtasy"), moment=MOMENT, page=2)

        metadata = json.loads(self.store.sources()[0]["metadata_json"])
        self.assertEqual([link["handle"] for link in metadata["official_links"]],
                         ["Auxtasy", "auxtasy"])

    def test_first_fetch_adds_items_and_classifies_variants(self):
        source_id = self._source(entity_id=self._entity())
        outcome = self.store.record(source_id, _fetch([
            _candidate("4542713", "Fiona - Paizuri"),
            _candidate("4542721", "Fiona - Paizuri (Nude)"),
            _candidate("4542899", "Mitsuru [WIP]"),
        ]), creator_aliases=self.store.creator_aliases(1), moment=MOMENT)
        self.assertEqual((outcome.added, outcome.updated, outcome.discovered), (3, 0, 3))
        kinds = {item.external_id: item.variant_kind for item in self.store.items()}
        self.assertEqual(kinds, {"4542713": "main", "4542721": "alt", "4542899": "wip"})

    def test_second_fetch_updates_without_resetting_user_state(self):
        source_id = self._source()
        self.store.record(source_id, _fetch([_candidate("1", "Fiona - Paizuri")]),
                          moment=MOMENT)
        item = self.store.items()[0]
        self.store.set_status(item.id, "ignored")
        later = MOMENT + timedelta(hours=1)
        outcome = self.store.record(
            source_id, _fetch([_candidate("1", "Fiona - Paizuri", url="https://x.test/1")],
                              raw=b"<html>2</html>"), moment=later)
        self.assertEqual((outcome.added, outcome.updated), (0, 1))
        refreshed = self.store.items()[0]
        self.assertEqual(refreshed.status, "ignored")
        self.assertEqual(refreshed.url, "https://x.test/1")
        self.assertEqual(refreshed.first_seen_at, item.first_seen_at)
        self.assertNotEqual(refreshed.last_seen_at, item.last_seen_at)

    def test_a_failed_detail_never_erases_a_known_upload_time(self):
        """paheal 每个候选都要单独打一次详情页,被限流时 `_detail` 返回 {}。

        此前 upsert 无条件覆盖,于是上一轮已经取到的上传时间和时长被抹成 NULL,
        而 `COALESCE(published_at, first_seen_at)` 会让界面改显示抓取时刻——
        看着是条完整记录,时间却是错的。实测 168 条 paheal 里 7 条正是这样。
        """
        source_id = self._source()
        self.store.record(source_id, _fetch([_candidate(
            "1", "A", published_at="2026-08-18T09:03:30Z", duration=44.7)]), moment=MOMENT)
        first = self.store.items()[0]
        self.assertEqual(first.published_at, "2026-08-18T09:03:30Z")

        later = MOMENT + timedelta(hours=1)
        self.store.record(source_id, _fetch([_candidate("1", "A")], raw=b"<html>2</html>"),
                          moment=later)
        again = self.store.items()[0]
        self.assertEqual(again.published_at, "2026-08-18T09:03:30Z")
        self.assertEqual(again.duration, 44.7)
        self.assertEqual(again.published_precision, "exact")

    def test_a_later_fetch_with_a_real_time_still_wins(self):
        """保守只针对「取不到」;来源真的给了新时间就要覆盖,不能变成只写一次。"""
        source_id = self._source()
        self.store.record(source_id, _fetch([_candidate("1", "A")]), moment=MOMENT)
        self.assertIsNone(self.store.items()[0].published_at)
        self.store.record(source_id, _fetch([_candidate(
            "1", "A", published_at="2026-08-18T09:03:30Z")], raw=b"<html>2</html>"),
            moment=MOMENT + timedelta(hours=1))
        item = self.store.items()[0]
        self.assertEqual(item.published_at, "2026-08-18T09:03:30Z")
        self.assertEqual(item.published_precision, "exact")

    def test_not_modified_records_the_check_without_touching_items(self):
        source_id = self._source()
        self.store.record(source_id, _fetch([_candidate("1", "A")]), moment=MOMENT)
        outcome = self.store.record(
            source_id, _fetch([], raw=None, not_modified=True), moment=MOMENT)
        self.assertTrue(outcome.not_modified)
        self.assertEqual(len(self.store.items()), 1)
        self.assertEqual(self.store.sources()[0]["last_status"], "not_modified")

    def test_conditional_cursor_is_stored_on_the_source_row(self):
        source_id = self._source()
        self.store.record(source_id, _fetch([_candidate("1", "A")], etag='"v1"',
                                            last_modified="Mon, 25 Aug 2026 09:00:00 GMT"),
                          moment=MOMENT)
        row = self.store.sources()[0]
        self.assertEqual(row["etag"], '"v1"')
        self.assertEqual(row["last_modified"], "Mon, 25 Aug 2026 09:00:00 GMT")

    def test_raw_evidence_is_written_once_and_immutably(self):
        source_id = self._source()
        outcome = self.store.record(source_id, _fetch([_candidate("1", "A")]),
                                    moment=MOMENT)
        # evidence_path 是相对 `sources/` 的路径,分隔符随平台不同——不能写死 `/`。
        evidence = self.root / "sources" / Path(outcome.evidence_path)
        self.assertTrue(evidence.is_file())
        sidecar = json.loads(evidence.with_suffix(".json").read_text(encoding="utf-8"))
        self.assertEqual(sidecar["candidates"], 1)
        self.assertEqual(sidecar["request_url"], "https://rule34video.test/lazyprocrastinator")

    def test_an_unusable_evidence_root_does_not_lose_the_candidates(self):
        """Mac 上 `peach-data/sources` 指向外置盘。盘不在时它是一条断链,
        `mkdir(exist_ok=True)` 会抛 `FileExistsError`(链接在、目标不在)。
        发现跟归档盘无关,不该让整次检查连同已抓到的候选一起炸掉。

        这里用「路径被普通文件占住」构造同一个 `FileExistsError`:断链要 symlink,
        而非管理员的 Windows 建不了 symlink,那样测试就只在一个平台成立。
        """
        (self.root / "evidence").write_text("not a directory", encoding="utf-8")
        store = FollowStore(lambda: self.connection,
                            sources_root=self.root / "evidence")
        source_id = store.register(provider="rule34video", ref="x", label="X",
                                   url="https://x.test/", moment=MOMENT)
        outcome = store.record(source_id, _fetch([_candidate("1", "Fiona - Paizuri")]),
                               moment=MOMENT)
        self.assertEqual(outcome.added, 1)
        self.assertIsNone(outcome.evidence_path)
        self.assertIn("证据未取得", outcome.evidence_error)
        item = store.items()[0]
        self.assertEqual(item.title, "Fiona - Paizuri")
        self.assertIsNone(item.metadata.get("evidence_path"))

    def test_a_readable_evidence_root_reports_no_error(self):
        source_id = self._source()
        outcome = self.store.record(source_id, _fetch([_candidate("1", "A")]),
                                    moment=MOMENT)
        self.assertIsNotNone(outcome.evidence_path)
        self.assertIsNone(outcome.evidence_error)

    def test_identical_evidence_is_reused_without_overwriting_files(self):
        source_id = self._source()
        fetch = _fetch([_candidate("1", "A")])
        first = self.store.record(source_id, fetch, moment=MOMENT)
        evidence = self.root / "sources" / Path(first.evidence_path)
        sidecar = evidence.with_suffix(".json")
        before = [(path.read_bytes(), path.stat().st_mtime_ns) for path in (evidence, sidecar)]
        again = self.store.record(source_id, fetch, moment=MOMENT)
        self.assertEqual(again.evidence_path, first.evidence_path)
        self.assertEqual(again.added, 0)
        self.assertEqual([(path.read_bytes(), path.stat().st_mtime_ns)
                          for path in (evidence, sidecar)], before)

    def test_shared_response_body_keeps_distinct_snapshot_metadata(self):
        for provider, spec in follow_providers.PROVIDERS.items():
            if not spec.source_url:
                continue
            with self.subTest(provider=provider):
                source_id = self._source(provider=provider, semantics=spec.semantics)
                fetch = _fetch([_candidate("1", "A", provider=provider)],
                               provider=provider, semantics=spec.semantics)
                variants = (fetch, replace(fetch, request_url=fetch.request_url + "?firstId=1"),
                            replace(fetch, candidates=()), replace(fetch, raw_body=b"different body"))
                paths = []
                for offset, variant in enumerate(variants):
                    # 时刻各不相同:保留规则按时间留最近几份,同一时刻的先后没有定义。
                    outcome = self.store.record(source_id, variant, moment=MOMENT + timedelta(seconds=offset))
                    self.assertIsNone(outcome.evidence_error)
                    paths.append(self.root / "sources" / Path(outcome.evidence_path))
                self.assertEqual(len(set(paths)), 4)
                for path, variant in zip(paths, variants):
                    self.assertEqual(path.read_bytes(), variant.raw_body)
                    sidecar = json.loads(path.with_suffix(".json").read_text(encoding="utf-8"))
                    self.assertEqual(sidecar["provider"], provider)
                    self.assertEqual(sidecar["request_url"], variant.request_url)
                    self.assertEqual(sidecar["candidates"], len(variant.candidates))

    def test_conflicting_evidence_still_rejects_overwriting(self):
        source_id = self._source()
        fetch = _fetch([_candidate("1", "A")])
        outcome = self.store.record(source_id, fetch, moment=MOMENT)
        evidence = self.root / "sources" / Path(outcome.evidence_path)
        sidecar = evidence.with_suffix(".json")
        original = sidecar.read_bytes()
        sidecar.write_bytes(b"conflicting evidence")
        with self.assertRaisesRegex(FollowSourceError, "immutable follow snapshot collision"):
            self.store.record(source_id, fetch, moment=MOMENT)
        self.assertEqual(sidecar.read_bytes(), b"conflicting evidence")
        self.assertEqual(evidence.read_bytes(), fetch.raw_body)
        sidecar.write_bytes(original)

    def test_approximate_precision_is_carried_through_from_the_connector(self):
        source_id = self._source()
        self.store.record(source_id, _fetch([
            _candidate("1", "A", published_at="2026-08-18T00:00:00Z",
                       extra={"published_precision": "approximate"}),
            _candidate("2", "B"),
        ]), moment=MOMENT)
        precision = {i.external_id: i.published_precision for i in self.store.items()}
        self.assertEqual(precision, {"1": "approximate", "2": "unknown"})

    def test_record_error_keeps_the_message_and_status(self):
        source_id = self._source()
        self.store.record_error(source_id, "HTTP 403", moment=MOMENT,
                                status="unauthorized")
        row = self.store.sources()[0]
        self.assertEqual(row["last_status"], "unauthorized")
        self.assertEqual(row["last_error"], "HTTP 403")

    def test_items_can_be_filtered_by_status(self):
        source_id = self._source()
        self.store.record(source_id, _fetch([_candidate("1", "A"), _candidate("2", "B")]),
                          moment=MOMENT)
        self.store.set_status(self.store.items()[0].id, "seen")
        self.assertEqual(len(self.store.items(statuses=("new",))), 1)

    def test_an_ignored_item_can_be_restored_to_new(self):
        source_id = self._source()
        self.store.record(source_id, _fetch([_candidate("1", "A")]), moment=MOMENT)
        item = self.store.items()[0]
        self.store.set_status(item.id, "ignored")
        self.store.set_status(item.id, "new")
        self.assertEqual(self.store.items()[0].status, "new")

    def test_saved_status_cannot_be_set_directly(self):
        source_id = self._source()
        self.store.record(source_id, _fetch([_candidate("1", "A")]), moment=MOMENT)
        with self.assertRaises(FollowSourceError):
            self.store.set_status(self.store.items()[0].id, "saved")
        with self.assertRaises(FollowSourceError):
            self.store.set_status(self.store.items()[0].id, "nonsense")

    def test_a_tag_the_site_later_marks_as_a_character_follows_on_older_items(self):
        """站方事后把标签改成角色:新条目记上之后,同站旧条目里的同名标签跟着改。

        跨作者也改;别的站不动;只往 character 改,新条目记成 general 不降级旧行。
        """
        def typed(external_id, kind, **kwargs):
            return _candidate(external_id, external_id, extra={"tag_types": {
                "d.mon (overwatch)": kind, "sex": "general"}}, **kwargs)

        old = self._source(ref="vileclipse")
        other_site = self._source(provider="rule34xxx", ref="vileclipse")
        self.store.record(old, _fetch([typed("1", "general")], ref="vileclipse"),
                          moment=MOMENT)
        self.store.record(other_site, _fetch([typed("9", "general", provider="rule34xxx")],
                                             provider="rule34xxx", ref="vileclipse"),
                          moment=MOMENT)
        newer = self._source(ref="someone-else")
        # 探详情失败的候选是 partial,它带的类型不作数。
        self.store.record(newer, _fetch([typed("2", "character", partial=True)],
                                        ref="someone-else"), moment=MOMENT)

        def kinds():
            return {item.external_id: item.metadata["tag_types"]["d.mon (overwatch)"]
                    for item in self.store.items()}

        self.assertEqual(kinds()["1"], "general")
        self.store.record(newer, _fetch([typed("3", "character")], ref="someone-else"),
                          moment=MOMENT)
        self.assertEqual(kinds(), {"1": "character", "2": "character", "3": "character",
                                   "9": "general"})
        old_item = next(item for item in self.store.items() if item.external_id == "1")
        self.assertEqual(old_item.metadata["tag_types"]["sex"], "general")

        self.store.record(old, _fetch([typed("4", "general")], ref="vileclipse"),
                          moment=MOMENT)
        self.assertEqual(kinds()["1"], "character")


class GroupingTests(_StoreCase):
    def test_repeated_collection_of_the_main_upload_preserves_its_variants(self):
        for ref in ("hydrafxx", "zmsfm"):
            source = self._source(ref=ref)
            self.store.record(source, _fetch([
                _candidate("main", "Evening Movie"),
                _candidate("alt", "Evening Movie (4K60fps)"),
            ], ref=ref), moment=MOMENT)
        self.assertEqual(len(self.store.group(self.store.items())), 1)
        for source in self.store.sources():
            self.assertEqual(len(self.store.group(self.store.items(source_id=source["id"]))), 1)

    def test_stored_combined_video_specs_group_without_rewriting_rows(self):
        source = self._source()
        self.store.record(source, _fetch([
            _candidate("one", "Evening Movie (4K60fps)(NO WM)"),
            _candidate("two", "Evening Movie (60fps)(NO WM)"),
        ]), moment=MOMENT)
        self.connection.execute("UPDATE follow_item SET release_key=? WHERE external_id='one'",
                                ("evening movie 4k60fps",))
        self.connection.commit()
        groups = self.store.group(self.store.items())
        self.assertEqual(len(groups), 1)
        self.assertEqual({row.external_id for row in (groups[0].primary, *groups[0].variants)},
                         {"one", "two"})
        stored = self.connection.execute("SELECT release_key FROM follow_item WHERE external_id='one'").fetchone()[0]
        self.assertEqual(stored, "evening movie 4k60fps")

    def _populate(self):
        video = self._source()
        booru = self._source(provider="rule34xxx", ref="lazyprocrastinator")
        self.store.record(video, _fetch([
            _candidate("4542713", "Fiona - Paizuri"),
            _candidate("4542721", "Fiona - Paizuri (Nude)"),
            _candidate("4542705", "Fiona - Missionary"),
        ]), moment=MOMENT)
        self.store.record(booru, _fetch([
            FollowCandidate(provider="rule34xxx", external_id="9988770",
                            title="fiona paizuri",
                            group_hint="rule34xxx:post:9988770"),
            FollowCandidate(provider="rule34xxx", external_id="9988776",
                            title="totally different filename",
                            title_is_name=False,
                            group_hint="rule34xxx:post:9988770"),
        ], provider="rule34xxx"), moment=MOMENT)

    def test_alt_folds_under_its_main_and_other_works_stay_apart(self):
        self._populate()
        groups = {g.primary.title: g for g in self.store.group(self.store.items())}
        self.assertIn("Fiona - Paizuri", groups)
        self.assertIn("Fiona - Missionary", groups)
        paizuri = groups["Fiona - Paizuri"]
        self.assertIn("Fiona - Paizuri (Nude)", [v.title for v in paizuri.variants])
        self.assertEqual(groups["Fiona - Missionary"].variants, ())

    def test_cross_site_duplicate_is_reported_separately_from_same_site_variants(self):
        self._populate()
        paizuri = next(g for g in self.store.group(self.store.items())
                       if g.primary.title == "Fiona - Paizuri")
        self.assertEqual(sorted(paizuri.providers), ["rule34video", "rule34xxx"])
        self.assertTrue(all(d.provider == "rule34xxx" for d in paizuri.duplicates))

    def test_group_hint_beats_an_unrelated_title(self):
        # booru 子帖的「标题」是标签拼的,和父帖毫无关系,只有来源声明的键能连起来。
        self._populate()
        groups = self.store.group(self.store.items())
        stray = [g for g in groups if g.primary.title == "totally different filename"]
        self.assertEqual(stray, [])

    def test_a_shared_origin_key_merges_two_different_sites(self):
        # 这是跨站去重真正靠得住的那条路:rule34.xxx 从 source 归一出的键,
        # 和 kemono 上同一帖子的键完全相同,标题再不一样也能精确合并。
        booru = self._source(provider="rule34xxx", ref="lazyprocrastinator")
        kemono = self._source(provider="kemono", ref="fanbox/30917150")
        self.store.record(booru, _fetch([
            FollowCandidate(provider="rule34xxx", external_id="18534395",
                            title="fiona · blush", title_is_name=False,
                            group_hint="fanbox:12304831"),
        ], provider="rule34xxx", ref="lazyprocrastinator"), moment=MOMENT)
        self.store.record(kemono, _fetch([
            FollowCandidate(provider="kemono", external_id="12304831",
                            title="Fiona - Paizuri", group_hint="fanbox:12304831"),
        ], provider="kemono", ref="fanbox/30917150"), moment=MOMENT)
        groups = self.store.group(self.store.items())
        self.assertEqual(len(groups), 1)
        self.assertEqual(sorted(groups[0].providers), ["kemono", "rule34xxx"])

    def test_paheal_and_existing_sources_deduplicate_on_the_subscribestar_post(self):
        paheal = self._source(provider="rule34paheal", ref="initiala")
        mirror = self._source(provider="rule34xxx", ref="initiala")
        self.store.record(paheal, _fetch([
            FollowCandidate(provider="rule34paheal", external_id="7428820",
                            title="Amina · Tifa", title_is_name=False,
                            group_hint="subscribestar:2639932"),
        ], provider="rule34paheal", ref="initiala"), moment=MOMENT)
        self.store.record(mirror, _fetch([
            FollowCandidate(provider="rule34xxx", external_id="18540000",
                            title="Different tag label", title_is_name=False,
                            group_hint="subscribestar:2639932"),
        ], provider="rule34xxx", ref="initiala"), moment=MOMENT)
        groups = self.store.group(self.store.items())
        self.assertEqual(len(groups), 1)
        self.assertEqual(sorted(groups[0].providers), ["rule34paheal", "rule34xxx"])

    def test_a_tag_derived_title_never_merges_two_works_on_its_own(self):
        # 标签拼出来的「标题」不是名字:同一作者标签相似的两条不能因此被并掉。
        source_id = self._source(provider="rule34xxx", ref="lazyprocrastinator")
        self.store.record(source_id, _fetch([
            FollowCandidate(provider="rule34xxx", external_id="1",
                            title="fiona · blush", title_is_name=False),
            FollowCandidate(provider="rule34xxx", external_id="2",
                            title="fiona · blush", title_is_name=False),
        ], provider="rule34xxx", ref="lazyprocrastinator"), moment=MOMENT)
        self.assertEqual(len(self.store.group(self.store.items())), 2)

    def _booru_post(self, external_id, minutes, *, character="angel_(kof)",
                    general=("cowgirl_position", "blush", "nude", "pov"), **extra):
        tag_types = {"king_of_fighters": "copyright", character: "character",
                     "you": "character", "lazyprocrastinator": "artist",
                     **{tag: "general" for tag in general}}
        return FollowCandidate(
            provider="rule34xxx", external_id=external_id,
            title=f"{character} · {general[0]}", title_is_name=False,
            published_at=(MOMENT + timedelta(minutes=minutes)).isoformat(),
            group_hint=f"rule34xxx:post:{external_id}",
            extra={"title_from": "tags", "tag_types": tag_types, **extra})

    def _record_booru(self, *posts):
        source_id = self._source(provider="rule34xxx", ref="lazyprocrastinator")
        self.store.record(source_id, _fetch(posts, provider="rule34xxx"), moment=MOMENT)
        return self.store.group(self.store.items())

    def test_posts_uploaded_in_one_burst_fold_into_one_work(self):
        # 同一段动画按横屏、竖屏和机位拆成几帖连着发:角色相同、标签几乎一样。
        groups = self._record_booru(
            self._booru_post("1", 0), self._booru_post("2", 1),
            self._booru_post("3", 2, general=("cowgirl_position", "blush", "nude", "boobjob")),
            self._booru_post("4", 150))
        self.assertEqual(len(groups), 1)
        self.assertEqual(groups[0].primary.external_id, "1")
        self.assertEqual(len(groups[0].variants), 3)

    def test_a_burst_needs_the_same_characters(self):
        groups = self._record_booru(
            self._booru_post("1", 0), self._booru_post("2", 1, character="lavinia_voda"))
        self.assertEqual(len(groups), 2)

    def test_a_long_pause_starts_a_new_work(self):
        groups = self._record_booru(self._booru_post("1", 0), self._booru_post("2", 60 * 4))
        self.assertEqual(len(groups), 2)

    def test_posts_with_little_tag_overlap_stay_apart(self):
        groups = self._record_booru(
            self._booru_post("1", 0),
            self._booru_post("2", 1, general=("standing_sex", "against_wall", "hat", "nude")))
        self.assertEqual(len(groups), 2)

    def test_a_post_without_an_origin_joins_the_declared_work_it_was_uploaded_with(self):
        # LazyProcrastinator 的 Aerith 一批 6 帖,5 帖写了出处,夹在中间的一帖没写。
        origin = "https://www.fanbox.cc/@lazy/posts/12304831"
        groups = self._record_booru(
            self._booru_post("15101361", 0, source=origin),
            self._booru_post("15101385", 3),
            self._booru_post("15101390", 4, source=origin))
        self.assertEqual(len(groups), 1)
        self.assertEqual(len(groups[0].variants), 2)

    def test_two_declared_works_are_not_joined_through_a_post_between_them(self):
        # 来源把两帖分成了两个作品,中间那帖挂哪一组都是猜,三帖各自成卡。
        groups = self._record_booru(
            self._booru_post("1", 0, source="https://www.fanbox.cc/@lazy/posts/12304831"),
            self._booru_post("2", 1),
            self._booru_post("3", 2, source="https://www.fanbox.cc/@lazy/posts/12304832"))
        self.assertEqual(len(groups), 3)

    def test_posts_retagged_together_are_not_a_burst(self):
        # Rekin3D 的两帖分别在 05-01 与 05-20 上传,09-14 一起被改了标签,落库时间都是
        # 改标签那一分钟。帖子 id 相差近 20 万,不是连着传的。
        groups = self._record_booru(
            self._booru_post("17361475", 1), self._booru_post("17559518", 0))
        self.assertEqual(len(groups), 2)

    def test_an_untagged_booru_post_joins_its_batch_by_the_whole_tag_set(self):
        # rule34.paheal 不给标签分类。InitialA 一口气传的 Lily 动画里,8 帖写着同一个
        # subscribestar 出处,最后一帖没写;整组标签相同,归进那一组。
        source_id = self._source(provider="rule34paheal", ref="initiala", label="initiala")
        tags = {tag: "general" for tag in
                ("InitialA", "Lily_Artemis_II", "Stellar_Blade", "animated", "blender")}

        def post(external_id, seconds, **extra):
            return FollowCandidate(
                provider="rule34paheal", external_id=external_id,
                title="lily artemis ii · stellar blade", title_is_name=False,
                published_at=(MOMENT + timedelta(seconds=seconds)).isoformat(),
                group_hint="subscribestar:2685233" if "source" in extra
                else f"rule34paheal:post:{external_id}",
                extra={"title_from": "tags", "tag_types": tags, **extra})

        origin = "https://subscribestar.adult/posts/2685233"
        self.store.record(source_id, _fetch([
            post("7452299", 0, source=origin), post("7452300", 1, source=origin),
            post("7452301", 25),
            post("7452302", 30, tag_types={**tags, "Eve": "general"}),
        ], provider="rule34paheal", ref="initiala"), moment=MOMENT)
        groups = sorted(sorted(m.external_id for m in (g.primary, *g.variants, *g.duplicates))
                        for g in self.store.group(self.store.items()))
        self.assertEqual(groups, [["7452299", "7452300", "7452301"], ["7452302"]])

    def test_a_stored_hint_is_recomputed_from_the_recorded_source(self):
        # 落库时缺协议头的出处没认出来,键退回了帖子自己的 id;读时按出处重算后并组。
        stale = self._booru_post("2", 1, character="ahri",
                                 source="x.com/vileclipse/status/2101310844021928089")
        groups = self._record_booru(
            self._booru_post("1", 0, source="https://x.com/vileclipse/status/2101310844021928089"),
            replace(stale, group_hint="rule34xxx:post:2"))
        self.assertEqual(len(groups), 1)

    def _upload(self, external_id, hours, *, duration=60.054, character="grace_ashcroft",
                general=("athletic_female", "blender", "cowgirl_position", "nude"), **extra):
        return replace(self._booru_post(external_id, hours * 60, character=character,
                                        general=general, **extra), duration=duration)

    def _members(self, groups):
        return sorted(sorted(m.external_id for m in (g.primary, *g.variants, *g.duplicates))
                      for g in groups)

    def test_the_same_clip_uploaded_again_days_later_joins_the_first_upload(self):
        # Memz 的 Grace 动画:07-18 一帖带出处,07-23 又传两帖。重新编码过,只有时长逐毫秒
        # 相同;后两帖多挂一个作品标签,一般标签也不完全一样。
        later = {"resident_evil_9:_requiem": "copyright", "grace_ashcroft": "character",
                 "memz": "artist", "athletic_female": "general", "blender": "general",
                 "cowgirl_position": "general", "nude": "general", "3d_animation": "general"}
        groups = self._record_booru(
            self._upload("18148361", 0, source="https://danbooru.donmai.us/posts/11817280"),
            self._upload("18193355", 104, tag_types=later),
            self._upload("18193376", 104, tag_types=later))
        self.assertEqual(self._members(groups), [["18148361", "18193355", "18193376"]])

    def test_an_export_length_shared_by_other_characters_is_not_evidence(self):
        # LazyProcrastinator 的 20.02 秒用在上百种角色组合上:同一角色隔几天的两帖是两部。
        groups = self._record_booru(
            self._upload("1", 0, duration=20.02, character="reika_(doa)"),
            self._upload("2", 24 * 100, duration=20.02, character="reika_(doa)"),
            self._upload("3", 24 * 50, duration=20.02, character="jote_(ffxvi)"))
        self.assertEqual(len(groups), 3)

    def test_a_filtered_page_still_knows_an_export_length(self):
        # 只看未读时,别的角色那几帖可能都不在这一页;认固定长度要按筛选前的全部条目。
        self._record_booru(
            self._upload("1", 0, duration=20.02, character="reika_(doa)"),
            self._upload("2", 24 * 100, duration=20.02, character="reika_(doa)"),
            self._upload("3", 24 * 50, duration=20.02, character="jote_(ffxvi)"))
        everything = self.store.items()
        page = tuple(item for item in everything if item.external_id != "3")
        self.assertEqual(len(self.store.group(page, population=everything)), 2)

    def test_uploads_within_one_session_are_left_to_the_burst_rule(self):
        # 同一小时里时长相同的几帖是同一场景的不同体位,连发判据按相邻帖的标签切开了:
        # 1 与 3 标签相近,中间隔着体位不同的 2,这里不拿时长把 1 和 3 重新串起来。
        handjob = ("beach", "handjob", "duo", "pov")
        groups = self._record_booru(
            self._upload("1", 0, general=handjob),
            self._upload("2", 0.25, general=("beach", "ass_focus", "doggy", "wall")),
            self._upload("3", 0.5, general=(*handjob[:3], "kneeling")))
        self.assertEqual(len(groups), 3)

    def test_reuploads_need_overlapping_tags_and_a_millisecond_length(self):
        groups = self._record_booru(
            self._upload("1", 0), self._upload("2", 48, general=("standing", "hat", "wall", "nude")),
            self._upload("3", 0, duration=45.0, character="nyotengu"),
            self._upload("4", 48, duration=45.0, character="nyotengu"))
        self.assertEqual(len(groups), 4)

    def test_two_declared_releases_are_not_joined_as_a_reupload(self):
        # Pantsushi3D 发过两条推:来源自己说是两次发布,缩略图再像也不并。
        groups = self._record_booru(
            self._upload("4704518", 0, source="https://twitter.com/Pantsushi3D/status/1"),
            self._upload("6178800", 24 * 392, source="https://twitter.com/Pantsushi3D/status/2"))
        self.assertEqual(len(groups), 2)

    def _clip(self, video_id, title, *, duration=20.0, character="angel (kof)",
              work="King of Fighters", **extra_tags):
        tag_types = {"beach": "general", character: "general", work: "copyright",
                     "LazyProcrastinator": "artist", **extra_tags}
        return FollowCandidate(provider="rule34video", external_id=str(video_id),
                               title=title, duration=duration,
                               extra={"tag_types": tag_types})

    def _record_clips(self, *clips):
        source_id = self._source(provider="rule34video", ref="lazyprocrastinator")
        self.store.record(source_id, _fetch(clips), moment=MOMENT)
        return sorted(sorted(m.title for m in (g.primary, *g.variants, *g.duplicates))
                      for g in self.store.group(self.store.items()))

    def test_a_batch_of_clips_folds_per_character(self):
        # 同一段 KOF 动画导出成几条 20 秒短片连着传;Angel 与 Mai 是两个角色,分成两组。
        mai = "mai shiranui (dead or alive)"
        groups = self._record_clips(
            self._clip(4608755, "Angel-riding hugging"), self._clip(4608759, "Angel-missionary"),
            self._clip(4608779, "Angel-handjob"), self._clip(4608787, "Mai-blowjob", character=mai),
            self._clip(4608795, "Mai-doggy", character=mai))
        self.assertEqual(groups, [["Angel-handjob", "Angel-missionary", "Angel-riding hugging"],
                                  ["Mai-blowjob", "Mai-doggy"]])

    def test_a_clip_a_few_dozen_uploads_earlier_joins_its_pack(self):
        # Barney's Mom 一包 8 条 20 秒短片,Riding 比下一条早 64 个 id,中间夹着别人的上传。
        freyja = {"freyja (final fantasy)": "general"}
        groups = self._record_clips(
            self._clip(4608531, "Barney's Mom - Riding", work="Final Fantasy", **freyja),
            self._clip(4608595, "Barney's Mom - Doggy 3", work="Final Fantasy", **freyja),
            self._clip(4608599, "Barney's Mom - Sitting Blowjob", work="Final Fantasy",
                       **freyja))
        self.assertEqual(len(groups), 1)

    def test_the_same_character_uploaded_the_next_day_stays_apart(self):
        # LazyProcrastinator 的 Lavinia 两条隔天上传,id 相差 300,是两部。
        groups = self._record_clips(
            self._clip(4600000, "Lavinia-cowgirl", character="lavinia (zzz)"),
            self._clip(4600300, "Lavinia-doggystyle", character="lavinia (zzz)"))
        self.assertEqual(len(groups), 2)

    def test_clips_far_apart_or_of_another_length_stay_apart(self):
        groups = self._record_clips(
            self._clip(4608755, "Angel-riding hugging"), self._clip(4609755, "Angel-missionary"),
            self._clip(4609759, "Angel-handjob", duration=45.0))
        self.assertEqual(len(groups), 3)

    def test_a_series_title_does_not_fold_different_characters(self):
        # 标题开头是系列名,角色在后半截;带括号的角色标签对不上就拆开。配音演员
        # `Chloeangelva (VA)` 两边都有,但它归 artist,不算角色。
        groups = self._record_clips(
            self._clip(4454645, "Summer Fantasy – Cindy & Lunafreya", duration=97.0,
                       character="cindy aurum (final fantasy)", work="Final Fantasy",
                       **{"Chloeangelva (VA)": "artist"}),
            self._clip(4454649, "Summer Fantasy – Tifa & Aerith", duration=97.0,
                       character="tifa lockhart (final fantasy)", work="Final Fantasy",
                       **{"Chloeangelva (VA)": "artist"}))
        self.assertEqual(len(groups), 2)

    def test_wip_is_surfaced_on_the_group(self):
        source_id = self._source()
        self.store.record(source_id, _fetch([
            _candidate("1", "Mitsuru School Movie"),
            _candidate("2", "Mitsuru School Movie [WIP]"),
        ]), moment=MOMENT)
        group = self.store.group(self.store.items())[0]
        self.assertTrue(group.has_wip)
        self.assertEqual(group.primary.variant_kind, "main")

    def test_versions_posted_together_fold_by_title_family(self):
        # 同一作品按清晰度分帖发:尾巴上的 `Ultra HD` 不在变体词表里,靠「键接一小段
        # 尾巴、自己带变体标记、同期发布」归进同一组。
        source_id = self._source()
        self.store.record(source_id, _fetch([
            _candidate("1", "2B Love at Sunset - 720p", published_at="2026-08-31T23:57:42Z"),
            _candidate("2", "2B Love at Sunset - 1080p", published_at="2026-08-31T23:59:24Z"),
            _candidate("3", "2B Love at Sunset - 4K Ultra HD Unwatermarked",
                       published_at="2026-09-01T00:19:38Z"),
            # 续作编号、隔了几个月的相似标题、尾巴上没有变体标记的,各自成组。
            _candidate("4", "Sayuri - Cowgirl [4K]", published_at="2026-09-01T00:00:00Z"),
            _candidate("5", "Sayuri - Cowgirl Part 2 [4K]", published_at="2026-09-01T00:00:00Z"),
            _candidate("6", "Tifa Beach [4K]", published_at="2026-05-01T00:00:00Z"),
            _candidate("7", "Tifa Beach Night [4K]", published_at="2026-09-01T00:00:00Z"),
            _candidate("8", "Aerith Garden", published_at="2026-09-01T00:00:00Z"),
            _candidate("9", "Aerith Garden Party", published_at="2026-09-01T00:00:00Z"),
        ]), moment=MOMENT)
        groups = self.store.group(self.store.items())
        sunset = [group for group in groups
                  if group.primary.title.startswith("2B Love at Sunset")]
        self.assertEqual(len(sunset), 1)
        self.assertEqual(sorted(item.external_id for item in sunset[0].variants), ["2", "3"])
        self.assertEqual(len(groups), 7)

    def test_the_same_author_on_other_sites_folds_once_names_are_stripped(self):
        # 来源没绑实体时,标题里夹着的作者名入库剥不掉。给了作者映射,分组前剥掉,
        # 相似标题的版本也跨这位作者的来源去对;一个词的标题底下不接别的作品。
        fanbox = self._source(provider="kemono", ref="fanbox/30917150", label="Pantsushi · fanbox")
        video = self._source(ref="pantsushi", label="pantsushi")
        self.store.record(fanbox, _fetch([
            _candidate("f1", "2B Love at Sunset - 1080p", provider="kemono",
                       published_at="2026-08-31T23:59:24Z"),
            _candidate("f2", "Kasumi Training - Alt Black 4K Ultra HD", provider="kemono",
                       published_at="2026-06-24T00:00:00Z"),
            _candidate("f3", "Tifa Lifeguard [4K]", provider="kemono",
                       published_at="2026-06-20T00:00:00Z"),
        ], provider="kemono", ref="fanbox/30917150"), moment=MOMENT)
        self.store.record(video, _fetch([
            _candidate("v1", "2B Love at Sunset [pantsushi] 4K", published_at="2026-09-02T00:00:00Z"),
            _candidate("v2", "Kasumi Training - Alt Black 4K", published_at="2026-06-25T00:00:00Z"),
            _candidate("v3", "Tifa [pantsushi]", published_at="2026-06-23T00:00:00Z"),
            # 剥完等于别的作者的标题,而这位作者自己没有这部:不借剥名去撞别人的键。
            _candidate("v4", "Kyrie Canaan [pantsushi]", published_at="2026-06-01T00:00:00Z"),
        ], ref="pantsushi"), moment=MOMENT)
        other = self._source(ref="billyhhyb", label="billyhhyb")
        self.store.record(other, _fetch([
            _candidate("o1", "Kyrie Canaan", published_at="2026-06-01T00:00:00Z"),
        ], ref="billyhhyb"), moment=MOMENT)
        items = self.store.items()
        pantsushi = ("name:pantsushi", frozenset({"pantsushi"}))
        authors = {fanbox: pantsushi, video: pantsushi,
                   other: ("name:billyhhyb", frozenset({"billyhhyb"}))}
        folded = {frozenset(item.external_id for item in (g.primary, *g.variants, *g.duplicates))
                  for g in self.store.group(items, authors)}
        self.assertEqual(folded, {frozenset({"f1", "v1"}), frozenset({"f2", "v2"}),
                                  frozenset({"f3"}), frozenset({"v3"}),
                                  frozenset({"v4"}), frozenset({"o1"})})
        self.assertEqual(len(self.store.group(items)), 8)

    def test_groups_are_ordered_newest_first(self):
        source_id = self._source()
        self.store.record(source_id, _fetch([
            _candidate("1", "Older", published_at="2026-08-01T00:00:00Z"),
            _candidate("2", "Newer", published_at="2026-08-20T00:00:00Z"),
        ]), moment=MOMENT)
        self.assertEqual([g.primary.title for g in self.store.group(self.store.items())],
                         ["Newer", "Older"])

    def test_empty_input_groups_to_nothing(self):
        self.assertEqual(self.store.group(()), ())

    def test_same_provider_title_collision_is_not_merged_under_work_semantics(self):
        # 实测踩到的:kemono 上「February Poll Animations」和「February Poll + Animations」
        # 归一化后完全相同,却是两个帖子。同站两个 main 撞车只说明标题判据到头了。
        source_id = self._source(provider="kemono", ref="fanbox/30917150")
        self.store.record(source_id, _fetch([
            FollowCandidate(provider="kemono", external_id="10",
                            title="February Poll + Animations",
                            published_at="2026-02-15T21:27:50Z"),
            FollowCandidate(provider="kemono", external_id="19",
                            title="February Poll Animations",
                            published_at="2026-01-31T22:10:13Z"),
        ], provider="kemono", ref="fanbox/30917150"), moment=MOMENT)
        groups = self.store.group(self.store.items())
        self.assertEqual(len(groups), 2)
        self.assertEqual([g.variants for g in groups], [(), ()])

    def test_an_alt_still_folds_when_its_provider_has_only_one_main(self):
        source_id = self._source()
        self.store.record(source_id, _fetch([
            _candidate("1", "Fiona - Paizuri"),
            _candidate("2", "Fiona - Paizuri (Nude)"),
        ]), moment=MOMENT)
        groups = self.store.group(self.store.items())
        self.assertEqual(len(groups), 1)
        self.assertEqual(len(groups[0].variants), 1)

    def test_f95_resource_replies_are_independent_release_groups(self):
        source_id = self._source(provider="f95zone", ref="50685", semantics="release")
        self.store.record(source_id, _fetch([
            FollowCandidate(provider="f95zone", external_id="21383374",
                            title="Lazy Procrastinator Collection [2026-06-28]",
                            published_at="2026-08-21T04:14:09Z"),
            FollowCandidate(provider="f95zone", external_id="21394555",
                            title="Lazy Procrastinator Collection [2026-06-28]",
                            published_at="2026-08-22T18:09:23Z"),
        ], provider="f95zone", ref="50685", semantics="release"), moment=MOMENT)
        groups = self.store.group(self.store.items())
        self.assertEqual(len(groups), 2)
        self.assertTrue(all(group.is_release for group in groups))
        self.assertTrue(all(group.variants == () for group in groups))
        # 最新楼层排在前面,但不会把较早的资源楼层吞成「动态」。
        self.assertEqual(groups[0].primary.external_id, "21394555")
        # 标题里的日期是批次标签,不是版本:`[2026-06-28]` 不进版本位。
        self.assertIsNone(groups[0].primary.version)


class SaveAssetTests(_StoreCase):
    def _one_item(self, **candidate_kwargs):
        entity_id = self._entity()
        source_id = self._source(entity_id=entity_id)
        candidate_kwargs.setdefault("url", "https://rule34video.com/video/4542713/x/")
        candidate_kwargs.setdefault("duration", 20.0)
        self.store.record(
            source_id,
            _fetch([_candidate("4542713", "Fiona - Paizuri", **candidate_kwargs)]),
            moment=MOMENT)
        return entity_id, self.store.items()[0]

    def test_saving_requires_explicit_confirmation(self):
        _, item = self._one_item()
        with self.assertRaises(FollowSourceError):
            self.store.save_asset(item.id)
        self.assertEqual(self.connection.execute(
            "SELECT count(*) FROM asset").fetchone()[0], 0)

    def test_confirmed_save_creates_one_online_asset_and_links_the_creator(self):
        entity_id, item = self._one_item(published_at="2026-08-18T06:23:33Z")
        asset_id = self.store.save_asset(item.id, confirm=True, moment=MOMENT)
        row = self.connection.execute(
            "SELECT location,path,name,medium,creator,duration,release_date FROM asset"
            " WHERE id=?", (asset_id,)).fetchone()
        self.assertEqual(row["location"], "online")
        self.assertEqual(row["path"], "https://rule34video.com/video/4542713/x/")
        self.assertEqual(row["medium"], "video")
        self.assertEqual(row["creator"], "LazyProcrast")
        self.assertEqual(row["release_date"], "2026-08-18")
        relation = self.connection.execute(
            "SELECT role,source FROM asset_entity WHERE asset_id=? AND entity_id=?",
            (asset_id, entity_id)).fetchone()
        self.assertEqual((relation["role"], relation["source"]),
                         ("creator", "follow:rule34video"))
        self.assertEqual(self.store.items()[0].status, "saved")

    def test_saving_twice_is_idempotent(self):
        _, item = self._one_item()
        first = self.store.save_asset(item.id, confirm=True, moment=MOMENT)
        second = self.store.save_asset(item.id, confirm=True, moment=MOMENT)
        self.assertEqual(first, second)
        self.assertEqual(self.connection.execute(
            "SELECT count(*) FROM asset").fetchone()[0], 1)

    def test_an_existing_online_asset_is_reused_not_duplicated(self):
        _, item = self._one_item()
        self.connection.execute(
            "INSERT INTO asset(location,path,name) VALUES('online',?,'existing')",
            (item.url,))
        asset_id = self.store.save_asset(item.id, confirm=True, moment=MOMENT)
        self.assertEqual(self.connection.execute(
            "SELECT name FROM asset WHERE id=?", (asset_id,)).fetchone()[0], "existing")

    def test_kemono_posts_without_duration_are_saved_as_illustrations(self):
        source_id = self._source(provider="kemono", ref="fanbox/30917150")
        self.store.record(source_id, _fetch(
            [FollowCandidate(provider="kemono", external_id="11406814", title="VVD 7",
                             url="https://kemono.cr/fanbox/user/30917150/post/11406814")],
            provider="kemono", ref="fanbox/30917150"), moment=MOMENT)
        asset_id = self.store.save_asset(self.store.items()[0].id, confirm=True,
                                         moment=MOMENT)
        self.assertEqual(self.connection.execute(
            "SELECT medium FROM asset WHERE id=?", (asset_id,)).fetchone()[0],
            "illustration")

    def test_a_candidate_without_a_page_url_cannot_be_saved(self):
        source_id = self._source()
        self.store.record(source_id, _fetch([_candidate("1", "No URL")]), moment=MOMENT)
        with self.assertRaises(FollowSourceError):
            self.store.save_asset(self.store.items()[0].id, confirm=True)

    def test_missing_item_is_an_error(self):
        with self.assertRaises(FollowSourceError):
            self.store.save_asset(9999, confirm=True)


class MediaReparseTests(_StoreCase):
    """`source_needs_media_reparse` 的判定口径。

    这个判定交给 SQLite 的 `json_extract`,不把整个来源的 `metadata_json` 取回
    Python 再逐条 `json.loads`:回填过的来源单源上千行,为一个布尔值全解一遍。
    下推到 SQL 要守住同样的容忍度。
    """

    def _write_metadata(self, source_id: int, external_id: str, raw) -> None:
        self.connection.execute(
            "INSERT INTO follow_item(source_id,external_id,release_key,title,url,"
            "metadata_json,first_seen_at,last_seen_at) VALUES(?,?,?,?,?,?,?,?)",
            (source_id, external_id, external_id, external_id,
             f"https://x.test/{external_id}",
             raw, "2026-08-25T00:00:00Z", "2026-08-25T00:00:00Z"))

    def test_no_item_needs_a_reparse_on_an_empty_source(self):
        self.assertFalse(self.store.source_needs_media_reparse(self._source()))

    def test_one_flagged_item_is_enough(self):
        source_id = self._source()
        self._write_metadata(source_id, "a", json.dumps({"markers": []}))
        self._write_metadata(source_id, "b",
                             json.dumps({"media_needs_credential": True}))
        self.assertTrue(self.store.source_needs_media_reparse(source_id))

    def test_an_explicit_false_does_not_count(self):
        source_id = self._source()
        self._write_metadata(source_id, "a",
                             json.dumps({"media_needs_credential": False}))
        self.assertFalse(self.store.source_needs_media_reparse(source_id))

    def test_unparsable_or_odd_shaped_metadata_is_skipped_not_fatal(self):
        """非法 JSON 只是「这条不知道」,不该让整个检查崩掉。

        `json_extract` 遇到非法 JSON 是报错而不是返回 NULL,所以查询里那层
        `json_valid` 不能省:它顶的是 `except JSONDecodeError: continue` 那一级容忍。
        列上是 `NOT NULL DEFAULT '{}'`,所以「没有元数据」落盘的形状是 `{}` 和
        空字串,不是 NULL;空字串并不是合法 JSON,同样得被 `json_valid` 拦住。
        """
        source_id = self._source()
        self._write_metadata(source_id, "a", "not json at all")
        self._write_metadata(source_id, "b", "")
        self._write_metadata(source_id, "c", "[1,2,3]")
        self._write_metadata(source_id, "d", "{}")
        self.assertFalse(self.store.source_needs_media_reparse(source_id))
        self._write_metadata(source_id, "e",
                             json.dumps({"media_needs_credential": True}))
        self.assertTrue(self.store.source_needs_media_reparse(source_id))

    def test_the_flag_does_not_leak_across_sources(self):
        first = self._source()
        second = self._source(ref="someone-else")
        self._write_metadata(second, "a",
                             json.dumps({"media_needs_credential": True}))
        self.assertFalse(self.store.source_needs_media_reparse(first))
        self.assertTrue(self.store.source_needs_media_reparse(second))


class ReleaseGroupTests(unittest.TestCase):
    def test_group_is_immutable(self):
        group = ReleaseGroup("k", None, (), ())
        with self.assertRaises(Exception):
            group.release_key = "other"


class AuthorIdentityTests(_StoreCase):
    """作者别名表的写入口径。这些 SQL 归存储层,不写在 Web 处理函数里。"""

    def test_preferred_name_preserves_all_spellings_and_the_group(self):
        current = "Suzutaru 3D (鈴太郎3D)"
        self.store.upsert_author_alias(current, "suzutaro3d", source="manual")
        self.store.upsert_author_alias(current, "鈴太郎3D", source="manual")
        result = self.store.rename_author_group(normalized_author_name(current), "鈴太郎3D")
        mapping, groups = self.store.author_aliases()
        self.assertEqual(result, {"previous": current, "canonical": "鈴太郎3D"})
        self.assertEqual(groups[0]["canonical_name"], "鈴太郎3D")
        self.assertEqual({row["name"] for row in groups[0]["aliases"]}, {current, "suzutaro3d"})
        self.assertEqual(set(mapping.values()), {normalized_author_name("鈴太郎3D")})

    def test_preferred_name_rejects_other_groups_and_missing_groups(self):
        self.store.upsert_author_alias("作者 A", "alias A", source="manual")
        self.store.upsert_author_alias("作者 B", "alias B", source="manual")
        before = self.store.author_aliases()
        for key, name in ((normalized_author_name("作者 A"), "alias B"), ("missing", "名称"),
                          (normalized_author_name("作者 A"), "")):
            with self.subTest(key=key, name=name), self.assertRaises(ValueError):
                self.store.rename_author_group(key, name)
            self.assertEqual(self.store.author_aliases(), before)

    def test_the_container_suffix_is_not_part_of_the_author_name(self):
        """F95 的线程标题说的是一个容器,不是另一个作者。

        真实数据是 `Lazy Procrastinator Collection`,而每一条作者来源都是
        `LazyProcrastinator`;留着这个通用后缀会凭空多出一个分组。
        """
        self.assertEqual(
            normalized_author_name("Lazy Procrastinator Collection",
                                   provider="f95zone"),
            normalized_author_name("LazyProcrastinator"))
        # 只对 F95 成立:别的站上 `Collection` 可能真是名字的一部分。
        self.assertNotEqual(
            normalized_author_name("Lazy Procrastinator Collection"),
            normalized_author_name("LazyProcrastinator"))

    def test_the_service_suffix_only_says_where_they_publish(self):
        self.assertEqual(normalized_author_name("LazyProcrastinator · fanbox"),
                         normalized_author_name("lazyprocrastinator"))
        self.assertEqual(author_display_text("Billyhhyb · patreon"), "Billyhhyb")

    def test_the_f95_author_sits_in_the_last_bracket(self):
        """站点的标题约定是 `作品名 [版本或日期] [作者]`。"""
        self.assertEqual(
            author_display_text("Strauzek Collection [2026-09-04] [Mr_Strauz]",
                                provider="f95zone"), "Mr_Strauz")
        self.assertEqual(
            author_display_text("Some Game [v1.2] [Final] [DevName]",
                                provider="f95zone"), "DevName")

    def test_a_title_without_an_author_bracket_falls_back_to_its_stem(self):
        # `[Collection Request]` 是版块标签,`Complete Collection` 是容器措辞,
        # 剥掉之后剩下的才是这个人。
        self.assertEqual(
            author_display_text(
                "[Collection Request] Suzutaru 3D - Complete Collection",
                provider="f95zone"), "Suzutaru 3D")
        self.assertEqual(
            author_display_text("Memz3D Models Collection", provider="f95zone"),
            "Memz3D")

    def test_both_handles_in_the_author_bracket_are_kept(self):
        # 一个人常在作者位上写两个手柄,第一个当显示名,另一个够格做别名候选。
        self.assertEqual(
            f95_author_handles(
                "Lazy Procrastinator Collection [2026-06-28]"
                " [LazyProcrastinator/LazyProcrast]"),
            ("LazyProcrastinator", "LazyProcrast"))
        self.assertEqual(
            f95_author_name(
                "Lazy Procrastinator Collection [2026-06-28]"
                " [LazyProcrastinator/LazyProcrast]"), "LazyProcrastinator")

    def test_the_bracket_rule_is_only_for_f95(self):
        # 别的站上方括号可能真是标签的一部分,不能拿 F95 的约定去套。
        self.assertEqual(
            author_display_text("Strauzek Collection [2026-09-04] [Mr_Strauz]"),
            "Strauzek Collection [2026-09-04] [Mr_Strauz]")

    def test_the_f95_author_key_matches_the_name_on_the_other_sites(self):
        self.assertEqual(
            normalized_author_name("Strauzek Collection [2026-09-04] [Mr_Strauz]",
                                   provider="f95zone"),
            normalized_author_name("mr_strauz"))

    def test_a_manual_alias_maps_both_names_to_one_canonical_key(self):
        self.store.upsert_author_alias("Initiala", "ffxivinitiala", source="manual",
                                       moment=MOMENT)
        mapping, groups = self.store.author_aliases()
        self.assertEqual(mapping["ffxivinitiala"], "initiala")
        self.assertEqual(mapping["initiala"], "initiala")
        self.assertEqual([group["canonical_name"] for group in groups], ["Initiala"])
        self.assertEqual([alias["name"] for alias in groups[0]["aliases"]],
                         ["ffxivinitiala"])

    def test_automatic_evidence_never_overwrites_a_decision(self):
        """人工确认可以有意重新分组;自动证据只填此前未知的手柄。"""
        self.store.upsert_author_alias("Initiala", "ffxivinitiala", source="manual",
                                       moment=MOMENT)
        self.assertIsNone(self.store.upsert_author_alias(
            "Someone Else", "ffxivinitiala", source="official:fanbox", moment=MOMENT))
        mapping, _groups = self.store.author_aliases()
        self.assertEqual(mapping["ffxivinitiala"], "initiala")

    def test_two_names_that_normalize_the_same_are_not_an_alias(self):
        for canonical, alias in (("Initiala", "initi-ala"), ("", "x"), ("x", "")):
            with self.assertRaises(ValueError):
                self.store.upsert_author_alias(canonical, alias, source="manual")

    def test_the_canonical_name_cannot_be_removed_as_an_alias(self):
        """删规范名会拆散整个组,剩下的别名指向一个不存在的键。"""
        self.store.upsert_author_alias("Initiala", "ffxivinitiala", source="manual",
                                       moment=MOMENT)
        with self.assertRaises(ValueError):
            self.store.remove_author_alias("Initiala")
        with self.assertRaises(ValueError):
            self.store.remove_author_alias("never-registered")
        with self.assertRaises(ValueError):
            self.store.remove_author_alias("")
        self.store.remove_author_alias("ffxivinitiala")
        self.assertEqual(self.store.author_aliases(), ({"initiala": "initiala"}, []))

    def test_an_official_handle_is_learned_only_from_one_unambiguous_author(self):
        learned = self.store.learn_official_author_alias(
            "fanbox", "ffxivinitiala",
            (_candidate("1", "a", provider="fanbox", author="Initiala"),))
        self.assertEqual(learned["alias"], "ffxivinitiala")
        self.assertEqual(learned["source"], "official:fanbox")
        # 归档站的 ref 是数字 id,不是名字。
        self.assertIsNone(self.store.learn_official_author_alias(
            "kemono", "fanbox/30917150",
            (_candidate("1", "a", provider="kemono", author="Initiala"),)))

    def test_iwara_author_profile_provides_a_trusted_handle_alias(self):
        learned = self.store.learn_official_author_alias(
            "iwara", "user_creator",
            (_candidate("1", "Work", provider="iwara", author="Test Creator"),))
        self.assertEqual(learned["alias"], "user_creator")
        self.assertEqual(learned["source"], "official:iwara")


class SourceRemovalTests(_StoreCase):
    def test_removing_a_source_takes_it_out_of_the_listing(self):
        source_id = self._source()
        other = self._source(provider="rule34xxx", ref="tag")
        self.store.remove_source(source_id)
        self.assertEqual([row["id"] for row in self.store.sources()], [other])

    def test_removing_a_source_takes_its_items_and_their_playback(self):
        source_id = self._source()
        self.store.record(source_id, _fetch([_candidate("1", "Fiona")]), moment=MOMENT)
        self.store.record_playback(self.store.items()[0].id, MOMENT)
        self.store.remove_source(source_id)
        for table in ("follow_item", "follow_playback"):
            self.assertEqual(self.connection.execute(
                f"SELECT count(*) FROM {table}").fetchone()[0], 0, table)
        self.assertEqual(self.connection.execute("PRAGMA foreign_key_check").fetchall(), [])


class PlaybackTests(_StoreCase):
    def _item(self):
        source_id = self._source()
        self.store.record(source_id, _fetch([_candidate("1", "Fiona")]),
                          moment=MOMENT)
        return self.store.items()[0].id

    def test_playing_a_new_item_marks_it_seen_once(self):
        item_id = self._item()
        self.assertEqual(self.store.record_playback(item_id, MOMENT), "seen")
        # 已经播过的不再改状态:手动标成 ignored 之后再播不该被拉回 seen。
        self.store.set_status(item_id, "ignored")
        self.assertEqual(self.store.record_playback(item_id, MOMENT), "ignored")
        count = self.connection.execute(
            "SELECT play_count FROM follow_playback WHERE follow_item_id=?",
            (item_id,)).fetchone()[0]
        self.assertEqual(count, 2)

    def test_activity_accumulates_time_and_keeps_the_furthest_point(self):
        item_id = self._item()
        first = self.store.record_playback_activity(
            item_id, position=30, duration=100, delta=30, moment=MOMENT)
        self.assertEqual(first, {"play_seconds": 30.0, "max_reached": 0.3})
        # 往回拖再看一遍:时长累加,最远位置不倒退。
        second = self.store.record_playback_activity(
            item_id, position=10, duration=100, delta=10, moment=MOMENT)
        self.assertEqual(second, {"play_seconds": 40.0, "max_reached": 0.3})
        ended = self.store.record_playback_activity(
            item_id, position=99, duration=100, delta=1, ended=True, moment=MOMENT)
        self.assertEqual(ended["max_reached"], 1.0)

    def test_an_unknown_item_is_a_request_error_not_a_storage_failure(self):
        """调用方把 ValueError 映射成 400;这里不能变成 500。"""
        for call in (lambda: self.store.record_playback(9999),
                     lambda: self.store.record_playback_activity(9999)):
            with self.assertRaises(ValueError):
                call()

    def test_nonsense_numbers_do_not_reach_the_database(self):
        item_id = self._item()
        result = self.store.record_playback_activity(
            item_id, position=-5, duration=0, delta=-3, moment=MOMENT)
        self.assertEqual(result, {"play_seconds": 0.0, "max_reached": 0.0})
class PartialCandidateTests(_StoreCase):
    """`partial=True` 的候选(跳过了第二阶段)落库时不能抹掉已有的细节。

    upsert 不整块替换 metadata、media_url、thumb_url 和 group_hint:第二阶段一旦
    按「已补齐」跳过,同一条再落一次就会把上一轮取到的细节清成空——而清空之后
    「详情本来就没有」和「这次没去问」在数据里长得一模一样。
    """

    def _both(self):
        source_id = self._source(provider="rule34paheal", ref="initiala")
        full = FollowCandidate(
            provider="rule34paheal", external_id="7428820", title="Tifa",
            url="https://rule34.paheal.net/post/view/7428820",
            media_url="https://r34i.paheal-cdn.net/df/fb/video",
            thumb_url="https://r34t.paheal.net/df/fb/poster",
            published_at="2026-08-26T15:21:00Z", duration=28.4, author="VHSephi",
            group_hint="subscribestar:2639932",
            extra={"tag": "initiala", "source": "https://subscribestar.adult/posts/2639932",
                   "tag_types": {"tifa_lockhart": "general"}})
        partial = FollowCandidate(
            provider="rule34paheal", external_id="7428820", title="Tifa Lockhart",
            url="https://rule34.paheal.net/post/view/7428820",
            media_url="https://r34i.paheal-cdn.net/df/fb/listing",
            thumb_url="https://r34t.paheal.net/df/fb/thumb",
            group_hint="rule34paheal:post:7428820", partial=True,
            extra={"tag": "initiala", "media_kind": "video",
                   "tag_types": {"amina": "general"}})
        return source_id, full, partial

    def _item(self, source_id):
        return self.store.items(source_id=source_id)[0]

    def _record(self, source_id, candidate):
        self.store.record(source_id, _fetch([candidate], provider="rule34paheal",
                                            ref="initiala"), moment=MOMENT)

    def test_a_partial_row_keeps_every_detail_only_column(self):
        source_id, full, partial = self._both()
        self._record(source_id, full)
        self._record(source_id, partial)
        row = self._item(source_id)
        self.assertEqual(row.published_at, "2026-08-26T15:21:00Z")
        self.assertEqual(row.duration, 28.4)
        self.assertEqual(row.media_url, "https://r34i.paheal-cdn.net/df/fb/video")
        self.assertEqual(row.thumb_url, "https://r34t.paheal.net/df/fb/poster")
        self.assertEqual(row.group_hint, "subscribestar:2639932")
        # 列表本来就权威的那几列照常更新。
        self.assertEqual(row.title, "Tifa Lockhart")

    def test_a_partial_row_merges_metadata_instead_of_replacing_it(self):
        source_id, full, partial = self._both()
        self._record(source_id, full)
        self._record(source_id, partial)
        metadata = self._item(source_id).metadata
        self.assertEqual(metadata["source"], "https://subscribestar.adult/posts/2639932",
                         "详情给出的出处不在这次的补丁里,就该原样留着")
        self.assertEqual(metadata["media_kind"], "video")
        self.assertEqual(metadata["tag_types"],
                         {"tifa_lockhart": "general", "amina": "general"})

    def test_a_complete_row_still_replaces_what_it_carries(self):
        """第二阶段成功时是完整视图,该覆盖就覆盖——否则改正过的细节写不进去。"""
        source_id, full, partial = self._both()
        self._record(source_id, partial)
        self._record(source_id, full)
        row = self._item(source_id)
        self.assertEqual(row.thumb_url, "https://r34t.paheal.net/df/fb/poster")
        self.assertEqual(row.group_hint, "subscribestar:2639932")
        self.assertEqual(row.metadata["tag_types"], {"tifa_lockhart": "general"})


class ImageDimsTests(_StoreCase):
    """图片宽高是给图片墙占位用的比例,三处来源(接口、文件头、界面回写)同权。"""

    def _record(self, source_id, candidate):
        self.store.record(source_id, _fetch([candidate], provider="kemono", ref="a"),
                          moment=MOMENT)
        return self.store.items(source_id=source_id)[0]

    def _image(self, **extra):
        return FollowCandidate(
            provider="kemono", external_id="1", title="Sketch",
            url="https://kemono.cr/fanbox/user/1/post/1",
            media_url="https://kemono.cr/data/ab/cd/abcd.jpg",
            thumb_url="https://kemono.cr/thumbnail/data/ab/cd/abcd.jpg", extra=extra)

    def test_item_dims_fill_only_a_gap_and_say_whether_they_wrote(self):
        source_id = self._source(provider="kemono", ref="a")
        item = self._record(source_id, self._image())
        self.assertTrue(self.store.set_image_dims(item.id, 800, 600))
        self.assertFalse(self.store.set_image_dims(item.id, 1024, 768),
                         "已有尺寸不覆盖:三处给的都是同一张图的比例,没有谁更权威")
        metadata = self.store.item(item.id).metadata
        self.assertEqual((metadata["width"], metadata["height"]), (800, 600))
        self.assertFalse(self.store.set_image_dims(item.id + 99, 800, 600))
        with self.assertRaises(FollowSourceError):
            self.store.set_image_dims(item.id, 0, 600)

    def test_media_dims_land_on_that_one_media_only(self):
        source_id = self._source(provider="kemono", ref="a")
        item = self._record(source_id, self._image(media_items=[
            {"id": "a", "media_kind": "image", "url": "https://kemono.cr/data/a.png"},
            {"id": "b", "media_kind": "image", "url": "https://kemono.cr/data/b.png"},
        ]))
        self.assertTrue(self.store.set_image_dims(item.id, 1920, 1080, media_index=1))
        self.assertFalse(self.store.set_image_dims(item.id, 1, 1, media_index=5))
        media_items = self.store.item(item.id).metadata["media_items"]
        self.assertNotIn("width", media_items[0])
        self.assertEqual((media_items[1]["width"], media_items[1]["height"]), (1920, 1080))
        self.assertNotIn("width", self.store.item(item.id).metadata,
                         "媒体级尺寸不该顺手写成条目级")

    def test_partial_item_and_media_dimensions_accept_a_complete_pair(self):
        source_id = self._source(provider="kemono", ref="a")
        item = self._record(source_id, self._image(width=800, media_items=[
            {"id": "a", "media_kind": "image", "url": "https://kemono.cr/data/a.png", "width": 800},
            {"id": "b", "media_kind": "image", "url": "https://kemono.cr/data/b.png", "height": 600},
        ]))
        self.assertTrue(self.store.set_image_dims(item.id, 800, 600))
        for index in (0, 1):
            self.assertTrue(self.store.set_image_dims(item.id, 800, 600, media_index=index))
            self.assertFalse(self.store.set_image_dims(item.id, 1, 1, media_index=index))
        metadata = self.store.item(item.id).metadata
        self.assertEqual((metadata["width"], metadata["height"]), (800, 600))
        self.assertEqual([(media["width"], media["height"]) for media in metadata["media_items"]],
                         [(800, 600), (800, 600)])

    def test_a_full_refetch_keeps_learned_dims_until_the_source_reports_its_own(self):
        source_id = self._source(provider="kemono", ref="a")
        item = self._record(source_id, self._image(tags="a"))
        self.store.set_image_dims(item.id, 800, 600)
        # 下一轮检查更新是完整候选,走整块替换 metadata 的那条 SET。
        metadata = self._record(source_id, self._image(tags="a b")).metadata
        self.assertEqual(metadata["tags"], "a b")
        self.assertEqual((metadata["width"], metadata["height"]), (800, 600))
        # 来源自己开始报尺寸时以来源为准。
        metadata = self._record(source_id, self._image(width=1600, height=1200)).metadata
        self.assertEqual((metadata["width"], metadata["height"]), (1600, 1200))

    def test_a_full_refetch_keeps_learned_media_dims_by_media_identity(self):
        """清单里每张图学到的尺寸按媒体稳定键对回:作者在前面插了一张,尺寸不串位。"""
        source_id = self._source(provider="kemono", ref="a")
        item = self._record(source_id, self._image(media_items=[
            {"id": "a", "media_kind": "image", "url": "https://kemono.cr/data/a.png"},
            {"id": "b", "media_kind": "image", "url": "https://kemono.cr/data/b.png"},
        ]))
        self.store.set_image_dims(item.id, 1920, 1080, media_index=1)
        media_items = self._record(source_id, self._image(media_items=[
            {"id": "new", "media_kind": "image", "url": "https://kemono.cr/data/new.png"},
            {"id": "a", "media_kind": "image", "url": "https://kemono.cr/data/a.png"},
            {"id": "b", "media_kind": "image", "url": "https://kemono.cr/data/b.png",
             "width": 640, "height": 480},
            {"id": "c", "media_kind": "image", "url": "https://kemono.cr/data/c.png"},
        ])).metadata["media_items"]
        self.assertNotIn("width", media_items[0])
        self.assertNotIn("width", media_items[1])
        self.assertEqual((media_items[2]["width"], media_items[2]["height"]), (640, 480),
                         "来源自己报了尺寸时以来源为准")
        self.assertNotIn("width", media_items[3])
        media_items = self._record(source_id, self._image(media_items=[
            {"id": "b", "media_kind": "image", "url": "https://kemono.cr/data/b.png"},
        ])).metadata["media_items"]
        self.assertEqual((media_items[0]["width"], media_items[0]["height"]), (640, 480))

    def test_a_refetched_media_tuple_keeps_learned_dims(self):
        """f95 的清单是附件与 gofile 两段拼成的 tuple,学到的尺寸照样对回。"""
        source_id = self._source(provider="kemono", ref="a")
        attachment = {"id": "f95-attachment-1", "media_kind": "image",
                      "url": "https://attachments.f95zone.to/2026/10/1_a.png"}
        item = self._record(source_id, self._image(media_items=[attachment]))
        self.store.set_image_dims(item.id, 2000, 1125, media_index=0)
        media_items = self._record(source_id, self._image(media_items=(attachment,))).metadata["media_items"]
        self.assertEqual((media_items[0]["width"], media_items[0]["height"]), (2000, 1125))


class EnrichedMarkTests(_StoreCase):
    """「这一行不必再打详情页」怎么从 ledger 里读出来。"""

    def test_the_paheal_mark_is_a_published_time_the_detail_page_gave(self):
        source_id = self._source(provider="rule34paheal", ref="initiala")
        self.store.record(source_id, _fetch([
            FollowCandidate(provider="rule34paheal", external_id="1", title="a",
                            published_at="2026-08-26T15:21:00Z"),
            FollowCandidate(provider="rule34paheal", external_id="2", title="b",
                            partial=True),
        ], provider="rule34paheal", ref="initiala"), moment=MOMENT)
        self.assertEqual(self.store.enriched_external_ids(source_id, "published_at"),
                         frozenset({"1"}))

    def test_the_rule34xxx_mark_is_the_taxonomy_only_the_detail_page_gives(self):
        source_id = self._source(provider="rule34xxx", ref="tag")
        self.store.record(source_id, _fetch([
            FollowCandidate(provider="rule34xxx", external_id="1", title="a",
                            published_at="2026-08-26T15:21:00Z",
                            extra={"tag_types": {"tifa": "general"}}),
            # 上传时间来自列表,详情页这次被挡回来了:这一条还没补齐。
            FollowCandidate(provider="rule34xxx", external_id="2", title="b",
                            published_at="2026-08-26T15:20:00Z", partial=True,
                            extra={"tag": "tag"}),
        ], provider="rule34xxx", ref="tag"), moment=MOMENT)
        self.assertEqual(self.store.enriched_external_ids(source_id, "tag_types_duration"),
                         frozenset({"1"}))

    def test_a_rule34xxx_video_without_duration_is_not_yet_enriched(self):
        """分类有了、mp4 的时长没读到,下次检查还要进第二阶段;图片不需要时长。"""
        source_id = self._source(provider="rule34xxx", ref="tag")
        types = {"tag_types": {"tifa": "general"}}
        self.store.record(source_id, _fetch([
            FollowCandidate(provider="rule34xxx", external_id="clip", title="a",
                            media_url="https://api-cdn-mp4.rule34.xxx/images/1/a.mp4",
                            extra=types),
            FollowCandidate(provider="rule34xxx", external_id="timed", title="b",
                            media_url="https://api-cdn-mp4.rule34.xxx/images/1/b.mp4",
                            duration=12.5, extra=types),
            FollowCandidate(provider="rule34xxx", external_id="still", title="c",
                            media_url="https://api-cdn.rule34.xxx/images/1/c.jpeg",
                            extra=types),
        ], provider="rule34xxx", ref="tag"), moment=MOMENT)
        self.assertEqual(self.store.enriched_external_ids(source_id, "tag_types_duration"),
                         frozenset({"timed", "still"}))

    def test_a_partial_candidate_fills_an_empty_duration_without_overwriting(self):
        """帖子页被挡回来时候选仍是 partial,文件头读到的时长只补空着的那一格。"""
        source_id = self._source(provider="rule34xxx", ref="tag")
        self.store.record(source_id, _fetch([
            FollowCandidate(provider="rule34xxx", external_id="empty", title="a"),
            FollowCandidate(provider="rule34xxx", external_id="known", title="b",
                            duration=30.0),
        ], provider="rule34xxx", ref="tag"), moment=MOMENT)
        self.store.record(source_id, _fetch([
            FollowCandidate(provider="rule34xxx", external_id="empty", title="a",
                            partial=True, duration=10.0),
            FollowCandidate(provider="rule34xxx", external_id="known", title="b",
                            partial=True, duration=99.0),
        ], provider="rule34xxx", ref="tag"), moment=MOMENT)
        durations = dict(self.connection.execute(
            "SELECT external_id, duration FROM follow_item WHERE source_id=?", (source_id,)))
        self.assertEqual(durations, {"empty": 10.0, "known": 30.0})

    def test_a_rule34video_row_stays_detailed_when_the_list_sees_it_again(self):
        """补齐过的 rule34video 行再被列表看到时不打详情页,详情给的值都要留住。"""
        source_id = self._source(provider="rule34video", ref="artist")
        self.store.record(source_id, _fetch([
            FollowCandidate(provider="rule34video", external_id="1", title="a",
                            media_url="https://rule34video.com/get_file/1_720p.mp4/",
                            published_at="2026-08-18T00:00:00Z",
                            extra={"media_kind": "video", "published_precision": "exact",
                                   "tag_types": {"3D": "metadata"}}),
            FollowCandidate(provider="rule34video", external_id="2", title="b",
                            media_url="https://rule34video.com/get_file/2_preview.mp4/",
                            partial=True, extra={"media_kind": "preview_clip"}),
        ], provider="rule34video", ref="artist"), moment=MOMENT)
        self.assertEqual(self.store.enriched_external_ids(source_id, "tag_types"),
                         frozenset({"1"}))
        self.store.record(source_id, _fetch([
            FollowCandidate(provider="rule34video", external_id="1", title="a",
                            media_url="https://rule34video.com/get_file/1_preview.mp4/",
                            published_at="2026-08-10T00:00:00Z", partial=True,
                            extra={"added_text": "2 months ago"}),
        ], provider="rule34video", ref="artist"), moment=MOMENT + timedelta(minutes=1))
        media_url, published_at, metadata = self.connection.execute(
            "SELECT media_url, published_at, metadata_json FROM follow_item"
            " WHERE source_id=? AND external_id='1'", (source_id,)).fetchone()
        self.assertEqual(media_url, "https://rule34video.com/get_file/1_720p.mp4/")
        self.assertEqual(published_at, "2026-08-18T00:00:00Z")
        self.assertEqual(json.loads(metadata)["media_kind"], "video")
        self.assertEqual(self.store.enriched_external_ids(source_id, "tag_types"),
                         frozenset({"1"}))

    def test_every_kemono_row_in_the_ledger_counts_as_judged(self):
        source_id = self._source(provider="kemono", ref="fanbox/1")
        self.store.record(source_id, _fetch([
            FollowCandidate(provider="kemono", external_id="10", title="a"),
            FollowCandidate(provider="kemono", external_id="11", title="b", partial=True),
        ], provider="kemono", ref="fanbox/1"), moment=MOMENT)
        self.assertEqual(self.store.enriched_external_ids(source_id, "kept"),
                         frozenset({"10", "11"}))

    def test_an_unregistered_mark_is_refused_not_silently_matched(self):
        source_id = self._source()
        with self.assertRaises(ValueError):
            self.store.enriched_external_ids(source_id, "whatever")


class StoredCompilationTests(_StoreCase):
    """判据收紧之前入库的跨作者打包,按同一判据清退。"""

    def _record(self, external_id, title, models):
        source_id = self._source()
        self.store.record(source_id, _fetch([
            _candidate(external_id, title, extra={"models": list(models),
                                                  "model_count": len(models)}),
        ]), moment=MOMENT)
        return self.store.items()[0]

    def test_voice_and_audio_credits_keep_a_single_creator_work_out_of_the_list(self):
        self._record("1", "Yunara Showing Ahri Some Discipline",
                     ["Iidssm", "Adaline (VA)", "GeminiStarsign1 (VA)",
                      "Huntress___ (Audio/SFX)", "HentAudio (Audio)"])
        self.assertEqual(self.store.collected_compilations(), ())

    def test_a_cross_artist_pack_is_listed_with_both_counts(self):
        self._record("2", "Fuck Track / Futa PMV", [f"M{n}" for n in range(13)])
        listed = self.store.collected_compilations()
        self.assertEqual(len(listed), 1)
        self.assertEqual((listed[0].credited, listed[0].visual), (13, 13))
        self.assertEqual(listed[0].external_id, "2")

    def test_an_item_already_saved_as_an_asset_is_left_alone(self):
        item = self._record("3", "ON AND ON | HMV / PMV", [f"M{n}" for n in range(17)])
        self.connection.execute("UPDATE follow_item SET url=? WHERE id=?",
                                ("https://rule34video.com/video/3/x/", item.id))
        self.store.save_asset(item.id, confirm=True, moment=MOMENT)
        self.assertEqual(self.store.collected_compilations(), ())

    def test_purging_requires_explicit_confirmation(self):
        self._record("4", "Resident Evil - The Fallen Saga",
                     [f"M{n}" for n in range(14)])
        listed = self.store.collected_compilations()
        with self.assertRaises(FollowSourceError):
            self.store.purge_compilations(listed)
        self.assertEqual(self.connection.execute(
            "SELECT count(*) FROM follow_item").fetchone()[0], 1)

    def test_a_confirmed_purge_takes_the_playback_rows_with_it(self):
        item = self._record("5", "triss blacked censored", [f"M{n}" for n in range(13)])
        self.store.record_playback(item.id)
        listed = self.store.collected_compilations()
        self.assertEqual(self.store.purge_compilations(listed, confirm=True), 1)
        self.assertEqual(self.connection.execute(
            "SELECT count(*) FROM follow_item").fetchone()[0], 0)
        self.assertEqual(self.connection.execute(
            "SELECT count(*) FROM follow_playback").fetchone()[0], 0)


def _old_snapshot(directory: Path, index: int, size: int = 10) -> Path:
    """升级前留下的一份快照:`.raw` 加同名 `.json`,名字按序号排出先后。"""
    directory.mkdir(parents=True, exist_ok=True)
    raw = directory / f"20260801T{index:06d}.000000Z-{index:064x}.raw"
    raw.write_bytes(b"x" * size)
    raw.with_suffix(".json").write_bytes(b"{}")
    return raw


class EvidenceRetentionTests(_StoreCase):
    """原始快照:相同的不重写,每个来源只留最近 3 份和被条目引用的,存量等用户确认才删。"""

    def _evidence(self):
        return [row[0] for row in self.connection.execute("SELECT evidence_path FROM follow_item")]

    def _raws(self, provider="rule34video"):
        return sorted((self.root / "sources" / "follow" / provider).rglob("*.raw"))

    def test_an_unchanged_page_reuses_the_latest_snapshot(self):
        source_id = self._source()
        fetch = _fetch([_candidate("1", "A")])
        first = self.store.record(source_id, fetch, moment=MOMENT)
        again = self.store.record(source_id, fetch, moment=MOMENT + timedelta(hours=1))
        self.assertEqual(again.evidence_path, first.evidence_path)
        self.assertEqual(len(self._raws()), 1)
        changed = self.store.record(source_id, replace(fetch, raw_body=b"<html>new</html>"),
                                    moment=MOMENT + timedelta(hours=2))
        self.assertNotEqual(changed.evidence_path, first.evidence_path)
        repeat = self.store.record(source_id, fetch, moment=MOMENT + timedelta(hours=3))
        self.assertNotEqual(repeat.evidence_path, first.evidence_path,
                            "只和最新一份比:页面改回旧样子也记一份新的")
        self.assertEqual(len(self._raws()), 3)
        self.assertEqual(self._evidence(), [first.evidence_path])

    def test_each_source_keeps_three_recent_snapshots_and_the_referenced_ones(self):
        other = self.root / "sources" / "follow" / "rule34video" / "another-source"
        untouched = [_old_snapshot(other, index) for index in range(5)]
        source_id = self._source()
        paths = []
        for index in range(5):
            outcome = self.store.record(
                source_id, _fetch([_candidate("1", "A")], raw=f"<html>{index}</html>".encode()),
                moment=MOMENT + timedelta(hours=index))
            paths.append(self.root / "sources" / Path(outcome.evidence_path))
        self.assertEqual(self._evidence(), [str(paths[0].relative_to(self.root / "sources"))])
        self.assertEqual([path.exists() for path in paths], [True, False, True, True, True])
        self.assertFalse(paths[1].with_suffix(".json").exists())
        self.assertTrue(all(path.exists() for path in untouched), "只清这次写入的那个来源目录")

    def test_a_new_snapshot_only_offsets_its_own_growth_and_leaves_the_stock(self):
        """升级后第一次检查不能把存量一口气删光:那在写事务里,也绕过了「修复与迁移」的确认。"""
        source_id = self._source()
        fetch = _fetch([_candidate("1", "A")])
        directory = (self.root / "sources" / Path(self.store.record(source_id, fetch, moment=MOMENT)
                                                  .evidence_path)).parent
        stock = [_old_snapshot(directory, index) for index in range(20)]
        self.store.record(source_id, replace(fetch, raw_body=b"<html>new</html>"),
                          moment=MOMENT + timedelta(hours=1))
        self.assertEqual(sum(not raw.exists() for raw in stock), 1)
        self.assertEqual(len(list(directory.glob("*.raw"))), 22 - 1)
        self.store.record(source_id, replace(fetch, raw_body=b"<html>new</html>"),
                          moment=MOMENT + timedelta(hours=2))
        self.assertEqual(sum(not raw.exists() for raw in stock), 1, "去重命中时没写新的,也不删")

    def test_stock_snapshots_wait_for_the_user_and_keep_what_items_reference(self):
        from peach import repair_center
        from peach.follow_evidence import Reclaim
        from peach.repository import LedgerDatabase
        source_id = self._source()
        directory = self.root / "sources" / "follow" / "rule34video" / "legacy"
        raws = [_old_snapshot(directory, index, size=100) for index in range(6)]
        # 迁移数据根前留下的绝对路径、反斜杠也认得出是同一个文件。
        self.connection.execute(
            "INSERT INTO follow_item(source_id,external_id,title,release_key,evidence_path,first_seen_at,"
            "last_seen_at) VALUES(?,?,?,'old',?,?,?)", (source_id, "old", "Old", "D:\\peach-data\\sources\\follow\\rule34video"
                                     f"\\legacy\\{raws[1].name}", "2026-08-01T00:00:00Z", "2026-08-01T00:00:00Z"))
        self.connection.commit()
        from peach.task_runs import TaskRunStore
        database = LedgerDatabase(self.root / "ledger.db")
        runs = TaskRunStore(database)
        reclaim = Reclaim(self.root / "sources" / "follow", database, runs=runs)

        def settle():
            while reclaim.worker is not None and reclaim.worker.is_alive():
                reclaim.worker.join(10)

        def item():
            with database.read_connection() as connection:
                center = repair_center.snapshot(connection, {}, cloud_pending=0,
                                                follow_evidence_status=reclaim.status())
            return next((row for row in center["items"] if row["id"] == "follow-evidence"), None), center

        self.assertTrue(item()[0]["estimating"])
        settle()
        waiting, _ = item()
        # 留 3、4、5 三份最近的和被引用的 1,删 0、2 两份,每份 100 + 2 字节。
        self.assertEqual((waiting["mode"], waiting["state"], waiting["files"], waiting["bytes"]),
                         ("confirm", "waiting", 4, 204))
        self.assertTrue(all(raw.exists() for raw in raws), "估算不删文件")
        reclaim.start()
        settle()
        self.assertEqual([raw.exists() for raw in raws], [False, True, False, True, True, True])
        done, center = item()
        self.assertEqual((done["state"], done["changed"]), ("done", 4))
        self.assertEqual([(row["title"], row["changed"]) for row in center["history"]],
                         [("清理关注抓取的旧快照", 4)])
        # 删除这一轮在活动页登记:计数、结算与摘要都在任务中心。
        run = runs.query(task_key="follow-evidence-reclaim")[0]
        self.assertEqual((run.status, run.progress_current, run.progress_total, run.result_summary),
                         ("succeeded", 4, 4, {"files_removed": 4, "bytes_reclaimed": 204}))

    def test_stock_cleanup_pauses_and_cancels_from_the_task_center(self):
        """活动页的暂停停在两份快照之间,继续前不再删;取消结束这一轮,删了多少照实记,剩下的重新等确认。"""
        import threading
        import time
        from unittest.mock import patch
        from peach import follow_evidence, task_control
        from peach.follow_evidence import Reclaim
        from peach.repository import LedgerDatabase
        from peach.task_runs import TaskRunStore
        root = self.root / "sources" / "follow" / "rule34video"
        first = [_old_snapshot(root / "a", index) for index in range(5)]
        second = [_old_snapshot(root / "b", index) for index in range(5)]
        database = LedgerDatabase(self.root / "ledger.db")
        runs = TaskRunStore(database)
        reclaim = Reclaim(self.root / "sources" / "follow", database, runs=runs)
        reclaim.status()
        reclaim.worker.join(10)
        # 删完第一个目录后停在闸门前,等测试发出暂停再放行。
        reached, release = threading.Event(), threading.Event()
        listing = follow_evidence.directories

        def gated(path):
            for index, directory in enumerate(listing(path)):
                if index == 1:
                    reached.set()
                    release.wait(10)
                yield directory

        def wait_for(predicate):
            deadline = time.monotonic() + 10
            while not predicate():
                self.assertLess(time.monotonic(), deadline)
                time.sleep(0.02)

        with patch.object(follow_evidence, "directories", gated):
            reclaim.start()
            self.assertTrue(reached.wait(10))
            run = runs.query(task_key="follow-evidence-reclaim")[0]
            self.assertEqual(task_control.projection(runs, run)["actions"], ["pause", "cancel", "set_speed"])
            task_control.request(runs, run.id, "pause")
            release.set()
            wait_for(lambda: task_control.projection(runs, runs.get(run.id))["state"] == "paused")
            self.assertEqual([raw.exists() for raw in first], [False, False, True, True, True])
            self.assertTrue(all(raw.exists() for raw in second), "暂停后不再删")
            task_control.request(runs, run.id, "cancel")
            reclaim.worker.join(10)
        run = runs.get(run.id)
        self.assertEqual((run.status, run.result_summary["files_removed"]), ("cancelled", 4))
        self.assertTrue(all(raw.exists() for raw in second))
        wait_for(lambda: reclaim.status()["phase"] == "ready")
        self.assertEqual(reclaim.status()["files"], 4, "剩下的重新等确认")

    def test_a_library_without_follow_snapshots_does_not_list_the_item(self):
        from peach import repair_center
        from peach.follow_evidence import Reclaim
        reclaim = Reclaim(self.root / "sources" / "follow", None)
        self.assertEqual(reclaim.status(), {"phase": "idle"})
        center = repair_center.snapshot(self.connection, {}, cloud_pending=0,
                                        follow_evidence_status=reclaim.status())
        self.assertNotIn("follow-evidence", [row["id"] for row in center["items"]])


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