This commit is contained in:
246
tests/contexts/LeftMenuContext.test.tsx
Normal file
246
tests/contexts/LeftMenuContext.test.tsx
Normal file
@@ -0,0 +1,246 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { act } from 'react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
LeftMenuProvider,
|
||||
type LeftMenuContent,
|
||||
useLeftMenu,
|
||||
} from '../../src/contexts/LeftMenuContext';
|
||||
|
||||
function buildDefaultContent(label = 'Default menu'): LeftMenuContent {
|
||||
return {
|
||||
ariaLabel: label,
|
||||
render: ({ collapsed }) => <div>{collapsed ? `${label} (collapsed)` : `${label} (expanded)`}</div>,
|
||||
};
|
||||
}
|
||||
|
||||
function setViewportWidth(width: number) {
|
||||
Object.defineProperty(window, 'innerWidth', {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: width,
|
||||
});
|
||||
Object.defineProperty(window, 'matchMedia', {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
function renderLeftMenuHarness(initialPathname = '/users') {
|
||||
let currentPathname = initialPathname;
|
||||
let currentValue: ReturnType<typeof useLeftMenu> | null = null;
|
||||
const defaultContent = buildDefaultContent();
|
||||
|
||||
function Probe() {
|
||||
currentValue = useLeftMenu();
|
||||
const renderState = {
|
||||
collapsed: currentValue.collapsed,
|
||||
mobileOpen: currentValue.mobileOpen,
|
||||
isDesktop: window.innerWidth >= 1024,
|
||||
closeMenu: currentValue.closeMenu,
|
||||
};
|
||||
|
||||
return <div data-testid="left-menu-content">{currentValue.content.render(renderState)}</div>;
|
||||
}
|
||||
|
||||
function Wrapper({ pathname }: Readonly<{ pathname: string }>) {
|
||||
return (
|
||||
<LeftMenuProvider defaultContent={defaultContent} closeOnPathname={pathname}>
|
||||
<Probe />
|
||||
</LeftMenuProvider>
|
||||
);
|
||||
}
|
||||
|
||||
const rendered = render(<Wrapper pathname={currentPathname} />);
|
||||
|
||||
return {
|
||||
getCurrent() {
|
||||
if (!currentValue) {
|
||||
throw new Error('Left menu context value not initialized');
|
||||
}
|
||||
return currentValue;
|
||||
},
|
||||
reroute(nextPathname: string) {
|
||||
currentPathname = nextPathname;
|
||||
rendered.rerender(<Wrapper pathname={currentPathname} />);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('LeftMenuContext', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.removeItem('authSidebarWidth');
|
||||
localStorage.removeItem('authSidebarCollapsed');
|
||||
setViewportWidth(1024);
|
||||
});
|
||||
|
||||
it('throws when useLeftMenu is used outside provider', () => {
|
||||
function Invalid() {
|
||||
useLeftMenu();
|
||||
return null;
|
||||
}
|
||||
|
||||
expect(() => render(<Invalid />)).toThrow('useLeftMenu must be used within LeftMenuProvider');
|
||||
});
|
||||
|
||||
it('supports desktop collapse/expand/toggle semantics', () => {
|
||||
const harness = renderLeftMenuHarness('/users');
|
||||
|
||||
expect(harness.getCurrent().collapsed).toBe(false);
|
||||
|
||||
act(() => {
|
||||
harness.getCurrent().closeMenu();
|
||||
});
|
||||
expect(harness.getCurrent().collapsed).toBe(true);
|
||||
|
||||
act(() => {
|
||||
harness.getCurrent().openMenu();
|
||||
});
|
||||
expect(harness.getCurrent().collapsed).toBe(false);
|
||||
|
||||
act(() => {
|
||||
harness.getCurrent().toggleMenu();
|
||||
});
|
||||
expect(harness.getCurrent().collapsed).toBe(true);
|
||||
});
|
||||
|
||||
it('restores collapsed state from storage and clamps persisted width', () => {
|
||||
localStorage.setItem('authSidebarCollapsed', '1');
|
||||
localStorage.setItem('authSidebarWidth', '999');
|
||||
const harness = renderLeftMenuHarness('/users');
|
||||
|
||||
expect(harness.getCurrent().collapsed).toBe(true);
|
||||
expect(harness.getCurrent().desktopMenuStyle['--auth-sidebar-width']).toBe('56px');
|
||||
});
|
||||
|
||||
it('locks body scroll on mobile open and unlocks on close', () => {
|
||||
setViewportWidth(768);
|
||||
|
||||
const harness = renderLeftMenuHarness('/users');
|
||||
|
||||
act(() => {
|
||||
harness.getCurrent().openMenu();
|
||||
});
|
||||
expect(harness.getCurrent().mobileOpen).toBe(true);
|
||||
expect(document.body.style.overflow).toBe('hidden');
|
||||
|
||||
act(() => {
|
||||
harness.getCurrent().closeMenu();
|
||||
});
|
||||
expect(harness.getCurrent().mobileOpen).toBe(false);
|
||||
expect(document.body.style.overflow).toBe('');
|
||||
});
|
||||
|
||||
it('reads, clamps, and persists sidebar width through pointer resize', async () => {
|
||||
localStorage.setItem('authSidebarWidth', '999');
|
||||
const harness = renderLeftMenuHarness('/users');
|
||||
|
||||
expect(harness.getCurrent().desktopMenuStyle['--auth-sidebar-width']).toBe('420px');
|
||||
|
||||
const preventDefault = vi.fn();
|
||||
act(() => {
|
||||
harness.getCurrent().startResize({
|
||||
clientX: 420,
|
||||
preventDefault,
|
||||
} as never);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
fireEvent.pointerMove(window, { clientX: -1000 });
|
||||
fireEvent.pointerUp(window);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(preventDefault).toHaveBeenCalledTimes(1);
|
||||
expect(localStorage.getItem('authSidebarWidth')).toBe('220');
|
||||
expect(harness.getCurrent().desktopMenuStyle['--auth-sidebar-width']).toBe('220px');
|
||||
});
|
||||
|
||||
it('accepts custom content and resets to default on route changes', () => {
|
||||
setViewportWidth(768);
|
||||
const harness = renderLeftMenuHarness('/users');
|
||||
|
||||
act(() => {
|
||||
harness.getCurrent().openMenu();
|
||||
harness.getCurrent().setMenuContent({
|
||||
ariaLabel: 'Custom menu',
|
||||
render: () => <div>Custom menu content</div>,
|
||||
});
|
||||
});
|
||||
|
||||
expect(harness.getCurrent().mobileOpen).toBe(true);
|
||||
expect(screen.getByText('Custom menu content')).toBeInTheDocument();
|
||||
|
||||
harness.reroute('/posts');
|
||||
|
||||
expect(harness.getCurrent().mobileOpen).toBe(false);
|
||||
expect(screen.queryByText('Custom menu content')).not.toBeInTheDocument();
|
||||
expect(screen.getByText('Default menu (expanded)')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('applies provided content in openMenu/toggleMenu and toggles mobile open state', () => {
|
||||
setViewportWidth(768);
|
||||
const harness = renderLeftMenuHarness('/users');
|
||||
|
||||
act(() => {
|
||||
harness.getCurrent().openMenu({
|
||||
render: () => <div>Open payload</div>,
|
||||
});
|
||||
});
|
||||
expect(screen.getByText('Open payload')).toBeInTheDocument();
|
||||
expect(harness.getCurrent().mobileOpen).toBe(true);
|
||||
|
||||
act(() => {
|
||||
harness.getCurrent().toggleMenu({
|
||||
render: () => <div>Toggle payload</div>,
|
||||
});
|
||||
});
|
||||
expect(screen.getByText('Toggle payload')).toBeInTheDocument();
|
||||
expect(harness.getCurrent().mobileOpen).toBe(false);
|
||||
});
|
||||
|
||||
it('updates default content only when current content is still default', () => {
|
||||
function Harness() {
|
||||
const menu = useLeftMenu();
|
||||
const state = {
|
||||
collapsed: menu.collapsed,
|
||||
mobileOpen: menu.mobileOpen,
|
||||
isDesktop: true,
|
||||
closeMenu: menu.closeMenu,
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button type="button" onClick={() => menu.setMenuContent({ render: () => <div>Custom</div> })}>
|
||||
custom
|
||||
</button>
|
||||
<div data-testid="content">{menu.content.render(state)}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Wrapper({ label }: Readonly<{ label: string }>) {
|
||||
return (
|
||||
<LeftMenuProvider defaultContent={buildDefaultContent(label)}>
|
||||
<Harness />
|
||||
</LeftMenuProvider>
|
||||
);
|
||||
}
|
||||
|
||||
const rendered = render(<Wrapper label="Menu A" />);
|
||||
expect(screen.getByTestId('content')).toHaveTextContent('Menu A (expanded)');
|
||||
|
||||
rendered.rerender(<Wrapper label="Menu B" />);
|
||||
expect(screen.getByTestId('content')).toHaveTextContent('Menu B (expanded)');
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'custom' }));
|
||||
expect(screen.getByTestId('content')).toHaveTextContent('Custom');
|
||||
|
||||
rendered.rerender(<Wrapper label="Menu C" />);
|
||||
expect(screen.getByTestId('content')).toHaveTextContent('Custom');
|
||||
});
|
||||
});
|
||||
249
tests/contexts/RightSidebarContext.test.tsx
Normal file
249
tests/contexts/RightSidebarContext.test.tsx
Normal file
@@ -0,0 +1,249 @@
|
||||
import { fireEvent, render } from '@testing-library/react';
|
||||
import { act } from 'react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
RightSidebarProvider,
|
||||
useRightSidebar,
|
||||
} from '../../src/contexts/RightSidebarContext';
|
||||
|
||||
type RightSidebarHarnessOptions = {
|
||||
pathname?: string;
|
||||
onMobileOpenRequest?: () => void;
|
||||
};
|
||||
|
||||
function setViewportWidth(width: number) {
|
||||
Object.defineProperty(window, 'innerWidth', {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: width,
|
||||
});
|
||||
Object.defineProperty(window, 'matchMedia', {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
function renderRightSidebarHarness(options: RightSidebarHarnessOptions = {}) {
|
||||
const { pathname = '/users', onMobileOpenRequest } = options;
|
||||
let currentPathname = pathname;
|
||||
let currentValue: ReturnType<typeof useRightSidebar> | null = null;
|
||||
|
||||
function Probe() {
|
||||
currentValue = useRightSidebar();
|
||||
return null;
|
||||
}
|
||||
|
||||
function Wrapper({ pathname }: Readonly<{ pathname: string }>) {
|
||||
return (
|
||||
<RightSidebarProvider
|
||||
closeOnPathname={pathname}
|
||||
onMobileOpenRequest={onMobileOpenRequest}
|
||||
>
|
||||
<Probe />
|
||||
</RightSidebarProvider>
|
||||
);
|
||||
}
|
||||
|
||||
const rendered = render(<Wrapper pathname={currentPathname} />);
|
||||
|
||||
return {
|
||||
getCurrent() {
|
||||
if (!currentValue) {
|
||||
throw new Error('Right sidebar context value not initialized');
|
||||
}
|
||||
return currentValue;
|
||||
},
|
||||
reroute(nextPathname: string) {
|
||||
currentPathname = nextPathname;
|
||||
rendered.rerender(<Wrapper pathname={currentPathname} />);
|
||||
},
|
||||
unmount: rendered.unmount,
|
||||
};
|
||||
}
|
||||
|
||||
describe('RightSidebarContext', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.removeItem('authRightSidebarWidth');
|
||||
setViewportWidth(1024);
|
||||
});
|
||||
|
||||
it('throws when useRightSidebar is used outside provider', () => {
|
||||
function Invalid() {
|
||||
useRightSidebar();
|
||||
return null;
|
||||
}
|
||||
|
||||
expect(() => render(<Invalid />)).toThrow('useRightSidebar must be used within RightSidebarProvider');
|
||||
});
|
||||
|
||||
it('opens and closes with content', () => {
|
||||
const harness = renderRightSidebarHarness({ pathname: '/users' });
|
||||
|
||||
act(() => {
|
||||
harness.getCurrent().openSidebar({
|
||||
title: 'Meta',
|
||||
content: <div>Body</div>,
|
||||
});
|
||||
});
|
||||
|
||||
expect(harness.getCurrent().isOpen).toBe(true);
|
||||
expect(harness.getCurrent().content?.title).toBe('Meta');
|
||||
|
||||
act(() => {
|
||||
harness.getCurrent().closeSidebar();
|
||||
});
|
||||
|
||||
expect(harness.getCurrent().isOpen).toBe(false);
|
||||
expect(harness.getCurrent().content).toBeNull();
|
||||
});
|
||||
|
||||
it('does not open without current or next content', () => {
|
||||
const harness = renderRightSidebarHarness({ pathname: '/users' });
|
||||
|
||||
act(() => {
|
||||
harness.getCurrent().openSidebar();
|
||||
});
|
||||
|
||||
expect(harness.getCurrent().isOpen).toBe(false);
|
||||
expect(harness.getCurrent().content).toBeNull();
|
||||
});
|
||||
|
||||
it('updates content live and supports toggle semantics', () => {
|
||||
const harness = renderRightSidebarHarness({ pathname: '/users' });
|
||||
|
||||
act(() => {
|
||||
harness.getCurrent().openSidebar({
|
||||
title: 'Meta',
|
||||
content: <div>Body</div>,
|
||||
});
|
||||
});
|
||||
|
||||
act(() => {
|
||||
harness.getCurrent().setSidebarContent({
|
||||
title: 'Meta Updated',
|
||||
content: <div>Updated</div>,
|
||||
});
|
||||
});
|
||||
|
||||
expect(harness.getCurrent().isOpen).toBe(true);
|
||||
expect(harness.getCurrent().content?.title).toBe('Meta Updated');
|
||||
|
||||
act(() => {
|
||||
harness.getCurrent().toggleSidebar();
|
||||
});
|
||||
expect(harness.getCurrent().isOpen).toBe(false);
|
||||
expect(harness.getCurrent().content).toBeNull();
|
||||
});
|
||||
|
||||
it('toggleSidebar opens with provided content when closed', () => {
|
||||
const harness = renderRightSidebarHarness({ pathname: '/users' });
|
||||
|
||||
act(() => {
|
||||
harness.getCurrent().toggleSidebar({
|
||||
title: 'Toggle open',
|
||||
content: <div>Toggle body</div>,
|
||||
});
|
||||
});
|
||||
|
||||
expect(harness.getCurrent().isOpen).toBe(true);
|
||||
expect(harness.getCurrent().content?.title).toBe('Toggle open');
|
||||
});
|
||||
|
||||
it('closes on pathname changes', () => {
|
||||
const harness = renderRightSidebarHarness({ pathname: '/users' });
|
||||
|
||||
act(() => {
|
||||
harness.getCurrent().openSidebar({
|
||||
title: 'Meta',
|
||||
content: <div>Body</div>,
|
||||
});
|
||||
});
|
||||
expect(harness.getCurrent().isOpen).toBe(true);
|
||||
|
||||
harness.reroute('/posts');
|
||||
expect(harness.getCurrent().isOpen).toBe(false);
|
||||
expect(harness.getCurrent().content).toBeNull();
|
||||
});
|
||||
|
||||
it('reads/clamps persisted width, persists resized widths, and reacts to escape', async () => {
|
||||
localStorage.setItem('authRightSidebarWidth', '999');
|
||||
const harness = renderRightSidebarHarness({ pathname: '/users' });
|
||||
|
||||
expect(harness.getCurrent().desktopSidebarStyle['--auth-right-sidebar-width']).toBe(
|
||||
'480px',
|
||||
);
|
||||
|
||||
act(() => {
|
||||
harness.getCurrent().openSidebar({
|
||||
title: 'Meta',
|
||||
content: <div>Body</div>,
|
||||
});
|
||||
});
|
||||
|
||||
const preventDefault = vi.fn();
|
||||
act(() => {
|
||||
harness.getCurrent().startResize({
|
||||
clientX: 480,
|
||||
preventDefault,
|
||||
} as never);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
fireEvent.pointerMove(window, { clientX: -400 });
|
||||
fireEvent.pointerUp(window);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(preventDefault).toHaveBeenCalledTimes(1);
|
||||
expect(localStorage.getItem('authRightSidebarWidth')).toBe('480');
|
||||
|
||||
act(() => {
|
||||
fireEvent.keyDown(window, { key: 'Escape' });
|
||||
});
|
||||
expect(harness.getCurrent().isOpen).toBe(false);
|
||||
});
|
||||
|
||||
it('notifies mobile open requests when opening on mobile', () => {
|
||||
setViewportWidth(768);
|
||||
const onMobileOpenRequest = vi.fn();
|
||||
const harness = renderRightSidebarHarness({
|
||||
pathname: '/users',
|
||||
onMobileOpenRequest,
|
||||
});
|
||||
|
||||
act(() => {
|
||||
harness.getCurrent().openSidebar({
|
||||
title: 'Meta',
|
||||
content: <div>Body</div>,
|
||||
});
|
||||
});
|
||||
|
||||
expect(harness.getCurrent().isOpen).toBe(true);
|
||||
expect(onMobileOpenRequest).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('normalizes custom sizing values', () => {
|
||||
function Probe() {
|
||||
const sidebar = useRightSidebar();
|
||||
return <div data-testid="width">{sidebar.desktopSidebarStyle['--auth-right-sidebar-width']}</div>;
|
||||
}
|
||||
|
||||
const { getByTestId } = render(
|
||||
<RightSidebarProvider
|
||||
sizing={{
|
||||
defaultWidth: 1000,
|
||||
minWidth: 500,
|
||||
maxWidth: 300,
|
||||
}}
|
||||
>
|
||||
<Probe />
|
||||
</RightSidebarProvider>,
|
||||
);
|
||||
|
||||
expect(getByTestId('width').textContent).toBe('500px');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user