806 lines · 25.8 KB · TypeScript
RawDownload
import { describe, expect, it, vi } from "vite-plus/test";import { closestCenter, type CollisionDetection } from "@dnd-kit/core";import { verticalListSortingStrategy, type SortingStrategy } from "@dnd-kit/sortable";import {  createSidebarCollisionDetection,  createSidebarSortingStrategy,  restrictBelowSidebarLabel,} from "./Sidebar.drag";import {  resolveSidebarDropTarget,  sidebarListItemId,  sidebarMarkerId,  type SidebarListItem,  type SidebarListMarker,  type SidebarSection,} from "./Sidebar.logic"; const thread = (key: string, section: SidebarSection): SidebarListItem => ({  kind: "thread",  key,  section,});const marker = (marker: SidebarListMarker): SidebarListItem => ({ kind: "marker", marker });const pinnedHeader = marker("pinned-header");const divider = marker("pinned-divider");const settledHeader = marker("settled-header");const stationary = { x: 0, y: 0, scaleX: 1, scaleY: 1 }; function layout(  items: readonly SidebarListItem[],  active: string,  over: string,  scale = 1,  cardHeight = 82,) {  let top = 100;  const rects = items.map((item) => {    const height =      item.kind === "thread"        ? (item.section === "pinned" || item.section === "active" ? cardHeight : 36) * scale        : item.marker === "pinned-header" || item.marker === "pinned-divider"          ? 0          : (item.marker.endsWith("placeholder") ? 0 : 32) * scale;    const rect = { top, height, bottom: top + height, left: 0, right: 260, width: 260 };    top += height + 1;    return rect;  });  const activeIndex = items.findIndex((item) => sidebarListItemId(item) === active);  return {    activeIndex,    overIndex: items.findIndex((item) => sidebarListItemId(item) === over),    activeNodeRect: rects[activeIndex]!,    rects,    index: 0,  } satisfies Parameters<SortingStrategy>[0];} function preview(  input: Parameters<typeof createSidebarSortingStrategy>[0],  active: string,  over: string,  scale = 1,) {  const strategy = createSidebarSortingStrategy(input);  const args = layout(input.items, active, over, scale);  return new Map(    input.items.map((item, index) => [sidebarListItemId(item), strategy({ ...args, index })]),  );} describe("sidebar collision detection", () => {  function collisionArgs(blockedAboveSource = false) {    const rows = [thread("source", "active"), thread("blocked", "active")];    const items = [      pinnedHeader,      divider,      ...(blockedAboveSource ? rows.toReversed() : rows),      settledHeader,      marker("settled-placeholder"),    ];    const { rects, activeIndex, overIndex } = layout(items, "source", "blocked");    const collisionRect = rects[overIndex]!;    return {      active: {        id: "source",        data: { current: {} },        rect: { current: { initial: rects[activeIndex]!, translated: collisionRect } },      },      collisionRect,      droppableRects: new Map(items.map((item, index) => [sidebarListItemId(item), rects[index]!])),      droppableContainers: items.map((item, index) => ({        id: sidebarListItemId(item),        key: sidebarListItemId(item),        disabled: false,        data: { current: {} },        node: { current: null },        rect: { current: rects[index]! },      })),      pointerCoordinates: null,    } satisfies Parameters<CollisionDetection>[0];  }   it.each([    [false, sidebarMarkerId("settled-header")],    [true, sidebarMarkerId("pinned-divider")],  ] as const)(    "rejects unsupported Active instead of selecting %s / %s",    (blockedAboveSource, nearbyTarget) => {      const args = collisionArgs(blockedAboveSource);      const detector = createSidebarCollisionDetection((id) => id !== "blocked");      const filtered = closestCenter({        ...args,        droppableContainers: args.droppableContainers.filter(          (container) => container.id !== "blocked",        ),      });      expect(filtered[0]?.id).toBe(nearbyTarget);      expect(detector(args).map((collision) => collision.id)).toEqual(["source"]);    },  );   it("selects the nearest supported target", () => {    const detector = createSidebarCollisionDetection(() => true);    expect(detector(collisionArgs())[0]?.id).toBe("blocked");  });   it.each([    { sourceSection: "active", pins: 0 },    { sourceSection: "active", pins: 1 },    { sourceSection: "pinned", pins: 1 },    { sourceSection: "settled", pins: 1 },  ] as const)(    "switches on crossing the divider row from $sourceSection with $pins pins",    ({ sourceSection, pins }) => {      const items = [        pinnedHeader,        ...(pins ? [thread("p", "pinned")] : []),        ...(sourceSection === "pinned" ? [thread("source", "pinned")] : []),        divider,        thread("a", "active"),        ...(sourceSection === "active" ? [thread("source", "active")] : []),        settledHeader,        ...(sourceSection === "settled" ? [thread("source", "settled")] : []),      ];      const { rects, activeIndex } = layout(items, "source", "a");      const sourceRect = rects[activeIndex]!;      let boundaryTop = 300;      const boundaryNode = {        querySelector: () => ({          getBoundingClientRect: () => ({            top: boundaryTop,            bottom: boundaryTop + 16,            left: 0,            right: 260,          }),        }),      } as unknown as HTMLElement;      const detector = createSidebarCollisionDetection(() => true, {        items,        activationY: sourceSection === "pinned" ? 200 : 600,      });      const at = (center: number) => {        const collisionRect = {          ...sourceRect,          top: center - sourceRect.height / 2,          bottom: center + sourceRect.height / 2,        };        const args = {          ...collisionArgs(),          active: {            id: "source",            data: { current: {} },            rect: { current: { initial: sourceRect, translated: collisionRect } },          },          collisionRect,          pointerCoordinates: { x: 130, y: center },          droppableRects: new Map(            items.map((item, index) => [sidebarListItemId(item), rects[index]!]),          ),          droppableContainers: items.map((item, index) => ({            id: sidebarListItemId(item),            key: sidebarListItemId(item),            disabled: false,            data: { current: {} },            node: {              current:                item === divider                  ? boundaryNode                  : item === settledHeader                    ? ({ getBoundingClientRect: () => ({ top: 600 }) } as unknown as HTMLElement)                    : null,            },            rect: { current: rects[index]! },          })),        };        const over = detector(args)[0];        return over ? resolveSidebarDropTarget(items, "source", String(over.id))?.section : null;      };      expect(at(330)).toBe("active");      expect(at(317)).toBe("active");      expect(at(316)).toBe("pinned");      // The preview moves the divider; a stationary pointer must not undo the drop target.      boundaryTop = 400;      expect(at(316)).toBe("pinned");      expect(at(399)).toBe("pinned");      expect(at(400)).toBe("active");      boundaryTop = 300;      expect(at(400)).toBe("active");      expect(at(317)).toBe("active");      expect(at(316)).toBe("pinned");    },  );   it("returns no collision if an unsupported target has no source fallback", () => {    const args = collisionArgs();    const detector = createSidebarCollisionDetection(() => false);    expect(      detector({        ...args,        droppableContainers: args.droppableContainers.filter(          (container) => container.id !== "source",        ),      }),    ).toEqual([]);  });   it("validates each hovered target once and always allows returning to the source", () => {    const args = collisionArgs();    const isValid = vi.fn((id: string) => id !== "blocked");    const detector = createSidebarCollisionDetection(isValid);    expect(detector(args)[0]?.id).toBe("source");    expect(      detector({        ...args,        collisionRect: {          ...args.collisionRect,          top: args.collisionRect.top + 3,          bottom: args.collisionRect.bottom + 3,        },      })[0]?.id,    ).toBe("source");    expect(detector({ ...args, collisionRect: args.droppableRects.get("source")! })[0]?.id).toBe(      "source",    );    expect(      detector({        ...args,        collisionRect: args.droppableRects.get(sidebarMarkerId("settled-placeholder"))!,      })[0]?.id,    ).toBe(sidebarMarkerId("settled-placeholder"));    expect(isValid.mock.calls).toEqual([["blocked"], [sidebarMarkerId("settled-placeholder")]]);  });}); describe("sidebar drag projection", () => {  const pinned = [    pinnedHeader,    thread("p1", "pinned"),    thread("p2", "pinned"),    divider,    thread("a1", "active"),    settledHeader,    thread("s1", "settled"),  ];   it.each([    ["p1", "p2"],    ["p2", "p1"],  ])("preserves existing pinned transforms from %s to %s", (active, over) => {    const strategy = createSidebarSortingStrategy({      items: pinned,      settledOrder: [],      settledExpanded: true,    });    const args = layout(pinned, active, over);    // dnd-kit moves the lifted row by the pointer delta, so only peers matter.    for (let index = 0; index < pinned.length; index += 1) {      if (index === args.activeIndex) continue;      expect(strategy({ ...args, index })).toEqual(verticalListSortingStrategy({ ...args, index }));    }  });   it("keeps the pinned header above the gap when a lower pin moves to the top", () => {    const result = preview(      { items: pinned, settledOrder: [], settledExpanded: true },      "p2",      sidebarMarkerId("pinned-header"),    );    expect(result.get(sidebarMarkerId("pinned-header"))).toEqual(stationary);    expect(result.get("p1")).toEqual({ ...stationary, y: 83 });    expect(result.get(sidebarMarkerId("pinned-divider"))).toEqual(stationary);    expect(result.get("a1")).toEqual(stationary);  });   it.each([    ["a1", "a2"],    ["a2", "a1"],  ])("uses pinned dragging behavior for Active from %s to %s", (active, over) => {    const items = [      pinnedHeader,      thread("p", "pinned"),      divider,      thread("a1", "active"),      thread("a2", "active"),      settledHeader,      thread("s", "settled"),    ];    const strategy = createSidebarSortingStrategy({      items,      settledOrder: [],      settledExpanded: true,    });    const args = layout(items, active, over);    for (let index = 0; index < items.length; index += 1) {      if (index === args.activeIndex) continue;      expect(strategy({ ...args, index })).toEqual(verticalListSortingStrategy({ ...args, index }));    }  });   it("preserves settled order while opening the zero-height Active target", () => {    const items = [      pinnedHeader,      divider,      marker("active-placeholder"),      settledHeader,      thread("first", "settled"),      thread("second", "settled"),    ];    const result = preview(      { items, settledOrder: ["first", "second"], settledExpanded: true },      "second",      "first",    );    expect(result.get(sidebarMarkerId("active-placeholder"))).toEqual(stationary);    expect(result.get(sidebarMarkerId("settled-header"))).toEqual({ ...stationary, y: 36 });    expect(result.get("first")).toEqual({ ...stationary, y: 36 });    expect(result.get("second")).toEqual(stationary);  });   it.each([    [sidebarMarkerId("pinned-divider"), 0, 0],    ["a1", -83, 0],    ["a2", -83, -83],  ] as const)(    "opens the active pointer slot over %s without adding an empty pinned row",    (over, a1Offset, a2Offset) => {      const items = [        pinnedHeader,        thread("p", "pinned"),        divider,        thread("a1", "active"),        thread("a2", "active"),        settledHeader,        thread("s", "settled"),      ];      const result = preview({ items, settledOrder: [], settledExpanded: true }, "p", over);      expect(result.get(sidebarMarkerId("pinned-header"))).toEqual(stationary);      expect(result.get(sidebarMarkerId("pinned-divider"))?.y).toBe(-83);      expect(result.get("a1")?.y).toBe(a1Offset);      expect(result.get("a2")?.y).toBe(a2Offset);      expect(result.get(sidebarMarkerId("settled-header"))?.y).toBe(0);    },  );   it("opens label space below each pinned boundary while dragging", () => {    const items = [      pinnedHeader,      thread("p", "pinned"),      divider,      thread("a1", "active"),      thread("a2", "active"),      settledHeader,      thread("s", "settled"),    ];    // Reorder inside active: the header gap shifts every row, the divider    // gap shifts the active rows and the shelf below by a second label.    const result = preview(      { items, settledOrder: [], settledExpanded: true, boundaryLabelHeight: 16 },      "a2",      "a1",    );    expect(result.get(sidebarMarkerId("pinned-header"))).toEqual(stationary);    expect(result.get("p")?.y).toBe(16);    expect(result.get(sidebarMarkerId("pinned-divider"))?.y).toBe(16);    expect(result.get("a2")).toEqual(stationary);    expect(result.get("a1")?.y).toBe(32 + 83);    expect(result.get(sidebarMarkerId("settled-header"))?.y).toBe(32);    expect(result.get("s")?.y).toBe(32);  });   it.each(["s1", "missing-target"])(    "keeps label clearance when a settled drag is over %s",    (over) => {      const items = [        pinnedHeader,        thread("p", "pinned"),        divider,        thread("a", "active"),        settledHeader,        thread("s1", "settled"),        thread("s2", "settled"),      ];      const result = preview(        {          items,          settledOrder: ["s1", "s2"],          settledExpanded: true,          boundaryLabelHeight: 24,        },        "s2",        over,      );      expect(result.get("p")?.y).toBe(24);      expect(result.get(sidebarMarkerId("pinned-divider"))?.y).toBe(24);      expect(result.get("a")?.y).toBe(48);      expect(result.get("s1")?.y).toBe(48);      expect(result.get("s2")).toEqual(stationary);    },  );   it("stacks the labels with their gaps when the pinned section is empty", () => {    const items = [      pinnedHeader,      divider,      thread("a1", "active"),      thread("a2", "active"),      settledHeader,      thread("s", "settled"),    ];    const result = preview(      { items, settledOrder: [], settledExpanded: true, boundaryLabelHeight: 16 },      "a2",      "a1",    );    expect(result.get(sidebarMarkerId("pinned-header"))).toEqual(stationary);    expect(result.get(sidebarMarkerId("pinned-divider"))?.y).toBe(16);    expect(result.get("a1")?.y).toBe(32 + 83);    expect(result.get(sidebarMarkerId("settled-header"))?.y).toBe(32);  });   it("scales the label space with the measured root scale", () => {    const items = [pinnedHeader, thread("p", "pinned"), divider, thread("a1", "active")];    const result = preview(      { items, settledOrder: [], settledExpanded: true, boundaryLabelHeight: 16 },      "a1",      "p",      2,    );    expect(result.get(sidebarMarkerId("pinned-divider"))?.y).toBe(32 + 165);  });   it("keeps the pinned header above the first arriving pin", () => {    const items = [      pinnedHeader,      divider,      thread("a1", "active"),      thread("a2", "active"),      settledHeader,      thread("s", "settled"),    ];    const result = preview(      { items, settledOrder: [], settledExpanded: true },      "a2",      sidebarMarkerId("pinned-header"),    );    expect(result.get(sidebarMarkerId("pinned-header"))).toEqual(stationary);    expect(result.get(sidebarMarkerId("pinned-divider"))?.y).toBe(83);    expect(result.get("a1")?.y).toBe(83);    expect(result.get(sidebarMarkerId("settled-header"))?.y).toBe(0);  });   it.each([    ["p", -83, -1],    ["s", 0, 82],  ] as const)(    "replaces the empty Active target when %s enters",    (active, dividerOffset, settledOffset) => {      const items = [        pinnedHeader,        thread("p", "pinned"),        divider,        marker("active-placeholder"),        settledHeader,        thread("s", "settled"),      ];      const result = preview(        { items, settledOrder: [], settledExpanded: true },        active,        sidebarMarkerId("active-placeholder"),      );      expect(result.get(sidebarMarkerId("active-placeholder"))?.scaleY).toBe(0);      expect(result.get(sidebarMarkerId("pinned-divider"))?.y).toBe(dividerOffset);      expect(result.get(sidebarMarkerId("settled-header"))?.y).toBe(settledOffset);    },  );   it("uses the canonical settled rank and the destination's slim height", () => {    const items = [      pinnedHeader,      thread("p", "pinned"),

Lines 1–500 of 806.