013b9d7f06
Replaces the auto-renumbered 1..N position key with operator-OWNED page numbers: sparse, gaps allowed, editable, never auto-renumbered. Order follows the numbers; unnumbered pages sort to the tail. This is the fix for the model that clobbered hand-set numbers on the flatten — numbers are now data, not a derived sequence. - series_service: drop the renumber-on-reorder/remove; order by page_number NULLS LAST; new set_page_number(image_id, n|None); list_pages returns `gaps` (one entry per missing-number run) + each pending group's parsed `start_page`; set_cover renumbers below the current min; place_pending(image_ids, start_page) numbers placed pages sequentially from the start (drop junk first → numbers line up); add_post stamps the parsed start on staged pages. - api/tags: POST /series/<id>/pages/number (set one page's number); /pending/ place takes start_page; removed /reorder. - frontend: per-card editable number input; one gap block per gap with drop-on-edge to assign the adjacent number (middle → type); append drop zone; pending tray gets a "from page N" field + "Place from page N". - tests reworked: sparse numbers + gaps, place-from-start, set-page-number route. No migration; nothing destructive. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
132 lines
4.2 KiB
Python
132 lines
4.2 KiB
Python
"""FC-6.x Phase 2 — pending staging for add-from-post."""
|
|
|
|
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 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_pend_{_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
|
|
|
|
|
|
async def _placed_order(db, sid):
|
|
rows = (
|
|
await db.execute(
|
|
select(SeriesPage.image_id)
|
|
.where(
|
|
SeriesPage.series_tag_id == sid,
|
|
SeriesPage.status == "placed",
|
|
)
|
|
.order_by(SeriesPage.page_number)
|
|
)
|
|
).scalars().all()
|
|
return list(rows)
|
|
|
|
|
|
async def _setup(db, name, seed_n=2):
|
|
svc = SeriesService(db)
|
|
artist = await _artist(db, name + " Artist")
|
|
sid = (await TagService(db).find_or_create(name, TagKind.series)).id
|
|
seed = []
|
|
if seed_n:
|
|
seed_post = await _post(db, artist, name + " seed", name + "-s")
|
|
seed = await _post_images(db, seed_post, artist, seed_n)
|
|
await svc.add_images(sid, seed)
|
|
return svc, artist, sid, seed
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_place_pending_appends_and_flips_status(db):
|
|
svc, artist, sid, seed = await _setup(db, "PA")
|
|
post = await _post(db, artist, "PA pages", "pa-p")
|
|
imgs = await _post_images(db, post, artist, 3)
|
|
await svc.add_post(sid, post.id)
|
|
|
|
placed = await svc.place_pending(sid, imgs, start_page=3)
|
|
assert placed == 3
|
|
assert await _placed_order(db, sid) == seed + imgs
|
|
# nothing left pending; the placed pages are numbered 1,2 (seed) + 3,4,5
|
|
data = await svc.list_pages(sid)
|
|
assert data["pending"] == []
|
|
assert [p["page_number"] for p in data["pages"]] == [1, 2, 3, 4, 5]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_place_pending_numbers_from_start(db):
|
|
svc, artist, sid, seed = await _setup(db, "PB") # seed numbered 1, 2
|
|
post = await _post(db, artist, "PB pages", "pb-p")
|
|
x, y = await _post_images(db, post, artist, 2)
|
|
await svc.add_post(sid, post.id)
|
|
|
|
await svc.place_pending(sid, [x, y], start_page=10)
|
|
data = await svc.list_pages(sid)
|
|
nums = {p["image_id"]: p["page_number"] for p in data["pages"]}
|
|
assert nums[x] == 10
|
|
assert nums[y] == 11
|
|
# order = seed (1, 2) then x (10), y (11)
|
|
assert [p["image_id"] for p in data["pages"]] == seed + [x, y]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_place_pending_ignores_already_placed(db):
|
|
svc, artist, sid, seed = await _setup(db, "PC", seed_n=1)
|
|
# the seed page is already placed — place_pending is a no-op for it.
|
|
assert await svc.place_pending(sid, [seed[0]]) == 0
|
|
assert await _placed_order(db, sid) == seed
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_remove_drops_a_pending_junk_page(db):
|
|
svc, artist, sid, _ = await _setup(db, "PD", seed_n=0)
|
|
post = await _post(db, artist, "PD pages", "pd-p")
|
|
x, y, z = await _post_images(db, post, artist, 3)
|
|
await svc.add_post(sid, post.id)
|
|
# y is junk (a text-free alt) — drop it before placing.
|
|
await svc.remove_images(sid, [y])
|
|
data = await svc.list_pages(sid)
|
|
assert [pg["image_id"] for pg in data["pending"][0]["pages"]] == [x, z]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_place_pending_route_requires_ids(client):
|
|
r = await client.post(
|
|
"/api/tags", json={"name": "PReq", "kind": "series"}
|
|
)
|
|
sid = (await r.get_json())["id"]
|
|
resp = await client.post(f"/api/series/{sid}/pending/place", json={})
|
|
assert resp.status_code == 400
|