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