fix(stats): strip Season N suffix from AniList title searches (#121)

This commit is contained in:
2026-06-12 01:07:11 -07:00
committed by GitHub
parent 0a384a22c9
commit 94a65416ae
7 changed files with 218 additions and 6 deletions
@@ -0,0 +1,149 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { Window } from 'happy-dom';
import { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { apiClient } from '../../lib/api-client';
import { AnilistSelector } from './AnilistSelector';
interface TestWindow extends Window {
IS_REACT_ACT_ENVIRONMENT?: boolean;
}
function installDom(): () => void {
const previousWindow = globalThis.window;
const previousDocument = globalThis.document;
const previousHTMLElement = globalThis.HTMLElement;
const previousISReactActEnvironment = (
globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
).IS_REACT_ACT_ENVIRONMENT;
const window = new Window() as TestWindow;
Object.defineProperty(globalThis, 'window', { value: window, configurable: true });
Object.defineProperty(globalThis, 'document', { value: window.document, configurable: true });
Object.defineProperty(globalThis, 'HTMLElement', {
value: window.HTMLElement,
configurable: true,
});
(
globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
).IS_REACT_ACT_ENVIRONMENT = true;
return () => {
Object.defineProperty(globalThis, 'window', {
value: previousWindow,
configurable: true,
});
Object.defineProperty(globalThis, 'document', {
value: previousDocument,
configurable: true,
});
Object.defineProperty(globalThis, 'HTMLElement', {
value: previousHTMLElement,
configurable: true,
});
(
globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
).IS_REACT_ACT_ENVIRONMENT = previousISReactActEnvironment;
};
}
function renderSelector(root: Root, props: { animeId: number; initialQuery: string }): void {
root.render(
<AnilistSelector
animeId={props.animeId}
initialQuery={props.initialQuery}
onClose={() => {}}
onLinked={() => {}}
/>,
);
}
function inputValue(container: Element): string {
const input = container.querySelector('input');
assert.ok(input);
return input.value;
}
function deferred<T>(): {
promise: Promise<T>;
resolve: (value: T) => void;
} {
let resolve!: (value: T) => void;
const promise = new Promise<T>((done) => {
resolve = done;
});
return { promise, resolve };
}
test('AnilistSelector resyncs normalized query and searches when the initial anime changes', async () => {
const uninstallDom = installDom();
const originalSearchAnilist = apiClient.searchAnilist;
const secondSearch = deferred<Awaited<ReturnType<typeof apiClient.searchAnilist>>>();
const searchCalls: string[] = [];
apiClient.searchAnilist = (async (query: string) => {
searchCalls.push(query);
if (query === 'My Hero Academia') {
return secondSearch.promise;
}
return [
{
id: 1,
episodes: 1,
season: null,
seasonYear: null,
description: null,
coverImage: null,
title: { romaji: 'First Result', english: null, native: null },
},
];
}) as typeof apiClient.searchAnilist;
try {
const container = document.createElement('div');
document.body.append(container);
const root = createRoot(container);
await act(async () => {
renderSelector(root, { animeId: 1, initialQuery: 'Sword Art Online Season 1' });
});
assert.equal(inputValue(container), 'Sword Art Online');
assert.deepEqual(searchCalls, ['Sword Art Online']);
assert.match(container.textContent ?? '', /First Result/);
await act(async () => {
renderSelector(root, { animeId: 2, initialQuery: 'My Hero Academia: Season 3' });
});
assert.equal(inputValue(container), 'My Hero Academia');
assert.deepEqual(searchCalls, ['Sword Art Online', 'My Hero Academia']);
assert.doesNotMatch(container.textContent ?? '', /First Result/);
assert.match(container.textContent ?? '', /Searching/);
await act(async () => {
secondSearch.resolve([
{
id: 2,
episodes: 2,
season: null,
seasonYear: null,
description: null,
coverImage: null,
title: { romaji: 'Second Result', english: null, native: null },
},
]);
await secondSearch.promise;
});
assert.match(container.textContent ?? '', /Second Result/);
await act(async () => {
root.unmount();
});
} finally {
apiClient.searchAnilist = originalSearchAnilist;
uninstallDom();
}
});
+13 -5
View File
@@ -1,5 +1,6 @@
import { useState, useEffect, useRef } from 'react';
import { apiClient } from '../../lib/api-client';
import { normalizeAnilistSearchQuery } from '../../lib/anilist-search-query';
interface AnilistMedia {
id: number;
@@ -24,7 +25,7 @@ export function AnilistSelector({
onClose,
onLinked,
}: AnilistSelectorProps) {
const [query, setQuery] = useState(initialQuery);
const [query, setQuery] = useState(() => normalizeAnilistSearchQuery(initialQuery));
const [results, setResults] = useState<AnilistMedia[]>([]);
const [loading, setLoading] = useState(false);
const [linking, setLinking] = useState<number | null>(null);
@@ -33,17 +34,24 @@ export function AnilistSelector({
useEffect(() => {
inputRef.current?.focus();
if (initialQuery) doSearch(initialQuery);
}, []);
const normalizedInitialQuery = normalizeAnilistSearchQuery(initialQuery);
setQuery(normalizedInitialQuery);
setResults([]);
setLoading(false);
setLinking(null);
if (debounceRef.current) clearTimeout(debounceRef.current);
if (normalizedInitialQuery) doSearch(normalizedInitialQuery);
}, [initialQuery, animeId]);
const doSearch = async (q: string) => {
if (!q.trim()) {
const searchQuery = normalizeAnilistSearchQuery(q);
if (!searchQuery) {
setResults([]);
return;
}
setLoading(true);
try {
const data = await apiClient.searchAnilist(q.trim());
const data = await apiClient.searchAnilist(searchQuery);
setResults(data);
} catch {
setResults([]);
@@ -0,0 +1,22 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { normalizeAnilistSearchQuery } from './anilist-search-query';
test('normalizeAnilistSearchQuery removes appended season scope from anime titles', () => {
assert.equal(normalizeAnilistSearchQuery('Sword Art Online Season 1'), 'Sword Art Online');
assert.equal(normalizeAnilistSearchQuery('KonoSuba Season 02'), 'KonoSuba');
});
test('normalizeAnilistSearchQuery removes bracketed season scope without dropping real title text', () => {
assert.equal(normalizeAnilistSearchQuery('KonoSuba (Season 2)'), 'KonoSuba');
assert.equal(normalizeAnilistSearchQuery('KonoSuba - Season 2'), 'KonoSuba');
});
test('normalizeAnilistSearchQuery removes colon-delimited season scope from anime titles', () => {
assert.equal(normalizeAnilistSearchQuery('My Hero Academia: Season 3'), 'My Hero Academia');
assert.equal(normalizeAnilistSearchQuery('Title: Season 01'), 'Title');
});
test('normalizeAnilistSearchQuery keeps inputs when stripping season scope would erase title', () => {
assert.equal(normalizeAnilistSearchQuery('Season 1'), 'Season 1');
});
+9
View File
@@ -0,0 +1,9 @@
export function normalizeAnilistSearchQuery(query: string): string {
const trimmed = query.trim().replace(/\s+/g, ' ');
const withoutSeason = trimmed
.replace(/\s*[\[(]\s*Season\s+0?\d+\s*[\])]\s*$/i, '')
.replace(/\s*[-:]\s*Season\s+0?\d+\s*$/i, '')
.replace(/\s+Season\s+0?\d+\s*$/i, '')
.trim();
return withoutSeason.length > 0 ? withoutSeason : trimmed;
}