committed by
GitHub
62 changed files with 2419 additions and 625 deletions
File diff suppressed because it is too large
@ -0,0 +1,43 @@ |
|||
# Mocks Directory |
|||
|
|||
This directory contains mock implementations used by Vitest for testing. |
|||
|
|||
## Structure |
|||
|
|||
The directory structure mirrors the actual project structure to make mocking |
|||
more intuitive: |
|||
|
|||
``` |
|||
__mocks__/ |
|||
├── components/ |
|||
│ └── UI/ |
|||
│ ├── Dialog.tsx |
|||
│ ├── Button.tsx |
|||
│ ├── Checkbox.tsx |
|||
│ └── ... |
|||
├── core/ |
|||
│ └── ... |
|||
└── ... |
|||
``` |
|||
|
|||
## Auto-mocking |
|||
|
|||
Vitest will automatically use the mock files in this directory when the |
|||
corresponding module is imported in tests. For example, when a test imports |
|||
`@components/UI/Dialog.tsx`, Vitest will use |
|||
`__mocks__/components/UI/Dialog.tsx` instead. |
|||
|
|||
## Creating New Mocks |
|||
|
|||
To create a new mock: |
|||
|
|||
1. Create a file in the same relative path as the original module |
|||
2. Export the mocked functionality with the same names as the original |
|||
3. Add a `vi.mock()` statement to `vitest.setup.ts` if needed |
|||
|
|||
## Mock Guidelines |
|||
|
|||
- Keep mocks as simple as possible |
|||
- Use `data-testid` attributes for easy querying in tests |
|||
- Implement just enough functionality to test the component |
|||
- Use TypeScript types to ensure compatibility with the original module |
|||
@ -0,0 +1,20 @@ |
|||
import { vi } from 'vitest' |
|||
|
|||
vi.mock('@components/UI/Button.tsx', () => ({ |
|||
Button: ({ children, name, disabled, onClick }: { |
|||
children: React.ReactNode, |
|||
variant: string, |
|||
name: string, |
|||
disabled?: boolean, |
|||
onClick: () => void |
|||
}) => |
|||
<button |
|||
type="button" |
|||
name={name} |
|||
data-testid={`button-${name}`} |
|||
disabled={disabled} |
|||
onClick={onClick} |
|||
> |
|||
{children} |
|||
</button> |
|||
})); |
|||
@ -0,0 +1,6 @@ |
|||
import { vi } from 'vitest' |
|||
|
|||
vi.mock('@components/UI/Checkbox.tsx', () => ({ |
|||
Checkbox: ({ id, checked, onChange }: { id: string, checked: boolean, onChange: () => void }) => |
|||
<input data-testid="checkbox" type="checkbox" id={id} checked={checked} onChange={onChange} /> |
|||
})); |
|||
@ -0,0 +1,43 @@ |
|||
import React from 'react'; |
|||
|
|||
export const Dialog = ({ children, open }: { |
|||
children: React.ReactNode, |
|||
open: boolean, |
|||
onOpenChange?: (open: boolean) => void |
|||
}) => open ? <div data-testid="dialog">{children}</div> : null; |
|||
|
|||
export const DialogContent = ({ |
|||
children, |
|||
className |
|||
}: { |
|||
children: React.ReactNode, |
|||
className?: string |
|||
}) => <div data-testid="dialog-content" className={className}>{children}</div>; |
|||
|
|||
export const DialogHeader = ({ |
|||
children |
|||
}: { |
|||
children: React.ReactNode |
|||
}) => <div data-testid="dialog-header">{children}</div>; |
|||
|
|||
export const DialogTitle = ({ |
|||
children |
|||
}: { |
|||
children: React.ReactNode |
|||
}) => <div data-testid="dialog-title">{children}</div>; |
|||
|
|||
export const DialogDescription = ({ |
|||
children, |
|||
className |
|||
}: { |
|||
children: React.ReactNode, |
|||
className?: string |
|||
}) => <div data-testid="dialog-description" className={className}>{children}</div>; |
|||
|
|||
export const DialogFooter = ({ |
|||
children, |
|||
className |
|||
}: { |
|||
children: React.ReactNode, |
|||
className?: string |
|||
}) => <div data-testid="dialog-footer" className={className}>{children}</div>; |
|||
@ -0,0 +1,6 @@ |
|||
import { vi } from 'vitest' |
|||
|
|||
vi.mock('@components/UI/Label.tsx', () => ({ |
|||
Label: ({ children, htmlFor, className }: { children: React.ReactNode, htmlFor: string, className?: string }) => |
|||
<label data-testid="label" htmlFor={htmlFor} className={className}>{children}</label> |
|||
})); |
|||
@ -0,0 +1,7 @@ |
|||
import { vi } from "vitest"; |
|||
|
|||
vi.mock('@components/UI/Typography/Link.tsx', () => ({ |
|||
Link: ({ children, href, className }: { children: React.ReactNode, href: string, className?: string }) => |
|||
<a data-testid="link" href={href} className={className}>{children}</a> |
|||
})); |
|||
|
|||
@ -0,0 +1,91 @@ |
|||
// deno-lint-ignore-file
|
|||
import { render, screen, fireEvent } from "@testing-library/react"; |
|||
import { describe, it, expect, vi } from "vitest"; |
|||
import { UnsafeRolesDialog } from "@components/Dialog/UnsafeRolesDialog/UnsafeRolesDialog.tsx"; |
|||
import { eventBus } from "@core/utils/eventBus.ts"; |
|||
import { DeviceWrapper } from "@app/DeviceWrapper.tsx"; |
|||
|
|||
describe("UnsafeRolesDialog", () => { |
|||
const mockDevice = { |
|||
setDialogOpen: vi.fn(), |
|||
}; |
|||
|
|||
const renderWithDeviceContext = (ui: any) => { |
|||
return render( |
|||
<DeviceWrapper device={mockDevice}> |
|||
{ui} |
|||
</DeviceWrapper> |
|||
); |
|||
}; |
|||
|
|||
it("renders the dialog when open is true", () => { |
|||
renderWithDeviceContext(<UnsafeRolesDialog open={true} onOpenChange={vi.fn()} />); |
|||
|
|||
const dialog = screen.getByRole('dialog'); |
|||
expect(dialog).toBeInTheDocument(); |
|||
|
|||
expect(screen.getByText(/I have read the/i)).toBeInTheDocument(); |
|||
expect(screen.getByText(/understand the implications/i)).toBeInTheDocument(); |
|||
|
|||
const links = screen.getAllByRole('link'); |
|||
expect(links).toHaveLength(2); |
|||
expect(links[0]).toHaveTextContent('Device Role Documentation'); |
|||
expect(links[1]).toHaveTextContent('Choosing The Right Device Role'); |
|||
}); |
|||
|
|||
it("displays the correct links", () => { |
|||
renderWithDeviceContext(<UnsafeRolesDialog open={true} onOpenChange={vi.fn()} />); |
|||
|
|||
const docLink = screen.getByRole("link", { name: /Device Role Documentation/i }); |
|||
const blogLink = screen.getByRole("link", { name: /Choosing The Right Device Role/i }); |
|||
|
|||
expect(docLink).toHaveAttribute("href", "https://meshtastic.org/docs/configuration/radio/device/"); |
|||
expect(blogLink).toHaveAttribute("href", "https://meshtastic.org/blog/choosing-the-right-device-role/"); |
|||
}); |
|||
|
|||
it("does not allow confirmation until checkbox is checked", () => { |
|||
renderWithDeviceContext(<UnsafeRolesDialog open={true} onOpenChange={vi.fn()} />); |
|||
|
|||
const confirmButton = screen.getByRole("button", { name: /confirm/i }); |
|||
|
|||
expect(confirmButton).toBeDisabled(); |
|||
|
|||
const checkbox = screen.getByRole("checkbox"); |
|||
fireEvent.click(checkbox); |
|||
|
|||
expect(confirmButton).toBeEnabled(); |
|||
}); |
|||
|
|||
it("emits the correct event when closing via close button", () => { |
|||
const eventSpy = vi.spyOn(eventBus, "emit"); |
|||
renderWithDeviceContext(<UnsafeRolesDialog open={true} onOpenChange={vi.fn()} />); |
|||
|
|||
const dismissButton = screen.getByRole("button", { name: /close/i }); |
|||
fireEvent.click(dismissButton); |
|||
|
|||
expect(eventSpy).toHaveBeenCalledWith("dialog:unsafeRoles", { action: "dismiss" }); |
|||
}); |
|||
|
|||
it("emits the correct event when dismissing", () => { |
|||
const eventSpy = vi.spyOn(eventBus, "emit"); |
|||
renderWithDeviceContext(<UnsafeRolesDialog open={true} onOpenChange={vi.fn()} />); |
|||
|
|||
const dismissButton = screen.getByRole("button", { name: /dismiss/i }); |
|||
fireEvent.click(dismissButton); |
|||
|
|||
expect(eventSpy).toHaveBeenCalledWith("dialog:unsafeRoles", { action: "dismiss" }); |
|||
}); |
|||
|
|||
it("emits the correct event when confirming", () => { |
|||
const eventSpy = vi.spyOn(eventBus, "emit"); |
|||
renderWithDeviceContext(<UnsafeRolesDialog open={true} onOpenChange={vi.fn()} />); |
|||
|
|||
const checkbox = screen.getByRole("checkbox"); |
|||
const confirmButton = screen.getByRole("button", { name: /confirm/i }); |
|||
|
|||
fireEvent.click(checkbox); |
|||
fireEvent.click(confirmButton); |
|||
|
|||
expect(eventSpy).toHaveBeenCalledWith("dialog:unsafeRoles", { action: "confirm" }); |
|||
}); |
|||
}); |
|||
@ -0,0 +1,71 @@ |
|||
import { |
|||
Dialog, |
|||
DialogClose, |
|||
DialogContent, |
|||
DialogDescription, |
|||
DialogFooter, |
|||
DialogHeader, |
|||
DialogTitle, |
|||
} from "@components/UI/Dialog.tsx"; |
|||
import { Link } from "@components/UI/Typography/Link.tsx"; |
|||
import { Checkbox } from "@components/UI/Checkbox/index.tsx"; |
|||
import { Button } from "@components/UI/Button.tsx"; |
|||
import { useDevice } from "@core/stores/deviceStore.ts"; |
|||
import { useState } from "react"; |
|||
import { eventBus } from "@core/utils/eventBus.ts"; |
|||
|
|||
export interface RouterRoleDialogProps { |
|||
open: boolean; |
|||
onOpenChange: (open: boolean) => void; |
|||
} |
|||
|
|||
export const UnsafeRolesDialog = ({ open, onOpenChange }: RouterRoleDialogProps) => { |
|||
const [confirmState, setConfirmState] = useState(false); |
|||
const { setDialogOpen } = useDevice(); |
|||
|
|||
const deviceRoleLink = "https://meshtastic.org/docs/configuration/radio/device/"; |
|||
const choosingTheRightDeviceRoleLink = "https://meshtastic.org/blog/choosing-the-right-device-role/"; |
|||
|
|||
const handleCloseDialog = (action: 'confirm' | 'dismiss') => { |
|||
setDialogOpen('unsafeRoles', false); |
|||
setConfirmState(false); |
|||
eventBus.emit('dialog:unsafeRoles', { action }); |
|||
} |
|||
|
|||
return ( |
|||
<Dialog open={open} onOpenChange={onOpenChange}> |
|||
<DialogContent className="max-w-8 flex flex-col"> |
|||
<DialogClose onClick={() => handleCloseDialog('dismiss')} /> |
|||
<DialogHeader> |
|||
<DialogTitle>Are you sure?</DialogTitle> |
|||
</DialogHeader> |
|||
<DialogDescription className="text-md"> |
|||
I have read the <Link href={deviceRoleLink} className="">Device Role Documentation</Link>{" "} |
|||
and the blog post about <Link href={choosingTheRightDeviceRoleLink}>Choosing The Right Device Role</Link> and understand the implications of changing the role. |
|||
</DialogDescription> |
|||
<div className="flex items-center gap-2"> |
|||
<Checkbox |
|||
id="routerRole" |
|||
checked={confirmState} |
|||
onChange={() => setConfirmState(!confirmState)} |
|||
> |
|||
Yes, I know what I'm doing |
|||
</Checkbox> |
|||
</div> |
|||
<DialogFooter className="mt-6"> |
|||
<Button |
|||
variant="default" |
|||
name="dismiss" |
|||
onClick={() => handleCloseDialog('dismiss')}> Dismiss |
|||
</Button> |
|||
<Button |
|||
variant="default" |
|||
name="confirm" |
|||
disabled={!confirmState} |
|||
onClick={() => handleCloseDialog('confirm')}> Confirm |
|||
</Button> |
|||
</DialogFooter> |
|||
</DialogContent> |
|||
</Dialog > |
|||
); |
|||
}; |
|||
@ -0,0 +1,117 @@ |
|||
import { describe, it, expect, vi, beforeEach, afterEach, Mock } from 'vitest'; |
|||
import { renderHook } from '@testing-library/react'; |
|||
import { useUnsafeRolesDialog, UNSAFE_ROLES } from "@components/Dialog/UnsafeRolesDialog/useUnsafeRolesDialog"; |
|||
import { eventBus } from "@core/utils/eventBus"; |
|||
|
|||
vi.mock('@core/utils/eventBus', () => ({ |
|||
eventBus: { |
|||
on: vi.fn(), |
|||
off: vi.fn(), |
|||
emit: vi.fn(), |
|||
}, |
|||
})); |
|||
|
|||
const mockDevice = { |
|||
setDialogOpen: vi.fn(), |
|||
}; |
|||
|
|||
vi.mock('@core/stores/deviceStore', () => ({ |
|||
useDevice: () => ({ |
|||
setDialogOpen: mockDevice.setDialogOpen, |
|||
}), |
|||
})); |
|||
|
|||
describe('useUnsafeRolesDialog', () => { |
|||
beforeEach(() => { |
|||
vi.resetAllMocks(); |
|||
}); |
|||
|
|||
afterEach(() => { |
|||
vi.clearAllMocks(); |
|||
}); |
|||
|
|||
const renderUnsafeRolesHook = () => { |
|||
return renderHook(() => useUnsafeRolesDialog()); |
|||
}; |
|||
|
|||
describe('handleCloseDialog', () => { |
|||
it('should call setDialogOpen with correct parameters when dialog is closed', () => { |
|||
const { result } = renderUnsafeRolesHook(); |
|||
|
|||
result.current.handleCloseDialog(); |
|||
|
|||
expect(mockDevice.setDialogOpen).toHaveBeenCalledWith('unsafeRoles', false); |
|||
}); |
|||
}); |
|||
|
|||
describe('validateRoleSelection', () => { |
|||
it('should resolve with true for safe roles without opening dialog', async () => { |
|||
const { result } = renderUnsafeRolesHook(); |
|||
const safeRole = 'SAFE_ROLE'; |
|||
|
|||
const validationResult = await result.current.validateRoleSelection(safeRole); |
|||
|
|||
expect(validationResult).toBe(true); |
|||
expect(mockDevice.setDialogOpen).not.toHaveBeenCalled(); |
|||
}); |
|||
|
|||
it('should open dialog for unsafe roles and resolve with true when confirmed', async () => { |
|||
const { result } = renderUnsafeRolesHook(); |
|||
|
|||
const validationPromise = result.current.validateRoleSelection(UNSAFE_ROLES[0]); |
|||
|
|||
expect(mockDevice.setDialogOpen).toHaveBeenCalledWith('unsafeRoles', true); |
|||
expect(eventBus.on).toHaveBeenCalledWith('dialog:unsafeRoles', expect.any(Function)); |
|||
|
|||
const onHandler = (eventBus.on as Mock).mock.calls[0][1]; |
|||
onHandler({ action: 'confirm' }); |
|||
const validationResult = await validationPromise; |
|||
|
|||
expect(validationResult).toBe(true); |
|||
expect(eventBus.off).toHaveBeenCalledWith('dialog:unsafeRoles', onHandler); |
|||
}); |
|||
|
|||
it('should resolve with false when user dismisses the dialog', async () => { |
|||
const { result } = renderUnsafeRolesHook(); |
|||
const validationPromise = result.current.validateRoleSelection(UNSAFE_ROLES[0]); |
|||
const onHandler = (eventBus.on as Mock).mock.calls[0][1]; |
|||
onHandler({ action: 'dismiss' }); |
|||
|
|||
const validationResult = await validationPromise; |
|||
expect(validationResult).toBe(false); |
|||
expect(eventBus.off).toHaveBeenCalledWith('dialog:unsafeRoles', onHandler); |
|||
}); |
|||
|
|||
it('should clean up event listener after response', async () => { |
|||
const { result } = renderUnsafeRolesHook(); |
|||
|
|||
const validationPromise = result.current.validateRoleSelection(UNSAFE_ROLES[1]); |
|||
const onHandler = (eventBus.on as Mock).mock.calls[0][1]; |
|||
|
|||
onHandler({ action: 'confirm' }); |
|||
await validationPromise; |
|||
|
|||
expect(eventBus.off).toHaveBeenCalledWith('dialog:unsafeRoles', onHandler); |
|||
}); |
|||
}); |
|||
|
|||
it('should work with all unsafe roles', async () => { |
|||
const { result } = renderUnsafeRolesHook(); |
|||
|
|||
for (const unsafeRole of UNSAFE_ROLES) { |
|||
mockDevice.setDialogOpen.mockClear(); |
|||
(eventBus.on as Mock).mockClear(); |
|||
|
|||
const validationPromise = result.current.validateRoleSelection(unsafeRole); |
|||
|
|||
expect(mockDevice.setDialogOpen).toHaveBeenCalledWith('unsafeRoles', true); |
|||
|
|||
const onHandler = (eventBus.on as Mock).mock.calls[0][1]; |
|||
onHandler({ action: 'confirm' }); |
|||
|
|||
const validationResult = await validationPromise; |
|||
|
|||
expect(validationResult).toBe(true); |
|||
} |
|||
}); |
|||
}); |
|||
@ -0,0 +1,39 @@ |
|||
import { useCallback } from "react"; |
|||
import { eventBus } from "@core/utils/eventBus.ts"; |
|||
import { useDevice } from "@core/stores/deviceStore.ts"; |
|||
|
|||
export const UNSAFE_ROLES = ["ROUTER", "REPEATER"]; |
|||
export type UnsafeRole = typeof UNSAFE_ROLES[number]; |
|||
|
|||
export const useUnsafeRolesDialog = () => { |
|||
const { setDialogOpen } = useDevice(); |
|||
|
|||
const handleCloseDialog = useCallback(() => { |
|||
setDialogOpen("unsafeRoles", false); |
|||
}, [setDialogOpen]); |
|||
|
|||
const validateRoleSelection = useCallback( |
|||
(newRoleKey: string): Promise<boolean> => { |
|||
if (!UNSAFE_ROLES.includes(newRoleKey as UnsafeRole)) { |
|||
return Promise.resolve(true); |
|||
} |
|||
|
|||
setDialogOpen("unsafeRoles", true); |
|||
|
|||
return new Promise((resolve) => { |
|||
const handleResponse = ({ action }: { action: "confirm" | "dismiss" }) => { |
|||
eventBus.off("dialog:unsafeRoles", handleResponse); |
|||
resolve(action === "confirm"); |
|||
}; |
|||
|
|||
eventBus.on("dialog:unsafeRoles", handleResponse); |
|||
}); |
|||
}, |
|||
[setDialogOpen] |
|||
); |
|||
|
|||
return { |
|||
handleCloseDialog, |
|||
validateRoleSelection, |
|||
}; |
|||
}; |
|||
@ -0,0 +1,129 @@ |
|||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; |
|||
import { render, screen, fireEvent, waitFor } from '@testing-library/react'; |
|||
import { Device } from '@components/PageComponents/Config/Device/index.tsx'; |
|||
import { useDevice } from "@core/stores/deviceStore.ts"; |
|||
import { useUnsafeRolesDialog } from "@components/Dialog/UnsafeRolesDialog/useUnsafeRolesDialog.ts"; |
|||
import { Protobuf } from "@meshtastic/core"; |
|||
|
|||
vi.mock('@core/stores/deviceStore', () => ({ |
|||
useDevice: vi.fn() |
|||
})); |
|||
|
|||
vi.mock('@components/Dialog/UnsafeRolesDialog/useUnsafeRolesDialog', () => ({ |
|||
useUnsafeRolesDialog: vi.fn() |
|||
})); |
|||
|
|||
// Mock the DynamicForm component since we're testing the Device component,
|
|||
// not the DynamicForm implementation
|
|||
vi.mock('@components/Form/DynamicForm', () => ({ |
|||
DynamicForm: vi.fn(({ onSubmit }) => { |
|||
// Render a simplified version of the form for testing
|
|||
return ( |
|||
<div data-testid="dynamic-form"> |
|||
<select |
|||
data-testid="role-select" |
|||
onChange={(e) => { |
|||
// Simulate the validation and submission process
|
|||
const mockData = { role: e.target.value }; |
|||
onSubmit(mockData); |
|||
}} |
|||
> |
|||
{Object.entries(Protobuf.Config.Config_DeviceConfig_Role).map(([key, value]) => ( |
|||
<option key={key} value={value}> |
|||
{key} |
|||
</option> |
|||
))} |
|||
</select> |
|||
<button type="submit" |
|||
data-testid="submit-button" |
|||
onClick={() => onSubmit({ role: "CLIENT" })} |
|||
> |
|||
Submit |
|||
</button> |
|||
</div> |
|||
); |
|||
}) |
|||
})); |
|||
|
|||
describe('Device component', () => { |
|||
const setWorkingConfigMock = vi.fn(); |
|||
const validateRoleSelectionMock = vi.fn(); |
|||
const mockDeviceConfig = { |
|||
role: "CLIENT", |
|||
buttonGpio: 0, |
|||
buzzerGpio: 0, |
|||
rebroadcastMode: "ALL", |
|||
nodeInfoBroadcastSecs: 300, |
|||
doubleTapAsButtonPress: false, |
|||
disableTripleClick: false, |
|||
ledHeartbeatDisabled: false, |
|||
}; |
|||
|
|||
beforeEach(() => { |
|||
vi.resetAllMocks(); |
|||
|
|||
// Mock the useDevice hook
|
|||
(useDevice as any).mockReturnValue({ |
|||
config: { |
|||
device: mockDeviceConfig |
|||
}, |
|||
setWorkingConfig: setWorkingConfigMock |
|||
}); |
|||
|
|||
// Mock the useUnsafeRolesDialog hook
|
|||
validateRoleSelectionMock.mockResolvedValue(true); |
|||
(useUnsafeRolesDialog as any).mockReturnValue({ |
|||
validateRoleSelection: validateRoleSelectionMock |
|||
}); |
|||
}); |
|||
|
|||
afterEach(() => { |
|||
vi.clearAllMocks(); |
|||
}); |
|||
|
|||
it('should render the Device form', () => { |
|||
render(<Device />); |
|||
expect(screen.getByTestId('dynamic-form')).toBeInTheDocument(); |
|||
}); |
|||
|
|||
it('should use the validateRoleSelection from the unsafe roles hook', () => { |
|||
render(<Device />); |
|||
expect(useUnsafeRolesDialog).toHaveBeenCalled(); |
|||
}); |
|||
|
|||
it('should call setWorkingConfig when form is submitted', async () => { |
|||
render(<Device />); |
|||
|
|||
fireEvent.click(screen.getByTestId('submit-button')); |
|||
|
|||
await waitFor(() => { |
|||
expect(setWorkingConfigMock).toHaveBeenCalledWith( |
|||
expect.objectContaining({ |
|||
payloadVariant: { |
|||
case: "device", |
|||
value: expect.objectContaining({ role: "CLIENT" }) |
|||
} |
|||
}) |
|||
); |
|||
}); |
|||
}); |
|||
|
|||
|
|||
it('should create config with proper structure', async () => { |
|||
render(<Device />); |
|||
|
|||
// Simulate form submission
|
|||
fireEvent.click(screen.getByTestId('submit-button')); |
|||
|
|||
await waitFor(() => { |
|||
expect(setWorkingConfigMock).toHaveBeenCalledWith( |
|||
expect.objectContaining({ |
|||
payloadVariant: { |
|||
case: "device", |
|||
value: expect.any(Object) |
|||
} |
|||
}) |
|||
); |
|||
}); |
|||
}); |
|||
}); |
|||
@ -0,0 +1,152 @@ |
|||
import { MessageInput } from '@components/PageComponents/Messages/MessageInput.tsx'; |
|||
import { useDevice } from "@core/stores/deviceStore.ts"; |
|||
import { vi, describe, it, expect, beforeEach, Mock } from 'vitest'; |
|||
import { render, screen, fireEvent, waitFor } from '@testing-library/react'; |
|||
import userEvent from '@testing-library/user-event'; |
|||
|
|||
vi.mock("@core/stores/deviceStore.ts", () => ({ |
|||
useDevice: vi.fn(), |
|||
})); |
|||
|
|||
vi.mock("@core/utils/debounce.ts", () => ({ |
|||
debounce: (fn: () => void) => fn, |
|||
})); |
|||
|
|||
vi.mock("@components/UI/Button.tsx", () => ({ |
|||
Button: ({ children, ...props }: { children: React.ReactNode }) => <button {...props}>{children}</button> |
|||
})); |
|||
|
|||
vi.mock("@components/UI/Input.tsx", () => ({ |
|||
Input: (props: any) => <input {...props} /> |
|||
})); |
|||
|
|||
vi.mock("lucide-react", () => ({ |
|||
SendIcon: () => <div data-testid="send-icon">Send</div> |
|||
})); |
|||
|
|||
// TODO: getting an error with this test
|
|||
describe('MessageInput Component', () => { |
|||
const mockProps = { |
|||
to: "broadcast" as const, |
|||
channel: 0 as const, |
|||
maxBytes: 100, |
|||
}; |
|||
|
|||
const mockSetMessageDraft = vi.fn(); |
|||
const mockSetMessageState = vi.fn(); |
|||
const mockSendText = vi.fn().mockResolvedValue(123); |
|||
|
|||
beforeEach(() => { |
|||
vi.clearAllMocks(); |
|||
|
|||
(useDevice as Mock).mockReturnValue({ |
|||
connection: { |
|||
sendText: mockSendText, |
|||
}, |
|||
setMessageState: mockSetMessageState, |
|||
messageDraft: "", |
|||
setMessageDraft: mockSetMessageDraft, |
|||
hardware: { |
|||
myNodeNum: 1234567890, |
|||
}, |
|||
}); |
|||
}); |
|||
|
|||
it('renders correctly with initial state', () => { |
|||
render(<MessageInput {...mockProps} />); |
|||
|
|||
expect(screen.getByPlaceholderText('Enter Message')).toBeInTheDocument(); |
|||
expect(screen.getByTestId('send-icon')).toBeInTheDocument(); |
|||
|
|||
expect(screen.getByText('0/100')).toBeInTheDocument(); |
|||
}); |
|||
|
|||
it('updates local draft and byte count when typing', () => { |
|||
render(<MessageInput {...mockProps} />); |
|||
|
|||
const inputField = screen.getByPlaceholderText('Enter Message'); |
|||
fireEvent.change(inputField, { target: { value: 'Hello' } }) |
|||
|
|||
expect(screen.getByText('5/100')).toBeInTheDocument(); |
|||
expect(inputField).toHaveValue('Hello'); |
|||
expect(mockSetMessageDraft).toHaveBeenCalledWith('Hello'); |
|||
}); |
|||
|
|||
it.skip('does not allow input exceeding max bytes', () => { |
|||
render(<MessageInput {...mockProps} maxBytes={5} />); |
|||
|
|||
const inputField = screen.getByPlaceholderText('Enter Message'); |
|||
|
|||
expect(screen.getByText('0/100')).toBeInTheDocument(); |
|||
|
|||
userEvent.type(inputField, 'Lorem ipsum dolor sit amet, consectetuer adipiscing elit. Aenean commodo ligula eget dolor. Aenean massa. Cum sociis natoque penatibus et magnis dis p') |
|||
|
|||
expect(screen.getByText('100/100')).toBeInTheDocument(); |
|||
expect(inputField).toHaveValue('Lorem ipsum dolor sit amet, consectetuer adipiscing elit. Aenean commodo ligula eget dolor. Aenean m'); |
|||
}); |
|||
|
|||
it.skip('sends message and resets form when submitting', async () => { |
|||
try { |
|||
render(<MessageInput {...mockProps} />); |
|||
|
|||
const inputField = screen.getByPlaceholderText('Enter Message'); |
|||
const submitButton = screen.getByText('Send'); |
|||
|
|||
fireEvent.change(inputField, { target: { value: 'Test Message' } }); |
|||
fireEvent.click(submitButton); |
|||
|
|||
const form = screen.getByRole('form'); |
|||
fireEvent.submit(form); |
|||
|
|||
expect(mockSendText).toHaveBeenCalledWith('Test message', 'broadcast', true, 0); |
|||
|
|||
await waitFor(() => { |
|||
expect(mockSetMessageState).toHaveBeenCalledWith( |
|||
'broadcast', |
|||
0, |
|||
'broadcast', |
|||
1234567890, |
|||
123, |
|||
'ack' |
|||
); |
|||
|
|||
}); |
|||
|
|||
expect(inputField).toHaveValue(''); |
|||
expect(screen.getByText('0/100')).toBeInTheDocument(); |
|||
expect(mockSetMessageDraft).toHaveBeenCalledWith(''); |
|||
} catch (e) { |
|||
console.error(e); |
|||
} |
|||
}); |
|||
it('prevents sending empty messages', () => { |
|||
render(<MessageInput {...mockProps} />); |
|||
|
|||
const form = screen.getByPlaceholderText('Enter Message') |
|||
fireEvent.submit(form); |
|||
|
|||
expect(mockSendText).not.toHaveBeenCalled(); |
|||
}); |
|||
|
|||
it('initializes with existing message draft', () => { |
|||
(useDevice as Mock).mockReturnValue({ |
|||
connection: { |
|||
sendText: mockSendText, |
|||
}, |
|||
setMessageState: mockSetMessageState, |
|||
messageDraft: "Existing draft", |
|||
setMessageDraft: mockSetMessageDraft, |
|||
isQueueingMessages: false, |
|||
queueStatus: { free: 10 }, |
|||
hardware: { |
|||
myNodeNum: 1234567890, |
|||
}, |
|||
}); |
|||
|
|||
render(<MessageInput {...mockProps} />); |
|||
|
|||
const inputField = screen.getByRole('textbox'); |
|||
|
|||
expect(inputField).toHaveValue('Existing draft'); |
|||
}); |
|||
}); |
|||
@ -1,28 +0,0 @@ |
|||
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"; |
|||
import { Check } from "lucide-react"; |
|||
import * as React from "react"; |
|||
|
|||
import { cn } from "@core/utils/cn.ts"; |
|||
|
|||
const Checkbox = React.forwardRef< |
|||
React.ElementRef<typeof CheckboxPrimitive.Root>, |
|||
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root> |
|||
>(({ className, ...props }, ref) => ( |
|||
<CheckboxPrimitive.Root |
|||
ref={ref} |
|||
className={cn( |
|||
"peer h-4 w-4 shrink-0 rounded-xs border border-slate-300 focus:outline-hidden focus:ring-2 focus:ring-slate-400 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 dark:border-slate-700 dark:text-slate-50 dark:focus:ring-slate-400 dark:focus:ring-offset-slate-900", |
|||
className, |
|||
)} |
|||
{...props} |
|||
> |
|||
<CheckboxPrimitive.Indicator |
|||
className={cn("flex items-center justify-center")} |
|||
> |
|||
<Check className="h-4 w-4" /> |
|||
</CheckboxPrimitive.Indicator> |
|||
</CheckboxPrimitive.Root> |
|||
)); |
|||
Checkbox.displayName = CheckboxPrimitive.Root.displayName; |
|||
|
|||
export { Checkbox }; |
|||
@ -0,0 +1,120 @@ |
|||
import { describe, it, expect, vi, beforeEach } from 'vitest'; |
|||
import { render, screen, fireEvent, cleanup } from '@testing-library/react'; |
|||
import { Checkbox } from '@components/UI/Checkbox/index.tsx'; |
|||
import React from "react"; |
|||
|
|||
vi.mock('@components/UI/Label.tsx', () => ({ |
|||
Label: ({ children, className, htmlFor, id }: { children: React.ReactNode; className: string; htmlFor: string; id: string }) => ( |
|||
<label data-testid="label-component" className={className} htmlFor={htmlFor} id={id}> |
|||
{children} |
|||
</label> |
|||
), |
|||
})); |
|||
|
|||
vi.mock('@core/utils/cn.ts', () => ({ |
|||
cn: (...args: any) => args.filter(Boolean).join(' '), |
|||
})); |
|||
|
|||
vi.mock('react', async () => { |
|||
const actual = await vi.importActual('react'); |
|||
return { |
|||
...actual, |
|||
useId: () => 'test-id', |
|||
}; |
|||
}); |
|||
|
|||
describe('Checkbox', () => { |
|||
beforeEach(cleanup); |
|||
|
|||
it('renders unchecked by default', () => { |
|||
render(<Checkbox />); |
|||
const checkbox = screen.getByRole('checkbox'); |
|||
expect(checkbox).not.toBeChecked(); |
|||
expect(screen.queryByText('Check')).not.toBeInTheDocument(); |
|||
}); |
|||
|
|||
it('renders checked when checked prop is true', () => { |
|||
render(<Checkbox checked={true} />); |
|||
expect(screen.getByRole('checkbox')).toBeChecked(); |
|||
expect(screen.getByRole('presentation')).toBeInTheDocument(); |
|||
}); |
|||
|
|||
it('calls onChange when clicked', () => { |
|||
const onChange = vi.fn(); |
|||
render(<Checkbox onChange={onChange} />); |
|||
|
|||
fireEvent.click(screen.getByRole('presentation')); |
|||
expect(onChange).toHaveBeenCalledWith(true); |
|||
|
|||
fireEvent.click(screen.getByRole('presentation')); |
|||
expect(onChange).toHaveBeenCalledWith(false); |
|||
}); |
|||
|
|||
it('uses provided id', () => { |
|||
render(<Checkbox id="custom-id" />); |
|||
expect(screen.getByRole('checkbox').id).toBe('custom-id'); |
|||
}); |
|||
|
|||
it('generates id when not provided', () => { |
|||
render(<Checkbox />); |
|||
expect(screen.getByRole('checkbox').id).toBe('test-id'); |
|||
}); |
|||
|
|||
it('renders children in Label component', () => { |
|||
render(<Checkbox>Test Label</Checkbox>); |
|||
expect(screen.getByTestId('label-component')).toHaveTextContent('Test Label'); |
|||
}); |
|||
|
|||
it('applies custom className', () => { |
|||
const { container } = render(<Checkbox className="custom-class" />); |
|||
expect(container.firstChild).toHaveClass('custom-class'); |
|||
}); |
|||
|
|||
it('applies labelClassName to Label', () => { |
|||
render(<Checkbox labelClassName="label-class">Test</Checkbox>); |
|||
expect(screen.getByTestId('label-component')).toHaveClass('label-class'); |
|||
}); |
|||
|
|||
it('disables checkbox when disabled prop is true', () => { |
|||
render(<Checkbox disabled />); |
|||
expect(screen.getByRole('checkbox')).toBeDisabled(); |
|||
expect(screen.getByRole('presentation')).toHaveClass('opacity-50'); |
|||
}); |
|||
|
|||
it('does not call onChange when disabled', () => { |
|||
const onChange = vi.fn(); |
|||
render(<Checkbox onChange={onChange} disabled />); |
|||
|
|||
fireEvent.click(screen.getByRole('presentation')); |
|||
expect(onChange).not.toHaveBeenCalled(); |
|||
}); |
|||
|
|||
it('sets required attribute when required prop is true', () => { |
|||
render(<Checkbox required />); |
|||
expect(screen.getByRole('checkbox')).toHaveAttribute('required'); |
|||
}); |
|||
|
|||
it('sets name attribute when name prop is provided', () => { |
|||
render(<Checkbox name="test-name" />); |
|||
expect(screen.getByRole('checkbox')).toHaveAttribute('name', 'test-name'); |
|||
}); |
|||
|
|||
it('passes through additional props', () => { |
|||
render(<Checkbox data-testid="extra-prop" />); |
|||
expect(screen.getByRole('checkbox')).toHaveAttribute('data-testid', 'extra-prop'); |
|||
}); |
|||
|
|||
it('toggles checked state correctly', () => { |
|||
render(<Checkbox />); |
|||
const checkbox = screen.getByRole('checkbox'); |
|||
const presentation = screen.getByRole('presentation'); |
|||
|
|||
expect(checkbox).not.toBeChecked(); |
|||
|
|||
fireEvent.click(presentation); |
|||
expect(checkbox).toBeChecked(); |
|||
|
|||
fireEvent.click(presentation); |
|||
expect(checkbox).not.toBeChecked(); |
|||
}); |
|||
}); |
|||
@ -0,0 +1,93 @@ |
|||
import { useState, useId } from "react"; |
|||
import { Check } from "lucide-react"; |
|||
import { Label } from "@components/UI/Label.tsx"; |
|||
import { cn } from "@core/utils/cn.ts"; |
|||
|
|||
interface CheckboxProps { |
|||
checked?: boolean; |
|||
onChange?: (checked: boolean) => void; |
|||
className?: string; |
|||
labelClassName?: string; |
|||
id?: string; |
|||
children?: React.ReactNode; |
|||
disabled?: boolean; |
|||
required?: boolean; |
|||
name?: string; |
|||
} |
|||
|
|||
export function Checkbox({ |
|||
checked, |
|||
onChange, |
|||
className, |
|||
labelClassName, |
|||
id: propId, |
|||
children, |
|||
disabled = false, |
|||
required = false, |
|||
name, |
|||
...rest |
|||
}: CheckboxProps) { |
|||
const generatedId = useId(); |
|||
const id = propId || generatedId; |
|||
|
|||
const [isChecked, setIsChecked] = useState(checked || false); |
|||
|
|||
const handleToggle = () => { |
|||
if (disabled) return; |
|||
|
|||
const newChecked = !isChecked; |
|||
setIsChecked(newChecked); |
|||
onChange?.(newChecked); |
|||
}; |
|||
|
|||
return ( |
|||
<div className={cn("flex items-center", className)}> |
|||
<div className="relative flex items-start"> |
|||
<div className="flex items-center h-5"> |
|||
<input |
|||
type="checkbox" |
|||
id={id} |
|||
checked={isChecked} |
|||
onChange={handleToggle} |
|||
disabled={disabled} |
|||
required={required} |
|||
name={name} |
|||
className="sr-only" |
|||
{...rest} |
|||
/> |
|||
<div |
|||
onClick={handleToggle} |
|||
role="presentation" |
|||
className={cn( |
|||
"w-6 h-6 border-2 border-gray-500 rounded-md flex items-center justify-center", |
|||
disabled ? "opacity-50 cursor-not-allowed" : "cursor-pointer focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2", |
|||
isChecked ? "" : "" |
|||
)} |
|||
> |
|||
{isChecked && ( |
|||
<div className="animate-fade-in scale-100 opacity-100"> |
|||
<Check className="w-4 h-4 text-slate-900 dark:text-slate-900" /> |
|||
</div> |
|||
)} |
|||
</div> |
|||
</div> |
|||
|
|||
{children && ( |
|||
<div className="ml-3 text-sm"> |
|||
<Label |
|||
htmlFor={id} |
|||
id={`${id}-label`} |
|||
className={cn( |
|||
"text-gray-900 dark:text-gray-900", |
|||
disabled ? "opacity-50 cursor-not-allowed" : "cursor-pointer", |
|||
labelClassName |
|||
)} |
|||
> |
|||
{children} |
|||
</Label> |
|||
</div> |
|||
)} |
|||
</div> |
|||
</div> |
|||
); |
|||
} |
|||
@ -0,0 +1,111 @@ |
|||
import { describe, it, expect } from "vitest"; |
|||
import { render, screen, fireEvent } from "@testing-library/react"; |
|||
import { Table } from "@components/generic/Table/index.tsx"; |
|||
import { TimeAgo } from "@components/generic/TimeAgo.tsx"; |
|||
import { Mono } from "@components/generic/Mono.tsx"; |
|||
|
|||
|
|||
describe("Generic Table", () => { |
|||
it("Can render an empty table.", () => { |
|||
render( |
|||
<Table |
|||
headings={[]} |
|||
rows={[]} |
|||
/> |
|||
); |
|||
expect(screen.getByRole("table")).toBeInTheDocument(); |
|||
}); |
|||
|
|||
it("Can render a table with headers and no rows.", async () => { |
|||
render( |
|||
<Table |
|||
headings={[ |
|||
{ title: "", type: "blank", sortable: false }, |
|||
{ title: "Short Name", type: "normal", sortable: true }, |
|||
{ title: "Long Name", type: "normal", sortable: true }, |
|||
{ title: "Model", type: "normal", sortable: true }, |
|||
{ title: "MAC Address", type: "normal", sortable: true }, |
|||
{ title: "Last Heard", type: "normal", sortable: true }, |
|||
{ title: "SNR", type: "normal", sortable: true }, |
|||
{ title: "Encryption", type: "normal", sortable: false }, |
|||
{ title: "Connection", type: "normal", sortable: true }, |
|||
]} |
|||
rows={[]} |
|||
/> |
|||
); |
|||
await screen.findByRole('table'); |
|||
expect(screen.getAllByRole("columnheader")).toHaveLength(9); |
|||
}); |
|||
|
|||
// A simplified version of the rows in pages/Nodes.tsx for testing purposes
|
|||
const mockDevicesWithShortNameAndConnection = [ |
|||
{user: {shortName: "TST1"}, hopsAway: 1, lastHeard: Date.now() + 1000 }, |
|||
{user: {shortName: "TST2"}, hopsAway: 0, lastHeard: Date.now() + 4000 }, |
|||
{user: {shortName: "TST3"}, hopsAway: 4, lastHeard: Date.now() }, |
|||
{user: {shortName: "TST4"}, hopsAway: 3, lastHeard: Date.now() + 2000 } |
|||
]; |
|||
|
|||
const mockRows = mockDevicesWithShortNameAndConnection.map(node => [ |
|||
<h1 data-testshortname> { node.user.shortName } </h1>, |
|||
<><TimeAgo timestamp={node.lastHeard * 1000} /></>, |
|||
<Mono key="hops" data-testhops> |
|||
{node.lastHeard !== 0 |
|||
? node.hopsAway === 0 |
|||
? "Direct" |
|||
: `${node.hopsAway?.toString()} ${ |
|||
node.hopsAway > 1 ? "hops" : "hop" |
|||
} away` |
|||
: "-"} |
|||
</Mono> |
|||
]) |
|||
|
|||
it("Can sort rows appropriately.", async () => { |
|||
render( |
|||
<Table |
|||
headings={[ |
|||
{ title: "Short Name", type: "normal", sortable: true }, |
|||
{ title: "Last Heard", type: "normal", sortable: true }, |
|||
{ title: "Connection", type: "normal", sortable: true }, |
|||
]} |
|||
rows={mockRows} |
|||
/> |
|||
); |
|||
const renderedTable = await screen.findByRole('table'); |
|||
const columnHeaders = screen.getAllByRole("columnheader"); |
|||
expect(columnHeaders).toHaveLength(3); |
|||
|
|||
// Will be sorted "Last heard" "asc" by default
|
|||
expect( [...renderedTable.querySelectorAll('[data-testshortname]')] |
|||
.map(el=>el.textContent) |
|||
.map(v=>v?.trim()) |
|||
.join(',')) |
|||
.toMatch('TST2,TST4,TST1,TST3'); |
|||
|
|||
fireEvent.click(columnHeaders[0]); |
|||
|
|||
// Re-sort by Short Name asc
|
|||
expect( [...renderedTable.querySelectorAll('[data-testshortname]')] |
|||
.map(el=>el.textContent) |
|||
.map(v=>v?.trim()) |
|||
.join(',')) |
|||
.toMatch('TST1,TST2,TST3,TST4'); |
|||
|
|||
fireEvent.click(columnHeaders[0]); |
|||
|
|||
// Re-sort by Short Name desc
|
|||
expect( [...renderedTable.querySelectorAll('[data-testshortname]')] |
|||
.map(el=>el.textContent) |
|||
.map(v=>v?.trim()) |
|||
.join(',')) |
|||
.toMatch('TST4,TST3,TST2,TST1'); |
|||
|
|||
fireEvent.click(columnHeaders[2]); |
|||
|
|||
// Re-sort by Hops Away
|
|||
expect( [...renderedTable.querySelectorAll('[data-testshortname]')] |
|||
.map(el=>el.textContent) |
|||
.map(v=>v?.trim()) |
|||
.join(',')) |
|||
.toMatch('TST2,TST1,TST4,TST3'); |
|||
}); |
|||
}) |
|||
@ -0,0 +1,179 @@ |
|||
// taken from https://react-hooked.vercel.app/docs/useLocalStorage/
|
|||
|
|||
import { useCallback, useEffect, useState } from "react"; |
|||
|
|||
import type { Dispatch, SetStateAction } from "react"; |
|||
|
|||
declare global { |
|||
// eslint-disable-next-line @typescript-eslint/consistent-type-definitions
|
|||
interface WindowEventMap { |
|||
"local-storage": CustomEvent; |
|||
} |
|||
} |
|||
|
|||
type UseLocalStorageOptions<T> = { |
|||
serializer?: (value: T) => string; |
|||
deserializer?: (value: string) => T; |
|||
initializeWithValue?: boolean; |
|||
}; |
|||
|
|||
const IS_SERVER = typeof window === "undefined"; |
|||
|
|||
/** |
|||
* Hook for persisting state to localStorage. |
|||
* |
|||
* @param {string} key - The key to use for localStorage. |
|||
* @param {T | (() => T)} initialValue - The initial value to use, if not found in localStorage. |
|||
* @param {UseLocalStorageOptions<T>} options - Options for the hook. |
|||
* @returns A tuple of [storedValue, setValue, removeValue]. |
|||
*/ |
|||
export default function useLocalStorage<T>( |
|||
key: string, |
|||
initialValue: T | (() => T), |
|||
options: UseLocalStorageOptions<T> = {}, |
|||
): [T, Dispatch<SetStateAction<T>>, () => void] { |
|||
const { initializeWithValue = true } = options; |
|||
|
|||
const serializer = useCallback<(value: T) => string>( |
|||
(value) => { |
|||
if (options.serializer) { |
|||
return options.serializer(value); |
|||
} |
|||
|
|||
return JSON.stringify(value); |
|||
}, |
|||
[options], |
|||
); |
|||
|
|||
const deserializer = useCallback<(value: string) => T>( |
|||
(value) => { |
|||
if (options.deserializer) { |
|||
return options.deserializer(value); |
|||
} |
|||
// Support 'undefined' as a value
|
|||
if (value === "undefined") { |
|||
return undefined as unknown as T; |
|||
} |
|||
|
|||
const defaultValue = |
|||
initialValue instanceof Function ? initialValue() : initialValue; |
|||
|
|||
let parsed: unknown; |
|||
try { |
|||
parsed = JSON.parse(value); |
|||
} catch (error) { |
|||
console.error("Error parsing JSON:", error); |
|||
return defaultValue; // Return initialValue if parsing fails
|
|||
} |
|||
|
|||
return parsed as T; |
|||
}, |
|||
[options, initialValue], |
|||
); |
|||
|
|||
// Get from local storage then
|
|||
// parse stored json or return initialValue
|
|||
const readValue = useCallback((): T => { |
|||
const initialValueToUse = |
|||
initialValue instanceof Function ? initialValue() : initialValue; |
|||
|
|||
// Prevent build error "window is undefined" but keep working
|
|||
if (IS_SERVER) { |
|||
return initialValueToUse; |
|||
} |
|||
|
|||
try { |
|||
const raw = window.localStorage.getItem(key); |
|||
return raw ? deserializer(raw) : initialValueToUse; |
|||
} catch (error) { |
|||
console.warn(`Error reading localStorage key “${key}”:`, error); |
|||
return initialValueToUse; |
|||
} |
|||
}, [initialValue, key, deserializer]); |
|||
|
|||
const [storedValue, setStoredValue] = useState(() => { |
|||
if (initializeWithValue) { |
|||
return readValue(); |
|||
} |
|||
|
|||
return initialValue instanceof Function ? initialValue() : initialValue; |
|||
}); |
|||
|
|||
// Return a wrapped version of useState's setter function that ...
|
|||
// ... persists the new value to localStorage.
|
|||
const setValue: Dispatch<SetStateAction<T>> = useCallback( |
|||
(value) => { |
|||
// Prevent build error "window is undefined" but keeps working
|
|||
if (IS_SERVER) { |
|||
console.warn( |
|||
`Tried setting localStorage key “${key}” even though environment is not a client`, |
|||
); |
|||
} |
|||
|
|||
try { |
|||
// Allow value to be a function so we have the same API as useState
|
|||
const newValue = value instanceof Function ? value(readValue()) : value; |
|||
|
|||
// Save to local storage
|
|||
window.localStorage.setItem(key, serializer(newValue)); |
|||
|
|||
// Save state
|
|||
setStoredValue(newValue); |
|||
|
|||
// We dispatch a custom event so every similar useLocalStorage hook is notified
|
|||
window.dispatchEvent(new StorageEvent("local-storage", { key })); |
|||
} catch (error) { |
|||
console.warn(`Error setting localStorage key “${key}”:`, error); |
|||
} |
|||
}, |
|||
[key, serializer, readValue], |
|||
); |
|||
|
|||
const removeValue = useCallback(() => { |
|||
// Prevent build error "window is undefined" but keeps working
|
|||
if (IS_SERVER) { |
|||
console.warn( |
|||
`Tried removing localStorage key “${key}” even though environment is not a client`, |
|||
); |
|||
} |
|||
|
|||
const defaultValue = |
|||
initialValue instanceof Function ? initialValue() : initialValue; |
|||
|
|||
// Remove the key from local storage
|
|||
window.localStorage.removeItem(key); |
|||
|
|||
// Save state with default value
|
|||
setStoredValue(defaultValue); |
|||
|
|||
// We dispatch a custom event so every similar useLocalStorage hook is notified
|
|||
window.dispatchEvent(new StorageEvent("local-storage", { key })); |
|||
}, [key]); |
|||
|
|||
useEffect(() => { |
|||
setStoredValue(readValue()); |
|||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|||
}, [key]); |
|||
|
|||
const handleStorageChange = useCallback( |
|||
(event: StorageEvent | CustomEvent) => { |
|||
if ((event as StorageEvent).key && (event as StorageEvent).key !== key) { |
|||
return; |
|||
} |
|||
setStoredValue(readValue()); |
|||
}, |
|||
[key, readValue], |
|||
); |
|||
|
|||
useEffect(() => { |
|||
addEventListener("storage", handleStorageChange); |
|||
// this is a custom event, triggered in writeValueToLocalStorage
|
|||
addEventListener("local-storage", handleStorageChange); |
|||
return () => { |
|||
removeEventListener("storage", handleStorageChange); |
|||
removeEventListener("local-storage", handleStorageChange); |
|||
}; |
|||
}, []); |
|||
|
|||
return [storedValue, setValue, removeValue]; |
|||
} |
|||
@ -0,0 +1,71 @@ |
|||
import { describe, it, expect, vi, beforeEach } from "vitest"; |
|||
import { eventBus } from "@core/utils/eventBus.ts"; |
|||
|
|||
describe("EventBus", () => { |
|||
beforeEach(() => { |
|||
// Reset event listeners before each test
|
|||
(eventBus as any).listeners = {}; |
|||
}); |
|||
|
|||
it("should register an event listener and trigger it on emit", () => { |
|||
const mockCallback = vi.fn(); |
|||
|
|||
eventBus.on("dialog:unsafeRoles", mockCallback); |
|||
eventBus.emit("dialog:unsafeRoles", { action: "confirm" }); |
|||
|
|||
expect(mockCallback).toHaveBeenCalledWith({ action: "confirm" }); |
|||
}); |
|||
|
|||
it("should remove an event listener with off", () => { |
|||
const mockCallback = vi.fn(); |
|||
|
|||
eventBus.on("dialog:unsafeRoles", mockCallback); |
|||
eventBus.off("dialog:unsafeRoles", mockCallback); |
|||
eventBus.emit("dialog:unsafeRoles", { action: "dismiss" }); |
|||
|
|||
expect(mockCallback).not.toHaveBeenCalled(); |
|||
}); |
|||
|
|||
it("should return an unsubscribe function from on", () => { |
|||
const mockCallback = vi.fn(); |
|||
const unsubscribe = eventBus.on("dialog:unsafeRoles", mockCallback); |
|||
|
|||
unsubscribe(); |
|||
eventBus.emit("dialog:unsafeRoles", { action: "confirm" }); |
|||
|
|||
expect(mockCallback).not.toHaveBeenCalled(); |
|||
}); |
|||
|
|||
it("should allow multiple listeners for the same event", () => { |
|||
const mockCallback1 = vi.fn(); |
|||
const mockCallback2 = vi.fn(); |
|||
|
|||
eventBus.on("dialog:unsafeRoles", mockCallback1); |
|||
eventBus.on("dialog:unsafeRoles", mockCallback2); |
|||
eventBus.emit("dialog:unsafeRoles", { action: "confirm" }); |
|||
|
|||
expect(mockCallback1).toHaveBeenCalledWith({ action: "confirm" }); |
|||
expect(mockCallback2).toHaveBeenCalledWith({ action: "confirm" }); |
|||
}); |
|||
|
|||
it("should only remove the specific listener when off is called", () => { |
|||
const mockCallback1 = vi.fn(); |
|||
const mockCallback2 = vi.fn(); |
|||
|
|||
eventBus.on("dialog:unsafeRoles", mockCallback1); |
|||
eventBus.on("dialog:unsafeRoles", mockCallback2); |
|||
eventBus.off("dialog:unsafeRoles", mockCallback1); |
|||
eventBus.emit("dialog:unsafeRoles", { action: "dismiss" }); |
|||
|
|||
expect(mockCallback1).not.toHaveBeenCalled(); |
|||
expect(mockCallback2).toHaveBeenCalledWith({ action: "dismiss" }); |
|||
}); |
|||
|
|||
it("should not fail when calling off on a non-existent listener", () => { |
|||
const mockCallback = vi.fn(); |
|||
eventBus.off("dialog:unsafeRoles", mockCallback); |
|||
eventBus.emit("dialog:unsafeRoles", { action: "confirm" }); |
|||
|
|||
expect(mockCallback).not.toHaveBeenCalled(); // No error should occur
|
|||
}); |
|||
}); |
|||
@ -0,0 +1,44 @@ |
|||
export type EventMap = { |
|||
'dialog:unsafeRoles': { |
|||
action: 'confirm' | 'dismiss'; |
|||
}; |
|||
// add more events as required
|
|||
}; |
|||
|
|||
export type EventName = keyof EventMap; |
|||
export type EventCallback<T extends EventName> = (data: EventMap[T]) => void; |
|||
|
|||
class EventBus { |
|||
private listeners: { [K in EventName]?: Array<EventCallback<K>> } = {}; |
|||
|
|||
public on<T extends EventName>(event: T, callback: EventCallback<T>): () => void { |
|||
if (!this.listeners[event]) { |
|||
this.listeners[event] = []; |
|||
} |
|||
|
|||
this.listeners[event]?.push(callback as any); |
|||
|
|||
return () => { |
|||
this.off(event, callback); |
|||
}; |
|||
} |
|||
|
|||
public off<T extends EventName>(event: T, callback: EventCallback<T>): void { |
|||
if (!this.listeners[event]) return; |
|||
|
|||
const callbackIndex = this.listeners[event]?.indexOf(callback as any); |
|||
if (callbackIndex !== undefined && callbackIndex > -1) { |
|||
this.listeners[event]?.splice(callbackIndex, 1); |
|||
} |
|||
} |
|||
|
|||
public emit<T extends EventName>(event: T, data: EventMap[T]): void { |
|||
if (!this.listeners[event]) return; |
|||
|
|||
this.listeners[event]?.forEach(callback => { |
|||
callback(data); |
|||
}); |
|||
} |
|||
} |
|||
|
|||
export const eventBus = new EventBus(); |
|||
@ -1,7 +1,18 @@ |
|||
import { expect, afterEach } from 'vitest'; |
|||
import { cleanup } from '@testing-library/react'; |
|||
import * as matchers from '@testing-library/jest-dom/matchers'; |
|||
import "@testing-library/jest-dom"; |
|||
|
|||
// Enable auto mocks for our UI components
|
|||
//vi.mock('@components/UI/Dialog.tsx');
|
|||
//vi.mock('@components/UI/Typography/Link.tsx');
|
|||
|
|||
globalThis.ResizeObserver = class { |
|||
observe() { } |
|||
unobserve() { } |
|||
disconnect() { } |
|||
}; |
|||
}; |
|||
|
|||
afterEach(() => { |
|||
cleanup(); |
|||
}); |
|||
@ -1 +1,18 @@ |
|||
{ "github": { "silent": true } } |
|||
{ |
|||
"github": { "silent": true }, |
|||
"headers": [ |
|||
{ |
|||
"source": "/", |
|||
"headers": [ |
|||
{ |
|||
"key": "Cross-Origin-Embedder-Policy", |
|||
"value": "require-corp" |
|||
}, |
|||
{ |
|||
"key": "Cross-Origin-Opener-Policy", |
|||
"value": "same-origin" |
|||
} |
|||
] |
|||
} |
|||
] |
|||
} |
|||
|
|||
@ -0,0 +1,28 @@ |
|||
import path from "node:path"; |
|||
import react from '@vitejs/plugin-react'; |
|||
import { defineConfig } from 'vitest/config' |
|||
|
|||
export default defineConfig({ |
|||
plugins: [ |
|||
react(), |
|||
], |
|||
resolve: { |
|||
alias: { |
|||
'@app': path.resolve(process.cwd(), './src'), |
|||
'@pages': path.resolve(process.cwd(), './src/pages'), |
|||
'@components': path.resolve(process.cwd(), './src/components'), |
|||
'@core': path.resolve(process.cwd(), './src/core'), |
|||
'@layouts': path.resolve(process.cwd(), './src/layouts'), |
|||
}, |
|||
}, |
|||
test: { |
|||
environment: 'happy-dom', |
|||
globals: true, |
|||
mockReset: true, |
|||
clearMocks: true, |
|||
restoreMocks: true, |
|||
root: path.resolve(process.cwd(), './src'), |
|||
include: ['**/*.{test,spec}.{ts,tsx}'], |
|||
setupFiles: ["./src/tests/setupTests.ts"], |
|||
}, |
|||
}) |
|||
Loading…
Reference in new issue