fix(stats): preserve provider identities and artwork during linking

This commit is contained in:
2026-09-19 00:03:06 -07:00
parent 2b33d32879
commit c6059e340b
25 changed files with 926 additions and 267 deletions
@@ -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();
}
});
+1 -1
View File
@@ -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();
}
});
+19 -2
View File
@@ -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);
};