mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-09-19 05:16:27 -07:00
fix(stats): preserve provider identities and artwork during linking
This commit is contained in:
@@ -183,3 +183,42 @@ test('AnimeTab refetches the library after the AniList entry is relinked', async
|
||||
uninstallDom();
|
||||
}
|
||||
});
|
||||
|
||||
test('AnimeTab watch time follows the displayed kind filter', async () => {
|
||||
const uninstallDom = installDom();
|
||||
const original = apiClient.getAnimeLibrary;
|
||||
apiClient.getAnimeLibrary = async () => [
|
||||
{ ...libraryItem(42), totalActiveMs: 3600000 },
|
||||
{
|
||||
...libraryItem(null),
|
||||
animeId: 8,
|
||||
canonicalTitle: 'Drama',
|
||||
mediaKind: 'live_action',
|
||||
tmdbId: 12,
|
||||
tmdbType: 'tv',
|
||||
totalActiveMs: 7200000,
|
||||
},
|
||||
];
|
||||
const container = document.createElement('div');
|
||||
document.body.append(container);
|
||||
const root = createRoot(container);
|
||||
try {
|
||||
await act(async () => {
|
||||
root.render(<AnimeTab />);
|
||||
});
|
||||
assert.match(container.textContent ?? '', /2 titles · 3h/);
|
||||
const select = container.querySelector('select');
|
||||
assert.ok(select);
|
||||
await act(async () => {
|
||||
select.value = 'live_action';
|
||||
select.dispatchEvent(new window.Event('change', { bubbles: true }));
|
||||
});
|
||||
assert.match(container.textContent ?? '', /1 titles · 2h/);
|
||||
} finally {
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
apiClient.getAnimeLibrary = original;
|
||||
uninstallDom();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -123,7 +123,7 @@ export function AnimeTab({
|
||||
return sortAnime(base, sortKey);
|
||||
}, [anime, search, sortKey, kindFilter]);
|
||||
|
||||
const totalMs = anime.reduce((sum, a) => sum + a.totalActiveMs, 0);
|
||||
const totalMs = filtered.reduce((sum, a) => sum + a.totalActiveMs, 0);
|
||||
const checkedEntries = checkedAnimeIds
|
||||
.map((animeId) => anime.find((entry) => entry.animeId === animeId))
|
||||
.filter((entry): entry is (typeof anime)[number] => entry !== undefined);
|
||||
|
||||
@@ -152,3 +152,122 @@ test('TmdbSelector explains a missing API key instead of showing "No results"',
|
||||
uninstallDom();
|
||||
}
|
||||
});
|
||||
|
||||
for (const staleFailure of [false, true]) {
|
||||
test(`TmdbSelector ignores superseded ${staleFailure ? 'errors' : 'results'} and loading changes`, async () => {
|
||||
const uninstallDom = installDom();
|
||||
const originalSearch = apiClient.searchTmdb;
|
||||
const requests: Array<{
|
||||
resolve: (results: StatsTmdbSearchResult[]) => void;
|
||||
reject: (error: Error) => void;
|
||||
}> = [];
|
||||
apiClient.searchTmdb = () =>
|
||||
new Promise((resolve, reject) => {
|
||||
requests.push({ resolve, reject });
|
||||
});
|
||||
const container = document.createElement('div');
|
||||
document.body.append(container);
|
||||
const root = createRoot(container);
|
||||
try {
|
||||
const render = (initialQuery: string) =>
|
||||
root.render(
|
||||
<TmdbSelector
|
||||
animeId={1}
|
||||
initialQuery={initialQuery}
|
||||
onClose={() => {}}
|
||||
onLinked={() => {}}
|
||||
/>,
|
||||
);
|
||||
await act(async () => {
|
||||
render('First title');
|
||||
});
|
||||
await act(async () => {
|
||||
render('Second title');
|
||||
});
|
||||
assert.equal(requests.length, 2);
|
||||
await act(async () => {
|
||||
if (staleFailure) requests[0]!.reject(new Error('stale error'));
|
||||
else requests[0]!.resolve([HANZAWA]);
|
||||
});
|
||||
assert.match(container.textContent ?? '', /Searching/);
|
||||
assert.doesNotMatch(container.textContent ?? '', /Hanzawa|failed/);
|
||||
await act(async () => {
|
||||
requests[1]!.resolve([HANZAWA]);
|
||||
});
|
||||
assert.match(container.textContent ?? '', /Hanzawa/);
|
||||
assert.doesNotMatch(container.textContent ?? '', /Searching/);
|
||||
await act(async () => {
|
||||
render('Third title');
|
||||
});
|
||||
await act(async () => {
|
||||
render('');
|
||||
});
|
||||
await act(async () => {
|
||||
requests[2]!.resolve([HANZAWA]);
|
||||
});
|
||||
assert.doesNotMatch(container.textContent ?? '', /Hanzawa|Searching/);
|
||||
} finally {
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
apiClient.searchTmdb = originalSearch;
|
||||
uninstallDom();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test('TmdbSelector invalidates requests as soon as the user edits or clears the query', async () => {
|
||||
const uninstallDom = installDom();
|
||||
const originalSearch = apiClient.searchTmdb;
|
||||
const requests: Array<(results: StatsTmdbSearchResult[]) => void> = [];
|
||||
apiClient.searchTmdb = () =>
|
||||
new Promise((resolve) => {
|
||||
requests.push(resolve);
|
||||
});
|
||||
const container = document.createElement('div');
|
||||
document.body.append(container);
|
||||
const root = createRoot(container);
|
||||
try {
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<TmdbSelector animeId={1} initialQuery="First" onClose={() => {}} onLinked={() => {}} />,
|
||||
);
|
||||
});
|
||||
const input = container.querySelector('input');
|
||||
assert.ok(input);
|
||||
const setValue = Object.getOwnPropertyDescriptor(
|
||||
window.HTMLInputElement.prototype,
|
||||
'value',
|
||||
)?.set;
|
||||
assert.ok(setValue);
|
||||
const edit = async (value: string) => {
|
||||
await act(async () => {
|
||||
setValue.call(input, value);
|
||||
input.dispatchEvent(new window.Event('input', { bubbles: true }));
|
||||
input.dispatchEvent(new window.KeyboardEvent('keyup', { bubbles: true }));
|
||||
});
|
||||
};
|
||||
await edit('Second');
|
||||
await act(async () => {
|
||||
requests[0]!([HANZAWA]);
|
||||
});
|
||||
assert.doesNotMatch(container.textContent ?? '', /Hanzawa|No results/);
|
||||
assert.match(container.textContent ?? '', /Searching/);
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 450));
|
||||
});
|
||||
assert.equal(requests.length, 2);
|
||||
await edit('');
|
||||
assert.doesNotMatch(container.textContent ?? '', /Searching/);
|
||||
await act(async () => {
|
||||
requests[1]!([HANZAWA]);
|
||||
});
|
||||
assert.doesNotMatch(container.textContent ?? '', /Hanzawa|Searching/);
|
||||
} finally {
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
apiClient.searchTmdb = originalSearch;
|
||||
uninstallDom();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -27,6 +27,7 @@ export function TmdbSelector({ animeId, initialQuery, onClose, onLinked }: TmdbS
|
||||
const [linking, setLinking] = useState<number | null>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const searchSequenceRef = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
inputRef.current?.focus();
|
||||
@@ -38,28 +39,44 @@ export function TmdbSelector({ animeId, initialQuery, onClose, onLinked }: TmdbS
|
||||
setLinking(null);
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
if (normalizedInitialQuery) void doSearch(normalizedInitialQuery);
|
||||
return () => {
|
||||
searchSequenceRef.current += 1;
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
};
|
||||
}, [initialQuery, animeId]);
|
||||
|
||||
const doSearch = async (q: string) => {
|
||||
const sequence = ++searchSequenceRef.current;
|
||||
const searchQuery = normalizeAnilistSearchQuery(q);
|
||||
if (!searchQuery) {
|
||||
setResults([]);
|
||||
setError(null);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
setResults(await apiClient.searchTmdb(searchQuery));
|
||||
const nextResults = await apiClient.searchTmdb(searchQuery);
|
||||
if (sequence === searchSequenceRef.current) setResults(nextResults);
|
||||
} catch (err) {
|
||||
if (sequence !== searchSequenceRef.current) return;
|
||||
setResults([]);
|
||||
setError(describeSearchError(err));
|
||||
} finally {
|
||||
if (sequence === searchSequenceRef.current) setLoading(false);
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const handleInput = (value: string) => {
|
||||
searchSequenceRef.current += 1;
|
||||
setQuery(value);
|
||||
setResults([]);
|
||||
setError(null);
|
||||
const hasQuery = Boolean(normalizeAnilistSearchQuery(value));
|
||||
setLoading(hasQuery);
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
if (!hasQuery) return;
|
||||
debounceRef.current = setTimeout(() => void doSearch(value), 400);
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user