59746d213d
Reframe a series from "ordered chapters that own pages" to ONE flat, series-global ordered run of pages with optional cosmetic chapter DIVIDERS over it. A chapter no longer wraps content — it's a labeled divider anchored to the page that begins it; a page's chapter is derived as the nearest preceding divider. This is what lets installments assembled from multiple sources sit in one continuous, correctly-numbered sequence (operator's Goblin Juice case). - migration 0047: flatten each series to a series-global page_number (preserving today's reading order); convert each existing chapter to a divider anchored at its first page (keeping title/stated_part); drop series_page.chapter_id; reshape series_chapter (anchor_page_id UNIQUE FK, drop chapter_number/is_placeholder/stated_page_start/end). Loss-safe for content; drops empty placeholder chapters + a redundant page-1 divider. - series_page: page_number is now the series-global order; no chapter_id. - series_chapter: anchored divider (anchor_page_id, title, stated_part). - series_service: flat list_pages (one run + derived dividers + per-page source_post + part_gaps), series-wide reorder/renumber, divider CRUD (create/update/move/delete); retired per-chapter reorder/merge/placement. - api/tags: drop chapter_id from add; /chapters endpoints are divider create/update/delete (removed chapter reorder/merge/page-reorder). - series_match_service: series "end" reads max(series_page.stated_page); accept appends via add_post. tag_service series-merge appends src's pages after tgt's max so the merged series stays one clean run. - frontend: seriesManage store + SeriesManageView → one continuous drag-reorder grid with inline divider bars + series-global page numbers; reader walks the flat run, headings from dividers; PostSeriesMenu copy. - tests reworked across the series suite for the divider model. Phase 2 (pending staging for add-from-post) is separate. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
140 lines
4.6 KiB
Python
140 lines
4.6 KiB
Python
"""FC-6.x — promote a post to a series + add a post's pages + browse list."""
|
|
|
|
import pytest
|
|
from sqlalchemy import select
|
|
|
|
from backend.app.models import Artist, ImageRecord, Post, TagKind
|
|
from backend.app.models.series_page import SeriesPage
|
|
from backend.app.services.series_service import SeriesError, 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"])
|
|
# one flat run in capture order, no dividers, stated pages parsed 1..3
|
|
assert [p["image_id"] for p in data["pages"]] == imgs
|
|
assert [p["stated_page"] for p in data["pages"]] == [1, 2, 3]
|
|
assert data["dividers"] == []
|
|
|
|
|
|
@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")
|
|
with pytest.raises(SeriesError):
|
|
await svc.promote_post_to_series(post.id)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_add_post_appends_pages_to_the_run(db):
|
|
svc = SeriesService(db)
|
|
artist = await _artist(db, "Story Artist")
|
|
sid = (await TagService(db).find_or_create("Story", TagKind.series)).id
|
|
# Seed the series with two existing pages.
|
|
seed_post = await _post(db, artist, "seed", "pp3a")
|
|
seed = await _post_images(db, seed_post, artist, 2)
|
|
await svc.add_images(sid, seed)
|
|
# A later post's pages append to the END (operator orders afterward).
|
|
post = await _post(db, artist, "Story pages 9-11", "pp3b")
|
|
imgs = await _post_images(db, post, artist, 3)
|
|
out = await svc.add_post(sid, post.id)
|
|
assert out["added"] == 3
|
|
|
|
rows = (
|
|
await db.execute(
|
|
select(SeriesPage.image_id, SeriesPage.stated_page)
|
|
.where(SeriesPage.series_tag_id == sid)
|
|
.order_by(SeriesPage.page_number)
|
|
)
|
|
).all()
|
|
assert [r.image_id for r in rows] == seed + imgs
|
|
stated = {r.image_id: r.stated_page for r in rows}
|
|
assert [stated[i] for i in imgs] == [9, 10, 11]
|
|
|
|
|
|
@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"] == 0 # a flat promote has no dividers
|
|
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)
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_source_post_label_is_per_page(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["pages"][0]["source_post"]
|
|
assert sp is not None
|
|
assert sp["id"] == post.id
|
|
assert sp["title"] == "Source Comic pages 1-2"
|