From cd33c641d549073a8b7054ae283e61f056d495b2 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Mon, 24 Aug 2026 10:35:43 -0700 Subject: [PATCH] test(react): pin the copy/paste column-space coupling while grouped MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The derived group column sits in paste's target space: `mapPasteToTargets` filters only `ROW_SELECT_COLUMN_ID`, so `__pretable_group__` occupies a slot, takes the block's first value when the anchor lands at column 0, and reports it back as a `not-editable` rejection. That reads like an oversight and isn't. Copy and CSV both emit a field for the same column — the group label on a group row, an empty field on a data row — so the slot is what keeps the two sides counting across the same column space. Dropping it from paste alone shifts every value one column right on the way back in: a whole-row copy of `\tr0a\tr0b\tr0c\tr0d` pasted onto another row writes `a=""` (blanking a real column), `b=r0a`, `c=r0b`, `d=r0c`, and clips `r0d`. Silent and destructive, where today's cost is a cosmetic rejection entry. Nothing pinned that coupling: all 116 existing clipboard tests pass with the group column excluded from `mapPasteToTargets`. This test round-trips a whole grouped row through `serializeRanges` -> `parseTsv` -> `mapPasteToTargets` and asserts every value lands back in the column it came from. It holds under either arrangement — group column in both spaces or in neither — and fails only when the two disagree. Co-Authored-By: Claude Opus 5 --- .../react/src/__tests__/paste-map.test.ts | 72 ++++++++++++++++++- 1 file changed, 71 insertions(+), 1 deletion(-) diff --git a/packages/react/src/__tests__/paste-map.test.ts b/packages/react/src/__tests__/paste-map.test.ts index 5eb633a87..faa837bbd 100644 --- a/packages/react/src/__tests__/paste-map.test.ts +++ b/packages/react/src/__tests__/paste-map.test.ts @@ -1,8 +1,10 @@ import { describe, expect, it, vi } from "vitest"; import { ROW_SELECT_COLUMN_ID } from "../constants"; -import { mapPasteToTargets } from "../paste"; +import { serializeRanges } from "../copy"; +import { mapPasteToTargets, parseTsv } from "../paste"; import { + GROUP_COLUMN_ID, createColumnHelper, createLocalRowModel, type PretableRowModelSnapshot, @@ -455,3 +457,71 @@ describe("mapPasteToTargets — group rows", () => { expect(result.clipped).toEqual({ rows: 0, columns: 0 }); }); }); + +describe("mapPasteToTargets — the derived group column", () => { + const groupedSnapshot = createLocalRowModel({ + rows, + columns: modelColumns, + initialExpansion: { kind: "expanded" }, + query: { + filters: [], + sort: [], + rowGroups: [{ columnId: "group" }], + }, + }).getState().snapshot; + + // The DRAWN list while grouped: the derived group column leads, and the + // column being grouped by is gone. The surface passes this same array to + // BOTH `serializeRanges` and `mapPasteToTargets` — which is the whole point + // of the test below. + const groupedColumns: PretableColumn[] = [ + { id: ROW_SELECT_COLUMN_ID }, + { id: GROUP_COLUMN_ID, header: "Group", value: () => "" }, + { id: "a" }, + { id: "b" }, + { id: "c" }, + { id: "d" }, + ]; + + // The derived group column is a paste TARGET (it lands in `rejected` as + // `not-editable`, since nothing can be written to it) purely so that the + // column space paste counts across stays the same one copy counts across. + // Copy emits a field for the group column — the group label on a group row, + // an empty field on a data row — so dropping the column from one side alone + // shifts every value by one column on the way back in, silently overwriting + // a real column with copy's empty group field. Change both sides together + // or neither: this test passes under either arrangement and fails only when + // they disagree. + it("round-trips a whole grouped row back into the columns it came from", () => { + const copied = serializeRanges({ + ranges: [ + { + start: { rowId: "r0", columnId: GROUP_COLUMN_ID }, + end: { rowId: "r0", columnId: "d" }, + }, + ], + rowModelSnapshot: groupedSnapshot, + columns: groupedColumns, + copyWithHeaders: false, + locale: "en-US", + }); + // A data row's group cell copies as an empty leading field. + expect(copied?.text).toBe("\tr0a\tr0b\tr0c\tr0d"); + + const result = mapPasteToTargets({ + matrix: parseTsv(copied!.text), + anchor: { + ref: { kind: "data", rowId: "r2" }, + columnId: ROW_SELECT_COLUMN_ID, + }, + selectionSize: { rows: 1, columns: 1 }, + rowModelSnapshot: groupedSnapshot, + columns: groupedColumns, + }); + + expect( + shape(result).filter((cell) => !cell.includes(GROUP_COLUMN_ID)), + ).toEqual(["r2:a=r0a", "r2:b=r0b", "r2:c=r0c", "r2:d=r0d"]); + expect(result.clipped).toEqual({ rows: 0, columns: 0 }); + }); +});