15 changed files with 430 additions and 529 deletions
@ -1,141 +1,127 @@ |
|||
import { describe, expect, it } from "vitest"; |
|||
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 { Mono } from "@components/generic/Mono.tsx"; |
|||
|
|||
describe("Generic Table", () => { |
|||
it("Can render an empty table.", () => { |
|||
render( |
|||
<Table |
|||
headings={[]} |
|||
rows={[]} |
|||
/>, |
|||
); |
|||
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={[]} |
|||
/>, |
|||
); |
|||
const headings: Heading[] = [ |
|||
{ title: "Short Name", sortable: true }, |
|||
{ title: "Last Heard", sortable: true }, |
|||
{ title: "Connection", sortable: true }, |
|||
]; |
|||
render(<Table headings={headings} rows={[]} />); |
|||
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
|
|||
const mockDevicesWithShortNameAndConnection = [ |
|||
// Mock data representing devices
|
|||
const mockDevices = [ |
|||
{ |
|||
user: { shortName: "TST1" }, |
|||
id: "TST1", |
|||
shortName: "TST1", |
|||
hopsAway: 1, |
|||
lastHeard: Date.now() + 1000, |
|||
lastHeard: Date.now() - 3000, |
|||
viaMqtt: false, |
|||
}, |
|||
{ |
|||
user: { shortName: "TST2" }, |
|||
id: "TST2", |
|||
shortName: "TST2", |
|||
hopsAway: 0, |
|||
lastHeard: Date.now() + 4000, |
|||
lastHeard: Date.now() - 1000, |
|||
viaMqtt: true, |
|||
isFavorite: true, // Favorite device
|
|||
}, |
|||
{ |
|||
user: { shortName: "TST3" }, |
|||
id: "TST3", |
|||
shortName: "TST3", |
|||
hopsAway: 4, |
|||
lastHeard: Date.now(), |
|||
lastHeard: Date.now() - 5000, |
|||
viaMqtt: false, |
|||
}, |
|||
{ |
|||
user: { shortName: "TST4" }, |
|||
id: "TST4", |
|||
shortName: "TST4", |
|||
hopsAway: 3, |
|||
lastHeard: Date.now() + 2000, |
|||
lastHeard: Date.now() - 2000, |
|||
viaMqtt: true, |
|||
}, |
|||
]; |
|||
|
|||
const mockRows = mockDevicesWithShortNameAndConnection.map((node) => [ |
|||
<h1 data-testshortname key={node.user.shortName}>{node.user.shortName}</h1>, |
|||
<Mono key="lastHeard" data-testheard> |
|||
<TimeAgo timestamp={node.lastHeard * 1000} /> |
|||
</Mono>, |
|||
<Mono key="hops" data-testhops> |
|||
{node.lastHeard !== 0 |
|||
? node.viaMqtt === false && node.hopsAway === 0 |
|||
? "Direct" |
|||
: `${node.hopsAway?.toString()} ${ |
|||
node.hopsAway ?? 0 > 1 ? "hops" : "hop" |
|||
} away` |
|||
: "-"} |
|||
{node.viaMqtt === true ? ", via MQTT" : ""} |
|||
</Mono>, |
|||
]); |
|||
// Transform mock data into the format expected by the Table component
|
|||
const mockRows: DataRow[] = mockDevices.map((node) => ({ |
|||
id: node.id, |
|||
isFavorite: node.isFavorite, |
|||
cells: [ |
|||
{ |
|||
content: <b data-testid="short-name">{node.shortName}</b>, |
|||
sortValue: node.shortName, |
|||
}, |
|||
{ |
|||
content: ( |
|||
<Mono> |
|||
<TimeAgo timestamp={node.lastHeard} /> |
|||
</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 () => { |
|||
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 headings: Heading[] = [ |
|||
{ title: "Short Name", sortable: true }, |
|||
{ title: "Last Heard", sortable: true }, |
|||
{ title: "Connection", sortable: true }, |
|||
]; |
|||
|
|||
it("Can sort rows, keeping favorites at the top", async () => { |
|||
render(<Table headings={headings} 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]); |
|||
const getRenderedOrder = () => |
|||
[...renderedTable.querySelectorAll("[data-testid='short-name']")].map( |
|||
(el) => el.textContent?.trim(), |
|||
); |
|||
|
|||
// 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"); |
|||
// Default sort: "Last Heard" desc. TST2 is favorite, so it's first.
|
|||
// Then the rest are sorted by lastHeard timestamp (most recent first).
|
|||
// Order of timestamps: TST2 (latest, but favorite), TST4, TST1, TST3 (oldest).
|
|||
expect(getRenderedOrder()).toEqual(["TST2", "TST4", "TST1", "TST3"]); |
|||
|
|||
// Click "Short Name" to sort asc
|
|||
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
|
|||
expect( |
|||
[...renderedTable.querySelectorAll("[data-testshortname]")] |
|||
.map((el) => el.textContent) |
|||
.map((v) => v?.trim()) |
|||
.join(","), |
|||
) |
|||
.toMatch("TST4,TST3,TST2,TST1"); |
|||
// Click "Short Name" again to sort desc
|
|||
fireEvent.click(columnHeaders[0]); |
|||
// TST2 is favorite, so it's first. Then TST4, TST3, TST1 reverse alphabetically.
|
|||
expect(getRenderedOrder()).toEqual(["TST2", "TST4", "TST3", "TST1"]); |
|||
|
|||
// Click "Connection" to sort by hops asc
|
|||
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"); |
|||
// TST2 is favorite (and also has 0 hops). Then sorted by hops: TST1 (1), TST4 (3), TST3 (4).
|
|||
expect(getRenderedOrder()).toEqual(["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