diff --git a/apps/website/eslint.config.mjs b/apps/website/eslint.config.mjs index d83a5e66da..05c9fb7eb4 100644 --- a/apps/website/eslint.config.mjs +++ b/apps/website/eslint.config.mjs @@ -1,6 +1,8 @@ import nextPlugin from "@next/eslint-plugin-next"; import nextConfig from "@dxc-technology/eslint-config/next.js"; import js from "@eslint/js"; +import jest from "eslint-plugin-jest"; +import globals from "globals"; import path from "node:path"; import { fileURLToPath } from "node:url"; @@ -19,6 +21,20 @@ export default [ tsconfigRootDir: __dirname, tsconfigName: "tsconfig.lint.json", }), + { + files: ["**/*.test.{ts,tsx,js,jsx}"], + plugins: { jest }, + rules: { + ...jest.configs.recommended.rules, + "jest/no-commented-out-tests": "off", + }, + languageOptions: { + globals: { + ...globals.jest, + ...globals.node, + }, + }, + }, { ignores: ["out/**", ".next/**"], }, diff --git a/apps/website/jest.config.mjs b/apps/website/jest.config.mjs new file mode 100644 index 0000000000..ebfaa4fd72 --- /dev/null +++ b/apps/website/jest.config.mjs @@ -0,0 +1,27 @@ +import nextJest from "next/jest.js"; + +const createJestConfig = nextJest({ + dir: "./", +}); + +const customJestConfig = { + testEnvironment: "jsdom", + collectCoverage: true, + coveragePathIgnorePatterns: [ + "utils.ts", + "index.ts", + "test/mocks", + ".*Context\\.tsx$", + ], + moduleNameMapper: { + "\\.(css|less|scss|sass)$": "identity-obj-proxy", + "\\.(svg)$": "/test/mocks/svgMock.ts", + "\\.(png)$": "/test/mocks/pngMock.ts", + "^screens/(.*)$": "/screens/$1", + "^hooks/(.*)$": "/hooks/$1", + "^@/(.*)$": "/$1", + }, + testMatch: ["**/?(*.)+(spec|test).[jt]s?(x)", "!**/?(*.)+(accessibility.)(spec|test).[jt]s?(x)"], +}; + +export default createJestConfig(customJestConfig); diff --git a/apps/website/package.json b/apps/website/package.json index 17dd4324ac..ff8ed2a14c 100644 --- a/apps/website/package.json +++ b/apps/website/package.json @@ -5,7 +5,8 @@ "dev": "next dev --webpack", "build": "next build --webpack", "start": "next start", - "lint": "eslint . --max-warnings 0" + "lint": "eslint . --max-warnings 0", + "test": "jest --config=./jest.config.mjs" }, "dependencies": { "@adobe/leonardo-contrast-colors": "^1.0.0", @@ -29,14 +30,19 @@ "slugify": "^1.6.5" }, "devDependencies": { + "@testing-library/jest-dom": "^6.8.0", + "@testing-library/react": "^16.3.0", "@dxc-technology/typescript-config": "*", "@eslint/js": "^9.31.1", + "@types/jest": "^29.5.12", "@types/node": "^20", "@types/react": "^18", "@types/react-color": "^3.0.6", "@types/react-dom": "^18", "eslint": "^9.39.1", "eslint-config-next": "16.0.8", + "jest": "^30.2.0", + "jest-environment-jsdom": "^30.2.0", "typescript": "^5.6.3" } } diff --git a/apps/website/screens/theme-generator/ThemeGeneratorConfigPage.tsx b/apps/website/screens/theme-generator/ThemeGeneratorConfigPage.tsx index 28342182c8..800784be89 100644 --- a/apps/website/screens/theme-generator/ThemeGeneratorConfigPage.tsx +++ b/apps/website/screens/theme-generator/ThemeGeneratorConfigPage.tsx @@ -3,7 +3,6 @@ import { DxcContainer, DxcFlex, DxcWizard } from "@dxc-technology/halstack-react import StepHeading from "./components/StepHeading"; import BottomButtons from "./components/BottomButtons"; import ThemeGeneratorPreviewPage from "./ThemeGeneratorPreviewPage"; -// import { FileData } from "../../../../packages/lib/src/file-input/types"; import { BrandingDetails } from "./steps/BrandingDetails"; import { generateTokens, handleExport } from "./utils"; diff --git a/apps/website/screens/theme-generator/steps/ReviewDetails.tsx b/apps/website/screens/theme-generator/steps/ReviewDetails.tsx index 2fa9341863..43fa76bd81 100644 --- a/apps/website/screens/theme-generator/steps/ReviewDetails.tsx +++ b/apps/website/screens/theme-generator/steps/ReviewDetails.tsx @@ -21,6 +21,7 @@ const ReviewDetails = ({ tokens, logos, themeJson }: { tokens: Tokens; logos: Lo mode="secondary" icon="content_copy" size={{ height: "medium" }} + title="Copy theme" onClick={() => handleCopy(themeJson)} /> diff --git a/apps/website/test/hooks/useCopyToClipboard.test.tsx b/apps/website/test/hooks/useCopyToClipboard.test.tsx new file mode 100644 index 0000000000..13183e86fe --- /dev/null +++ b/apps/website/test/hooks/useCopyToClipboard.test.tsx @@ -0,0 +1,81 @@ +import { renderHook } from "@testing-library/react"; +import { HalstackProvider } from "@dxc-technology/halstack-react"; +import useCopyToClipboard from "../../hooks/useCopyToClipboard"; + +describe("useCopyToClipboard", () => { + const wrapper = ({ children }: { children: React.ReactNode }) => {children}; + + let mockWriteText: jest.Mock; + + beforeEach(() => { + jest.clearAllMocks(); + + mockWriteText = jest.fn(); + Object.assign(navigator, { + clipboard: { + writeText: mockWriteText, + }, + }); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it("should return a function", () => { + const { result } = renderHook(() => useCopyToClipboard(), { wrapper }); + expect(typeof result.current).toBe("function"); + }); + + it("should copy text to clipboard successfully", async () => { + const { result } = renderHook(() => useCopyToClipboard(), { wrapper }); + const testText = "Hello World"; + + mockWriteText.mockResolvedValue(undefined); + + result.current(testText); + + expect(mockWriteText).toHaveBeenCalledWith(testText); + + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + + it("should handle errors when copying to clipboard", async () => { + const { result } = renderHook(() => useCopyToClipboard(), { wrapper }); + const testText = "Test Error"; + const error = new Error("Clipboard error"); + + mockWriteText.mockRejectedValue(error); + + result.current(testText); + + expect(mockWriteText).toHaveBeenCalledWith(testText); + + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + + it("should call navigator.clipboard.writeText with the correct text", () => { + const { result } = renderHook(() => useCopyToClipboard(), { wrapper }); + const testTexts = ["Text 1", "Example code", ""]; + + mockWriteText.mockResolvedValue(undefined); + + for (const text of testTexts) { + result.current(text); + expect(mockWriteText).toHaveBeenCalledWith(text); + } + }); + + it("should work with multiple consecutive calls", () => { + const { result } = renderHook(() => useCopyToClipboard(), { wrapper }); + + mockWriteText.mockResolvedValue(undefined); + + result.current("First copy"); + result.current("Second copy"); + + expect(mockWriteText).toHaveBeenCalledTimes(2); + expect(mockWriteText).toHaveBeenNthCalledWith(1, "First copy"); + expect(mockWriteText).toHaveBeenNthCalledWith(2, "Second copy"); + }); +}); diff --git a/apps/website/test/theme-generator/BottomButtons.test.tsx b/apps/website/test/theme-generator/BottomButtons.test.tsx new file mode 100644 index 0000000000..b87784f494 --- /dev/null +++ b/apps/website/test/theme-generator/BottomButtons.test.tsx @@ -0,0 +1,37 @@ +import "@testing-library/jest-dom"; +import { fireEvent, render, screen } from "@testing-library/react"; +import BottomButtons from "../../screens/theme-generator/components/BottomButtons"; + +describe("BottomButtons", () => { + it("shows Back disabled and Next enabled on first step", () => { + const onChangeStep = jest.fn(); + const onExport = jest.fn(); + + render(); + + const backButton = screen.getByRole("button", { name: "Back" }); + const nextButton = screen.getByRole("button", { name: "Next" }); + + expect(backButton).toBeDisabled(); + expect(nextButton).toBeEnabled(); + + fireEvent.click(nextButton); + expect(onChangeStep).toHaveBeenCalledWith(1); + }); + + it("shows Export theme on last step and triggers export", () => { + const onChangeStep = jest.fn(); + const onExport = jest.fn(); + + render(); + + const backButton = screen.getByRole("button", { name: "Back" }); + const exportButton = screen.getByRole("button", { name: "Export theme" }); + + fireEvent.click(backButton); + expect(onChangeStep).toHaveBeenCalledWith(1); + + fireEvent.click(exportButton); + expect(onExport).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/website/test/theme-generator/BrandingDetails.test.tsx b/apps/website/test/theme-generator/BrandingDetails.test.tsx new file mode 100644 index 0000000000..022169e546 --- /dev/null +++ b/apps/website/test/theme-generator/BrandingDetails.test.tsx @@ -0,0 +1,114 @@ +import "@testing-library/jest-dom"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { BrandingDetails } from "../../screens/theme-generator/steps/BrandingDetails"; +import { Colors, Logos } from "../../screens/theme-generator/types"; +import { CssColor } from "@adobe/leonardo-contrast-colors"; + +// Mock ResizeObserver +global.ResizeObserver = jest.fn().mockImplementation(() => ({ + observe: jest.fn(), + unobserve: jest.fn(), + disconnect: jest.fn(), +})); + +// Mock SketchColorPicker to avoid react-color issues (Canvas/Context errors) +jest.mock("react-color", () => ({ + SketchPicker: ({ color, onChange }: { color: string; onChange: (color: { hex: string }) => void }) => ( +
+ Picker for {color} + +
+ ), +})); + +describe("BrandingDetails", () => { + it("renders color sections with ColorCard components", () => { + const onColorsChange = jest.fn(); + const onLogosChange = jest.fn(); + + const colors: Colors = { + primary: "#5F249F" as CssColor, + secondary: "#0067B3" as CssColor, + tertiary: "#F7CF2B" as CssColor, + neutral: "#999999" as CssColor, + info: "#0067B3" as CssColor, + success: "#59D97D" as CssColor, + error: "#FE344F" as CssColor, + warning: "#F59F3D" as CssColor, + }; + + const logos: Logos = { mainLogo: [], footerLogo: [], footerReducedLogo: [], favicon: [] }; + + render( + + ); + + expect(screen.getByDisplayValue("#5F249F")).toBeInTheDocument(); + expect(screen.getByText("Primary")).toBeInTheDocument(); + }); + + it("updates colors when input value changes", async () => { + const onColorsChange = jest.fn(); + const onLogosChange = jest.fn(); + + const colors: Colors = { + primary: "#5F249F" as CssColor, + secondary: "#0067B3" as CssColor, + tertiary: "#F7CF2B" as CssColor, + neutral: "#999999" as CssColor, + info: "#0067B3" as CssColor, + success: "#59D97D" as CssColor, + error: "#FE344F" as CssColor, + warning: "#F59F3D" as CssColor, + }; + + const logos: Logos = { mainLogo: [], footerLogo: [], footerReducedLogo: [], favicon: [] }; + + render( + + ); + + const primaryInput = screen.getByDisplayValue("#5F249F"); + fireEvent.change(primaryInput, { target: { value: "#111111" } }); + fireEvent.blur(primaryInput); + + await waitFor(() => { + expect(onColorsChange).toHaveBeenCalledWith( + expect.objectContaining({ + primary: "#111111", + secondary: "#0067B3", + }) + ); + }); + }); + + it("renders logo sections with FileInput components", () => { + const onColorsChange = jest.fn(); + const onLogosChange = jest.fn(); + + const colors: Colors = { + primary: "#5F249F" as CssColor, + secondary: "#0067B3" as CssColor, + tertiary: "#F7CF2B" as CssColor, + neutral: "#999999" as CssColor, + info: "#0067B3" as CssColor, + success: "#59D97D" as CssColor, + error: "#FE344F" as CssColor, + warning: "#F59F3D" as CssColor, + }; + + const logos: Logos = { + mainLogo: [], + footerLogo: [{ file: new File(["f"], "footer.png", { type: "image/png" }) }], + footerReducedLogo: [], + favicon: [], + }; + + render( + + ); + + expect(screen.getByText("Main logo")).toBeInTheDocument(); + expect(screen.getByText("Default footer logo")).toBeInTheDocument(); + }); +}); diff --git a/apps/website/test/theme-generator/ColorCard.test.tsx b/apps/website/test/theme-generator/ColorCard.test.tsx new file mode 100644 index 0000000000..f723d0d7e5 --- /dev/null +++ b/apps/website/test/theme-generator/ColorCard.test.tsx @@ -0,0 +1,315 @@ +import "@testing-library/jest-dom"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { ColorCard } from "../../screens/theme-generator/components/branding/ColorCard"; + +// Mock ResizeObserver +global.ResizeObserver = jest.fn().mockImplementation(() => ({ + observe: jest.fn(), + unobserve: jest.fn(), + disconnect: jest.fn(), +})); + +const mockHandleCopy = jest.fn(); + +jest.mock("hooks/useCopyToClipboard", () => ({ + __esModule: true, + default: () => mockHandleCopy, +})); + +// Mock SketchColorPicker to avoid react-color issues (Canvas/Context errors) +jest.mock("react-color", () => ({ + SketchPicker: ({ + color, + onChange, + onErrorChange, + }: { + color: string; + onChange: (color: { hex: string }) => void; + onErrorChange?: (error: boolean) => void; + }) => ( +
+ Picker for {color} + + + +
+ ), +})); + +describe("ColorCard", () => { + const defaultProps = { + label: "Primary Color", + helperText: "Main brand color", + color: "#5F249F", + onChange: jest.fn(), + }; + + describe("Rendering", () => { + it("should render with initial color and label", () => { + render(); + + expect(screen.getByText("Primary Color")).toBeInTheDocument(); + expect(screen.getByText("Main brand color")).toBeInTheDocument(); + }); + + it("should render color box button", () => { + render(); + + const colorBox = screen.getByLabelText("Open color picker for Primary Color"); + expect(colorBox).toBeInTheDocument(); + expect(colorBox).toHaveAttribute("tabIndex", "0"); + }); + + it("should render with initial color value in input", () => { + render(); + + const input = screen.getByDisplayValue("#5F249F"); + expect(input).toBeInTheDocument(); + }); + }); + + describe("Color Picker Popover", () => { + it("should open sketch picker when clicking color box", async () => { + render(); + + const colorBox = screen.getByLabelText("Open color picker for Primary Color"); + fireEvent.click(colorBox); + + await waitFor(() => { + expect(screen.getByTestId("mock-sketch-picker")).toBeInTheDocument(); + }); + }); + + it("should call onChange when selecting color from picker", async () => { + const onChange = jest.fn(); + render(); + + const colorBox = screen.getByLabelText("Open color picker for Primary Color"); + fireEvent.click(colorBox); + + await waitFor(() => { + expect(screen.getByTestId("mock-sketch-picker")).toBeInTheDocument(); + }); + + const selectWhiteButton = screen.getByText("Select White"); + fireEvent.click(selectWhiteButton); + + expect(onChange).toHaveBeenCalledWith("#ffffff"); + }); + + it("should update input value when changing color from picker", async () => { + render(); + + const colorBox = screen.getByLabelText("Open color picker for Primary Color"); + fireEvent.click(colorBox); + + await waitFor(() => { + expect(screen.getByTestId("mock-sketch-picker")).toBeInTheDocument(); + }); + + const selectWhiteButton = screen.getByText("Select White"); + fireEvent.click(selectWhiteButton); + + await waitFor(() => { + expect(screen.getByDisplayValue("#ffffff")).toBeInTheDocument(); + }); + }); + }); + + describe("Text Input", () => { + it("should update input value when typing", () => { + render(); + + const input = screen.getByDisplayValue("#5F249F"); + fireEvent.change(input, { target: { value: "#123456" } }); + + expect(screen.getByDisplayValue("#123456")).toBeInTheDocument(); + }); + + it("should call onChange with valid hex color on blur", async () => { + const onChange = jest.fn(); + render(); + + const input = screen.getByDisplayValue("#5F249F"); + fireEvent.change(input, { target: { value: "#123456" } }); + fireEvent.blur(input); + + await waitFor(() => { + expect(onChange).toHaveBeenCalledWith("#123456"); + }); + }); + + it("should accept 3-character hex colors", async () => { + const onChange = jest.fn(); + render(); + + const input = screen.getByDisplayValue("#5F249F"); + fireEvent.change(input, { target: { value: "#fff" } }); + fireEvent.blur(input); + + await waitFor(() => { + expect(onChange).toHaveBeenCalledWith("#fff"); + }); + }); + + it("should accept 6-character hex colors", async () => { + const onChange = jest.fn(); + render(); + + const input = screen.getByDisplayValue("#5F249F"); + fireEvent.change(input, { target: { value: "#ffffff" } }); + fireEvent.blur(input); + + await waitFor(() => { + expect(onChange).toHaveBeenCalledWith("#ffffff"); + }); + }); + + it("should show error for invalid hex format", async () => { + const onChange = jest.fn(); + render(); + + const input = screen.getByDisplayValue("#5F249F"); + fireEvent.change(input, { target: { value: "invalid" } }); + fireEvent.blur(input); + + await waitFor(() => { + expect(screen.getByText("Please match the format requested.")).toBeInTheDocument(); + }); + expect(onChange).not.toHaveBeenCalled(); + }); + + it("should show error for hex without # prefix", async () => { + const onChange = jest.fn(); + render(); + + const input = screen.getByDisplayValue("#5F249F"); + fireEvent.change(input, { target: { value: "123456" } }); + fireEvent.blur(input); + + await waitFor(() => { + expect(screen.getByText("Please match the format requested.")).toBeInTheDocument(); + }); + expect(onChange).not.toHaveBeenCalled(); + }); + + it("should show error for hex with wrong length", async () => { + const onChange = jest.fn(); + render(); + + const input = screen.getByDisplayValue("#5F249F"); + fireEvent.change(input, { target: { value: "#12345" } }); + fireEvent.blur(input); + + await waitFor(() => { + expect(screen.getByText("Please match the format requested.")).toBeInTheDocument(); + }); + expect(onChange).not.toHaveBeenCalled(); + }); + }); + + describe("Copy to Clipboard", () => { + it("should copy current input value when clicking copy button", () => { + render(); + + const copyButton = screen.getByLabelText("Copy the hex value"); + fireEvent.click(copyButton); + + expect(mockHandleCopy).toHaveBeenCalledWith("#5F249F"); + }); + + it("should copy updated value after input change", () => { + render(); + + const input = screen.getByDisplayValue("#5F249F"); + fireEvent.change(input, { target: { value: "#ABCDEF" } }); + + const copyButton = screen.getByLabelText("Copy the hex value"); + fireEvent.click(copyButton); + + expect(mockHandleCopy).toHaveBeenCalledWith("#ABCDEF"); + }); + + it("should copy value from color picker change", async () => { + render(); + + const colorBox = screen.getByLabelText("Open color picker for Primary Color"); + fireEvent.click(colorBox); + + await waitFor(() => { + expect(screen.getByTestId("mock-sketch-picker")).toBeInTheDocument(); + }); + + const selectWhiteButton = screen.getByText("Select White"); + fireEvent.click(selectWhiteButton); + + const copyButton = screen.getByLabelText("Copy the hex value"); + fireEvent.click(copyButton); + + expect(mockHandleCopy).toHaveBeenCalledWith("#ffffff"); + }); + }); + + describe("Accessibility", () => { + it("should have proper aria attributes on color box", () => { + render(); + + const colorBox = screen.getByLabelText("Open color picker for Primary Color"); + expect(colorBox).toHaveAttribute("aria-label", "Open color picker for Primary Color"); + expect(colorBox).toHaveAttribute("aria-haspopup", "dialog"); + }); + + it("should have accessible label for input", () => { + render(); + + expect(screen.getByText("Primary Color")).toBeInTheDocument(); + }); + + it("should have helper text", () => { + render(); + + expect(screen.getByText("Main brand color")).toBeInTheDocument(); + }); + }); + + describe("Edge Cases", () => { + it("should handle uppercase hex colors", async () => { + const onChange = jest.fn(); + render(); + + const input = screen.getByDisplayValue("#5F249F"); + fireEvent.change(input, { target: { value: "#FFFFFF" } }); + fireEvent.blur(input); + + await waitFor(() => { + expect(onChange).toHaveBeenCalledWith("#FFFFFF"); + }); + }); + + it("should handle lowercase hex colors", async () => { + const onChange = jest.fn(); + render(); + + const input = screen.getByDisplayValue("#5F249F"); + fireEvent.change(input, { target: { value: "#ffffff" } }); + fireEvent.blur(input); + + await waitFor(() => { + expect(onChange).toHaveBeenCalledWith("#ffffff"); + }); + }); + + it("should handle mixed case hex colors", async () => { + const onChange = jest.fn(); + render(); + + const input = screen.getByDisplayValue("#5F249F"); + fireEvent.change(input, { target: { value: "#FfFfFf" } }); + fireEvent.blur(input); + + await waitFor(() => { + expect(onChange).toHaveBeenCalledWith("#FfFfFf"); + }); + }); + }); +}); diff --git a/apps/website/test/theme-generator/ReviewDetails.test.tsx b/apps/website/test/theme-generator/ReviewDetails.test.tsx new file mode 100644 index 0000000000..6b9295ff6e --- /dev/null +++ b/apps/website/test/theme-generator/ReviewDetails.test.tsx @@ -0,0 +1,54 @@ +import "@testing-library/jest-dom"; +import { fireEvent, render, screen } from "@testing-library/react"; +import ReviewDetails from "../../screens/theme-generator/steps/ReviewDetails"; + +// Mock @adobe/leonardo-contrast-colors (ESM module) +jest.mock("@adobe/leonardo-contrast-colors", () => { + return { + Color: jest.fn(), + }; +}); + +// Mock ResizeObserver +global.ResizeObserver = jest.fn().mockImplementation(() => ({ + observe: jest.fn(), + unobserve: jest.fn(), + disconnect: jest.fn(), +})); + +const mockHandleCopy = jest.fn(); + +jest.mock("hooks/useCopyToClipboard", () => ({ + __esModule: true, + default: () => mockHandleCopy, +})); + +describe("ReviewDetails", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("renders review sections with real components", () => { + const tokens = { "--color-primary-500": "#101010" }; + const logos = { mainLogo: [], footerLogo: [], footerReducedLogo: [], favicon: [] }; + const themeJson = '{"tokens":{"--color-primary-500":"#101010"}}'; + + render(); + + expect(screen.getByText("Color palette & theme")).toBeInTheDocument(); + expect(screen.getByText("Branding assets")).toBeInTheDocument(); + }); + + it("copies theme json when clicking copy button", () => { + const tokens = { "--color-primary-500": "#101010" }; + const logos = { mainLogo: [], footerLogo: [], footerReducedLogo: [], favicon: [] }; + const themeJson = '{"tokens":{"--color-primary-500":"#101010"}}'; + + render(); + + const copyButton = screen.getByLabelText("Copy theme"); + fireEvent.click(copyButton); + + expect(mockHandleCopy).toHaveBeenCalledWith(themeJson); + }); +}); diff --git a/apps/website/test/theme-generator/ThemeGeneratorConfigPage.test.tsx b/apps/website/test/theme-generator/ThemeGeneratorConfigPage.test.tsx new file mode 100644 index 0000000000..edf2951cd1 --- /dev/null +++ b/apps/website/test/theme-generator/ThemeGeneratorConfigPage.test.tsx @@ -0,0 +1,163 @@ +import "@testing-library/jest-dom"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import ThemeGeneratorConfigPage from "../../screens/theme-generator/ThemeGeneratorConfigPage"; + +const mockGenerateTokens = jest.fn((_: Record) => ({ "--color-primary-500": "#101010" })); +const mockHandleExport = jest.fn((_: string) => undefined); + +jest.mock("../../screens/theme-generator/utils", () => ({ + generateTokens: (baseColors: Record) => mockGenerateTokens(baseColors), + handleExport: (themeJson: string) => mockHandleExport(themeJson), + divideColorTokens: (tokens: Record) => ({ + primary: [tokens["--color-primary-500"]], + secondary: [], + tertiary: [], + semantic01: [], + semantic02: [], + semantic03: [], + semantic04: [], + neutral: [], + alpha: [], + }), + SHADE_VALUES: [50, 100, 200, 300, 400, 500, 600, 700, 800, 900], + CONTRAST_RATIOS: [1.03, 1.18, 1.34, 1.52, 2.04, 2.79, 4.3, 6.7, 9, 12.46], +})); + +// Mock @adobe/leonardo-contrast-colors (ESM module) +jest.mock("@adobe/leonardo-contrast-colors", () => ({ + Color: jest.fn(), +})); + +// Mock ResizeObserver +global.ResizeObserver = jest.fn().mockImplementation(() => ({ + observe: jest.fn(), + unobserve: jest.fn(), + disconnect: jest.fn(), +})); + +// Mock useCopyToClipboard +jest.mock("hooks/useCopyToClipboard", () => ({ + __esModule: true, + default: () => jest.fn(), +})); + +// Mock react-color SketchPicker +jest.mock("react-color", () => ({ + SketchPicker: () =>
Color Picker
, +})); + +// Mock componentsRegistry and examplesRegistry +jest.mock("../../screens/theme-generator/componentsRegistry", () => ({ + componentsRegistry: {}, + examplesRegistry: {}, +})); + +describe("ThemeGeneratorConfigPage", () => { + beforeEach(() => { + mockGenerateTokens.mockClear(); + mockHandleExport.mockClear(); + }); + + it("shows initial step content and heading", () => { + render(); + + expect(screen.getByText("Add your theme specifics")).toBeInTheDocument(); + expect(screen.getByText("Core colors")).toBeInTheDocument(); + expect(screen.getByText("Semantic colors")).toBeInTheDocument(); + expect(screen.getByLabelText("Primary")).toBeInTheDocument(); + }); + + it("generates tokens when leaving step 0", async () => { + render(); + + const nextButton = screen.getByRole("button", { name: "Next" }); + fireEvent.click(nextButton); + + await waitFor(() => { + expect(mockGenerateTokens).toHaveBeenCalledTimes(1); + }); + + expect(mockGenerateTokens).toHaveBeenCalledWith({ + primary: "#5F249F", + secondary: "#0067B3", + tertiary: "#F7CF2B", + semantic01: "#0067B3", + semantic02: "#59D97D", + semantic03: "#F59F3D", + semantic04: "#FE344F", + neutral: "#999999", + }); + + expect(screen.getByText("Preview how your theme applies")).toBeInTheDocument(); + }); + + it("does not regenerate tokens when moving from step 1 to step 2 with unchanged colors", async () => { + render(); + + const nextButton = screen.getByRole("button", { name: "Next" }); + fireEvent.click(nextButton); + + await waitFor(() => { + expect(mockGenerateTokens).toHaveBeenCalledTimes(1); + }); + + fireEvent.click(nextButton); + + await waitFor(() => { + expect(screen.getByText("Review and export your theme")).toBeInTheDocument(); + }); + + expect(mockGenerateTokens).toHaveBeenCalledTimes(1); + }); + + it("regenerates tokens with updated colors", async () => { + render(); + + const primaryInput = screen.getByLabelText("Primary"); + fireEvent.change(primaryInput, { target: { value: "#123456" } }); + fireEvent.blur(primaryInput); + + const nextButton = screen.getByRole("button", { name: "Next" }); + fireEvent.click(nextButton); + + await waitFor(() => { + expect(mockGenerateTokens).toHaveBeenCalledTimes(1); + }); + + expect(mockGenerateTokens).toHaveBeenCalledWith( + expect.objectContaining({ + primary: "#123456", + }) + ); + }); + + it("exports the generated theme json", async () => { + render(); + + // Navigate to step 2 + const nextButton = screen.getByRole("button", { name: "Next" }); + fireEvent.click(nextButton); + + await waitFor(() => { + expect(mockGenerateTokens).toHaveBeenCalledTimes(1); + }); + + fireEvent.click(nextButton); + + await waitFor(() => { + expect(screen.getByText("Review and export your theme")).toBeInTheDocument(); + }); + + const exportButton = screen.getByRole("button", { name: "Export theme" }); + fireEvent.click(exportButton); + + expect(mockHandleExport).toHaveBeenCalledTimes(1); + const exportedTheme = mockHandleExport.mock.calls.at(0)?.[0]; + expect(exportedTheme).toBeDefined(); + if (!exportedTheme) return; + + expect(exportedTheme).toContain('"tokens": {'); + expect(exportedTheme).toContain('"--color-primary-500": "#101010"'); + expect(exportedTheme).toContain('"logos": {'); + }); +}); diff --git a/apps/website/test/theme-generator/ThemeGeneratorPreviewPage.test.tsx b/apps/website/test/theme-generator/ThemeGeneratorPreviewPage.test.tsx new file mode 100644 index 0000000000..79a1bab72f --- /dev/null +++ b/apps/website/test/theme-generator/ThemeGeneratorPreviewPage.test.tsx @@ -0,0 +1,60 @@ +import "@testing-library/jest-dom"; +import { fireEvent, render, screen } from "@testing-library/react"; +import ThemeGeneratorPreviewPage from "../../screens/theme-generator/ThemeGeneratorPreviewPage"; +import { Logos } from "../../screens/theme-generator/types"; + +// Mock ResizeObserver +global.ResizeObserver = jest.fn().mockImplementation(() => ({ + observe: jest.fn(), + unobserve: jest.fn(), + disconnect: jest.fn(), +})); + +jest.mock("screens/theme-generator/componentsRegistry", () => ({ + componentsRegistry: { + "/components/button": () =>
Button Component
, + }, + examplesRegistry: { + "/examples/application": ({ logos }: { logos: Logos }) => { + const mainLogoPreview = logos.mainLogo[0]?.preview ?? "no-logo"; + return
Application Example {mainLogoPreview}
; + }, + }, +})); + +describe("ThemeGeneratorPreviewPage", () => { + it("shows empty state and renders component preview after selection", () => { + const logos = { mainLogo: [], footerLogo: [], footerReducedLogo: [], favicon: [] }; + + render(); + + expect(screen.getByText("Select a component to preview")).toBeInTheDocument(); + + fireEvent.click(screen.getByRole("combobox", { name: "Select" })); + + const buttonOption = screen.getByText("Button"); + fireEvent.click(buttonOption); + + expect(screen.getByText("Button Component")).toBeInTheDocument(); + }); + + it("switches to examples mode and renders selected example", () => { + const logos = { + mainLogo: [{ file: new File(["x"], "main.png"), preview: "main-preview" }], + footerLogo: [], + footerReducedLogo: [], + favicon: [], + }; + + render(); + + fireEvent.click(screen.getByRole("button", { name: "Layout examples" })); + + fireEvent.click(screen.getByRole("combobox", { name: "Select" })); + + const appExample = screen.getByText("Application example"); + fireEvent.click(appExample); + + expect(screen.getByText("Application Example main-preview")).toBeInTheDocument(); + }); +}); diff --git a/apps/website/test/theme-generator/utils.test.ts b/apps/website/test/theme-generator/utils.test.ts new file mode 100644 index 0000000000..39798ea69e --- /dev/null +++ b/apps/website/test/theme-generator/utils.test.ts @@ -0,0 +1,336 @@ +import "@testing-library/jest-dom"; +import { CssColor } from "@adobe/leonardo-contrast-colors"; +import { + CONTRAST_RATIOS, + SHADE_VALUES, + generatePalette, + generateTokens, + handleExport, + divideColorTokens, +} from "../../screens/theme-generator/utils"; + +const mockValues = [ + { value: "#fff5f5" }, + { value: "#ffe5e5" }, + { value: "#ffd5d5" }, + { value: "#ffc5c5" }, + { value: "#ffb5b5" }, + { value: "#ffa5a5" }, + { value: "#ff9595" }, + { value: "#ff8585" }, + { value: "#ff7575" }, + { value: "#ff6565" }, +]; + +jest.mock("@adobe/leonardo-contrast-colors", () => ({ + Color: jest.fn().mockImplementation(({ colorKeys }: { colorKeys: string[] }) => { + if (colorKeys[0] === "throw") { + throw new Error("Invalid color"); + } + return {}; + }), + BackgroundColor: jest.fn().mockImplementation(() => ({})), + Theme: jest.fn().mockImplementation(() => ({ + contrastColors: [null, { values: mockValues }], + })), +})); + +describe("theme-generator utils", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe("Constants", () => { + it("should export CONTRAST_RATIOS with correct length", () => { + expect(CONTRAST_RATIOS).toHaveLength(10); + expect(CONTRAST_RATIOS[0]).toBe(1.03); + expect(CONTRAST_RATIOS[9]).toBe(12.46); + }); + + it("should export SHADE_VALUES with correct values", () => { + expect(SHADE_VALUES).toEqual([50, 100, 200, 300, 400, 500, 600, 700, 800, 900]); + }); + }); + + describe("generatePalette", () => { + it("should generate palette with 10 colors", () => { + const palette = generatePalette("#FF5733" as CssColor); + expect(palette).toHaveLength(10); + }); + + it("should return array of hex colors", () => { + const palette = generatePalette("#FF5733" as CssColor); + palette.forEach((color) => { + expect(color).toMatch(/^#[0-9a-fA-F]{6}$/); + }); + }); + + it("should handle errors and return empty array", () => { + const consoleSpy = jest.spyOn(console, "error").mockImplementation(() => undefined); + + const palette = generatePalette("throw" as CssColor); + + expect(palette).toEqual([]); + expect(consoleSpy).toHaveBeenCalled(); + }); + + it("should return consistent palette structure", () => { + const palette = generatePalette("#5F249F" as CssColor); + expect(palette).toHaveLength(10); + expect(Array.isArray(palette)).toBe(true); + }); + + it("should return correct values from mocked Leonardo", () => { + const palette = generatePalette("#5F249F" as CssColor); + expect(palette[0]).toBe("#fff5f5"); + expect(palette[9]).toBe("#ff6565"); + }); + }); + + describe("generateTokens", () => { + it("should include base color tokens for all provided colors", () => { + const tokens = generateTokens({ + primary: "#111111" as CssColor, + secondary: "#222222" as CssColor, + neutral: "#333333" as CssColor, + }); + + expect(tokens["--color-primary-50"]).toBeDefined(); + expect(tokens["--color-primary-900"]).toBeDefined(); + expect(tokens["--color-secondary-50"]).toBeDefined(); + expect(tokens["--color-secondary-900"]).toBeDefined(); + expect(tokens["--color-neutral-50"]).toBeDefined(); + expect(tokens["--color-neutral-900"]).toBeDefined(); + }); + + it("should include alpha tokens", () => { + const tokens = generateTokens({ + primary: "#111111" as CssColor, + neutral: "#333333" as CssColor, + }); + + expect(tokens["--color-alpha-100-a"]).toBeDefined(); + expect(tokens["--color-alpha-200-a"]).toBeDefined(); + expect(tokens["--color-alpha-900-a"]).toBeDefined(); + }); + + it("should include absolute color tokens", () => { + const tokens = generateTokens({ + primary: "#111111" as CssColor, + }); + + expect(tokens["--color-absolutes-black"]).toBe("#000000"); + expect(tokens["--color-absolutes-white"]).toBe("#ffffff"); + }); + + it("should generate correct alpha values with opacity", () => { + const tokens = generateTokens({ + neutral: "#999999" as CssColor, + }); + + expect(tokens["--color-alpha-100-a"]).toMatch(/^#[0-9a-fA-F]{6}1a$/); + expect(tokens["--color-alpha-900-a"]).toMatch(/^#[0-9a-fA-F]{6}e5$/); + }); + + it("should handle single color input", () => { + const tokens = generateTokens({ + primary: "#FF5733" as CssColor, + }); + + expect(Object.keys(tokens).length).toBeGreaterThan(0); + expect(tokens["--color-primary-500"]).toBeDefined(); + }); + + it("should convert color names to lowercase in tokens", () => { + const tokens = generateTokens({ + Primary: "#FF5733" as CssColor, + }); + + expect(tokens["--color-primary-50"]).toBeDefined(); + }); + + it("should use default neutral color if not provided", () => { + const tokens = generateTokens({ + primary: "#FF5733" as CssColor, + }); + + // Alpha tokens should still be generated with default neutral + expect(tokens["--color-alpha-100-a"]).toBeDefined(); + }); + }); + + describe("handleExport", () => { + let anchor: HTMLAnchorElement; + let clickSpy: jest.SpyInstance; + let createElementSpy: jest.SpyInstance; + let appendChildSpy: jest.SpyInstance; + let removeSpy: jest.SpyInstance; + + beforeEach(() => { + anchor = document.createElement("a"); + clickSpy = jest.spyOn(anchor, "click").mockImplementation(() => undefined); + removeSpy = jest.spyOn(anchor, "remove").mockImplementation(() => undefined); + createElementSpy = jest.spyOn(document, "createElement").mockReturnValue(anchor); + appendChildSpy = jest.spyOn(document.body, "appendChild").mockImplementation(() => anchor); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it("should create an anchor element", () => { + handleExport('{"tokens":{}}'); + expect(createElementSpy).toHaveBeenCalledWith("a"); + }); + + it("should set correct download attribute", () => { + handleExport('{"tokens":{}}'); + expect(anchor.getAttribute("download")).toBe("halstack-theme.json"); + }); + + it("should set href with encoded JSON data", () => { + handleExport('{"tokens":{}}'); + expect(anchor.getAttribute("href")).toContain("data:text/json;charset=utf-8,"); + expect(anchor.getAttribute("href")).toContain(encodeURIComponent('{"tokens":{}}')); + }); + + it("should append anchor to body", () => { + handleExport('{"tokens":{}}'); + expect(appendChildSpy).toHaveBeenCalledWith(anchor); + }); + + it("should click the anchor element", () => { + handleExport('{"tokens":{}}'); + expect(clickSpy).toHaveBeenCalledTimes(1); + }); + + it("should remove anchor after click", () => { + handleExport('{"tokens":{}}'); + expect(removeSpy).toHaveBeenCalledTimes(1); + }); + + it("should handle complex JSON data", () => { + const complexJson = JSON.stringify({ + tokens: { + "--color-primary-500": "#FF5733", + "--color-secondary-500": "#33FF57", + }, + metadata: { version: "1.0" }, + }); + + handleExport(complexJson); + expect(anchor.getAttribute("href")).toContain(encodeURIComponent(complexJson)); + }); + + it("should handle empty JSON", () => { + handleExport("{}"); + expect(clickSpy).toHaveBeenCalled(); + }); + }); + + describe("divideColorTokens", () => { + it("should group tokens by color type", () => { + const grouped = divideColorTokens({ + "--color-primary-500": "#111111", + "--color-secondary-500": "#222222", + "--color-tertiary-500": "#333333", + }); + + expect(grouped.primary).toEqual(["#111111"]); + expect(grouped.secondary).toEqual(["#222222"]); + expect(grouped.tertiary).toEqual(["#333333"]); + }); + + it("should group semantic color tokens", () => { + const grouped = divideColorTokens({ + "--color-semantic01-500": "#111111", + "--color-semantic02-600": "#222222", + "--color-semantic03-700": "#333333", + "--color-semantic04-800": "#444444", + }); + + expect(grouped.semantic01).toEqual(["#111111"]); + expect(grouped.semantic02).toEqual(["#222222"]); + expect(grouped.semantic03).toEqual(["#333333"]); + expect(grouped.semantic04).toEqual(["#444444"]); + }); + + it("should group alpha tokens", () => { + const grouped = divideColorTokens({ + "--color-alpha-100-a": "#33333333", + "--color-alpha-200-a": "#33333366", + }); + + expect(grouped.alpha).toEqual(["#33333333", "#33333366"]); + }); + + it("should group neutral tokens", () => { + const grouped = divideColorTokens({ + "--color-neutral-300": "#999999", + "--color-neutral-500": "#666666", + }); + + expect(grouped.neutral).toEqual(["#999999", "#666666"]); + }); + + it("should ignore non-color tokens", () => { + const grouped = divideColorTokens({ + "--color-primary-500": "#111111", + "--spacing-padding-s": "8px", + "--font-size-large": "16px", + "--border-radius": "4px", + }); + + expect(grouped.primary).toEqual(["#111111"]); + expect(Object.values(grouped).flat()).toHaveLength(1); + }); + + it("should handle multiple values for same color group", () => { + const grouped = divideColorTokens({ + "--color-primary-50": "#fff5f5", + "--color-primary-100": "#ffe5e5", + "--color-primary-200": "#ffd5d5", + "--color-primary-500": "#ff5733", + }); + + expect(grouped.primary).toHaveLength(4); + expect(grouped.primary).toContain("#fff5f5"); + expect(grouped.primary).toContain("#ff5733"); + }); + + it("should return empty arrays for unused color groups", () => { + const grouped = divideColorTokens({ + "--color-primary-500": "#111111", + }); + + expect(grouped.secondary).toEqual([]); + expect(grouped.tertiary).toEqual([]); + expect(grouped.neutral).toEqual([]); + expect(grouped.alpha).toEqual([]); + }); + + it("should handle empty input", () => { + const grouped = divideColorTokens({}); + + expect(grouped.primary).toEqual([]); + expect(grouped.secondary).toEqual([]); + expect(grouped.tertiary).toEqual([]); + expect(grouped.semantic01).toEqual([]); + expect(grouped.semantic02).toEqual([]); + expect(grouped.semantic03).toEqual([]); + expect(grouped.semantic04).toEqual([]); + expect(grouped.neutral).toEqual([]); + expect(grouped.alpha).toEqual([]); + }); + + it("should preserve order of tokens within each group", () => { + const grouped = divideColorTokens({ + "--color-primary-900": "#333333", + "--color-primary-50": "#111111", + "--color-primary-500": "#222222", + }); + + expect(grouped.primary).toEqual(["#333333", "#111111", "#222222"]); + }); + }); +}); diff --git a/apps/website/tsconfig.json b/apps/website/tsconfig.json index aa9c8211b2..ba2885ac0c 100644 --- a/apps/website/tsconfig.json +++ b/apps/website/tsconfig.json @@ -9,6 +9,6 @@ "@/common/*": ["screens/common/*"] } }, - "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts", "next.config.ts"], + "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts", "next.config.ts", "test"], "exclude": ["node_modules", "out", ".turbo", ".next"] } diff --git a/package-lock.json b/package-lock.json index 1e5f82d66a..5f491c6d27 100644 --- a/package-lock.json +++ b/package-lock.json @@ -51,12 +51,17 @@ "devDependencies": { "@dxc-technology/typescript-config": "*", "@eslint/js": "^9.31.1", + "@testing-library/jest-dom": "^6.8.0", + "@testing-library/react": "^16.3.0", + "@types/jest": "^29.5.12", "@types/node": "^20", "@types/react": "^18", "@types/react-color": "^3.0.6", "@types/react-dom": "^18", "eslint": "^9.39.1", "eslint-config-next": "16.0.8", + "jest": "^30.2.0", + "jest-environment-jsdom": "^30.2.0", "typescript": "^5.6.3" } }, diff --git a/packages/lib/src/resultset-table/ResultsetTable.tsx b/packages/lib/src/resultset-table/ResultsetTable.tsx index c5dce75d66..04fd302bad 100644 --- a/packages/lib/src/resultset-table/ResultsetTable.tsx +++ b/packages/lib/src/resultset-table/ResultsetTable.tsx @@ -122,7 +122,7 @@ const DxcResultsetTable = ({ ); const renderPaginator = () => - !hidePaginator && rows.length > itemsPerPage ? ( + !hidePaginator && (rows.length > itemsPerPage || !!itemsPerPageOptions?.length || itemsPerPageFunction != null) ? (