committed by
GitHub
10 changed files with 386 additions and 40 deletions
@ -0,0 +1,114 @@ |
|||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; |
||||
|
import { render, screen, fireEvent, waitFor } from '@testing-library/react'; |
||||
|
import { RebootOTADialog } from './RebootOTADialog.tsx'; |
||||
|
import { ReactNode } from "react"; |
||||
|
|
||||
|
const rebootOtaMock = vi.fn(); |
||||
|
let mockConnection: { rebootOta: (delay: number) => void } | undefined = { |
||||
|
rebootOta: rebootOtaMock, |
||||
|
}; |
||||
|
|
||||
|
vi.mock('@core/stores/deviceStore.ts', () => ({ |
||||
|
useDevice: () => ({ |
||||
|
connection: mockConnection, |
||||
|
}), |
||||
|
})); |
||||
|
|
||||
|
vi.mock('@components/UI/Button.tsx', async () => { |
||||
|
const actual = await vi.importActual('@components/UI/Button.tsx'); |
||||
|
return { |
||||
|
...actual, |
||||
|
Button: (props: any) => <button {...props} />, |
||||
|
}; |
||||
|
}); |
||||
|
|
||||
|
vi.mock('@components/UI/Input.tsx', async () => { |
||||
|
const actual = await vi.importActual('@components/UI/Input.tsx'); |
||||
|
return { |
||||
|
...actual, |
||||
|
Input: (props: any) => <input {...props} />, |
||||
|
}; |
||||
|
}); |
||||
|
|
||||
|
vi.mock('@components/UI/Dialog.tsx', () => { |
||||
|
return { |
||||
|
Dialog: ({ children }: { children: ReactNode }) => <div>{children}</div>, |
||||
|
DialogContent: ({ children }: { children: ReactNode }) => <div>{children}</div>, |
||||
|
DialogHeader: ({ children }: { children: ReactNode }) => <div>{children}</div>, |
||||
|
DialogTitle: ({ children }: { children: ReactNode }) => <h1>{children}</h1>, |
||||
|
DialogDescription: ({ children }: { children: ReactNode }) => <p>{children}</p>, |
||||
|
DialogClose: () => null, |
||||
|
}; |
||||
|
}); |
||||
|
|
||||
|
|
||||
|
describe('RebootOTADialog', () => { |
||||
|
beforeEach(() => { |
||||
|
vi.useFakeTimers(); |
||||
|
rebootOtaMock.mockClear(); |
||||
|
}); |
||||
|
|
||||
|
afterEach(() => { |
||||
|
vi.useRealTimers(); |
||||
|
}); |
||||
|
|
||||
|
it('renders dialog with default input value', () => { |
||||
|
render(<RebootOTADialog open={true} onOpenChange={() => { }} />); |
||||
|
expect(screen.getByPlaceholderText(/enter delay/i)).toHaveValue(5); |
||||
|
expect(screen.getByText(/schedule reboot/i)).toBeInTheDocument(); |
||||
|
expect(screen.getByText(/reboot to ota mode now/i)).toBeInTheDocument(); |
||||
|
}); |
||||
|
|
||||
|
it('schedules a reboot with delay and calls rebootOta', async () => { |
||||
|
const onOpenChangeMock = vi.fn(); |
||||
|
render(<RebootOTADialog open={true} onOpenChange={onOpenChangeMock} />); |
||||
|
|
||||
|
fireEvent.change(screen.getByPlaceholderText(/enter delay/i), { |
||||
|
target: { value: '3' }, |
||||
|
}); |
||||
|
|
||||
|
fireEvent.click(screen.getByText(/schedule reboot/i)); |
||||
|
|
||||
|
expect(screen.getByText(/reboot has been scheduled/i)).toBeInTheDocument(); |
||||
|
|
||||
|
vi.advanceTimersByTime(3000); |
||||
|
|
||||
|
await waitFor(() => { |
||||
|
expect(rebootOtaMock).toHaveBeenCalledWith(0); |
||||
|
expect(onOpenChangeMock).toHaveBeenCalledWith(false); |
||||
|
}); |
||||
|
}); |
||||
|
|
||||
|
it('triggers an instant reboot', async () => { |
||||
|
const onOpenChangeMock = vi.fn(); |
||||
|
render(<RebootOTADialog open={true} onOpenChange={onOpenChangeMock} />); |
||||
|
|
||||
|
fireEvent.click(screen.getByText(/reboot to ota mode now/i)); |
||||
|
|
||||
|
await waitFor(() => { |
||||
|
expect(rebootOtaMock).toHaveBeenCalledWith(5); |
||||
|
expect(onOpenChangeMock).toHaveBeenCalledWith(false); |
||||
|
}); |
||||
|
}); |
||||
|
|
||||
|
it('does not call reboot if connection is undefined', async () => { |
||||
|
const onOpenChangeMock = vi.fn(); |
||||
|
|
||||
|
// simulate no connection
|
||||
|
mockConnection = undefined; |
||||
|
|
||||
|
render(<RebootOTADialog open={true} onOpenChange={onOpenChangeMock} />); |
||||
|
|
||||
|
fireEvent.click(screen.getByText(/schedule reboot/i)); |
||||
|
vi.advanceTimersByTime(5000); |
||||
|
|
||||
|
await waitFor(() => { |
||||
|
expect(rebootOtaMock).not.toHaveBeenCalled(); |
||||
|
expect(onOpenChangeMock).not.toHaveBeenCalled(); |
||||
|
}); |
||||
|
|
||||
|
// reset connection for other tests
|
||||
|
mockConnection = { rebootOta: rebootOtaMock }; |
||||
|
}); |
||||
|
|
||||
|
}); |
||||
@ -0,0 +1,104 @@ |
|||||
|
import { useState } from "react"; |
||||
|
import { ClockIcon, RefreshCwIcon } from "lucide-react"; |
||||
|
import { Button } from "@components/UI/Button.tsx"; |
||||
|
import { |
||||
|
Dialog, |
||||
|
DialogClose, |
||||
|
DialogContent, |
||||
|
DialogDescription, |
||||
|
DialogHeader, |
||||
|
DialogTitle, |
||||
|
} from "@components/UI/Dialog.tsx"; |
||||
|
import { Input } from "@components/UI/Input.tsx"; |
||||
|
import { useDevice } from "@core/stores/deviceStore.ts"; |
||||
|
|
||||
|
export interface RebootOTADialogProps { |
||||
|
open: boolean; |
||||
|
onOpenChange: (open: boolean) => void; |
||||
|
} |
||||
|
|
||||
|
const DEFAULT_REBOOT_DELAY = 5; // seconds
|
||||
|
|
||||
|
export const RebootOTADialog = ({ open, onOpenChange }: RebootOTADialogProps) => { |
||||
|
const { connection } = useDevice(); |
||||
|
const [time, setTime] = useState<number>(DEFAULT_REBOOT_DELAY); |
||||
|
const [isScheduled, setIsScheduled] = useState(false); |
||||
|
const [inputValue, setInputValue] = useState(DEFAULT_REBOOT_DELAY.toString()); |
||||
|
|
||||
|
const handleSetTime = (e: React.ChangeEvent<HTMLInputElement>) => { |
||||
|
if (!e.target.validity.valid) { |
||||
|
e.preventDefault(); |
||||
|
return |
||||
|
}; |
||||
|
|
||||
|
const val = e.target.value; |
||||
|
setInputValue(val); |
||||
|
|
||||
|
const parsed = Number(val); |
||||
|
if (!isNaN(parsed) && parsed > 0) { |
||||
|
setTime(parsed); |
||||
|
} |
||||
|
}; |
||||
|
|
||||
|
const handleRebootWithTimeout = async () => { |
||||
|
if (!connection) return; |
||||
|
setIsScheduled(true); |
||||
|
|
||||
|
const delay = time > 0 ? time : DEFAULT_REBOOT_DELAY; |
||||
|
|
||||
|
await new Promise<void>((resolve) => { |
||||
|
setTimeout(() => { |
||||
|
console.log("Rebooting..."); |
||||
|
resolve(); |
||||
|
}, delay * 1000); |
||||
|
}).finally(() => { |
||||
|
setIsScheduled(false); |
||||
|
onOpenChange(false); |
||||
|
setInputValue(DEFAULT_REBOOT_DELAY.toString()); |
||||
|
}); |
||||
|
connection.rebootOta(0); |
||||
|
}; |
||||
|
|
||||
|
const handleInstantReboot = async () => { |
||||
|
if (!connection) return; |
||||
|
|
||||
|
await connection.rebootOta(DEFAULT_REBOOT_DELAY); |
||||
|
onOpenChange(false); |
||||
|
}; |
||||
|
|
||||
|
return ( |
||||
|
<Dialog open={open} onOpenChange={onOpenChange}> |
||||
|
<DialogContent> |
||||
|
<DialogClose /> |
||||
|
<DialogHeader> |
||||
|
<DialogTitle>Reboot to OTA Mode</DialogTitle> |
||||
|
<DialogDescription> |
||||
|
Reboot the connected node after a delay into OTA (Over-the-Air) mode. |
||||
|
</DialogDescription> |
||||
|
</DialogHeader> |
||||
|
|
||||
|
<div className="flex gap-2 p-2 items-center relative"> |
||||
|
<Input |
||||
|
type="number" |
||||
|
min={1} |
||||
|
max={86400} |
||||
|
className="dark:text-slate-900 appearance-none" |
||||
|
value={inputValue} |
||||
|
onChange={handleSetTime} |
||||
|
placeholder="Enter delay (sec)" |
||||
|
/> |
||||
|
<Button onClick={() => handleRebootWithTimeout()} className="w-9/12"> |
||||
|
<ClockIcon className="mr-2" size={18} /> |
||||
|
{isScheduled ? 'Reboot has been scheduled' : 'Schedule Reboot'} |
||||
|
</Button> |
||||
|
</div> |
||||
|
|
||||
|
<Button variant="destructive" onClick={() => handleInstantReboot()}> |
||||
|
<RefreshCwIcon className="mr-2" size={16} /> |
||||
|
Reboot to OTA Mode Now |
||||
|
</Button> |
||||
|
</DialogContent> |
||||
|
</Dialog> |
||||
|
); |
||||
|
}; |
||||
|
|
||||
@ -0,0 +1,65 @@ |
|||||
|
import { renderHook, act } from "@testing-library/react"; |
||||
|
import { describe, it, expect, vi, beforeEach } from "vitest"; |
||||
|
import { usePinnedItems } from "./usePinnedItems.ts"; |
||||
|
|
||||
|
const mockSetPinnedItems = vi.fn(); |
||||
|
const mockUseLocalStorage = vi.fn(); |
||||
|
|
||||
|
vi.mock("@core/hooks/useLocalStorage.ts", () => ({ |
||||
|
default: (...args: any[]) => mockUseLocalStorage(...args), |
||||
|
})); |
||||
|
|
||||
|
describe("usePinnedItems", () => { |
||||
|
beforeEach(() => { |
||||
|
vi.clearAllMocks(); |
||||
|
}); |
||||
|
|
||||
|
it("returns default pinnedItems and togglePinnedItem", () => { |
||||
|
mockUseLocalStorage.mockReturnValue([[], mockSetPinnedItems]); |
||||
|
|
||||
|
const { result } = renderHook(() => |
||||
|
usePinnedItems({ storageName: "test-storage" }) |
||||
|
); |
||||
|
|
||||
|
expect(result.current.pinnedItems).toEqual([]); |
||||
|
expect(typeof result.current.togglePinnedItem).toBe("function"); |
||||
|
}); |
||||
|
|
||||
|
it("adds an item if it's not already pinned", () => { |
||||
|
mockUseLocalStorage.mockReturnValue([["item1"], mockSetPinnedItems]); |
||||
|
|
||||
|
const { result } = renderHook(() => |
||||
|
usePinnedItems({ storageName: "test-storage" }) |
||||
|
); |
||||
|
|
||||
|
act(() => { |
||||
|
result.current.togglePinnedItem("item2"); |
||||
|
}); |
||||
|
|
||||
|
expect(mockSetPinnedItems).toHaveBeenCalledWith(expect.any(Function)); |
||||
|
|
||||
|
const updater = mockSetPinnedItems.mock.calls[0][0]; |
||||
|
const updated = updater(["item1"]); |
||||
|
|
||||
|
expect(updated).toEqual(["item1", "item2"]); |
||||
|
}); |
||||
|
|
||||
|
it("removes an item if it's already pinned", () => { |
||||
|
mockUseLocalStorage.mockReturnValue([["item1", "item2"], mockSetPinnedItems]); |
||||
|
|
||||
|
const { result } = renderHook(() => |
||||
|
usePinnedItems({ storageName: "test-storage" }) |
||||
|
); |
||||
|
|
||||
|
act(() => { |
||||
|
result.current.togglePinnedItem("item1"); |
||||
|
}); |
||||
|
|
||||
|
expect(mockSetPinnedItems).toHaveBeenCalledWith(expect.any(Function)); |
||||
|
|
||||
|
const updater = mockSetPinnedItems.mock.calls[0][0]; |
||||
|
const updated = updater(["item1", "item2"]); |
||||
|
|
||||
|
expect(updated).toEqual(["item2"]); |
||||
|
}); |
||||
|
}); |
||||
@ -0,0 +1,19 @@ |
|||||
|
import useLocalStorage from "@core/hooks/useLocalStorage.ts"; |
||||
|
import { useCallback } from "react"; |
||||
|
|
||||
|
export function usePinnedItems({ storageName }: { storageName: string }) { |
||||
|
const [pinnedItems, setPinnedItems] = useLocalStorage<string[]>(storageName, []); |
||||
|
|
||||
|
const togglePinnedItem = useCallback((label: string) => { |
||||
|
setPinnedItems((prev) => |
||||
|
prev.includes(label) |
||||
|
? prev.filter((g) => g !== label) |
||||
|
: [...prev, label] |
||||
|
); |
||||
|
}, []); |
||||
|
|
||||
|
return { |
||||
|
pinnedItems, |
||||
|
togglePinnedItem, |
||||
|
}; |
||||
|
} |
||||
Loading…
Reference in new issue