({
useParams: () => ({ sections: mockSections }),
useRouter: () => ({ push: mockPush }),
usePathname: () => "/" + mockSections.join("/"),
- useSearchParams: () => new URLSearchParams()
+ useSearchParams: () => new URLSearchParams(mockQuery)
}));
import { EntryIndexMenu } from "@/app/_components/entry-index-menu";
@@ -42,12 +44,13 @@ const SORT = {
trending: "entry-filter.filter-trending",
hot: "entry-filter.filter-hot",
created: "entry-filter.filter-created",
- top: "entry-filter.filter-top"
+ payout: "g.payout"
};
describe("EntryIndexMenu — Source × Sort filter bar", () => {
beforeEach(() => {
mockSections = [];
+ mockQuery = "";
mockPush.mockClear();
setLoggedIn(false);
});
@@ -58,11 +61,13 @@ describe("EntryIndexMenu — Source × Sort filter bar", () => {
expect(screen.queryByRole("link", { name: SOURCE.following })).toBeNull();
expect(screen.queryByRole("link", { name: SOURCE.communities })).toBeNull();
- expect(screen.getByRole("link", { name: SORT.trending }).getAttribute("href")).toBe("/trending");
+ expect(screen.getByRole("link", { name: SORT.trending }).getAttribute("href")).toBe(
+ "/trending"
+ );
expect(screen.getByRole("link", { name: SORT.hot }).getAttribute("href")).toBe("/hot");
expect(screen.getByRole("link", { name: SORT.created }).getAttribute("href")).toBe("/created");
- // "Top" surfaces the payout sort out of the overflow menu.
- expect(screen.getByRole("link", { name: SORT.top }).getAttribute("href")).toBe("/payout");
+ // The payout label uses the existing singular translation.
+ expect(screen.getByRole("link", { name: SORT.payout }).getAttribute("href")).toBe("/payout");
});
it("shows Following, Communities and Global as sources once logged in", () => {
@@ -94,9 +99,11 @@ describe("EntryIndexMenu — Source × Sort filter bar", () => {
expect(screen.getByRole("link", { name: SORT.trending }).getAttribute("href")).toBe(
"/trending/my"
);
- expect(screen.getByRole("link", { name: SORT.top }).getAttribute("href")).toBe("/payout/my");
+ expect(screen.getByRole("link", { name: SORT.payout }).getAttribute("href")).toBe("/payout/my");
// Global drops the tag, keeping the current sort.
- expect(screen.getByRole("link", { name: SOURCE.global }).getAttribute("href")).toBe("/trending");
+ expect(screen.getByRole("link", { name: SOURCE.global }).getAttribute("href")).toBe(
+ "/trending"
+ );
});
it("hides sorts and shows the reblog toggle on the Following feed", () => {
@@ -105,15 +112,15 @@ describe("EntryIndexMenu — Source × Sort filter bar", () => {
render();
expect(screen.queryByRole("link", { name: SORT.trending })).toBeNull();
- expect(screen.queryByRole("link", { name: SORT.top })).toBeNull();
+ expect(screen.queryByRole("link", { name: SORT.payout })).toBeNull();
- expect(
- screen.getByRole("link", { name: SOURCE.following }).getAttribute("aria-current")
- ).toBe("page");
- // Reblog toggle replaces the sort group (rendered for both desktop and mobile).
- expect(
- screen.getAllByRole("button", { name: "entry-filter.filter-no-reblog" }).length
- ).toBeGreaterThan(0);
+ expect(screen.getByRole("link", { name: SOURCE.following }).getAttribute("aria-current")).toBe(
+ "page"
+ );
+ expect(screen.getByRole("switch", { name: "entry-filter.show-reblogs" })).toHaveAttribute(
+ "aria-checked",
+ "true"
+ );
});
it("does not strand logged-out visitors on a feed URL — sorts stay as an escape", () => {
@@ -124,17 +131,53 @@ describe("EntryIndexMenu — Source × Sort filter bar", () => {
// Not treated as the personal Following feed, so the sort tabs remain
// navigable instead of collapsing to a lone reblog toggle.
expect(screen.getByRole("link", { name: SORT.hot }).getAttribute("href")).toBe("/hot");
- expect(screen.getByRole("link", { name: SORT.trending }).getAttribute("href")).toBe("/trending");
- expect(screen.queryByRole("button", { name: "entry-filter.filter-no-reblog" })).toBeNull();
+ expect(screen.getByRole("link", { name: SORT.trending }).getAttribute("href")).toBe(
+ "/trending"
+ );
+ expect(screen.queryByRole("switch", { name: "entry-filter.show-reblogs" })).toBeNull();
+ });
+
+ it("excludes reblogs without dropping unrelated query parameters", () => {
+ setLoggedIn(true);
+ mockSections = ["feed", "@alice"];
+ mockQuery = "view=compact&test=a%26b";
+ render();
+ fireEvent.click(screen.getByRole("switch", { name: "entry-filter.show-reblogs" }));
+ expect(mockPush).toHaveBeenCalledWith("/feed/@alice?view=compact&test=a%26b&no-reblog=true");
+ });
+
+ it("reflects a bookmarked exclusion and only removes that filter when enabled", () => {
+ setLoggedIn(true);
+ mockSections = ["feed", "@alice"];
+ mockQuery = "no-reblog=true&view=compact";
+ render();
+ const toggle = screen.getByRole("switch", { name: "entry-filter.show-reblogs" });
+ expect(toggle).toHaveAttribute("aria-checked", "false");
+ fireEvent.click(toggle);
+ expect(mockPush).toHaveBeenCalledWith("/feed/@alice?view=compact");
});
- it("labels the mobile sort trigger with the active overflow filter", () => {
- mockSections = ["muted"];
+ it("does not leave a trailing question mark after enabling reblogs", () => {
+ setLoggedIn(true);
+ mockSections = ["feed", "@alice"];
+ mockQuery = "no-reblog=true";
render();
+ fireEvent.click(screen.getByRole("switch", { name: "entry-filter.show-reblogs" }));
+ expect(mockPush).toHaveBeenCalledWith("/feed/@alice");
+ });
- // Muted lives in the overflow menu, but the closed mobile trigger must still
+ it.each([
+ ["muted", "entry-filter.filter-muted"],
+ ["promoted", "entry-filter.filter-promoted"],
+ ["payout", "g.payout"]
+ ])("marks %s as active in desktop tabs and the mobile trigger", (filter, label) => {
+ mockSections = [filter];
+ render();
+
+ // The closed mobile trigger must still
// reflect it rather than falling back to the generic "Sort by" label.
- expect(screen.getByRole("button", { name: "entry-filter.filter-muted" })).toBeTruthy();
+ expect(screen.getByRole("button", { name: label })).toBeTruthy();
+ expect(screen.getByRole("link", { name: label })).toHaveAttribute("aria-current", "page");
});
it("shows a hashtag chip, leaves Global unselected and keeps the tag on sorts", () => {
@@ -146,14 +189,18 @@ describe("EntryIndexMenu — Source × Sort filter bar", () => {
const chip = screen.getByRole("link", { name: "#photography" });
expect(chip.getAttribute("aria-current")).toBe("page");
// ...so Global must NOT read as selected.
- expect(screen.getByRole("link", { name: SOURCE.global }).getAttribute("aria-current")).toBeNull();
- expect(screen.getByRole("link", { name: SOURCE.global }).getAttribute("href")).toBe("/trending");
+ expect(
+ screen.getByRole("link", { name: SOURCE.global }).getAttribute("aria-current")
+ ).toBeNull();
+ expect(screen.getByRole("link", { name: SOURCE.global }).getAttribute("href")).toBe(
+ "/trending"
+ );
// Sort tabs stay and preserve the hashtag.
expect(screen.getByRole("link", { name: SORT.hot }).getAttribute("href")).toBe(
"/hot/photography"
);
- expect(screen.getByRole("link", { name: SORT.top }).getAttribute("href")).toBe(
+ expect(screen.getByRole("link", { name: SORT.payout }).getAttribute("href")).toBe(
"/payout/photography"
);
});
@@ -171,12 +218,12 @@ describe("EntryIndexMenu — Source × Sort filter bar", () => {
expect(screen.queryByRole("link", { name: SOURCE.communities })).toBeNull();
});
- it("exposes Muted and Promoted behind the More filters menu", () => {
+ it("exposes Muted and Promoted directly without a More filters menu", () => {
setLoggedIn(true);
mockSections = ["hot"];
render();
- fireEvent.click(screen.getByRole("button", { name: "entry-filter.more-filters" }));
+ expect(screen.queryByRole("button", { name: "entry-filter.more-filters" })).toBeNull();
expect(
screen.getByRole("link", { name: "entry-filter.filter-muted" }).getAttribute("href")
diff --git a/apps/web/src/specs/app/top-communities-widget.spec.tsx b/apps/web/src/specs/app/top-communities-widget.spec.tsx
new file mode 100644
index 0000000000..8d4d522e9d
--- /dev/null
+++ b/apps/web/src/specs/app/top-communities-widget.spec.tsx
@@ -0,0 +1,135 @@
+import React from "react";
+import "@testing-library/jest-dom";
+import { fireEvent, screen, waitFor } from "@testing-library/react";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { renderWithQueryClient, mockCommunity } from "@/specs/test-utils";
+import type { Community } from "@/entities";
+
+const subscribe = vi.fn(async () => undefined);
+const unsubscribe = vi.fn(async () => undefined);
+let ranked: Community[] = [];
+let town: Community | null = null;
+let failTown = false;
+let failRanked = false;
+let subscriptions: string[][] = [];
+
+vi.mock("@/core/hooks/use-active-account", () => ({
+ useActiveAccount: () => ({ activeUser: { username: "alice" } })
+}));
+vi.mock("@/features/shared", () => ({
+ LoginRequired: ({ children }: React.PropsWithChildren) => <>{children}>
+}));
+vi.mock("@/features/shared/user-avatar", () => ({
+ UserAvatar: () =>
+}));
+vi.mock("@/utils", async () => ({
+ ...(await vi.importActual("@/utils"))
+}));
+vi.mock("@/core/caches", () => ({
+ getCommunityCache: () => ({
+ queryKey: ["town"],
+ queryFn: async () => {
+ if (failTown) throw new Error("Town Square unavailable");
+ return town;
+ }
+ })
+}));
+vi.mock("@ecency/sdk", async () => ({
+ ...(await vi.importActual("@ecency/sdk")),
+ getCommunitiesQueryOptions: () => ({
+ queryKey: ["ranked"],
+ queryFn: async () => {
+ if (failRanked) throw new Error("Ranked communities unavailable");
+ return ranked;
+ }
+ }),
+ getAccountSubscriptionsQueryOptions: () => ({
+ queryKey: ["subscriptions"],
+ queryFn: async () => subscriptions
+ })
+}));
+vi.mock("@/api/sdk-mutations", () => ({
+ useSubscribeCommunityMutation: () => ({ mutateAsync: subscribe, isPending: false }),
+ useUnsubscribeCommunityMutation: () => ({ mutateAsync: unsubscribe, isPending: false })
+}));
+
+import { TopCommunitiesWidget } from "@/app/_components/top-communities-widget";
+
+describe("feed community suggestions", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ failTown = false;
+ failRanked = false;
+ subscriptions = [];
+ town = mockCommunity({ name: "hive-125125", title: "Town Square" });
+ ranked = [
+ town,
+ mockCommunity({ name: "hive-100001", title: "Photography" }),
+ mockCommunity({ name: "hive-100002", title: "Gaming" }),
+ mockCommunity({ name: "hive-100003", title: "Music" })
+ ];
+ });
+
+ it("shows three distinct communities and keeps discovery and creation reachable", async () => {
+ renderWithQueryClient();
+ await screen.findByRole("link", { name: "Gaming" });
+ expect(screen.getAllByRole("link", { name: "Town Square" })).toHaveLength(1);
+ expect(screen.queryByRole("link", { name: "Music" })).toBeNull();
+ expect(screen.getByRole("link", { name: "Photography" })).toHaveAttribute(
+ "href",
+ "/created/hive-100001"
+ );
+ expect(screen.getByRole("link", { name: "top-communities.explore" })).toHaveAttribute(
+ "href",
+ "/communities"
+ );
+ expect(screen.getByRole("link", { name: "top-communities.create-button" })).toHaveAttribute(
+ "href",
+ "/communities/create"
+ );
+ });
+
+ it("renders the ranked list even when the Town Square request fails", async () => {
+ failTown = true;
+ ranked = ranked.slice(1);
+ renderWithQueryClient();
+ await screen.findByRole("link", { name: "Music" });
+ expect(screen.getByRole("link", { name: "Photography" })).toBeInTheDocument();
+ });
+
+ it("handles duplicates without hanging or repeating a community", async () => {
+ ranked = [town!, town!, ranked[1], ranked[1]];
+ renderWithQueryClient();
+ await screen.findByRole("link", { name: "Photography" });
+ expect(screen.getAllByRole("link", { name: "Town Square" })).toHaveLength(1);
+ expect(screen.getAllByRole("link", { name: "Photography" })).toHaveLength(1);
+ });
+
+ it("keeps cached Town Square available if ranking fails", async () => {
+ failRanked = true;
+ renderWithQueryClient();
+ expect(await screen.findByRole("link", { name: "Town Square" })).toBeInTheDocument();
+ });
+
+ it("retains join, joined status, and unsubscribe with the compact presentation", async () => {
+ ranked = [];
+ renderWithQueryClient();
+ fireEvent.click(await screen.findByRole("button", { name: "community.subscribe" }));
+ await waitFor(() => expect(subscribe).toHaveBeenCalledWith({ community: "hive-125125" }));
+ const joined = await screen.findByRole("button", { name: "community.subscribed" });
+ await waitFor(() => expect(joined).toBeEnabled());
+ fireEvent.click(joined);
+ await waitFor(() => expect(unsubscribe).toHaveBeenCalledWith({ community: "hive-125125" }));
+ expect(await screen.findByRole("button", { name: "community.subscribe" })).toBeInTheDocument();
+ });
+
+ it("keeps discovery links available when both requests fail", async () => {
+ failRanked = failTown = true;
+ renderWithQueryClient();
+ expect(await screen.findByText("g.server-error")).toBeInTheDocument();
+ expect(screen.getByRole("link", { name: "top-communities.explore" })).toHaveAttribute(
+ "href",
+ "/communities"
+ );
+ });
+});
diff --git a/apps/web/src/specs/app/trending-tags-card.spec.tsx b/apps/web/src/specs/app/trending-tags-card.spec.tsx
index 9875091a12..eab9c20507 100644
--- a/apps/web/src/specs/app/trending-tags-card.spec.tsx
+++ b/apps/web/src/specs/app/trending-tags-card.spec.tsx
@@ -1,17 +1,22 @@
-import { screen } from "@testing-library/react";
+import { screen, fireEvent, waitFor, within } from "@testing-library/react";
import "@testing-library/jest-dom";
-import { beforeEach, describe, expect, it, vi } from "vitest";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithQueryClient } from "@/specs/test-utils";
import { useActiveAccount } from "@/core/hooks/use-active-account";
import { TrendingTagsCard } from "@/app/_components/trending-tags-card";
+import i18next from "i18next";
+import english from "@/features/i18n/locales/en-US.json";
vi.mock("@/core/hooks/use-active-account", () => ({
useActiveAccount: vi.fn()
}));
+let sections = ["hot", ""];
+const push = vi.fn();
+
vi.mock("next/navigation", () => ({
- useRouter: () => ({ push: vi.fn() }),
- useParams: () => ({ sections: ["hot", ""] })
+ useRouter: () => ({ push }),
+ useParams: () => ({ sections })
}));
// The global @/utils mock keeps only two members; TagLink needs isCommunity.
@@ -29,7 +34,7 @@ vi.mock("@/core/caches", () => ({
}));
let followedTags: string[] = [];
-const TRENDING = ["hive", "photography", "life", "art"];
+let trending = ["hive", "photography", "life", "art"];
vi.mock("@ecency/sdk", async () => {
const actual = await vi.importActual("@ecency/sdk");
@@ -37,7 +42,7 @@ vi.mock("@ecency/sdk", async () => {
...actual,
getTrendingTagsQueryOptions: vi.fn(() => ({
queryKey: ["trending-tags", 250],
- queryFn: async () => TRENDING,
+ queryFn: async () => trending,
initialPageParam: 0,
getNextPageParam: () => undefined
})),
@@ -56,16 +61,23 @@ vi.mock("@ecency/sdk", async () => {
};
});
-// The chips only: the card's "View more" link sits outside the flex-wrap row.
+// Topic links are separate from their follow and dismiss actions.
const chipTexts = () =>
- Array.from(document.querySelectorAll(".trending-tags-card .flex-wrap a")).map((a) =>
- (a.textContent ?? "").trim()
+ within(screen.getByRole("list")).queryAllByRole("link").map((a) =>
+ (a.textContent ?? "").replace(/^#/, "").trim()
);
describe("TrendingTagsCard", () => {
beforeEach(() => {
vi.clearAllMocks();
+ vi.spyOn(Math, "random").mockReturnValue(0.999);
followedTags = [];
+ sections = ["hot", ""];
+ trending = ["hive", "photography", "life", "art"];
+ });
+
+ afterEach(() => {
+ vi.mocked(Math.random).mockRestore();
});
it("pins the user's followed tags first and lists each tag once", async () => {
@@ -87,7 +99,117 @@ describe("TrendingTagsCard", () => {
renderWithQueryClient();
await screen.findByText("art");
- expect(chipTexts()).toEqual(TRENDING);
+ expect(chipTexts()).toEqual(trending);
expect(screen.queryByRole("button", { name: "follow-tag.add" })).not.toBeInTheDocument();
});
+ it("limits suggestions, keeps the selected topic and offers the full topics page", async () => {
+ vi.mocked(useActiveAccount).mockReturnValue({ activeUser: { username: "alice" } } as never);
+ trending = ["hive", "photography", "life", "art", "gaming", "music", "gardening", "travel"];
+ sections = ["hot", "travel"];
+ renderWithQueryClient();
+ await screen.findByText("photography");
+ expect(chipTexts()).toHaveLength(6);
+ expect(screen.getByText("travel").closest("a")).toHaveAttribute("aria-current", "page");
+ expect(screen.getByRole("link", { name: "trending-tags.explore" })).toHaveAttribute(
+ "href",
+ "/tags"
+ );
+ fireEvent.click(screen.getByRole("button", { name: "g.dismiss" }));
+ expect(push).toHaveBeenCalledWith("/hot/my");
+ });
+
+ it("does not put follow controls inside navigation links", async () => {
+ vi.mocked(useActiveAccount).mockReturnValue({ activeUser: { username: "alice" } } as never);
+ renderWithQueryClient();
+ await waitFor(() =>
+ expect(screen.getAllByRole("button", { name: "follow-tag.add" })).toHaveLength(4)
+ );
+ for (const control of screen.getAllByRole("button", { name: "follow-tag.add" })) {
+ expect(control.closest("a")).toBeNull();
+ }
+ });
+
+ it("randomizes suggestions while keeping selected and followed topics first and stable", async () => {
+ vi.mocked(Math.random).mockReturnValue(0);
+ vi.mocked(useActiveAccount).mockReturnValue({ activeUser: { username: "alice" } } as never);
+ followedTags = ["art", "contest-2026"];
+ sections = ["hot", "niche-topic"];
+ trending = ["hive", "photography", "life", "art", "gaming", "music", "travel"];
+ const { rerender, unmount, queryClient } = renderWithQueryClient();
+ await waitFor(() =>
+ expect(chipTexts()).toEqual([
+ "niche-topic",
+ "art",
+ "contest-2026",
+ "photography",
+ "life",
+ "gaming"
+ ])
+ );
+ vi.mocked(Math.random).mockReturnValue(0.999);
+ rerender();
+ expect(chipTexts()).toEqual([
+ "niche-topic",
+ "art",
+ "contest-2026",
+ "photography",
+ "life",
+ "gaming"
+ ]);
+ expect(new Set(chipTexts()).size).toBe(6);
+ unmount();
+ renderWithQueryClient(, { queryClient });
+ await waitFor(() =>
+ expect(chipTexts()).toEqual([
+ "niche-topic",
+ "art",
+ "contest-2026",
+ "hive",
+ "photography",
+ "life"
+ ])
+ );
+ });
+
+ it("keeps a non-trending route topic selected and dismissible", async () => {
+ vi.mocked(useActiveAccount).mockReturnValue({ activeUser: null } as never);
+ sections = ["created", "niche-topic"];
+ renderWithQueryClient();
+ await screen.findByText("photography");
+ expect(chipTexts()[0]).toBe("niche-topic");
+ expect(screen.getByText("niche-topic").closest("a")).toHaveAttribute("aria-current", "page");
+ fireEvent.click(screen.getByRole("button", { name: "g.dismiss" }));
+ expect(push).toHaveBeenCalledWith("/created");
+ });
+
+ it("gives the icon-only dismiss control a translated accessible name", async () => {
+ const { createInstance } = await vi.importActual("i18next");
+ const translations = createInstance();
+ await translations.init({ lng: "en-US", resources: { "en-US": { translation: english } } });
+ const translate = vi.spyOn(i18next, "t").mockImplementation(translations.t);
+ try {
+ vi.mocked(useActiveAccount).mockReturnValue({ activeUser: null } as never);
+ sections = ["hot", "niche-topic"];
+ renderWithQueryClient();
+ fireEvent.click(screen.getByRole("button", { name: "Dismiss", exact: true }));
+ expect(push).toHaveBeenCalledWith("/hot");
+ } finally {
+ translate.mockRestore();
+ }
+ });
+
+ it.each([
+ ["hot", "my"],
+ ["hot", "global"],
+ ["feed", "%40alice"],
+ ["feed", "@alice"],
+ ["hot", "%40alice"]
+ ])("does not treat the %s/%s route marker as a selected topic", async (filter, tag) => {
+ vi.mocked(useActiveAccount).mockReturnValue({ activeUser: { username: "alice" } } as never);
+ sections = [filter, tag];
+ renderWithQueryClient();
+ await screen.findByText("photography");
+ expect(chipTexts()).toEqual(trending);
+ expect(screen.queryByRole("button", { name: "g.dismiss" })).not.toBeInTheDocument();
+ });
});
diff --git a/apps/web/src/specs/features/shared/navbar-perks-button.spec.tsx b/apps/web/src/specs/features/shared/navbar-perks-button.spec.tsx
index ddc556581d..890602a459 100644
--- a/apps/web/src/specs/features/shared/navbar-perks-button.spec.tsx
+++ b/apps/web/src/specs/features/shared/navbar-perks-button.spec.tsx
@@ -1,10 +1,14 @@
import { vi } from "vitest";
import React from "react";
-import { render } from "@testing-library/react";
+import i18next from "i18next";
+import { render, screen } from "@testing-library/react";
+import english from "@/features/i18n/locales/en-US.json";
import "@testing-library/jest-dom";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
-const useActiveAccount = vi.fn(() => ({ activeUser: { username: "tester" } as { username: string } | null }));
+const useActiveAccount = vi.fn(() => ({
+ activeUser: { username: "tester" } as { username: string } | null
+}));
vi.mock("@/core/hooks/use-active-account", () => ({
useActiveAccount: () => useActiveAccount()
}));
@@ -22,14 +26,14 @@ import { NavbarPerksButton } from "@/features/shared/navbar/navbar-perks-button"
type Streak = { current: number; best: number; at_risk: boolean };
-function renderWith(streak?: Streak) {
+function renderWith(streak?: Streak, subdued = false) {
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
if (streak) {
queryClient.setQueryData(["quests", "status", "tester"], { streak });
}
return render(
-
+
);
}
@@ -96,4 +100,21 @@ describe("NavbarPerksButton", () => {
const { container } = renderWith({ current: 6, best: 6, at_risk: false });
expect(dot(container)).toBeNull();
});
+ test("the subdued feed variant preserves the streak, destination, and at-risk signal", async () => {
+ markPerksSeen();
+ const { createInstance } = await vi.importActual("i18next");
+ const translations = createInstance();
+ await translations.init({ lng: "en-US", resources: { "en-US": { translation: english } } });
+ const translate = vi.spyOn(i18next, "t").mockImplementation(translations.t);
+ try {
+ const { container } = renderWith({ current: 150, best: 200, at_risk: true }, true);
+ const link = screen.getByRole("link", { name: "Perks: 150-day streak", exact: true });
+ expect(link).toHaveAttribute("href", "/perks");
+ expect(link).toHaveTextContent("99+");
+ expect(link).toHaveClass("text-orange-500");
+ expect(dot(container)).not.toBeNull();
+ } finally {
+ translate.mockRestore();
+ }
+ });
});
diff --git a/apps/web/src/specs/features/shared/reading-list-loading.spec.tsx b/apps/web/src/specs/features/shared/reading-list-loading.spec.tsx
new file mode 100644
index 0000000000..281b43dd6a
--- /dev/null
+++ b/apps/web/src/specs/features/shared/reading-list-loading.spec.tsx
@@ -0,0 +1,21 @@
+import { render, act } from "@testing-library/react";
+import { afterEach, describe, expect, it } from "vitest";
+import { useGlobalStore } from "@/core/global-store";
+import { ListStyle } from "@/enums";
+import { ReadingListLoading } from "@/features/shared/reading-layout/reading-list-loading";
+
+// Loading routes must follow the same shared preference as loaded lists.
+describe("ReadingListLoading", () => {
+ afterEach(() => useGlobalStore.getState().setListStyle(ListStyle.row));
+
+ it("uses the selected grid preference and follows changes back to rows", () => {
+ useGlobalStore.getState().setListStyle(ListStyle.grid);
+ const { container } = render();
+ expect(container.querySelector(".entry-list-body.grid-view")).not.toBeNull();
+ expect(container.querySelectorAll(".entry-list-loading-item")).toHaveLength(6);
+ expect(container.querySelector(".linear-progress")).not.toBeNull();
+ act(() => useGlobalStore.getState().setListStyle(ListStyle.row));
+ expect(container.querySelector(".entry-list-body.grid-view")).toBeNull();
+ expect(container.querySelectorAll(".entry-list-loading-item")).toHaveLength(6);
+ });
+});