Skip to content
Closed
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
147 changes: 147 additions & 0 deletions src/hooks/__tests__/useCardSettings.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
import { renderHook, act } from "@testing-library/react";
import { describe, it, expect, vi, beforeEach } from "vitest";
import { useCardSettings } from "../useCardSettings";
import * as cardSettingsLib from "@/lib/cardSettings";
import * as cardLayoutLib from "@/lib/cardLayout";

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Since we are removing the mock for @/lib/cardLayout and using the actual state changes in our assertions, the import of cardLayoutLib is no longer needed. Let's remove it.

import { DEFAULT_CARD_LAYOUT } from "@/lib/types";
import type { CardLayout } from "@/lib/types";

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Import CardBlockId from @/lib/types so we can use it for type casting in our tests instead of as any.

Suggested change
import type { CardLayout } from "@/lib/types";
import type { CardLayout, CardBlockId } from "@/lib/types";
References
  1. Maintain explicit return types for functions in TypeScript to ensure type safety and API clarity. If a type is only used for annotations, use 'import type' instead of removing the annotation to satisfy linting rules.


// Mock the external dependencies
vi.mock("@/lib/cardSettings", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/cardSettings")>();
return {
...actual,
loadCardSettings: vi.fn(),
saveCardSettings: vi.fn(),
};
});

vi.mock("@/lib/cardLayout", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/cardLayout")>();
return {
...actual,
toggleBlockVisibility: vi.fn(),
};
});
Comment on lines +19 to +25

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Mocking pure utility functions like toggleBlockVisibility from @/lib/cardLayout is an anti-pattern that introduces unnecessary complexity and type-safety workarounds (such as as any casting). Since toggleBlockVisibility is a pure function, we can use its actual implementation directly in the tests.

Let's remove this mock entirely.


describe("useCardSettings", () => {
const mockDefaultOptions = {
showAvatar: true,
showBio: true,
showStats: true,
showLanguage: true,
showRepos: true,
showCompany: true,
showLocation: true,
showWebsite: true,
showTwitter: true,
showJoinedDate: true,
showTopics: true,
showContributionBreakdown: true,
showStreaks: true,
showInterests: true,
showActivityBreakdown: true,
};

const mockLoadedSettings = {
layout: {
...DEFAULT_CARD_LAYOUT,
blocks: [
{ id: "profile", visible: true, column: "full" },
{ id: "stats", visible: false, column: "left" },
]
} as CardLayout,
Comment on lines +46 to +53

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The mock layout uses an incorrect property order instead of column, which is the actual property defined in the CardBlock type. Using the type assertion as CardLayout masks this type mismatch.

Let's update the mock layout to adhere to the actual CardBlock type structure by using column and removing the unsafe type assertion.

  const mockLoadedSettings = {
    layout: {
      ...DEFAULT_CARD_LAYOUT,
      blocks: [
        { id: "profile", visible: true, column: "full" },
        { id: "stats", visible: false, column: "left" },
      ]
    },

options: {
...mockDefaultOptions,
showAvatar: false,
},
};

beforeEach(() => {
vi.clearAllMocks();
vi.mocked(cardSettingsLib.loadCardSettings).mockReturnValue(mockLoadedSettings);
vi.mocked(cardLayoutLib.toggleBlockVisibility).mockImplementation(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(prev: any, id: any) => ({ ...prev, toggled: id }) as any
);
});

it("should initialize with values from loadCardSettings", () => {
const { result } = renderHook(() => useCardSettings(false));

expect(result.current.layout).toEqual(mockLoadedSettings.layout);
expect(result.current.displayOptions).toEqual(mockLoadedSettings.options);
expect(cardSettingsLib.loadCardSettings).toHaveBeenCalledTimes(2);
});

it("should not hydrate or save if not mounted", () => {
renderHook(() => useCardSettings(false));

// It's called once during state initialization, but the effect shouldn't re-call it or save
expect(cardSettingsLib.loadCardSettings).toHaveBeenCalledTimes(2);
expect(cardSettingsLib.saveCardSettings).not.toHaveBeenCalled();
});

it("should hydrate and update state when mounted changes to true", () => {
const { result, rerender } = renderHook(({ mounted }) => useCardSettings(mounted), {
initialProps: { mounted: false },
});

// Reset mock count to ignore initialization
vi.clearAllMocks();

rerender({ mounted: true });

expect(cardSettingsLib.loadCardSettings).toHaveBeenCalledTimes(1);
expect(result.current.layout).toEqual(mockLoadedSettings.layout);
expect(result.current.displayOptions).toEqual(mockLoadedSettings.options);
});

it("should save settings when layout or options change (if hydrated)", () => {
const { result } = renderHook(() => useCardSettings(true));

act(() => {
result.current.toggleDisplayOption("showAvatar");
});

// Once for initial hydration, once for the change
expect(cardSettingsLib.saveCardSettings).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({ showAvatar: true })
);
});

it("toggleMainBlockVisibility should update layout using toggleBlockVisibility", () => {
const { result } = renderHook(() => useCardSettings(true));

act(() => {
result.current.toggleMainBlockVisibility("stats");
});

expect(cardLayoutLib.toggleBlockVisibility).toHaveBeenCalledWith(
mockLoadedSettings.layout,
"stats"
);
expect(result.current.layout).toHaveProperty("toggled", "stats");
});
Comment on lines +114 to +126

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

With the mock of toggleBlockVisibility removed, we can assert on the actual state change of the layout blocks. This makes the test much more robust and type-safe.

  it("toggleMainBlockVisibility should update layout using toggleBlockVisibility", () => {
    const { result } = renderHook(() => useCardSettings(true));

    act(() => {
      result.current.toggleMainBlockVisibility("stats");
    });

    const statsBlock = result.current.layout.blocks.find((b) => b.id === "stats");
    expect(statsBlock?.visible).toBe(true);
  });


it("toggleDisplayOption should flip boolean values in displayOptions", () => {
const { result } = renderHook(() => useCardSettings(true));

expect(result.current.displayOptions.showBio).toBe(true);

act(() => {
result.current.toggleDisplayOption("showBio");
});

expect(result.current.displayOptions.showBio).toBe(false);
});

it("isBlockVisible should return true if block is visible, false otherwise", () => {
const { result } = renderHook(() => useCardSettings(true));

expect(result.current.isBlockVisible("profile")).toBe(true);
expect(result.current.isBlockVisible("stats")).toBe(false);
expect(result.current.isBlockVisible("non-existent-block" as import("@/lib/types").CardBlockId)).toBe(false);
});
});
1 change: 0 additions & 1 deletion src/hooks/useCardSettings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,6 @@ export function useCardSettings(mounted: boolean) {

// eslint-disable-next-line react-hooks/set-state-in-effect
setLayout((prev) => JSON.stringify(prev) !== JSON.stringify(storedLayout) ? storedLayout : prev);
// eslint-disable-next-line react-hooks/set-state-in-effect
setDisplayOptions((prev) => JSON.stringify(prev) !== JSON.stringify(storedOptions) ? storedOptions : prev);

setIsHydrated(true);
Expand Down
Loading