diff --git a/__tests__/ad-block-modal.test.tsx b/__tests__/ad-block-modal.test.tsx deleted file mode 100644 index 8a36b2e..0000000 --- a/__tests__/ad-block-modal.test.tsx +++ /dev/null @@ -1,65 +0,0 @@ -import { fireEvent, render, screen } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; -import { beforeEach, describe, expect, it, vi } from "vitest"; -import { AdBlockModal } from "@/components/molecules/ad-block-modal"; - -describe("AdBlockModal", () => { - const mockOnClose = vi.fn(); - const mockOnConfirm = vi.fn(); - - beforeEach(() => { - vi.clearAllMocks(); - }); - - it("does not render when isOpen is false", () => { - const { container } = render(); - expect(container.querySelector("h2")).toBeNull(); - }); - - it("renders modal content when isOpen is true", () => { - render(); - expect(screen.getByText("Opa! Uma ajudinha?")).toBeDefined(); - expect(screen.getByText("Prometemos não ser chatos")).toBeDefined(); - expect(screen.getByText("Já desativei, pode contar comigo!")).toBeDefined(); - expect(screen.getByText("Continuar com AdBlock ativo")).toBeDefined(); - }); - - it("calls onConfirm when confirm button is clicked", async () => { - const user = userEvent.setup(); - render(); - await user.click(screen.getByText("Já desativei, pode contar comigo!")); - expect(mockOnConfirm).toHaveBeenCalledOnce(); - }); - - it("calls onClose when continue button is clicked", async () => { - const user = userEvent.setup(); - render(); - await user.click(screen.getByText("Continuar com AdBlock ativo")); - expect(mockOnClose).toHaveBeenCalled(); - }); - - it("calls onClose when X button is clicked", async () => { - const user = userEvent.setup(); - render(); - await user.click( - screen.getByRole("button", { - name: "Fechar aviso do bloqueador de anúncios", - }), - ); - expect(mockOnClose).toHaveBeenCalledOnce(); - }); - - it("opens as a modal dialog named by its heading", () => { - const { container } = render(); - const dialog = container.querySelector("dialog") as HTMLDialogElement; - expect(dialog.open).toBe(true); - expect(dialog.getAttribute("aria-labelledby")).toBe("ad-block-modal-title"); - expect(screen.getByText("Opa! Uma ajudinha?").id).toBe("ad-block-modal-title"); - }); - - it("calls onClose when the native close event fires", () => { - const { container } = render(); - fireEvent(container.querySelector("dialog") as Element, new Event("close")); - expect(mockOnClose).toHaveBeenCalledOnce(); - }); -}); diff --git a/__tests__/ad-block-notice.test.tsx b/__tests__/ad-block-notice.test.tsx new file mode 100644 index 0000000..bec3cd2 --- /dev/null +++ b/__tests__/ad-block-notice.test.tsx @@ -0,0 +1,52 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { AdBlockNotice } from "@/components/molecules/ad-block-notice"; + +describe("AdBlockNotice", () => { + const mockOnClose = vi.fn(); + const mockOnConfirm = vi.fn(); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("does not render when isOpen is false", () => { + const { container } = render(); + expect(container.innerHTML).toBe(""); + }); + + it("renders the notice content when isOpen is true", () => { + render(); + expect(screen.getByText("Opa! Uma ajudinha?")).toBeDefined(); + expect(screen.getByText("Prometemos não ser chatos")).toBeDefined(); + expect(screen.getByText("Já desativei, pode contar comigo!")).toBeDefined(); + expect(screen.getByText("Continuar com AdBlock ativo")).toBeDefined(); + }); + + it("renders as a dismissible footer notice instead of a blocking dialog", () => { + const { container } = render(); + + expect(container.querySelector("dialog")).toBeNull(); + + const notice = screen.getByRole("complementary"); + expect(notice.getAttribute("aria-labelledby")).toBe("ad-block-notice-title"); + expect(screen.getByText("Opa! Uma ajudinha?").id).toBe("ad-block-notice-title"); + expect(notice.className).toContain("fixed"); + expect(notice.className).toContain("env(safe-area-inset-bottom)"); + }); + + it("calls onConfirm when confirm button is clicked", async () => { + const user = userEvent.setup(); + render(); + await user.click(screen.getByText("Já desativei, pode contar comigo!")); + expect(mockOnConfirm).toHaveBeenCalledOnce(); + }); + + it("calls onClose when the dismiss button is clicked", async () => { + const user = userEvent.setup(); + render(); + await user.click(screen.getByText("Continuar com AdBlock ativo")); + expect(mockOnClose).toHaveBeenCalledOnce(); + }); +}); diff --git a/__tests__/ad-manager.test.tsx b/__tests__/ad-manager.test.tsx index 6514236..d59c8e6 100644 --- a/__tests__/ad-manager.test.tsx +++ b/__tests__/ad-manager.test.tsx @@ -5,7 +5,8 @@ import { AdManager } from "@/components/organisms/ad-manager"; const MOCK_ADSENSE_ID = "ca-pub-123456789"; const SIDE_AD_KEY = "workload_side_ads_last_view"; const VIDEO_AD_KEY = "workload_video_ad_last_view"; -const _ONE_WEEK_MS = 7 * 24 * 60 * 60 * 1000; +const VIDEO_AD_DELAY_MS = 120000; +const REQUIRED_IDLE_MS = 30000; const mockFetch = vi.fn(); global.fetch = mockFetch; @@ -16,6 +17,17 @@ Object.defineProperty(window, "location", { value: { ...window.location, reload: mockReload }, }); +const enableAdsEnv = () => { + vi.stubEnv("NEXT_PUBLIC_ADSENSE_ID", MOCK_ADSENSE_ID); + vi.stubEnv("NEXT_PUBLIC_ENABLE_ADS", "true"); +}; + +const advanceBy = (milliseconds: number) => { + act(() => { + vi.advanceTimersByTime(milliseconds); + }); +}; + describe("AdManager", () => { beforeEach(() => { localStorage.clear(); @@ -32,83 +44,103 @@ describe("AdManager", () => { }); it("checks cooldowns in localStorage on mount", () => { - vi.stubEnv("NEXT_PUBLIC_ADSENSE_ID", MOCK_ADSENSE_ID); - vi.stubEnv("NEXT_PUBLIC_ENABLE_ADS", "true"); + enableAdsEnv(); const spy = vi.spyOn(Storage.prototype, "getItem"); render(); expect(spy).toHaveBeenCalledWith(SIDE_AD_KEY); expect(spy).toHaveBeenCalledWith(VIDEO_AD_KEY); }); - it("shows side ads after 2 seconds when no previous view exists", async () => { - vi.stubEnv("NEXT_PUBLIC_ADSENSE_ID", MOCK_ADSENSE_ID); - vi.stubEnv("NEXT_PUBLIC_ENABLE_ADS", "true"); + it("shows side ads after 2 seconds when no previous view exists", () => { + enableAdsEnv(); render(); expect(screen.queryByText("Espaço do Apoiador")).toBeNull(); - act(() => { - vi.advanceTimersByTime(2000); - }); + advanceBy(2000); expect(screen.getAllByText("Espaço do Apoiador")).toHaveLength(2); }); - it("shows video modal after 30 seconds", async () => { - vi.stubEnv("NEXT_PUBLIC_ADSENSE_ID", MOCK_ADSENSE_ID); - vi.stubEnv("NEXT_PUBLIC_ENABLE_ADS", "true"); + it("shows the video modal once the user has been idle for the whole delay", () => { + enableAdsEnv(); render(); expect(screen.queryByText("Vídeo da Semana")).toBeNull(); - act(() => { - vi.advanceTimersByTime(120000); - }); + advanceBy(VIDEO_AD_DELAY_MS); expect(screen.getByText("Vídeo da Semana")).toBeDefined(); }); + it("reschedules the video modal while the user is still interacting", () => { + enableAdsEnv(); + render(); + + advanceBy(VIDEO_AD_DELAY_MS - 10000); + fireEvent.keyDown(document.body, { key: "a" }); + advanceBy(10000); + + expect(screen.queryByText("Vídeo da Semana")).toBeNull(); + + advanceBy(REQUIRED_IDLE_MS); + + expect(screen.getByText("Vídeo da Semana")).toBeDefined(); + }); + + it("reschedules the video modal while a form field holds the focus", () => { + enableAdsEnv(); + const formField = document.createElement("input"); + document.body.appendChild(formField); + formField.focus(); + + render(); + + advanceBy(VIDEO_AD_DELAY_MS); + expect(screen.queryByText("Vídeo da Semana")).toBeNull(); + + formField.blur(); + advanceBy(REQUIRED_IDLE_MS); + + expect(screen.getByText("Vídeo da Semana")).toBeDefined(); + formField.remove(); + }); + it("hides side ads when viewed less than a week ago", () => { - vi.stubEnv("NEXT_PUBLIC_ADSENSE_ID", MOCK_ADSENSE_ID); - vi.stubEnv("NEXT_PUBLIC_ENABLE_ADS", "true"); + enableAdsEnv(); localStorage.setItem(SIDE_AD_KEY, Date.now().toString()); render(); - act(() => { - vi.advanceTimersByTime(2000); - }); + advanceBy(2000); expect(screen.queryByText("Espaço do Apoiador")).toBeNull(); }); it("hides video ad when viewed less than a week ago", () => { - vi.stubEnv("NEXT_PUBLIC_ADSENSE_ID", MOCK_ADSENSE_ID); - vi.stubEnv("NEXT_PUBLIC_ENABLE_ADS", "true"); + enableAdsEnv(); localStorage.setItem(VIDEO_AD_KEY, Date.now().toString()); render(); - act(() => { - vi.advanceTimersByTime(120000); - }); + advanceBy(VIDEO_AD_DELAY_MS); expect(screen.queryByText("Vídeo da Semana")).toBeNull(); }); - it("shows adblock modal when fetch fails", async () => { - vi.stubEnv("NEXT_PUBLIC_ADSENSE_ID", MOCK_ADSENSE_ID); - vi.stubEnv("NEXT_PUBLIC_ENABLE_ADS", "true"); + it("shows the adblock notice without blocking the page when fetch fails", async () => { + enableAdsEnv(); mockFetch.mockRejectedValueOnce(new Error("blocked")); - render(); + const { container } = render(); await vi.waitFor(() => { expect(screen.getByText("Opa! Uma ajudinha?")).toBeDefined(); }); + expect(container.querySelector("dialog[open]")).toBeNull(); + expect(document.body.style.overflow).toBe(""); }); - it("closes the adblock modal without reloading when dismissed", async () => { - vi.stubEnv("NEXT_PUBLIC_ADSENSE_ID", MOCK_ADSENSE_ID); - vi.stubEnv("NEXT_PUBLIC_ENABLE_ADS", "true"); + it("closes the adblock notice without reloading when dismissed", async () => { + enableAdsEnv(); mockFetch.mockRejectedValueOnce(new Error("blocked")); render(); @@ -119,12 +151,12 @@ describe("AdManager", () => { fireEvent.click(screen.getByText("Continuar com AdBlock ativo")); + expect(screen.queryByText("Opa! Uma ajudinha?")).toBeNull(); expect(mockReload).not.toHaveBeenCalled(); }); - it("reloads the page and closes the modal when confirming adblock is disabled", async () => { - vi.stubEnv("NEXT_PUBLIC_ADSENSE_ID", MOCK_ADSENSE_ID); - vi.stubEnv("NEXT_PUBLIC_ENABLE_ADS", "true"); + it("reloads the page and closes the notice when confirming adblock is disabled", async () => { + enableAdsEnv(); mockFetch.mockRejectedValueOnce(new Error("blocked")); render(); @@ -138,37 +170,28 @@ describe("AdManager", () => { expect(mockReload).toHaveBeenCalledOnce(); }); - it("persists the side ads cooldown and hides them when closed early", () => { - vi.stubEnv("NEXT_PUBLIC_ADSENSE_ID", MOCK_ADSENSE_ID); - vi.stubEnv("NEXT_PUBLIC_ENABLE_ADS", "true"); + it("persists the side ads cooldown and hides them when closed", () => { + enableAdsEnv(); render(); - act(() => { - vi.advanceTimersByTime(2000); - }); + advanceBy(2000); expect(screen.getAllByText("Espaço do Apoiador")).toHaveLength(2); - const closeButtons = screen.getAllByRole("button"); - fireEvent.click(closeButtons[0]); + fireEvent.click(screen.getByRole("button", { name: "Fechar anúncio do lado esquerdo" })); expect(localStorage.getItem(SIDE_AD_KEY)).not.toBeNull(); expect(screen.queryByText("Espaço do Apoiador")).toBeNull(); }); it("marks the video ad as watched and closes it once playback completes", () => { - vi.stubEnv("NEXT_PUBLIC_ADSENSE_ID", MOCK_ADSENSE_ID); - vi.stubEnv("NEXT_PUBLIC_ENABLE_ADS", "true"); + enableAdsEnv(); render(); - act(() => { - vi.advanceTimersByTime(120000); - }); + advanceBy(VIDEO_AD_DELAY_MS); expect(screen.getByText("Vídeo da Semana")).toBeDefined(); fireEvent.click(screen.getByText("Ver vídeo e apoiar o projeto")); - act(() => { - vi.advanceTimersByTime(15000); - }); + advanceBy(15000); fireEvent.click(screen.getByRole("button", { name: "Fechar vídeo" })); diff --git a/__tests__/analytics-wrapper.test.tsx b/__tests__/analytics-wrapper.test.tsx index db46f5a..98a359d 100644 --- a/__tests__/analytics-wrapper.test.tsx +++ b/__tests__/analytics-wrapper.test.tsx @@ -1,6 +1,7 @@ -import { render } from "@testing-library/react"; +import { act, render } from "@testing-library/react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { AnalyticsWrapper } from "@/components/organisms/analytics-wrapper"; +import { CONSENT_CHANGED_EVENT } from "@/lib/consent"; const CONSENT_KEY = "workload_cookie_consent"; @@ -43,4 +44,28 @@ describe("AnalyticsWrapper", () => { const { getByTestId } = render(); expect(getByTestId("google-analytics")).toHaveAttribute("data-ga-id", "GA-TEST-ID"); }); + + it("loads analytics as soon as consent is granted, without a reload", () => { + vi.stubEnv("NEXT_PUBLIC_GA_ID", "GA-TEST-ID"); + const { container, queryByTestId } = render(); + expect(container).toBeEmptyDOMElement(); + + localStorage.setItem(CONSENT_KEY, JSON.stringify({ telemetry: true })); + act(() => { + window.dispatchEvent(new Event(CONSENT_CHANGED_EVENT)); + }); + + expect(queryByTestId("google-analytics")).toBeInTheDocument(); + }); + + it("stops listening for consent changes once unmounted", () => { + vi.stubEnv("NEXT_PUBLIC_GA_ID", "GA-TEST-ID"); + const removeEventListener = vi.spyOn(window, "removeEventListener"); + const { unmount } = render(); + + unmount(); + + expect(removeEventListener).toHaveBeenCalledWith(CONSENT_CHANGED_EVENT, expect.any(Function)); + removeEventListener.mockRestore(); + }); }); diff --git a/__tests__/app-header.test.tsx b/__tests__/app-header.test.tsx new file mode 100644 index 0000000..ed7576b --- /dev/null +++ b/__tests__/app-header.test.tsx @@ -0,0 +1,87 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { renderToString } from "react-dom/server"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { AppHeader } from "@/components/organisms/app-header"; +import { safeGAEvent } from "@/lib/analytics"; + +vi.mock("@/lib/analytics", () => ({ + safeGAEvent: vi.fn(), +})); + +const themeState: { resolvedTheme: string | undefined; setTheme: () => void } = { + resolvedTheme: undefined, + setTheme: vi.fn(), +}; + +vi.mock("next-themes", () => ({ + useTheme: () => themeState, +})); + +function renderAtFixedTime() { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2025-01-06T09:30:00")); + return render(); +} + +describe("AppHeader", () => { + beforeEach(() => { + vi.clearAllMocks(); + themeState.resolvedTheme = undefined; + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("names the application and holds the clock still on the server", () => { + const markup = renderToString(); + + expect(markup).toContain("WorkLoad"); + expect(markup).toContain("Sua jornada de trabalho, clara e no seu controle"); + expect(markup).toContain("--:--:--"); + }); + + it("shows the live clock once the client takes over", () => { + renderAtFixedTime(); + + expect(screen.getByText("09:30:00")).toBeInTheDocument(); + }); + + it("hides the ticking clock from assistive technology", () => { + renderAtFixedTime(); + + expect(screen.getByText("09:30:00").closest("[aria-hidden='true']")).toBeInTheDocument(); + }); + + it("reports the session metadata on mount", () => { + render(); + + expect(safeGAEvent).toHaveBeenCalledWith( + "session_metadata", + expect.objectContaining({ viewport_width: window.innerWidth }), + ); + }); + + it("offers the dark theme while the light one is active", async () => { + themeState.resolvedTheme = "light"; + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole("button", { name: "Alternar tema" })); + + expect(themeState.setTheme).toHaveBeenCalledWith("dark"); + expect(safeGAEvent).toHaveBeenCalledWith("toggle_theme", { theme: "dark" }); + }); + + it("offers the light theme while the dark one is active", async () => { + themeState.resolvedTheme = "dark"; + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole("button", { name: "Alternar tema" })); + + expect(themeState.setTheme).toHaveBeenCalledWith("light"); + expect(safeGAEvent).toHaveBeenCalledWith("toggle_theme", { theme: "light" }); + }); +}); diff --git a/__tests__/calculator-page.test.tsx b/__tests__/calculator-page.test.tsx new file mode 100644 index 0000000..4a0bb4b --- /dev/null +++ b/__tests__/calculator-page.test.tsx @@ -0,0 +1,73 @@ +import { render, screen } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { CalculatorPage } from "@/components/templates/calculator-page"; + +vi.mock("@/lib/analytics", () => ({ + safeGAEvent: vi.fn(), +})); + +vi.mock("next-themes", () => ({ + useTheme: () => ({ resolvedTheme: undefined, setTheme: vi.fn() }), +})); + +function renderShell() { + return render( + +

Conteúdo da calculadora

+
, + ); +} + +describe("CalculatorPage", () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2025-01-06T09:30:00")); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("wraps its children in the application shell", () => { + renderShell(); + + expect(screen.getByText("Conteúdo da calculadora")).toBeInTheDocument(); + expect(screen.getByRole("heading", { name: "WorkLoad" })).toBeInTheDocument(); + expect(screen.getByRole("main")).toContainElement(screen.getByText("Conteúdo da calculadora")); + }); + + it("offers a skip link that jumps straight to the main content", () => { + renderShell(); + + expect(screen.getByRole("link", { name: "Pular para o conteúdo principal" })).toHaveAttribute( + "href", + "#main-content", + ); + }); + + it("describes the application to search engines with valid structured data", () => { + const { container } = renderShell(); + const script = container.querySelector('script[type="application/ld+json"]'); + + expect(script).not.toBeNull(); + expect(JSON.parse(script?.textContent ?? "")).toEqual({ + "@context": "https://schema.org", + "@type": "WebApplication", + name: "WorkLoad", + url: "https://workload.devrma.com", + description: "Calculadora inteligente de jornada e valor de trabalho.", + applicationCategory: "BusinessApplication", + operatingSystem: "Any", + author: { + "@type": "Person", + name: "Rafael Augusto", + }, + }); + }); + + it("keeps the decorative background out of the accessibility tree", () => { + const { container } = renderShell(); + + expect(container.querySelectorAll(".pointer-events-none .blur-\\[120px\\]")).toHaveLength(3); + }); +}); diff --git a/__tests__/calculator-view.test.ts b/__tests__/calculator-view.test.ts new file mode 100644 index 0000000..3b8d664 --- /dev/null +++ b/__tests__/calculator-view.test.ts @@ -0,0 +1,8 @@ +import { describe, expect, it } from "vitest"; +import { VIEW_PATHS } from "@/lib/calculator-view"; + +describe("VIEW_PATHS", () => { + it("gives each view its own static route", () => { + expect(VIEW_PATHS).toEqual({ work: "/", salary: "/custo-da-hora" }); + }); +}); diff --git a/__tests__/calculator-views.test.tsx b/__tests__/calculator-views.test.tsx index 310288e..bdfd4a8 100644 --- a/__tests__/calculator-views.test.tsx +++ b/__tests__/calculator-views.test.tsx @@ -1,7 +1,7 @@ import { render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import { CalculatorViews, CalculatorViewsFromUrl, toCalculatorView } from "@/components/organisms/calculator-views"; +import { CalculatorViews } from "@/components/organisms/calculator-views"; import { safeGAEvent } from "@/lib/analytics"; vi.mock("@/lib/analytics", () => ({ @@ -16,32 +16,16 @@ vi.mock("@/components/organisms/salary-calculator", () => ({ SalaryCalculator: () =>

Painel do custo da hora

, })); -const searchParams = { current: new URLSearchParams() }; - -vi.mock("next/navigation", () => ({ - useSearchParams: () => searchParams.current, -})); - -describe("toCalculatorView", () => { - it("only accepts the salary view, falling back to the journey", () => { - expect(toCalculatorView("salary")).toBe("salary"); - expect(toCalculatorView("work")).toBe("work"); - expect(toCalculatorView("anything-else")).toBe("work"); - expect(toCalculatorView(null)).toBe("work"); - }); -}); - describe("CalculatorViews", () => { beforeEach(() => { vi.clearAllMocks(); - searchParams.current = new URLSearchParams(); }); it("marks the active tab and links both views by URL", () => { render(); - expect(screen.getByRole("link", { name: "Jornada" })).toHaveAttribute("href", "/?view=work"); - expect(screen.getByRole("link", { name: "Custo da Hora" })).toHaveAttribute("href", "/?view=salary"); + expect(screen.getByRole("link", { name: "Jornada" })).toHaveAttribute("href", "/"); + expect(screen.getByRole("link", { name: "Custo da Hora" })).toHaveAttribute("href", "/custo-da-hora"); expect(screen.getByRole("link", { name: "Custo da Hora" })).toHaveAttribute("aria-current", "page"); expect(screen.getByRole("link", { name: "Jornada" })).not.toHaveAttribute("aria-current"); }); @@ -63,16 +47,25 @@ describe("CalculatorViews", () => { expect(safeGAEvent).toHaveBeenCalledWith("switch_tab", { tab: "salary" }); }); - it("reads the active view from the query string", () => { - searchParams.current = new URLSearchParams("view=salary"); - render(); + it("leaves the focus alone on the first render", () => { + const { container } = render(); - expect(screen.getByText("Painel do custo da hora")).toBeInTheDocument(); + expect(container.querySelector("#main-content")).not.toHaveFocus(); + expect(document.activeElement).toBe(document.body); }); - it("falls back to the journey when the query string has no view", () => { - render(); + it("moves the focus to the panel once the view changes", () => { + const { container, rerender } = render(); - expect(screen.getByText("Painel da jornada")).toBeInTheDocument(); + rerender(); + + expect(container.querySelector("#main-content")).toHaveFocus(); + }); + + it("promises that nothing leaves the browser and that the numbers are an estimate", () => { + render(); + + expect(screen.getByText(/fica salvo apenas neste navegador/)).toBeInTheDocument(); + expect(screen.getByText(/não substituem seu holerite/)).toBeInTheDocument(); }); }); diff --git a/__tests__/cookie-consent.test.tsx b/__tests__/cookie-consent.test.tsx index b1fdd28..ebd2eff 100644 --- a/__tests__/cookie-consent.test.tsx +++ b/__tests__/cookie-consent.test.tsx @@ -3,6 +3,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { CookieConsent } from "@/components/molecules/cookie-consent"; const CONSENT_KEY = "workload_cookie_consent"; +const CONSENT_CHANGED_EVENT = "workload:consent-changed"; const BANNER_DELAY_MS = 1500; const mockReload = vi.fn(); @@ -11,20 +12,34 @@ Object.defineProperty(window, "location", { value: { ...window.location, reload: mockReload }, }); +let consentEventDetails: { telemetry: boolean }[] = []; +const captureConsentEvent: EventListener = (event) => { + consentEventDetails.push((event as CustomEvent<{ telemetry: boolean }>).detail); +}; + const openSettingsFromShieldButton = () => { fireEvent.click(screen.getByRole("button", { name: "Configurações de Privacidade" })); }; const getTelemetryToggle = () => screen.getByRole("switch"); +const showBanner = () => { + act(() => { + vi.advanceTimersByTime(BANNER_DELAY_MS); + }); +}; + describe("CookieConsent", () => { beforeEach(() => { localStorage.clear(); vi.clearAllMocks(); vi.useFakeTimers(); + consentEventDetails = []; + window.addEventListener(CONSENT_CHANGED_EVENT, captureConsentEvent); }); afterEach(() => { + window.removeEventListener(CONSENT_CHANGED_EVENT, captureConsentEvent); vi.useRealTimers(); }); @@ -35,10 +50,8 @@ describe("CookieConsent", () => { it("renders the banner after the delay elapses when no consent is stored", () => { render(); - act(() => { - vi.advanceTimersByTime(BANNER_DELAY_MS); - }); - expect(screen.getByText("Respeitamos sua privacidade")).toBeDefined(); + showBanner(); + expect(screen.getByRole("heading", { level: 2, name: "Respeitamos sua privacidade" })).toBeDefined(); }); it("clears the mount timer on unmount so it never fires", () => { @@ -51,62 +64,54 @@ describe("CookieConsent", () => { it("does not show the banner and seeds the toggle as enabled when consent was stored as true", () => { localStorage.setItem(CONSENT_KEY, JSON.stringify({ telemetry: true })); render(); - act(() => { - vi.advanceTimersByTime(BANNER_DELAY_MS); - }); + showBanner(); expect(screen.queryByText("Respeitamos sua privacidade")).toBeNull(); openSettingsFromShieldButton(); fireEvent.click(screen.getByText("Salvar Preferências")); expect(JSON.parse(localStorage.getItem(CONSENT_KEY) ?? "{}").telemetry).toBe(true); - expect(mockReload).toHaveBeenCalledOnce(); + expect(consentEventDetails).toEqual([{ telemetry: true }]); }); it("does not show the banner and seeds the toggle as disabled when consent was stored as false", () => { localStorage.setItem(CONSENT_KEY, JSON.stringify({ telemetry: false })); render(); - act(() => { - vi.advanceTimersByTime(BANNER_DELAY_MS); - }); + showBanner(); expect(screen.queryByText("Respeitamos sua privacidade")).toBeNull(); openSettingsFromShieldButton(); fireEvent.click(screen.getByText("Salvar Preferências")); expect(JSON.parse(localStorage.getItem(CONSENT_KEY) ?? "{}").telemetry).toBe(false); - expect(mockReload).toHaveBeenCalledOnce(); + expect(consentEventDetails).toEqual([{ telemetry: false }]); }); - it("persists accepting all cookies and reloads the page", () => { + it("persists accepting all cookies and announces the change without reloading", () => { render(); - act(() => { - vi.advanceTimersByTime(BANNER_DELAY_MS); - }); + showBanner(); fireEvent.click(screen.getByText("Aceitar Tudo")); expect(JSON.parse(localStorage.getItem(CONSENT_KEY) ?? "{}").telemetry).toBe(true); - expect(mockReload).toHaveBeenCalledOnce(); + expect(consentEventDetails).toEqual([{ telemetry: true }]); + expect(mockReload).not.toHaveBeenCalled(); }); - it("persists refusing cookies and reloads the page", () => { + it("persists refusing cookies and announces the change without reloading", () => { render(); - act(() => { - vi.advanceTimersByTime(BANNER_DELAY_MS); - }); + showBanner(); fireEvent.click(screen.getByText("Recusar")); expect(JSON.parse(localStorage.getItem(CONSENT_KEY) ?? "{}").telemetry).toBe(false); - expect(mockReload).toHaveBeenCalledOnce(); + expect(consentEventDetails).toEqual([{ telemetry: false }]); + expect(mockReload).not.toHaveBeenCalled(); }); it("opens the settings dialog from the banner's Configurar link", () => { render(); - act(() => { - vi.advanceTimersByTime(BANNER_DELAY_MS); - }); + showBanner(); fireEvent.click(screen.getByText("Configurar")); @@ -115,9 +120,7 @@ describe("CookieConsent", () => { it("opens the settings dialog as a modal named by its heading", () => { const { container } = render(); - act(() => { - vi.advanceTimersByTime(BANNER_DELAY_MS); - }); + showBanner(); fireEvent.click(screen.getByText("Configurar")); const dialog = container.querySelector("dialog") as HTMLDialogElement; @@ -128,23 +131,19 @@ describe("CookieConsent", () => { it("closes the settings dialog from the backdrop without persisting anything", () => { const { container } = render(); - act(() => { - vi.advanceTimersByTime(BANNER_DELAY_MS); - }); + showBanner(); fireEvent.click(screen.getByText("Configurar")); fireEvent.click(container.querySelector("dialog") as Element); expect(screen.queryByText("Privacidade")).toBeNull(); expect(localStorage.getItem(CONSENT_KEY)).toBeNull(); - expect(mockReload).not.toHaveBeenCalled(); + expect(consentEventDetails).toEqual([]); }); it("closes the settings dialog when the native close event fires", () => { const { container } = render(); - act(() => { - vi.advanceTimersByTime(BANNER_DELAY_MS); - }); + showBanner(); fireEvent.click(screen.getByText("Configurar")); fireEvent(container.querySelector("dialog") as Element, new Event("close")); @@ -155,9 +154,7 @@ describe("CookieConsent", () => { it("closes the settings dialog from the X button without persisting anything", () => { render(); - act(() => { - vi.advanceTimersByTime(BANNER_DELAY_MS); - }); + showBanner(); fireEvent.click(screen.getByText("Configurar")); fireEvent.click( @@ -168,24 +165,20 @@ describe("CookieConsent", () => { expect(screen.queryByText("Privacidade")).toBeNull(); expect(localStorage.getItem(CONSENT_KEY)).toBeNull(); - expect(mockReload).not.toHaveBeenCalled(); + expect(consentEventDetails).toEqual([]); }); it("announces the always-on state of the essential cookies", () => { render(); - act(() => { - vi.advanceTimersByTime(BANNER_DELAY_MS); - }); + showBanner(); fireEvent.click(screen.getByText("Configurar")); expect(screen.getByText("Sempre ativo")).toBeDefined(); }); - it("toggles telemetry off and saves the toggled value", () => { + it("toggles telemetry off, saves the toggled value and closes the dialog", () => { render(); - act(() => { - vi.advanceTimersByTime(BANNER_DELAY_MS); - }); + showBanner(); fireEvent.click(screen.getByText("Configurar")); const toggle = getTelemetryToggle(); @@ -197,15 +190,14 @@ describe("CookieConsent", () => { fireEvent.click(screen.getByText("Salvar Preferências")); expect(JSON.parse(localStorage.getItem(CONSENT_KEY) ?? "{}").telemetry).toBe(false); - expect(mockReload).toHaveBeenCalledOnce(); + expect(consentEventDetails).toEqual([{ telemetry: false }]); + expect(screen.queryByText("Privacidade")).toBeNull(); }); it("toggles telemetry back on from a stored disabled consent and saves the toggled value", () => { localStorage.setItem(CONSENT_KEY, JSON.stringify({ telemetry: false })); render(); - act(() => { - vi.advanceTimersByTime(BANNER_DELAY_MS); - }); + showBanner(); openSettingsFromShieldButton(); @@ -216,6 +208,16 @@ describe("CookieConsent", () => { fireEvent.click(screen.getByText("Salvar Preferências")); expect(JSON.parse(localStorage.getItem(CONSENT_KEY) ?? "{}").telemetry).toBe(true); - expect(mockReload).toHaveBeenCalledOnce(); + expect(consentEventDetails).toEqual([{ telemetry: true }]); + }); + + it("keeps the privacy shortcut readable and focusable", () => { + localStorage.setItem(CONSENT_KEY, JSON.stringify({ telemetry: true })); + render(); + showBanner(); + + const shortcut = screen.getByRole("button", { name: "Configurações de Privacidade" }); + expect(shortcut.className).not.toContain("opacity-30"); + expect(shortcut.className).toContain("focus-visible:ring-2"); }); }); diff --git a/__tests__/custo-da-hora-page.test.tsx b/__tests__/custo-da-hora-page.test.tsx new file mode 100644 index 0000000..bb89e01 --- /dev/null +++ b/__tests__/custo-da-hora-page.test.tsx @@ -0,0 +1,59 @@ +import { renderToString } from "react-dom/server"; +import { describe, expect, it, vi } from "vitest"; +import CostPerHour, { metadata } from "@/app/custo-da-hora/page"; + +vi.mock("@/lib/analytics", () => ({ + safeGAEvent: vi.fn(), +})); + +vi.mock("next-themes", () => ({ + useTheme: () => ({ resolvedTheme: undefined, setTheme: vi.fn() }), +})); + +vi.mock("@/components/organisms/work-calculator", () => ({ + WorkCalculator: () =>

Painel da jornada

, +})); + +vi.mock("@/components/organisms/salary-calculator", () => ({ + SalaryCalculator: () =>

Painel do custo da hora

, +})); + +describe("CostPerHour", () => { + it("renders the whole shell on the server instead of a blank document", () => { + const markup = renderToString(); + + expect(markup).toContain("WorkLoad"); + expect(markup).toContain("Jornada"); + expect(markup).toContain("Custo da Hora"); + expect(markup).toContain("Pular para o conteúdo principal"); + expect(markup).toContain("--:--:--"); + }); + + it("serves the salary panel on the very first frame", () => { + const markup = renderToString(); + + expect(markup).toContain("Painel do custo da hora"); + expect(markup).not.toContain("Painel da jornada"); + }); + + it("links back to the journey route", () => { + const markup = renderToString(); + + expect(markup).toContain('href="/"'); + expect(markup).toContain('href="/custo-da-hora"'); + }); +}); + +describe("metadata", () => { + it("describes the salary view with its own canonical", () => { + expect(metadata.title).toEqual({ + absolute: "Calculadora de Valor da Hora e Salário Líquido CLT | WorkLoad", + }); + expect(metadata.description).toContain("salário bruto"); + expect(metadata.alternates).toEqual({ canonical: "/custo-da-hora" }); + expect(metadata.openGraph).toMatchObject({ + title: "Calculadora de Valor da Hora e Salário Líquido CLT", + url: "/custo-da-hora", + }); + }); +}); diff --git a/__tests__/day-summary.test.tsx b/__tests__/day-summary.test.tsx index e287033..3158478 100644 --- a/__tests__/day-summary.test.tsx +++ b/__tests__/day-summary.test.tsx @@ -149,7 +149,7 @@ describe("DaySummary", () => { it("invites the reader to calculate the hourly value when it is unknown", () => { renderSummary({ firstTierMinutes: 60, extraTierMinutes: 30 }); - expect(screen.getByRole("link", { name: /Calcule o valor da sua hora/ })).toHaveAttribute("href", "/?view=salary"); + expect(screen.getByRole("link", { name: /Calcule o valor da sua hora/ })).toHaveAttribute("href", "/custo-da-hora"); expect(screen.queryByText(/R\$/)).toBeNull(); }); diff --git a/__tests__/page.test.tsx b/__tests__/page.test.tsx index df5b83b..963fff8 100644 --- a/__tests__/page.test.tsx +++ b/__tests__/page.test.tsx @@ -1,21 +1,13 @@ -import { render, screen } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; import { renderToString } from "react-dom/server"; -import { beforeEach, describe, expect, it, vi } from "vitest"; -import Home from "@/app/page"; -import { safeGAEvent } from "@/lib/analytics"; +import { describe, expect, it, vi } from "vitest"; +import Home, { metadata } from "@/app/page"; vi.mock("@/lib/analytics", () => ({ safeGAEvent: vi.fn(), })); -const themeState: { resolvedTheme: string | undefined; setTheme: () => void } = { - resolvedTheme: undefined, - setTheme: vi.fn(), -}; - vi.mock("next-themes", () => ({ - useTheme: () => themeState, + useTheme: () => ({ resolvedTheme: undefined, setTheme: vi.fn() }), })); vi.mock("@/components/organisms/work-calculator", () => ({ @@ -26,16 +18,7 @@ vi.mock("@/components/organisms/salary-calculator", () => ({ SalaryCalculator: () =>

Painel do custo da hora

, })); -vi.mock("next/navigation", () => ({ - useSearchParams: () => new URLSearchParams(), -})); - describe("Home", () => { - beforeEach(() => { - vi.clearAllMocks(); - themeState.resolvedTheme = undefined; - }); - it("renders the whole shell on the server instead of a blank document", () => { const markup = renderToString(); @@ -46,56 +29,31 @@ describe("Home", () => { expect(markup).toContain("--:--:--"); }); - it("describes the application for search engines", () => { + it("serves the journey panel on the very first frame", () => { const markup = renderToString(); - expect(markup).toContain("application/ld+json"); - expect(markup).toContain("WebApplication"); + expect(markup).toContain("Painel da jornada"); + expect(markup).not.toContain("Painel do custo da hora"); }); - it("shows the live clock once the client takes over", () => { - render(); - - expect(screen.getByText(/^\d{2}:\d{2}:\d{2}$/)).toBeInTheDocument(); - }); - - it("reports the session metadata on mount", () => { - render(); - - expect(safeGAEvent).toHaveBeenCalledWith( - "session_metadata", - expect.objectContaining({ viewport_width: window.innerWidth }), - ); - }); - - it("starts on the journey view", () => { - render(); + it("marks the journey tab as the current page", () => { + const markup = renderToString(); - expect(screen.getByText("Painel da jornada")).toBeInTheDocument(); - expect(screen.getByRole("link", { name: "Jornada" })).toHaveAttribute("aria-current", "page"); + expect(markup).toContain('href="/"'); + expect(markup).toContain('aria-current="page"'); }); +}); - it("offers the dark theme while the light one is active", async () => { - themeState.resolvedTheme = "light"; - const user = userEvent.setup(); - render(); - - await user.click(screen.getByTitle("Alternar tema")); - - expect(safeGAEvent).toHaveBeenCalledWith("toggle_theme", { - theme: "dark", +describe("metadata", () => { + it("describes the journey view with its own canonical", () => { + expect(metadata.title).toEqual({ + absolute: "Calculadora de Jornada, Horas Extras e Banco de Horas | WorkLoad", }); - }); - - it("offers the light theme while the dark one is active", async () => { - themeState.resolvedTheme = "dark"; - const user = userEvent.setup(); - render(); - - await user.click(screen.getByTitle("Alternar tema")); - - expect(safeGAEvent).toHaveBeenCalledWith("toggle_theme", { - theme: "light", + expect(metadata.description).toContain("hora extra"); + expect(metadata.alternates).toEqual({ canonical: "/" }); + expect(metadata.openGraph).toMatchObject({ + title: "Calculadora de Jornada, Horas Extras e Banco de Horas", + url: "/", }); }); }); diff --git a/__tests__/salary-calculator.test.tsx b/__tests__/salary-calculator.test.tsx index 4a34bc5..a705430 100644 --- a/__tests__/salary-calculator.test.tsx +++ b/__tests__/salary-calculator.test.tsx @@ -16,7 +16,7 @@ describe("SalaryCalculator", () => { render(); expect(screen.getByLabelText("Salário Bruto (R$)")).toHaveValue("0,00"); - expect(screen.getByText(/0,00 por hora/)).toBeInTheDocument(); + expect(screen.getByText(/0,00 por minuto/)).toBeInTheDocument(); }); it("shows the stored salary, the workload and the resulting hourly value", () => { @@ -110,14 +110,29 @@ describe("SalaryCalculator", () => { expect(screen.getByText(/58\.480,37/)).toBeInTheDocument(); }); - it("summarises net salary, total received and total deductions", () => { + it("summarises net salary and total deductions", () => { render(); expect(screen.getByText("Salário Líquido")).toBeInTheDocument(); - expect(screen.getByText("Total Recebido")).toBeInTheDocument(); expect(screen.getByText("Total Descontos")).toBeInTheDocument(); }); + it("keeps the total received hidden while it would only repeat the net salary", () => { + localStorage.setItem("grossSalary", "5000"); + render(); + + expect(screen.queryByText("Total Recebido")).toBeNull(); + }); + + it("shows the total received once there are extra gains to add", () => { + localStorage.setItem("grossSalary", "5000"); + localStorage.setItem("extraGains", JSON.stringify([{ id: "bonus", name: "Bônus", value: 300 }])); + render(); + + expect(screen.getByText("Total Recebido")).toBeInTheDocument(); + expect(screen.getByText("Líquido + Extras")).toBeInTheDocument(); + }); + it("recalculates the hourly value when the salary changes", async () => { const user = userEvent.setup(); render(); @@ -152,7 +167,17 @@ describe("SalaryCalculator", () => { render(); expect(screen.queryByRole("alert")).toBeNull(); - expect(screen.getByText(/20,45 por hora/)).toBeInTheDocument(); + expect(screen.getByText(/0,34 por minuto/)).toBeInTheDocument(); + }); + + it("spells out the hourly rate once the headline shows another period", async () => { + const user = userEvent.setup(); + localStorage.setItem("grossSalary", "5000"); + render(); + + await user.click(screen.getByRole("radio", { name: "Mês" })); + + expect(screen.getByText(/20,45 por hora · R\$ 0,34 por minuto/)).toBeInTheDocument(); }); it("accepts a new workload", async () => { diff --git a/__tests__/side-ads.test.tsx b/__tests__/side-ads.test.tsx index bc885c0..4a20091 100644 --- a/__tests__/side-ads.test.tsx +++ b/__tests__/side-ads.test.tsx @@ -23,13 +23,20 @@ describe("SideAds", () => { expect(screen.getAllByText("Espaço do Apoiador")).toHaveLength(2); }); - it("auto-hides and calls onClose after 45 seconds", () => { + it("keeps the ads on screen until the user closes them", () => { render(); act(() => { - vi.advanceTimersByTime(45000); + vi.advanceTimersByTime(600000); }); - expect(mockOnClose).toHaveBeenCalledOnce(); - expect(screen.queryByText("Espaço do Apoiador")).toBeNull(); + expect(screen.getAllByText("Espaço do Apoiador")).toHaveLength(2); + expect(mockOnClose).not.toHaveBeenCalled(); + }); + + it("clears the reveal timer on unmount so it never fires", () => { + const clearTimeoutSpy = vi.spyOn(window, "clearTimeout"); + const { unmount } = render(); + unmount(); + expect(clearTimeoutSpy).toHaveBeenCalled(); }); it("closes the left ad and calls onClose when its close button is clicked", () => { @@ -37,8 +44,7 @@ describe("SideAds", () => { act(() => { vi.advanceTimersByTime(2000); }); - const closeButtons = screen.getAllByRole("button"); - fireEvent.click(closeButtons[0]); + fireEvent.click(screen.getByRole("button", { name: "Fechar anúncio do lado esquerdo" })); expect(mockOnClose).toHaveBeenCalledOnce(); }); @@ -47,8 +53,17 @@ describe("SideAds", () => { act(() => { vi.advanceTimersByTime(2000); }); - const closeButtons = screen.getAllByRole("button"); - fireEvent.click(closeButtons[1]); + fireEvent.click(screen.getByRole("button", { name: "Fechar anúncio do lado direito" })); expect(mockOnClose).toHaveBeenCalledOnce(); }); + + it("reveals the close buttons on keyboard focus", () => { + render(); + act(() => { + vi.advanceTimersByTime(2000); + }); + for (const closeButton of screen.getAllByRole("button")) { + expect(closeButton.className).toContain("focus-visible:opacity-100"); + } + }); }); diff --git a/__tests__/sitemap.test.ts b/__tests__/sitemap.test.ts index 7744e65..80a6c54 100644 --- a/__tests__/sitemap.test.ts +++ b/__tests__/sitemap.test.ts @@ -2,12 +2,24 @@ import { describe, expect, it } from "vitest"; import sitemap from "@/app/sitemap"; describe("sitemap", () => { - it("returns the expected sitemap entries", () => { + it("lists both calculator routes", () => { const result = sitemap(); - expect(result).toHaveLength(1); - expect(result[0]?.url).toBe("https://workload.devrma.com"); - expect(result[0]?.changeFrequency).toBe("weekly"); - expect(result[0]?.priority).toBe(1); - expect(result[0]?.lastModified).toBeInstanceOf(Date); + + expect(result).toHaveLength(2); + expect(result.map((entry) => entry.url)).toEqual([ + "https://workload.devrma.com", + "https://workload.devrma.com/custo-da-hora", + ]); + }); + + it("ranks the journey above the hourly cost and refreshes both weekly", () => { + const [journey, hourlyCost] = sitemap(); + + expect(journey?.priority).toBe(1); + expect(hourlyCost?.priority).toBe(0.9); + expect(journey?.changeFrequency).toBe("weekly"); + expect(hourlyCost?.changeFrequency).toBe("weekly"); + expect(journey?.lastModified).toBeInstanceOf(Date); + expect(hourlyCost?.lastModified).toBeInstanceOf(Date); }); }); diff --git a/__tests__/use-hourly-rate.test.ts b/__tests__/use-hourly-rate.test.ts new file mode 100644 index 0000000..6b07528 --- /dev/null +++ b/__tests__/use-hourly-rate.test.ts @@ -0,0 +1,32 @@ +import { renderHook } from "@testing-library/react"; +import { beforeEach, describe, expect, it } from "vitest"; +import { useHourlyRate } from "@/hooks/use-hourly-rate"; +import { HOURLY_RATE_KEY } from "@/lib/storage"; + +describe("useHourlyRate", () => { + beforeEach(() => { + localStorage.clear(); + }); + + it("reads the stored hourly rate", () => { + localStorage.setItem(HOURLY_RATE_KEY, "20.45"); + + const { result } = renderHook(() => useHourlyRate()); + + expect(result.current).toBe(20.45); + }); + + it("stays unknown while nothing was stored", () => { + const { result } = renderHook(() => useHourlyRate()); + + expect(result.current).toBeNull(); + }); + + it("treats a zeroed rate as unknown instead of free work", () => { + localStorage.setItem(HOURLY_RATE_KEY, "0"); + + const { result } = renderHook(() => useHourlyRate()); + + expect(result.current).toBeNull(); + }); +}); diff --git a/__tests__/use-salary-calculator.test.ts b/__tests__/use-salary-calculator.test.ts index 3669e3a..950b67a 100644 --- a/__tests__/use-salary-calculator.test.ts +++ b/__tests__/use-salary-calculator.test.ts @@ -1,6 +1,7 @@ import { act, renderHook } from "@testing-library/react"; import { beforeEach, describe, expect, it } from "vitest"; import { useSalaryCalculator } from "@/hooks/use-salary-calculator"; +import { DAILY_MINUTES_KEY, HOURLY_RATE_KEY } from "@/lib/storage"; describe("useSalaryCalculator", () => { beforeEach(() => { @@ -215,10 +216,34 @@ describe("useSalaryCalculator", () => { result.current.setDailyMinutes(450); }); - expect(localStorage.getItem("dailyMinutes")).toBe("450"); expect(result.current.stats.periodValue).toBeCloseTo(result.current.stats.hourlyRate * 7.5, 6); }); + it("shares the daily journey with the journey tab instead of keeping a second copy", () => { + localStorage.setItem(DAILY_MINUTES_KEY, "450"); + + const { result } = renderHook(() => useSalaryCalculator()); + expect(result.current.dailyMinutes).toBe(450); + + act(() => { + result.current.setDailyMinutes(480); + }); + + expect(localStorage.getItem(DAILY_MINUTES_KEY)).toBe("480"); + }); + + it("publishes the hourly value for the journey tab to price overtime with", () => { + const { result } = renderHook(() => useSalaryCalculator(5000)); + + expect(Number(localStorage.getItem(HOURLY_RATE_KEY))).toBeCloseTo(20.4477, 4); + + act(() => { + result.current.setGrossSalary(10000); + }); + + expect(Number(localStorage.getItem(HOURLY_RATE_KEY))).toBeCloseTo(result.current.stats.hourlyRate, 6); + }); + it("has no hourly value while the monthly hours are cleared", () => { const { result } = renderHook(() => useSalaryCalculator()); diff --git a/__tests__/work-calculator.test.tsx b/__tests__/work-calculator.test.tsx index de4e456..343d550 100644 --- a/__tests__/work-calculator.test.tsx +++ b/__tests__/work-calculator.test.tsx @@ -198,12 +198,20 @@ describe("WorkCalculator", () => { }); it("prices the overtime once the salary tab knows the hourly value", () => { - localStorage.setItem("grossSalary", "5000"); + localStorage.setItem("hourlyRate", "20"); vi.setSystemTime(new Date(`${DAY}T19:00:00`)); render(); expect(screen.queryByRole("link", { name: /Calcule o valor da sua hora/ })).toBeNull(); - expect(screen.getByText(/36,81/)).toBeInTheDocument(); + expect(screen.getByText(/36,00/)).toBeInTheDocument(); + }); + + it("ignores a zeroed hourly value instead of pricing the day at nothing", () => { + localStorage.setItem("hourlyRate", "0"); + vi.setSystemTime(new Date(`${DAY}T19:00:00`)); + render(); + + expect(screen.getByRole("link", { name: /Calcule o valor da sua hora/ })).toBeInTheDocument(); }); it("offers the salary tab while the hourly value is unknown", () => { diff --git a/app/custo-da-hora/page.tsx b/app/custo-da-hora/page.tsx new file mode 100644 index 0000000..d015904 --- /dev/null +++ b/app/custo-da-hora/page.tsx @@ -0,0 +1,23 @@ +import type { Metadata } from "next"; +import { CalculatorViews } from "@/components/organisms/calculator-views"; +import { CalculatorPage } from "@/components/templates/calculator-page"; + +export const metadata: Metadata = { + title: { absolute: "Calculadora de Valor da Hora e Salário Líquido CLT | WorkLoad" }, + description: + "Descubra quanto vale a sua hora de trabalho a partir do salário bruto, com INSS, IRRF, dependentes, descontos e ganhos extras.", + alternates: { canonical: "/custo-da-hora" }, + openGraph: { + title: "Calculadora de Valor da Hora e Salário Líquido CLT", + description: "Descubra quanto vale a sua hora de trabalho, já com INSS, IRRF e dependentes.", + url: "/custo-da-hora", + }, +}; + +export default function CostPerHour() { + return ( + + + + ); +} diff --git a/app/page.tsx b/app/page.tsx index f306055..73b7e23 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,109 +1,23 @@ -"use client"; - -import { Clock, Moon, Sun, Wallet } from "lucide-react"; -import { useTheme } from "next-themes"; -import { Suspense, useEffect } from "react"; -import { Button } from "@/components/atoms/button"; -import { CalculatorViews, CalculatorViewsFromUrl } from "@/components/organisms/calculator-views"; -import { useCurrentTime } from "@/hooks/use-current-time"; -import { safeGAEvent } from "@/lib/analytics"; -import { formatClockTime } from "@/lib/utils"; - -const PLACEHOLDER_CLOCK = "--:--:--"; +import type { Metadata } from "next"; +import { CalculatorViews } from "@/components/organisms/calculator-views"; +import { CalculatorPage } from "@/components/templates/calculator-page"; + +export const metadata: Metadata = { + title: { absolute: "Calculadora de Jornada, Horas Extras e Banco de Horas | WorkLoad" }, + description: + "Veja a que horas você pode sair, quanto já trabalhou hoje e quanto tem de hora extra, com adicional noturno e os limites da CLT.", + alternates: { canonical: "/" }, + openGraph: { + title: "Calculadora de Jornada, Horas Extras e Banco de Horas", + description: "Veja a que horas você pode sair, quanto já trabalhou hoje e quanto tem de hora extra.", + url: "/", + }, +}; export default function Home() { - const currentTime = useCurrentTime(); - const { setTheme, resolvedTheme } = useTheme(); - - useEffect(() => { - safeGAEvent("session_metadata", { - screen_width: window.screen.width, - screen_height: window.screen.height, - viewport_width: window.innerWidth, - viewport_height: window.innerHeight, - device_pixel_ratio: window.devicePixelRatio, - user_language: navigator.language, - timezone: Intl.DateTimeFormat().resolvedOptions().timeZone, - }); - }, []); - return ( - <> -