Merge pull request 'Tag-casing acronym fix, Patreon resolver hardening, archive diagnostics, post-card strip' (#74) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 2s
Build images / build-ml (push) Successful in 8s
Build images / build-web (push) Successful in 10s
CI / frontend-build (push) Successful in 22s
CI / backend-lint-and-test (push) Successful in 26s
CI / integration (push) Successful in 3m1s
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 2s
Build images / build-ml (push) Successful in 8s
Build images / build-web (push) Successful in 10s
CI / frontend-build (push) Successful in 22s
CI / backend-lint-and-test (push) Successful in 26s
CI / integration (push) Successful in 3m1s
This commit was merged in pull request #74.
This commit is contained in:
@@ -25,13 +25,17 @@ from pathlib import Path
|
||||
|
||||
from .gallery_dl import DownloadResult, ErrorType
|
||||
from .patreon_ingester import PatreonIngester
|
||||
from .patreon_resolver import resolve_campaign_id_for_source
|
||||
from .patreon_resolver import extract_vanity, resolve_campaign_id_for_source
|
||||
|
||||
# Platforms whose download + verify go through the native ingester rather than
|
||||
# gallery-dl. gallery-dl still serves every other platform (subscribestar,
|
||||
# hentaifoundry, discord, pixiv, deviantart) unchanged.
|
||||
NATIVE_INGESTER_PLATFORMS = frozenset({"patreon"})
|
||||
|
||||
# Mirrors patreon_resolver._CAMPAIGNS_URL — surfaced in resolution-failure
|
||||
# messages so the operator sees the exact lookup endpoint that was hit.
|
||||
_CAMPAIGNS_API = "https://www.patreon.com/api/campaigns"
|
||||
|
||||
|
||||
def uses_native_ingester(platform: str) -> bool:
|
||||
"""True when `platform` is served by the native ingester (not gallery-dl).
|
||||
@@ -93,15 +97,19 @@ async def _run_native_ingester(
|
||||
ctx["url"], ctx["cookies_path"], overrides
|
||||
)
|
||||
if not campaign_id:
|
||||
url = ctx["url"]
|
||||
vanity = extract_vanity(url)
|
||||
return (
|
||||
DownloadResult(
|
||||
success=False,
|
||||
url=ctx["url"],
|
||||
url=url,
|
||||
artist_slug=ctx["artist_slug"],
|
||||
platform="patreon",
|
||||
error_type=ErrorType.NOT_FOUND,
|
||||
error_message=(
|
||||
"Could not resolve Patreon campaign id from the source URL "
|
||||
f"Could not resolve Patreon campaign id. source_url={url!r}; "
|
||||
f"vanity={vanity!r}; "
|
||||
f"lookup=GET {_CAMPAIGNS_API}?filter[vanity]={vanity or ''} "
|
||||
"(vanity lookup failed — cookies expired or creator moved?)"
|
||||
),
|
||||
),
|
||||
@@ -172,9 +180,11 @@ async def preview_source(
|
||||
url, cookies_path, config_overrides or {}
|
||||
)
|
||||
if not campaign_id:
|
||||
vanity = extract_vanity(url)
|
||||
return {
|
||||
"error": (
|
||||
"Couldn't resolve the campaign id from the source URL "
|
||||
f"Couldn't resolve the campaign id. source_url={url!r}; "
|
||||
f"vanity={vanity!r}; lookup=GET {_CAMPAIGNS_API}?filter[vanity]={vanity or ''} "
|
||||
"(cookies expired, or the creator moved/renamed?)."
|
||||
)
|
||||
}
|
||||
|
||||
@@ -269,6 +269,11 @@ class DownloadService:
|
||||
source_row = self.sync_session.get(Source, ctx["source_id"])
|
||||
|
||||
import_summary = {"attached": 0, "skipped": 0, "errors": 0}
|
||||
# Archives detected but captured WITHOUT extracting any image (probe
|
||||
# rejected / corrupt / missing extractor backend). Surfaced on the event
|
||||
# so a post showing "no images" beside a zip is diagnosable (plan
|
||||
# follow-up 2026-06-06 — the recurring archive-association report).
|
||||
unextracted_archives: list[dict] = []
|
||||
bytes_downloaded = 0
|
||||
|
||||
loop = asyncio.get_running_loop()
|
||||
@@ -322,6 +327,17 @@ class DownloadService:
|
||||
# mirror duplicate_hash cleanup so we don't keep two copies.
|
||||
# Operator-flagged 2026-06-02 (Lustria OST zip).
|
||||
import_summary["attached"] += 1
|
||||
# An archive captured WITHOUT extracting any image carries a
|
||||
# reason on result.error — record it so the event explains the
|
||||
# "no images beside a zip" symptom instead of staying silent.
|
||||
if result.error:
|
||||
unextracted_archives.append(
|
||||
{"file": path.name, "reason": result.error}
|
||||
)
|
||||
log.warning(
|
||||
"archive captured unextracted (%s): %s",
|
||||
path.name, result.error,
|
||||
)
|
||||
try:
|
||||
bytes_downloaded += path.stat().st_size # noqa: ASYNC240
|
||||
except OSError:
|
||||
@@ -403,6 +419,10 @@ class DownloadService:
|
||||
"duration_seconds": dl_result.duration_seconds,
|
||||
"quarantined_paths": dl_result.quarantined_paths or None,
|
||||
"import_summary": import_summary,
|
||||
# Archives detected but captured without extracting an image — the
|
||||
# recurring "post shows a zip but no images" report. Each entry is
|
||||
# {file, reason}; None when every archive extracted cleanly.
|
||||
"unextracted_archives": unextracted_archives or None,
|
||||
}
|
||||
await self._update_source_health(
|
||||
source_id=ctx["source_id"], status=status, error_message=ev.error,
|
||||
|
||||
@@ -430,13 +430,17 @@ class Importer:
|
||||
self._capture_attachment(
|
||||
source, post=post, artist=artist_use, resolved=True,
|
||||
)
|
||||
return ImportResult(status="attached")
|
||||
reason = f"archive probe rejected, captured unextracted: {probe.reason}"
|
||||
log.warning("%s: %s", source.name, reason)
|
||||
return ImportResult(status="attached", error=reason)
|
||||
|
||||
artist_use = artist if artist is not None else self._resolve_artist(source)
|
||||
post = self._post_for_sidecar(source, artist_use)
|
||||
member_ids: list[int] = []
|
||||
member_total = 0
|
||||
with extract_archive(source) as members:
|
||||
for _name, member_path in members:
|
||||
member_total += 1
|
||||
if not is_supported(member_path):
|
||||
continue # non-media preserved via the stored archive
|
||||
res = self._import_media(
|
||||
@@ -453,7 +457,19 @@ class Importer:
|
||||
status="imported", image_id=member_ids[0],
|
||||
member_image_ids=member_ids,
|
||||
)
|
||||
return ImportResult(status="attached")
|
||||
# No images landed — surface WHY so a post showing "no images" beside an
|
||||
# archive is diagnosable instead of silent. Zero members usually means
|
||||
# the extractor backend is missing/failed (unar for rar, py7zr for 7z)
|
||||
# or the file is corrupt; non-zero-but-no-images means it held only
|
||||
# non-media files.
|
||||
reason = (
|
||||
"archive extracted but held no supported image/video members"
|
||||
if member_total
|
||||
else "archive yielded no members (unsupported/corrupt, or the "
|
||||
"extractor backend failed)"
|
||||
)
|
||||
log.warning("%s: %s", source.name, reason)
|
||||
return ImportResult(status="attached", error=reason)
|
||||
|
||||
def _import_media(
|
||||
self, source: Path, attribution_path: Path,
|
||||
|
||||
@@ -45,7 +45,7 @@ from .patreon_client import (
|
||||
PatreonDriftError,
|
||||
)
|
||||
from .patreon_downloader import PatreonDownloader
|
||||
from .patreon_resolver import resolve_campaign_id_for_source
|
||||
from .patreon_resolver import extract_vanity, resolve_campaign_id_for_source
|
||||
|
||||
__all__ = [
|
||||
"DEAD_LETTER_THRESHOLD",
|
||||
@@ -186,9 +186,11 @@ async def verify_patreon_credential(
|
||||
"""
|
||||
campaign_id, _ = await resolve_campaign_id_for_source(url, cookies_path, overrides)
|
||||
if not campaign_id:
|
||||
vanity = extract_vanity(url)
|
||||
return None, (
|
||||
"Couldn't resolve the Patreon campaign id from the source URL — "
|
||||
"can't verify (cookies expired, or the creator moved/renamed?)."
|
||||
f"Couldn't resolve the Patreon campaign id — can't verify. "
|
||||
f"source_url={url!r}; vanity={vanity!r} "
|
||||
"(cookies expired, or the creator moved/renamed?)."
|
||||
)
|
||||
client = PatreonClient(cookies_path)
|
||||
loop = asyncio.get_running_loop()
|
||||
|
||||
@@ -41,6 +41,19 @@ _USER_AGENT = (
|
||||
)
|
||||
_TIMEOUT_SECONDS = 10.0
|
||||
|
||||
# Fallback resolution: Patreon's `/api/campaigns?filter[vanity]=` lookup has
|
||||
# proven unreliable (returns empty `data` for creators that clearly exist —
|
||||
# operator-flagged 2026-06-06). gallery-dl never used that endpoint; it scrapes
|
||||
# the campaign id out of the creator page's bootstrap JSON. We do the same as a
|
||||
# fallback: fetch the creator page HTML and pull the first campaign id out of
|
||||
# any of these embeddings (ordered most- to least-specific).
|
||||
_PAGE_CAMPAIGN_ID_PATTERNS = (
|
||||
re.compile(r'"id":\s*"(\d+)",\s*"type":\s*"campaign"'),
|
||||
re.compile(r'"campaign":\s*\{\s*"data":\s*\{\s*"id":\s*"(\d+)"'),
|
||||
re.compile(r"/api/campaigns/(\d+)"),
|
||||
re.compile(r'"campaign_id":\s*"?(\d+)'),
|
||||
)
|
||||
|
||||
|
||||
def _load_cookie_jar(cookies_path: str | None) -> http.cookiejar.MozillaCookieJar | None:
|
||||
if not cookies_path or not os.path.isfile(cookies_path):
|
||||
@@ -55,6 +68,15 @@ def _load_cookie_jar(cookies_path: str | None) -> http.cookiejar.MozillaCookieJa
|
||||
|
||||
|
||||
def _sync_lookup(vanity: str, cookies_path: str | None) -> str | None:
|
||||
"""Resolve a vanity to a campaign id: try the campaigns API first (cheap,
|
||||
structured), then fall back to scraping the creator page (robust against the
|
||||
API's empty-data failures). Returns None only when both miss."""
|
||||
return _lookup_via_api(vanity, cookies_path) or _lookup_via_page(
|
||||
vanity, cookies_path
|
||||
)
|
||||
|
||||
|
||||
def _lookup_via_api(vanity: str, cookies_path: str | None) -> str | None:
|
||||
jar = _load_cookie_jar(cookies_path)
|
||||
headers = {
|
||||
"User-Agent": _USER_AGENT,
|
||||
@@ -102,6 +124,50 @@ def _sync_lookup(vanity: str, cookies_path: str | None) -> str | None:
|
||||
return campaign_id
|
||||
|
||||
|
||||
def _scrape_campaign_id(html: str) -> str | None:
|
||||
"""First campaign id found in creator-page HTML via the known embeddings."""
|
||||
if not isinstance(html, str):
|
||||
return None
|
||||
for pat in _PAGE_CAMPAIGN_ID_PATTERNS:
|
||||
m = pat.search(html)
|
||||
if m:
|
||||
return m.group(1)
|
||||
return None
|
||||
|
||||
|
||||
def _lookup_via_page(vanity: str, cookies_path: str | None) -> str | None:
|
||||
"""Fallback: GET the creator page and scrape the campaign id from the page
|
||||
bootstrap (gallery-dl's method). Tries both the bare and `/c/` vanity paths
|
||||
Patreon redirects between. Never raises."""
|
||||
jar = _load_cookie_jar(cookies_path)
|
||||
headers = {"User-Agent": _USER_AGENT, "Accept": "text/html"}
|
||||
for page_url in (
|
||||
f"https://www.patreon.com/{vanity}",
|
||||
f"https://www.patreon.com/c/{vanity}",
|
||||
):
|
||||
try:
|
||||
resp = requests.get(
|
||||
page_url,
|
||||
headers=headers,
|
||||
cookies=jar,
|
||||
timeout=_TIMEOUT_SECONDS,
|
||||
allow_redirects=True,
|
||||
)
|
||||
except requests.RequestException as exc:
|
||||
log.warning("Patreon creator-page fetch failed for %s: %s", page_url, exc)
|
||||
continue
|
||||
if resp.status_code != 200:
|
||||
continue
|
||||
campaign_id = _scrape_campaign_id(resp.text)
|
||||
if campaign_id:
|
||||
log.info(
|
||||
"Resolved Patreon vanity=%s → campaign_id=%s via creator page",
|
||||
vanity, campaign_id,
|
||||
)
|
||||
return campaign_id
|
||||
return None
|
||||
|
||||
|
||||
async def resolve_campaign_id(
|
||||
vanity: str,
|
||||
cookies_path: str | None,
|
||||
|
||||
@@ -17,15 +17,19 @@ log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def normalize_tag_name(name: str) -> str:
|
||||
"""Canonical tag form (#701): collapse whitespace + Title Case.
|
||||
"""Canonical tag form (#701): collapse whitespace + capitalize the first
|
||||
letter of each word, PRESERVING the rest of the word.
|
||||
|
||||
Per-word capitalize (NOT str.title(), which mangles apostrophes:
|
||||
don't → Don'T). Lowercasing the word tail also folds ALL-CAPS input
|
||||
(HATSUNE MIKU → Hatsune Miku); acronym casing is sacrificed, an accepted
|
||||
Title-Case trade-off (operator-chosen 2026-06-06).
|
||||
don't → Don'T). The word tail is left untouched so acronyms survive
|
||||
(DC stays DC, NSFW stays NSFW) — operator-revised 2026-06-06: protecting
|
||||
acronyms matters more than folding ALL-CAPS input. This MATCHES
|
||||
ml/tag_name._title_word, so a tag suggested by the Camie tagger keeps the
|
||||
exact casing the suggestion UI showed when it round-trips through the
|
||||
create endpoint on Accept.
|
||||
"""
|
||||
return " ".join(
|
||||
w[:1].upper() + w[1:].lower() for w in (name or "").split()
|
||||
w[:1].upper() + w[1:] for w in (name or "").split()
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -34,7 +34,10 @@
|
||||
>
|
||||
<img :src="hero.thumbnail_url" alt="hero thumbnail" loading="lazy" />
|
||||
</button>
|
||||
<div v-if="rail.length || moreCount" class="fc-post-card__rail">
|
||||
<div
|
||||
v-if="rail.length || moreCount" class="fc-post-card__rail"
|
||||
:style="{ '--fc-rail-cols': railCols }"
|
||||
>
|
||||
<button
|
||||
v-for="t in rail" :key="t.image_id" type="button"
|
||||
class="fc-post-card__rail-cell"
|
||||
@@ -117,13 +120,29 @@ const totalImages = computed(() => images.value.length + (props.post.thumbnails_
|
||||
const plainTitle = computed(() => toPlainText(props.post.post_title))
|
||||
|
||||
const hero = computed(() => images.value[0])
|
||||
const rail = computed(() => images.value.slice(1, 4))
|
||||
// The thumbnail strip spans the hero's full width (CSS grid, equal columns),
|
||||
// rather than a fixed 3-cell cap. Show up to RAIL_MAX cells; when there are
|
||||
// more images than fit, the last cell becomes a "+N" overflow tile so the
|
||||
// count stays accurate.
|
||||
const RAIL_MAX = 5
|
||||
const serverMore = computed(() => props.post.thumbnails_more || 0)
|
||||
const afterHero = computed(() => images.value.slice(1))
|
||||
const hasOverflow = computed(
|
||||
() => serverMore.value > 0 || afterHero.value.length > RAIL_MAX,
|
||||
)
|
||||
const rail = computed(() =>
|
||||
hasOverflow.value
|
||||
? afterHero.value.slice(0, RAIL_MAX - 1)
|
||||
: afterHero.value.slice(0, RAIL_MAX),
|
||||
)
|
||||
const visibleCount = computed(() => (images.value.length ? 1 + rail.value.length : 0))
|
||||
const moreCount = computed(() => {
|
||||
const more = props.post.thumbnails_more || 0
|
||||
const extraShown = Math.max(0, images.value.length - visibleCount.value)
|
||||
return more + extraShown
|
||||
if (!hasOverflow.value) return 0
|
||||
return serverMore.value + Math.max(0, images.value.length - visibleCount.value)
|
||||
})
|
||||
// Columns = thumbnails shown plus the overflow tile, so the strip fills the
|
||||
// hero's full width with equal cells.
|
||||
const railCols = computed(() => rail.value.length + (moreCount.value > 0 ? 1 : 0))
|
||||
|
||||
const sortDateIso = computed(() => props.post.post_date || props.post.downloaded_at)
|
||||
const absoluteDate = computed(() => new Date(sortDateIso.value).toLocaleString())
|
||||
@@ -279,11 +298,16 @@ function formatBytes (n) {
|
||||
.fc-post-card__hero:hover img,
|
||||
.fc-post-card__rail-cell:hover img { transform: scale(1.03); filter: brightness(1.08); }
|
||||
|
||||
/* Equal columns spanning the hero's full width — cells stretch horizontally
|
||||
(1fr) at a fixed height, so the strip always reaches the edge of the hero
|
||||
regardless of how many thumbnails the post has. */
|
||||
.fc-post-card__rail {
|
||||
display: flex; gap: 6px; margin-top: 6px; flex-wrap: wrap;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(var(--fc-rail-cols, 3), 1fr);
|
||||
gap: 6px; margin-top: 6px;
|
||||
}
|
||||
.fc-post-card__rail-cell {
|
||||
width: 80px; height: 80px;
|
||||
width: 100%; height: 84px;
|
||||
overflow: hidden; border-radius: 4px;
|
||||
}
|
||||
.fc-post-card__rail-cell img {
|
||||
@@ -291,7 +315,7 @@ function formatBytes (n) {
|
||||
object-fit: cover; display: block;
|
||||
}
|
||||
.fc-post-card__rail-more {
|
||||
width: 80px; height: 80px;
|
||||
width: 100%; height: 84px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
border: 1px dashed rgb(var(--v-theme-on-surface-variant));
|
||||
border-radius: 4px;
|
||||
|
||||
@@ -40,6 +40,30 @@ describe('PostCard', () => {
|
||||
expect(full.text()).not.toContain('Show more')
|
||||
})
|
||||
|
||||
const thumbs = (n) =>
|
||||
Array.from({ length: n }, (_, i) => ({ image_id: 100 + i, thumbnail_url: `/t${i}` }))
|
||||
|
||||
it('fills the rail to full width (5 cells, no overflow) for a 6-image post', () => {
|
||||
const post = { ...BASE, description_plain: 'd', thumbnails: thumbs(6), thumbnails_more: 0 }
|
||||
const w = mountComponent(PostCard, { props: { post }, pinia: freshPinia() })
|
||||
// hero + 5 rail thumbnails, no "+N" tile.
|
||||
expect(w.findAll('.fc-post-card__rail-cell')).toHaveLength(5)
|
||||
expect(w.find('.fc-post-card__rail-more').exists()).toBe(false)
|
||||
// The grid spans the hero width via a column count == cells shown.
|
||||
expect(w.find('.fc-post-card__rail').attributes('style')).toContain('--fc-rail-cols: 5')
|
||||
})
|
||||
|
||||
it('reserves the last cell for a "+N" overflow tile when there are more images', () => {
|
||||
const post = { ...BASE, description_plain: 'd', thumbnails: thumbs(6), thumbnails_more: 4 }
|
||||
const w = mountComponent(PostCard, { props: { post }, pinia: freshPinia() })
|
||||
// 4 thumbnails + 1 overflow tile == 5 columns; tile counts every hidden image.
|
||||
expect(w.findAll('.fc-post-card__rail-cell')).toHaveLength(4)
|
||||
const more = w.find('.fc-post-card__rail-more')
|
||||
expect(more.exists()).toBe(true)
|
||||
expect(more.text()).toBe('+5')
|
||||
expect(w.find('.fc-post-card__rail').attributes('style')).toContain('--fc-rail-cols: 5')
|
||||
})
|
||||
|
||||
it('clicking an image opens the post-scoped modal (never expands the card)', async () => {
|
||||
const pinia = freshPinia()
|
||||
const modal = useModalStore()
|
||||
|
||||
@@ -74,13 +74,14 @@ 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. #701: still Title-Cased as a single word."""
|
||||
colon and prefix stay literal. #701: first letter capitalized, tail
|
||||
preserved (acronym-safe), so the 'S' in Saber survives."""
|
||||
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"
|
||||
|
||||
|
||||
|
||||
@@ -91,6 +91,41 @@ async def test_loads_cookies_file_when_present(tmp_path):
|
||||
assert kwargs.get("cookies") is not None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_falls_back_to_creator_page_when_api_empty():
|
||||
"""When the campaigns API returns empty data (its known failure mode), the
|
||||
resolver scrapes the campaign id out of the creator page (gallery-dl's
|
||||
method)."""
|
||||
api_resp = MagicMock()
|
||||
api_resp.status_code = 200
|
||||
api_resp.json.return_value = {"data": []}
|
||||
page_resp = MagicMock()
|
||||
page_resp.status_code = 200
|
||||
page_resp.text = (
|
||||
'<html><script>window.patreon={"bootstrap":{"campaign":'
|
||||
'{"data":{"id":"7654321","type":"campaign"}}}}</script></html>'
|
||||
)
|
||||
|
||||
def _get(url, **kwargs):
|
||||
return api_resp if "/api/campaigns" in url else page_resp
|
||||
|
||||
with patch(
|
||||
"backend.app.services.patreon_resolver.requests.get", side_effect=_get
|
||||
):
|
||||
result = await resolve_campaign_id("alice", cookies_path=None)
|
||||
assert result == "7654321"
|
||||
|
||||
|
||||
def test_scrape_campaign_id_patterns():
|
||||
from backend.app.services.patreon_resolver import _scrape_campaign_id
|
||||
|
||||
assert _scrape_campaign_id('foo /api/campaigns/424242 bar') == "424242"
|
||||
assert _scrape_campaign_id('"campaign_id":"99"') == "99"
|
||||
assert _scrape_campaign_id('{"id":"5","type":"campaign"}') == "5"
|
||||
assert _scrape_campaign_id("no id here") is None
|
||||
assert _scrape_campaign_id(None) is None
|
||||
|
||||
|
||||
# -- resolve_campaign_id_for_source (shared by download + verify) ----------
|
||||
|
||||
|
||||
|
||||
@@ -87,8 +87,9 @@ async def test_dry_run_projection_counts(db):
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_live_merges_case_variants_and_repoints_images(db):
|
||||
# lowercase vs sentence-case → both canonicalize to "Hatsune Miku" and fold.
|
||||
a = await _raw_tag(db, "hatsune miku", TagKind.character)
|
||||
b = await _raw_tag(db, "HATSUNE MIKU", TagKind.character)
|
||||
b = await _raw_tag(db, "Hatsune miku", TagKind.character)
|
||||
i1, i2, i3 = await _img(db), await _img(db), await _img(db)
|
||||
await _link(db, i1, a.id)
|
||||
await _link(db, i2, a.id)
|
||||
@@ -125,7 +126,7 @@ async def test_live_merges_case_variants_and_repoints_images(db):
|
||||
@pytest.mark.asyncio
|
||||
async def test_idempotent_second_run_is_noop(db):
|
||||
await _raw_tag(db, "kafka", TagKind.character)
|
||||
await _raw_tag(db, "KAFKA", TagKind.character)
|
||||
await _raw_tag(db, "Kafka", TagKind.character)
|
||||
|
||||
first = await normalize_existing_tags(db, dry_run=False)
|
||||
assert first["groups_processed"] == 1
|
||||
@@ -138,6 +139,24 @@ async def test_idempotent_second_run_is_noop(db):
|
||||
assert again["renamed"] == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_acronym_casing_is_preserved_not_folded(db):
|
||||
"""Acronym-preserving rule (2026-06-06): an already-canonical acronym tag
|
||||
is a no-op, and an all-caps variant does NOT fold into a Title-cased one
|
||||
(protecting DC/NSFW from becoming Dc/Nsfw is the deliberate trade)."""
|
||||
await _raw_tag(db, "DC", TagKind.general)
|
||||
proj = await normalize_existing_tags(db, dry_run=True)
|
||||
assert proj["total_changes"] == 0 # "DC" is already canonical
|
||||
|
||||
# All-caps and Title-case are distinct canonical forms → not merged.
|
||||
await _raw_tag(db, "Nsfw", TagKind.general)
|
||||
await _raw_tag(db, "NSFW", TagKind.general)
|
||||
summary = await normalize_existing_tags(db, dry_run=False)
|
||||
assert summary["merged"] == 0
|
||||
assert await _count_named(db, "DC", TagKind.general) == 1
|
||||
assert await _count_named(db, "NSFW", TagKind.general) == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_same_name_different_fandom_not_merged(db):
|
||||
f1 = await _raw_tag(db, "Bleach", TagKind.fandom)
|
||||
|
||||
@@ -77,7 +77,12 @@ 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"
|
||||
# Acronym-preserving (2026-06-06): only the first letter of each word is
|
||||
# touched, so already-uppercase tails survive (DC stays DC, not Dc).
|
||||
assert normalize_tag_name("DC comics") == "DC Comics"
|
||||
assert normalize_tag_name("dc") == "Dc"
|
||||
assert normalize_tag_name("NSFW variant") == "NSFW Variant"
|
||||
assert normalize_tag_name("HATSUNE MIKU") == "HATSUNE MIKU"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
Reference in New Issue
Block a user