feat(tags): Title-Case normalization on create/rename — #701 (core)
CI / lint (push) Successful in 2s
CI / frontend-build (push) Successful in 19s
CI / backend-lint-and-test (push) Successful in 25s
CI / integration (push) Failing after 3m0s

Tags now normalize to Title Case + collapsed whitespace at the central
TagService.find_or_create (and rename) — so manual entry, the create API, and
anything routed through find_or_create produce the canonical form. The lookup is
case-insensitive, so a differently-cased entry finds the existing tag instead of
forking a case-variant duplicate ('hatsune miku' / 'HATSUNE MIKU' → one tag).

normalize_tag_name uses per-word capitalize (not str.title(), which mangles
apostrophes) and folds ALL-CAPS input. Existing tags keep their current casing
until touched — a retro-normalize maintenance pass (Title-Case + merge
case-collisions) is the follow-up to convert the back-catalog.

Test: create title-cases + collapses whitespace; case/whitespace variants dedupe
to one tag.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-06 15:42:20 -04:00
parent 4fe53cdf6b
commit 2b69540ecc
2 changed files with 38 additions and 4 deletions
+13
View File
@@ -59,6 +59,19 @@ async def test_character_with_fandom(db):
assert char.fandom_id == fandom.id
@pytest.mark.asyncio
async def test_find_or_create_titlecases_and_dedups_case_insensitive(db):
"""#701: tags normalize to Title Case + collapsed whitespace on create, and a
differently-cased entry finds the existing tag instead of forking."""
svc = TagService(db)
t1 = await svc.find_or_create("hatsune miku", TagKind.character)
assert t1.name == "Hatsune Miku"
t2 = await svc.find_or_create("HATSUNE miku", TagKind.character)
assert t2.id == t1.id # case + whitespace variant → same tag, no fork
g = await svc.find_or_create(" looking at viewer ", TagKind.general)
assert g.name == "Looking At Viewer"
@pytest.mark.asyncio
async def test_character_same_name_different_fandoms(db):
svc = TagService(db)