diff --git a/src/__tests__/features/opds/useOpdsScreenOrchestration.test.tsx b/src/__tests__/features/opds/useOpdsScreenOrchestration.test.tsx new file mode 100644 index 0000000..2783f87 --- /dev/null +++ b/src/__tests__/features/opds/useOpdsScreenOrchestration.test.tsx @@ -0,0 +1,141 @@ +import { act, renderHook } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { + type UseOpdsScreenOrchestrationParams, + useOpdsScreenOrchestration, +} from "@/features/opds/useOpdsScreenOrchestration"; +import { savedCatalogsService } from "@/services/savedCatalogs"; +import type { Publication } from "@/types/opds"; + +vi.mock("@/services/savedCatalogs", () => ({ + savedCatalogsService: { + save: vi.fn(), + }, +})); + +const makePublication = (id: string): Publication => ({ + id, + title: "Dune", + authors: [], + languages: ["en"], + categories: [], + relations: [], + descriptions: [], + identifiers: {}, + links: [], +}); + +describe("useOpdsScreenOrchestration", () => { + let connection: UseOpdsScreenOrchestrationParams["connection"]; + let downloads: UseOpdsScreenOrchestrationParams["downloads"]; + + beforeEach(() => { + connection = { + url: "https://example.com/opds", + username: "alice", + disconnect: vi.fn(), + }; + downloads = { + clearDownloads: vi.fn(), + }; + vi.clearAllMocks(); + }); + + const render = () => + renderHook((props: UseOpdsScreenOrchestrationParams) => useOpdsScreenOrchestration(props), { + initialProps: { connection, downloads }, + }); + + describe("disconnect orchestration", () => { + it("disconnects the connection and clears the download registry", () => { + const { result } = render(); + + act(() => { + result.current.handleDisconnect(); + }); + + expect(connection.disconnect).toHaveBeenCalledTimes(1); + expect(downloads.clearDownloads).toHaveBeenCalledTimes(1); + }); + }); + + describe("save-catalog orchestration", () => { + it("persists the catalog with a non-empty fallback name and bumps the refresh key", async () => { + vi.mocked(savedCatalogsService.save).mockResolvedValue({ + id: "cat-1", + name: "https://example.com/opds", + url: "https://example.com/opds", + username: "alice", + added_at: "2026-08-28T00:00:00Z", + }); + const { result } = render(); + expect(result.current.savedCatalogsKey).toBe(0); + + await act(async () => { + await result.current.handleSaveCatalog(); + }); + + expect(savedCatalogsService.save).toHaveBeenCalledWith( + "https://example.com/opds", + connection.url, + connection.username, + ); + expect(result.current.savedCatalogsKey).toBe(1); + }); + + it("uses 'Untitled catalog' when the url trims to empty", async () => { + vi.mocked(savedCatalogsService.save).mockResolvedValue({ + id: "cat-2", + name: "Untitled catalog", + url: "", + username: connection.username, + added_at: "2026-08-28T00:00:00Z", + }); + const { result } = renderHook( + (props: UseOpdsScreenOrchestrationParams) => useOpdsScreenOrchestration(props), + { + initialProps: { + connection: { ...connection, url: " " }, + downloads, + }, + }, + ); + + await act(async () => { + await result.current.handleSaveCatalog(); + }); + + expect(savedCatalogsService.save).toHaveBeenCalledWith( + "Untitled catalog", + " ", + connection.username, + ); + }); + + it("is best-effort: swallows a save failure without bumping the refresh key", async () => { + vi.mocked(savedCatalogsService.save).mockRejectedValue(new Error("backend down")); + const { result } = render(); + + await expect(result.current.handleSaveCatalog()).resolves.toBeUndefined(); + expect(result.current.savedCatalogsKey).toBe(0); + }); + }); + + describe("detail publication state", () => { + it("opens and closes the detail modal on the selected publication", () => { + const { result } = render(); + expect(result.current.detailPublication).toBeNull(); + const pub = makePublication("pub-detail"); + + act(() => { + result.current.openDetail(pub); + }); + expect(result.current.detailPublication).toBe(pub); + + act(() => { + result.current.closeDetail(); + }); + expect(result.current.detailPublication).toBeNull(); + }); + }); +}); diff --git a/src/features/opds/OpdsCatalogScreenContainer.tsx b/src/features/opds/OpdsCatalogScreenContainer.tsx index fddbb7b..96cda96 100644 --- a/src/features/opds/OpdsCatalogScreenContainer.tsx +++ b/src/features/opds/OpdsCatalogScreenContainer.tsx @@ -1,8 +1,5 @@ import type React from "react"; -import { useCallback, useState } from "react"; import { useOpdsCatalog } from "@/hooks/useOpdsCatalog"; -import { savedCatalogsService } from "@/services/savedCatalogs"; -import type { Publication } from "@/types/opds"; import { OpdsCatalogScreen } from "./OpdsCatalogScreen"; import { PublicationDetailModal } from "./PublicationDetailModal"; import { SavedCatalogsManager } from "./SavedCatalogsManager"; @@ -10,13 +7,11 @@ import { useCatalogConnection } from "./useCatalogConnection"; import { useDownloadRegistry } from "./useDownloadRegistry"; import { useOfflineLibraryState } from "./useOfflineLibraryState"; import { useOpdsDownload } from "./useOpdsDownload"; +import { useOpdsScreenOrchestration } from "./useOpdsScreenOrchestration"; const OpdsCatalogScreenContainer: React.FC = () => { const { status, error, localPath, mediaType, progress, startDownload } = useOpdsDownload(); - const [detailPublication, setDetailPublication] = useState(null); - const [savedCatalogsKey, setSavedCatalogsKey] = useState(0); - const connection = useCatalogConnection(); const offline = useOfflineLibraryState({ connected: connection.connected, @@ -40,24 +35,7 @@ const OpdsCatalogScreenContainer: React.FC = () => { connection.page, connection.connected, ); - - const handleDisconnect = useCallback(() => { - connection.disconnect(); - downloads.clearDownloads(); - }, [connection.disconnect, downloads.clearDownloads]); - - const handleSaveCatalog = useCallback(async () => { - try { - await savedCatalogsService.save( - connection.url.trim() || "Untitled catalog", - connection.url, - connection.username, - ); - setSavedCatalogsKey((k) => k + 1); - } catch { - // Non-fatal: saving the catalog is best-effort. - } - }, [connection]); + const orchestration = useOpdsScreenOrchestration({ connection, downloads }); return ( <> @@ -70,7 +48,7 @@ const OpdsCatalogScreenContainer: React.FC = () => { onPasswordChange={connection.setPassword} connected={connection.connected} onConnect={connection.connect} - onDisconnect={handleDisconnect} + onDisconnect={orchestration.handleDisconnect} catalog={catalogQuery.data} loading={catalogQuery.isFetching} error={catalogQuery.error?.message ?? null} @@ -87,27 +65,29 @@ const OpdsCatalogScreenContainer: React.FC = () => { deletingRevisionId={offline.deletingRevisionId} onDeleteLocal={offline.handleDeleteLocal} onRefreshLibrary={offline.handleRefreshLibrary} - onViewDetails={setDetailPublication} - onSaveCatalog={handleSaveCatalog} + onViewDetails={orchestration.openDetail} + onSaveCatalog={orchestration.handleSaveCatalog} savedCatalogs={ } /> - {detailPublication && ( + {orchestration.detailPublication && ( setDetailPublication(null)} + publication={orchestration.detailPublication} + onClose={orchestration.closeDetail} catalogUrl={connection.url.trim()} transientUsername={connection.username} transientPassword={connection.password} contentRoot={connection.contentRoot} onDownload={downloads.handleDownload} - downloadStatus={downloads.downloadStatuses[detailPublication.id]} - downloadErrorMessage={downloads.downloadErrors[detailPublication.id]} - libraryInfo={offline.libraryInfoByPublicationId[detailPublication.id] ?? null} + downloadStatus={downloads.downloadStatuses[orchestration.detailPublication.id]} + downloadErrorMessage={downloads.downloadErrors[orchestration.detailPublication.id]} + libraryInfo={ + offline.libraryInfoByPublicationId[orchestration.detailPublication.id] ?? null + } /> )} diff --git a/src/features/opds/index.ts b/src/features/opds/index.ts index 5f46382..74f59f5 100644 --- a/src/features/opds/index.ts +++ b/src/features/opds/index.ts @@ -8,4 +8,5 @@ export { useCatalogConnection } from "./useCatalogConnection"; export { useDownloadRegistry } from "./useDownloadRegistry"; export { useOfflineLibraryState } from "./useOfflineLibraryState"; export { useOpdsDownload } from "./useOpdsDownload"; +export { useOpdsScreenOrchestration } from "./useOpdsScreenOrchestration"; export { usePublicationState } from "./usePublicationState"; diff --git a/src/features/opds/useOpdsScreenOrchestration.ts b/src/features/opds/useOpdsScreenOrchestration.ts new file mode 100644 index 0000000..98e5e85 --- /dev/null +++ b/src/features/opds/useOpdsScreenOrchestration.ts @@ -0,0 +1,62 @@ +import { useCallback, useState } from "react"; +import { savedCatalogsService } from "@/services/savedCatalogs"; +import type { Publication } from "@/types/opds"; +import type { UseCatalogConnectionResult } from "./useCatalogConnection"; +import type { UseDownloadRegistryResult } from "./useDownloadRegistry"; + +export interface UseOpdsScreenOrchestrationParams { + /** Catalog connection state; only the fields consumed here are used. */ + connection: Pick; + /** Download registry; only the fields consumed here are used. */ + downloads: Pick; +} + +/** + * Screen-level orchestration for the OPDS catalog: the selected detail + * publication, the saved-catalog refresh trigger, and the disconnect / + * save-catalog callbacks that coordinate connection and download state. + */ +export function useOpdsScreenOrchestration({ + connection, + downloads, +}: UseOpdsScreenOrchestrationParams) { + const [detailPublication, setDetailPublication] = useState(null); + const [savedCatalogsKey, setSavedCatalogsKey] = useState(0); + + const handleDisconnect = useCallback(() => { + connection.disconnect(); + downloads.clearDownloads(); + }, [connection.disconnect, downloads.clearDownloads]); + + const handleSaveCatalog = useCallback(async () => { + try { + await savedCatalogsService.save( + connection.url.trim() || "Untitled catalog", + connection.url, + connection.username, + ); + setSavedCatalogsKey((k) => k + 1); + } catch { + // Non-fatal: saving the catalog is best-effort. + } + }, [connection]); + + const openDetail = useCallback((publication: Publication) => { + setDetailPublication(publication); + }, []); + + const closeDetail = useCallback(() => { + setDetailPublication(null); + }, []); + + return { + detailPublication, + openDetail, + closeDetail, + savedCatalogsKey, + handleDisconnect, + handleSaveCatalog, + }; +} + +export type UseOpdsScreenOrchestrationResult = ReturnType;