diff --git a/src/__tests__/ActivityFeed.test.tsx b/src/__tests__/ActivityFeed.test.tsx
index b35d12c..24b40f5 100644
--- a/src/__tests__/ActivityFeed.test.tsx
+++ b/src/__tests__/ActivityFeed.test.tsx
@@ -70,6 +70,80 @@ describe("ActivityFeed", () => {
expect(screen.getByText("No activity yet")).toBeInTheDocument();
});
+ it("renders empty state with icon and CTA link", () => {
+ mockUseActivityFeed.mockReturnValue({
+ events: [],
+ readIds: new Set(),
+ unreadCount: 0,
+ isConnected: true,
+ markAsRead: vi.fn(),
+ markManyAsRead: vi.fn(),
+ activeFilters: [],
+ toggleFilter: vi.fn(),
+ clearFilters: vi.fn(),
+ });
+
+ render();
+ expect(screen.getByText("No activity yet")).toBeInTheDocument();
+ expect(screen.getByText(/create your first invoice/i)).toBeInTheDocument();
+ });
+
+ it("includes CTA link to /invoice/new in empty state", () => {
+ mockUseActivityFeed.mockReturnValue({
+ events: [],
+ readIds: new Set(),
+ unreadCount: 0,
+ isConnected: true,
+ markAsRead: vi.fn(),
+ markManyAsRead: vi.fn(),
+ activeFilters: [],
+ toggleFilter: vi.fn(),
+ clearFilters: vi.fn(),
+ });
+
+ render();
+ const ctaLink = screen.getByRole("link", { name: /create.*invoice/i });
+ expect(ctaLink).toHaveAttribute("href", "/invoice/new");
+ });
+
+ it("hides empty state when data is loading", () => {
+ mockUseActivityFeed.mockReturnValue({
+ events: [],
+ readIds: new Set(),
+ unreadCount: 0,
+ isConnected: false,
+ markAsRead: vi.fn(),
+ markManyAsRead: vi.fn(),
+ activeFilters: [],
+ toggleFilter: vi.fn(),
+ clearFilters: vi.fn(),
+ });
+
+ render();
+ const skeleton = document.querySelector('[data-testid="skeleton"]');
+ if (skeleton) {
+ expect(screen.queryByText(/No activity yet/)).not.toBeInTheDocument();
+ }
+ });
+
+ it("renders skeleton loader during loading state", () => {
+ mockUseActivityFeed.mockReturnValue({
+ events: [],
+ readIds: new Set(),
+ unreadCount: 0,
+ isConnected: false,
+ markAsRead: vi.fn(),
+ markManyAsRead: vi.fn(),
+ activeFilters: [],
+ toggleFilter: vi.fn(),
+ clearFilters: vi.fn(),
+ });
+
+ render();
+ const loaders = document.querySelectorAll('.animate-pulse, [class*="skeleton"]');
+ expect(loaders.length).toBeGreaterThanOrEqual(0);
+ });
+
it("renders feed items with event descriptions", () => {
mockUseActivityFeed.mockReturnValue({
events: [
diff --git a/src/__tests__/ActivityHeatmapTooltip.test.tsx b/src/__tests__/ActivityHeatmapTooltip.test.tsx
new file mode 100644
index 0000000..3fef94f
--- /dev/null
+++ b/src/__tests__/ActivityHeatmapTooltip.test.tsx
@@ -0,0 +1,190 @@
+import { describe, it, expect, beforeEach, vi } from "vitest";
+import { render, screen, fireEvent, waitFor } from "@testing-library/react";
+import ActivityHeatmap from "@/components/ActivityHeatmap";
+import type { Invoice } from "@stellar-split/sdk";
+
+const mockInvoices: Invoice[] = [
+ {
+ id: "inv-1",
+ creator: "GCREATOR",
+ recipients: [{ address: "GRECIPIENT", amount: 100_000_000n }],
+ token: "CUSDC",
+ deadline: 0,
+ funded: 50_000_000n,
+ status: "Pending",
+ payments: [
+ {
+ payer: "GPAYER",
+ amount: 50_000_000n,
+ timestamp: Math.floor(Date.now() / 1000),
+ },
+ ],
+ },
+];
+
+describe("ActivityHeatmap Tooltip Positioning", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it("renders heatmap cells for activity data", () => {
+ render();
+ const cells = screen.getAllByRole("button");
+ expect(cells.length).toBeGreaterThan(0);
+ });
+
+ it("shows tooltip on cell hover", async () => {
+ render();
+ const cells = screen.getAllByRole("button");
+
+ fireEvent.mouseEnter(cells[0]);
+
+ await waitFor(() => {
+ const tooltip = screen.queryByText(/payment/i);
+ expect(tooltip || document.querySelector('[role="tooltip"]')).toBeTruthy();
+ });
+ });
+
+ it("positions tooltip to the right by default", () => {
+ render();
+ const cells = screen.getAllByRole("button");
+
+ fireEvent.mouseEnter(cells[0]);
+
+ const tooltip = document.querySelector('[role="tooltip"]');
+ if (tooltip) {
+ const style = window.getComputedStyle(tooltip);
+ expect(style.visibility).not.toBe("hidden");
+ }
+ });
+
+ it("flips tooltip to the left when right edge would overflow", () => {
+ const container = render();
+ const cells = screen.getAllByRole("button");
+ const rightEdgeCell = cells[cells.length - 1];
+
+ fireEvent.mouseEnter(rightEdgeCell);
+
+ const tooltip = document.querySelector('[role="tooltip"]');
+ if (tooltip) {
+ const tooltipRect = tooltip.getBoundingClientRect();
+ expect(tooltipRect.right).toBeLessThanOrEqual(window.innerWidth);
+ }
+ });
+
+ it("flips tooltip downward when top edge would overflow", () => {
+ render();
+ const cells = screen.getAllByRole("button");
+
+ fireEvent.mouseEnter(cells[0]);
+
+ const tooltip = document.querySelector('[role="tooltip"]');
+ if (tooltip) {
+ const tooltipRect = tooltip.getBoundingClientRect();
+ expect(tooltipRect.bottom).toBeLessThanOrEqual(window.innerHeight);
+ }
+ });
+
+ it("keeps tooltip fully within viewport on all edges", async () => {
+ render();
+ const cells = screen.getAllByRole("button");
+
+ for (const cell of cells) {
+ fireEvent.mouseEnter(cell);
+
+ await waitFor(() => {
+ const tooltip = document.querySelector('[role="tooltip"]');
+ if (tooltip) {
+ const rect = tooltip.getBoundingClientRect();
+ expect(rect.left).toBeGreaterThanOrEqual(0);
+ expect(rect.right).toBeLessThanOrEqual(window.innerWidth);
+ expect(rect.top).toBeGreaterThanOrEqual(0);
+ expect(rect.bottom).toBeLessThanOrEqual(window.innerHeight);
+ }
+ });
+ }
+ });
+
+ it("hides tooltip on mouse leave", async () => {
+ render();
+ const cells = screen.getAllByRole("button");
+
+ fireEvent.mouseEnter(cells[0]);
+
+ const tooltip = document.querySelector('[role="tooltip"]');
+ if (tooltip) {
+ fireEvent.mouseLeave(cells[0]);
+
+ await waitFor(() => {
+ expect(tooltip).not.toBeVisible();
+ });
+ }
+ });
+
+ it("updates tooltip content for different cells", async () => {
+ render();
+ const cells = screen.getAllByRole("button");
+
+ fireEvent.mouseEnter(cells[0]);
+
+ await waitFor(() => {
+ expect(document.querySelector('[role="tooltip"]')).toBeTruthy();
+ });
+
+ fireEvent.mouseLeave(cells[0]);
+ fireEvent.mouseEnter(cells[1]);
+
+ await waitFor(() => {
+ expect(document.querySelector('[role="tooltip"]')).toBeTruthy();
+ });
+ });
+
+ it("adjusts position when viewport is resized", () => {
+ render();
+ const cells = screen.getAllByRole("button");
+
+ fireEvent.mouseEnter(cells[0]);
+
+ const originalTooltip = document.querySelector('[role="tooltip"]');
+ const originalRect = originalTooltip?.getBoundingClientRect();
+
+ window.innerWidth = 400;
+ fireEvent.resize(window);
+
+ const resizedTooltip = document.querySelector('[role="tooltip"]');
+ const resizedRect = resizedTooltip?.getBoundingClientRect();
+
+ if (resizedTooltip) {
+ expect(resizedRect?.right).toBeLessThanOrEqual(400);
+ }
+ });
+
+ it("shows tooltip content with activity count", async () => {
+ render();
+ const cells = screen.getAllByRole("button");
+
+ fireEvent.mouseEnter(cells[0]);
+
+ await waitFor(() => {
+ const tooltip = document.querySelector('[role="tooltip"]');
+ expect(tooltip?.textContent).toMatch(/\d+ payment/i);
+ });
+ });
+
+ it("positions tooltip relative to heatmap container coordinates", () => {
+ const { container } = render();
+ const heatmap = container.querySelector('svg');
+ const cells = screen.getAllByRole("button");
+
+ fireEvent.mouseEnter(cells[0]);
+
+ const tooltip = document.querySelector('[role="tooltip"]');
+ if (tooltip && heatmap) {
+ const heatmapRect = heatmap.getBoundingClientRect();
+ const tooltipRect = tooltip.getBoundingClientRect();
+
+ expect(tooltipRect.left).toBeGreaterThanOrEqual(heatmapRect.left - 100);
+ expect(tooltipRect.right).toBeLessThanOrEqual(heatmapRect.right + 100);
+ }
+ });
+});
diff --git a/src/__tests__/InvoiceTableColumnVisibility.test.tsx b/src/__tests__/InvoiceTableColumnVisibility.test.tsx
new file mode 100644
index 0000000..8bc9ab1
--- /dev/null
+++ b/src/__tests__/InvoiceTableColumnVisibility.test.tsx
@@ -0,0 +1,178 @@
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import { render, screen, fireEvent, waitFor } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import InvoiceTable from "@/components/InvoiceTable";
+import type { Invoice } from "@stellar-split/sdk";
+
+const mockInvoices: Invoice[] = [
+ {
+ id: "inv-1",
+ creator: "GCREATOR1",
+ recipients: [{ address: "GRECIPIENT1", amount: 100_000_000n }],
+ token: "CUSDC",
+ deadline: 0,
+ funded: 50_000_000n,
+ status: "Pending",
+ payments: [],
+ },
+ {
+ id: "inv-2",
+ creator: "GCREATOR2",
+ recipients: [{ address: "GRECIPIENT2", amount: 200_000_000n }],
+ token: "CUSDC",
+ deadline: Math.floor(Date.now() / 1000) + 86400,
+ funded: 100_000_000n,
+ status: "Released",
+ payments: [{ payer: "GPAYER1", amount: 100_000_000n }],
+ },
+];
+
+describe("InvoiceTable Column Visibility Toggle", () => {
+ beforeEach(() => {
+ localStorage.clear();
+ });
+
+ it("renders a gear icon button to open column settings", () => {
+ render();
+ const gearButton = screen.getByRole("button", { name: /column settings/i });
+ expect(gearButton).toBeInTheDocument();
+ });
+
+ it("displays dropdown menu with column toggles when gear icon is clicked", async () => {
+ const user = userEvent.setup();
+ render();
+
+ const gearButton = screen.getByRole("button", { name: /column settings/i });
+ await user.click(gearButton);
+
+ expect(screen.getByText(/ID/i)).toBeInTheDocument();
+ expect(screen.getByText(/Amount/i)).toBeInTheDocument();
+ expect(screen.getByText(/Status/i)).toBeInTheDocument();
+ expect(screen.getByText(/Deadline/i)).toBeInTheDocument();
+ expect(screen.getByText(/Funded/i)).toBeInTheDocument();
+ });
+
+ it("toggles column visibility when checkbox is clicked", async () => {
+ const user = userEvent.setup();
+ render();
+
+ const gearButton = screen.getByRole("button", { name: /column settings/i });
+ await user.click(gearButton);
+
+ const tagsCheckbox = screen.getByRole("checkbox", { name: /Tags/i });
+ await user.click(tagsCheckbox);
+
+ const tagHeaders = screen.queryAllByText(/Tags/i);
+ expect(tagHeaders.length).toBe(1);
+ });
+
+ it("persists column visibility preference to localStorage", async () => {
+ const user = userEvent.setup();
+ const { unmount } = render();
+
+ const gearButton = screen.getByRole("button", { name: /column settings/i });
+ await user.click(gearButton);
+
+ const recipientsCheckbox = screen.getByRole("checkbox", { name: /Recipients/i });
+ await user.click(recipientsCheckbox);
+
+ const savedPreferences = localStorage.getItem("invoiceTableColumns");
+ expect(savedPreferences).toBeTruthy();
+ expect(savedPreferences).toContain("recipients");
+
+ unmount();
+ });
+
+ it("restores column visibility from localStorage on mount", async () => {
+ localStorage.setItem(
+ "invoiceTableColumns",
+ JSON.stringify({ tags: false, recipients: false })
+ );
+
+ render();
+
+ const gearButton = screen.getByRole("button", { name: /column settings/i });
+ await userEvent.click(gearButton);
+
+ const tagsCheckbox = screen.getByRole("checkbox", { name: /Tags/i });
+ const recipientsCheckbox = screen.getByRole("checkbox", { name: /Recipients/i });
+
+ expect(tagsCheckbox).not.toBeChecked();
+ expect(recipientsCheckbox).not.toBeChecked();
+ });
+
+ it("keeps required columns visible when optional columns are hidden", async () => {
+ const user = userEvent.setup();
+ render();
+
+ const gearButton = screen.getByRole("button", { name: /column settings/i });
+ await user.click(gearButton);
+
+ const tagsCheckbox = screen.getByRole("checkbox", { name: /Tags/i });
+ const recipientsCheckbox = screen.getByRole("checkbox", { name: /Recipients/i });
+
+ await user.click(tagsCheckbox);
+ await user.click(recipientsCheckbox);
+
+ expect(screen.getByText("inv-1")).toBeInTheDocument();
+ expect(screen.getByText("inv-2")).toBeInTheDocument();
+ expect(screen.getByText("100.00")).toBeInTheDocument();
+ });
+
+ it("closes dropdown menu when clicking outside", async () => {
+ const user = userEvent.setup();
+ const { container } = render();
+
+ const gearButton = screen.getByRole("button", { name: /column settings/i });
+ await user.click(gearButton);
+
+ expect(screen.getByText(/ID/i)).toBeInTheDocument();
+
+ fireEvent.click(container);
+
+ await waitFor(() => {
+ expect(screen.queryByText(/ID/i)).not.toBeInTheDocument();
+ });
+ });
+
+ it("closes dropdown menu when Escape key is pressed", async () => {
+ const user = userEvent.setup();
+ render();
+
+ const gearButton = screen.getByRole("button", { name: /column settings/i });
+ await user.click(gearButton);
+
+ await user.keyboard("{Escape}");
+
+ await waitFor(() => {
+ expect(screen.queryByRole("checkbox")).not.toBeInTheDocument();
+ });
+ });
+
+ it("hides columns without page reload after toggling", async () => {
+ const user = userEvent.setup();
+ render();
+
+ const amountBefore = screen.getAllByText(/100\.00/);
+ expect(amountBefore.length).toBeGreaterThan(0);
+
+ const gearButton = screen.getByRole("button", { name: /column settings/i });
+ await user.click(gearButton);
+
+ const amountCheckbox = screen.getByRole("checkbox", { name: /Amount/i });
+ await user.click(amountCheckbox);
+
+ const amountAfter = screen.queryAllByText(/100\.00/);
+ expect(amountAfter.length).toBe(0);
+ });
+
+ it("renders all columns initially visible by default", () => {
+ render();
+
+ expect(screen.getByText("ID")).toBeInTheDocument();
+ expect(screen.getByText("Amount")).toBeInTheDocument();
+ expect(screen.getByText("Status")).toBeInTheDocument();
+ expect(screen.getByText("Deadline")).toBeInTheDocument();
+ expect(screen.getByText("Funded")).toBeInTheDocument();
+ });
+});
diff --git a/src/__tests__/NavbarMobileMenu.test.tsx b/src/__tests__/NavbarMobileMenu.test.tsx
new file mode 100644
index 0000000..4e73c48
--- /dev/null
+++ b/src/__tests__/NavbarMobileMenu.test.tsx
@@ -0,0 +1,239 @@
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import { render, screen, fireEvent, waitFor } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import Navbar from "@/components/Navbar";
+
+vi.mock("next/navigation", () => ({
+ useRouter: () => ({ push: vi.fn(), back: vi.fn() }),
+ usePathname: () => "/dashboard",
+ useSearchParams: () => new URLSearchParams(),
+}));
+
+vi.mock("@/components/ThemeToggle", () => ({
+ __esModule: true,
+ default: () =>
Theme Toggle
,
+}));
+
+vi.mock("@/components/SimulationModeToggle", () => ({
+ __esModule: true,
+ default: () => Sim Toggle
,
+}));
+
+vi.mock("@/components/NotificationCenter", () => ({
+ __esModule: true,
+ default: () => Notifications
,
+}));
+
+vi.mock("@/components/HeaderShortcutsButton", () => ({
+ __esModule: true,
+ default: () => Shortcuts
,
+}));
+
+vi.mock("@/components/NetworkStatus", () => ({
+ __esModule: true,
+ default: () => Network
,
+}));
+
+vi.mock("@/components/GlobalSearch", () => ({
+ __esModule: true,
+ default: () => Search
,
+}));
+
+vi.mock("@/components/layout/MobileSidebar", () => ({
+ __esModule: true,
+ default: ({ isOpen }: { isOpen: boolean }) => (
+ isOpen ? Mobile Menu
: null
+ ),
+}));
+
+describe("Navbar Mobile Menu", () => {
+ beforeEach(() => {
+ global.innerWidth = 640;
+ });
+
+ it("renders hamburger icon below sm breakpoint", () => {
+ global.innerWidth = 500;
+ render();
+
+ const hamburgerButton = screen.getByRole("button", { name: /menu/i });
+ expect(hamburgerButton).toBeInTheDocument();
+ });
+
+ it("hides hamburger icon above sm breakpoint", () => {
+ global.innerWidth = 768;
+ render();
+
+ const hamburgerButton = screen.queryByRole("button", { name: /menu/i });
+ expect(hamburgerButton).not.toBeInTheDocument();
+ });
+
+ it("shows horizontal navigation links above sm breakpoint", () => {
+ global.innerWidth = 768;
+ render();
+
+ expect(screen.getByText("Dashboard")).toBeInTheDocument();
+ expect(screen.getByText("Subscriptions")).toBeInTheDocument();
+ expect(screen.getByText("Groups")).toBeInTheDocument();
+ });
+
+ it("opens mobile menu when hamburger is clicked", async () => {
+ const user = userEvent.setup();
+ global.innerWidth = 500;
+ render();
+
+ const hamburgerButton = screen.getByRole("button", { name: /menu/i });
+ await user.click(hamburgerButton);
+
+ await waitFor(() => {
+ expect(screen.getByTestId("mobile-sidebar")).toBeInTheDocument();
+ });
+ });
+
+ it("closes mobile menu when clicking outside", async () => {
+ const user = userEvent.setup();
+ global.innerWidth = 500;
+ const { container } = render();
+
+ const hamburgerButton = screen.getByRole("button", { name: /menu/i });
+ await user.click(hamburgerButton);
+
+ fireEvent.click(container);
+
+ await waitFor(() => {
+ expect(screen.queryByTestId("mobile-sidebar")).not.toBeInTheDocument();
+ });
+ });
+
+ it("closes mobile menu when Escape key is pressed", async () => {
+ const user = userEvent.setup();
+ global.innerWidth = 500;
+ render();
+
+ const hamburgerButton = screen.getByRole("button", { name: /menu/i });
+ await user.click(hamburgerButton);
+
+ await waitFor(() => {
+ expect(screen.getByTestId("mobile-sidebar")).toBeInTheDocument();
+ });
+
+ await user.keyboard("{Escape}");
+
+ await waitFor(() => {
+ expect(screen.queryByTestId("mobile-sidebar")).not.toBeInTheDocument();
+ });
+ });
+
+ it("renders navigation links vertically in mobile menu", async () => {
+ const user = userEvent.setup();
+ global.innerWidth = 500;
+ render();
+
+ const hamburgerButton = screen.getByRole("button", { name: /menu/i });
+ await user.click(hamburgerButton);
+
+ await waitFor(() => {
+ expect(screen.getByText("Dashboard")).toBeInTheDocument();
+ expect(screen.getByText("Subscriptions")).toBeInTheDocument();
+ expect(screen.getByText("Groups")).toBeInTheDocument();
+ expect(screen.getByText("Contacts")).toBeInTheDocument();
+ expect(screen.getByText("Recipients")).toBeInTheDocument();
+ expect(screen.getByText("Leaderboard")).toBeInTheDocument();
+ });
+ });
+
+ it("closes menu when a navigation link is clicked", async () => {
+ const user = userEvent.setup();
+ global.innerWidth = 500;
+ render();
+
+ const hamburgerButton = screen.getByRole("button", { name: /menu/i });
+ await user.click(hamburgerButton);
+
+ const dashboardLink = screen.getByRole("link", { name: /dashboard/i });
+ await user.click(dashboardLink);
+
+ await waitFor(() => {
+ expect(screen.queryByTestId("mobile-sidebar")).not.toBeInTheDocument();
+ });
+ });
+
+ it("maintains open state when window is resized above breakpoint", () => {
+ global.innerWidth = 500;
+ render();
+
+ const hamburgerButton = screen.getByRole("button", { name: /menu/i });
+ fireEvent.click(hamburgerButton);
+
+ global.innerWidth = 768;
+ fireEvent.resize(window);
+
+ const horizontalLinks = screen.getByText("Dashboard");
+ expect(horizontalLinks).toBeInTheDocument();
+ });
+
+ it("hamburger button is accessible with keyboard navigation", async () => {
+ const user = userEvent.setup();
+ global.innerWidth = 500;
+ render();
+
+ const hamburgerButton = screen.getByRole("button", { name: /menu/i });
+ hamburgerButton.focus();
+ expect(hamburgerButton).toHaveFocus();
+
+ await user.keyboard("{Enter}");
+
+ await waitFor(() => {
+ expect(screen.getByTestId("mobile-sidebar")).toBeInTheDocument();
+ });
+ });
+
+ it("logo links to home on all screen sizes", () => {
+ global.innerWidth = 500;
+ render();
+
+ const logoLink = screen.getByRole("link", { name: /StellarSplit home/i });
+ expect(logoLink).toHaveAttribute("href", "/");
+ });
+
+ it("mobile menu is full width on small screens", async () => {
+ const user = userEvent.setup();
+ global.innerWidth = 500;
+ const { container } = render();
+
+ const hamburgerButton = screen.getByRole("button", { name: /menu/i });
+ await user.click(hamburgerButton);
+
+ await waitFor(() => {
+ const sidebar = screen.getByTestId("mobile-sidebar");
+ expect(sidebar).toBeInTheDocument();
+ });
+ });
+
+ it("closes menu when clicking menu button again (toggle)", async () => {
+ const user = userEvent.setup();
+ global.innerWidth = 500;
+ render();
+
+ const hamburgerButton = screen.getByRole("button", { name: /menu/i });
+ await user.click(hamburgerButton);
+
+ await waitFor(() => {
+ expect(screen.getByTestId("mobile-sidebar")).toBeInTheDocument();
+ });
+
+ await user.click(hamburgerButton);
+
+ await waitFor(() => {
+ expect(screen.queryByTestId("mobile-sidebar")).not.toBeInTheDocument();
+ });
+ });
+
+ it("preserves header controls (theme, notifications) in mobile view", () => {
+ global.innerWidth = 500;
+ render();
+
+ expect(screen.getByTestId("theme-toggle")).toBeInTheDocument();
+ expect(screen.getByTestId("notification-center")).toBeInTheDocument();
+ expect(screen.getByTestId("network-status")).toBeInTheDocument();
+ });
+});