Files
openidea/src/features/database/Grid.test.tsx
T
l0ng-ai e2e699dca9 fix(database): bound large cell previews and use dialog editing (#1040)
Limit grid and export-preview cell text to 1000 UTF-16 code units plus an ellipsis. Edit large values in a wrapped dialog while preserving complete clipboard, export and database values.

Reproduced STATUS_STACK_OVERFLOW (0xC00000FD) in Windows WebView2 against a real PostgreSQL table with nine rows and 8.83 MB of jsonb data. Verified the fix through display, selection, editing, full clipboard copy, JSON file export and PostgreSQL writeback. All PR CI checks passed, including 5625 frontend tests and Rust checks on Windows, macOS and Linux.

Fixes #825
2026-11-03 21:49:26 +08:00

133 lines
5.6 KiB
TypeScript

import { installKeyboardDispatcher, useKeymap } from "@/platform/keymap";
import { registerActions } from "@/platform/actions";
import { fireEvent, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { Grid } from "./Grid";
import { CELL_PREVIEW_LIMIT, cellPreview } from "./cellPreview";
import { exportData } from "./export";
vi.mock("@tanstack/react-virtual", () => ({
useVirtualizer: ({ count }: { count: number }) => ({
getTotalSize: () => count * 24,
getVirtualItems: () => Array.from({ length: count }, (_, index) => ({ index, start: index * 24, key: index })),
scrollToIndex: () => {},
}),
}));
describe("database grid", () => {
const disposers: Array<() => void> = [];
afterEach(() => {
disposers
.splice(0)
.reverse()
.forEach((dispose) => dispose());
});
it("drags a rectangle and copies it as TSV through the Windows dispatcher", () => {
useKeymap.setState({ preset: "intellij-windows", overrides: {} });
const globalCopy = vi.fn();
disposers.push(registerActions([{ id: "edit.copy", title: "Copy", run: globalCopy }]), installKeyboardDispatcher());
const writeText = vi.fn();
Object.defineProperty(navigator, "clipboard", { configurable: true, value: { writeText } });
render(
<Grid
columns={[
{ name: "a", type: "text" },
{ name: "b", type: "text" },
]}
rowCount={2}
value={(r, c) =>
[
["one", "two"],
["three", "four"],
][r]![c]!
}
/>,
);
const cells = screen.getAllByRole("gridcell");
fireEvent.mouseDown(cells[0]!, { button: 0 });
fireEvent.mouseEnter(cells[3]!);
fireEvent.mouseUp(window);
fireEvent.keyDown(screen.getByRole("grid"), { key: "c", code: "KeyC", ctrlKey: true });
expect(writeText).toHaveBeenCalledWith("one\ttwo\nthree\tfour");
expect(globalCopy).not.toHaveBeenCalled();
});
it("pastes a rectangle as pending edits and skips deleted rows", () => {
const onEdit = vi.fn();
render(
<Grid
columns={[
{ name: "a", type: "text" },
{ name: "b", type: "text" },
]}
rowCount={2}
value={() => "old"}
editable
onEdit={onEdit}
rowState={(r) => (r === 1 ? "deleted" : null)}
/>,
);
fireEvent.mouseDown(screen.getAllByRole("gridcell")[0]!, { button: 0 });
fireEvent.paste(screen.getByRole("grid"), { clipboardData: { getData: () => "new\tother\nignored\tignored" } });
expect(onEdit.mock.calls).toEqual([
[0, 0, "new"],
[0, 1, "other"],
]);
});
});
describe("large database cells", () => {
const full = JSON.stringify({ text: "\u8bf4\u660e text \u6df7\u5408 ".repeat(200_000), tail: "complete" });
const columns = [{ name: "payload", type: "jsonb" }];
it("preserves short values and truncates only beyond the preview limit", () => {
for (const value of ["", "hello", "\u4e2d".repeat(CELL_PREVIEW_LIMIT)]) expect(cellPreview(value)).toBe(value);
expect(cellPreview(full)).toBe(full.slice(0, CELL_PREVIEW_LIMIT) + "\u2026");
expect(cellPreview("a".repeat(999) + "\ud83d\ude00tail")).toBe("a".repeat(999) + "\u2026");
});
it("bounds rendered text while copying and exporting the complete value", () => {
const writeText = vi.fn();
Object.defineProperty(navigator, "clipboard", { configurable: true, value: { writeText } });
render(<Grid columns={columns} rowCount={1} value={() => full} />);
const cell = screen.getByRole("gridcell");
expect(cell.textContent).toBe(cellPreview(full));
expect(cell.title).toBe(full.slice(0, 2000));
fireEvent.mouseDown(cell, { button: 0 });
fireEvent.keyDown(screen.getByRole("grid"), { key: "c", ctrlKey: true });
expect(writeText).toHaveBeenCalledWith(full);
expect(JSON.parse(exportData("json", { columns, rows: [[full]], table: "big", driver: "postgres" }))).toEqual([
{ payload: full },
]);
});
it("edits the full large value in a dialog and supports cancel and apply", () => {
const onEdit = vi.fn();
render(<Grid columns={columns} rowCount={1} value={() => full} editable onEdit={onEdit} />);
fireEvent.doubleClick(screen.getByRole("gridcell"));
expect(screen.getByRole("dialog")).toBeTruthy();
const editor = screen.getByRole("textbox") as HTMLTextAreaElement;
expect(editor.tagName).toBe("TEXTAREA");
expect(editor.value).toBe(full);
fireEvent.change(editor, { target: { value: full + "changed" } });
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
expect(onEdit).not.toHaveBeenCalled();
fireEvent.doubleClick(screen.getByRole("gridcell"));
fireEvent.change(screen.getByRole("textbox"), { target: { value: full + "changed" } });
fireEvent.click(screen.getByRole("button", { name: "Apply" }));
expect(onEdit).toHaveBeenCalledWith(0, 0, full + "changed");
expect(screen.queryByRole("dialog")).toBeNull();
});
it("keeps short values in the inline editor and preserves null display", () => {
const onEdit = vi.fn();
const { rerender } = render(<Grid columns={columns} rowCount={1} value={() => "short"} editable onEdit={onEdit} />);
fireEvent.doubleClick(screen.getByRole("gridcell"));
expect(screen.getByRole("textbox").tagName).toBe("INPUT");
fireEvent.change(screen.getByRole("textbox"), { target: { value: "updated" } });
fireEvent.keyDown(screen.getByRole("textbox"), { key: "Enter" });
expect(onEdit).toHaveBeenCalledWith(0, 0, "updated");
rerender(<Grid columns={columns} rowCount={1} value={() => null} />);
expect(screen.getByRole("gridcell").textContent).toBe("<null>");
});
});