Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
104 changes: 104 additions & 0 deletions frontend/src/components/data-table/__tests__/columns.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -749,6 +749,110 @@ describe("LocaleNumber", () => {
});
});

describe("renderCellValue with string + edge whitespace", () => {
const createMockStringColumn = () =>
({
id: "desc",
columnDef: {
meta: {
dataType: "string" as const,
dtype: "object",
},
},
getColumnFormatting: () => undefined,
getColumnWrapping: () => undefined,
applyColumnFormatting: (value: unknown) => value,
}) as unknown as Column<unknown>;

const renderWithProviders = (node: React.ReactNode) =>
render(
<I18nProvider locale="en-US">
<TooltipProvider>{node}</TooltipProvider>
</I18nProvider>,
);

it("renders edge whitespace markers and still detects the URL in the middle", () => {
const mockColumn = createMockStringColumn();
const value = " https://example.com ";
const result = renderCellValue({
column: mockColumn,
renderValue: () => value,
getValue: () => value,
selectCell: undefined,
cellStyles: "",
});

const { container } = renderWithProviders(result);

// URL detection runs on the middle, so the anchor is still rendered.
const link = container.querySelector("a");
expect(link).toBeTruthy();
expect(link?.href).toBe("https://example.com/");

// The link text is exactly the URL — no leading/trailing whitespace
// leaked into the anchor.
expect(link?.textContent).toBe("https://example.com");

// Both edge-whitespace marker containers are present and render
// visible glyphs (U+2423 "open box" for regular spaces).
const markerSpans = container.querySelectorAll(
"span[aria-label$='space'], span[aria-label$='spaces']",
);
expect(markerSpans.length).toBeGreaterThanOrEqual(2);
expect(container.textContent?.includes("\u2423")).toBe(true);
});

it("does not split URLs on whitespace padding (regression)", () => {
const mockColumn = createMockStringColumn();
// Trailing whitespace would previously be consumed by the URL regex
// (\S+). We render the middle only through parseContent to avoid that.
const value = "go here: https://example.com/path ";
const result = renderCellValue({
column: mockColumn,
renderValue: () => value,
getValue: () => value,
selectCell: undefined,
cellStyles: "",
});

const { container } = renderWithProviders(result);

const link = container.querySelector("a");
expect(link).toBeTruthy();
// href is URL-normalized by the browser — should not include the
// trailing spaces as part of the URL path.
expect(link?.href).toBe("https://example.com/path");
expect(link?.textContent?.trimEnd()).toBe("https://example.com/path");
});

it("renders no marker span when the string has no edge whitespace", () => {
const mockColumn = createMockStringColumn();
const value = "https://example.com";
const result = renderCellValue({
column: mockColumn,
renderValue: () => value,
getValue: () => value,
selectCell: undefined,
cellStyles: "",
});

const { container } = renderWithProviders(result);
// No marker glyph leaked through.
expect(container.textContent?.includes("\u2423")).toBe(false);
// And no WhitespaceMarkers wrapper was rendered at all. The component
// returns null for empty strings, and always sets an aria-label
// generated by `describeWhitespace` when it does render (e.g.
// "1 space", "2 spaces", "1 tab", "1 unicode whitespace").
// Matching the "space"/"spaces" suffix is enough here because the
// test value contains no whitespace, so no marker of any kind should
// appear.
const markerSpans = container.querySelectorAll(
"span[aria-label$='space'], span[aria-label$='spaces']",
);
expect(markerSpans.length).toBe(0);
});
});

