"""FC-6.3 — matcher writes pending suggestions; accept/dismiss lifecycle.""" import pytest from sqlalchemy import func, select from backend.app.models import Artist, ImageRecord, Post, TagKind from backend.app.models.series_suggestion import SeriesSuggestion from backend.app.services.series_match_service import SeriesMatchService from backend.app.services.series_service import SeriesService pytestmark = pytest.mark.integration _SEQ = 0 async def _artist(db, name): a = Artist(name=name, slug=name.lower().replace(" ", "-")) db.add(a) await db.flush() return a async def _post_with_images(db, artist, title, ext, n): global _SEQ p = Post(artist_id=artist.id, external_post_id=ext, post_title=title) db.add(p) await db.flush() for _ in range(n): _SEQ += 1 db.add(ImageRecord( path=f"/tmp/fc_sm_{_SEQ}.png", sha256=f"{_SEQ:064d}", size_bytes=1, mime="image/png", origin="downloaded", primary_post_id=p.id, artist_id=artist.id, )) await db.flush() return p async def _series_from(db, post): out = await SeriesService(db).promote_post_to_series(post.id) return out["series_tag_id"] @pytest.mark.asyncio async def test_match_post_suggests_continuation(db): artist = await _artist(db, "Story Artist") a = await _post_with_images(db, artist, "Story pages 1-4", "a", 4) sid = await _series_from(db, a) # series stated 1-4 b = await _post_with_images(db, artist, "Story pages 5-8", "b", 4) svc = SeriesMatchService(db) made = await svc.match_post(b.id, threshold=0.5) assert made == 1 pending = await svc.list_pending() assert len(pending) == 1 sug = pending[0] assert sug["series_tag_id"] == sid assert sug["post_id"] == b.id # title 1.0 + artist 1.0 + pages 1.0 (5 == 4+1) → 0.4+0.2+0.25 = 0.85 assert sug["score"] >= 0.85 assert sug["signals"]["pages"] == 1.0 @pytest.mark.asyncio async def test_match_is_idempotent_under_unique(db): artist = await _artist(db, "Idem Artist") a = await _post_with_images(db, artist, "Tale pages 1-4", "a", 2) await _series_from(db, a) b = await _post_with_images(db, artist, "Tale pages 5-8", "b", 2) svc = SeriesMatchService(db) await svc.match_post(b.id, threshold=0.5) await svc.match_post(b.id, threshold=0.5) # again cnt = await db.scalar( select(func.count()).select_from(SeriesSuggestion) .where(SeriesSuggestion.post_id == b.id) ) assert cnt == 1 @pytest.mark.asyncio async def test_accept_appends_pages_and_marks_added(db): artist = await _artist(db, "Accept Artist") a = await _post_with_images(db, artist, "Saga pages 1-4", "a", 3) sid = await _series_from(db, a) b = await _post_with_images(db, artist, "Saga pages 5-8", "b", 3) svc = SeriesMatchService(db) await svc.match_post(b.id, threshold=0.5) sug = (await svc.list_pending())[0] await svc.accept(sug["id"]) data = await SeriesService(db).list_pages(sid) # b's pages are STAGED pending (3 from a placed + 3 from b pending), # awaiting the operator's sort. assert len(data["pages"]) == 3 assert sum(len(g["pages"]) for g in data["pending"]) == 3 status = await db.scalar( select(SeriesSuggestion.status).where(SeriesSuggestion.id == sug["id"]) ) assert status == "added" @pytest.mark.asyncio async def test_dismiss_then_rematch_skips(db): artist = await _artist(db, "Dismiss Artist") a = await _post_with_images(db, artist, "Epic pages 1-4", "a", 2) await _series_from(db, a) b = await _post_with_images(db, artist, "Epic pages 5-8", "b", 2) svc = SeriesMatchService(db) await svc.match_post(b.id, threshold=0.5) sug = (await svc.list_pending())[0] await svc.dismiss(sug["id"]) assert await svc.list_pending() == [] # A re-match must respect the dismissal (decided → skipped). made = await svc.match_post(b.id, threshold=0.5) assert made == 0 @pytest.mark.asyncio async def test_rescan_scans_posts(db): artist = await _artist(db, "Rescan Artist") a = await _post_with_images(db, artist, "Quest pages 1-4", "a", 2) await _series_from(db, a) await _post_with_images(db, artist, "Quest pages 5-8", "b", 2) svc = SeriesMatchService(db) summary = await svc.rescan(threshold=0.5) assert summary["scanned"] >= 2 assert summary["suggested"] >= 1 assert summary["partial"] is False