diff --git a/backend/app/api/tags.py b/backend/app/api/tags.py index 94bddff..288ade6 100644 --- a/backend/app/api/tags.py +++ b/backend/app/api/tags.py @@ -14,6 +14,7 @@ from ..services.tag_service import ( TagMergeConflict, TagService, TagValidationError, + normalize_tag_name, ) from ..utils.tag_prefix import parse_kind_prefix @@ -141,6 +142,11 @@ async def create_tag(): fandom_id = body.get("fandom_id") + # #701: Title-Case operator-entered tags. Only here (the explicit create + # endpoint), NOT in the shared find_or_create — the ML tagger uses that path + # and must keep the booru vocabulary's casing for allowlist matching. + name = normalize_tag_name(name) + async with get_session() as session: svc = TagService(session) try: diff --git a/backend/app/services/tag_service.py b/backend/app/services/tag_service.py index fc0e924..b26132b 100644 --- a/backend/app/services/tag_service.py +++ b/backend/app/services/tag_service.py @@ -84,7 +84,11 @@ class TagService: - if fandom_id is set, the referenced tag must exist and have kind == TagKind.fandom """ - name = normalize_tag_name(name) + # NOTE: case is NOT normalized here — find_or_create is the shared path + # the ML tagger / allowlist also use, and Title-Casing the booru + # vocabulary breaks allowlist matching. Display-casing (Title Case) is + # applied at the user-entry layer (api/tags create_tag) only (#701). + name = name.strip() if not name: raise TagValidationError("Tag name cannot be empty") @@ -259,14 +263,14 @@ class TagService: with an existing tag of the same (kind, fandom_id) — the API turns that into a 409 merge hint. """ - new_name = normalize_tag_name(new_name) + new_name = new_name.strip() if not new_name: raise TagValidationError("Tag name cannot be empty") tag = await self.session.get(Tag, tag_id) if tag is None: raise TagValidationError(f"Tag {tag_id} not found") - # Case-insensitive clash (#701) — colliding with a differently-cased tag + # Case-insensitive clash (#701) — renaming onto a differently-cased tag # is still a merge, not a silent fork. clash_stmt = ( select(Tag) diff --git a/tests/test_api_tags.py b/tests/test_api_tags.py index c4ead6e..c630baa 100644 --- a/tests/test_api_tags.py +++ b/tests/test_api_tags.py @@ -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" diff --git a/tests/test_tag_service.py b/tests/test_tag_service.py index d427ffd..3cb8a9b 100644 --- a/tests/test_tag_service.py +++ b/tests/test_tag_service.py @@ -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