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(); + }); +});