db490e92df
The post-aware on-ramp + the data behind the missing Series browse view. - page_number_parser: conservative stated-page parser (pages 9-12 / page 5 / [3/8] / 3 of 8), keyword-gated to avoid false positives. Pure + unit-tested. - SeriesService.promote_post_to_series: a self-contained post becomes its own series — series tag named after the post, one chapter, the post's images as pages (ordered by capture order; stated pages parsed from title/description). - SeriesService.add_post_as_chapter: append a post as the next chapter of an existing series, titled after the post and slotted by parsed page number (a "pages 1-4" post lands ahead of the "pages 9-12" chapter). - SeriesService.list_series: browse cards — cover thumb, artist, chapter/page counts, gap flag, last-updated; sort recent|name|size + filter by artist. - API: GET /api/series, POST /api/series/from-post, POST /api/series/<id>/add-post. - Resolver uses ImageRecord.primary_post_id (same linkage the posts feed renders). Frontend (Add-to-series control + Series view + nav) lands next. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
116 lines
3.9 KiB
Python
116 lines
3.9 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))
|