12 changed files with 517 additions and 69 deletions
@ -0,0 +1,30 @@ |
|||||
|
import { vi } from "vitest"; |
||||
|
import type { NodeDB } from "./index.ts"; |
||||
|
|
||||
|
/** |
||||
|
* You can spread this base mock in your tests and override only the |
||||
|
* properties relevant to a specific test case. |
||||
|
*/ |
||||
|
export const mockNodeDBStore: NodeDB = { |
||||
|
id: 0, |
||||
|
myNodeNum: 0, |
||||
|
nodeErrors: new Map(), |
||||
|
nodeMap: new Map(), |
||||
|
|
||||
|
addNode: vi.fn(), |
||||
|
addUser: vi.fn(), |
||||
|
addPosition: vi.fn(), |
||||
|
removeNode: vi.fn(), |
||||
|
processPacket: vi.fn(), |
||||
|
setNodeError: vi.fn(), |
||||
|
clearNodeError: vi.fn(), |
||||
|
getNodeError: vi.fn().mockReturnValue(undefined), |
||||
|
hasNodeError: vi.fn().mockReturnValue(false), |
||||
|
getNodes: vi.fn().mockReturnValue([]), |
||||
|
getNodesLength: vi.fn().mockReturnValue(0), |
||||
|
getNode: vi.fn().mockReturnValue(undefined), |
||||
|
getMyNode: vi.fn(), |
||||
|
updateFavorite: vi.fn(), |
||||
|
updateIgnore: vi.fn(), |
||||
|
setNodeNum: vi.fn(), |
||||
|
}; |
||||
@ -0,0 +1,247 @@ |
|||||
|
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(); |
||||
|
}), |
||||
|
})); |
||||
|
|
||||
|
// import a fresh copy of the store module (because the store is created at import time)
|
||||
|
async function freshStore() { |
||||
|
vi.resetModules(); |
||||
|
const mod = await import("../nodeDBStore"); |
||||
|
return mod; |
||||
|
} |
||||
|
|
||||
|
function makeNode(num: number, extras: Record<string, any> = {}) { |
||||
|
return { num, ...extras } as any; |
||||
|
} |
||||
|
|
||||
|
describe("NodeDB store", () => { |
||||
|
beforeEach(() => { |
||||
|
idbMem.clear(); |
||||
|
vi.clearAllMocks(); |
||||
|
}); |
||||
|
|
||||
|
it("addNodeDB returns same instance on repeated calls; getNodeDB works", async () => { |
||||
|
const { useNodeDBStore } = await freshStore(); |
||||
|
|
||||
|
const db1 = useNodeDBStore.getState().addNodeDB(123); |
||||
|
const db2 = useNodeDBStore.getState().addNodeDB(123); |
||||
|
expect(db1).toBe(db2); |
||||
|
|
||||
|
const got = useNodeDBStore.getState().getNodeDB(123); |
||||
|
expect(got).toBe(db1); |
||||
|
|
||||
|
expect(useNodeDBStore.getState().getNodeDBs().length).toBe(1); |
||||
|
}); |
||||
|
|
||||
|
it("addNode, getNode(s), getNodesLength, removeNode", async () => { |
||||
|
const { useNodeDBStore } = await freshStore(); |
||||
|
const db = useNodeDBStore.getState().addNodeDB(1); |
||||
|
|
||||
|
db.addNode(makeNode(10)); |
||||
|
db.addNode(makeNode(11)); |
||||
|
expect(db.getNodesLength()).toBe(2); |
||||
|
expect(db.getNode(10)?.num).toBe(10); |
||||
|
|
||||
|
const all = db.getNodes(); |
||||
|
expect(all.map(n => n.num).sort()).toEqual([10, 11]); |
||||
|
|
||||
|
db.removeNode(10); |
||||
|
expect(db.getNodesLength()).toBe(1); |
||||
|
expect(db.getNode(10)).toBeUndefined(); |
||||
|
}); |
||||
|
|
||||
|
it("processPacket creates or updates a node", async () => { |
||||
|
const { useNodeDBStore } = await freshStore(); |
||||
|
const db = useNodeDBStore.getState().addNodeDB(1); |
||||
|
|
||||
|
db.processPacket({ from: 50, time: 1111, snr: 7 } as any); |
||||
|
expect(db.getNode(50)).toBeTruthy(); |
||||
|
expect(db.getNode(50)?.lastHeard).toBe(1111); |
||||
|
expect(db.getNode(50)?.snr).toBe(7); |
||||
|
|
||||
|
db.processPacket({ from: 50, time: 2222, snr: 9 } as any); |
||||
|
expect(db.getNode(50)?.lastHeard).toBe(2222); |
||||
|
expect(db.getNode(50)?.snr).toBe(9); |
||||
|
}); |
||||
|
|
||||
|
it("addUser and addPosition updates existing or creates new nodes", async () => { |
||||
|
const { useNodeDBStore } = await freshStore(); |
||||
|
const db = useNodeDBStore.getState().addNodeDB(1); |
||||
|
|
||||
|
// addUser creates node if missing
|
||||
|
db.addUser({ from: 77, data: { id: "u" } } as any); |
||||
|
expect(db.getNode(77)?.user).toEqual({ id: "u" }); |
||||
|
|
||||
|
// addPosition updates same node
|
||||
|
db.addPosition({ from: 77, data: { lat: 1, lon: 2 } } as any); |
||||
|
expect(db.getNode(77)?.position).toEqual({ lat: 1, lon: 2 }); |
||||
|
expect(db.getNode(77)?.num).toBe(77); |
||||
|
}); |
||||
|
|
||||
|
it("errors map: setNodeError, getNodeError, hasNodeError, clearNodeError", async () => { |
||||
|
const { useNodeDBStore } = await freshStore(); |
||||
|
const db = useNodeDBStore.getState().addNodeDB(1); |
||||
|
|
||||
|
db.setNodeError(10, "BadFoo" as any); |
||||
|
expect(db.hasNodeError(10)).toBe(true); |
||||
|
expect(db.getNodeError(10)).toEqual({ node: 10, error: "BadFoo" }); |
||||
|
|
||||
|
db.clearNodeError(10); |
||||
|
expect(db.hasNodeError(10)).toBe(false); |
||||
|
expect(db.getNodeError(10)).toBeUndefined(); |
||||
|
}); |
||||
|
|
||||
|
it("getMyNode throws before setNodeNum; works after", async () => { |
||||
|
const { useNodeDBStore } = await freshStore(); |
||||
|
const db = useNodeDBStore.getState().addNodeDB(1); |
||||
|
db.addNode(makeNode(123)); |
||||
|
|
||||
|
expect(() => db.getMyNode()).toThrow(); |
||||
|
db.setNodeNum(123); |
||||
|
|
||||
|
const me = db.getMyNode(); |
||||
|
expect(me.num).toBe(123); |
||||
|
}); |
||||
|
|
||||
|
it("setNodeNum merges with existing DB with same myNodeNum", async () => { |
||||
|
const { useNodeDBStore } = await freshStore(); |
||||
|
const st = useNodeDBStore.getState(); |
||||
|
|
||||
|
|
||||
|
const oldDB = st.addNodeDB(10); |
||||
|
oldDB.setNodeNum(999); |
||||
|
oldDB.addNode(makeNode(200)); |
||||
|
oldDB.setNodeError(200, "ERROR" as any); |
||||
|
|
||||
|
const newDB = st.addNodeDB(11); |
||||
|
// newDB currently empty; setting same myNodeNum should copy maps from oldDB and delete old
|
||||
|
newDB.setNodeNum(999); |
||||
|
|
||||
|
expect(st.getNodeDB(10)).toBeUndefined(); |
||||
|
expect(st.getNodeDB(11)).toBeDefined(); |
||||
|
expect(newDB.getNode(200)).toBeTruthy(); |
||||
|
expect(newDB.getNodeError(200)).toEqual({ node: 200, error: "ERROR" }); |
||||
|
}); |
||||
|
|
||||
|
it("setNodeNum does not merge when new DB already has nodes; old is removed", async () => { |
||||
|
const { useNodeDBStore } = await freshStore(); |
||||
|
const st = useNodeDBStore.getState(); |
||||
|
|
||||
|
const oldDB = st.addNodeDB(10); |
||||
|
oldDB.setNodeNum(999); |
||||
|
oldDB.addNode(makeNode(200)); // old has data
|
||||
|
|
||||
|
const newDB = st.addNodeDB(11); |
||||
|
newDB.addNode(makeNode(300)); // new has data -> no merge
|
||||
|
newDB.setNodeNum(999); |
||||
|
|
||||
|
expect(st.getNodeDB(10)).toBeUndefined(); // old removed
|
||||
|
// new kept its own nodes; did not copy old's
|
||||
|
expect(newDB.getNode(300)).toBeTruthy(); |
||||
|
expect(newDB.getNode(200)).toBeUndefined(); |
||||
|
}); |
||||
|
|
||||
|
it("partialize persists only data, and onRehydrateStorage rebuilds methods", async () => { |
||||
|
{ |
||||
|
const { useNodeDBStore } = await freshStore(); |
||||
|
const st = useNodeDBStore.getState(); |
||||
|
const db = st.addNodeDB(123); |
||||
|
db.setNodeNum(321); |
||||
|
db.addNode(makeNode(50)); |
||||
|
db.setNodeError(50, "ERROR" as any); |
||||
|
} |
||||
|
{ |
||||
|
const { useNodeDBStore } = await freshStore(); |
||||
|
const st = useNodeDBStore.getState(); |
||||
|
const db = st.getNodeDB(123)!; |
||||
|
|
||||
|
// methods should work after rehydrate
|
||||
|
expect(db.getNode(50)?.num).toBe(50); |
||||
|
expect(db.getNodeError(50)).toEqual({ node: 50, error: "ERROR" }); |
||||
|
db.addNode(makeNode(51)); |
||||
|
expect(db.getNode(51)).toBeTruthy(); |
||||
|
} |
||||
|
}); |
||||
|
|
||||
|
it("getNodes applies filter and excludes myNodeNum", async () => { |
||||
|
const { useNodeDBStore } = await freshStore(); |
||||
|
const db = useNodeDBStore.getState().addNodeDB(1); |
||||
|
db.setNodeNum(11); |
||||
|
db.addNode(makeNode(10)); |
||||
|
db.addNode(makeNode(11)); |
||||
|
db.addNode(makeNode(12)); |
||||
|
|
||||
|
const all = db.getNodes(); |
||||
|
expect(all.map((n) => n.num).sort()).toEqual([10, 12]); // excludes my (11)
|
||||
|
|
||||
|
const filtered = db.getNodes((n) => n.num > 10); |
||||
|
expect(filtered.map((n) => n.num).sort()).toEqual([12]); // still excludes 11
|
||||
|
}); |
||||
|
|
||||
|
it("when exceeding cap, evicts earliest inserted, not the newly added", async () => { |
||||
|
const { useNodeDBStore } = await freshStore(); |
||||
|
const st = useNodeDBStore.getState(); |
||||
|
for (let i = 1; i <= 10; i++) st.addNodeDB(i); |
||||
|
st.addNodeDB(11); |
||||
|
expect(st.getNodeDB(1)).toBeUndefined(); |
||||
|
expect(st.getNodeDB(11)).toBeDefined(); |
||||
|
}); |
||||
|
|
||||
|
it("removeNodeDB persists removal across reload", async () => { |
||||
|
{ |
||||
|
const { useNodeDBStore } = await freshStore(); |
||||
|
const st = useNodeDBStore.getState(); |
||||
|
st.addNodeDB(99); |
||||
|
expect(st.getNodeDB(99)).toBeDefined(); |
||||
|
st.removeNodeDB(99); |
||||
|
expect(st.getNodeDB(99)).toBeUndefined(); |
||||
|
} |
||||
|
{ |
||||
|
const { useNodeDBStore } = await freshStore(); |
||||
|
const st = useNodeDBStore.getState(); |
||||
|
expect(st.getNodeDB(99)).toBeUndefined(); // still gone
|
||||
|
} |
||||
|
}); |
||||
|
|
||||
|
it("on rehydrate only rebuilds DBs with myNodeNum set (orphans dropped)", async () => { |
||||
|
{ |
||||
|
const { useNodeDBStore } = await freshStore(); |
||||
|
const st = useNodeDBStore.getState(); |
||||
|
|
||||
|
const orphan = st.addNodeDB(500); // no setNodeNum
|
||||
|
orphan.addNode(makeNode(1)); |
||||
|
|
||||
|
const good = st.addNodeDB(501); |
||||
|
good.setNodeNum(42); |
||||
|
good.addNode(makeNode(2)); |
||||
|
} |
||||
|
{ |
||||
|
const { useNodeDBStore } = await freshStore(); |
||||
|
const st = useNodeDBStore.getState(); |
||||
|
expect(st.getNodeDB(500)).toBeUndefined(); // orphan dropped
|
||||
|
expect(st.getNodeDB(501)).toBeDefined(); // kept
|
||||
|
expect(st.getNodeDB(501)!.getNode(2)).toBeTruthy(); |
||||
|
} |
||||
|
}); |
||||
|
|
||||
|
it("methods throw after their DB is removed from the map", async () => { |
||||
|
const { useNodeDBStore } = await freshStore(); |
||||
|
const st = useNodeDBStore.getState(); |
||||
|
const db = st.addNodeDB(800); |
||||
|
|
||||
|
st.removeNodeDB(800); |
||||
|
|
||||
|
expect(() => db.getNodesLength()).toThrow(/No nodeDB found/); |
||||
|
expect(() => db.addNode(makeNode(1))).toThrow(/No nodeDB found/); |
||||
|
}); |
||||
|
}); |
||||
@ -0,0 +1,158 @@ |
|||||
|
import { beforeEach, describe, expect, it, vi } from "vitest"; |
||||
|
import * as idb from "idb-keyval"; |
||||
|
import { createStorage } from "./indexDB"; |
||||
|
|
||||
|
type PersistStorage<T> = ReturnType<typeof createStorage<T>>; |
||||
|
|
||||
|
describe("indexDB.ts persistence (steps 1–5)", () => { |
||||
|
let store: PersistStorage<any>; |
||||
|
|
||||
|
beforeEach(() => { |
||||
|
vi.restoreAllMocks(); |
||||
|
store = createStorage<any>(); |
||||
|
}); |
||||
|
|
||||
|
async function roundTrip(obj: any) { |
||||
|
const setSpy = vi.spyOn(idb, "set").mockResolvedValue(); |
||||
|
const getSpy = vi.spyOn(idb, "get"); |
||||
|
await store.setItem("rt", obj); |
||||
|
const storedStr = (setSpy.mock.calls[0] as any[])[1] as string; |
||||
|
getSpy.mockResolvedValue(storedStr); |
||||
|
return await store.getItem("rt"); |
||||
|
} |
||||
|
|
||||
|
// Basic methods
|
||||
|
it("getItem returns null when idb-keyval.get yields undefined", async () => { |
||||
|
const getSpy = vi.spyOn(idb, "get").mockResolvedValue(undefined); |
||||
|
const res = await store.getItem("missing-key"); |
||||
|
expect(getSpy).toHaveBeenCalledWith("missing-key"); |
||||
|
expect(res).toBeNull(); |
||||
|
}); |
||||
|
|
||||
|
it("setItem writes a string via idb-keyval.set", async () => { |
||||
|
const setSpy = vi.spyOn(idb, "set").mockResolvedValue(); |
||||
|
const payload = { state: { a: 1 }, version: 0 }; |
||||
|
await store.setItem("k1", payload); |
||||
|
expect(setSpy).toHaveBeenCalledTimes(1); |
||||
|
const [, value] = setSpy.mock.calls[0]!; |
||||
|
expect(typeof value).toBe("string"); |
||||
|
// sanity: it should be JSON
|
||||
|
expect(() => JSON.parse(value as string)).not.toThrow(); |
||||
|
}); |
||||
|
|
||||
|
it("removeItem calls idb-keyval.del and getItem returns null afterwards", async () => { |
||||
|
const delSpy = vi.spyOn(idb, "del").mockResolvedValue(); |
||||
|
await store.removeItem("k2"); |
||||
|
expect(delSpy).toHaveBeenCalledWith("k2"); |
||||
|
|
||||
|
const getSpy = vi.spyOn(idb, "get").mockResolvedValue(undefined); |
||||
|
const res = await store.getItem("k2"); |
||||
|
expect(getSpy).toHaveBeenCalledWith("k2"); |
||||
|
expect(res).toBeNull(); |
||||
|
}); |
||||
|
|
||||
|
// Map
|
||||
|
it("round-trips an empty Map", async () => { |
||||
|
const out = await roundTrip({ state: { m: new Map() }, version: 0 }); |
||||
|
expect(out?.state.m instanceof Map).toBe(true); |
||||
|
expect(out?.state.m.size).toBe(0); |
||||
|
}); |
||||
|
|
||||
|
it("round-trips a Map<number,string> and preserves numeric key semantics", async () => { |
||||
|
const m = new Map<number, string>([[1, "a"]]); |
||||
|
const out = await roundTrip({ state: { m }, version: 0 }); |
||||
|
const m2 = out!.state.m as Map<number, string>; |
||||
|
expect(m2 instanceof Map).toBe(true); |
||||
|
expect(m2.get(1)).toBe("a"); |
||||
|
}); |
||||
|
|
||||
|
it("round-trips nested Map inside arrays/objects", async () => { |
||||
|
const payload = { |
||||
|
state: { |
||||
|
list: [new Map<number, number>([[2, 3]])], |
||||
|
obj: { inner: new Map<string, number>([["k", 7]]) }, |
||||
|
}, |
||||
|
version: 0, |
||||
|
}; |
||||
|
const out = await roundTrip(payload); |
||||
|
|
||||
|
expect(out!.state.list[0] instanceof Map).toBe(true); |
||||
|
expect((out!.state.list[0] as Map<number, number>).get(2)).toBe(3); |
||||
|
expect(out!.state.obj.inner instanceof Map).toBe(true); |
||||
|
expect((out!.state.obj.inner as Map<string, number>).get("k")).toBe(7); |
||||
|
}); |
||||
|
|
||||
|
// Uint8Array
|
||||
|
it("round-trips a Uint8Array (simple)", async () => { |
||||
|
const u8 = new Uint8Array([1, 2, 255]); |
||||
|
const out = await roundTrip({ state: { u8 }, version: 0 }); |
||||
|
const u2 = out!.state.u8 as Uint8Array; |
||||
|
expect(u2 instanceof Uint8Array).toBe(true); |
||||
|
expect(Array.from(u2)).toEqual([1, 2, 255]); |
||||
|
}); |
||||
|
|
||||
|
it("round-trips a Uint8Array view with non-zero byteOffset", async () => { |
||||
|
const buf = new Uint8Array([0, 9, 8, 7, 6, 5, 4, 3]).buffer; |
||||
|
const view = new Uint8Array(buf, 2, 4); // [8,7,6,5]
|
||||
|
const out = await roundTrip({ state: { view }, version: 0 }); |
||||
|
const v2 = out!.state.view as Uint8Array; |
||||
|
expect(v2 instanceof Uint8Array).toBe(true); |
||||
|
expect(Array.from(v2)).toEqual([8, 7, 6, 5]); |
||||
|
// ensure it's a standalone buffer now, without offset
|
||||
|
expect(v2.byteOffset).toBe(0); |
||||
|
}); |
||||
|
|
||||
|
// Mixed & nested structures
|
||||
|
it("round-trips Map values containing objects with nested Uint8Array", async () => { |
||||
|
const inner = { key: new Uint8Array([7, 8]) }; |
||||
|
const m = new Map<number, { key: Uint8Array }>([[42, inner]]); |
||||
|
const out = await roundTrip({ state: { m }, version: 0 }); |
||||
|
const m2 = out!.state.m as Map<number, { key: Uint8Array }>; |
||||
|
expect(m2 instanceof Map).toBe(true); |
||||
|
|
||||
|
const got = m2.get(42)!; |
||||
|
expect(got.key instanceof Uint8Array).toBe(true); |
||||
|
expect(Array.from(got.key)).toEqual([7, 8]); |
||||
|
}); |
||||
|
|
||||
|
it("round-trips deep nesting (array → object → map → u8)", async () => { |
||||
|
const payload = { |
||||
|
state: { |
||||
|
arr: [ |
||||
|
{ |
||||
|
obj: { |
||||
|
m: new Map<string, Uint8Array>([["k", new Uint8Array([9])]]), |
||||
|
}, |
||||
|
}, |
||||
|
], |
||||
|
}, |
||||
|
version: 0, |
||||
|
}; |
||||
|
const out = await roundTrip(payload); |
||||
|
const m2 = out!.state.arr[0].obj.m as Map<string, Uint8Array>; |
||||
|
expect(m2 instanceof Map).toBe(true); |
||||
|
const u = m2.get("k")!; |
||||
|
expect(u instanceof Uint8Array).toBe(true); |
||||
|
expect(Array.from(u)).toEqual([9]); |
||||
|
}); |
||||
|
|
||||
|
it("does not alter plain objects/arrays", async () => { |
||||
|
const payload = { state: { a: 1, b: [2, 3], c: { d: 4 } }, version: 0 }; |
||||
|
const out = await roundTrip(payload); |
||||
|
expect(out).toEqual(payload); |
||||
|
}); |
||||
|
|
||||
|
it("revives envelope-looking objects", async () => { |
||||
|
// Current implementation will treat any {__datatype:"Map",value:[...]} as an envelope.
|
||||
|
const forged = JSON.stringify({ |
||||
|
state: { m: { __datatype: "Map", value: [[1, "x"]] } }, |
||||
|
version: 0, |
||||
|
}); |
||||
|
const getSpy = vi.spyOn(idb, "get").mockResolvedValue(forged); |
||||
|
const out = await store.getItem("forged"); |
||||
|
expect(getSpy).toHaveBeenCalled(); |
||||
|
const m2 = out!.state.m as Map<number, string>; |
||||
|
expect(m2 instanceof Map).toBe(true); |
||||
|
expect(m2.get(1)).toBe("x"); |
||||
|
}); |
||||
|
}); |
||||
Loading…
Reference in new issue