feat(tags): non-mutating merge preview + admin dry_run (#8a)
Cluster B, milestone #99. TagService.merge_preview(source, target) computes the same counts the apply produces (rule 93 parity) without mutating: images_moving (source links the apply UPDATEs), images_already_on_target (links it drops), source_total, series_pages, will_alias (_keep_as_alias), a kind/fandom compatible flag (surfaced, not raised, so the UI can warn), and up to 6 thumbnails of the moving images. The admin /tags/<dest>/merge route gains a dry_run flag returning the preview JSON. Tests: preview moving-count == apply merged_count (parity), incompatible flagged without raising, self/missing raise, admin dry_run returns preview + no mutation. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XCUHUGQLrBrkgyk1t49kpX
This commit is contained in:
@@ -270,6 +270,41 @@ async def test_tag_merge_succeeds_for_same_kind(client, db):
|
||||
assert body["result"]["target_id"] == dest_id
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tag_merge_dry_run_previews_without_mutating(client, db):
|
||||
from backend.app.models import ImageRecord
|
||||
from backend.app.models.tag import image_tag
|
||||
|
||||
src = Tag(name="dry-src", kind=TagKind.general)
|
||||
dest = Tag(name="dry-dest", kind=TagKind.general)
|
||||
db.add_all([src, dest])
|
||||
await db.flush()
|
||||
img = ImageRecord(
|
||||
path="/images/dry.jpg", sha256="dry" + "0" * 61, size_bytes=1,
|
||||
mime="image/jpeg", width=1, height=1,
|
||||
origin="imported_filesystem", integrity_status="unknown",
|
||||
)
|
||||
db.add(img)
|
||||
await db.flush()
|
||||
await db.execute(image_tag.insert().values(
|
||||
image_record_id=img.id, tag_id=src.id, source="manual",
|
||||
))
|
||||
src_id, dest_id = src.id, dest.id
|
||||
await db.commit()
|
||||
|
||||
resp = await client.post(
|
||||
f"/api/admin/tags/{dest_id}/merge",
|
||||
json={"source_id": src_id, "dry_run": True},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
p = (await resp.get_json())["preview"]
|
||||
assert p["compatible"] is True
|
||||
assert p["images_moving"] == 1
|
||||
assert p["source_total"] == 1
|
||||
# No mutation: source still exists.
|
||||
assert await db.get(Tag, src_id) is not None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tag_merge_rejects_self_merge(client, db):
|
||||
t = Tag(name="x", kind=TagKind.general)
|
||||
|
||||
@@ -56,6 +56,48 @@ async def test_merge_conflict_is_a_tag_validation_error(db):
|
||||
assert issubclass(TagMergeConflict, TagValidationError)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_merge_preview_matches_apply(db):
|
||||
# #8 rule-93 parity: the preview's moving-count is exactly what the apply
|
||||
# moves, and already-on-target links are the ones dropped.
|
||||
svc = TagService(db)
|
||||
target = await svc.find_or_create("MTarget", TagKind.general)
|
||||
source = await svc.find_or_create("MSource", TagKind.general)
|
||||
a, b, c = await _img(db), await _img(db), await _img(db)
|
||||
for img in (a, b, c):
|
||||
await svc.add_to_image(img, source.id, source="manual")
|
||||
await svc.add_to_image(a, target.id, source="manual") # a already on target
|
||||
|
||||
preview = await svc.merge_preview(source.id, target.id)
|
||||
assert preview.compatible is True
|
||||
assert preview.images_moving == 2 # b, c
|
||||
assert preview.images_already_on_target == 1 # a
|
||||
assert preview.source_total == 3
|
||||
assert preview.will_alias is False # purely manual
|
||||
|
||||
result = await svc.merge(source.id, target.id)
|
||||
assert result.merged_count == preview.images_moving # parity
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_merge_preview_flags_incompatible_without_raising(db):
|
||||
svc = TagService(db)
|
||||
target = await svc.find_or_create("CharT", TagKind.character)
|
||||
source = await svc.find_or_create("GenS", TagKind.general)
|
||||
preview = await svc.merge_preview(source.id, target.id)
|
||||
assert preview.compatible is False # kind mismatch surfaced, not raised
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_merge_preview_self_and_missing_raise(db):
|
||||
svc = TagService(db)
|
||||
t = await svc.find_or_create("Solo", TagKind.general)
|
||||
with pytest.raises(TagValidationError):
|
||||
await svc.merge_preview(t.id, t.id)
|
||||
with pytest.raises(TagValidationError):
|
||||
await svc.merge_preview(t.id, 999999)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_will_alias_true_when_machine_sourced(db):
|
||||
svc = TagService(db)
|
||||
|
||||
Reference in New Issue
Block a user