109 changed files with 3686 additions and 918 deletions
@ -0,0 +1,229 @@ |
|||||
|
import { describe, vi, expect, it, beforeEach, afterEach, type MockInstance } from "vitest"; |
||||
|
import { runTransportContract } from "../../../tests/utils/transportContract"; |
||||
|
import { TransportHTTP } from "./transport"; |
||||
|
|
||||
|
let abortTimeoutSpy: MockInstance | undefined; |
||||
|
beforeEach(() => { |
||||
|
abortTimeoutSpy = vi.spyOn( |
||||
|
globalThis.AbortSignal as unknown as { timeout(ms: number): AbortSignal }, |
||||
|
"timeout", |
||||
|
).mockImplementation((ms: number) => { |
||||
|
const ctrl = new AbortController(); |
||||
|
const abort = () => |
||||
|
ctrl.abort(new DOMException("Timeout reached", "TimeoutError")); |
||||
|
// Uses setTimeout so vi.useFakeTimers() can fast-forward it
|
||||
|
setTimeout(abort, ms); |
||||
|
return ctrl.signal; |
||||
|
}); |
||||
|
}); |
||||
|
|
||||
|
afterEach(() => { |
||||
|
abortTimeoutSpy?.mockRestore(); |
||||
|
}); |
||||
|
|
||||
|
function stubFetch() { |
||||
|
const inbox: Uint8Array[] = []; |
||||
|
let lastWritten: ArrayBuffer | undefined; |
||||
|
|
||||
|
let forceNextReadToHang = false; |
||||
|
let forceNextReadToReturn500 = false; |
||||
|
|
||||
|
function makeAbortAwareHang(signal?: AbortSignal): Promise<Response> { |
||||
|
return new Promise((_, reject) => { |
||||
|
const abort = () => reject(new DOMException("Aborted", "AbortError")); |
||||
|
if (signal?.aborted) { |
||||
|
abort(); |
||||
|
return; |
||||
|
} |
||||
|
if (signal) { |
||||
|
signal.addEventListener("abort", abort, { once: true }); |
||||
|
} |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
const mockFetch = vi.fn(async (url: string, init?: RequestInit) => { |
||||
|
const method = (init?.method ?? "GET").toUpperCase(); |
||||
|
|
||||
|
if (url.includes("/api/v1/toradio") && method === "OPTIONS") { |
||||
|
return { ok: true, status: 204 } as Response; |
||||
|
} |
||||
|
|
||||
|
if (url.includes("/api/v1/toradio") && method === "PUT") { |
||||
|
lastWritten = init?.body as ArrayBuffer; |
||||
|
return { ok: true, status: 200 } as Response; |
||||
|
} |
||||
|
|
||||
|
if (url.includes("/api/v1/fromradio") && method === "GET") { |
||||
|
if (forceNextReadToHang) { |
||||
|
forceNextReadToHang = false; |
||||
|
return makeAbortAwareHang(init?.signal ?? undefined); |
||||
|
} |
||||
|
|
||||
|
if (forceNextReadToReturn500) { |
||||
|
forceNextReadToReturn500 = false; |
||||
|
return { |
||||
|
ok: false, |
||||
|
status: 500, |
||||
|
arrayBuffer: async () => new ArrayBuffer(0), |
||||
|
} as Response; |
||||
|
} |
||||
|
|
||||
|
const next = inbox.shift() ?? new Uint8Array(); |
||||
|
return { |
||||
|
ok: true, |
||||
|
status: 200, |
||||
|
arrayBuffer: async () => next.buffer, |
||||
|
} as Response; |
||||
|
} |
||||
|
|
||||
|
return { ok: true, status: 200 } as Response; |
||||
|
}); |
||||
|
|
||||
|
vi.stubGlobal("fetch", mockFetch); |
||||
|
|
||||
|
return { |
||||
|
pushIncoming: (u8: Uint8Array) => inbox.push(u8), |
||||
|
assertLastWritten: (u8: Uint8Array) => { |
||||
|
const got = new Uint8Array(lastWritten || new ArrayBuffer(0)); |
||||
|
expect(got).toEqual(u8); |
||||
|
}, |
||||
|
forceReadErrorOnce: () => { |
||||
|
forceNextReadToReturn500 = true; |
||||
|
}, |
||||
|
forceReadTimeoutOnce: () => { |
||||
|
forceNextReadToHang = true; |
||||
|
}, |
||||
|
getMock: () => mockFetch, |
||||
|
cleanup: () => vi.unstubAllGlobals(), |
||||
|
}; |
||||
|
} |
||||
|
|
||||
|
async function tickNextTimer() { |
||||
|
try { |
||||
|
await vi.advanceTimersToNextTimerAsync(); |
||||
|
} catch { |
||||
|
await new Promise((r) => setTimeout(r, 5)); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
describe("TransportHTTP (contract)", () => { |
||||
|
runTransportContract({ |
||||
|
name: "TransportHTTP", |
||||
|
setup: () => { |
||||
|
vi.useFakeTimers(); |
||||
|
}, |
||||
|
teardown: () => { |
||||
|
vi.useRealTimers(); |
||||
|
vi.restoreAllMocks(); |
||||
|
vi.unstubAllGlobals(); |
||||
|
}, |
||||
|
create: async () => { |
||||
|
(globalThis as unknown as { __http: ReturnType<typeof stubFetch> }).__http = stubFetch(); |
||||
|
const transport = await TransportHTTP.create("127.0.0.1:80", false); |
||||
|
await tickNextTimer(); |
||||
|
return transport; |
||||
|
}, |
||||
|
pushIncoming: async (bytes) => { |
||||
|
(globalThis as unknown as { __http: ReturnType<typeof stubFetch> }).__http.pushIncoming(bytes); |
||||
|
await tickNextTimer(); |
||||
|
}, |
||||
|
assertLastWritten: (bytes) => { |
||||
|
(globalThis as unknown as { __http: ReturnType<typeof stubFetch> }).__http.assertLastWritten(bytes); |
||||
|
}, |
||||
|
triggerDisconnect: async () => { |
||||
|
(globalThis as unknown as { __http: ReturnType<typeof stubFetch> }).__http.forceReadErrorOnce(); |
||||
|
await tickNextTimer(); |
||||
|
}, |
||||
|
}); |
||||
|
}); |
||||
|
|
||||
|
describe("TransportHTTP (extras)", () => { |
||||
|
let httpStub: ReturnType<typeof stubFetch> | undefined; |
||||
|
|
||||
|
beforeEach(() => { |
||||
|
vi.useFakeTimers(); |
||||
|
}); |
||||
|
|
||||
|
afterEach(() => { |
||||
|
vi.useRealTimers(); |
||||
|
vi.restoreAllMocks(); |
||||
|
httpStub?.cleanup(); |
||||
|
httpStub = undefined; |
||||
|
}); |
||||
|
|
||||
|
async function createTransport(): Promise<TransportHTTP> { |
||||
|
httpStub = stubFetch(); |
||||
|
const transport = await TransportHTTP.create("127.0.0.1:80", false); |
||||
|
await tickNextTimer(); |
||||
|
return transport; |
||||
|
} |
||||
|
|
||||
|
async function advanceOnePoll() { |
||||
|
await tickNextTimer(); |
||||
|
} |
||||
|
|
||||
|
it("emits DeviceDisconnected with reason 'read-timeout' when GET /fromradio hangs", async () => { |
||||
|
const transport = await createTransport(); |
||||
|
const reader = transport.fromDevice.getReader(); |
||||
|
|
||||
|
httpStub!.forceReadTimeoutOnce(); |
||||
|
|
||||
|
await tickNextTimer(); |
||||
|
await vi.advanceTimersByTimeAsync(8000); |
||||
|
|
||||
|
let sawReadTimeout = false; |
||||
|
for (let i = 0; i < 6; i++) { |
||||
|
const { value } = await reader.read(); |
||||
|
if (value?.type === "status" && value.data.reason === "read-timeout") { |
||||
|
sawReadTimeout = true; |
||||
|
break; |
||||
|
} |
||||
|
} |
||||
|
expect(sawReadTimeout).toBe(true); |
||||
|
|
||||
|
reader.releaseLock(); |
||||
|
await transport.disconnect(); |
||||
|
}); |
||||
|
|
||||
|
it("stops polling after disconnect()", async () => { |
||||
|
const transport = await createTransport(); |
||||
|
|
||||
|
const fetchMock = httpStub!.getMock(); |
||||
|
const callsBeforeDisconnect = fetchMock.mock.calls.length; |
||||
|
|
||||
|
await transport.disconnect(); |
||||
|
|
||||
|
await advanceOnePoll(); |
||||
|
await vi.runOnlyPendingTimersAsync(); |
||||
|
|
||||
|
const callsAfterDisconnect = fetchMock.mock.calls.length; |
||||
|
expect(callsAfterDisconnect).toBe(callsBeforeDisconnect); |
||||
|
}); |
||||
|
|
||||
|
it("emits DeviceDisconnected with reason 'read-timeout' when GET /fromradio hangs", async () => { |
||||
|
const transport = await createTransport(); |
||||
|
const reader = transport.fromDevice.getReader(); |
||||
|
|
||||
|
httpStub!.forceReadTimeoutOnce(); |
||||
|
|
||||
|
await vi.advanceTimersToNextTimerAsync(); |
||||
|
|
||||
|
await vi.advanceTimersByTimeAsync(8000); |
||||
|
|
||||
|
await Promise.resolve(); |
||||
|
await Promise.resolve(); |
||||
|
|
||||
|
let sawReadTimeout = false; |
||||
|
for (let i = 0; i < 6; i++) { |
||||
|
const { value } = await reader.read(); |
||||
|
if (value?.type === "status" && value.data.reason === "read-timeout") { |
||||
|
sawReadTimeout = true; |
||||
|
break; |
||||
|
} |
||||
|
} |
||||
|
expect(sawReadTimeout).toBe(true); |
||||
|
|
||||
|
reader.releaseLock(); |
||||
|
await transport.disconnect(); |
||||
|
}); |
||||
|
}); |
||||
@ -0,0 +1,187 @@ |
|||||
|
import { describe, vi, expect, beforeEach, afterEach, it } from "vitest"; |
||||
|
import { Duplex } from "node:stream"; |
||||
|
import type { SerialPort } from "serialport"; |
||||
|
import { Types, Utils } from "@meshtastic/core"; |
||||
|
import { runTransportContract } from "../../../tests/utils/transportContract"; |
||||
|
import { TransportNodeSerial } from "./transport"; |
||||
|
|
||||
|
function isStatusEvent( |
||||
|
output: Types.DeviceOutput | undefined, |
||||
|
): output is Extract<Types.DeviceOutput, { type: "status" }> { |
||||
|
return output !== undefined && output.type === "status"; |
||||
|
} |
||||
|
|
||||
|
class FakeSerialPort extends Duplex { |
||||
|
public lastWritten: Uint8Array | undefined; |
||||
|
|
||||
|
constructor() { |
||||
|
super({ objectMode: false }); |
||||
|
} |
||||
|
|
||||
|
_read() {} |
||||
|
|
||||
|
_write( |
||||
|
chunk: Buffer, |
||||
|
_encoding: BufferEncoding, |
||||
|
callback: (error?: Error | null) => void, |
||||
|
) { |
||||
|
this.lastWritten = new Uint8Array( |
||||
|
chunk.buffer, |
||||
|
chunk.byteOffset, |
||||
|
chunk.byteLength, |
||||
|
); |
||||
|
callback(); |
||||
|
} |
||||
|
|
||||
|
pushIncoming(data: Uint8Array) { |
||||
|
const buf = Buffer.from(data.buffer, data.byteOffset, data.byteLength); |
||||
|
this.push(buf); |
||||
|
} |
||||
|
|
||||
|
emitErrorOnce(message = "simulated serial error") { |
||||
|
this.emit("error", new Error(message)); |
||||
|
} |
||||
|
|
||||
|
emitClose() { |
||||
|
this.emit("close"); |
||||
|
} |
||||
|
|
||||
|
close() { |
||||
|
this.destroy(); |
||||
|
this.emit("close"); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
function stubCoreTransforms() { |
||||
|
const toDevice = new TransformStream<Uint8Array, Uint8Array>({ |
||||
|
transform(chunk, controller) { |
||||
|
controller.enqueue(chunk); |
||||
|
}, |
||||
|
}); |
||||
|
|
||||
|
const fromDeviceFactory = () => |
||||
|
new TransformStream<Uint8Array, Types.DeviceOutput>({ |
||||
|
transform(chunk, controller) { |
||||
|
controller.enqueue({ type: "packet", data: chunk }); |
||||
|
}, |
||||
|
}); |
||||
|
|
||||
|
// Utils.toDeviceStream is a getter
|
||||
|
vi.spyOn(Utils, "toDeviceStream", "get").mockReturnValue( |
||||
|
toDevice as unknown as typeof Utils.toDeviceStream, |
||||
|
); |
||||
|
|
||||
|
vi.spyOn(Utils, "fromDeviceStream").mockImplementation( |
||||
|
() => |
||||
|
fromDeviceFactory() as unknown as TransformStream< |
||||
|
Uint8Array, |
||||
|
Types.DeviceOutput |
||||
|
>, |
||||
|
); |
||||
|
|
||||
|
return { |
||||
|
restore: () => vi.restoreAllMocks(), |
||||
|
}; |
||||
|
} |
||||
|
|
||||
|
describe("TransportNodeSerial (contract)", () => { |
||||
|
let transformsStub: { restore: () => void } | undefined; |
||||
|
|
||||
|
beforeEach(() => { |
||||
|
transformsStub = stubCoreTransforms(); |
||||
|
}); |
||||
|
|
||||
|
afterEach(() => { |
||||
|
transformsStub?.restore(); |
||||
|
}); |
||||
|
|
||||
|
runTransportContract({ |
||||
|
name: "TransportNodeSerial", |
||||
|
setup: () => {}, |
||||
|
teardown: () => { |
||||
|
vi.restoreAllMocks(); |
||||
|
}, |
||||
|
create: async () => { |
||||
|
const fakePort = new FakeSerialPort(); |
||||
|
const transport = new TransportNodeSerial( |
||||
|
fakePort as unknown as SerialPort, |
||||
|
); |
||||
|
await Promise.resolve(); |
||||
|
(globalThis as unknown as { __fakePort: FakeSerialPort }).__fakePort = |
||||
|
fakePort; |
||||
|
return transport; |
||||
|
}, |
||||
|
pushIncoming: async (bytes) => { |
||||
|
(globalThis as unknown as { __fakePort: FakeSerialPort }).__fakePort.pushIncoming( |
||||
|
bytes, |
||||
|
); |
||||
|
await Promise.resolve(); |
||||
|
}, |
||||
|
assertLastWritten: (bytes) => { |
||||
|
const port = |
||||
|
(globalThis as unknown as { __fakePort: FakeSerialPort }).__fakePort; |
||||
|
expect(port.lastWritten).toBeDefined(); |
||||
|
expect(port.lastWritten).toEqual(bytes); |
||||
|
}, |
||||
|
triggerDisconnect: async () => { |
||||
|
(globalThis as unknown as { __fakePort: FakeSerialPort }).__fakePort.emitErrorOnce( |
||||
|
"test-disconnect", |
||||
|
); |
||||
|
await Promise.resolve(); |
||||
|
}, |
||||
|
}); |
||||
|
}); |
||||
|
|
||||
|
describe("TransportNodeSerial (extras)", () => { |
||||
|
let transformsStub: { restore: () => void } | undefined; |
||||
|
|
||||
|
beforeEach(() => { |
||||
|
transformsStub = stubCoreTransforms(); |
||||
|
}); |
||||
|
|
||||
|
afterEach(() => { |
||||
|
transformsStub?.restore(); |
||||
|
}); |
||||
|
|
||||
|
it("emits DeviceDisconnected with reason 'port-closed' on close event", async () => { |
||||
|
const fakePort = new FakeSerialPort(); |
||||
|
const transport = new TransportNodeSerial( |
||||
|
fakePort as unknown as SerialPort, |
||||
|
); |
||||
|
const reader = transport.fromDevice.getReader(); |
||||
|
|
||||
|
await Promise.resolve(); |
||||
|
|
||||
|
const first = await reader.read(); |
||||
|
expect(isStatusEvent(first.value)).toBe(true); |
||||
|
if (isStatusEvent(first.value)) { |
||||
|
expect(first.value.data.status).toBe( |
||||
|
Types.DeviceStatusEnum.DeviceConnecting, |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
const second = await reader.read(); |
||||
|
expect(isStatusEvent(second.value)).toBe(true); |
||||
|
if (isStatusEvent(second.value)) { |
||||
|
expect(second.value.data.status).toBe( |
||||
|
Types.DeviceStatusEnum.DeviceConnected, |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
fakePort.emitClose(); |
||||
|
await Promise.resolve(); |
||||
|
|
||||
|
let sawClosed = false; |
||||
|
for (let i = 0; i < 6; i++) { |
||||
|
const { value } = await reader.read(); |
||||
|
if (isStatusEvent(value) && value.data.reason === "port-closed") { |
||||
|
sawClosed = true; |
||||
|
break; |
||||
|
} |
||||
|
} |
||||
|
expect(sawClosed).toBe(true); |
||||
|
|
||||
|
reader.releaseLock(); |
||||
|
await transport.disconnect(); |
||||
|
}); |
||||
|
}); |
||||
@ -0,0 +1,186 @@ |
|||||
|
import { describe, vi, expect, beforeEach, afterEach, it } from "vitest"; |
||||
|
import { Duplex } from "node:stream"; |
||||
|
import type { Socket } from "node:net"; |
||||
|
import { runTransportContract } from "../../../tests/utils/transportContract"; |
||||
|
import { TransportNode } from "./transport"; |
||||
|
import { Utils, Types } from "@meshtastic/core"; |
||||
|
|
||||
|
function isStatusEvent( |
||||
|
out: Types.DeviceOutput | undefined, |
||||
|
): out is Extract<Types.DeviceOutput, { type: "status" }> { |
||||
|
return !!out && (out as any).type === "status"; |
||||
|
} |
||||
|
|
||||
|
class FakeSocket extends Duplex { |
||||
|
public lastWritten: Uint8Array | undefined; |
||||
|
|
||||
|
constructor() { |
||||
|
super({ objectMode: false }); |
||||
|
} |
||||
|
|
||||
|
_read() {} |
||||
|
|
||||
|
_write( |
||||
|
chunk: Buffer, |
||||
|
_encoding: BufferEncoding, |
||||
|
callback: (error?: Error | null) => void, |
||||
|
) { |
||||
|
this.lastWritten = new Uint8Array( |
||||
|
chunk.buffer, |
||||
|
chunk.byteOffset, |
||||
|
chunk.byteLength, |
||||
|
); |
||||
|
callback(); |
||||
|
} |
||||
|
|
||||
|
pushIncoming(data: Uint8Array) { |
||||
|
const buf = Buffer.from(data.buffer, data.byteOffset, data.byteLength); |
||||
|
this.push(buf); |
||||
|
} |
||||
|
|
||||
|
emitErrorOnce(message = "simulated error") { |
||||
|
this.emit("error", new Error(message)); |
||||
|
} |
||||
|
|
||||
|
emitClose() { |
||||
|
this.emit("close"); |
||||
|
} |
||||
|
|
||||
|
override destroy(error?: Error) { |
||||
|
super.destroy(error); |
||||
|
this.emit("close"); |
||||
|
return this; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
function stubCoreTransforms() { |
||||
|
const toDevice = new TransformStream<Uint8Array, Uint8Array>({ |
||||
|
transform(chunk, controller) { |
||||
|
controller.enqueue(chunk); |
||||
|
}, |
||||
|
}); |
||||
|
|
||||
|
const fromDeviceFactory = () => |
||||
|
new TransformStream<Uint8Array, Types.DeviceOutput>({ |
||||
|
transform(chunk, controller) { |
||||
|
controller.enqueue({ type: "packet", data: chunk }); |
||||
|
}, |
||||
|
}); |
||||
|
|
||||
|
vi.spyOn(Utils, "toDeviceStream", "get").mockReturnValue( |
||||
|
toDevice as unknown as typeof Utils.toDeviceStream, |
||||
|
); |
||||
|
|
||||
|
vi |
||||
|
.spyOn(Utils, "fromDeviceStream") |
||||
|
.mockImplementation( |
||||
|
() => |
||||
|
fromDeviceFactory() as unknown as TransformStream< |
||||
|
Uint8Array, |
||||
|
Types.DeviceOutput |
||||
|
>, |
||||
|
); |
||||
|
|
||||
|
return { |
||||
|
restore: () => vi.restoreAllMocks(), |
||||
|
}; |
||||
|
} |
||||
|
|
||||
|
describe("TransportNode (contract)", () => { |
||||
|
let transformsStub: { restore: () => void } | undefined; |
||||
|
|
||||
|
beforeEach(() => { |
||||
|
transformsStub = stubCoreTransforms(); |
||||
|
}); |
||||
|
|
||||
|
afterEach(() => { |
||||
|
transformsStub?.restore(); |
||||
|
}); |
||||
|
|
||||
|
runTransportContract({ |
||||
|
name: "TransportNode", |
||||
|
setup: () => {}, |
||||
|
teardown: () => { |
||||
|
vi.restoreAllMocks(); |
||||
|
}, |
||||
|
create: async () => { |
||||
|
const fakeSocket = new FakeSocket(); |
||||
|
const transport = new TransportNode(fakeSocket as unknown as Socket); |
||||
|
await Promise.resolve(); |
||||
|
(globalThis as unknown as { __nodeSock: FakeSocket }).__nodeSock = |
||||
|
fakeSocket; |
||||
|
return transport; |
||||
|
}, |
||||
|
pushIncoming: async (bytes) => { |
||||
|
(globalThis as unknown as { __nodeSock: FakeSocket }).__nodeSock.pushIncoming( |
||||
|
bytes, |
||||
|
); |
||||
|
await Promise.resolve(); |
||||
|
}, |
||||
|
assertLastWritten: (bytes) => { |
||||
|
const sock = (globalThis as unknown as { __nodeSock: FakeSocket }) |
||||
|
.__nodeSock; |
||||
|
expect(sock.lastWritten).toBeDefined(); |
||||
|
expect(sock.lastWritten).toEqual(bytes); |
||||
|
}, |
||||
|
triggerDisconnect: async () => { |
||||
|
(globalThis as unknown as { __nodeSock: FakeSocket }).__nodeSock.emitErrorOnce( |
||||
|
"test-disconnect", |
||||
|
); |
||||
|
await Promise.resolve(); |
||||
|
}, |
||||
|
}); |
||||
|
}); |
||||
|
|
||||
|
describe("TransportNode (extras)", () => { |
||||
|
let transformsStub: { restore: () => void } | undefined; |
||||
|
|
||||
|
beforeEach(() => { |
||||
|
transformsStub = stubCoreTransforms(); |
||||
|
}); |
||||
|
|
||||
|
afterEach(() => { |
||||
|
transformsStub?.restore(); |
||||
|
}); |
||||
|
|
||||
|
it("emits DeviceDisconnected with reason 'socket-closed' on close event", async () => { |
||||
|
const fakeSocket = new FakeSocket(); |
||||
|
const transport = new TransportNode(fakeSocket as unknown as Socket); |
||||
|
const reader = transport.fromDevice.getReader(); |
||||
|
|
||||
|
await Promise.resolve(); |
||||
|
|
||||
|
const first = await reader.read(); |
||||
|
expect(isStatusEvent(first.value)).toBe(true); |
||||
|
if (isStatusEvent(first.value)) { |
||||
|
expect(first.value.data.status).toBe( |
||||
|
Types.DeviceStatusEnum.DeviceConnecting, |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
const second = await reader.read(); |
||||
|
expect(isStatusEvent(second.value)).toBe(true); |
||||
|
if (isStatusEvent(second.value)) { |
||||
|
expect(second.value.data.status).toBe( |
||||
|
Types.DeviceStatusEnum.DeviceConnected, |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
fakeSocket.emitClose(); |
||||
|
await Promise.resolve(); |
||||
|
|
||||
|
let sawClosed = false; |
||||
|
for (let i = 0; i < 6; i++) { |
||||
|
const { value } = await reader.read(); |
||||
|
if (isStatusEvent(value) && value.data.reason === "socket-closed") { |
||||
|
sawClosed = true; |
||||
|
break; |
||||
|
} |
||||
|
} |
||||
|
expect(sawClosed).toBe(true); |
||||
|
|
||||
|
reader.releaseLock(); |
||||
|
await transport.disconnect(); |
||||
|
}); |
||||
|
|
||||
|
}); |
||||
@ -0,0 +1,166 @@ |
|||||
|
import { describe, vi, expect, beforeEach, afterEach } from "vitest"; |
||||
|
import { runTransportContract } from "../../../tests/utils/transportContract"; |
||||
|
import { TransportWebBluetooth } from "./transport"; |
||||
|
|
||||
|
class MiniEmitter { |
||||
|
private listeners = new Map<string, Set<(e: Event) => void>>(); |
||||
|
addEventListener(type: string, listener: (e: Event) => void) { |
||||
|
if (!this.listeners.has(type)) this.listeners.set(type, new Set()); |
||||
|
this.listeners.get(type)!.add(listener); |
||||
|
} |
||||
|
removeEventListener(type: string, listener: (e: Event) => void) { |
||||
|
this.listeners.get(type)?.delete(listener); |
||||
|
} |
||||
|
dispatchEvent(event: Event) { |
||||
|
this.listeners.get(event.type)?.forEach((l) => l(event)); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
function stubWebBluetooth() { |
||||
|
const incomingQueue: Uint8Array[] = []; |
||||
|
let lastWritten: Uint8Array | undefined; |
||||
|
|
||||
|
// fromRadioCharacteristic: read bytes from queue, one buffer per read
|
||||
|
const fromRadioCharacteristic: BluetoothRemoteGATTCharacteristic = { |
||||
|
async readValue() { |
||||
|
const next = incomingQueue.shift() ?? new Uint8Array(); |
||||
|
return new DataView( |
||||
|
next.buffer, |
||||
|
next.byteOffset, |
||||
|
next.byteLength, |
||||
|
) as unknown as DataView; |
||||
|
}, |
||||
|
addEventListener() {}, |
||||
|
removeEventListener() {}, |
||||
|
} as unknown as BluetoothRemoteGATTCharacteristic; |
||||
|
|
||||
|
// characteristicvaluechanged event plumbing (fromNumCharacteristic)
|
||||
|
const charEmitter = new MiniEmitter(); |
||||
|
|
||||
|
const fromNumCharacteristic: BluetoothRemoteGATTCharacteristic = { |
||||
|
async startNotifications() { |
||||
|
return this; |
||||
|
}, |
||||
|
addEventListener(type: string, listener: (e: Event) => void) { |
||||
|
charEmitter.addEventListener(type, listener); |
||||
|
}, |
||||
|
removeEventListener(type: string, listener: (e: Event) => void) { |
||||
|
charEmitter.removeEventListener(type, listener); |
||||
|
}, |
||||
|
} as unknown as BluetoothRemoteGATTCharacteristic; |
||||
|
|
||||
|
const toRadioCharacteristic: BluetoothRemoteGATTCharacteristic = { |
||||
|
async writeValue(bufferSource: BufferSource) { |
||||
|
const u8 = |
||||
|
bufferSource instanceof ArrayBuffer |
||||
|
? new Uint8Array(bufferSource) |
||||
|
: new Uint8Array( |
||||
|
bufferSource.buffer, |
||||
|
bufferSource.byteOffset, |
||||
|
bufferSource.byteLength, |
||||
|
); |
||||
|
lastWritten = new Uint8Array(u8); |
||||
|
}, |
||||
|
} as unknown as BluetoothRemoteGATTCharacteristic; |
||||
|
|
||||
|
// Primary service returns our three characteristics by UUID
|
||||
|
const primaryService: BluetoothRemoteGATTService = { |
||||
|
async getCharacteristic(uuid: string) { |
||||
|
if (uuid === TransportWebBluetooth.ToRadioUuid) return toRadioCharacteristic; |
||||
|
if (uuid === TransportWebBluetooth.FromRadioUuid) return fromRadioCharacteristic; |
||||
|
if (uuid === TransportWebBluetooth.FromNumUuid) return fromNumCharacteristic; |
||||
|
throw new Error("Unknown characteristic: " + uuid); |
||||
|
}, |
||||
|
} as unknown as BluetoothRemoteGATTService; |
||||
|
|
||||
|
// Device-level emitter to deliver gattserverdisconnected
|
||||
|
const deviceEmitter = new MiniEmitter(); |
||||
|
|
||||
|
// GATT server with readonly connected
|
||||
|
let isConnected = true; |
||||
|
const gattServer: BluetoothRemoteGATTServer = { |
||||
|
get connected() { |
||||
|
return isConnected; |
||||
|
}, |
||||
|
async connect() { |
||||
|
isConnected = true; |
||||
|
return gattServer; |
||||
|
}, |
||||
|
disconnect() { |
||||
|
isConnected = false; |
||||
|
deviceEmitter.dispatchEvent(new Event("gattserverdisconnected")); |
||||
|
}, |
||||
|
async getPrimaryService() { |
||||
|
return primaryService; |
||||
|
}, |
||||
|
device: { |
||||
|
addEventListener: (...args: Parameters<EventTarget["addEventListener"]>) => |
||||
|
deviceEmitter.addEventListener(args[0] as string, args[1] as (e: Event) => void), |
||||
|
removeEventListener: (...args: Parameters<EventTarget["removeEventListener"]>) => |
||||
|
deviceEmitter.removeEventListener(args[0] as string, args[1] as (e: Event) => void), |
||||
|
} as unknown as BluetoothDevice, |
||||
|
} as unknown as BluetoothRemoteGATTServer; |
||||
|
|
||||
|
const fakeDevice: BluetoothDevice = { |
||||
|
async watchAdvertisements() {}, |
||||
|
gatt: gattServer, |
||||
|
} as unknown as BluetoothDevice; |
||||
|
|
||||
|
const fakeNavigator = { |
||||
|
bluetooth: { |
||||
|
async requestDevice() { |
||||
|
return fakeDevice; |
||||
|
}, |
||||
|
}, |
||||
|
}; |
||||
|
|
||||
|
vi.stubGlobal("navigator", Object.assign({}, globalThis.navigator, fakeNavigator)); |
||||
|
|
||||
|
// helper actions for tests/contract
|
||||
|
return { |
||||
|
pushIncoming: (u8: Uint8Array) => { |
||||
|
incomingQueue.push(u8); |
||||
|
charEmitter.dispatchEvent(new Event("characteristicvaluechanged")); |
||||
|
}, |
||||
|
assertLastWritten: (u8: Uint8Array) => { |
||||
|
expect(lastWritten).toBeDefined(); |
||||
|
expect(lastWritten).toEqual(u8); |
||||
|
}, |
||||
|
// simulate underlying link drop (OS-level disconnect)
|
||||
|
triggerGattDisconnect: () => { |
||||
|
isConnected = false; |
||||
|
deviceEmitter.dispatchEvent(new Event("gattserverdisconnected")); |
||||
|
}, |
||||
|
cleanup: () => { |
||||
|
vi.unstubAllGlobals(); |
||||
|
}, |
||||
|
}; |
||||
|
} |
||||
|
|
||||
|
describe("TransportWebBluetooth (contract)", () => { |
||||
|
runTransportContract({ |
||||
|
name: "TransportWebBluetooth", |
||||
|
setup: () => {}, |
||||
|
teardown: () => { |
||||
|
(globalThis as unknown as { __ble?: ReturnType<typeof stubWebBluetooth> }).__ble?.cleanup(); |
||||
|
(globalThis as unknown as { __ble?: ReturnType<typeof stubWebBluetooth> }).__ble = undefined; |
||||
|
vi.restoreAllMocks(); |
||||
|
vi.unstubAllGlobals(); |
||||
|
}, |
||||
|
create: async () => { |
||||
|
(globalThis as unknown as { __ble: ReturnType<typeof stubWebBluetooth> }).__ble = stubWebBluetooth(); |
||||
|
return await TransportWebBluetooth.create(); |
||||
|
}, |
||||
|
pushIncoming: async (bytes) => { |
||||
|
(globalThis as unknown as { __ble: ReturnType<typeof stubWebBluetooth> }).__ble.pushIncoming(bytes); |
||||
|
await Promise.resolve(); |
||||
|
}, |
||||
|
assertLastWritten: (bytes) => { |
||||
|
(globalThis as unknown as { __ble: ReturnType<typeof stubWebBluetooth> }).__ble.assertLastWritten(bytes); |
||||
|
}, |
||||
|
triggerDisconnect: async () => { |
||||
|
(globalThis as unknown as { __ble: ReturnType<typeof stubWebBluetooth> }).__ble.triggerGattDisconnect(); |
||||
|
await Promise.resolve(); |
||||
|
}, |
||||
|
}); |
||||
|
}); |
||||
@ -0,0 +1,225 @@ |
|||||
|
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; |
||||
|
import { TransportWebSerial } from "./transport"; |
||||
|
import { Types, Utils } from "@meshtastic/core"; |
||||
|
import { runTransportContract } from "../../../tests/utils/transportContract"; |
||||
|
|
||||
|
function stubCoreTransforms() { |
||||
|
const toDevice = new TransformStream<Uint8Array, Uint8Array>({ |
||||
|
transform(chunk, controller) { |
||||
|
controller.enqueue(chunk); |
||||
|
}, |
||||
|
}); |
||||
|
|
||||
|
// maps raw bytes -> DeviceOutput.packet
|
||||
|
const fromDeviceFactory = () => |
||||
|
new TransformStream<Uint8Array, Types.DeviceOutput>({ |
||||
|
transform(chunk, controller) { |
||||
|
controller.enqueue({ type: "packet", data: chunk }); |
||||
|
}, |
||||
|
}); |
||||
|
|
||||
|
const restoreTo = vi |
||||
|
.spyOn(Utils, "toDeviceStream", "get") |
||||
|
.mockReturnValue(toDevice as unknown as typeof Utils.toDeviceStream); |
||||
|
|
||||
|
const restoreFrom = vi |
||||
|
.spyOn(Utils, "fromDeviceStream") |
||||
|
.mockImplementation( |
||||
|
() => |
||||
|
fromDeviceFactory() as unknown as TransformStream< |
||||
|
Uint8Array, |
||||
|
Types.DeviceOutput |
||||
|
>, |
||||
|
); |
||||
|
|
||||
|
return { |
||||
|
restore: () => { |
||||
|
restoreTo.mockRestore(); |
||||
|
restoreFrom.mockRestore(); |
||||
|
}, |
||||
|
}; |
||||
|
} |
||||
|
|
||||
|
function stubNavigatorSerial() { |
||||
|
type SerialDisconnectHandler = (ev: { port?: any }) => void; |
||||
|
const handlers = new Set<SerialDisconnectHandler>(); |
||||
|
|
||||
|
const serialStub = { |
||||
|
addEventListener: (type: string, handler: EventListenerOrEventListenerObject) => { |
||||
|
if (type === "disconnect") handlers.add(handler as any as SerialDisconnectHandler); |
||||
|
}, |
||||
|
removeEventListener: (type: string, handler: EventListenerOrEventListenerObject) => { |
||||
|
if (type === "disconnect") handlers.delete(handler as any as SerialDisconnectHandler); |
||||
|
}, |
||||
|
dispatchDisconnect(port: any) { |
||||
|
for (const h of handlers) h({ port }); |
||||
|
}, |
||||
|
requestPort: vi.fn(async () => new FakeSerialPort()), |
||||
|
}; |
||||
|
|
||||
|
const nav: any = (globalThis as any).navigator ?? {}; |
||||
|
const hadNavigator = !!(globalThis as any).navigator; |
||||
|
const originalSerial = nav.serial; |
||||
|
|
||||
|
if (!hadNavigator) { |
||||
|
Object.defineProperty(globalThis as any, "navigator", { |
||||
|
value: nav, |
||||
|
configurable: true, |
||||
|
writable: false, |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
Object.defineProperty(nav, "serial", { |
||||
|
value: serialStub, |
||||
|
configurable: true, |
||||
|
enumerable: true, |
||||
|
writable: true, |
||||
|
}); |
||||
|
|
||||
|
return { |
||||
|
serialStub, |
||||
|
restore: () => { |
||||
|
if (hadNavigator) { |
||||
|
if (originalSerial === undefined) { |
||||
|
delete (globalThis as any).navigator.serial; |
||||
|
} else { |
||||
|
Object.defineProperty((globalThis as any).navigator, "serial", { |
||||
|
value: originalSerial, |
||||
|
configurable: true, |
||||
|
enumerable: true, |
||||
|
writable: true, |
||||
|
}); |
||||
|
} |
||||
|
} else { |
||||
|
delete (globalThis as any).navigator; |
||||
|
} |
||||
|
}, |
||||
|
}; |
||||
|
} |
||||
|
|
||||
|
class FakeSerialPort { |
||||
|
readable: ReadableStream<Uint8Array>; |
||||
|
writable: WritableStream<Uint8Array>; |
||||
|
lastWritten?: Uint8Array; |
||||
|
|
||||
|
private _readController!: ReadableStreamDefaultController<Uint8Array>; |
||||
|
|
||||
|
constructor() { |
||||
|
this.readable = new ReadableStream<Uint8Array>({ |
||||
|
start: (controller) => { |
||||
|
this._readController = controller; |
||||
|
}, |
||||
|
}); |
||||
|
|
||||
|
this.writable = new WritableStream<Uint8Array>({ |
||||
|
write: async (chunk) => { |
||||
|
this.lastWritten = chunk; |
||||
|
}, |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
open(_options?: { baudRate?: number }): Promise<void> { |
||||
|
return Promise.resolve(); |
||||
|
} |
||||
|
|
||||
|
close(): Promise<void> { |
||||
|
try { |
||||
|
this._readController.close(); |
||||
|
} catch {} |
||||
|
return Promise.resolve(); |
||||
|
} |
||||
|
|
||||
|
pushIncoming(bytes: Uint8Array) { |
||||
|
this._readController.enqueue(bytes); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
describe("TransportWebSerial (contract)", () => { |
||||
|
let transforms: { restore(): void } | undefined; |
||||
|
let navSerial: { serialStub: any; restore(): void } | undefined; |
||||
|
|
||||
|
beforeEach(() => { |
||||
|
transforms = stubCoreTransforms(); |
||||
|
navSerial = stubNavigatorSerial(); |
||||
|
}); |
||||
|
|
||||
|
afterEach(() => { |
||||
|
transforms?.restore(); |
||||
|
navSerial?.restore(); |
||||
|
vi.restoreAllMocks(); |
||||
|
}); |
||||
|
|
||||
|
runTransportContract({ |
||||
|
name: "TransportWebSerial", |
||||
|
setup: () => {}, |
||||
|
teardown: () => {}, |
||||
|
create: async () => { |
||||
|
const fake = new FakeSerialPort(); |
||||
|
const transport = await TransportWebSerial.createFromPort(fake as any); |
||||
|
(globalThis as any).__ws = { fake, serial: navSerial!.serialStub }; |
||||
|
await Promise.resolve(); |
||||
|
return transport; |
||||
|
}, |
||||
|
pushIncoming: async (bytes) => { |
||||
|
(globalThis as any).__ws.fake.pushIncoming(bytes); |
||||
|
await Promise.resolve(); |
||||
|
}, |
||||
|
assertLastWritten: (bytes) => { |
||||
|
expect((globalThis as any).__ws.fake.lastWritten).toEqual(bytes); |
||||
|
}, |
||||
|
triggerDisconnect: async () => { |
||||
|
(globalThis as any).__ws.serial.dispatchDisconnect( |
||||
|
(globalThis as any).__ws.fake, |
||||
|
); |
||||
|
await Promise.resolve(); |
||||
|
}, |
||||
|
}); |
||||
|
}); |
||||
|
|
||||
|
describe("TransportWebSerial (extras)", () => { |
||||
|
let transforms: { restore(): void } | undefined; |
||||
|
let navSerial: { serialStub: any; restore(): void } | undefined; |
||||
|
|
||||
|
beforeEach(() => { |
||||
|
transforms = stubCoreTransforms(); |
||||
|
navSerial = stubNavigatorSerial(); |
||||
|
}); |
||||
|
|
||||
|
afterEach(() => { |
||||
|
transforms?.restore(); |
||||
|
navSerial?.restore(); |
||||
|
vi.restoreAllMocks(); |
||||
|
}); |
||||
|
|
||||
|
it("emits DeviceDisconnected('serial-disconnected') on OS disconnect event", async () => { |
||||
|
const fake = new FakeSerialPort(); |
||||
|
const transport = await TransportWebSerial.createFromPort(fake as any); |
||||
|
(globalThis as any).__ws = { fake, serial: navSerial!.serialStub }; |
||||
|
|
||||
|
const reader = transport.fromDevice.getReader(); |
||||
|
|
||||
|
// drain statuses until connected
|
||||
|
for (let i = 0; i < 3; i++) { |
||||
|
const { value } = await reader.read(); |
||||
|
if (!value || value.type !== "status") break; |
||||
|
if (value.data.status === Types.DeviceStatusEnum.DeviceConnected) break; |
||||
|
} |
||||
|
|
||||
|
// fire OS-level disconnect
|
||||
|
navSerial!.serialStub.dispatchDisconnect(fake as any); |
||||
|
await Promise.resolve(); |
||||
|
|
||||
|
let saw = false; |
||||
|
for (let i = 0; i < 6; i++) { |
||||
|
const { value } = await reader.read(); |
||||
|
if (value?.type === "status" && value.data.reason === "serial-disconnected") { |
||||
|
saw = true; |
||||
|
break; |
||||
|
} |
||||
|
} |
||||
|
expect(saw).toBe(true); |
||||
|
|
||||
|
reader.releaseLock(); |
||||
|
await transport.disconnect(); |
||||
|
}); |
||||
|
}); |
||||
@ -1,13 +1,15 @@ |
|||||
import { type Device, DeviceContext } from "@core/stores"; |
import { CurrentDeviceContext } from "@core/stores"; |
||||
import type { ReactNode } from "react"; |
import type { ReactNode } from "react"; |
||||
|
|
||||
export interface DeviceWrapperProps { |
export interface DeviceWrapperProps { |
||||
children: ReactNode; |
children: ReactNode; |
||||
device?: Device; |
deviceId: number; |
||||
} |
} |
||||
|
|
||||
export const DeviceWrapper = ({ children, device }: DeviceWrapperProps) => { |
export const DeviceWrapper = ({ children, deviceId }: DeviceWrapperProps) => { |
||||
return ( |
return ( |
||||
<DeviceContext.Provider value={device}>{children}</DeviceContext.Provider> |
<CurrentDeviceContext.Provider value={{ deviceId }}> |
||||
|
{children} |
||||
|
</CurrentDeviceContext.Provider> |
||||
); |
); |
||||
}; |
}; |
||||
|
|||||
@ -1,22 +1,45 @@ |
|||||
export { useAppStore } from "@core/stores/appStore"; |
import { useDeviceContext } from "@app/core/stores/utils/useDeviceContext"; |
||||
|
import { type Device, useDeviceStore } from "@core/stores/deviceStore"; |
||||
|
import { type NodeDB, useNodeDBStore } from "@core/stores/nodeDBStore"; |
||||
|
|
||||
|
export { |
||||
|
CurrentDeviceContext, |
||||
|
type DeviceContext, |
||||
|
useDeviceContext, |
||||
|
} from "@app/core/stores/utils/useDeviceContext"; |
||||
|
export { useAppStore } from "@core/stores/appStore"; |
||||
export { |
export { |
||||
type Device, |
type Device, |
||||
DeviceContext, |
type Page, |
||||
useDevice, |
|
||||
useDeviceStore, |
useDeviceStore, |
||||
type ValidConfigType, |
type ValidConfigType, |
||||
type ValidModuleConfigType, |
type ValidModuleConfigType, |
||||
} from "@core/stores/deviceStore"; |
} from "@core/stores/deviceStore"; |
||||
|
|
||||
export { |
export { |
||||
MessageState, |
MessageState, |
||||
type MessageStore, |
type MessageStore, |
||||
MessageType, |
MessageType, |
||||
useMessageStore, |
useMessageStore, // TODO: Bring hook into this file
|
||||
} from "@core/stores/messageStore"; |
} from "@core/stores/messageStore"; |
||||
|
export { type NodeDB, useNodeDBStore } from "@core/stores/nodeDBStore"; |
||||
export { |
export { |
||||
SidebarProvider, |
SidebarProvider, |
||||
useSidebar, |
useSidebar, // TODO: Bring hook into this file
|
||||
} from "@core/stores/sidebarStore"; |
} from "@core/stores/sidebarStore"; |
||||
|
|
||||
|
// Define hooks to access the stores
|
||||
|
export const useNodeDB = (): NodeDB => { |
||||
|
const { deviceId } = useDeviceContext(); |
||||
|
const nodeDB = useNodeDBStore( |
||||
|
(s) => s.getNodeDB(deviceId) ?? s.addNodeDB(deviceId), |
||||
|
); |
||||
|
return nodeDB; |
||||
|
}; |
||||
|
export const useDevice = (): Device => { |
||||
|
const { deviceId } = useDeviceContext(); |
||||
|
|
||||
|
const device = useDeviceStore( |
||||
|
(s) => s.getDevice(deviceId) ?? s.addDevice(deviceId), |
||||
|
); |
||||
|
return device; |
||||
|
}; |
||||
|
|||||
@ -0,0 +1,440 @@ |
|||||
|
import { create } from "@bufbuild/protobuf"; |
||||
|
import { featureFlags } from "@core/services/featureFlags"; |
||||
|
import { createStorage } from "@core/stores/utils/indexDB.ts"; |
||||
|
import { Protobuf, type Types } from "@meshtastic/core"; |
||||
|
import { produce } from "immer"; |
||||
|
import { create as createStore, type StateCreator } from "zustand"; |
||||
|
import { type PersistOptions, persist } from "zustand/middleware"; |
||||
|
import type { NodeError, NodeErrorType, ProcessPacketParams } from "./types"; |
||||
|
|
||||
|
const CURRENT_STORE_VERSION = 0; |
||||
|
const NODEDB_RETENTION_NUM = 10; |
||||
|
|
||||
|
export interface NodeDB { |
||||
|
id: number; |
||||
|
myNodeNum: number | undefined; |
||||
|
nodeMap: Map<number, Protobuf.Mesh.NodeInfo>; |
||||
|
nodeErrors: Map<number, NodeError>; |
||||
|
|
||||
|
addNode: (nodeInfo: Protobuf.Mesh.NodeInfo) => void; |
||||
|
removeNode: (nodeNum: number) => void; |
||||
|
removeAllNodes: (keepMyNode?: boolean) => void; |
||||
|
processPacket: (data: ProcessPacketParams) => void; |
||||
|
addUser: (user: Types.PacketMetadata<Protobuf.Mesh.User>) => void; |
||||
|
addPosition: (position: Types.PacketMetadata<Protobuf.Mesh.Position>) => void; |
||||
|
updateFavorite: (nodeNum: number, isFavorite: boolean) => void; |
||||
|
updateIgnore: (nodeNum: number, isIgnored: boolean) => void; |
||||
|
setNodeNum: (nodeNum: number) => void; |
||||
|
setNodeError: (nodeNum: number, error: NodeErrorType) => void; |
||||
|
clearNodeError: (nodeNum: number) => void; |
||||
|
removeAllNodeErrors: () => void; |
||||
|
|
||||
|
getNodesLength: () => number; |
||||
|
getNode: (nodeNum: number) => Protobuf.Mesh.NodeInfo | undefined; |
||||
|
getNodes: ( |
||||
|
filter?: (node: Protobuf.Mesh.NodeInfo) => boolean, |
||||
|
) => Protobuf.Mesh.NodeInfo[]; |
||||
|
getMyNode: () => Protobuf.Mesh.NodeInfo; |
||||
|
|
||||
|
getNodeError: (nodeNum: number) => NodeError | undefined; |
||||
|
hasNodeError: (nodeNum: number) => boolean; |
||||
|
} |
||||
|
|
||||
|
export interface nodeDBState { |
||||
|
addNodeDB: (id: number) => NodeDB; |
||||
|
removeNodeDB: (id: number) => void; |
||||
|
getNodeDBs: () => NodeDB[]; |
||||
|
getNodeDB: (id: number) => NodeDB | undefined; |
||||
|
} |
||||
|
|
||||
|
interface PrivateNodeDBState extends nodeDBState { |
||||
|
nodeDBs: Map<number, NodeDB>; |
||||
|
} |
||||
|
|
||||
|
type NodeDBData = { |
||||
|
id: number; |
||||
|
myNodeNum: number | undefined; |
||||
|
nodeMap: Map<number, Protobuf.Mesh.NodeInfo>; |
||||
|
nodeErrors: Map<number, NodeError>; |
||||
|
}; |
||||
|
|
||||
|
type NodeDBPersisted = { |
||||
|
nodeDBs: Map<number, NodeDBData>; |
||||
|
}; |
||||
|
|
||||
|
function nodeDBFactory( |
||||
|
id: number, |
||||
|
get: () => PrivateNodeDBState, |
||||
|
set: typeof useNodeDBStore.setState, |
||||
|
data?: Partial<NodeDBData>, |
||||
|
): NodeDB { |
||||
|
const nodeMap = data?.nodeMap ?? new Map<number, Protobuf.Mesh.NodeInfo>(); |
||||
|
const nodeErrors = data?.nodeErrors ?? new Map<number, NodeError>(); |
||||
|
const myNodeNum = data?.myNodeNum; |
||||
|
|
||||
|
return { |
||||
|
id, |
||||
|
myNodeNum, |
||||
|
nodeMap, |
||||
|
nodeErrors, |
||||
|
|
||||
|
addNode: (node) => |
||||
|
set( |
||||
|
produce<PrivateNodeDBState>((draft) => { |
||||
|
const nodeDB = draft.nodeDBs.get(id); |
||||
|
if (!nodeDB) { |
||||
|
throw new Error(`No nodeDB found (id: ${id})`); |
||||
|
} |
||||
|
nodeDB.nodeMap.set(node.num, node); |
||||
|
}), |
||||
|
), |
||||
|
|
||||
|
removeNode: (nodeNum) => |
||||
|
set( |
||||
|
produce<PrivateNodeDBState>((draft) => { |
||||
|
const nodeDB = draft.nodeDBs.get(id); |
||||
|
if (!nodeDB) { |
||||
|
throw new Error(`No nodeDB found (id: ${id})`); |
||||
|
} |
||||
|
nodeDB.nodeMap.delete(nodeNum); |
||||
|
}), |
||||
|
), |
||||
|
|
||||
|
removeAllNodes: (keepMyNode) => |
||||
|
set( |
||||
|
produce<PrivateNodeDBState>((draft) => { |
||||
|
const nodeDB = draft.nodeDBs.get(id); |
||||
|
if (!nodeDB) { |
||||
|
throw new Error(`No nodeDB found (id: ${id})`); |
||||
|
} |
||||
|
const newNodeMap = new Map<number, Protobuf.Mesh.NodeInfo>(); |
||||
|
if ( |
||||
|
keepMyNode && |
||||
|
nodeDB.myNodeNum !== undefined && |
||||
|
nodeDB.nodeMap.has(nodeDB.myNodeNum) |
||||
|
) { |
||||
|
newNodeMap.set( |
||||
|
nodeDB.myNodeNum, |
||||
|
nodeDB.nodeMap.get(nodeDB.myNodeNum) ?? |
||||
|
create(Protobuf.Mesh.NodeInfoSchema), |
||||
|
); |
||||
|
} |
||||
|
nodeDB.nodeMap = newNodeMap; |
||||
|
}), |
||||
|
), |
||||
|
|
||||
|
setNodeError: (nodeNum, error) => |
||||
|
set( |
||||
|
produce<PrivateNodeDBState>((draft) => { |
||||
|
const nodeDB = draft.nodeDBs.get(id); |
||||
|
if (!nodeDB) { |
||||
|
throw new Error(`No nodeDB found (id: ${id})`); |
||||
|
} |
||||
|
nodeDB.nodeErrors.set(nodeNum, { node: nodeNum, error }); |
||||
|
}), |
||||
|
), |
||||
|
|
||||
|
clearNodeError: (nodeNum) => |
||||
|
set( |
||||
|
produce<PrivateNodeDBState>((draft) => { |
||||
|
const nodeDB = draft.nodeDBs.get(id); |
||||
|
if (!nodeDB) { |
||||
|
throw new Error(`No nodeDB found (id: ${id})`); |
||||
|
} |
||||
|
nodeDB.nodeErrors.delete(nodeNum); |
||||
|
}), |
||||
|
), |
||||
|
|
||||
|
removeAllNodeErrors: () => |
||||
|
set( |
||||
|
produce<PrivateNodeDBState>((draft) => { |
||||
|
const nodeDB = draft.nodeDBs.get(id); |
||||
|
if (!nodeDB) { |
||||
|
throw new Error(`No nodeDB found (id: ${id})`); |
||||
|
} |
||||
|
nodeDB.nodeErrors = new Map<number, NodeError>(); |
||||
|
}), |
||||
|
), |
||||
|
|
||||
|
processPacket: (data) => |
||||
|
set( |
||||
|
produce<PrivateNodeDBState>((draft) => { |
||||
|
const nodeDB = draft.nodeDBs.get(id); |
||||
|
if (!nodeDB) { |
||||
|
throw new Error(`No nodeDB found (id: ${id})`); |
||||
|
} |
||||
|
const node = nodeDB.nodeMap.get(data.from); |
||||
|
if (node) { |
||||
|
node.lastHeard = data.time; |
||||
|
node.snr = data.snr; |
||||
|
nodeDB.nodeMap.set(data.from, node); |
||||
|
} else { |
||||
|
nodeDB.nodeMap.set( |
||||
|
data.from, |
||||
|
create(Protobuf.Mesh.NodeInfoSchema, { |
||||
|
num: data.from, |
||||
|
lastHeard: data.time, |
||||
|
snr: data.snr, |
||||
|
}), |
||||
|
); |
||||
|
} |
||||
|
}), |
||||
|
), |
||||
|
|
||||
|
addUser: (user) => |
||||
|
set( |
||||
|
produce<PrivateNodeDBState>((draft) => { |
||||
|
const nodeDB = draft.nodeDBs.get(id); |
||||
|
if (!nodeDB) { |
||||
|
throw new Error(`No nodeDB found (id: ${id})`); |
||||
|
} |
||||
|
const current = |
||||
|
nodeDB.nodeMap.get(user.from) ?? |
||||
|
create(Protobuf.Mesh.NodeInfoSchema); |
||||
|
current.user = user.data; |
||||
|
current.num = user.from; |
||||
|
nodeDB.nodeMap.set(user.from, current); |
||||
|
}), |
||||
|
), |
||||
|
|
||||
|
addPosition: (position) => |
||||
|
set( |
||||
|
produce<PrivateNodeDBState>((draft) => { |
||||
|
const nodeDB = draft.nodeDBs.get(id); |
||||
|
if (!nodeDB) { |
||||
|
throw new Error(`No nodeDB found (id: ${id})`); |
||||
|
} |
||||
|
const current = |
||||
|
nodeDB.nodeMap.get(position.from) ?? |
||||
|
create(Protobuf.Mesh.NodeInfoSchema); |
||||
|
current.position = position.data; |
||||
|
current.num = position.from; |
||||
|
nodeDB.nodeMap.set(position.from, current); |
||||
|
}), |
||||
|
), |
||||
|
|
||||
|
setNodeNum: (nodeNum) => |
||||
|
set( |
||||
|
produce<PrivateNodeDBState>((draft) => { |
||||
|
const newDB = draft.nodeDBs.get(id); |
||||
|
if (!newDB) { |
||||
|
throw new Error(`No nodeDB found for id: ${id}`); |
||||
|
} |
||||
|
|
||||
|
newDB.myNodeNum = nodeNum; |
||||
|
|
||||
|
for (const [key, oldDB] of draft.nodeDBs) { |
||||
|
if (key === id) { |
||||
|
continue; |
||||
|
} |
||||
|
if (oldDB.myNodeNum === nodeNum) { |
||||
|
// The new DB is typically empty when nodenum is set, so we can safely copy over from the old DB
|
||||
|
// otherwise, discard the old DB completely
|
||||
|
if (newDB.nodeMap.size === 0) { |
||||
|
newDB.nodeMap = oldDB.nodeMap; |
||||
|
newDB.nodeErrors = oldDB.nodeErrors; |
||||
|
} else { |
||||
|
console.error( |
||||
|
`NodeDB with id: ${id} already has nodes, not merging with old DB`, |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
draft.nodeDBs.delete(key); |
||||
|
} |
||||
|
} |
||||
|
}), |
||||
|
), |
||||
|
|
||||
|
updateFavorite: (nodeNum, isFavorite) => |
||||
|
set( |
||||
|
produce<PrivateNodeDBState>((draft) => { |
||||
|
const nodeDB = draft.nodeDBs.get(id); |
||||
|
if (!nodeDB) { |
||||
|
throw new Error(`No nodeDB found (id: ${id})`); |
||||
|
} |
||||
|
|
||||
|
const node = nodeDB.nodeMap.get(nodeNum); |
||||
|
if (node) { |
||||
|
node.isFavorite = isFavorite; |
||||
|
} |
||||
|
}), |
||||
|
), |
||||
|
|
||||
|
updateIgnore: (nodeNum, isIgnored) => |
||||
|
set( |
||||
|
produce<PrivateNodeDBState>((draft) => { |
||||
|
const nodeDB = draft.nodeDBs.get(id); |
||||
|
if (!nodeDB) { |
||||
|
throw new Error(`No nodeDB found (id: ${id})`); |
||||
|
} |
||||
|
|
||||
|
const node = nodeDB.nodeMap.get(nodeNum); |
||||
|
if (node) { |
||||
|
node.isIgnored = isIgnored; |
||||
|
} |
||||
|
}), |
||||
|
), |
||||
|
|
||||
|
getNodesLength: () => { |
||||
|
const nodeDB = get().nodeDBs.get(id); |
||||
|
if (!nodeDB) { |
||||
|
throw new Error(`No nodeDB found (id: ${id})`); |
||||
|
} |
||||
|
return nodeDB.nodeMap.size; |
||||
|
}, |
||||
|
|
||||
|
getNode: (nodeNum) => { |
||||
|
const nodeDB = get().nodeDBs.get(id); |
||||
|
if (!nodeDB) { |
||||
|
throw new Error(`No nodeDB found (id: ${id})`); |
||||
|
} |
||||
|
return nodeDB.nodeMap.get(nodeNum); |
||||
|
}, |
||||
|
|
||||
|
getNodes: (filter) => { |
||||
|
const nodeDB = get().nodeDBs.get(id); |
||||
|
if (!nodeDB) { |
||||
|
throw new Error(`No nodeDB found (id: ${id})`); |
||||
|
} |
||||
|
const all = Array.from(nodeDB.nodeMap.values()).filter( |
||||
|
(n) => n.num !== nodeDB.myNodeNum, |
||||
|
); |
||||
|
return filter ? all.filter(filter) : all; |
||||
|
}, |
||||
|
|
||||
|
getMyNode: () => { |
||||
|
const nodeDB = get().nodeDBs.get(id); |
||||
|
if (!nodeDB) { |
||||
|
throw new Error(`No nodeDB found (id: ${id})`); |
||||
|
} |
||||
|
if (!nodeDB.myNodeNum) { |
||||
|
throw new Error(`No myNodeNum set for nodeDB with id: ${id}`); |
||||
|
} |
||||
|
return ( |
||||
|
nodeDB.nodeMap.get(nodeDB.myNodeNum) ?? |
||||
|
create(Protobuf.Mesh.NodeInfoSchema) |
||||
|
); |
||||
|
}, |
||||
|
|
||||
|
getNodeError: (nodeNum) => { |
||||
|
const nodeDB = get().nodeDBs.get(id); |
||||
|
if (!nodeDB) { |
||||
|
throw new Error(`No nodeDB found (id: ${id})`); |
||||
|
} |
||||
|
return nodeDB.nodeErrors.get(nodeNum); |
||||
|
}, |
||||
|
|
||||
|
hasNodeError: (nodeNum) => { |
||||
|
const nodeDB = get().nodeDBs.get(id); |
||||
|
if (!nodeDB) { |
||||
|
throw new Error(`No nodeDB found (id: ${id})`); |
||||
|
} |
||||
|
return nodeDB.nodeErrors.has(nodeNum); |
||||
|
}, |
||||
|
}; |
||||
|
} |
||||
|
|
||||
|
export const nodeDBInitializer: StateCreator<PrivateNodeDBState> = ( |
||||
|
set, |
||||
|
get, |
||||
|
) => ({ |
||||
|
nodeDBs: new Map(), |
||||
|
|
||||
|
addNodeDB: (id) => { |
||||
|
const existing = get().nodeDBs.get(id); |
||||
|
if (existing) { |
||||
|
return existing; |
||||
|
} |
||||
|
|
||||
|
const nodeDB = nodeDBFactory(id, get, set); |
||||
|
set( |
||||
|
produce<PrivateNodeDBState>((draft) => { |
||||
|
draft.nodeDBs.set(id, nodeDB); |
||||
|
|
||||
|
// If over limit, remove oldest inserted. FIFO
|
||||
|
if (draft.nodeDBs.size > NODEDB_RETENTION_NUM) { |
||||
|
const firstKey = draft.nodeDBs.keys().next().value; |
||||
|
if (firstKey !== undefined) { |
||||
|
draft.nodeDBs.delete(firstKey); |
||||
|
} |
||||
|
} |
||||
|
}), |
||||
|
); |
||||
|
|
||||
|
return nodeDB; |
||||
|
}, |
||||
|
removeNodeDB: (id) => { |
||||
|
set( |
||||
|
produce<PrivateNodeDBState>((draft) => { |
||||
|
draft.nodeDBs.delete(id); |
||||
|
}), |
||||
|
); |
||||
|
}, |
||||
|
getNodeDBs: () => Array.from(get().nodeDBs.values()), |
||||
|
getNodeDB: (id) => get().nodeDBs.get(id), |
||||
|
}); |
||||
|
|
||||
|
const persistOptions: PersistOptions<PrivateNodeDBState, NodeDBPersisted> = { |
||||
|
name: "meshtastic-nodedb-store", |
||||
|
storage: createStorage<NodeDBPersisted>(), |
||||
|
version: CURRENT_STORE_VERSION, |
||||
|
partialize: (s): NodeDBPersisted => ({ |
||||
|
nodeDBs: new Map( |
||||
|
Array.from(s.nodeDBs.entries()).map(([id, db]) => [ |
||||
|
id, |
||||
|
{ |
||||
|
id: db.id, |
||||
|
myNodeNum: db.myNodeNum, |
||||
|
nodeMap: db.nodeMap, |
||||
|
nodeErrors: db.nodeErrors, |
||||
|
}, |
||||
|
]), |
||||
|
), |
||||
|
}), |
||||
|
onRehydrateStorage: () => (state) => { |
||||
|
if (!state) { |
||||
|
return; |
||||
|
} |
||||
|
console.debug( |
||||
|
"NodeDBStore: Rehydrating state with ", |
||||
|
state.nodeDBs.size, |
||||
|
" nodeDBs -", |
||||
|
state.nodeDBs, |
||||
|
); |
||||
|
|
||||
|
useNodeDBStore.setState( |
||||
|
produce<PrivateNodeDBState>((draft) => { |
||||
|
const rebuilt = new Map<number, NodeDB>(); |
||||
|
for (const [id, data] of ( |
||||
|
draft.nodeDBs as unknown as Map<number, NodeDBData> |
||||
|
).entries()) { |
||||
|
if (data.myNodeNum !== undefined) { |
||||
|
// Only rebuild if there is a nodenum set otherwise orphan dbs will acumulate
|
||||
|
rebuilt.set( |
||||
|
id, |
||||
|
nodeDBFactory( |
||||
|
id, |
||||
|
useNodeDBStore.getState, |
||||
|
useNodeDBStore.setState, |
||||
|
data, |
||||
|
), |
||||
|
); |
||||
|
} |
||||
|
} |
||||
|
draft.nodeDBs = rebuilt; |
||||
|
}), |
||||
|
); |
||||
|
}, |
||||
|
}; |
||||
|
|
||||
|
// Add persist middleware on the store if the feature flag is enabled
|
||||
|
const persistNodes = featureFlags.get("persistNodeDB"); |
||||
|
console.debug( |
||||
|
`NodeDBStore: Persisting nodes is ${persistNodes ? "enabled" : "disabled"}`, |
||||
|
); |
||||
|
|
||||
|
export const useNodeDBStore = persistNodes |
||||
|
? createStore<PrivateNodeDBState, [["zustand/persist", NodeDBPersisted]]>( |
||||
|
persist(nodeDBInitializer, persistOptions), |
||||
|
) |
||||
|
: createStore<PrivateNodeDBState>()(nodeDBInitializer); |
||||
@ -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,258 @@ |
|||||
|
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; |
||||
|
} |
||||
|
|
||||
|
vi.mock("@core/services/featureFlags", () => { |
||||
|
return { |
||||
|
featureFlags: { |
||||
|
get: vi.fn((key: string) => { |
||||
|
if (key === "persistNodeDB") return true; |
||||
|
return false; |
||||
|
}), |
||||
|
}, |
||||
|
}; |
||||
|
}); |
||||
|
|
||||
|
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,16 @@ |
|||||
|
import type { Protobuf } from "@meshtastic/core"; |
||||
|
|
||||
|
type NodeErrorType = Protobuf.Mesh.Routing_Error | "MISMATCH_PKI"; |
||||
|
|
||||
|
type NodeError = { |
||||
|
node: number; |
||||
|
error: NodeErrorType; |
||||
|
}; |
||||
|
|
||||
|
type ProcessPacketParams = { |
||||
|
from: number; |
||||
|
snr: number; |
||||
|
time: number; |
||||
|
}; |
||||
|
|
||||
|
export type { NodeError, ProcessPacketParams, NodeErrorType }; |
||||
@ -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"); |
||||
|
}); |
||||
|
}); |
||||
Some files were not shown because too many files changed in this diff
Loading…
Reference in new issue