Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 83 additions & 0 deletions frontend/src/tests/useDebounce.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
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");
});
});
81 changes: 81 additions & 0 deletions frontend/src/tests/useSessionState.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});