wrolpi/app/src/contexts/FileWorkerStatusContext.js
Roland 17d658fc0b
test: one place for fixtures, real providers instead of mocked context modules
Specs had no shared vocabulary for the app's data or its contexts, so each one
rebuilt both.  A settings object was written out by hand wherever one was needed,
twenty-odd keys at a time, and nothing held those copies to each other or to the
API.  Contexts were worse: to render a consumer a spec mocked the whole context
module, which replaces every export including the providers, so each spec
reinvented the pieces it still needed.  One supplies a `Media` that always
reports desktop -- quietly leaving that component's mobile branch untested -- and
one hand-writes a context as a bare `_currentValue` with no provider, working only
because useContext reads that React internal.

test-fixtures.js is now the single definition of both, shared by jest, the
Cypress component specs, and e2e intercept bodies, so the three cannot disagree
about what the API returns.  Every fixture is a function taking overrides; a
shared mutable object lets one test change what the next one sees.

The drift this prevents was already real.  The theme fixture supplied `i`, `s`
and `t` holding hardcoded Semantic greys for months after ThemeProvider stopped
supplying them, and ThemeContext still declared all four.  So the fixture agreed
with the tests, and with nothing else: FileBrowser was reading `inverted` off the
context and pasting the resulting `undefined` into a className, which is why the
file browser's footer -- near-white #f9fafb, with a `.inverted` dark variant that
nothing could select any more -- stayed light in dark, night and amber.  A white
bar pinned to the bottom of the screen in night mode costs the user exactly the
dark adaptation the theme exists to protect.  It takes tokens now.

test-fixtures.test.js holds each fixture to its context's declared shape in both
directions, so a property added to one and forgotten in the other fails a test.

Two failed attempts are recorded in test-utils.js because both were instructive.
Importing Tags.js and FileWorkerStatusContext.js so the harness could supply
those providers broke eight suites: it put both into the module graph of every
spec ahead of that spec's own mocks, and components bound to a second instance of
a mocked module -- a different jest.fn() than the test had configured, so a hook
mocked to return a value returned undefined, in a file no test mentions.
Requiring them lazily fixed that and not its mirror image: useContext read a
different TagsContext than the harness had required, so the provider was ignored
and the context default won.  A harness must not change what a spec imports, so
it owns only contexts/contexts.js and a spec passes any other context itself.

DomainTagging is converted off the `_currentValue` fake as proof, and now asserts
the tag name a user sees rather than a test id its own mock invented.  The five
remaining specs are listed in a ratchet so the count can only fall.

974 jest, 15 browser component, 46 e2e, clean cache-busted build, tsc clean.
Fixtures are confirmed absent from the shipped bundle.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 18:04:04 -06:00

73 lines
2.4 KiB
JavaScript

import React, {createContext, useContext, useState, useEffect, useCallback, useRef} from 'react';
import {fetchFilesProgress} from '../api';
/*
* Exported so a test can wrap a component in the real provider holding a fixture value. While
* this was private the only way to render a consumer was `jest.mock` on this whole module,
* which replaces the provider and the hooks together and has to be rewritten in every spec
* that needs it.
*/
export const FileWorkerStatusContext = createContext(null);
export function FileWorkerStatusProvider({children, pollInterval = 3000}) {
const [status, setStatus] = useState(null);
const [error, setError] = useState(null);
const requestedInterval = useRef(pollInterval);
const refresh = useCallback(async () => {
try {
const data = await fetchFilesProgress();
setStatus(data);
setError(null);
} catch (err) {
setError(err);
}
}, []);
// Allow components to request faster polling temporarily
const setFastPolling = useCallback((fast) => {
requestedInterval.current = fast ? 500 : pollInterval;
}, [pollInterval]);
useEffect(() => {
let timeoutId;
const poll = async () => {
await refresh();
timeoutId = setTimeout(poll, requestedInterval.current);
};
poll();
return () => clearTimeout(timeoutId);
}, [refresh]);
return (
<FileWorkerStatusContext.Provider value={{status, error, refresh, setFastPolling}}>
{children}
</FileWorkerStatusContext.Provider>
);
}
export function useFileWorkerStatus() {
const context = useContext(FileWorkerStatusContext);
if (!context) {
throw new Error('useFileWorkerStatus must be used within FileWorkerStatusProvider');
}
return context;
}
// Convenience hook for reorganization-specific status
export function useReorganizationStatus() {
const {status: progress, refresh} = useFileWorkerStatus();
const isReorganizing = progress?.status === 'reorganizing' ||
progress?.status === 'batch_reorganizing';
return {
isReorganizing,
taskType: progress?.task_type,
collectionId: progress?.collection_id,
collectionKind: progress?.collection_kind,
batchStatus: progress?.batch_status,
workerStatus: progress,
refresh,
};
}