describe("renderCellValue with boolean values", () => {
const createMockColumn = () =>
({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,18 @@

import { render } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { SentinelCell } from "../sentinel-cell";
import { SentinelCell, WhitespaceMarkers } from "../sentinel-cell";
import type { CellValueSentinel } from "../types";

function renderSentinel(sentinel: CellValueSentinel) {
const { container } = render(<SentinelCell sentinel={sentinel} />);
return container.querySelector("span")!;
}

function renderMarkers(value: string) {
return render(<WhitespaceMarkers value={value} />);
}

describe("SentinelCell", () => {
it("renders null as None", () => {
const span = renderSentinel({ type: "null", value: null });
Expand Down Expand Up @@ -81,3 +85,87 @@ describe("SentinelCell", () => {
expect(span.getAttribute("title")).toBe("NaT (Not a Time)");
});
});

describe("WhitespaceMarkers", () => {
it("renders nothing for empty string", () => {
const { container } = renderMarkers("");
expect(container.firstChild).toBeNull();
});

it("renders a single space as open box", () => {
const { container } = renderMarkers(" ");
const outer = container.querySelector("span")!;
expect(outer.textContent).toBe("\u2423");
expect(outer.getAttribute("aria-label")).toBe("1 space");
});

it("renders multiple spaces as multiple open boxes", () => {
const { container } = renderMarkers(" ");
const outer = container.querySelector("span")!;
expect(outer.textContent).toBe("\u2423\u2423\u2423");
expect(outer.getAttribute("aria-label")).toBe("3 spaces");
});

it("renders tab, newline, CR with escape labels", () => {
const { container } = renderMarkers("\t\n\r");
const outer = container.querySelector("span")!;
expect(outer.textContent).toBe("\\t\\n\\r");
});

it("renders each char in its own span for CSS spacing", () => {
const { container } = renderMarkers(" ");
const outer = container.querySelector("span")!;
// Outer wrapper + three inner spans (one per char)
expect(outer.querySelectorAll("span")).toHaveLength(3);
});

it("renders unknown whitespace (NBSP) as \\uXXXX escape", () => {
const { container } = renderMarkers("\u00a0");
const outer = container.querySelector("span")!;
expect(outer.textContent).toBe("\\u00a0");
});

it("renders BOM as \\ufeff", () => {
const { container } = renderMarkers("\ufeff");
const outer = container.querySelector("span")!;
expect(outer.textContent).toBe("\\ufeff");
});

it("renders en space and em space as escapes", () => {
const { container } = renderMarkers("\u2002\u2003");
const outer = container.querySelector("span")!;
expect(outer.textContent).toBe("\\u2002\\u2003");
});

it("mixes known glyphs and unknown escapes correctly", () => {
const { container } = renderMarkers(" \t\u00a0");
const outer = container.querySelector("span")!;
expect(outer.textContent).toBe("\u2423\\t\\u00a0");
});

it("describes mixed whitespace in aria-label", () => {
const { container } = renderMarkers(" \t\n");
const outer = container.querySelector("span")!;
expect(outer.getAttribute("aria-label")).toBe("1 space, 1 tab, 1 newline");
});

it("describes unknown whitespace as 'unicode whitespace'", () => {
const { container } = renderMarkers("\u00a0");
const outer = container.querySelector("span")!;
expect(outer.getAttribute("aria-label")).toBe("1 unicode whitespace");
});

it("pluralizes unknown whitespace in aria-label", () => {
const { container } = renderMarkers("\u00a0\u00a0\u2002");
const outer = container.querySelector("span")!;
expect(outer.getAttribute("aria-label")).toBe("3 unicode whitespaces");
});

it("mixes known and unknown whitespace labels", () => {
const { container } = renderMarkers(" \u00a0\t");
const outer = container.querySelector("span")!;
expect(outer.getAttribute("aria-label")).toBe(
"1 space, 1 unicode whitespace, 1 tab",
);
});
});
99 changes: 99 additions & 0 deletions frontend/src/components/data-table/__tests__/utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
getClipboardContent,
getPageIndexForRow,
getRawValue,
splitLeadingTrailingWhitespace,
stringifyUnknownValue,
} from "../utils";

Expand Down Expand Up @@ -342,3 +343,101 @@ describe("getRawValue", () => {
expect(getRawValue(table, 5, "a")).toBeUndefined();
});
});

describe("splitLeadingTrailingWhitespace", () => {
it("returns all empty for empty string", () => {
expect(splitLeadingTrailingWhitespace("")).toEqual({
leading: "",
middle: "",
trailing: "",
});
});

it("returns value as middle when no edge whitespace", () => {
expect(splitLeadingTrailingWhitespace("abc")).toEqual({
leading: "",
middle: "abc",
trailing: "",
});
});

it("preserves inner whitespace in middle", () => {
expect(splitLeadingTrailingWhitespace("abc d ef")).toEqual({
leading: "",
middle: "abc d ef",
trailing: "",
});
});

it("splits leading whitespace only", () => {
expect(splitLeadingTrailingWhitespace(" abc")).toEqual({
leading: " ",
middle: "abc",
trailing: "",
});
});

it("splits trailing whitespace only", () => {
expect(splitLeadingTrailingWhitespace("abc ")).toEqual({
leading: "",
middle: "abc",
trailing: " ",
});
});

it("splits both leading and trailing whitespace", () => {
expect(splitLeadingTrailingWhitespace(" abc ")).toEqual({
leading: " ",
middle: "abc",
trailing: " ",
});
});

it("handles mixed whitespace types at edges", () => {
expect(splitLeadingTrailingWhitespace("\t\n abc \r\t")).toEqual({
leading: "\t\n ",
middle: "abc",
trailing: " \r\t",
});
});

it("preserves inner whitespace when edges have whitespace", () => {
expect(splitLeadingTrailingWhitespace(" a b c ")).toEqual({
leading: " ",
middle: "a b c",
trailing: " ",
});
});

it("handles Unicode whitespace (NBSP) at edges", () => {
expect(splitLeadingTrailingWhitespace("\u00a0abc\u00a0")).toEqual({
leading: "\u00a0",
middle: "abc",
trailing: "\u00a0",
});
});

it("puts whitespace-only string in leading (caller should handle sentinel first)", () => {
expect(splitLeadingTrailingWhitespace(" ")).toEqual({
leading: " ",
middle: "",
trailing: "",
});
});

it("handles single whitespace char", () => {
expect(splitLeadingTrailingWhitespace(" ")).toEqual({
leading: " ",
middle: "",
trailing: "",
});
});

it("handles single non-whitespace char", () => {
expect(splitLeadingTrailingWhitespace("a")).toEqual({
leading: "",
middle: "a",
trailing: "",
});
});
});
Loading
Loading