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

1520 lines
50 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { ensureWindowStub, mockPartialVue } from "./helpers/vitestEnvironment.ts";
const fetchGridPageMock = vi.hoisted(() => vi.fn());
const appendAssetsMock = vi.hoisted(() => vi.fn());
function makeStorage() {
const data = new Map();
return {
getItem: vi.fn((key) => data.get(String(key)) ?? null),
setItem: vi.fn((key, value) => data.set(String(key), String(value))),
removeItem: vi.fn((key) => data.delete(String(key))),
clear: vi.fn(() => data.clear()),
};
}
mockPartialVue({
nextTick: async () => {},
reactive: (obj) => obj,
});
vi.mock("../api/client.js", () => ({
get: vi.fn(),
}));
vi.mock("../app/config.js", () => ({
APP_DEFAULTS: {
DEFAULT_PAGE_SIZE: 100,
MAX_PAGE_SIZE: 2000,
},
APP_CONFIG: {
DEFAULT_PAGE_SIZE: 100,
MAX_PAGE_SIZE: 2000,
PREFETCH_NEXT_PAGE: true,
PREFETCH_NEXT_PAGE_DELAY_MS: 700,
},
}));
vi.mock("../app/settings.js", () => ({
loadMajoorSettings: vi.fn(() => ({})),
}));
vi.mock("../components/Badges.js", () => ({
setFileBadgeCollision: vi.fn(),
}));
vi.mock("../utils/ids.js", () => ({
pickRootId: vi.fn(() => "root"),
}));
vi.mock("../features/grid/AssetCardRenderer.js", () => ({
appendAssets: appendAssetsMock,
rebuildAssetRendererState: vi.fn((state, assets = [], { assetKey = null, preserveHiddenCount = false } = {}) => {
const previousHiddenCount = Number(state?.hiddenPngSiblings || 0) || 0;
state.seenKeys = new Set();
state.assetIdSet = new Set();
state.filenameCounts = new Map();
state.nonImageSiblingKeys = new Set();
state.stemMap = new Map();
state.renderedFilenameMap = new Map();
state.hiddenPngSiblings = preserveHiddenCount ? previousHiddenCount : 0;
for (const asset of Array.isArray(assets) ? assets : []) {
if (asset?.id != null) state.assetIdSet.add(String(asset.id));
const key = typeof assetKey === "function" ? assetKey(asset) : "";
if (key) state.seenKeys.add(key);
}
}),
}));
vi.mock("../features/grid/StackGroupCards.js", () => ({
getStackAwareAssetKey: vi.fn((_grid, _asset, fallback) => fallback),
ensureDupStackCard: vi.fn(),
disposeStackGroupCards: vi.fn(),
}));
vi.mock("../vue/composables/useVirtualGrid.js", async () => {
const actual = await vi.importActual("../vue/composables/useVirtualGrid.js");
return {
...actual,
compareAssets: vi.fn(() => 0),
fetchPage: fetchGridPageMock,
getUpsertBatchState: vi.fn(() => null),
queueUpsertAsset: vi.fn(() => false),
};
});
function createVisibleElement({ dataset = {}, scrollTop = 0 } = {}) {
return {
dataset,
scrollTop,
isConnected: true,
clientWidth: 320,
clientHeight: 240,
getClientRects: vi.fn(() => [{ width: 320, height: 240 }]),
};
}
describe("useGridLoader adaptive paging", () => {
beforeEach(() => {
vi.resetModules();
vi.clearAllMocks();
vi.useRealTimers();
globalThis.sessionStorage = makeStorage();
globalThis.localStorage = makeStorage();
ensureWindowStub();
});
afterEach(() => {
vi.useRealTimers();
try {
delete (globalThis.navigator as any).deviceMemory;
} catch {}
});
it("increases page size when successive pages add no visible cards", async () => {
const { useGridLoader } = await import("../vue/composables/useGridLoader.js");
const requestedLimits = [];
const requestedOffsets = [];
fetchGridPageMock.mockImplementation(async (_grid, _query, limit, offset) => {
requestedLimits.push(limit);
requestedOffsets.push(offset);
return {
ok: true,
assets: [{ id: `${offset}-${limit}` }],
total: 7000,
count: limit,
limit,
offset,
};
});
appendAssetsMock.mockReturnValueOnce(0).mockReturnValueOnce(0).mockReturnValueOnce(1);
const state = {
loading: false,
done: false,
total: 7000,
offset: 0,
requestId: 1,
abortController: null,
assets: [],
activeId: "",
statusMessage: "",
statusError: false,
};
const gridContainer = createVisibleElement({ dataset: {} });
const scrollElement = createVisibleElement();
const loader = useGridLoader({
gridContainerRef: { value: gridContainer },
state,
setLoadingMessage: vi.fn(),
clearLoadingMessage: vi.fn(),
setStatusMessage: vi.fn(),
clearStatusMessage: vi.fn(),
resetAssets: vi.fn(),
setSelection: vi.fn(),
reconcileSelection: vi.fn(),
readScrollElement: () => scrollElement,
readRenderedCards: () => [],
scrollToAssetId: vi.fn(),
});
const result = await loader.loadNextPage();
expect(result.ok).toBe(true);
expect(requestedLimits).toEqual([100, 200, 400]);
expect(requestedOffsets).toEqual([0, 100, 300]);
expect(state.offset).toBe(700);
});
it("schedules another page attempt when consumed pages add no visible cards", async () => {
vi.useFakeTimers();
const { useGridLoader } = await import("../vue/composables/useGridLoader.js");
const requestedOffsets = [];
fetchGridPageMock.mockImplementation(async (_grid, _query, limit, offset) => {
requestedOffsets.push(offset);
return {
ok: true,
assets: Array.from({ length: limit }, (_, index) => ({
id: `${offset}-${index}`,
})),
total: 8000,
count: limit,
limit,
offset,
};
});
appendAssetsMock.mockReturnValue(0);
const state = {
loading: false,
done: false,
total: 8000,
offset: 0,
requestId: 1,
abortController: null,
query: "*",
assets: [],
activeId: "",
statusMessage: "",
statusError: false,
};
const loader = useGridLoader({
gridContainerRef: { value: createVisibleElement({ dataset: {} }) },
state,
setLoadingMessage: vi.fn(),
clearLoadingMessage: vi.fn(),
setStatusMessage: vi.fn(),
clearStatusMessage: vi.fn(),
resetAssets: vi.fn(),
setSelection: vi.fn(),
reconcileSelection: vi.fn(),
readScrollElement: () => createVisibleElement(),
readRenderedCards: () => [],
scrollToAssetId: vi.fn(),
});
const result = await loader.loadNextPage();
expect(result).toMatchObject({ ok: true, skippedEmpty: true });
expect(requestedOffsets).toEqual([0, 100, 300, 700, 1500, 3100]);
await vi.advanceTimersByTimeAsync(251);
await Promise.resolve();
await Promise.resolve();
expect(requestedOffsets.length).toBeGreaterThan(6);
});
it("skips delayed prefetch on constrained browser runtimes", async () => {
vi.useFakeTimers();
Object.defineProperty(globalThis.navigator, "deviceMemory", {
configurable: true,
value: 4,
});
const { useGridLoader } = await import("../vue/composables/useGridLoader.js");
const requestedOffsets = [];
fetchGridPageMock.mockImplementation(async (_grid, _query, limit, offset) => {
requestedOffsets.push(offset);
return {
ok: true,
assets: Array.from({ length: limit }, (_, index) => ({
id: `${offset}-${index}`,
})),
total: 8000,
count: limit,
limit,
offset,
};
});
appendAssetsMock.mockReturnValue(0);
const state = {
loading: false,
done: false,
total: 8000,
offset: 0,
requestId: 1,
abortController: null,
query: "*",
assets: [],
activeId: "",
statusMessage: "",
statusError: false,
};
const loader = useGridLoader({
gridContainerRef: { value: createVisibleElement({ dataset: {} }) },
state,
setLoadingMessage: vi.fn(),
clearLoadingMessage: vi.fn(),
setStatusMessage: vi.fn(),
clearStatusMessage: vi.fn(),
resetAssets: vi.fn(),
setSelection: vi.fn(),
reconcileSelection: vi.fn(),
readScrollElement: () => createVisibleElement(),
readRenderedCards: () => [],
scrollToAssetId: vi.fn(),
});
const result = await loader.loadNextPage();
expect(result).toMatchObject({ ok: true, skippedEmpty: true });
expect(requestedOffsets).toEqual([0, 100, 300, 700, 1500, 3100]);
await vi.advanceTimersByTimeAsync(1000);
await Promise.resolve();
await Promise.resolve();
expect(requestedOffsets).toHaveLength(6);
});
it("does not stop infinite scroll when default output browse returns a page-sized total", async () => {
const { useGridLoader } = await import("../vue/composables/useGridLoader.js");
const requestedOffsets = [];
fetchGridPageMock.mockImplementation(async (_grid, _query, limit, offset) => {
requestedOffsets.push(offset);
if (offset === 0) {
return {
ok: true,
assets: Array.from({ length: 8 }, (_, index) => ({ id: `asset-${index}` })),
total: 8,
count: 8,
limit,
offset,
};
}
return {
ok: true,
assets: [{ id: "asset-8" }],
total: null,
count: 1,
limit,
offset,
};
});
appendAssetsMock.mockImplementation((_grid, assets, state) => {
state.assets.push(...assets);
return assets.length;
});
const state = {
loading: false,
done: false,
total: null,
offset: 0,
requestId: 1,
abortController: null,
query: "*",
assets: [],
activeId: "",
statusMessage: "",
statusError: false,
};
const loader = useGridLoader({
gridContainerRef: {
value: createVisibleElement({
dataset: {
mjrScope: "output",
mjrQuery: "*",
mjrSort: "mtime_desc",
},
}),
},
state,
setLoadingMessage: vi.fn(),
clearLoadingMessage: vi.fn(),
setStatusMessage: vi.fn(),
clearStatusMessage: vi.fn(),
resetAssets: vi.fn(),
setSelection: vi.fn(),
reconcileSelection: vi.fn(),
readScrollElement: () => createVisibleElement(),
readRenderedCards: () => [],
scrollToAssetId: vi.fn(),
});
await loader.loadNextPage();
expect(state.offset).toBe(8);
expect(state.total).toBeNull();
expect(state.done).toBe(false);
await loader.loadNextPage();
expect(requestedOffsets).toEqual([0, 8]);
expect(state.assets).toHaveLength(9);
});
it("keeps append paging from resetting scroll or selection", async () => {
const { useGridLoader } = await import("../vue/composables/useGridLoader.js");
fetchGridPageMock.mockResolvedValue({
ok: true,
assets: [{ id: "asset-2", filename: "two.png" }],
total: 20,
count: 1,
limit: 100,
offset: 1,
});
appendAssetsMock.mockImplementation((_grid, assets, state) => {
state.assets.push(...assets);
return assets.length;
});
const state = {
loading: false,
done: false,
total: 20,
offset: 1,
requestId: 1,
abortController: null,
query: "*",
assets: [{ id: "asset-1", filename: "one.png" }],
selectedIds: ["asset-10"],
activeId: "asset-10",
selectionAnchorId: "asset-10",
statusMessage: "",
statusError: false,
};
const scrollElement = createVisibleElement({ scrollTop: 420 });
scrollElement.scrollHeight = 2000;
const resetAssets = vi.fn();
const reconcileSelection = vi.fn();
const setSelection = vi.fn((ids, activeId) => {
state.selectedIds = ids;
state.activeId = activeId;
});
const loader = useGridLoader({
gridContainerRef: { value: createVisibleElement({ dataset: {} }) },
state,
setLoadingMessage: vi.fn(),
clearLoadingMessage: vi.fn(),
setStatusMessage: vi.fn(),
clearStatusMessage: vi.fn(),
resetAssets,
setSelection,
reconcileSelection,
readScrollElement: () => scrollElement,
readRenderedCards: () => [],
scrollToAssetId: vi.fn(),
});
const result = await loader.appendNextPage();
expect(result.ok).toBe(true);
expect(resetAssets).not.toHaveBeenCalled();
expect(reconcileSelection).not.toHaveBeenCalled();
expect(scrollElement.scrollTop).toBe(420);
expect(state.selectedIds).toEqual(["asset-10"]);
expect(state.activeId).toBe("asset-10");
});
it("does not force the old scroll position back when the user scrolls during pagination", async () => {
const { useGridLoader } = await import("../vue/composables/useGridLoader.js");
let resolvePage;
fetchGridPageMock.mockImplementation(
() =>
new Promise((resolve) => {
resolvePage = resolve;
}),
);
appendAssetsMock.mockImplementation((_grid, assets, state) => {
state.assets.push(...assets);
return assets.length;
});
const state = {
loading: false,
done: false,
total: 20,
offset: 1,
requestId: 1,
abortController: null,
query: "*",
assets: [{ id: "asset-1", filename: "one.png" }],
selectedIds: [],
activeId: "",
statusMessage: "",
statusError: false,
};
const scrollElement = new EventTarget();
scrollElement.scrollTop = 420;
scrollElement.scrollHeight = 2000;
scrollElement.clientHeight = 500;
scrollElement.clientWidth = 320;
scrollElement.isConnected = true;
scrollElement.getClientRects = vi.fn(() => [{ width: 320, height: 500 }]);
const loader = useGridLoader({
gridContainerRef: { value: createVisibleElement({ dataset: {} }) },
state,
setLoadingMessage: vi.fn(),
clearLoadingMessage: vi.fn(),
setStatusMessage: vi.fn(),
clearStatusMessage: vi.fn(),
resetAssets: vi.fn(),
setSelection: vi.fn(),
reconcileSelection: vi.fn(),
readScrollElement: () => scrollElement,
readRenderedCards: () => [],
scrollToAssetId: vi.fn(),
});
const pending = loader.appendNextPage();
scrollElement.scrollTop = 900;
scrollElement.dispatchEvent(new Event("scroll"));
resolvePage({
ok: true,
assets: [{ id: "asset-2", filename: "two.png" }],
total: 20,
count: 1,
limit: 100,
offset: 1,
});
await pending;
expect(scrollElement.scrollTop).toBe(900);
});
it("does not prune unloaded selections after a partial reload", async () => {
const { useGridLoader } = await import("../vue/composables/useGridLoader.js");
fetchGridPageMock.mockResolvedValue({
ok: true,
assets: [{ id: "asset-1", filename: "one.png" }],
total: 500,
count: 1,
limit: 100,
offset: 0,
});
appendAssetsMock.mockImplementation((_grid, assets, state) => {
state.assets.push(...assets);
return assets.length;
});
const state = {
loading: false,
done: false,
total: null,
offset: 0,
requestId: 1,
abortController: null,
query: "*",
assets: [],
selectedIds: ["asset-40"],
activeId: "asset-40",
statusMessage: "",
statusError: false,
};
const resetAssets = vi.fn(({ query = "*", total = null, done = false } = {}) => {
state.query = query;
state.total = total;
state.done = done;
state.offset = 0;
state.assets = [];
});
const reconcileSelection = vi.fn();
const loader = useGridLoader({
gridContainerRef: { value: createVisibleElement({ dataset: {} }) },
state,
setLoadingMessage: vi.fn(),
clearLoadingMessage: vi.fn(),
setStatusMessage: vi.fn(),
clearStatusMessage: vi.fn(),
resetAssets,
setSelection: vi.fn(),
reconcileSelection,
readScrollElement: () => createVisibleElement(),
readRenderedCards: () => [],
scrollToAssetId: vi.fn(),
});
await loader.reload("*", { preserveVisibleUntilReady: false });
expect(reconcileSelection).not.toHaveBeenCalled();
expect(state.selectedIds).toEqual(["asset-40"]);
expect(state.activeId).toBe("asset-40");
});
it("exposes canonical grid state and debug metrics", async () => {
const { useGridLoader } = await import("../vue/composables/useGridLoader.js");
const state = {
loading: false,
done: false,
total: 2,
offset: 1,
requestId: 7,
abortController: null,
query: "*",
assets: [{ id: "asset-1", filename: "one.png" }],
selectedIds: ["asset-1"],
activeId: "asset-1",
selectionAnchorId: "asset-1",
statusMessage: "",
statusError: false,
};
const gridContainer = createVisibleElement({
dataset: {
mjrScope: "output",
mjrQuery: "*",
mjrSort: "mtime_desc",
mjrShown: "1",
},
});
const scrollElement = createVisibleElement({ scrollTop: 64 });
scrollElement.scrollHeight = 512;
const loader = useGridLoader({
gridContainerRef: { value: gridContainer },
state,
setLoadingMessage: vi.fn(),
clearLoadingMessage: vi.fn(),
setStatusMessage: vi.fn(),
clearStatusMessage: vi.fn(),
resetAssets: vi.fn(),
setSelection: vi.fn(),
reconcileSelection: vi.fn(),
readScrollElement: () => scrollElement,
readRenderedCards: () => [],
scrollToAssetId: vi.fn(),
});
expect(loader.getCanonicalState()).toMatchObject({
pagination: {
query: "*",
offset: 1,
total: 2,
loading: false,
done: false,
requestId: 7,
},
selection: {
selectedIds: ["asset-1"],
activeId: "asset-1",
anchorId: "asset-1",
},
viewport: {
scrollTop: 64,
},
context: {
scope: "output",
query: "*",
sort: "mtime_desc",
},
});
expect(loader.getDebugSnapshot()).toMatchObject({
counts: {
loaded: 1,
visible: 1,
total: 2,
},
metrics: {
pagesRequested: 0,
resetCount: 0,
},
});
});
it("preserves inline query syntax for advanced search tokens", async () => {
const { useGridLoader } = await import("../vue/composables/useGridLoader.js");
fetchGridPageMock.mockResolvedValue({
ok: true,
assets: [],
total: 0,
count: 0,
limit: 100,
offset: 0,
});
appendAssetsMock.mockReturnValue(0);
const state = {
loading: false,
done: false,
total: 0,
offset: 0,
requestId: 1,
abortController: null,
query: "*",
assets: [],
activeId: "",
statusMessage: "",
statusError: false,
};
const loader = useGridLoader({
gridContainerRef: { value: createVisibleElement({ dataset: {} }) },
state,
setLoadingMessage: vi.fn(),
clearLoadingMessage: vi.fn(),
setStatusMessage: vi.fn(),
clearStatusMessage: vi.fn(),
resetAssets: vi.fn(({ query = "*", total = null, done = false } = {}) => {
state.query = query;
state.total = total;
state.done = done;
state.offset = 0;
state.assets = [];
}),
setSelection: vi.fn(),
reconcileSelection: vi.fn(),
readScrollElement: () => createVisibleElement(),
readRenderedCards: () => [],
scrollToAssetId: vi.fn(),
});
const query = 'ext:png rating:5 "speed cinematic push-in"';
await loader.loadAssets(query, { preserveVisibleUntilReady: false });
expect(state.query).toBe(query);
expect(fetchGridPageMock).toHaveBeenCalledWith(
expect.anything(),
query,
expect.any(Number),
0,
expect.any(Object),
expect.any(Object),
);
});
it("does not hydrate persisted grid snapshots while the snapshot cache is disabled", async () => {
appendAssetsMock.mockImplementation((_grid, assets, state) => {
state.assets = Array.isArray(assets) ? assets.map((asset) => ({ ...asset })) : [];
return state.assets.length;
});
let module = await import("../vue/composables/useGridLoader.js");
const gridContainer = createVisibleElement({
dataset: {
mjrScope: "output",
mjrQuery: "*",
mjrSort: "mtime_desc",
},
});
const scrollElement = createVisibleElement();
const state1 = {
loading: false,
done: false,
total: 50,
offset: 2,
requestId: 1,
abortController: null,
query: "*",
assets: [
{ id: 1, filename: "one.png", kind: "image", source: "output" },
{ id: 2, filename: "two.png", kind: "image", source: "output" },
],
activeId: "",
statusMessage: "",
statusError: false,
};
const loader1 = module.useGridLoader({
gridContainerRef: { value: gridContainer },
state: state1,
setLoadingMessage: vi.fn(),
clearLoadingMessage: vi.fn(),
setStatusMessage: vi.fn(),
clearStatusMessage: vi.fn(),
resetAssets: vi.fn(),
setSelection: vi.fn(),
reconcileSelection: vi.fn(),
readScrollElement: () => scrollElement,
readRenderedCards: () => [],
scrollToAssetId: vi.fn(),
});
loader1.dispose();
vi.resetModules();
module = await import("../vue/composables/useGridLoader.js");
const state2 = {
loading: false,
done: false,
total: null,
offset: 0,
requestId: 1,
abortController: null,
assets: [],
activeId: "",
statusMessage: "",
statusError: false,
};
const resetAssets = vi.fn(({ query = "*", total = null, done = false } = {}) => {
state2.query = query;
state2.total = total;
state2.done = done;
state2.assets = [];
});
const loader2 = module.useGridLoader({
gridContainerRef: { value: gridContainer },
state: state2,
setLoadingMessage: vi.fn(),
clearLoadingMessage: vi.fn(),
setStatusMessage: vi.fn(),
clearStatusMessage: vi.fn(),
resetAssets,
setSelection: vi.fn(),
reconcileSelection: vi.fn(),
readScrollElement: () => scrollElement,
readRenderedCards: () => [],
scrollToAssetId: vi.fn(),
});
const restored = await loader2.hydrateFromSnapshot({
scope: "output",
query: "*",
sort: "mtime_desc",
});
expect(restored).toBe(false);
expect(state2.assets).toEqual([]);
expect(state2.offset).toBe(0);
expect(state2.total).toBeNull();
expect(state2.done).toBe(false);
});
it("pages through an 8000+ asset library across repeated scroll loads", async () => {
const { useGridLoader } = await import("../vue/composables/useGridLoader.js");
const total = 8200;
const requestedOffsets = [];
fetchGridPageMock.mockImplementation(async (_grid, _query, limit, offset) => {
requestedOffsets.push(offset);
const end = Math.min(total, offset + limit);
return {
ok: true,
assets: Array.from({ length: Math.max(0, end - offset) }, (_, index) => ({
id: `asset-${offset + index + 1}`,
})),
total,
count: end - offset,
limit,
offset,
};
});
appendAssetsMock.mockImplementation((_grid, assets, state) => {
const list = Array.isArray(assets) ? assets : [];
state.assets = [...(Array.isArray(state.assets) ? state.assets : []), ...list];
return list.length;
});
const state = {
loading: false,
done: false,
total,
offset: 0,
requestId: 1,
abortController: null,
query: "*",
assets: [],
activeId: "",
statusMessage: "",
statusError: false,
};
const loader = useGridLoader({
gridContainerRef: { value: createVisibleElement({ dataset: {} }) },
state,
setLoadingMessage: vi.fn(),
clearLoadingMessage: vi.fn(),
setStatusMessage: vi.fn(),
clearStatusMessage: vi.fn(),
resetAssets: vi.fn(),
setSelection: vi.fn(),
reconcileSelection: vi.fn(),
readScrollElement: () => createVisibleElement(),
readRenderedCards: () => [],
scrollToAssetId: vi.fn(),
});
for (let guard = 0; guard < 100 && !state.done; guard += 1) {
const result = await loader.loadNextPage();
expect(result.ok).toBe(true);
}
expect(state.done).toBe(true);
expect(state.offset).toBe(total);
expect(state.assets).toHaveLength(total);
expect(requestedOffsets.length).toBeGreaterThan(10);
expect(requestedOffsets.at(-1)).toBe(8200 - 100);
});
it("does not replace existing assets from snapshots while the snapshot cache is disabled", async () => {
appendAssetsMock.mockImplementation((_grid, assets, state) => {
state.assets = Array.isArray(assets) ? assets.map((asset) => ({ ...asset })) : [];
return state.assets.length;
});
let module = await import("../vue/composables/useGridLoader.js");
const gridContainer = createVisibleElement({
dataset: {
mjrScope: "output",
mjrQuery: "*",
mjrSort: "mtime_desc",
},
});
const scrollElement = createVisibleElement();
const snapshotSeedState = {
loading: false,
done: false,
total: 2,
offset: 2,
requestId: 1,
abortController: null,
query: "*",
assets: [
{ id: 1, filename: "out-one.png", kind: "image", source: "output" },
{ id: 2, filename: "out-two.png", kind: "image", source: "output" },
],
activeId: "",
statusMessage: "",
statusError: false,
};
const seedLoader = module.useGridLoader({
gridContainerRef: { value: gridContainer },
state: snapshotSeedState,
setLoadingMessage: vi.fn(),
clearLoadingMessage: vi.fn(),
setStatusMessage: vi.fn(),
clearStatusMessage: vi.fn(),
resetAssets: vi.fn(),
setSelection: vi.fn(),
reconcileSelection: vi.fn(),
readScrollElement: () => scrollElement,
readRenderedCards: () => [],
scrollToAssetId: vi.fn(),
});
seedLoader.dispose();
vi.resetModules();
module = await import("../vue/composables/useGridLoader.js");
appendAssetsMock.mockImplementation((_grid, assets, state) => {
state.assets = Array.isArray(assets) ? assets.map((asset) => ({ ...asset })) : [];
return state.assets.length;
});
const inputGridContainer = createVisibleElement({
dataset: {
mjrScope: "output",
mjrQuery: "*",
mjrSort: "mtime_desc",
},
});
const state = {
loading: false,
done: false,
total: 1,
offset: 1,
requestId: 1,
abortController: null,
query: "*",
assets: [{ id: 9, filename: "input-one.png", kind: "image", source: "input" }],
activeId: "",
statusMessage: "",
statusError: false,
};
const resetAssets = vi.fn(({ query = "*", total = null, done = false } = {}) => {
state.query = query;
state.total = total;
state.done = done;
state.offset = 0;
state.assets = [];
});
const loader = module.useGridLoader({
gridContainerRef: { value: inputGridContainer },
state,
setLoadingMessage: vi.fn(),
clearLoadingMessage: vi.fn(),
setStatusMessage: vi.fn(),
clearStatusMessage: vi.fn(),
resetAssets,
setSelection: vi.fn(),
reconcileSelection: vi.fn(),
readScrollElement: () => scrollElement,
readRenderedCards: () => [],
scrollToAssetId: vi.fn(),
});
const restored = await loader.hydrateFromSnapshot(
{ scope: "output", query: "*", sort: "mtime_desc" },
{ allowReplaceExisting: true },
);
expect(restored).toBe(false);
expect(state.assets.map((asset) => asset.filename)).toEqual(["input-one.png"]);
});
it("keeps scope-switch assets visible until the replacement page arrives", async () => {
const { useGridLoader } = await import("../vue/composables/useGridLoader.js");
let resolvePage;
fetchGridPageMock.mockImplementation(
() =>
new Promise((resolve) => {
resolvePage = resolve;
}),
);
appendAssetsMock.mockImplementation((_grid, assets, state) => {
state.assets = Array.isArray(assets) ? assets.map((asset) => ({ ...asset })) : [];
return state.assets.length;
});
const state = {
loading: false,
done: false,
total: 1,
offset: 1,
requestId: 1,
abortController: new AbortController(),
query: "*",
assets: [{ id: "input-1", filename: "input-one.png", kind: "image", source: "input" }],
activeId: "",
statusMessage: "",
statusError: false,
};
const gridContainer = createVisibleElement({
dataset: {
mjrScope: "output",
mjrQuery: "*",
mjrSort: "mtime_desc",
},
});
const scrollElement = createVisibleElement();
const resetAssets = vi.fn(({ query = "*", total = null, done = false } = {}) => {
state.query = query;
state.total = total;
state.done = done;
state.offset = 0;
state.assets = [];
});
const loader = useGridLoader({
gridContainerRef: { value: gridContainer },
state,
setLoadingMessage: vi.fn(),
clearLoadingMessage: vi.fn(),
setStatusMessage: vi.fn(),
clearStatusMessage: vi.fn(),
resetAssets,
setSelection: vi.fn(),
reconcileSelection: vi.fn(),
readScrollElement: () => scrollElement,
readRenderedCards: () => [],
scrollToAssetId: vi.fn(),
});
const previousController = state.abortController;
loader.prepareGridForScopeSwitch();
expect(resetAssets).not.toHaveBeenCalled();
expect(state.assets.map((asset) => asset.filename)).toEqual(["input-one.png"]);
expect(previousController.signal.aborted).toBe(true);
expect(state.abortController).toBeNull();
expect(state.loading).toBe(true);
const pendingLoad = loader.loadAssets("*", { reset: true });
expect(resetAssets).not.toHaveBeenCalled();
expect(state.assets.map((asset) => asset.filename)).toEqual(["input-one.png"]);
expect(fetchGridPageMock.mock.calls.at(-1)?.[3]).toBe(0);
resolvePage({
ok: true,
assets: [
{ id: "output-1", filename: "output-one.png", kind: "image", source: "output" },
],
total: 1,
count: 1,
limit: 100,
offset: 0,
});
await pendingLoad;
expect(resetAssets).toHaveBeenCalledTimes(1);
expect(state.assets.map((asset) => asset.filename)).toEqual(["output-one.png"]);
});
it("starts deferred visual resets from offset zero instead of the preserved asset count", async () => {
const { useGridLoader } = await import("../vue/composables/useGridLoader.js");
const requestedOffsets = [];
fetchGridPageMock.mockImplementation(async (_grid, _query, _limit, offset) => {
requestedOffsets.push(offset);
return {
ok: true,
assets: [
{ id: "new-1", filename: "new-head.png", kind: "image", source: "output" },
],
total: null,
count: 1,
limit: 100,
offset,
};
});
appendAssetsMock.mockImplementation((_grid, assets, state) => {
state.assets.push(...assets.map((asset) => ({ ...asset })));
return assets.length;
});
const state = {
loading: false,
done: false,
total: null,
offset: 200,
requestId: 1,
abortController: null,
query: "old-search",
assets: Array.from({ length: 200 }, (_, index) => ({
id: `old-${index}`,
filename: `old-${index}.png`,
kind: "image",
source: "output",
})),
activeId: "",
statusMessage: "",
statusError: false,
};
const gridContainer = createVisibleElement({
dataset: {
mjrScope: "output",
mjrQuery: "*",
mjrSort: "mtime_desc",
},
});
const scrollElement = createVisibleElement();
const resetAssets = vi.fn(({ query = "*", total = null, done = false } = {}) => {
state.query = query;
state.total = total;
state.done = done;
state.offset = 0;
state.assets = [];
});
const loader = useGridLoader({
gridContainerRef: { value: gridContainer },
state,
setLoadingMessage: vi.fn(),
clearLoadingMessage: vi.fn(),
setStatusMessage: vi.fn(),
clearStatusMessage: vi.fn(),
resetAssets,
setSelection: vi.fn(),
reconcileSelection: vi.fn(),
readScrollElement: () => scrollElement,
readRenderedCards: () => [],
scrollToAssetId: vi.fn(),
});
await loader.loadAssets("*", { reset: true, preserveVisibleUntilReady: true });
expect(requestedOffsets).toEqual([0]);
expect(resetAssets).toHaveBeenCalledTimes(1);
expect(state.assets.map((asset) => asset.filename)).toEqual(["new-head.png"]);
expect(state.offset).toBe(1);
});
it("does not let an aborted viewport fill skip the scope reload", async () => {
const { useGridLoader } = await import("../vue/composables/useGridLoader.js");
fetchGridPageMock.mockResolvedValue({
ok: true,
assets: [
{ id: "output-1", filename: "output-one.png", kind: "image", source: "output" },
],
total: 1,
count: 1,
limit: 100,
offset: 0,
});
appendAssetsMock.mockImplementation((_grid, assets, state) => {
state.assets = Array.isArray(assets) ? assets.map((asset) => ({ ...asset })) : [];
return state.assets.length;
});
const state = {
loading: true,
done: false,
total: null,
offset: 0,
requestId: 5,
abortController: new AbortController(),
query: "*",
assets: [],
activeId: "",
statusMessage: "",
statusError: false,
};
const gridContainer = createVisibleElement({
dataset: {
mjrScope: "output",
mjrQuery: "*",
mjrSort: "mtime_desc",
},
});
const resetAssets = vi.fn(({ query = "*", total = null, done = false } = {}) => {
state.query = query;
state.total = total;
state.done = done;
state.offset = 0;
state.assets = [];
});
const loader = useGridLoader({
gridContainerRef: { value: gridContainer },
state,
setLoadingMessage: vi.fn(),
clearLoadingMessage: vi.fn(),
setStatusMessage: vi.fn(),
clearStatusMessage: vi.fn(),
resetAssets,
setSelection: vi.fn(),
reconcileSelection: vi.fn(),
readScrollElement: () => createVisibleElement(),
readRenderedCards: () => [],
scrollToAssetId: vi.fn(),
});
await loader.loadAssets("*", { reset: true });
expect(fetchGridPageMock).toHaveBeenCalledTimes(1);
expect(fetchGridPageMock.mock.calls[0]?.[3]).toBe(0);
expect(state.assets.map((asset) => asset.filename)).toEqual(["output-one.png"]);
});
it("does not allow background refresh reasons to clear a populated grid immediately", async () => {
const { useGridLoader } = await import("../vue/composables/useGridLoader.js");
let resolvePage;
fetchGridPageMock.mockImplementation(
() =>
new Promise((resolve) => {
resolvePage = resolve;
}),
);
appendAssetsMock.mockImplementation((_grid, assets, state) => {
state.assets = Array.isArray(assets) ? assets.map((asset) => ({ ...asset })) : [];
return state.assets.length;
});
const state = {
loading: false,
done: false,
total: 1,
offset: 1,
requestId: 1,
abortController: null,
query: "*",
assets: [{ id: "old-1", filename: "old.png", kind: "image" }],
activeId: "",
statusMessage: "",
statusError: false,
_mjrLastGridContext: {
scope: "output",
query: "*",
customRootId: "",
subfolder: "",
collectionId: "",
viewScope: "",
kind: "",
workflowOnly: false,
minRating: "",
minSizeMB: "",
maxSizeMB: "",
resolutionCompare: "",
minWidth: "",
minHeight: "",
maxWidth: "",
maxHeight: "",
workflowType: "",
dateRange: "",
dateExact: "",
sort: "mtime_desc",
semanticMode: false,
},
};
const gridContainer = createVisibleElement({
dataset: {
mjrScope: "output",
mjrQuery: "*",
mjrSort: "mtime_desc",
},
});
const resetAssets = vi.fn(({ query = "*", total = null, done = false } = {}) => {
state.query = query;
state.total = total;
state.done = done;
state.offset = 0;
state.assets = [];
});
const loader = useGridLoader({
gridContainerRef: { value: gridContainer },
state,
setLoadingMessage: vi.fn(),
clearLoadingMessage: vi.fn(),
setStatusMessage: vi.fn(),
clearStatusMessage: vi.fn(),
resetAssets,
setSelection: vi.fn(),
reconcileSelection: vi.fn(),
readScrollElement: () => createVisibleElement(),
readRenderedCards: () => [],
scrollToAssetId: vi.fn(),
});
const pendingLoad = loader.loadAssets("*", {
reset: true,
preserveVisibleUntilReady: false,
reason: "scan",
});
expect(resetAssets).not.toHaveBeenCalled();
expect(state.assets.map((asset) => asset.filename)).toEqual(["old.png"]);
resolvePage({
ok: true,
assets: [{ id: "new-1", filename: "new.png", kind: "image" }],
total: 1,
count: 1,
limit: 100,
offset: 0,
});
await pendingLoad;
expect(resetAssets).toHaveBeenCalledTimes(1);
expect(loader.getDebugSnapshot().metrics).toMatchObject({
blockedImmediateResetCount: 1,
lastResetReason: "scan",
});
expect(state.assets.map((asset) => asset.filename)).toEqual(["new.png"]);
});
it("allows immediate reset when sort/filter/search scope context changes", async () => {
const { useGridLoader } = await import("../vue/composables/useGridLoader.js");
let resolvePage;
fetchGridPageMock.mockImplementation(
() =>
new Promise((resolve) => {
resolvePage = resolve;
}),
);
appendAssetsMock.mockImplementation((_grid, assets, state) => {
state.assets = Array.isArray(assets) ? assets.map((asset) => ({ ...asset })) : [];
return state.assets.length;
});
const state = {
loading: false,
done: false,
total: 1,
offset: 1,
requestId: 1,
abortController: null,
query: "*",
assets: [{ id: "old-1", filename: "old.png", kind: "image" }],
activeId: "",
statusMessage: "",
statusError: false,
_mjrLastGridContext: {
scope: "output",
query: "*",
customRootId: "",
subfolder: "",
collectionId: "",
viewScope: "",
kind: "",
workflowOnly: false,
sort: "mtime_desc",
semanticMode: false,
},
};
const gridContainer = createVisibleElement({
dataset: {
mjrScope: "output",
mjrQuery: "*",
mjrSort: "name_asc",
},
});
const resetAssets = vi.fn(({ query = "*", total = null, done = false } = {}) => {
state.query = query;
state.total = total;
state.done = done;
state.offset = 0;
state.assets = [];
});
const loader = useGridLoader({
gridContainerRef: { value: gridContainer },
state,
setLoadingMessage: vi.fn(),
clearLoadingMessage: vi.fn(),
setStatusMessage: vi.fn(),
clearStatusMessage: vi.fn(),
resetAssets,
setSelection: vi.fn(),
reconcileSelection: vi.fn(),
readScrollElement: () => createVisibleElement(),
readRenderedCards: () => [],
scrollToAssetId: vi.fn(),
});
const pendingLoad = loader.loadAssets("*", {
reset: true,
preserveVisibleUntilReady: false,
});
expect(resetAssets).toHaveBeenCalledTimes(1);
expect(loader.getDebugSnapshot().metrics.lastResetReason).toBe("sort");
resolvePage({
ok: true,
assets: [{ id: "new-1", filename: "new.png", kind: "image" }],
total: 1,
count: 1,
limit: 100,
offset: 0,
});
await pendingLoad;
expect(resetAssets).toHaveBeenCalledTimes(1);
expect(state.assets.map((asset) => asset.filename)).toEqual(["new.png"]);
});
it("skips next-page loading while the grid host is hidden", async () => {
const { useGridLoader } = await import("../vue/composables/useGridLoader.js");
const state = {
loading: false,
done: false,
total: 7000,
offset: 0,
requestId: 1,
abortController: null,
assets: [],
activeId: "",
statusMessage: "",
statusError: false,
};
const gridContainer = {
dataset: {},
isConnected: false,
getClientRects: vi.fn(() => []),
};
const scrollElement = {
clientWidth: 320,
clientHeight: 240,
isConnected: false,
getClientRects: vi.fn(() => []),
};
const loader = useGridLoader({
gridContainerRef: { value: gridContainer },
state,
setLoadingMessage: vi.fn(),
clearLoadingMessage: vi.fn(),
setStatusMessage: vi.fn(),
clearStatusMessage: vi.fn(),
resetAssets: vi.fn(),
setSelection: vi.fn(),
reconcileSelection: vi.fn(),
readScrollElement: () => scrollElement,
readRenderedCards: () => [],
scrollToAssetId: vi.fn(),
});
const result = await loader.loadNextPage();
expect(result).toMatchObject({ ok: true, skipped: true, hidden: true });
expect(fetchGridPageMock).not.toHaveBeenCalled();
});
it("continues loading when only the scroll wrapper has a visible rect", async () => {
const { useGridLoader } = await import("../vue/composables/useGridLoader.js");
fetchGridPageMock.mockResolvedValue({
ok: true,
assets: [{ id: "asset-1" }],
total: 1,
count: 1,
limit: 100,
offset: 0,
});
appendAssetsMock.mockReturnValue(1);
const state = {
loading: false,
done: false,
total: 1,
offset: 0,
requestId: 1,
abortController: null,
assets: [],
activeId: "",
statusMessage: "",
statusError: false,
};
const gridContainer = {
dataset: {},
isConnected: true,
clientWidth: 0,
clientHeight: 0,
getClientRects: vi.fn(() => []),
};
const scrollElement = createVisibleElement();
const loader = useGridLoader({
gridContainerRef: { value: gridContainer },
state,
setLoadingMessage: vi.fn(),
clearLoadingMessage: vi.fn(),
setStatusMessage: vi.fn(),
clearStatusMessage: vi.fn(),
resetAssets: vi.fn(),
setSelection: vi.fn(),
reconcileSelection: vi.fn(),
readScrollElement: () => scrollElement,
readRenderedCards: () => [],
scrollToAssetId: vi.fn(),
});
const result = await loader.loadNextPage();
expect(result).toMatchObject({ ok: true, count: 1, total: 1 });
expect(fetchGridPageMock).toHaveBeenCalledTimes(1);
});
});