committed by
GitHub
18 changed files with 1794 additions and 827 deletions
@ -0,0 +1,177 @@ |
|||||
|
import type { RasterSource } from "@core/stores/appStore/types.ts"; |
||||
|
import { beforeEach, describe, expect, it, vi } from "vitest"; |
||||
|
|
||||
|
const idbMem = new Map<string, string>(); |
||||
|
vi.mock("idb-keyval", () => ({ |
||||
|
get: vi.fn((key: string) => Promise.resolve(idbMem.get(key))), |
||||
|
set: vi.fn((key: string, val: string) => { |
||||
|
idbMem.set(key, val); |
||||
|
return Promise.resolve(); |
||||
|
}), |
||||
|
del: vi.fn((key: string) => { |
||||
|
idbMem.delete(key); |
||||
|
return Promise.resolve(); |
||||
|
}), |
||||
|
})); |
||||
|
|
||||
|
async function freshStore(persistApp = false) { |
||||
|
vi.resetModules(); |
||||
|
|
||||
|
vi.spyOn(console, "debug").mockImplementation(() => {}); |
||||
|
vi.spyOn(console, "log").mockImplementation(() => {}); |
||||
|
vi.spyOn(console, "info").mockImplementation(() => {}); |
||||
|
|
||||
|
vi.doMock("@core/services/featureFlags.ts", () => ({ |
||||
|
featureFlags: { |
||||
|
get: vi.fn((key: string) => (key === "persistApp" ? persistApp : false)), |
||||
|
}, |
||||
|
})); |
||||
|
|
||||
|
const storeMod = await import("./index.ts"); |
||||
|
return storeMod as typeof import("./index.ts"); |
||||
|
} |
||||
|
|
||||
|
function makeRaster(fields: Record<string, any>): RasterSource { |
||||
|
return { |
||||
|
enabled: true, |
||||
|
title: "default", |
||||
|
tiles: `https://default.com/default.json`, |
||||
|
tileSize: 256, |
||||
|
...fields, |
||||
|
}; |
||||
|
} |
||||
|
|
||||
|
describe("AppStore – basic state & actions", () => { |
||||
|
beforeEach(() => { |
||||
|
idbMem.clear(); |
||||
|
vi.clearAllMocks(); |
||||
|
}); |
||||
|
|
||||
|
it("setters flip UI flags and numeric fields", async () => { |
||||
|
const { useAppStore } = await freshStore(false); |
||||
|
const state = useAppStore.getState(); |
||||
|
|
||||
|
state.setSelectedDevice(42); |
||||
|
expect(useAppStore.getState().selectedDeviceId).toBe(42); |
||||
|
|
||||
|
state.setCommandPaletteOpen(true); |
||||
|
expect(useAppStore.getState().commandPaletteOpen).toBe(true); |
||||
|
|
||||
|
state.setConnectDialogOpen(true); |
||||
|
expect(useAppStore.getState().connectDialogOpen).toBe(true); |
||||
|
|
||||
|
state.setNodeNumToBeRemoved(123); |
||||
|
expect(useAppStore.getState().nodeNumToBeRemoved).toBe(123); |
||||
|
|
||||
|
state.setNodeNumDetails(777); |
||||
|
expect(useAppStore.getState().nodeNumDetails).toBe(777); |
||||
|
}); |
||||
|
|
||||
|
it("setRasterSources replaces; addRasterSource appends; removeRasterSource splices by index", async () => { |
||||
|
const { useAppStore } = await freshStore(false); |
||||
|
const state = useAppStore.getState(); |
||||
|
|
||||
|
const a = makeRaster({ title: "a" }); |
||||
|
const b = makeRaster({ title: "b" }); |
||||
|
const c = makeRaster({ title: "c" }); |
||||
|
|
||||
|
state.setRasterSources([a, b]); |
||||
|
expect( |
||||
|
useAppStore.getState().rasterSources.map((raster) => raster.title), |
||||
|
).toEqual(["a", "b"]); |
||||
|
|
||||
|
state.addRasterSource(c); |
||||
|
expect( |
||||
|
useAppStore.getState().rasterSources.map((raster) => raster.title), |
||||
|
).toEqual(["a", "b", "c"]); |
||||
|
|
||||
|
// "b"
|
||||
|
state.removeRasterSource(1); |
||||
|
expect( |
||||
|
useAppStore.getState().rasterSources.map((raster) => raster.title), |
||||
|
).toEqual(["a", "c"]); |
||||
|
}); |
||||
|
}); |
||||
|
|
||||
|
describe("AppStore – persistence: partialize + rehydrate", () => { |
||||
|
beforeEach(() => { |
||||
|
idbMem.clear(); |
||||
|
vi.clearAllMocks(); |
||||
|
}); |
||||
|
|
||||
|
it("persists only rasterSources; methods still work after rehydrate", async () => { |
||||
|
// Write data
|
||||
|
{ |
||||
|
const { useAppStore } = await freshStore(true); |
||||
|
const state = useAppStore.getState(); |
||||
|
|
||||
|
state.setRasterSources([ |
||||
|
makeRaster({ title: "x" }), |
||||
|
makeRaster({ title: "y" }), |
||||
|
]); |
||||
|
state.setSelectedDevice(99); |
||||
|
state.setCommandPaletteOpen(true); |
||||
|
// Only rasterSources should persist by partialize
|
||||
|
expect(useAppStore.getState().rasterSources.length).toBe(2); |
||||
|
} |
||||
|
|
||||
|
// Rehydrate from idbMem
|
||||
|
{ |
||||
|
const { useAppStore } = await freshStore(true); |
||||
|
const state = useAppStore.getState(); |
||||
|
|
||||
|
// persisted slice:
|
||||
|
expect(state.rasterSources.map((raster) => raster.title)).toEqual([ |
||||
|
"x", |
||||
|
"y", |
||||
|
]); |
||||
|
|
||||
|
// ephemeral fields reset to defaults:
|
||||
|
expect(state.selectedDeviceId).toBe(0); |
||||
|
expect(state.commandPaletteOpen).toBe(false); |
||||
|
expect(state.connectDialogOpen).toBe(false); |
||||
|
expect(state.nodeNumToBeRemoved).toBe(0); |
||||
|
expect(state.nodeNumDetails).toBe(0); |
||||
|
|
||||
|
// methods still work post-rehydrate:
|
||||
|
state.addRasterSource(makeRaster({ title: "z" })); |
||||
|
expect( |
||||
|
useAppStore.getState().rasterSources.map((raster) => raster.title), |
||||
|
).toEqual(["x", "y", "z"]); |
||||
|
state.removeRasterSource(0); |
||||
|
expect( |
||||
|
useAppStore.getState().rasterSources.map((raster) => raster.title), |
||||
|
).toEqual(["y", "z"]); |
||||
|
} |
||||
|
}); |
||||
|
|
||||
|
it("removing and resetting sources persists across reload", async () => { |
||||
|
{ |
||||
|
const { useAppStore } = await freshStore(true); |
||||
|
const state = useAppStore.getState(); |
||||
|
state.setRasterSources([ |
||||
|
makeRaster({ title: "keep" }), |
||||
|
makeRaster({ title: "drop" }), |
||||
|
]); |
||||
|
state.removeRasterSource(1); // drop "drop"
|
||||
|
expect( |
||||
|
useAppStore.getState().rasterSources.map((raster) => raster.title), |
||||
|
).toEqual(["keep"]); |
||||
|
} |
||||
|
{ |
||||
|
const { useAppStore } = await freshStore(true); |
||||
|
const state = useAppStore.getState(); |
||||
|
expect(state.rasterSources.map((raster) => raster.title)).toEqual([ |
||||
|
"keep", |
||||
|
]); |
||||
|
|
||||
|
// Now replace entirely
|
||||
|
state.setRasterSources([]); |
||||
|
} |
||||
|
{ |
||||
|
const { useAppStore } = await freshStore(true); |
||||
|
const state = useAppStore.getState(); |
||||
|
expect(state.rasterSources).toEqual([]); // stayed cleared
|
||||
|
} |
||||
|
}); |
||||
|
}); |
||||
@ -0,0 +1,6 @@ |
|||||
|
export interface RasterSource { |
||||
|
enabled: boolean; |
||||
|
title: string; |
||||
|
tiles: string; |
||||
|
tileSize: number; |
||||
|
} |
||||
@ -0,0 +1,516 @@ |
|||||
|
import { create, toBinary } from "@bufbuild/protobuf"; |
||||
|
import { Protobuf, type Types } from "@meshtastic/core"; |
||||
|
import { beforeEach, describe, expect, it, vi } from "vitest"; |
||||
|
|
||||
|
const idbMem = new Map<string, string>(); |
||||
|
vi.mock("idb-keyval", () => ({ |
||||
|
get: vi.fn((key: string) => Promise.resolve(idbMem.get(key))), |
||||
|
set: vi.fn((key: string, val: string) => { |
||||
|
idbMem.set(key, val); |
||||
|
return Promise.resolve(); |
||||
|
}), |
||||
|
del: vi.fn((k: string) => { |
||||
|
idbMem.delete(k); |
||||
|
return Promise.resolve(); |
||||
|
}), |
||||
|
})); |
||||
|
|
||||
|
// Helper to load a fresh copy of the store with persist flag on/off
|
||||
|
async function freshStore(persist = false) { |
||||
|
vi.resetModules(); |
||||
|
|
||||
|
// suppress console output from the store during tests (for github actions)
|
||||
|
vi.spyOn(console, "debug").mockImplementation(() => {}); |
||||
|
vi.spyOn(console, "log").mockImplementation(() => {}); |
||||
|
vi.spyOn(console, "info").mockImplementation(() => {}); |
||||
|
|
||||
|
vi.doMock("@core/services/featureFlags", () => ({ |
||||
|
featureFlags: { |
||||
|
get: vi.fn((key: string) => (key === "persistDevices" ? persist : false)), |
||||
|
}, |
||||
|
})); |
||||
|
|
||||
|
const storeMod = await import("./index.ts"); |
||||
|
const { useNodeDB } = await import("../index.ts"); |
||||
|
return { ...storeMod, useNodeDB }; |
||||
|
} |
||||
|
|
||||
|
function makeHardware(myNodeNum: number) { |
||||
|
return create(Protobuf.Mesh.MyNodeInfoSchema, { myNodeNum }); |
||||
|
} |
||||
|
function makeRoute(from: number, time = Date.now() / 1000) { |
||||
|
return { |
||||
|
from, |
||||
|
rxTime: time, |
||||
|
portnum: Protobuf.Portnums.PortNum.ROUTING_APP, |
||||
|
data: create(Protobuf.Mesh.RouteDiscoverySchema, {}), |
||||
|
} as unknown as Types.PacketMetadata<Protobuf.Mesh.RouteDiscovery>; |
||||
|
} |
||||
|
function makeChannel(index: number) { |
||||
|
return create(Protobuf.Channel.ChannelSchema, { index }); |
||||
|
} |
||||
|
function makeWaypoint(id: number, expire?: number) { |
||||
|
return create(Protobuf.Mesh.WaypointSchema, { id, expire }); |
||||
|
} |
||||
|
function makeConfig(fields: Record<string, any>) { |
||||
|
return create(Protobuf.Config.ConfigSchema, { |
||||
|
payloadVariant: { |
||||
|
case: "device", |
||||
|
value: create(Protobuf.Config.Config_DeviceConfigSchema, fields), |
||||
|
}, |
||||
|
}); |
||||
|
} |
||||
|
function makeModuleConfig(fields: Record<string, any>) { |
||||
|
return create(Protobuf.ModuleConfig.ModuleConfigSchema, { |
||||
|
payloadVariant: { |
||||
|
case: "mqtt", |
||||
|
value: create( |
||||
|
Protobuf.ModuleConfig.ModuleConfig_MQTTConfigSchema, |
||||
|
fields, |
||||
|
), |
||||
|
}, |
||||
|
}); |
||||
|
} |
||||
|
function makeAdminMessage(fields: Record<string, any>) { |
||||
|
return create(Protobuf.Admin.AdminMessageSchema, fields); |
||||
|
} |
||||
|
|
||||
|
describe("DeviceStore – basic map ops & retention", () => { |
||||
|
beforeEach(() => { |
||||
|
idbMem.clear(); |
||||
|
vi.clearAllMocks(); |
||||
|
}); |
||||
|
|
||||
|
it("addDevice returns same instance on repeated calls; getDevice(s) works; retention evicts oldest after cap", async () => { |
||||
|
const { useDeviceStore } = await freshStore(false); |
||||
|
const state = useDeviceStore.getState(); |
||||
|
|
||||
|
const a = state.addDevice(1); |
||||
|
const b = state.addDevice(1); |
||||
|
expect(a).toBe(b); |
||||
|
expect(state.getDevice(1)).toBe(a); |
||||
|
expect(state.getDevices().length).toBe(1); |
||||
|
|
||||
|
// DEVICESTORE_RETENTION_NUM = 10; create 11 to evict #1
|
||||
|
for (let i = 2; i <= 11; i++) { |
||||
|
state.addDevice(i); |
||||
|
} |
||||
|
expect(state.getDevice(1)).toBeUndefined(); |
||||
|
expect(state.getDevice(11)).toBeDefined(); |
||||
|
expect(state.getDevices().length).toBe(10); |
||||
|
}); |
||||
|
|
||||
|
it("removeDevice deletes only that entry", async () => { |
||||
|
const { useDeviceStore } = await freshStore(false); |
||||
|
const state = useDeviceStore.getState(); |
||||
|
state.addDevice(10); |
||||
|
state.addDevice(11); |
||||
|
expect(state.getDevices().length).toBe(2); |
||||
|
|
||||
|
state.removeDevice(10); |
||||
|
expect(state.getDevice(10)).toBeUndefined(); |
||||
|
expect(state.getDevice(11)).toBeDefined(); |
||||
|
expect(state.getDevices().length).toBe(1); |
||||
|
}); |
||||
|
}); |
||||
|
|
||||
|
describe("DeviceStore – working/effective config API", () => { |
||||
|
beforeEach(() => { |
||||
|
idbMem.clear(); |
||||
|
vi.clearAllMocks(); |
||||
|
}); |
||||
|
|
||||
|
it("setWorkingConfig/getWorkingConfig replaces by variant and getEffectiveConfig merges base + working", async () => { |
||||
|
const { useDeviceStore } = await freshStore(false); |
||||
|
const state = useDeviceStore.getState(); |
||||
|
const device = state.addDevice(42); |
||||
|
|
||||
|
// config deviceConfig.role = CLIENT
|
||||
|
device.setConfig( |
||||
|
create(Protobuf.Config.ConfigSchema, { |
||||
|
payloadVariant: { |
||||
|
case: "device", |
||||
|
value: create(Protobuf.Config.Config_DeviceConfigSchema, { |
||||
|
role: Protobuf.Config.Config_DeviceConfig_Role.CLIENT, |
||||
|
}), |
||||
|
}, |
||||
|
}), |
||||
|
); |
||||
|
|
||||
|
// working deviceConfig.role = ROUTER
|
||||
|
device.setWorkingConfig( |
||||
|
makeConfig({ |
||||
|
role: Protobuf.Config.Config_DeviceConfig_Role.ROUTER, |
||||
|
}), |
||||
|
); |
||||
|
|
||||
|
// expect working deviceConfig.role = ROUTER
|
||||
|
const working = device.getWorkingConfig("device"); |
||||
|
expect(working?.role).toBe(Protobuf.Config.Config_DeviceConfig_Role.ROUTER); |
||||
|
|
||||
|
// expect effective deviceConfig.role = ROUTER
|
||||
|
const effective = device.getEffectiveConfig("device"); |
||||
|
expect(effective?.role).toBe( |
||||
|
Protobuf.Config.Config_DeviceConfig_Role.ROUTER, |
||||
|
); |
||||
|
|
||||
|
// remove working, effective should equal base
|
||||
|
device.removeWorkingConfig("device"); |
||||
|
expect(device.getWorkingConfig("device")).toBeUndefined(); |
||||
|
expect(device.getEffectiveConfig("device")?.role).toBe( |
||||
|
Protobuf.Config.Config_DeviceConfig_Role.CLIENT, |
||||
|
); |
||||
|
|
||||
|
// add multiple, then clear all
|
||||
|
device.setWorkingConfig(makeConfig({})); |
||||
|
device.setWorkingConfig( |
||||
|
makeConfig({ |
||||
|
deviceRole: Protobuf.Config.Config_DeviceConfig_Role.ROUTER, |
||||
|
}), |
||||
|
); |
||||
|
device.removeWorkingConfig(); // clears all
|
||||
|
expect(device.getWorkingConfig("device")).toBeUndefined(); |
||||
|
}); |
||||
|
|
||||
|
it("setWorkingModuleConfig/getWorkingModuleConfig and getEffectiveModuleConfig", async () => { |
||||
|
const { useDeviceStore } = await freshStore(false); |
||||
|
const state = useDeviceStore.getState(); |
||||
|
const device = state.addDevice(7); |
||||
|
|
||||
|
// base moduleConfig.mqtt empty; add working mqtt host
|
||||
|
device.setModuleConfig( |
||||
|
create(Protobuf.ModuleConfig.ModuleConfigSchema, { |
||||
|
payloadVariant: { |
||||
|
case: "mqtt", |
||||
|
value: create(Protobuf.ModuleConfig.ModuleConfig_MQTTConfigSchema, { |
||||
|
address: "mqtt://base", |
||||
|
}), |
||||
|
}, |
||||
|
}), |
||||
|
); |
||||
|
device.setWorkingModuleConfig( |
||||
|
makeModuleConfig({ address: "mqtt://working" }), |
||||
|
); |
||||
|
|
||||
|
const mqtt = device.getWorkingModuleConfig("mqtt"); |
||||
|
expect(mqtt?.address).toBe("mqtt://working"); |
||||
|
expect(mqtt?.address).toBe("mqtt://working"); |
||||
|
|
||||
|
device.removeWorkingModuleConfig("mqtt"); |
||||
|
expect(device.getWorkingModuleConfig("mqtt")).toBeUndefined(); |
||||
|
expect(device.getEffectiveModuleConfig("mqtt")?.address).toBe( |
||||
|
"mqtt://base", |
||||
|
); |
||||
|
|
||||
|
// Clear all
|
||||
|
device.setWorkingModuleConfig(makeModuleConfig({ address: "x" })); |
||||
|
device.setWorkingModuleConfig(makeModuleConfig({ address: "y" })); |
||||
|
device.removeWorkingModuleConfig(); |
||||
|
expect(device.getWorkingModuleConfig("mqtt")).toBeUndefined(); |
||||
|
}); |
||||
|
|
||||
|
it("channel working config add/update/remove/get", async () => { |
||||
|
const { useDeviceStore } = await freshStore(false); |
||||
|
const state = useDeviceStore.getState(); |
||||
|
const device = state.addDevice(9); |
||||
|
|
||||
|
device.setWorkingChannelConfig(makeChannel(0)); |
||||
|
device.setWorkingChannelConfig( |
||||
|
create(Protobuf.Channel.ChannelSchema, { |
||||
|
index: 1, |
||||
|
settings: { name: "one" }, |
||||
|
}), |
||||
|
); |
||||
|
expect(device.getWorkingChannelConfig(0)?.index).toBe(0); |
||||
|
expect(device.getWorkingChannelConfig(1)?.settings?.name).toBe("one"); |
||||
|
|
||||
|
// update channel 1
|
||||
|
device.setWorkingChannelConfig( |
||||
|
create(Protobuf.Channel.ChannelSchema, { |
||||
|
index: 1, |
||||
|
settings: { name: "uno" }, |
||||
|
}), |
||||
|
); |
||||
|
expect(device.getWorkingChannelConfig(1)?.settings?.name).toBe("uno"); |
||||
|
|
||||
|
// remove specific
|
||||
|
device.removeWorkingChannelConfig(1); |
||||
|
expect(device.getWorkingChannelConfig(1)).toBeUndefined(); |
||||
|
|
||||
|
// remove all
|
||||
|
device.removeWorkingChannelConfig(); |
||||
|
expect(device.getWorkingChannelConfig(0)).toBeUndefined(); |
||||
|
}); |
||||
|
}); |
||||
|
|
||||
|
describe("DeviceStore – metadata, dialogs, unread counts, message draft", () => { |
||||
|
beforeEach(() => { |
||||
|
idbMem.clear(); |
||||
|
vi.clearAllMocks(); |
||||
|
}); |
||||
|
|
||||
|
it("addMetadata stores by node id", async () => { |
||||
|
const { useDeviceStore } = await freshStore(false); |
||||
|
const state = useDeviceStore.getState(); |
||||
|
const device = state.addDevice(1); |
||||
|
|
||||
|
const metadata = create(Protobuf.Mesh.DeviceMetadataSchema, { |
||||
|
firmwareVersion: "1.2.3", |
||||
|
}); |
||||
|
device.addMetadata(123, metadata); |
||||
|
|
||||
|
expect(useDeviceStore.getState().devices.get(1)?.metadata.get(123)).toEqual( |
||||
|
metadata, |
||||
|
); |
||||
|
}); |
||||
|
|
||||
|
it("dialogs set/get work and throw if device missing", async () => { |
||||
|
const { useDeviceStore } = await freshStore(false); |
||||
|
const state = useDeviceStore.getState(); |
||||
|
const device = state.addDevice(5); |
||||
|
|
||||
|
device.setDialogOpen("reboot", true); |
||||
|
expect(device.getDialogOpen("reboot")).toBe(true); |
||||
|
device.setDialogOpen("reboot", false); |
||||
|
expect(device.getDialogOpen("reboot")).toBe(false); |
||||
|
|
||||
|
// getDialogOpen uses getDevice or throws if device missing
|
||||
|
state.removeDevice(5); |
||||
|
expect(() => device.getDialogOpen("reboot")).toThrow(/Device 5 not found/); |
||||
|
}); |
||||
|
|
||||
|
it("unread counts: increment/get/getAll/reset", async () => { |
||||
|
const { useDeviceStore } = await freshStore(false); |
||||
|
const state = useDeviceStore.getState(); |
||||
|
const device = state.addDevice(2); |
||||
|
|
||||
|
expect(device.getUnreadCount(10)).toBe(0); |
||||
|
device.incrementUnread(10); |
||||
|
device.incrementUnread(10); |
||||
|
device.incrementUnread(11); |
||||
|
expect(device.getUnreadCount(10)).toBe(2); |
||||
|
expect(device.getUnreadCount(11)).toBe(1); |
||||
|
expect(device.getAllUnreadCount()).toBe(3); |
||||
|
|
||||
|
device.resetUnread(10); |
||||
|
expect(device.getUnreadCount(10)).toBe(0); |
||||
|
expect(device.getAllUnreadCount()).toBe(1); |
||||
|
}); |
||||
|
|
||||
|
it("setMessageDraft stores the text", async () => { |
||||
|
const { useDeviceStore } = await freshStore(false); |
||||
|
const device = useDeviceStore.getState().addDevice(3); |
||||
|
device.setMessageDraft("hello"); |
||||
|
|
||||
|
expect(useDeviceStore.getState().devices.get(3)?.messageDraft).toBe( |
||||
|
"hello", |
||||
|
); |
||||
|
}); |
||||
|
}); |
||||
|
|
||||
|
describe("DeviceStore – traceroutes & waypoints retention + merge on setHardware", () => { |
||||
|
beforeEach(() => { |
||||
|
idbMem.clear(); |
||||
|
vi.clearAllMocks(); |
||||
|
}); |
||||
|
|
||||
|
it("addTraceRoute appends and enforces per-target and target caps", async () => { |
||||
|
const { useDeviceStore } = await freshStore(false); |
||||
|
const state = useDeviceStore.getState(); |
||||
|
const device = state.addDevice(100); |
||||
|
|
||||
|
// Per target: cap = 100; push 101 for from=7
|
||||
|
for (let i = 0; i < 101; i++) { |
||||
|
device.addTraceRoute(makeRoute(7, i)); |
||||
|
} |
||||
|
|
||||
|
const routesFor7 = useDeviceStore |
||||
|
.getState() |
||||
|
.devices.get(100) |
||||
|
?.traceroutes.get(7)!; |
||||
|
expect(routesFor7.length).toBe(100); |
||||
|
expect(routesFor7[0]?.rxTime).toBe(1); // first (0) evicted
|
||||
|
|
||||
|
// Target map cap: 100 keys, add 101 unique "from"
|
||||
|
for (let from = 0; from <= 100; from++) { |
||||
|
device.addTraceRoute(makeRoute(1000 + from)); |
||||
|
} |
||||
|
|
||||
|
const keys = Array.from( |
||||
|
useDeviceStore.getState().devices.get(100)!.traceroutes.keys(), |
||||
|
); |
||||
|
expect(keys.length).toBe(100); |
||||
|
}); |
||||
|
|
||||
|
it("addWaypoint upserts by id and enforces retention; setHardware moves traceroutes + prunes expired waypoints", async () => { |
||||
|
vi.setSystemTime(new Date("2025-01-01T00:00:00Z")); |
||||
|
const { useDeviceStore } = await freshStore(false); |
||||
|
const state = useDeviceStore.getState(); |
||||
|
|
||||
|
// Old device with myNodeNum=777 and some waypoints (one expired)
|
||||
|
const oldDevice = state.addDevice(1); |
||||
|
oldDevice.connection = { sendWaypoint: vi.fn() } as any; |
||||
|
|
||||
|
oldDevice.setHardware(makeHardware(777)); |
||||
|
oldDevice.addWaypoint( |
||||
|
makeWaypoint(1, Date.parse("2024-12-31T23:59:59Z")), // This is expired, will not be added
|
||||
|
0, |
||||
|
0, |
||||
|
new Date(), |
||||
|
); // expired
|
||||
|
oldDevice.addWaypoint(makeWaypoint(2, 0), 0, 0, new Date()); // no expire
|
||||
|
oldDevice.addWaypoint( |
||||
|
makeWaypoint(3, Date.parse("2026-01-01T00:00:00Z")), |
||||
|
0, |
||||
|
0, |
||||
|
new Date(), |
||||
|
); // ok
|
||||
|
oldDevice.addTraceRoute(makeRoute(55)); |
||||
|
oldDevice.addTraceRoute(makeRoute(56)); |
||||
|
|
||||
|
// Upsert waypoint by id
|
||||
|
oldDevice.addWaypoint( |
||||
|
makeWaypoint(2, Date.parse("2027-01-01T00:00:00Z")), |
||||
|
0, |
||||
|
0, |
||||
|
new Date(), |
||||
|
); |
||||
|
|
||||
|
const wps = useDeviceStore.getState().devices.get(1)!.waypoints; |
||||
|
expect(wps.length).toBe(2); |
||||
|
expect(wps.find((w) => w.id === 2)?.expire).toBe( |
||||
|
Date.parse("2027-01-01T00:00:00Z"), |
||||
|
); |
||||
|
|
||||
|
// Retention: push 102 total waypoints -> capped at 100. Oldest evicted
|
||||
|
for (let i = 3; i <= 102; i++) { |
||||
|
oldDevice.addWaypoint(makeWaypoint(i), 0, 0, new Date()); |
||||
|
} |
||||
|
|
||||
|
expect(useDeviceStore.getState().devices.get(1)!.waypoints.length).toBe( |
||||
|
100, |
||||
|
); |
||||
|
|
||||
|
// Remove waypoint
|
||||
|
oldDevice.removeWaypoint(102, false); |
||||
|
expect(oldDevice.connection?.sendWaypoint).not.toHaveBeenCalled(); |
||||
|
|
||||
|
await oldDevice.removeWaypoint(101, true); // toMesh=true
|
||||
|
expect(oldDevice.connection?.sendWaypoint).toHaveBeenCalled(); |
||||
|
|
||||
|
expect(useDeviceStore.getState().devices.get(1)!.waypoints.length).toBe(98); |
||||
|
|
||||
|
// New device shares myNodeNum; setHardware should:
|
||||
|
// - move traceroutes from old device
|
||||
|
// - copy waypoints minus expired
|
||||
|
// - delete old device entry
|
||||
|
const newDevice = state.addDevice(2); |
||||
|
newDevice.setHardware(makeHardware(777)); |
||||
|
|
||||
|
expect(state.getDevice(1)).toBeUndefined(); |
||||
|
expect(state.getDevice(2)).toBeDefined(); |
||||
|
|
||||
|
// traceroutes moved:
|
||||
|
expect(state.getDevice(2)!.traceroutes.size).toBe(2); |
||||
|
|
||||
|
// Getter for waypoint by id works
|
||||
|
expect(newDevice.getWaypoint(1)).toBeUndefined(); |
||||
|
expect(newDevice.getWaypoint(2)).toBeUndefined(); |
||||
|
expect(newDevice.getWaypoint(3)).toBeTruthy(); |
||||
|
|
||||
|
vi.useRealTimers(); |
||||
|
}); |
||||
|
}); |
||||
|
|
||||
|
describe("DeviceStore – persistence partialize & rehydrate", () => { |
||||
|
beforeEach(() => { |
||||
|
idbMem.clear(); |
||||
|
vi.clearAllMocks(); |
||||
|
}); |
||||
|
|
||||
|
it("partialize stores only DeviceData; onRehydrateStorage rebuilds only devices with myNodeNum set (orphans dropped)", async () => { |
||||
|
// First run: persist=true
|
||||
|
{ |
||||
|
const { useDeviceStore } = await freshStore(true); |
||||
|
const state = useDeviceStore.getState(); |
||||
|
|
||||
|
const orphan = state.addDevice(500); // no myNodeNum -> should be dropped
|
||||
|
orphan.addWaypoint(makeWaypoint(123), 0, 0, new Date()); |
||||
|
|
||||
|
const good = state.addDevice(501); |
||||
|
good.setHardware(makeHardware(42)); // sets myNodeNum
|
||||
|
good.addTraceRoute(makeRoute(77)); |
||||
|
good.addWaypoint(makeWaypoint(1), 0, 0, new Date()); |
||||
|
// ensure some ephemeral fields differ so we can verify methods work after rehydrate
|
||||
|
good.setMessageDraft("draft"); |
||||
|
} |
||||
|
|
||||
|
// Reload: persist=true -> rehydrate from idbMem
|
||||
|
{ |
||||
|
const { useDeviceStore } = await freshStore(true); |
||||
|
const state = useDeviceStore.getState(); |
||||
|
|
||||
|
expect(state.getDevice(500)).toBeUndefined(); // orphan dropped
|
||||
|
const device = state.getDevice(501)!; |
||||
|
expect(device).toBeDefined(); |
||||
|
|
||||
|
// methods should work
|
||||
|
device.addWaypoint(makeWaypoint(2), 0, 0, new Date()); |
||||
|
expect( |
||||
|
useDeviceStore.getState().devices.get(501)!.waypoints.length, |
||||
|
).toBeGreaterThan(0); |
||||
|
|
||||
|
// traceroutes survived
|
||||
|
expect( |
||||
|
useDeviceStore.getState().devices.get(501)!.traceroutes.size, |
||||
|
).toBeGreaterThan(0); |
||||
|
} |
||||
|
}); |
||||
|
|
||||
|
it("removing a device persists across reload", async () => { |
||||
|
{ |
||||
|
const { useDeviceStore } = await freshStore(true); |
||||
|
const state = useDeviceStore.getState(); |
||||
|
const device = state.addDevice(900); |
||||
|
device.setHardware(makeHardware(9)); // ensure it will be rehydrated
|
||||
|
expect(state.getDevice(900)).toBeDefined(); |
||||
|
state.removeDevice(900); |
||||
|
expect(state.getDevice(900)).toBeUndefined(); |
||||
|
} |
||||
|
{ |
||||
|
const { useDeviceStore } = await freshStore(true); |
||||
|
expect(useDeviceStore.getState().getDevice(900)).toBeUndefined(); |
||||
|
} |
||||
|
}); |
||||
|
}); |
||||
|
|
||||
|
describe("DeviceStore – connection & sendAdminMessage", () => { |
||||
|
beforeEach(() => { |
||||
|
idbMem.clear(); |
||||
|
vi.clearAllMocks(); |
||||
|
}); |
||||
|
|
||||
|
it("sendAdminMessage calls through to connection.sendPacket with correct args", async () => { |
||||
|
const { useDeviceStore } = await freshStore(false); |
||||
|
const state = useDeviceStore.getState(); |
||||
|
const device = state.addDevice(77); |
||||
|
|
||||
|
const sendPacket = vi.fn(); |
||||
|
device.addConnection({ sendPacket } as any); |
||||
|
|
||||
|
const message = makeAdminMessage({ logVerbosity: 1 }); |
||||
|
device.sendAdminMessage(message); |
||||
|
|
||||
|
expect(sendPacket).toHaveBeenCalledTimes(1); |
||||
|
const [bytes, port, dest] = sendPacket.mock.calls[0]!; |
||||
|
expect(port).toBe(Protobuf.Portnums.PortNum.ADMIN_APP); |
||||
|
expect(dest).toBe("self"); |
||||
|
|
||||
|
// sanity: encoded bytes match toBinary on the same schema
|
||||
|
const expected = toBinary(Protobuf.Admin.AdminMessageSchema, message); |
||||
|
expect(bytes).toBeInstanceOf(Uint8Array); |
||||
|
|
||||
|
// compare content length as minimal assertion (exact byte-for-byte is fine too)
|
||||
|
expect((bytes as Uint8Array).length).toBe(expected.length); |
||||
|
}); |
||||
|
}); |
||||
File diff suppressed because it is too large
@ -0,0 +1,52 @@ |
|||||
|
import type { Protobuf } from "@meshtastic/core"; |
||||
|
|
||||
|
interface Dialogs { |
||||
|
import: boolean; |
||||
|
QR: boolean; |
||||
|
shutdown: boolean; |
||||
|
reboot: boolean; |
||||
|
deviceName: boolean; |
||||
|
nodeRemoval: boolean; |
||||
|
pkiBackup: boolean; |
||||
|
nodeDetails: boolean; |
||||
|
unsafeRoles: boolean; |
||||
|
refreshKeys: boolean; |
||||
|
deleteMessages: boolean; |
||||
|
managedMode: boolean; |
||||
|
clientNotification: boolean; |
||||
|
resetNodeDb: boolean; |
||||
|
clearAllStores: boolean; |
||||
|
factoryResetDevice: boolean; |
||||
|
factoryResetConfig: boolean; |
||||
|
} |
||||
|
|
||||
|
type DialogVariant = keyof Dialogs; |
||||
|
|
||||
|
type ValidConfigType = Exclude< |
||||
|
Protobuf.Config.Config["payloadVariant"]["case"], |
||||
|
"deviceUi" | "sessionkey" | undefined |
||||
|
>; |
||||
|
type ValidModuleConfigType = Exclude< |
||||
|
Protobuf.ModuleConfig.ModuleConfig["payloadVariant"]["case"], |
||||
|
undefined |
||||
|
>; |
||||
|
|
||||
|
type Page = "messages" | "map" | "config" | "channels" | "nodes"; |
||||
|
|
||||
|
type WaypointWithMetadata = Protobuf.Mesh.Waypoint & { |
||||
|
metadata: { |
||||
|
channel: number; // Channel on which the waypoint was received
|
||||
|
created: Date; // Timestamp when the waypoint was received
|
||||
|
updated?: Date; // Timestamp when the waypoint was last updated
|
||||
|
from: number; // Node number of the device that sent the waypoint
|
||||
|
}; |
||||
|
}; |
||||
|
|
||||
|
export type { |
||||
|
Page, |
||||
|
Dialogs, |
||||
|
DialogVariant, |
||||
|
ValidConfigType, |
||||
|
ValidModuleConfigType, |
||||
|
WaypointWithMetadata, |
||||
|
}; |
||||
@ -1,14 +1,24 @@ |
|||||
export function evictOldestEntries<K, V>( |
export function evictOldestEntries<T>(arr: T[], maxSize: number): void; |
||||
map: Map<K, V>, |
export function evictOldestEntries<K, V>(map: Map<K, V>, maxSize: number): void; |
||||
|
|
||||
|
export function evictOldestEntries<T, K, V>( |
||||
|
collection: T[] | Map<K, V>, |
||||
maxSize: number, |
maxSize: number, |
||||
): void { |
): void { |
||||
// while loop in case maxSize is ever changed to be lower, to trim all the way down
|
if (Array.isArray(collection)) { |
||||
while (map.size > maxSize) { |
// Trim array from the front (assuming oldest entries are at the start)
|
||||
const firstKey = map.keys().next().value; // maps keep insertion order, so this is oldest
|
while (collection.length > maxSize) { |
||||
if (firstKey !== undefined) { |
collection.shift(); |
||||
map.delete(firstKey); |
} |
||||
} else { |
} else if (collection instanceof Map) { |
||||
break; // should not happen, but just in case
|
// Trim map by insertion order
|
||||
|
while (collection.size > maxSize) { |
||||
|
const firstKey = collection.keys().next().value; |
||||
|
if (firstKey !== undefined) { |
||||
|
collection.delete(firstKey); |
||||
|
} else { |
||||
|
break; |
||||
|
} |
||||
} |
} |
||||
} |
} |
||||
} |
} |
||||
|
|||||
Loading…
Reference in new issue