form-tab-nav.test.tsx 6.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213
  1. /**
  2. * @vitest-environment happy-dom
  3. */
  4. import { act } from "react";
  5. import { createRoot } from "react-dom/client";
  6. import { describe, expect, it, vi } from "vitest";
  7. // Mock next-intl
  8. vi.mock("next-intl", () => ({
  9. useTranslations: () => (key: string) => key,
  10. }));
  11. // Mock framer-motion -- render motion.div as a plain div
  12. vi.mock("framer-motion", () => ({
  13. motion: {
  14. div: ({ children, layoutId, ...rest }: any) => (
  15. <div data-layout-id={layoutId} {...rest}>
  16. {children}
  17. </div>
  18. ),
  19. },
  20. }));
  21. // Mock lucide-react icons used by FormTabNav
  22. vi.mock("lucide-react", () => {
  23. const stub = ({ className }: any) => <span data-testid="icon" className={className} />;
  24. return {
  25. FileText: stub,
  26. Route: stub,
  27. Gauge: stub,
  28. Network: stub,
  29. FlaskConical: stub,
  30. };
  31. });
  32. import { FormTabNav } from "@/app/[locale]/settings/providers/_components/forms/provider-form/components/form-tab-nav";
  33. // ---------------------------------------------------------------------------
  34. // Render helper (matches project convention)
  35. // ---------------------------------------------------------------------------
  36. function render(node: React.ReactNode) {
  37. const container = document.createElement("div");
  38. document.body.appendChild(container);
  39. const root = createRoot(container);
  40. act(() => {
  41. root.render(node);
  42. });
  43. return {
  44. container,
  45. unmount: () => {
  46. act(() => root.unmount());
  47. container.remove();
  48. },
  49. };
  50. }
  51. // ---------------------------------------------------------------------------
  52. // Tests
  53. // ---------------------------------------------------------------------------
  54. describe("FormTabNav", () => {
  55. const defaultProps = {
  56. activeTab: "basic" as const,
  57. onTabChange: vi.fn(),
  58. };
  59. // -- Default (vertical) layout -------------------------------------------
  60. describe("default vertical layout", () => {
  61. it("renders all 5 tabs across 3 responsive breakpoints (15 total)", () => {
  62. const { container, unmount } = render(<FormTabNav {...defaultProps} />);
  63. // Desktop (5) + Tablet (5) + Mobile (5) = 15
  64. const buttons = container.querySelectorAll("button");
  65. expect(buttons.length).toBe(15);
  66. unmount();
  67. });
  68. it("renders vertical sidebar nav with hidden lg:flex classes", () => {
  69. const { container, unmount } = render(<FormTabNav {...defaultProps} />);
  70. const nav = container.querySelector("nav");
  71. expect(nav).toBeTruthy();
  72. expect(nav!.className).toContain("lg:flex");
  73. expect(nav!.className).toContain("flex-col");
  74. unmount();
  75. });
  76. });
  77. // -- Horizontal layout ---------------------------------------------------
  78. describe('layout="horizontal"', () => {
  79. it("renders a horizontal nav bar", () => {
  80. const { container, unmount } = render(<FormTabNav {...defaultProps} layout="horizontal" />);
  81. const nav = container.querySelector("nav");
  82. expect(nav).toBeTruthy();
  83. // Horizontal mode uses sticky top-0 nav with border-b
  84. expect(nav!.className).toContain("sticky");
  85. expect(nav!.className).toContain("border-b");
  86. unmount();
  87. });
  88. it("has overflow-x-auto for horizontal scrolling", () => {
  89. const { container, unmount } = render(<FormTabNav {...defaultProps} layout="horizontal" />);
  90. const scrollContainer = container.querySelector("nav > div");
  91. expect(scrollContainer).toBeTruthy();
  92. expect(scrollContainer!.className).toContain("overflow-x-auto");
  93. unmount();
  94. });
  95. it("highlights the active tab with text-primary", () => {
  96. const { container, unmount } = render(
  97. <FormTabNav {...defaultProps} activeTab="routing" layout="horizontal" />
  98. );
  99. const buttons = container.querySelectorAll("button");
  100. // "routing" is the second tab (index 1)
  101. const routingBtn = buttons[1];
  102. expect(routingBtn.className).toContain("text-primary");
  103. // Other tabs should have text-muted-foreground
  104. const basicBtn = buttons[0];
  105. expect(basicBtn.className).toContain("text-muted-foreground");
  106. unmount();
  107. });
  108. it("renders motion indicator for active tab with horizontal layoutId", () => {
  109. const { container, unmount } = render(
  110. <FormTabNav {...defaultProps} activeTab="basic" layout="horizontal" />
  111. );
  112. const indicator = container.querySelector('[data-layout-id="activeTabIndicatorHorizontal"]');
  113. expect(indicator).toBeTruthy();
  114. unmount();
  115. });
  116. it("calls onTabChange when a tab is clicked", () => {
  117. const onTabChange = vi.fn();
  118. const { container, unmount } = render(
  119. <FormTabNav {...defaultProps} onTabChange={onTabChange} layout="horizontal" />
  120. );
  121. const buttons = container.querySelectorAll("button");
  122. // Click the "network" tab (index 3)
  123. act(() => {
  124. buttons[3].click();
  125. });
  126. expect(onTabChange).toHaveBeenCalledWith("network");
  127. unmount();
  128. });
  129. it("disables all tabs when disabled prop is true", () => {
  130. const onTabChange = vi.fn();
  131. const { container, unmount } = render(
  132. <FormTabNav {...defaultProps} onTabChange={onTabChange} disabled layout="horizontal" />
  133. );
  134. const buttons = container.querySelectorAll("button");
  135. for (const btn of buttons) {
  136. expect(btn.disabled).toBe(true);
  137. expect(btn.className).toContain("opacity-50");
  138. expect(btn.className).toContain("cursor-not-allowed");
  139. }
  140. // Click should not fire because button is disabled
  141. act(() => {
  142. buttons[2].click();
  143. });
  144. expect(onTabChange).not.toHaveBeenCalled();
  145. unmount();
  146. });
  147. it("shows status dot for tabs with warning or configured status", () => {
  148. const { container, unmount } = render(
  149. <FormTabNav
  150. {...defaultProps}
  151. layout="horizontal"
  152. tabStatus={{ routing: "warning", limits: "configured" }}
  153. />
  154. );
  155. const buttons = container.querySelectorAll("button");
  156. // routing (index 1) should have a yellow dot
  157. const routingDot = buttons[1].querySelector(".bg-yellow-500");
  158. expect(routingDot).toBeTruthy();
  159. // limits (index 2) should have a primary dot
  160. const limitsDot = buttons[2].querySelector(".bg-primary");
  161. expect(limitsDot).toBeTruthy();
  162. // basic (index 0) should have no status dot
  163. const basicDot = buttons[0].querySelector(".rounded-full");
  164. expect(basicDot).toBeNull();
  165. unmount();
  166. });
  167. });
  168. });