feat(grid): preserve data-grid viewport and selection across tab switches
This commit is contained in:
1 parent
2b26bd6a1a
commit
41b5e51dcc
21 files changed
+1231
-8
No files matched your search
@@ -0,0 +1,206 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
DATA_GRID_VIEW_SNAPSHOT_RESTORE,
|
||||
MAX_CACHE_BYTES,
|
||||
MAX_SNAPSHOT_BYTES,
|
||||
MAX_SPARSE_CELL_ENTRIES,
|
||||
beginClosingDataGridViewSnapshotsForTab,
|
||||
buildDataGridViewProbe,
|
||||
clampDataGridViewSelection,
|
||||
clearDataGridViewSnapshot,
|
||||
clearDataGridViewSnapshotsForTab,
|
||||
dataGridViewSnapshotCacheBytes,
|
||||
dataGridViewSnapshotCacheSize,
|
||||
peekDataGridViewSnapshot,
|
||||
resetDataGridViewSnapshots,
|
||||
saveDataGridViewSnapshot,
|
||||
shouldNotifyOverBudgetSelection,
|
||||
type DataGridViewSelectionSnapshot,
|
||||
type DataGridViewSnapshot,
|
||||
} from "@/lib/dataGrid/dataGridViewStateCache";
|
||||
|
||||
function snapshot(ownerKey: string, patch: Partial<DataGridViewSnapshot> = {}): DataGridViewSnapshot {
|
||||
return {
|
||||
ownerKey,
|
||||
viewGeneration: "gen-1",
|
||||
probe: "probe",
|
||||
renderer: "dom",
|
||||
rowCount: 10,
|
||||
columnCount: 2,
|
||||
viewport: { top: 100, left: 0 },
|
||||
...patch,
|
||||
};
|
||||
}
|
||||
|
||||
function sparseCellSelection(count: number): DataGridViewSelectionSnapshot {
|
||||
return { kind: "cells", cellKeys: Array.from({ length: count }, (_, index) => `${index}:${index}`) };
|
||||
}
|
||||
|
||||
describe("dataGridViewStateCache", () => {
|
||||
beforeEach(() => {
|
||||
resetDataGridViewSnapshots();
|
||||
});
|
||||
|
||||
it("exposes an enabled rollback switch by default", () => {
|
||||
expect(DATA_GRID_VIEW_SNAPSHOT_RESTORE).toBe(true);
|
||||
});
|
||||
|
||||
it("round-trips a snapshot and refreshes LRU recency on peek", () => {
|
||||
saveDataGridViewSnapshot(snapshot("tab-1-run-1-0"));
|
||||
expect(peekDataGridViewSnapshot("tab-1-run-1-0")?.viewport.top).toBe(100);
|
||||
expect(peekDataGridViewSnapshot("missing")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("caps the cache at 32 owners and evicts the least recently used", () => {
|
||||
for (let index = 0; index < 33; index += 1) saveDataGridViewSnapshot(snapshot(`tab-${index}`));
|
||||
|
||||
expect(dataGridViewSnapshotCacheSize()).toBe(32);
|
||||
expect(peekDataGridViewSnapshot("tab-0")).toBeUndefined();
|
||||
expect(peekDataGridViewSnapshot("tab-32")).toBeDefined();
|
||||
});
|
||||
|
||||
it("measures size in bytes, not UTF-16 code units", () => {
|
||||
// A selection of multi-byte characters: byte length exceeds code-unit count.
|
||||
const selection: DataGridViewSelectionSnapshot = { kind: "cells", cellKeys: Array.from({ length: 300 }, () => "格:式") };
|
||||
const stored = snapshot("tab-1", { selection });
|
||||
|
||||
saveDataGridViewSnapshot(stored);
|
||||
|
||||
// ~300 CJK keys cost well above 600 UTF-16 units but above 1800 bytes; a
|
||||
// code-unit measure would keep this under a tiny budget while bytes exceed it.
|
||||
const kept = peekDataGridViewSnapshot("tab-1");
|
||||
expect(JSON.stringify(kept ?? {}).length).toBeGreaterThan(600);
|
||||
});
|
||||
|
||||
it("drops the cell keys when they exceed the per-snapshot byte budget and marks the snapshot", () => {
|
||||
// Long keys push the serialized selection past the 64 KB budget.
|
||||
const selection: DataGridViewSelectionSnapshot = { kind: "cells", cellKeys: Array.from({ length: 1200 }, (_, index) => `${index}:${"cell".repeat(20)}`) };
|
||||
const result = saveDataGridViewSnapshot(snapshot("tab-1", { selection }));
|
||||
|
||||
expect(result.droppedSelection).toBe(true);
|
||||
const kept = peekDataGridViewSnapshot("tab-1");
|
||||
expect(kept?.selection?.kind === "cells" && kept.selection.cellKeys).toEqual([]);
|
||||
expect(kept?.selectionDropped).toBe(true);
|
||||
expect(kept?.viewport.top).toBe(100);
|
||||
expect(dataGridViewSnapshotCacheBytes()).toBeLessThan(MAX_SNAPSHOT_BYTES);
|
||||
});
|
||||
|
||||
it("keeps a selection that fits the budget without a dropped flag", () => {
|
||||
const result = saveDataGridViewSnapshot(snapshot("tab-1", { selection: sparseCellSelection(3) }));
|
||||
|
||||
expect(result.droppedSelection).toBe(false);
|
||||
const kept = peekDataGridViewSnapshot("tab-1");
|
||||
expect(kept?.selection?.kind === "cells" && kept.selection.cellKeys).toHaveLength(3);
|
||||
expect(kept?.selectionDropped).toBeUndefined();
|
||||
});
|
||||
|
||||
it("clamps cell keys beyond the sparse cardinality cap", () => {
|
||||
const over = sparseCellSelection(MAX_SPARSE_CELL_ENTRIES + 1);
|
||||
const result = clampDataGridViewSelection(over);
|
||||
|
||||
expect(result.droppedSelection).toBe(true);
|
||||
expect(result.selection.kind === "cells" && result.selection.cellKeys).toEqual([]);
|
||||
expect(clampDataGridViewSelection(sparseCellSelection(MAX_SPARSE_CELL_ENTRIES)).droppedSelection).toBe(false);
|
||||
});
|
||||
|
||||
it("clears a single owner and its notice marker", () => {
|
||||
saveDataGridViewSnapshot(snapshot("tab-1"));
|
||||
expect(shouldNotifyOverBudgetSelection("tab-1", "gen-1")).toBe(true);
|
||||
|
||||
clearDataGridViewSnapshot("tab-1");
|
||||
|
||||
expect(peekDataGridViewSnapshot("tab-1")).toBeUndefined();
|
||||
expect(shouldNotifyOverBudgetSelection("tab-1", "gen-1")).toBe(true);
|
||||
});
|
||||
|
||||
it("clears every snapshot belonging to a tab", () => {
|
||||
saveDataGridViewSnapshot(snapshot("tab-1-run-a-0"));
|
||||
saveDataGridViewSnapshot(snapshot("tab-1-run-b-0"));
|
||||
saveDataGridViewSnapshot(snapshot("tab-2-run-a-0"));
|
||||
|
||||
clearDataGridViewSnapshotsForTab("tab-1");
|
||||
|
||||
expect(peekDataGridViewSnapshot("tab-1-run-a-0")).toBeUndefined();
|
||||
expect(peekDataGridViewSnapshot("tab-1-run-b-0")).toBeUndefined();
|
||||
expect(peekDataGridViewSnapshot("tab-2-run-a-0")).toBeDefined();
|
||||
});
|
||||
|
||||
it("lets a tab capture again right after a plain clear (no tombstone)", () => {
|
||||
clearDataGridViewSnapshotsForTab("tab-1");
|
||||
saveDataGridViewSnapshot(snapshot("tab-1"));
|
||||
|
||||
expect(peekDataGridViewSnapshot("tab-1")).toBeDefined();
|
||||
});
|
||||
|
||||
it("notifies at most once per owner and generation", () => {
|
||||
expect(shouldNotifyOverBudgetSelection("tab-1", "gen-1")).toBe(true);
|
||||
expect(shouldNotifyOverBudgetSelection("tab-1", "gen-1")).toBe(false);
|
||||
// A new logical result is a new chance to report.
|
||||
expect(shouldNotifyOverBudgetSelection("tab-1", "gen-2")).toBe(true);
|
||||
expect(shouldNotifyOverBudgetSelection("tab-2", "gen-1")).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps the whole cache within the global byte budget", () => {
|
||||
for (let index = 0; index < 40; index += 1) {
|
||||
saveDataGridViewSnapshot(snapshot(`tab-${index}`, { selection: sparseCellSelection(2000) }));
|
||||
}
|
||||
|
||||
expect(dataGridViewSnapshotCacheBytes()).toBeLessThanOrEqual(MAX_CACHE_BYTES);
|
||||
expect(dataGridViewSnapshotCacheSize()).toBeLessThanOrEqual(32);
|
||||
});
|
||||
|
||||
it("blocks writes for a closing tab without requiring window timers", () => {
|
||||
const originalWindow = globalThis.window;
|
||||
// Test hosts may stub `window` without timer functions.
|
||||
(globalThis as { window?: unknown }).window = {};
|
||||
try {
|
||||
expect(() => beginClosingDataGridViewSnapshotsForTab("tab-1")).not.toThrow();
|
||||
} finally {
|
||||
(globalThis as { window?: unknown }).window = originalWindow;
|
||||
}
|
||||
|
||||
saveDataGridViewSnapshot(snapshot("tab-1"));
|
||||
|
||||
expect(peekDataGridViewSnapshot("tab-1")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildDataGridViewProbe", () => {
|
||||
it("is deterministic and bounded", () => {
|
||||
const long = "x".repeat(500);
|
||||
const input = {
|
||||
columns: Array.from({ length: 40 }, (_, index) => `column-${index}`),
|
||||
columnTypes: Array.from({ length: 40 }, (_, index) => `type-${index}`),
|
||||
rowCount: 3,
|
||||
firstRow: [long, 1, 2, 3, 4],
|
||||
lastRow: ["z", 1],
|
||||
};
|
||||
|
||||
const probe = buildDataGridViewProbe(input);
|
||||
|
||||
expect(probe).toBe(buildDataGridViewProbe(input));
|
||||
expect(probe).not.toContain(long);
|
||||
expect(probe).toContain("x".repeat(64));
|
||||
// Bounded to 16 columns / 4 probed row columns.
|
||||
expect(probe).not.toContain("column-20");
|
||||
});
|
||||
|
||||
it("changes when the dataset changes", () => {
|
||||
const base = { columns: ["id", "name"], rowCount: 1, firstRow: [1, "a"] };
|
||||
|
||||
expect(buildDataGridViewProbe(base)).not.toBe(buildDataGridViewProbe({ ...base, firstRow: [2, "a"] }));
|
||||
expect(buildDataGridViewProbe(base)).not.toBe(buildDataGridViewProbe({ ...base, rowCount: 2 }));
|
||||
expect(buildDataGridViewProbe(base)).not.toBe(buildDataGridViewProbe({ ...base, columns: ["id", "other"] }));
|
||||
});
|
||||
|
||||
it("skips large-value cells instead of reading them", () => {
|
||||
const input = {
|
||||
columns: ["id", "blob"],
|
||||
rowCount: 1,
|
||||
firstRow: [1, "huge-value"],
|
||||
largeValueCells: [{ row_index: 0, column_index: 1 }],
|
||||
};
|
||||
|
||||
expect(buildDataGridViewProbe(input)).not.toContain("huge-value");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { decodeTabResultSnapshot, encodeTabResultSnapshot } from "@/lib/tabs/tabResultCache";
|
||||
import type { QueryResult } from "@/types/database";
|
||||
|
||||
function sampleResult(): QueryResult {
|
||||
return {
|
||||
columns: ["id", "name"],
|
||||
rows: [[1, "a"]],
|
||||
affected_rows: 0,
|
||||
execution_time_ms: 1,
|
||||
};
|
||||
}
|
||||
|
||||
describe("tab result cache view generation", () => {
|
||||
it("round-trips the tab-level view generation for a data tab with no runs", () => {
|
||||
const restored = decodeTabResultSnapshot(
|
||||
encodeTabResultSnapshot({
|
||||
result: sampleResult(),
|
||||
resultViewGeneration: "gen-data-tab",
|
||||
cachedAt: 1,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(restored?.resultViewGeneration).toBe("gen-data-tab");
|
||||
});
|
||||
|
||||
it("round-trips the per-run view generation", () => {
|
||||
const restored = decodeTabResultSnapshot(
|
||||
encodeTabResultSnapshot({
|
||||
resultRuns: [{ id: "run-1", title: "Run 1", sequence: 1, sql: "select 1", createdAt: 1, result: sampleResult(), resultViewGeneration: "gen-run" }],
|
||||
activeResultRunId: "run-1",
|
||||
cachedAt: 1,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(restored?.resultRuns?.[0]?.resultViewGeneration).toBe("gen-run");
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user