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>
136 lines
4.4 KiB
Python
136 lines
4.4 KiB
Python
import pytest
|
|
from sqlalchemy import func, select
|
|
|
|
from backend.app.models import ImageRecord, 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 _img(db):
|
|
global _SEQ
|
|
_SEQ += 1
|
|
rec = ImageRecord(
|
|
path=f"/tmp/fc_ss_{_SEQ}.png", sha256=f"{_SEQ:064d}",
|
|
size_bytes=1, mime="image/png", origin="uploaded",
|
|
)
|
|
db.add(rec)
|
|
await db.flush()
|
|
return rec.id
|
|
|
|
|
|
async def _order(db, sid):
|
|
rows = (
|
|
await db.execute(
|
|
select(SeriesPage.image_id, SeriesPage.page_number)
|
|
.where(SeriesPage.series_tag_id == sid)
|
|
.order_by(SeriesPage.page_number)
|
|
)
|
|
).all()
|
|
return [r.image_id for r in rows]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_add_appends_in_order(db):
|
|
s = await TagService(db).find_or_create("S", TagKind.series)
|
|
i1, i2 = await _img(db), await _img(db)
|
|
n = await SeriesService(db).add_images(s.id, [i1, i2])
|
|
assert n == 2
|
|
assert await _order(db, s.id) == [i1, i2]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_add_moves_from_other_series(db):
|
|
svc = SeriesService(db)
|
|
a = await TagService(db).find_or_create("A", TagKind.series)
|
|
b = await TagService(db).find_or_create("B", TagKind.series)
|
|
i1 = await _img(db)
|
|
await svc.add_images(a.id, [i1])
|
|
moved = await svc.add_images(b.id, [i1])
|
|
assert moved == 1
|
|
assert await _order(db, a.id) == []
|
|
assert await _order(db, b.id) == [i1]
|
|
cnt = await db.scalar(
|
|
select(func.count())
|
|
.select_from(SeriesPage).where(SeriesPage.image_id == i1)
|
|
)
|
|
assert cnt == 1
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_add_idempotent_for_same_series(db):
|
|
s = await TagService(db).find_or_create("S2", TagKind.series)
|
|
i1 = await _img(db)
|
|
svc = SeriesService(db)
|
|
await svc.add_images(s.id, [i1])
|
|
again = await svc.add_images(s.id, [i1])
|
|
assert again == 0
|
|
assert await _order(db, s.id) == [i1]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_remove(db):
|
|
s = await TagService(db).find_or_create("S3", TagKind.series)
|
|
i1, i2 = await _img(db), await _img(db)
|
|
svc = SeriesService(db)
|
|
await svc.add_images(s.id, [i1, i2])
|
|
removed = await svc.remove_images(s.id, [i1])
|
|
assert removed == 1
|
|
assert await _order(db, s.id) == [i2]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_set_page_number_sparse_with_gaps(db):
|
|
s = await TagService(db).find_or_create("S4", TagKind.series)
|
|
i1, i2, i3 = await _img(db), await _img(db), await _img(db)
|
|
svc = SeriesService(db)
|
|
await svc.add_images(s.id, [i1, i2, i3]) # numbered 1, 2, 3
|
|
# set a sparse number, leaving a gap
|
|
await svc.set_page_number(s.id, i3, 5)
|
|
out = await svc.list_pages(s.id)
|
|
assert [p["page_number"] for p in out["pages"]] == [1, 2, 5]
|
|
assert len(out["gaps"]) == 1
|
|
assert out["gaps"][0]["from"] == 3
|
|
assert out["gaps"][0]["to"] == 4
|
|
# unnumber a page → it sorts to the tail
|
|
await svc.set_page_number(s.id, i1, None)
|
|
out = await svc.list_pages(s.id)
|
|
assert [p["page_number"] for p in out["pages"]] == [2, 5, None]
|
|
with pytest.raises(SeriesError):
|
|
await svc.set_page_number(s.id, 99999, 1)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_set_cover_makes_page_one_preserving_rest(db):
|
|
s = await TagService(db).find_or_create("S5", TagKind.series)
|
|
i1, i2, i3 = await _img(db), await _img(db), await _img(db)
|
|
svc = SeriesService(db)
|
|
await svc.add_images(s.id, [i1, i2, i3])
|
|
await svc.set_cover(s.id, i3)
|
|
assert await _order(db, s.id) == [i3, i1, i2]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_list_pages_shape_and_guards(db):
|
|
svc = SeriesService(db)
|
|
s = await TagService(db).find_or_create("S6", TagKind.series)
|
|
i1 = await _img(db)
|
|
await svc.add_images(s.id, [i1])
|
|
out = await svc.list_pages(s.id)
|
|
assert out["series"]["id"] == s.id
|
|
assert out["pages"][0]["image_id"] == i1
|
|
assert out["pages"][0]["page_number"] == 1
|
|
assert out["pages"][0]["thumbnail_url"]
|
|
assert out["pages"][0]["image_url"].startswith("/images/")
|
|
g = await TagService(db).find_or_create("notser", TagKind.general)
|
|
with pytest.raises(SeriesError):
|
|
await svc.list_pages(g.id)
|
|
with pytest.raises(SeriesError):
|
|
await svc.add_images(g.id, [i1])
|
|
with pytest.raises(SeriesError):
|
|
await svc.add_images(s.id, list(range(1, 502)))
|