564 lines
18 KiB
TypeScript
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();
|
|
});
|
|
});
|