fix(409,447): LibrariesScreen 'Never scanned' label + stabilize flaky scan-poll test #451

Merged
timothy merged 1 commits from fix/409-447-librariesscreen-neverscanned into main 2026-07-18 18:18:46 +02:00
2 changed files with 58 additions and 4 deletions
+35 -2
View File
@@ -186,6 +186,29 @@ describe('LibrariesScreen', () => {
expect(handle.fetchSpy).toHaveBeenCalledWith('/api/v1/libraries/scan-status', expect.any(Object));
});
it('renders "Never scanned" for a DateTime.MinValue-seeded lastScan, not a bogus midnight time (#409)', async () => {
mockApi({
mediaSources: [
mediaSource({
libraries: [
library({ id: 31, itemCount: 0, lastScan: '0001-01-01T00:00:00', mediaKind: 'Movies', name: 'Movies' }),
library({ id: 32, itemCount: 0, lastScan: null, mediaKind: 'Shows', name: 'TV Shows' })
]
})
]
});
render(<LibrariesScreen />);
expect((await screen.findAllByText('Movies')).length).toBeGreaterThan(0);
// Both the never-scanned-MinValue library row and the never-scanned-null library row read
// "Never scanned" - a plain `Boolean(lastScan)` truthiness check would treat the MinValue string
// as a real scan and print "Last scan 12:00 AM" instead.
expect(screen.getAllByText('Never scanned').length).toBeGreaterThanOrEqual(2);
expect(screen.queryByText(/Last scan/)).not.toBeInTheDocument();
expect(screen.queryByText(/12:00 AM/)).not.toBeInTheDocument();
});
it('converts 0 and 1 fractional scan-status percents to 0% and 100%', async () => {
mockApi({
libraryScanStatuses: [
@@ -255,8 +278,18 @@ describe('LibrariesScreen', () => {
const sourceFetchesBeforeCompletion = fetchCount(handle, '/api/v1/media-sources');
await runPollTick(intervalHandlers);
expect(screen.queryByText('75%')).not.toBeInTheDocument();
expect(fetchCount(handle, '/api/v1/media-sources')).toBe(sourceFetchesBeforeCompletion + 1);
// loadScanStatuses (the interval handler) kicks off its own Promise.all(...).then(...) chain
// (libraries.ts) rather than being itself async/awaitable, so `handler()` returns void - the
// state update that clears the progress bar and the follow-up loadSources() refetch both land a
// few microtask ticks after runPollTick's `act(async () => ...)` callback has already resolved.
// Asserting synchronously right after the tick races that chain (#447); waitFor lets both
// settle before asserting, without weakening either check.
await waitFor(() => {
expect(screen.queryByText('75%')).not.toBeInTheDocument();
});
await waitFor(() => {
expect(fetchCount(handle, '/api/v1/media-sources')).toBe(sourceFetchesBeforeCompletion + 1);
});
});
it('reconciles a 409 "already scanning" against scan-status without an error toast (#232)', async () => {
+23 -2
View File
@@ -394,7 +394,7 @@ function LibraryRow({
<div className="ctv-library-row-main">
<strong>{library.name}</strong>
<span><span>{formatLibraryMediaKind(library.mediaKind)}</span> · <code>{library.itemCount.toLocaleString()}</code> items</span>
<small>{library.lastScan ? `Last scan ${formatDateTime(library.lastScan)}` : 'Never scanned'}</small>
<small>{hasScanned(library.lastScan) ? `Last scan ${formatDateTime(library.lastScan)}` : 'Never scanned'}</small>
</div>
<div className="ctv-library-row-status">
{scanStatus ? (
@@ -465,12 +465,33 @@ function formatLibraryMediaKind(kind: string): string {
function sourceLastScanLabel(source: MediaSource): string {
const scans = source.libraries
.map((library) => library.lastScan)
.filter((scan): scan is string => Boolean(scan))
.filter((scan): scan is string => hasScanned(scan))
.sort();
return scans.length > 0 ? `Last scan ${formatDateTime(scans[scans.length - 1])}` : 'Never scanned';
}
// A never-scanned local library's LastScan is seeded as DateTime.MinValue (not NULL), which the API
// serialises as the truthy string "0001-01-01T00:00:00" - a plain `Boolean(scan)` truthiness check
// (the previous behavior here) treats that as a real scan and renders a bogus midnight time (#409).
// Detect the MinValue sentinel client-side alongside null/undefined so both render "Never scanned".
// (Server-side normalisation of the DTO was considered - see #409 - but the SPA already owns every
// consumer of this field, so a read-boundary fix here covers both call sites without touching the API.)
function hasScanned(scan: string | null | undefined): scan is string {
if (!scan) {
return false;
}
const parsed = new Date(scan);
if (Number.isNaN(parsed.getTime())) {
return false;
}
// DateTime.MinValue is year 0001; treat anything before year 1900 as "no real scan" rather than
// matching the exact sentinel string, so any equivalent epoch-ish placeholder is also caught.
return parsed.getUTCFullYear() >= 1900;
}
// Inlined rather than imported back from App.tsx (mirrors the ChannelsScreen #244 extraction):
// App.tsx's formatDateTime is still used by other App.tsx-owned screens (Playouts, Guide), so
// keeping this screen self-sufficient means duplicating the tiny formatter here.