From 9e0da86d98c21272a18fe2c2cd588d4f8b0d6792 Mon Sep 17 00:00:00 2001 From: Helene Lotsberg Date: Tue, 6 Oct 2026 12:49:41 +0200 Subject: [PATCH 1/3] add test for useDebounce --- frontend/src/tests/useDebounce.test.ts | 84 ++++++++++++++++++++++++++ 1 file changed, 84 insertions(+) create mode 100644 frontend/src/tests/useDebounce.test.ts diff --git a/frontend/src/tests/useDebounce.test.ts b/frontend/src/tests/useDebounce.test.ts new file mode 100644 index 00000000..0ae049cc --- /dev/null +++ b/frontend/src/tests/useDebounce.test.ts @@ -0,0 +1,84 @@ +import { renderHook, act } from "@testing-library/react"; +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { useDebounce } from "../hooks/useDebounce"; + +describe("useDebounce", () => { + // fake timers let us move time forward without waiting for real + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + // the hook starts with the value it is given, with no delay + it("returns the initial value immediately", () => { + const { result } = renderHook(() => useDebounce("a", 800)); + + expect(result.current).toBe("a"); + }); + + // the new value should only show up after the delay has passed + it("updates the value only after the delay", () => { + // initialProps lets us change the value later with rerender() + const { result, rerender } = renderHook( + ({ value }) => useDebounce(value, 800), + { initialProps: { value: "a" } }, + ); + + rerender({ value: "ab" }); + expect(result.current).toBe("a"); // not updated yet + + // act() is needed because the timer updates React state + act(() => { + vi.advanceTimersByTime(800); + }); + expect(result.current).toBe("ab"); + }); + + // every new value restarts the timer, + // so the value only updates when the user has stopped typing + it("restarts the delay when the value changes quickly", () => { + const { result, rerender } = renderHook( + ({ value }) => useDebounce(value, 800), + { initialProps: { value: "a" } }, + ); + + rerender({ value: "ab" }); + act(() => { + vi.advanceTimersByTime(500); + }); + + rerender({ value: "abc" }); // new change, timer starts over + act(() => { + vi.advanceTimersByTime(500); + }); + expect(result.current).toBe("a"); // only 500 ms since the last change + + act(() => { + vi.advanceTimersByTime(300); + }); + expect(result.current).toBe("abc"); // 800 ms since the last change + }); + + // the hook has a default delay of 800 ms when none is given + it("uses 800 ms as the default delay", () => { + const { result, rerender } = renderHook( + ({ value }) => useDebounce(value), + { initialProps: { value: "a" } }, + ); + + rerender({ value: "b" }); + + act(() => { + vi.advanceTimersByTime(799); + }); + expect(result.current).toBe("a"); // 1 ms too early + + act(() => { + vi.advanceTimersByTime(1); + }); + expect(result.current).toBe("b"); + }); +}); \ No newline at end of file From 3790c3b91ba65a1b8689407e085728ce71de269a Mon Sep 17 00:00:00 2001 From: Helene Lotsberg Date: Tue, 6 Oct 2026 12:51:26 +0200 Subject: [PATCH 2/3] format with prettier --- frontend/src/tests/useDebounce.test.ts | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/frontend/src/tests/useDebounce.test.ts b/frontend/src/tests/useDebounce.test.ts index 0ae049cc..1692a7e6 100644 --- a/frontend/src/tests/useDebounce.test.ts +++ b/frontend/src/tests/useDebounce.test.ts @@ -64,10 +64,9 @@ describe("useDebounce", () => { // the hook has a default delay of 800 ms when none is given it("uses 800 ms as the default delay", () => { - const { result, rerender } = renderHook( - ({ value }) => useDebounce(value), - { initialProps: { value: "a" } }, - ); + const { result, rerender } = renderHook(({ value }) => useDebounce(value), { + initialProps: { value: "a" }, + }); rerender({ value: "b" }); @@ -81,4 +80,4 @@ describe("useDebounce", () => { }); expect(result.current).toBe("b"); }); -}); \ No newline at end of file +}); From 49b9f1e0696be5f33b645be5d71bada62cd3e655 Mon Sep 17 00:00:00 2001 From: Helene Lotsberg Date: Tue, 6 Oct 2026 12:58:59 +0200 Subject: [PATCH 3/3] add test for useSessionState and format with lint and prettier --- frontend/src/tests/useSessionState.test.ts | 81 ++++++++++++++++++++++ 1 file changed, 81 insertions(+) create mode 100644 frontend/src/tests/useSessionState.test.ts diff --git a/frontend/src/tests/useSessionState.test.ts b/frontend/src/tests/useSessionState.test.ts new file mode 100644 index 00000000..ec4121f1 --- /dev/null +++ b/frontend/src/tests/useSessionState.test.ts @@ -0,0 +1,81 @@ +import { renderHook, act } from "@testing-library/react"; +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { useSessionState } from "../hooks/useSessionState"; + +describe("useSessionState", () => { + // start every test with empty storage so tests don't affect each other + beforeEach(() => { + sessionStorage.clear(); + }); + + // remove the spies created with vi.spyOn in the error tests + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("has an empty string as the default value", () => { + const { result } = renderHook(() => useSessionState("test.key")); + + expect(result.current[0]).toBe(""); + }); + + it("uses initialValue when nothing is stored", () => { + const { result } = renderHook(() => useSessionState("test.key", "default")); + + expect(result.current[0]).toBe("default"); + }); + + it("reads an existing value from sessionStorage", () => { + sessionStorage.setItem("test.key", "monstera"); + + const { result } = renderHook(() => useSessionState("test.key", "default")); + + // the stored value wins over initialValue + expect(result.current[0]).toBe("monstera"); + }); + + it("saves a new value in sessionStorage", () => { + const { result } = renderHook(() => useSessionState("test.key")); + + // result.current is [value, setValue] + act(() => result.current[1]("monstera")); + + expect(result.current[0]).toBe("monstera"); + expect(sessionStorage.getItem("test.key")).toBe("monstera"); + }); + + // simulates a page refresh: the hook unmounts and mounts again + it("remembers the value when the hook is mounted again", () => { + const first = renderHook(() => useSessionState("test.key")); + act(() => first.result.current[1]("monstera")); + first.unmount(); + + const second = renderHook(() => useSessionState("test.key")); + + expect(second.result.current[0]).toBe("monstera"); + }); + + // vi.spyOn makes sessionStorage throw errors on purpose, + // to check that the hook keeps working when storage is blocked or full + it("falls back to initialValue when reading storage fails", () => { + vi.spyOn(Storage.prototype, "getItem").mockImplementation(() => { + throw new Error("Storage unavailable"); + }); + + const { result } = renderHook(() => useSessionState("test.key", "default")); + + expect(result.current[0]).toBe("default"); + }); + + // setItem throws on purpose; if saving fails, the value should still live in React state + it("keeps working when saving to storage fails", () => { + vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => { + throw new Error("Quota exceeded"); + }); + + const { result } = renderHook(() => useSessionState("test.key")); + act(() => result.current[1]("monstera")); + + expect(result.current[0]).toBe("monstera"); + }); +});