mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-06-12 15:13:32 -07:00
fix(stats): strip Season N suffix from AniList title searches (#121)
This commit is contained in:
@@ -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();
|
||||
}
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user