deer-flow/frontend/tests/unit/components/workspace/artifacts/artifact-table-preview.dom.test.tsx
YongxinFu 8337a80ae6
fix(frontend): count code points when previewing table cells and memory facts (#7503)
* fix(frontend): count code points when previewing table cells and memory facts

artifact-table-preview cut a cell at 120 UTF-16 code units and
memory-settings-page's truncateFactPreview measured and cut fact previews
at 140, so an emoji spanning two units could be split at the cut — the
lone half renders as U+FFFD — and a preview that already fit the
documented code-point budget was still shortened. Both previews now share
truncateToCodePoints in core/utils/text, which measures and cuts by code
points, the same rule briefReason in core/scheduled-tasks/format.ts
already follows.

Add unit tests for the helper and a DOM regression test covering both the
fits-exactly and the cut-inside-an-emoji cases.

* fix(frontend): count code points in the artifact-cell expand gate too

The expandable gate still measured value.length (UTF-16 units), so a
120-code-point cell ending in an astral character opened the expand
control even though the inline preview already showed the whole cell —
the same units-vs-code-points mismatch the preview cut fixed.

The gate now counts Array.from(value), and the boundary test asserts
the fits cell renders as a plain cell with no expand control while the
overflows cell keeps its button and whole-emoji cut.

Suggested-by: gdut4140 (port of the #7509 expandable gate)
2026-10-26 08:23:49 +08:00

117 lines
3.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { afterEach, beforeEach, describe, expect, it, rs } from "@rstest/core";
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
const state = rs.hoisted(() => ({
result: {
rows: [] as string[][],
columnCount: 2,
limited: false,
unevenRows: false,
},
status: "ready",
retry: rs.fn(),
}));
rs.mock("@/core/artifacts/use-delimited-preview", () => ({
useDelimitedPreview: () => state,
}));
import { ArtifactTablePreview } from "@/components/workspace/artifacts/artifact-table-preview";
import { I18nProvider } from "@/core/i18n/context";
function mount() {
return render(
<I18nProvider initialLocale="en-US">
<ArtifactTablePreview
content="fixture"
delimiter=","
truncated={false}
identity="file-1"
/>
</I18nProvider>,
);
}
afterEach(cleanup);
beforeEach(() => {
state.status = "ready";
state.result = {
rows: [
["ID", "Note"],
["00123", "<script>alert(1)</script>"],
],
columnCount: 2,
limited: false,
unevenRows: false,
};
});
describe("ArtifactTablePreview", () => {
it("preserves literal data and lets the first row become data", () => {
const { container } = mount();
expect(screen.getByRole("columnheader", { name: "ID" })).toBeTruthy();
expect(screen.getByText("00123")).toBeTruthy();
expect(container.querySelector("script")).toBeNull();
fireEvent.click(
screen.getByRole("checkbox", { name: "First row as header" }),
);
expect(screen.getByRole("columnheader", { name: "Column 1" })).toBeTruthy();
expect(screen.getByRole("cell", { name: "ID" })).toBeTruthy();
});
it("paginates locally and labels bounded samples honestly", () => {
state.result.rows = [
["ID", "Note"],
...Array.from({ length: 202 }, (_, i) => [String(i + 1), "note"]),
];
mount();
expect(screen.getByText("Preview of first 200 rows")).toBeTruthy();
expect(screen.getByText("1–50 of preview")).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: "Next page" }));
expect(screen.getByText("51–100 of preview")).toBeTruthy();
expect(screen.getByRole("cell", { name: "51" })).toBeTruthy();
expect(screen.queryByRole("cell", { name: "1" })).toBeNull();
});
it("keeps long fields available without enlarging every row", () => {
const longText = "first line\n" + "x".repeat(200);
state.result.rows = [
["ID", "Note"],
["00123", longText],
];
mount();
fireEvent.click(
screen.getByRole("button", { name: "View cell: row 1, column 2" }),
);
expect(
screen.getByRole<HTMLTextAreaElement>("textbox", {
name: "Cell value",
}).value,
).toBe(longText);
});
it("cuts a long cell by code points so an emoji never splits", () => {
// 120 code points but 121 UTF-16 units: the cell fits and keeps its last
// character instead of losing it to a unit-based cut.
const fits = `${"a".repeat(118)}😀b`;
// 122 code points: the cut lands after the whole emoji, never inside it.
const overflows = `${"a".repeat(119)}😀bc`;
state.result.rows = [
["ID", "Note"],
["00123", fits],
["00456", overflows],
];
mount();
// 120 code points is inside the budget: no expand control at all.
expect(screen.getByRole("cell", { name: fits })).toBeTruthy();
expect(
screen.queryByRole("button", { name: "View cell: row 1, column 2" }),
).toBeNull();
expect(
screen.getByRole("button", { name: "View cell: row 2, column 2" })
.textContent,
).toBe(`${"a".repeat(119)}😀`);
});
it("offers an explicit retry without displaying stale data on failure", () => {
state.status = "error";
mount();
expect(screen.queryByRole("table")).toBeNull();
fireEvent.click(screen.getByRole("button", { name: "Retry preview" }));
expect(state.retry).toHaveBeenCalled();
});
});