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"; |
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 { |
globalThis.ResizeObserver = class { |
||||
observe() { } |
observe() { } |
||||
unobserve() { } |
unobserve() { } |
||||
disconnect() { } |
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