/* Copyright 2026 Marimo. All rights reserved. */
import type { Table } from "@tanstack/react-table";
import { describe, expect, it } from "vitest";
import {
detectSentinel,
getClipboardContent,
getPageIndexForRow,
getRawValue,
splitLeadingTrailingWhitespace,
stringifyUnknownValue,
} from "../utils";
describe("getPageIndexForRow", () => {
it("should return null when row is on current page", () => {
// Page 0, rows 0-9
expect(getPageIndexForRow(0, 0, 10)).toBeNull();
expect(getPageIndexForRow(5, 0, 10)).toBeNull();
expect(getPageIndexForRow(9, 0, 10)).toBeNull();
// Page 1, rows 10-19
expect(getPageIndexForRow(10, 1, 10)).toBeNull();
expect(getPageIndexForRow(15, 1, 10)).toBeNull();
expect(getPageIndexForRow(19, 1, 10)).toBeNull();
});
it("should return new page index when row is on a different page", () => {
// Row 15 should be on page 1 when viewing page 0
expect(getPageIndexForRow(15, 0, 10)).toBe(1);
// Row 5 should be on page 0 when viewing page 1
expect(getPageIndexForRow(5, 1, 10)).toBe(0);
// Row 25 should be on page 2 when viewing page 0
expect(getPageIndexForRow(25, 0, 10)).toBe(2);
// Row 0 should be on page 0 when viewing page 5
expect(getPageIndexForRow(0, 5, 10)).toBe(0);
});
it("should handle different page sizes", () => {
// Page size of 20
expect(getPageIndexForRow(0, 0, 20)).toBeNull();
expect(getPageIndexForRow(19, 0, 20)).toBeNull();
expect(getPageIndexForRow(20, 0, 20)).toBe(1);
expect(getPageIndexForRow(39, 0, 20)).toBe(1);
expect(getPageIndexForRow(40, 0, 20)).toBe(2);
// Page size of 5
expect(getPageIndexForRow(0, 0, 5)).toBeNull();
expect(getPageIndexForRow(4, 0, 5)).toBeNull();
expect(getPageIndexForRow(5, 0, 5)).toBe(1);
expect(getPageIndexForRow(9, 0, 5)).toBe(1);
expect(getPageIndexForRow(10, 0, 5)).toBe(2);
});
it("should handle boundary cases", () => {
// First row of next page
expect(getPageIndexForRow(10, 0, 10)).toBe(1);
// Last row of previous page
expect(getPageIndexForRow(9, 1, 10)).toBe(0);
// First row of current page
expect(getPageIndexForRow(10, 1, 10)).toBeNull();
// Last row of current page
expect(getPageIndexForRow(19, 1, 10)).toBeNull();
// Last row of last page
expect(getPageIndexForRow(99, 9, 10)).toBeNull();
});
it("should handle edge case of row 0", () => {
expect(getPageIndexForRow(0, 0, 10)).toBeNull();
expect(getPageIndexForRow(0, 1, 10)).toBe(0);
expect(getPageIndexForRow(0, 5, 10)).toBe(0);
});
it("should handle large page numbers and row indices", () => {
// Page 100, rows 1000-1009 (page size 10)
expect(getPageIndexForRow(1000, 100, 10)).toBeNull();
expect(getPageIndexForRow(1009, 100, 10)).toBeNull();
expect(getPageIndexForRow(1010, 100, 10)).toBe(101);
expect(getPageIndexForRow(999, 100, 10)).toBe(99);
});
});
describe("stringifyUnknownValue", () => {
it("should stringify primitives", () => {
expect(stringifyUnknownValue({ value: "hello" })).toBe("hello");
expect(stringifyUnknownValue({ value: 42 })).toBe("42");
expect(stringifyUnknownValue({ value: true })).toBe("true");
expect(stringifyUnknownValue({ value: null })).toBe("null");
expect(stringifyUnknownValue({ value: undefined })).toBe("undefined");
});
it("should stringify null as empty string when flag is set", () => {
expect(
stringifyUnknownValue({ value: null, nullAsEmptyString: true }),
).toBe("");
});
it("should JSON-stringify plain objects", () => {
expect(stringifyUnknownValue({ value: { x: 1 } })).toBe('{"x":1}');
});
});
describe("getClipboardContent", () => {
it("should use rawValue for text when it differs from displayedValue", () => {
const displayed = {
_serialized_mime_bundle: {
mimetype: "text/html",
data: '42',
},
};
const result = getClipboardContent(42, displayed);
expect(result.text).toBe("42");
expect(result.html).toBe('42');
});
it("should strip html for text when rawValue equals displayedValue", () => {
const mimeBundle = {
_serialized_mime_bundle: {
mimetype: "text/html",
data: "bold",
},
};
const result = getClipboardContent(mimeBundle, mimeBundle);
expect(result.text).toBe("bold");
expect(result.html).toBe("bold");
});
it("should handle undefined rawValue", () => {
const displayed = {
_serialized_mime_bundle: {
mimetype: "text/html",
data: "hello",
},
};
const result = getClipboardContent(undefined, displayed);
expect(result.text).toBe("hello");
expect(result.html).toBe("hello");
});
it("should return no html for plain values", () => {
const result = getClipboardContent(undefined, "plain text");
expect(result.text).toBe("plain text");
expect(result.html).toBeUndefined();
});
it("should treat text/markdown as html since mo.md() data is rendered html", () => {
const displayed = {
_serialized_mime_bundle: {
mimetype: "text/markdown",
data: 'Hello',
},
};
const result = getClipboardContent(undefined, displayed);
expect(result.text).toBe("Hello");
expect(result.html).toBe(
'Hello',
);
});
it("should return no html for non-html mime bundles", () => {
const displayed = {
_serialized_mime_bundle: {
mimetype: "text/plain",
data: "just text",
},
};
const result = getClipboardContent(undefined, displayed);
expect(result.text).toBe("just text");
expect(result.html).toBeUndefined();
});
it("should handle null rawValue as a real value", () => {
const displayed = {
_serialized_mime_bundle: {
mimetype: "text/html",
data: "N/A",
},
};
const result = getClipboardContent(null, displayed);
expect(result.text).toBe("null");
expect(result.html).toBe("N/A");
});
});
describe("detectSentinel", () => {
it("should detect null and undefined", () => {
expect(detectSentinel(null, undefined)).toEqual({
type: "null",
value: null,
});
expect(detectSentinel(undefined, undefined)).toEqual({
type: "null",
value: undefined,
});
});
it("should detect empty string", () => {
expect(detectSentinel("", "string")).toEqual({
type: "empty-string",
value: "",
});
});
it("should detect whitespace-only strings", () => {
expect(detectSentinel(" ", "string")).toEqual({
type: "whitespace",
value: " ",
});
expect(detectSentinel(" ", "string")).toEqual({
type: "whitespace",
value: " ",
});
expect(detectSentinel("\t", "string")).toEqual({
type: "whitespace",
value: "\t",
});
expect(detectSentinel("\n", "string")).toEqual({
type: "whitespace",
value: "\n",
});
expect(detectSentinel("\t \n", "string")).toEqual({
type: "whitespace",
value: "\t \n",
});
});
it("should detect NaN", () => {
expect(detectSentinel(Number.NaN, "number")).toEqual({
type: "nan",
value: Number.NaN,
});
});
it("should detect Infinity", () => {
expect(detectSentinel(Number.POSITIVE_INFINITY, "number")).toEqual({
type: "positive-infinity",
value: Number.POSITIVE_INFINITY,
});
expect(detectSentinel(Number.NEGATIVE_INFINITY, "number")).toEqual({
type: "negative-infinity",
value: Number.NEGATIVE_INFINITY,
});
});
it("should return null for normal values", () => {
expect(detectSentinel("hello", "string")).toBeNull();
expect(detectSentinel(42, "number")).toBeNull();
expect(detectSentinel(0, "number")).toBeNull();
expect(detectSentinel(-1.5, "number")).toBeNull();
expect(detectSentinel(true, "boolean")).toBeNull();
expect(detectSentinel(false, "boolean")).toBeNull();
expect(detectSentinel({}, "unknown")).toBeNull();
expect(detectSentinel([], "unknown")).toBeNull();
});
it("should not match literal null-like strings", () => {
expect(detectSentinel("null", "string")).toBeNull();
expect(detectSentinel("NULL", "string")).toBeNull();
expect(detectSentinel("None", "string")).toBeNull();
});
it("should not match string NaN/Infinity in non-numeric columns", () => {
expect(detectSentinel("NaN", "string")).toBeNull();
expect(detectSentinel("Infinity", "string")).toBeNull();
expect(detectSentinel("-Infinity", "string")).toBeNull();
});
it("should match string NaN/Infinity in numeric columns", () => {
expect(detectSentinel("NaN", "number")).toEqual({
type: "nan",
value: "NaN",
});
expect(detectSentinel("Infinity", "number")).toEqual({
type: "positive-infinity",
value: "Infinity",
});
expect(detectSentinel("-Infinity", "number")).toEqual({
type: "negative-infinity",
value: "-Infinity",
});
expect(detectSentinel("inf", "number")).toEqual({
type: "positive-infinity",
value: "inf",
});
expect(detectSentinel("-inf", "number")).toEqual({
type: "negative-infinity",
value: "-inf",
});
});
it("should still not match normal strings in numeric columns", () => {
expect(detectSentinel("hello", "number")).toBeNull();
expect(detectSentinel("42", "number")).toBeNull();
});
it("should not match NaT in non-temporal columns", () => {
expect(detectSentinel("NaT", "string")).toBeNull();
});
it("should match NaT in temporal columns", () => {
expect(detectSentinel("NaT", "datetime")).toEqual({
type: "nat",
value: "NaT",
});
expect(detectSentinel("NaT", "date")).toEqual({
type: "nat",
value: "NaT",
});
});
});
function createMockTableWithMeta(rawData?: TData[]): Table {
return {
options: {
meta: { rawData },
},
} as unknown as Table;
}
describe("getRawValue", () => {
it("should return raw value when rawData is available", () => {
const table = createMockTableWithMeta([
{ a: 10, b: 20 },
{ a: 30, b: 40 },
]);
expect(getRawValue(table, 0, "a")).toBe(10);
expect(getRawValue(table, 1, "b")).toBe(40);
});
it("should return undefined when rawData is not set", () => {
const table = createMockTableWithMeta(undefined);
expect(getRawValue(table, 0, "a")).toBeUndefined();
});
it("should return undefined when row index is out of bounds", () => {
const table = createMockTableWithMeta([{ a: 1 }]);
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: "",
});
});
});