323 lines
9.3 KiB
TypeScript
323 lines
9.3 KiB
TypeScript
/**
|
|
* AppHeader.test.tsx
|
|
* Unit tests for the AppHeader component
|
|
* Covers: page titles, navigation links, active state, scoring card,
|
|
* logout button, mobile hamburger menu toggle, and accessibility.
|
|
*/
|
|
|
|
import React from 'react';
|
|
import { render, screen, fireEvent } from '@testing-library/react';
|
|
import { BrowserRouter } from 'react-router-dom';
|
|
import { vi } from 'vitest';
|
|
import AppHeader from '../../src/components/AppHeader';
|
|
|
|
const mockOnLogout = vi.fn();
|
|
|
|
const renderAppHeader = (props: React.ComponentProps<typeof AppHeader>) => {
|
|
return render(
|
|
<BrowserRouter>
|
|
<AppHeader {...props} />
|
|
</BrowserRouter>
|
|
);
|
|
};
|
|
|
|
describe('AppHeader Component', () => {
|
|
beforeEach(() => {
|
|
mockOnLogout.mockClear();
|
|
});
|
|
|
|
it('should render dashboard page title and icon', () => {
|
|
renderAppHeader({
|
|
page: 'dashboard',
|
|
gold: 100,
|
|
completedQuests: 5,
|
|
averageSavings: undefined,
|
|
onLogout: mockOnLogout,
|
|
});
|
|
|
|
// Find the h1 element which contains the page title
|
|
const heading = screen.getByRole('heading', { name: /dashboard/i });
|
|
expect(heading).toBeInTheDocument();
|
|
});
|
|
|
|
it('should render quests page title and icon', () => {
|
|
renderAppHeader({
|
|
page: 'quests',
|
|
gold: 100,
|
|
completedQuests: 5,
|
|
averageSavings: undefined,
|
|
onLogout: mockOnLogout,
|
|
});
|
|
|
|
const heading = screen.getByRole('heading', { name: /quests/i });
|
|
expect(heading).toBeInTheDocument();
|
|
});
|
|
|
|
it('should render map page title and icon', () => {
|
|
renderAppHeader({
|
|
page: 'map',
|
|
gold: 100,
|
|
completedQuests: 5,
|
|
averageSavings: undefined,
|
|
onLogout: mockOnLogout,
|
|
});
|
|
|
|
const heading = screen.getByRole('heading', { name: /middle-earth map/i });
|
|
expect(heading).toBeInTheDocument();
|
|
});
|
|
|
|
it('should render inventory page title and icon', () => {
|
|
renderAppHeader({
|
|
page: 'inventory',
|
|
gold: 100,
|
|
completedQuests: 5,
|
|
averageSavings: 15.5,
|
|
onLogout: mockOnLogout,
|
|
});
|
|
|
|
const heading = screen.getByRole('heading', { name: /inventory/i });
|
|
expect(heading).toBeInTheDocument();
|
|
});
|
|
|
|
it('should render navigation links', () => {
|
|
renderAppHeader({
|
|
page: 'dashboard',
|
|
gold: 100,
|
|
completedQuests: 5,
|
|
averageSavings: undefined,
|
|
onLogout: mockOnLogout,
|
|
});
|
|
|
|
expect(screen.getByTestId('header-nav-dashboard')).toBeInTheDocument();
|
|
expect(screen.getByTestId('header-nav-quests')).toBeInTheDocument();
|
|
expect(screen.getByTestId('header-nav-map')).toBeInTheDocument();
|
|
expect(screen.getByTestId('header-nav-inventory')).toBeInTheDocument();
|
|
});
|
|
|
|
it('should render logout button', () => {
|
|
renderAppHeader({
|
|
page: 'dashboard',
|
|
gold: 100,
|
|
completedQuests: 5,
|
|
averageSavings: undefined,
|
|
onLogout: mockOnLogout,
|
|
});
|
|
|
|
const logoutButton = screen.getByTestId('header-logout-button');
|
|
expect(logoutButton).toBeInTheDocument();
|
|
expect(logoutButton).toHaveTextContent('Leave Fellowship');
|
|
});
|
|
|
|
it('should highlight current page link', () => {
|
|
renderAppHeader({
|
|
page: 'dashboard',
|
|
gold: 100,
|
|
completedQuests: 5,
|
|
averageSavings: undefined,
|
|
onLogout: mockOnLogout,
|
|
});
|
|
|
|
const dashboardLink = screen.getByTestId('header-nav-dashboard');
|
|
// The dashboard link should have different styling when active
|
|
expect(dashboardLink).toHaveClass('font-bold', 'text-gold');
|
|
});
|
|
|
|
it('should render ScoringCard with correct props', () => {
|
|
renderAppHeader({
|
|
page: 'dashboard',
|
|
gold: 500,
|
|
completedQuests: 10,
|
|
averageSavings: 20.5,
|
|
onLogout: mockOnLogout,
|
|
});
|
|
|
|
// Check that ScoringCard is rendered with correct values
|
|
expect(screen.getByTestId('scoring-card')).toBeInTheDocument();
|
|
expect(screen.getByTestId('scoring-card-gold')).toHaveTextContent('500');
|
|
expect(screen.getByTestId('scoring-card-quests')).toHaveTextContent('10');
|
|
expect(screen.getByTestId('scoring-card-savings')).toHaveTextContent('20.50%');
|
|
});
|
|
|
|
it('should render ScoringCard without average savings on non-inventory pages', () => {
|
|
renderAppHeader({
|
|
page: 'quests',
|
|
gold: 100,
|
|
completedQuests: 5,
|
|
averageSavings: undefined,
|
|
onLogout: mockOnLogout,
|
|
});
|
|
|
|
expect(screen.getByTestId('scoring-card-savings')).toHaveTextContent('—');
|
|
});
|
|
|
|
it('should have navigation element with correct testId', () => {
|
|
renderAppHeader({
|
|
page: 'dashboard',
|
|
gold: 100,
|
|
completedQuests: 5,
|
|
averageSavings: undefined,
|
|
onLogout: mockOnLogout,
|
|
});
|
|
|
|
expect(screen.getByTestId('header-navigation')).toBeInTheDocument();
|
|
});
|
|
|
|
it('should render different page titles for different pages', () => {
|
|
const { rerender } = render(
|
|
<BrowserRouter>
|
|
<AppHeader
|
|
page="dashboard"
|
|
gold={100}
|
|
completedQuests={5}
|
|
averageSavings={undefined}
|
|
onLogout={mockOnLogout}
|
|
/>
|
|
</BrowserRouter>
|
|
);
|
|
|
|
let heading = screen.getByRole('heading', { name: /dashboard/i });
|
|
expect(heading).toBeInTheDocument();
|
|
|
|
rerender(
|
|
<BrowserRouter>
|
|
<AppHeader
|
|
page="inventory"
|
|
gold={100}
|
|
completedQuests={5}
|
|
averageSavings={15.5}
|
|
onLogout={mockOnLogout}
|
|
/>
|
|
</BrowserRouter>
|
|
);
|
|
|
|
heading = screen.getByRole('heading', { name: /inventory/i });
|
|
expect(heading).toBeInTheDocument();
|
|
});
|
|
|
|
// ── Mobile Hamburger Menu ──────────────────────────────────────────────────
|
|
|
|
it('should render the mobile hamburger button', () => {
|
|
renderAppHeader({
|
|
page: 'dashboard',
|
|
gold: 100,
|
|
completedQuests: 5,
|
|
averageSavings: undefined,
|
|
onLogout: mockOnLogout,
|
|
});
|
|
|
|
const hamburger = screen.getByTestId('header-mobile-menu-btn');
|
|
expect(hamburger).toBeInTheDocument();
|
|
expect(hamburger).toHaveAttribute('aria-expanded', 'false');
|
|
});
|
|
|
|
it('should toggle aria-expanded on hamburger click', () => {
|
|
renderAppHeader({
|
|
page: 'dashboard',
|
|
gold: 100,
|
|
completedQuests: 5,
|
|
averageSavings: undefined,
|
|
onLogout: mockOnLogout,
|
|
});
|
|
|
|
const hamburger = screen.getByTestId('header-mobile-menu-btn');
|
|
|
|
// Initially closed
|
|
expect(hamburger).toHaveAttribute('aria-expanded', 'false');
|
|
|
|
// Open
|
|
fireEvent.click(hamburger);
|
|
expect(hamburger).toHaveAttribute('aria-expanded', 'true');
|
|
|
|
// Close again
|
|
fireEvent.click(hamburger);
|
|
expect(hamburger).toHaveAttribute('aria-expanded', 'false');
|
|
});
|
|
|
|
it('should add lotr-hamburger--open class when menu is open', () => {
|
|
renderAppHeader({
|
|
page: 'dashboard',
|
|
gold: 100,
|
|
completedQuests: 5,
|
|
averageSavings: undefined,
|
|
onLogout: mockOnLogout,
|
|
});
|
|
|
|
const hamburger = screen.getByTestId('header-mobile-menu-btn');
|
|
expect(hamburger).not.toHaveClass('lotr-hamburger--open');
|
|
|
|
fireEvent.click(hamburger);
|
|
expect(hamburger).toHaveClass('lotr-hamburger--open');
|
|
});
|
|
|
|
it('should apply lotr-header--menu-open class to header when hamburger is open', () => {
|
|
renderAppHeader({
|
|
page: 'dashboard',
|
|
gold: 100,
|
|
completedQuests: 5,
|
|
averageSavings: undefined,
|
|
onLogout: mockOnLogout,
|
|
});
|
|
|
|
const hamburger = screen.getByTestId('header-mobile-menu-btn');
|
|
const header = screen.getByRole('banner');
|
|
|
|
expect(header).not.toHaveClass('lotr-header--menu-open');
|
|
|
|
fireEvent.click(hamburger);
|
|
expect(header).toHaveClass('lotr-header--menu-open');
|
|
});
|
|
|
|
it('should close mobile menu when a nav link is clicked', () => {
|
|
renderAppHeader({
|
|
page: 'dashboard',
|
|
gold: 100,
|
|
completedQuests: 5,
|
|
averageSavings: undefined,
|
|
onLogout: mockOnLogout,
|
|
});
|
|
|
|
const hamburger = screen.getByTestId('header-mobile-menu-btn');
|
|
fireEvent.click(hamburger);
|
|
expect(hamburger).toHaveAttribute('aria-expanded', 'true');
|
|
|
|
// Click a nav link → should close the menu
|
|
const questsLink = screen.getByTestId('header-nav-quests');
|
|
fireEvent.click(questsLink);
|
|
expect(hamburger).toHaveAttribute('aria-expanded', 'false');
|
|
});
|
|
|
|
// ── Fantasy Branding ──────────────────────────────────────────────────────
|
|
|
|
it('should render the brand ring icon SVG', () => {
|
|
renderAppHeader({
|
|
page: 'dashboard',
|
|
gold: 100,
|
|
completedQuests: 5,
|
|
averageSavings: undefined,
|
|
onLogout: mockOnLogout,
|
|
});
|
|
|
|
// The ring SVG is inside the header
|
|
const header = screen.getByRole('banner');
|
|
const rings = header.querySelectorAll('svg');
|
|
// At minimum two SVGs: ring icon + elvish border
|
|
expect(rings.length).toBeGreaterThanOrEqual(2);
|
|
});
|
|
|
|
it('should have aria-label on the hamburger for accessibility', () => {
|
|
renderAppHeader({
|
|
page: 'dashboard',
|
|
gold: 100,
|
|
completedQuests: 5,
|
|
averageSavings: undefined,
|
|
onLogout: mockOnLogout,
|
|
});
|
|
|
|
const hamburger = screen.getByTestId('header-mobile-menu-btn');
|
|
expect(hamburger).toHaveAttribute('aria-label', 'Open navigation menu');
|
|
|
|
fireEvent.click(hamburger);
|
|
expect(hamburger).toHaveAttribute('aria-label', 'Close navigation menu');
|
|
});
|
|
});
|