15 changed files with 430 additions and 529 deletions
@ -1,141 +1,127 @@ |
|||||
import { describe, expect, it } from "vitest"; |
import { describe, expect, it } from "vitest"; |
||||
import { fireEvent, render, screen } from "@testing-library/react"; |
import { fireEvent, render, screen } from "@testing-library/react"; |
||||
import { Table } from "@components/generic/Table/index.tsx"; |
import { DataRow, Heading, Table } from "@components/generic/Table/index.tsx"; |
||||
import { TimeAgo } from "@components/generic/TimeAgo.tsx"; |
import { TimeAgo } from "@components/generic/TimeAgo.tsx"; |
||||
import { Mono } from "@components/generic/Mono.tsx"; |
import { Mono } from "@components/generic/Mono.tsx"; |
||||
|
|
||||
describe("Generic Table", () => { |
describe("Generic Table", () => { |
||||
it("Can render an empty table.", () => { |
it("Can render an empty table.", () => { |
||||
render( |
render(<Table headings={[]} rows={[]} />); |
||||
<Table |
|
||||
headings={[]} |
|
||||
rows={[]} |
|
||||
/>, |
|
||||
); |
|
||||
expect(screen.getByRole("table")).toBeInTheDocument(); |
expect(screen.getByRole("table")).toBeInTheDocument(); |
||||
}); |
}); |
||||
|
|
||||
it("Can render a table with headers and no rows.", async () => { |
it("Can render a table with headers and no rows.", async () => { |
||||
render( |
const headings: Heading[] = [ |
||||
<Table |
{ title: "Short Name", sortable: true }, |
||||
headings={[ |
{ title: "Last Heard", sortable: true }, |
||||
{ title: "", type: "blank", sortable: false }, |
{ title: "Connection", sortable: true }, |
||||
{ title: "Short Name", type: "normal", sortable: true }, |
]; |
||||
{ title: "Long Name", type: "normal", sortable: true }, |
render(<Table headings={headings} rows={[]} />); |
||||
{ 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"); |
await screen.findByRole("table"); |
||||
expect(screen.getAllByRole("columnheader")).toHaveLength(9); |
expect(screen.getAllByRole("columnheader")).toHaveLength(3); |
||||
}); |
}); |
||||
|
|
||||
// A simplified version of the rows in pages/Nodes.tsx for testing purposes
|
// Mock data representing devices
|
||||
const mockDevicesWithShortNameAndConnection = [ |
const mockDevices = [ |
||||
{ |
{ |
||||
user: { shortName: "TST1" }, |
id: "TST1", |
||||
|
shortName: "TST1", |
||||
hopsAway: 1, |
hopsAway: 1, |
||||
lastHeard: Date.now() + 1000, |
lastHeard: Date.now() - 3000, |
||||
viaMqtt: false, |
viaMqtt: false, |
||||
}, |
}, |
||||
{ |
{ |
||||
user: { shortName: "TST2" }, |
id: "TST2", |
||||
|
shortName: "TST2", |
||||
hopsAway: 0, |
hopsAway: 0, |
||||
lastHeard: Date.now() + 4000, |
lastHeard: Date.now() - 1000, |
||||
viaMqtt: true, |
viaMqtt: true, |
||||
|
isFavorite: true, // Favorite device
|
||||
}, |
}, |
||||
{ |
{ |
||||
user: { shortName: "TST3" }, |
id: "TST3", |
||||
|
shortName: "TST3", |
||||
hopsAway: 4, |
hopsAway: 4, |
||||
lastHeard: Date.now(), |
lastHeard: Date.now() - 5000, |
||||
viaMqtt: false, |
viaMqtt: false, |
||||
}, |
}, |
||||
{ |
{ |
||||
user: { shortName: "TST4" }, |
id: "TST4", |
||||
|
shortName: "TST4", |
||||
hopsAway: 3, |
hopsAway: 3, |
||||
lastHeard: Date.now() + 2000, |
lastHeard: Date.now() - 2000, |
||||
viaMqtt: true, |
viaMqtt: true, |
||||
}, |
}, |
||||
]; |
]; |
||||
|
|
||||
const mockRows = mockDevicesWithShortNameAndConnection.map((node) => [ |
// Transform mock data into the format expected by the Table component
|
||||
<h1 data-testshortname key={node.user.shortName}>{node.user.shortName}</h1>, |
const mockRows: DataRow[] = mockDevices.map((node) => ({ |
||||
<Mono key="lastHeard" data-testheard> |
id: node.id, |
||||
<TimeAgo timestamp={node.lastHeard * 1000} /> |
isFavorite: node.isFavorite, |
||||
</Mono>, |
cells: [ |
||||
<Mono key="hops" data-testhops> |
{ |
||||
{node.lastHeard !== 0 |
content: <b data-testid="short-name">{node.shortName}</b>, |
||||
? node.viaMqtt === false && node.hopsAway === 0 |
sortValue: node.shortName, |
||||
? "Direct" |
}, |
||||
: `${node.hopsAway?.toString()} ${ |
{ |
||||
node.hopsAway ?? 0 > 1 ? "hops" : "hop" |
content: ( |
||||
} away` |
<Mono> |
||||
: "-"} |
<TimeAgo timestamp={node.lastHeard} /> |
||||
{node.viaMqtt === true ? ", via MQTT" : ""} |
</Mono> |
||||
</Mono>, |
), |
||||
]); |
sortValue: node.lastHeard, |
||||
|
}, |
||||
|
{ |
||||
|
content: ( |
||||
|
<Mono> |
||||
|
{node.lastHeard !== 0 |
||||
|
? node.viaMqtt === false && node.hopsAway === 0 |
||||
|
? "Direct" |
||||
|
: `${node.hopsAway} ${node.hopsAway > 1 ? "hops" : "hop"} away` |
||||
|
: "-"} |
||||
|
{node.viaMqtt ? ", via MQTT" : ""} |
||||
|
</Mono> |
||||
|
), |
||||
|
sortValue: node.hopsAway, |
||||
|
}, |
||||
|
], |
||||
|
})); |
||||
|
|
||||
it("Can sort rows appropriately.", async () => { |
const headings: Heading[] = [ |
||||
render( |
{ title: "Short Name", sortable: true }, |
||||
<Table |
{ title: "Last Heard", sortable: true }, |
||||
headings={[ |
{ title: "Connection", sortable: true }, |
||||
{ title: "Short Name", type: "normal", sortable: true }, |
]; |
||||
{ title: "Last Heard", type: "normal", sortable: true }, |
|
||||
{ title: "Connection", type: "normal", sortable: true }, |
it("Can sort rows, keeping favorites at the top", async () => { |
||||
]} |
render(<Table headings={headings} rows={mockRows} />); |
||||
rows={mockRows} |
|
||||
/>, |
|
||||
); |
|
||||
const renderedTable = await screen.findByRole("table"); |
const renderedTable = await screen.findByRole("table"); |
||||
const columnHeaders = screen.getAllByRole("columnheader"); |
const columnHeaders = screen.getAllByRole("columnheader"); |
||||
expect(columnHeaders).toHaveLength(3); |
expect(columnHeaders).toHaveLength(3); |
||||
|
|
||||
// Will be sorted "Last heard" "asc" by default
|
const getRenderedOrder = () => |
||||
expect( |
[...renderedTable.querySelectorAll("[data-testid='short-name']")].map( |
||||
[...renderedTable.querySelectorAll("[data-testshortname]")] |
(el) => el.textContent?.trim(), |
||||
.map((el) => el.textContent) |
); |
||||
.map((v) => v?.trim()) |
|
||||
.join(","), |
|
||||
) |
|
||||
.toMatch("TST2,TST4,TST1,TST3"); |
|
||||
|
|
||||
fireEvent.click(columnHeaders[0]); |
|
||||
|
|
||||
// Re-sort by Short Name asc
|
// Default sort: "Last Heard" desc. TST2 is favorite, so it's first.
|
||||
expect( |
// Then the rest are sorted by lastHeard timestamp (most recent first).
|
||||
[...renderedTable.querySelectorAll("[data-testshortname]")] |
// Order of timestamps: TST2 (latest, but favorite), TST4, TST1, TST3 (oldest).
|
||||
.map((el) => el.textContent) |
expect(getRenderedOrder()).toEqual(["TST2", "TST4", "TST1", "TST3"]); |
||||
.map((v) => v?.trim()) |
|
||||
.join(","), |
|
||||
) |
|
||||
.toMatch("TST1,TST2,TST3,TST4"); |
|
||||
|
|
||||
|
// Click "Short Name" to sort asc
|
||||
fireEvent.click(columnHeaders[0]); |
fireEvent.click(columnHeaders[0]); |
||||
|
// TST2 is favorite, so it's first. Then TST1, TST3, TST4 alphabetically.
|
||||
|
expect(getRenderedOrder()).toEqual(["TST2", "TST1", "TST3", "TST4"]); |
||||
|
|
||||
// Re-sort by Short Name desc
|
// Click "Short Name" again to sort desc
|
||||
expect( |
fireEvent.click(columnHeaders[0]); |
||||
[...renderedTable.querySelectorAll("[data-testshortname]")] |
// TST2 is favorite, so it's first. Then TST4, TST3, TST1 reverse alphabetically.
|
||||
.map((el) => el.textContent) |
expect(getRenderedOrder()).toEqual(["TST2", "TST4", "TST3", "TST1"]); |
||||
.map((v) => v?.trim()) |
|
||||
.join(","), |
|
||||
) |
|
||||
.toMatch("TST4,TST3,TST2,TST1"); |
|
||||
|
|
||||
|
// Click "Connection" to sort by hops asc
|
||||
fireEvent.click(columnHeaders[2]); |
fireEvent.click(columnHeaders[2]); |
||||
|
// TST2 is favorite (and also has 0 hops). Then sorted by hops: TST1 (1), TST4 (3), TST3 (4).
|
||||
// Re-sort by Hops Away
|
expect(getRenderedOrder()).toEqual(["TST2", "TST1", "TST4", "TST3"]); |
||||
expect( |
|
||||
[...renderedTable.querySelectorAll("[data-testshortname]")] |
|
||||
.map((el) => el.textContent) |
|
||||
.map((v) => v?.trim()) |
|
||||
.join(","), |
|
||||
) |
|
||||
.toMatch("TST2,TST1,TST4,TST3"); |
|
||||
}); |
}); |
||||
}); |
}); |
||||
|
|||||
@ -1,68 +0,0 @@ |
|||||
import { act, renderHook } from "@testing-library/react"; |
|
||||
import { beforeEach, describe, expect, it, vi } 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,82 @@ |
|||||
|
import { vi } from "vitest"; |
||||
|
import { type Device } from "./deviceStore.ts"; |
||||
|
import { Protobuf } from "@meshtastic/core"; |
||||
|
|
||||
|
/** |
||||
|
* You can spread this base mock in your tests and override only the |
||||
|
* properties relevant to a specific test case. |
||||
|
* |
||||
|
* @example |
||||
|
* vi.mocked(useDevice).mockReturnValue({ |
||||
|
* ...mockDeviceStore, |
||||
|
* getNode: (nodeNum) => mockNodes.get(nodeNum), |
||||
|
* }); |
||||
|
*/ |
||||
|
export const mockDeviceStore: Device = { |
||||
|
id: 0, |
||||
|
status: 5 as const, |
||||
|
channels: new Map(), |
||||
|
config: {} as Protobuf.LocalOnly.LocalConfig, |
||||
|
moduleConfig: {} as Protobuf.LocalOnly.LocalModuleConfig, |
||||
|
workingConfig: [], |
||||
|
workingModuleConfig: [], |
||||
|
hardware: {} as Protobuf.Mesh.MyNodeInfo, |
||||
|
metadata: new Map(), |
||||
|
traceroutes: new Map(), |
||||
|
nodeErrors: new Map(), |
||||
|
connection: undefined, |
||||
|
activeNode: 0, |
||||
|
waypoints: [], |
||||
|
pendingSettingsChanges: false, |
||||
|
messageDraft: "", |
||||
|
unreadCounts: new Map(), |
||||
|
nodesMap: new Map(), |
||||
|
dialog: { |
||||
|
import: false, |
||||
|
QR: false, |
||||
|
shutdown: false, |
||||
|
reboot: false, |
||||
|
rebootOTA: false, |
||||
|
deviceName: false, |
||||
|
nodeRemoval: false, |
||||
|
pkiBackup: false, |
||||
|
nodeDetails: false, |
||||
|
unsafeRoles: false, |
||||
|
refreshKeys: false, |
||||
|
deleteMessages: false, |
||||
|
}, |
||||
|
setStatus: vi.fn(), |
||||
|
setConfig: vi.fn(), |
||||
|
setModuleConfig: vi.fn(), |
||||
|
setWorkingConfig: vi.fn(), |
||||
|
setWorkingModuleConfig: vi.fn(), |
||||
|
setHardware: vi.fn(), |
||||
|
setActiveNode: vi.fn(), |
||||
|
setPendingSettingsChanges: vi.fn(), |
||||
|
addChannel: vi.fn(), |
||||
|
addWaypoint: vi.fn(), |
||||
|
addNodeInfo: vi.fn(), |
||||
|
addUser: vi.fn(), |
||||
|
addPosition: vi.fn(), |
||||
|
addConnection: vi.fn(), |
||||
|
addTraceRoute: vi.fn(), |
||||
|
addMetadata: vi.fn(), |
||||
|
removeNode: vi.fn(), |
||||
|
setDialogOpen: vi.fn(), |
||||
|
getDialogOpen: vi.fn().mockReturnValue(false), |
||||
|
processPacket: vi.fn(), |
||||
|
setMessageDraft: vi.fn(), |
||||
|
setNodeError: vi.fn(), |
||||
|
clearNodeError: vi.fn(), |
||||
|
getNodeError: vi.fn().mockReturnValue(undefined), |
||||
|
hasNodeError: vi.fn().mockReturnValue(false), |
||||
|
incrementUnread: vi.fn(), |
||||
|
resetUnread: vi.fn(), |
||||
|
getNodes: vi.fn().mockReturnValue([]), |
||||
|
getNodesLength: vi.fn().mockReturnValue(0), |
||||
|
getNode: vi.fn().mockReturnValue(undefined), |
||||
|
getMyNode: vi.fn(), |
||||
|
sendAdminMessage: vi.fn(), |
||||
|
updateFavorite: vi.fn(), |
||||
|
updateIgnored: vi.fn(), |
||||
|
}; |
||||
@ -1,81 +0,0 @@ |
|||||
import { beforeEach, describe, expect, it, vi } from "vitest"; |
|
||||
import { fireEvent, render, screen } from "@testing-library/react"; |
|
||||
import { MessagesPage } from "./Messages.tsx"; |
|
||||
import { useDevice } from "../core/stores/deviceStore.ts"; |
|
||||
import { Protobuf } from "@meshtastic/core"; |
|
||||
|
|
||||
vi.mock("../core/stores/deviceStore", () => ({ |
|
||||
useDevice: vi.fn(), |
|
||||
})); |
|
||||
|
|
||||
const mockUseDevice = { |
|
||||
channels: new Map([ |
|
||||
[0, { |
|
||||
index: 0, |
|
||||
settings: { name: "Primary" }, |
|
||||
role: Protobuf.Channel.Channel_Role.PRIMARY, |
|
||||
}], |
|
||||
]), |
|
||||
nodes: new Map([ |
|
||||
[0, { |
|
||||
num: 0, |
|
||||
user: { longName: "Test Node 0", shortName: "TN0", publicKey: "0000" }, |
|
||||
}], |
|
||||
[1111, { |
|
||||
num: 1111, |
|
||||
user: { longName: "Test Node 1", shortName: "TN1", publicKey: "12345" }, |
|
||||
}], |
|
||||
[2222, { |
|
||||
num: 2222, |
|
||||
user: { longName: "Test Node 2", shortName: "TN2", publicKey: "67890" }, |
|
||||
}], |
|
||||
[3333, { |
|
||||
num: 3333, |
|
||||
user: { longName: "Test Node 3", shortName: "TN3", publicKey: "11111" }, |
|
||||
}], |
|
||||
]), |
|
||||
hardware: { myNodeNum: 1 }, |
|
||||
messages: { broadcast: new Map(), direct: new Map() }, |
|
||||
metadata: new Map(), |
|
||||
unreadCounts: new Map([[1111, 3], [2222, 10]]), |
|
||||
resetUnread: vi.fn(), |
|
||||
hasNodeError: vi.fn(), |
|
||||
}; |
|
||||
|
|
||||
describe.skip("Messages Page", () => { |
|
||||
beforeEach(() => { |
|
||||
vi.mocked(useDevice).mockReturnValue(mockUseDevice as any); |
|
||||
}); |
|
||||
|
|
||||
it("sorts unreads to the top", () => { |
|
||||
render(<MessagesPage />); |
|
||||
const buttonOrder = screen.getAllByRole("button").filter((b) => |
|
||||
b.textContent?.includes("Test Node") |
|
||||
); |
|
||||
expect(buttonOrder[0].textContent).toContain("TN2Test Node 210"); |
|
||||
expect(buttonOrder[1].textContent).toContain("TN1Test Node 13"); |
|
||||
expect(buttonOrder[2].textContent).toContain("TN0Test Node 0"); |
|
||||
expect(buttonOrder[3].textContent).toContain("TN3Test Node 3"); |
|
||||
}); |
|
||||
|
|
||||
it("updates unread when active chat changes", () => { |
|
||||
render(<MessagesPage />); |
|
||||
const nodeButton = |
|
||||
screen.getAllByRole("button").filter((b) => |
|
||||
b.textContent?.includes("TN1Test Node 13") |
|
||||
)[0]; |
|
||||
fireEvent.click(nodeButton); |
|
||||
expect(mockUseDevice.resetUnread).toHaveBeenCalledWith(1111, 0); |
|
||||
}); |
|
||||
|
|
||||
it("does not update the incorrect node", () => { |
|
||||
render(<MessagesPage />); |
|
||||
const nodeButton = |
|
||||
screen.getAllByRole("button").filter((b) => |
|
||||
b.textContent?.includes("TN1Test Node 1") |
|
||||
)[0]; |
|
||||
fireEvent.click(nodeButton); |
|
||||
expect(mockUseDevice.resetUnread).toHaveBeenCalledWith(1111, 0); |
|
||||
expect(mockUseDevice.unreadCounts.get(2222)).toBe(10); |
|
||||
}); |
|
||||
}); |
|
||||
Loading…
Reference in new issue