fix(tags): Title-Case operator-entered tags at create endpoint only (plan #701)
normalize_tag_name (per-word capitalize + whitespace collapse) is applied in the POST /api/tags handler so operator-entered tags get clean display casing. It is NOT applied in the shared find_or_create / rename paths — those are used by the ML tagger and allowlist matching, which must preserve the booru vocabulary's original casing (Title-Casing it broke apply_allowlist matching). find_or_create / rename keep case-insensitive lookup + clash detection so a differently-cased entry dedups onto the existing tag instead of forking. Tests updated to expect Title-Cased create output (sunset→Sunset, character:Saber→Character:saber, http://example.com→Http://example.com) and a dedicated normalize_tag_name unit test. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -52,11 +52,12 @@ async def test_create_tag_missing_name_400(client):
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_tag_name_only_defaults_to_general(client):
|
||||
"""IR-style: name without kind and without `kind:` prefix → general."""
|
||||
"""IR-style: name without kind and without `kind:` prefix → general.
|
||||
#701: operator-entered names are Title-Cased at the create endpoint."""
|
||||
resp = await client.post("/api/tags", json={"name": "sunset"})
|
||||
assert resp.status_code == 201
|
||||
body = await resp.get_json()
|
||||
assert body["name"] == "sunset"
|
||||
assert body["name"] == "Sunset"
|
||||
assert body["kind"] == "general"
|
||||
|
||||
|
||||
@@ -73,21 +74,22 @@ async def test_create_tag_with_kind_prefix(client):
|
||||
@pytest.mark.asyncio
|
||||
async def test_explicit_kind_overrides_prefix_parsing(client):
|
||||
"""If caller passes explicit kind, don't re-parse the name —
|
||||
colon and prefix stay literal."""
|
||||
colon and prefix stay literal. #701: still Title-Cased as a single word."""
|
||||
resp = await client.post(
|
||||
"/api/tags", json={"name": "character:Saber", "kind": "general"}
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
body = await resp.get_json()
|
||||
assert body["name"] == "character:Saber"
|
||||
assert body["name"] == "Character:saber"
|
||||
assert body["kind"] == "general"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unknown_prefix_kept_literal(client):
|
||||
"""`http:example` — `http` not in KNOWN_KINDS → kind=general, literal name."""
|
||||
"""`http:example` — `http` not in KNOWN_KINDS → kind=general, literal name.
|
||||
#701: Title-Cased as one word (no internal whitespace to split on)."""
|
||||
resp = await client.post("/api/tags", json={"name": "http://example.com"})
|
||||
assert resp.status_code == 201
|
||||
body = await resp.get_json()
|
||||
assert body["name"] == "http://example.com"
|
||||
assert body["name"] == "Http://example.com"
|
||||
assert body["kind"] == "general"
|
||||
|
||||
@@ -60,16 +60,24 @@ async def test_character_with_fandom(db):
|
||||
|
||||
|
||||
@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."""
|
||||
async def test_find_or_create_dedups_case_insensitive(db):
|
||||
"""#701: find_or_create matches case-insensitively so a differently-cased
|
||||
entry finds the existing tag instead of forking. (Display Title-Casing is
|
||||
applied at the create API, not here — this path is shared with the ML
|
||||
tagger, which keeps the booru vocabulary's casing.)"""
|
||||
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"
|
||||
assert t1.name == "hatsune miku" # stored as given; not recased here
|
||||
t2 = await svc.find_or_create("HATSUNE MIKU", TagKind.character)
|
||||
assert t2.id == t1.id # case variant → same tag, no fork
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_normalize_tag_name():
|
||||
from backend.app.services.tag_service import normalize_tag_name
|
||||
assert normalize_tag_name("hatsune miku") == "Hatsune Miku"
|
||||
assert normalize_tag_name(" looking at viewer ") == "Looking At Viewer"
|
||||
assert normalize_tag_name("HATSUNE MIKU") == "Hatsune Miku"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
Reference in New Issue
Block a user