Files
MajoorWaldi-ComfyUI-Majoor-…/ui/tests/use_assets_query.vitest.ts
T

564 lines
18 KiB
TypeScript

import { beforeEach, afterEach, describe, expect, it, vi } from "vitest";
const apiState = vi.hoisted(() => ({
get: vi.fn(),
}));
vi.mock("../api/client.js", () => ({
get: apiState.get,
}));
vi.mock("../api/endpoints.js", () => ({
ENDPOINTS: {
HEALTH_COUNTERS: "/api/health/counters",
},
}));
function createGridContainer() {
return {
dataset: { mjrQuery: "*" },
isConnected: true,
clientWidth: 320,
clientHeight: 240,
getClientRects: vi.fn(() => [{ width: 320, height: 240 }]),
querySelector: vi.fn(() => null),
};
}
function createGridWrapper() {
return {
scrollTop: 0,
isConnected: true,
clientWidth: 320,
clientHeight: 240,
getClientRects: vi.fn(() => [{ width: 320, height: 240 }]),
};
}
function createInputElement(initialValue = "") {
class InputStub extends EventTarget {
constructor(value = "") {
super();
this.value = value;
}
}
return new InputStub(initialValue);
}
describe("useAssetsQuery", () => {
beforeEach(() => {
vi.clearAllMocks();
globalThis.CustomEvent =
globalThis.CustomEvent ||
class extends Event {
constructor(type, init = {}) {
super(type);
this.detail = init.detail;
}
};
});
afterEach(() => {
vi.useRealTimers();
});
it("coalesces queued reloads and restores the anchor after each pass", async () => {
const { createAssetsQueryController } =
await import("../vue/composables/useAssetsQuery.js");
const gridContainer = createGridContainer();
const gridWrapper = createGridWrapper();
const captureAnchor = vi.fn(() => ({ id: "anchor-1" }));
const restoreAnchor = vi.fn(async () => {});
const resolvers = [];
const reloadGrid = vi.fn(
() =>
new Promise((resolve) => {
resolvers.push(resolve);
}),
);
const controller = createAssetsQueryController({
gridContainer,
gridWrapper,
gridController: { reloadGrid },
captureAnchor,
restoreAnchor,
readScrollTop: () => gridWrapper.scrollTop,
});
const firstRun = controller.queuedReload();
controller.queuedReload();
expect(reloadGrid).toHaveBeenCalledTimes(1);
resolvers.shift()?.();
await Promise.resolve();
await Promise.resolve();
expect(reloadGrid).toHaveBeenCalledTimes(2);
resolvers.shift()?.();
await firstRun;
expect(captureAnchor).toHaveBeenCalledTimes(2);
expect(restoreAnchor).toHaveBeenCalledTimes(2);
});
it("keeps dirty reloads pending while hidden and runs them on visibility restore", async () => {
const { createAssetsQueryController } =
await import("../vue/composables/useAssetsQuery.js");
const gridContainer = createGridContainer();
gridContainer.isConnected = false;
gridContainer.getClientRects = vi.fn(() => []);
const gridWrapper = createGridWrapper();
gridWrapper.isConnected = false;
gridWrapper.getClientRects = vi.fn(() => []);
const reloadGrid = vi.fn(async () => ({ ok: true }));
const controller = createAssetsQueryController({
gridContainer,
gridWrapper,
gridController: { reloadGrid },
captureAnchor: () => null,
restoreAnchor: async () => {},
});
controller.markGridDirty("scan");
await Promise.resolve();
expect(reloadGrid).toHaveBeenCalledTimes(0);
gridContainer.isConnected = true;
gridContainer.getClientRects = vi.fn(() => [{ width: 320, height: 240 }]);
gridWrapper.isConnected = true;
gridWrapper.getClientRects = vi.fn(() => [{ width: 320, height: 240 }]);
controller.setVisibility(true);
await Promise.resolve();
await Promise.resolve();
expect(reloadGrid).toHaveBeenCalledTimes(1);
});
it("restores scroll, selection, and sidebar state after the initial load", async () => {
const { createAssetsQueryController } =
await import("../vue/composables/useAssetsQuery.js");
const previousRaf = globalThis.requestAnimationFrame;
globalThis.requestAnimationFrame = (callback) => {
callback();
return 1;
};
const gridContainer = createGridContainer();
const gridWrapper = createGridWrapper();
const restoreSelectionState = vi.fn();
const toggleSidebarDetails = vi.fn();
const controller = createAssetsQueryController({
gridContainer,
gridWrapper,
gridController: { reloadGrid: vi.fn() },
readScrollTop: () => 42,
restoreSelectionState,
readActiveAssetId: () => "asset-9",
isSidebarOpen: () => true,
toggleSidebarDetails,
});
await controller.restoreUiState(Promise.resolve({ ok: true }));
expect(gridWrapper.scrollTop).toBe(42);
expect(restoreSelectionState).toHaveBeenCalledWith({ scrollTop: 42 });
expect(toggleSidebarDetails).toHaveBeenCalledTimes(1);
globalThis.requestAnimationFrame = previousRaf;
});
it("delegates UI restoration to an external grid host when provided", async () => {
const { createAssetsQueryController } =
await import("../vue/composables/useAssetsQuery.js");
const restoreGridUiState = vi.fn(async () => {});
const controller = createAssetsQueryController({
gridContainer: createGridContainer(),
gridWrapper: createGridWrapper(),
gridController: { reloadGrid: vi.fn() },
restoreGridUiState,
});
const initialLoadPromise = Promise.resolve({ ok: true });
await controller.restoreUiState(initialLoadPromise);
expect(restoreGridUiState).toHaveBeenCalledWith(initialLoadPromise);
});
it("auto-loads the default output grid when counters show assets exist", async () => {
const { createAssetsQueryController } =
await import("../vue/composables/useAssetsQuery.js");
vi.useFakeTimers();
apiState.get.mockResolvedValue({
ok: true,
data: { total_assets: 3 },
});
const gridContainer = createGridContainer();
const loadAssets = vi.fn(async () => ({ ok: true }));
const controller = createAssetsQueryController({
gridContainer,
gridWrapper: createGridWrapper(),
gridController: { reloadGrid: vi.fn() },
getQuery: () => "*",
getScope: () => "output",
loadAssets,
});
controller.scheduleAutoLoad(Promise.resolve({ ok: true }), 25);
await vi.advanceTimersByTimeAsync(25);
expect(apiState.get).toHaveBeenCalledWith("/api/health/counters?scope=output", {
signal: undefined,
});
expect(loadAssets).toHaveBeenCalledWith(gridContainer);
});
it("does not auto-load while the grid is already loading", async () => {
const { createAssetsQueryController } =
await import("../vue/composables/useAssetsQuery.js");
vi.useFakeTimers();
apiState.get.mockResolvedValue({
ok: true,
data: { total_assets: 3 },
});
const gridContainer = createGridContainer();
gridContainer._mjrGetGridState = vi.fn(() => ({ loading: true, assets: [] }));
const loadAssets = vi.fn(async () => ({ ok: true }));
const controller = createAssetsQueryController({
gridContainer,
gridWrapper: createGridWrapper(),
gridController: { reloadGrid: vi.fn() },
getQuery: () => "*",
getScope: () => "output",
loadAssets,
});
controller.scheduleAutoLoad(Promise.resolve({ ok: true }), 25);
await vi.advanceTimersByTimeAsync(25);
expect(apiState.get).not.toHaveBeenCalled();
expect(loadAssets).not.toHaveBeenCalled();
});
it("defers auto-load while the grid host is hidden and resumes when visible again", async () => {
const { createAssetsQueryController } =
await import("../vue/composables/useAssetsQuery.js");
vi.useFakeTimers();
apiState.get.mockResolvedValue({
ok: true,
data: { total_assets: 3 },
});
const gridContainer = createGridContainer();
gridContainer.isConnected = false;
gridContainer.getClientRects = vi.fn(() => []);
const gridWrapper = {
clientWidth: 320,
clientHeight: 240,
isConnected: false,
getClientRects: vi.fn(() => []),
};
const loadAssets = vi.fn(async () => ({ ok: true }));
const controller = createAssetsQueryController({
gridContainer,
gridWrapper,
gridController: { reloadGrid: vi.fn() },
getQuery: () => "*",
getScope: () => "output",
loadAssets,
});
controller.scheduleAutoLoad(Promise.resolve({ ok: true }), 25);
await vi.advanceTimersByTimeAsync(25);
expect(loadAssets).toHaveBeenCalledTimes(0);
gridContainer.isConnected = true;
gridContainer.getClientRects = vi.fn(() => [{ width: 320, height: 240 }]);
gridWrapper.isConnected = true;
gridWrapper.getClientRects = vi.fn(() => [{ width: 320, height: 240 }]);
controller.setVisibility();
await vi.advanceTimersByTimeAsync(50);
expect(loadAssets).toHaveBeenCalledWith(gridContainer);
});
it("creates a counters update handler that triggers queued reload on new index updates", async () => {
const { createAssetsQueryController } =
await import("../vue/composables/useAssetsQuery.js");
const reloadGrid = vi.fn(async () => ({ ok: true }));
const controller = createAssetsQueryController({
gridContainer: createGridContainer(),
gridWrapper: createGridWrapper(),
gridController: { reloadGrid },
captureAnchor: () => null,
restoreAnchor: async () => {},
});
const handleCountersUpdate = controller.createCountersUpdateHandler({
state: {
scope: "output",
collectionId: "",
kindFilter: "",
workflowOnly: false,
minRating: 0,
minSizeMB: 0,
maxSizeMB: 0,
minWidth: 0,
minHeight: 0,
maxWidth: 0,
maxHeight: 0,
workflowType: "",
dateRangeFilter: "",
dateExactFilter: "",
},
getRecentUserInteractionAt: () => 0,
});
await handleCountersUpdate({
last_scan_end: "scan-1",
last_index_end: "idx-1",
total_assets: 10,
});
await handleCountersUpdate({
last_scan_end: "scan-1",
last_index_end: "idx-2",
total_assets: 10,
});
expect(reloadGrid).toHaveBeenCalledTimes(1);
});
it("does not reload on background total growth when the grid already shows cards", async () => {
const { createAssetsQueryController } =
await import("../vue/composables/useAssetsQuery.js");
const reloadGrid = vi.fn(async () => ({ ok: true }));
const controller = createAssetsQueryController({
gridContainer: {
...createGridContainer(),
querySelector: vi.fn((selector) =>
selector === ".mjr-asset-card" ? { dataset: { mjrAssetId: "asset-1" } } : null,
),
},
gridWrapper: createGridWrapper(),
gridController: { reloadGrid },
captureAnchor: () => null,
restoreAnchor: async () => {},
});
const handleCountersUpdate = controller.createCountersUpdateHandler({
state: {
scope: "output",
collectionId: "",
kindFilter: "",
workflowOnly: false,
minRating: 0,
minSizeMB: 0,
maxSizeMB: 0,
minWidth: 0,
minHeight: 0,
maxWidth: 0,
maxHeight: 0,
workflowType: "",
dateRangeFilter: "",
dateExactFilter: "",
},
getRecentUserInteractionAt: () => 0,
});
await handleCountersUpdate({
last_scan_end: "scan-1",
last_index_end: "idx-1",
total_assets: 100,
});
await handleCountersUpdate({
last_scan_end: "scan-1",
last_index_end: "idx-1",
total_assets: 140,
});
expect(reloadGrid).toHaveBeenCalledTimes(0);
});
it("does not reload on background index updates when the grid already shows cards", async () => {
const { createAssetsQueryController } =
await import("../vue/composables/useAssetsQuery.js");
const reloadGrid = vi.fn(async () => ({ ok: true }));
const controller = createAssetsQueryController({
gridContainer: {
...createGridContainer(),
querySelector: vi.fn((selector) =>
selector === ".mjr-asset-card" ? { dataset: { mjrAssetId: "asset-1" } } : null,
),
},
gridWrapper: createGridWrapper(),
gridController: { reloadGrid },
captureAnchor: () => null,
restoreAnchor: async () => {},
});
const handleCountersUpdate = controller.createCountersUpdateHandler({
state: {
scope: "output",
collectionId: "",
kindFilter: "",
workflowOnly: false,
minRating: 0,
minSizeMB: 0,
maxSizeMB: 0,
minWidth: 0,
minHeight: 0,
maxWidth: 0,
maxHeight: 0,
workflowType: "",
dateRangeFilter: "",
dateExactFilter: "",
},
getRecentUserInteractionAt: () => 0,
});
await handleCountersUpdate({
last_scan_end: "scan-1",
last_index_end: "idx-1",
total_assets: 100,
});
await handleCountersUpdate({
last_scan_end: "scan-1",
last_index_end: "idx-2",
total_assets: 100,
});
expect(reloadGrid).toHaveBeenCalledTimes(0);
});
it("defers counters-based reload while the grid host is hidden and resumes when visible", async () => {
const { createAssetsQueryController } =
await import("../vue/composables/useAssetsQuery.js");
const gridContainer = createGridContainer();
gridContainer.isConnected = false;
gridContainer.getClientRects = vi.fn(() => []);
const gridWrapper = {
clientWidth: 320,
clientHeight: 240,
isConnected: false,
getClientRects: vi.fn(() => []),
};
const reloadGrid = vi.fn(async () => ({ ok: true }));
const controller = createAssetsQueryController({
gridContainer,
gridWrapper,
gridController: { reloadGrid },
captureAnchor: () => null,
restoreAnchor: async () => {},
});
const handleCountersUpdate = controller.createCountersUpdateHandler({
state: {
scope: "output",
collectionId: "",
kindFilter: "",
workflowOnly: false,
minRating: 0,
minSizeMB: 0,
maxSizeMB: 0,
minWidth: 0,
minHeight: 0,
maxWidth: 0,
maxHeight: 0,
workflowType: "",
dateRangeFilter: "",
dateExactFilter: "",
},
getRecentUserInteractionAt: () => 0,
});
await handleCountersUpdate({
last_scan_end: "scan-1",
last_index_end: "idx-1",
total_assets: 10,
});
await handleCountersUpdate({
last_scan_end: "scan-1",
last_index_end: "idx-2",
total_assets: 10,
});
expect(reloadGrid).toHaveBeenCalledTimes(0);
gridContainer.isConnected = true;
gridContainer.getClientRects = vi.fn(() => [{ width: 320, height: 240 }]);
gridWrapper.isConnected = true;
gridWrapper.getClientRects = vi.fn(() => [{ width: 320, height: 240 }]);
controller.setVisibility(true);
await Promise.resolve();
await Promise.resolve();
expect(reloadGrid).toHaveBeenCalledTimes(1);
});
it("binds search input with debounce and immediate reload for empty query", async () => {
const { createAssetsQueryController } =
await import("../vue/composables/useAssetsQuery.js");
vi.useFakeTimers();
const controller = createAssetsQueryController({
gridContainer: createGridContainer(),
gridWrapper: createGridWrapper(),
gridController: { reloadGrid: vi.fn() },
});
const searchInputEl = createInputElement("");
const onQueryChanged = vi.fn();
const onBeforeReload = vi.fn();
const startSearchTimer = vi.fn();
const reloadGrid = vi.fn();
const dispose = controller.bindSearchInput({
searchInputEl,
debounceMs: 200,
onQueryChanged,
onBeforeReload,
startSearchTimer,
reloadGrid,
});
searchInputEl.value = "dragon";
searchInputEl.dispatchEvent(new Event("input"));
expect(onQueryChanged).toHaveBeenCalledTimes(1);
expect(reloadGrid).toHaveBeenCalledTimes(0);
await vi.advanceTimersByTimeAsync(200);
expect(startSearchTimer).toHaveBeenCalledTimes(1);
expect(onBeforeReload).toHaveBeenCalledTimes(1);
expect(reloadGrid).toHaveBeenCalledTimes(1);
searchInputEl.value = "";
searchInputEl.dispatchEvent(new Event("input"));
expect(startSearchTimer).toHaveBeenCalledTimes(2);
expect(onBeforeReload).toHaveBeenCalledTimes(2);
expect(reloadGrid).toHaveBeenCalledTimes(2);
dispose();
});
});