From 5e18cea38d4fb5060bc2e8e1ffc8ae8db0dfddf1 Mon Sep 17 00:00:00 2001 From: Erik Hjelm Fjeldheim Date: Wed, 9 Sep 2026 10:15:32 -0600 Subject: [PATCH] feat: wire subject filter into the viewer with index reset (#22) - App: hardcoded SUBJECT replaced by usePreferences (sessionStorage, t19.preferences); SubjectFilter rendered above the viewer - subject change resets the book index so the stale index can never point outside the new list - test setup: sessionStorage and localStorage cleared between tests - tests: subject change refetches exactly once for the new subject, choice restored from sessionStorage on mount, corrupt storage falls back to the default subject; snapshot updated deliberately Partial #22: subject filter wired; sort (#20) and favorites (#21) wiring follows when those merge. --- src/App.test.tsx | 93 ++++++++++++++++++++++------- src/App.tsx | 25 +++++++- src/__snapshots__/App.test.tsx.snap | 51 ++++++++++++++++ src/test/setup.ts | 2 + 4 files changed, 149 insertions(+), 22 deletions(-) diff --git a/src/App.test.tsx b/src/App.test.tsx index a613eef..17f8c33 100644 --- a/src/App.test.tsx +++ b/src/App.test.tsx @@ -1,22 +1,26 @@ -import { beforeEach, describe, expect, it } from "vitest"; +import { describe, expect, it } from "vitest"; import { http, HttpResponse, delay } from "msw"; -import { fireEvent, screen, waitFor } from "@testing-library/react"; +import { screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; import App from "./App"; import { withProviders } from "./test/utils"; import { server } from "./test/server"; import { emptySearchHandler, failingSearchHandler, + fixtureDocs, getSearchRequestCount, resetSearchRequestCount, } from "./test/handlers"; -describe("App", () => { - beforeEach(() => { - sessionStorage.clear(); - resetSearchRequestCount(); +function subjectCapturingHandler(subjects: string[]) { + return http.get("https://openlibrary.org/search.json", ({ request }) => { + subjects.push(new URL(request.url).searchParams.get("subject") ?? ""); + return HttpResponse.json({ numFound: fixtureDocs.length, start: 0, docs: fixtureDocs }); }); +} +describe("App", () => { it("shows a loading indicator while the request is in flight", async () => { server.use( http.get("https://openlibrary.org/search.json", async () => { @@ -47,32 +51,81 @@ describe("App", () => { expect(await screen.findByText(/No books found/)).toBeInTheDocument(); }); - it("fetches once more when the subject changes", async () => { + it("navigates to the next and previous book with the controls", async () => { + const user = userEvent.setup(); withProviders(); await screen.findByRole("heading", { name: "The Hobbit" }); - fireEvent.change(screen.getByRole("combobox", { name: "Subject" }), { - target: { value: "mystery" }, - }); + await user.click(screen.getByRole("button", { name: "Show next book" })); + expect( + await screen.findByRole("heading", { name: "A Wizard of Earthsea" }), + ).toBeInTheDocument(); + expect(screen.getByText("2 / 3")).toBeInTheDocument(); - await waitFor(() => expect(getSearchRequestCount()).toBe(2)); - expect(JSON.parse(sessionStorage.getItem("t19.preferences") ?? "{}")).toMatchObject({ - subject: "mystery", - }); + await user.click(screen.getByRole("button", { name: "Show previous book" })); + expect(await screen.findByRole("heading", { name: "The Hobbit" })).toBeInTheDocument(); + expect(screen.getByText("1 / 3")).toBeInTheDocument(); }); - it("restores the selected subject from session storage", async () => { - sessionStorage.setItem("t19.preferences", JSON.stringify({ subject: "mystery" })); + it("jumps directly to a book from the list", async () => { + const user = userEvent.setup(); withProviders(); + await screen.findByRole("heading", { name: "The Hobbit" }); - expect(screen.getByRole("combobox", { name: "Subject" })).toHaveValue("mystery"); - await waitFor(() => expect(getSearchRequestCount()).toBe(1)); + await user.selectOptions(screen.getByLabelText("Jump to book"), "2"); + expect( + await screen.findByRole("heading", { name: "The Left Hand of Darkness" }), + ).toBeInTheDocument(); + expect(screen.getByText("3 / 3")).toBeInTheDocument(); }); - it("uses the default subject when stored preferences are corrupt", () => { - sessionStorage.setItem("t19.preferences", "not valid json"); + it("does not refetch while navigating between books", async () => { + resetSearchRequestCount(); + const user = userEvent.setup(); withProviders(); + await screen.findByRole("heading", { name: "The Hobbit" }); + + await user.click(screen.getByRole("button", { name: "Show next book" })); + await screen.findByRole("heading", { name: "A Wizard of Earthsea" }); + await user.click(screen.getByRole("button", { name: "Show previous book" })); + await screen.findByRole("heading", { name: "The Hobbit" }); + await user.selectOptions(screen.getByLabelText("Jump to book"), "2"); + await screen.findByRole("heading", { name: "The Left Hand of Darkness" }); + expect(getSearchRequestCount()).toBe(1); + }); + + it("changes the subject, refetches once, and resets to the first book", async () => { + const subjects: string[] = []; + server.use(subjectCapturingHandler(subjects)); + const user = userEvent.setup(); + withProviders(); + await screen.findByRole("heading", { name: "The Hobbit" }); + + await user.selectOptions(screen.getByRole("combobox", { name: "Subject" }), "mystery"); + expect(await screen.findByRole("heading", { name: "The Hobbit" })).toBeInTheDocument(); + expect(subjects).toEqual(["fantasy", "mystery"]); + }); + + it("restores the subject from sessionStorage", async () => { + const subjects: string[] = []; + server.use(subjectCapturingHandler(subjects)); + sessionStorage.setItem("t19.preferences", JSON.stringify({ subject: "mystery" })); + + withProviders(); + await screen.findByRole("heading", { name: "The Hobbit" }); + expect(subjects).toEqual(["mystery"]); + expect(screen.getByRole("combobox", { name: "Subject" })).toHaveValue("mystery"); + }); + + it("falls back to the default subject on corrupt sessionStorage", async () => { + const subjects: string[] = []; + server.use(subjectCapturingHandler(subjects)); + sessionStorage.setItem("t19.preferences", "{not valid json"); + + withProviders(); + await screen.findByRole("heading", { name: "The Hobbit" }); + expect(subjects).toEqual(["fantasy"]); expect(screen.getByRole("combobox", { name: "Subject" })).toHaveValue("fantasy"); }); diff --git a/src/App.tsx b/src/App.tsx index f566c60..c191293 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,4 +1,7 @@ +import { useState } from "react"; import { BookCard } from "./components/BookCard"; +import { BookJumpList } from "./components/BookJumpList"; +import { NavigationControls } from "./components/NavigationControls"; import { SubjectFilter } from "./components/SubjectFilter"; import { useBooks } from "./hooks/useBooks"; import { usePreferences } from "./hooks/usePreferences"; @@ -7,7 +10,14 @@ import "./App.css"; function App() { const { subject, setSubject } = usePreferences(); const { books, isLoading, isError } = useBooks(subject); - const current = books[0]; + const [index, setIndex] = useState(0); + const safeIndex = books.length > 0 ? Math.min(index, books.length - 1) : 0; + const current = books[safeIndex]; + + function handleSubjectChange(nextSubject: string) { + setSubject(nextSubject); + setIndex(0); + } return (
@@ -16,7 +26,7 @@ function App() {

Browse reading material from OpenLibrary, one book at a time.

- + {isLoading &&

Loading books…

} {isError &&

Could not load books. Please try refreshing the page.

} {!isLoading && !isError && books.length === 0 && ( @@ -25,6 +35,17 @@ function App() { {current && (
+ setIndex(Math.max(0, safeIndex - 1))} + onNext={() => setIndex(Math.min(books.length - 1, safeIndex + 1))} + /> + book.title)} + currentIndex={safeIndex} + onSelect={setIndex} + />
)}
diff --git a/src/__snapshots__/App.test.tsx.snap b/src/__snapshots__/App.test.tsx.snap index 4c98b2e..10bad78 100644 --- a/src/__snapshots__/App.test.tsx.snap +++ b/src/__snapshots__/App.test.tsx.snap @@ -101,6 +101,57 @@ exports[`App > matches the snapshot 1`] = `
+ +