Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,40 +1,53 @@
// FactoryResetDeviceDialog.test.tsx
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { FactoryResetDeviceDialog } from "./FactoryResetDeviceDialog.tsx";

const mockFactoryResetDevice = vi.fn();
const mockDeleteAllMessages = vi.fn();
const mockRemoveAllNodeErrors = vi.fn();
const mockRemoveAllNodes = vi.fn();
const mockRemoveDevice = vi.fn();
const mockRemoveMessageStore = vi.fn();
const mockRemoveNodeDB = vi.fn();
const mockToast = vi.fn();

vi.mock("@core/stores", () => ({
CurrentDeviceContext: {
_currentValue: { deviceId: 1234 },
},
useDevice: () => ({
connection: {
factoryResetDevice: mockFactoryResetDevice,
vi.mock("@core/stores", () => {
// Make each store a callable fn (like a Zustand hook), and attach .getState()
const useDeviceStore = Object.assign(vi.fn(), {
getState: () => ({ removeDevice: mockRemoveDevice }),
});
const useMessageStore = Object.assign(vi.fn(), {
getState: () => ({ removeMessageStore: mockRemoveMessageStore }),
});
const useNodeDBStore = Object.assign(vi.fn(), {
getState: () => ({ removeNodeDB: mockRemoveNodeDB }),
});

return {
CurrentDeviceContext: {
_currentValue: { deviceId: 1234 },
},
}),
useMessages: () => ({
deleteAllMessages: mockDeleteAllMessages,
}),
useNodeDB: () => ({
removeAllNodeErrors: mockRemoveAllNodeErrors,
removeAllNodes: mockRemoveAllNodes,
}),
useDevice: () => ({
id: 42,
connection: { factoryResetDevice: mockFactoryResetDevice },
}),
useDeviceStore,
useMessageStore,
useNodeDBStore,
};
});

vi.mock("@core/hooks/useToast.ts", () => ({
toast: (...args: unknown[]) => mockToast(...args),
}));

describe("FactoryResetDeviceDialog", () => {
const mockOnOpenChange = vi.fn();

beforeEach(() => {
mockOnOpenChange.mockClear();
mockFactoryResetDevice.mockClear();
mockDeleteAllMessages.mockClear();
mockRemoveAllNodeErrors.mockClear();
mockRemoveAllNodes.mockClear();
mockFactoryResetDevice.mockReset();
mockRemoveDevice.mockClear();
mockRemoveMessageStore.mockClear();
mockRemoveNodeDB.mockClear();
mockToast.mockClear();
});

it("calls factoryResetDevice, closes dialog, and after reset resolves clears messages and node DB", async () => {
Expand All @@ -61,20 +74,12 @@ describe("FactoryResetDeviceDialog", () => {
expect(mockOnOpenChange).toHaveBeenCalledWith(false);
});

// Nothing else should have happened yet (the promise hasn't resolved)
expect(mockDeleteAllMessages).not.toHaveBeenCalled();
expect(mockRemoveAllNodeErrors).not.toHaveBeenCalled();
expect(mockRemoveAllNodes).not.toHaveBeenCalled();

// Resolve the reset
resolveReset?.();

// Now the .then() chain should fire
await waitFor(() => {
expect(mockDeleteAllMessages).toHaveBeenCalledTimes(1);
expect(mockRemoveAllNodeErrors).toHaveBeenCalledTimes(1);
expect(mockRemoveAllNodes).toHaveBeenCalledTimes(1);
});
expect(mockRemoveDevice).toHaveBeenCalledTimes(1);
expect(mockRemoveMessageStore).toHaveBeenCalledTimes(1);
expect(mockRemoveNodeDB).toHaveBeenCalledTimes(1);
});

it("calls onOpenChange(false) and does not call factoryResetDevice when cancel is clicked", async () => {
Expand All @@ -87,8 +92,8 @@ describe("FactoryResetDeviceDialog", () => {
});

expect(mockFactoryResetDevice).not.toHaveBeenCalled();
expect(mockDeleteAllMessages).not.toHaveBeenCalled();
expect(mockRemoveAllNodeErrors).not.toHaveBeenCalled();
expect(mockRemoveAllNodes).not.toHaveBeenCalled();
expect(mockRemoveDevice).not.toHaveBeenCalled();
expect(mockRemoveMessageStore).not.toHaveBeenCalled();
expect(mockRemoveNodeDB).not.toHaveBeenCalled();
});
});
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
import { toast } from "@core/hooks/useToast.ts";
import { useDevice, useMessages, useNodeDB } from "@core/stores";
import {
useDevice,
useDeviceStore,
useMessageStore,
useNodeDBStore,
} from "@core/stores";
import { useTranslation } from "react-i18next";
import { DialogWrapper } from "../DialogWrapper.tsx";

Expand All @@ -13,24 +18,24 @@ export const FactoryResetDeviceDialog = ({
onOpenChange,
}: FactoryResetDeviceDialogProps) => {
const { t } = useTranslation("dialog");
const { connection } = useDevice();
const { removeAllNodeErrors, removeAllNodes } = useNodeDB();
const { deleteAllMessages } = useMessages();
const { connection, id } = useDevice();

const handleFactoryResetDevice = () => {
connection
?.factoryResetDevice()
.then(() => {
deleteAllMessages();
removeAllNodeErrors();
removeAllNodes();
})
.catch((error) => {
toast({
title: t("factoryResetDevice.failedTitle"),
});
console.error("Failed to factory reset device:", error);
connection?.factoryResetDevice().catch((error) => {
toast({
title: t("factoryResetDevice.failedTitle"),
});
console.error("Failed to factory reset device:", error);
});

// The device will be wiped and disconnected without resolving the promise
// so we proceed to clear all data associated with the device immediately
useDeviceStore.getState().removeDevice(id);
useMessageStore.getState().removeMessageStore(id);
useNodeDBStore.getState().removeNodeDB(id);

// Reload the app to ensure all ephemeral state is cleared
window.location.href = "/";
};

return (
Expand Down
2 changes: 2 additions & 0 deletions packages/web/src/core/services/dev-overrides.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,5 +7,7 @@ if (isDev) {
featureFlags.setOverrides({
persistNodeDB: true,
persistMessages: true,
persistDevices: true,
persistApp: true,
});
}
2 changes: 2 additions & 0 deletions packages/web/src/core/services/featureFlags.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import { z } from "zod";
export const FLAG_ENV = {
persistNodeDB: "VITE_PERSIST_NODE_DB",
persistMessages: "VITE_PERSIST_MESSAGES",
persistDevices: "VITE_PERSIST_DEVICES",
persistApp: "VITE_PERSIST_APP",
} as const;

export type FlagKey = keyof typeof FLAG_ENV;
Expand Down
177 changes: 177 additions & 0 deletions packages/web/src/core/stores/appStore/appStore.test.ts
Original file line number Diff line number Diff line change
@@ -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
}
});
});
Loading