Files
FabledCurator/tests/test_series_from_post.py
T
bvandeusen 978959bdc4 feat(series): manage-view redesign — big pages, editable Part #, slide-over picker (FC-6.4)
Operator feedback: thumbnails too small to judge order, no obvious way to mark
'this installment is Part 2', and the permanent two-pane picker was busy and
competed with the ordering work.

- Full-width parts, each a card with a big page grid (150px, contain so whole
  pages are visible) and drag-to-reorder; positional page number as a badge.
- Editable Part # (hero field) backed by new series_chapter.stated_part —
  separate from the auto-managed chapter_number, mirroring the page_number vs
  stated_page split so reorder/delete renumbering can't wipe a hand-set part.
  Missing-Part hints when consecutive parts' stated_part jump >1.
- Each part labels its source post (derived from pages' primary_post_id) and
  shows the printed-page range with clear labels.
- Picker demoted to an on-demand right slide-over ('Add pages') with a target-
  part selector; part actions (move/merge/delete) collapsed into an overflow ⋮.

alembic 0042 adds series_chapter.stated_part (nullable int).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 20:29:10 -04:00

165 lines
5.8 KiB
Python

"""FC-6.2 — promote a post to a series + add a post as a chapter + browse list."""
import pytest
from backend.app.models import Artist, ImageRecord, Post, TagKind
from backend.app.services.series_service import SeriesService
from backend.app.services.tag_service import TagService
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(db, artist, title, ext):
p = Post(artist_id=artist.id, external_post_id=ext, post_title=title)
db.add(p)
await db.flush()
return p
async def _post_images(db, post, artist, n):
global _SEQ
ids = []
for _ in range(n):
_SEQ += 1
rec = ImageRecord(
path=f"/tmp/fc_ps_{_SEQ}.png", sha256=f"{_SEQ:064d}",
size_bytes=1, mime="image/png", origin="downloaded",
primary_post_id=post.id, artist_id=artist.id,
)
db.add(rec)
await db.flush()
ids.append(rec.id)
return ids
@pytest.mark.asyncio
async def test_promote_post_to_series(db):
svc = SeriesService(db)
artist = await _artist(db, "Bikupan")
post = await _post(db, artist, "My Comic pages 1-3", "pp1")
imgs = await _post_images(db, post, artist, 3)
out = await svc.promote_post_to_series(post.id)
assert out["added"] == 3
assert out["name"] == "My Comic pages 1-3"
data = await svc.list_pages(out["series_tag_id"])
assert len(data["chapters"]) == 1
ch = data["chapters"][0]
assert ch["stated_page_start"] == 1
assert ch["stated_page_end"] == 3
assert [p["image_id"] for p in ch["pages"]] == imgs # capture order
assert [p["stated_page"] for p in ch["pages"]] == [1, 2, 3]
@pytest.mark.asyncio
async def test_promote_requires_images(db):
svc = SeriesService(db)
artist = await _artist(db, "Empty Artist")
post = await _post(db, artist, "No images", "pp2")
from backend.app.services.series_service import SeriesError
with pytest.raises(SeriesError):
await svc.promote_post_to_series(post.id)
@pytest.mark.asyncio
async def test_add_post_as_chapter_slots_by_stated_page(db):
svc = SeriesService(db)
artist = await _artist(db, "Story Artist")
sid = (await TagService(db).find_or_create("Story", TagKind.series)).id
# An existing chapter stating pages 9-12.
await svc.create_chapter(sid, stated_page_start=9, stated_page_end=12)
# A post stating pages 1-4 should slot BEFORE the 9-12 chapter.
post = await _post(db, artist, "Story pages 1-4", "pp3")
await _post_images(db, post, artist, 4)
out = await svc.add_post_as_chapter(sid, post.id)
assert out["added"] == 4
data = await svc.list_pages(sid)
chapters = data["chapters"]
assert chapters[0]["stated_page_start"] == 1 # new one is first
assert chapters[1]["stated_page_start"] == 9
# gap 5-8 flagged between them
assert data["gaps"][0]["start"] == 5
assert data["gaps"][0]["end"] == 8
@pytest.mark.asyncio
async def test_list_series_cards(db):
svc = SeriesService(db)
artist = await _artist(db, "Card Artist")
post = await _post(db, artist, "Card Comic pages 1-2", "pp4")
await _post_images(db, post, artist, 2)
out = await svc.promote_post_to_series(post.id)
rows = await svc.list_series()
card = next(r for r in rows if r["id"] == out["series_tag_id"])
assert card["name"] == "Card Comic pages 1-2"
assert card["chapter_count"] == 1
assert card["page_count"] == 2
assert card["artist_name"] == "Card Artist"
assert card["cover_thumbnail_url"]
assert card["has_gap"] is False
# artist filter keeps it; a different artist id drops it.
assert any(r["id"] == out["series_tag_id"] for r in await svc.list_series(artist_id=artist.id))
assert all(r["id"] != out["series_tag_id"] for r in await svc.list_series(artist_id=artist.id + 99999))
# --- FC-6.4: stated_part, part_gaps, source_post label ---------------------
@pytest.mark.asyncio
async def test_update_chapter_sets_and_clears_stated_part(db):
svc = SeriesService(db)
sid = (await TagService(db).find_or_create("Part Series", TagKind.series)).id
ch = await svc.create_chapter(sid)
# Set the installment to Part 2 (chapter_number stays its positional value).
await svc.update_chapter(sid, ch["id"], stated_part=2, set_part=True)
data = await svc.list_pages(sid)
assert data["chapters"][0]["stated_part"] == 2
assert data["chapters"][0]["chapter_number"] == 1
# Clearing it writes NULL back.
await svc.update_chapter(sid, ch["id"], stated_part=None, set_part=True)
data = await svc.list_pages(sid)
assert data["chapters"][0]["stated_part"] is None
@pytest.mark.asyncio
async def test_part_gaps_flagged_from_stated_part(db):
svc = SeriesService(db)
sid = (await TagService(db).find_or_create("Gappy Series", TagKind.series)).id
c1 = await svc.create_chapter(sid)
c3 = await svc.create_chapter(sid)
await svc.update_chapter(sid, c1["id"], stated_part=1, set_part=True)
await svc.update_chapter(sid, c3["id"], stated_part=3, set_part=True)
data = await svc.list_pages(sid)
assert len(data["part_gaps"]) == 1
gap = data["part_gaps"][0]
assert gap["after_chapter_id"] == c1["id"]
assert gap["start"] == 2
assert gap["end"] == 2
@pytest.mark.asyncio
async def test_source_post_label_when_pages_share_one_post(db):
svc = SeriesService(db)
artist = await _artist(db, "Src Artist")
post = await _post(db, artist, "Source Comic pages 1-2", "pp5")
await _post_images(db, post, artist, 2)
out = await svc.promote_post_to_series(post.id)
data = await svc.list_pages(out["series_tag_id"])
sp = data["chapters"][0]["source_post"]
assert sp is not None
assert sp["id"] == post.id
assert sp["title"] == "Source Comic pages 1-2"