fix(409,447): LibrariesScreen 'Never scanned' label + stabilize flaky scan-poll test #451
@@ -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 () => {
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user