From 6de016ec7b36c5f18627bc04ec1258b4bb60952b Mon Sep 17 00:00:00 2001 From: Shinuwa Date: Sun, 13 Sep 2026 19:47:45 +0200 Subject: [PATCH] Test grouped drag constraints and orientation --- .beads/interactions.jsonl | 1 + e2e/fixtures/grouped-reorder-probe.js | 49 ++++++++++++++++++ e2e/grouped-reorder.spec.js | 74 +++++++++++++++++++++++++++ tests/static-toolboxes.test.mjs | 3 -- 4 files changed, 124 insertions(+), 3 deletions(-) create mode 100644 e2e/fixtures/grouped-reorder-probe.js create mode 100644 e2e/grouped-reorder.spec.js diff --git a/.beads/interactions.jsonl b/.beads/interactions.jsonl index cbcc9d3..6a34911 100644 --- a/.beads/interactions.jsonl +++ b/.beads/interactions.jsonl @@ -45,3 +45,4 @@ {"id":"int-37bf322d7d6ae9d06e033d9f16b7f057","kind":"field_change","created_at":"2026-09-12T21:33:50.250082802Z","actor":"Shinuwa","issue_id":"sokko-g-zsd","extra":{"field":"status","new_value":"open","old_value":"in_progress"}} {"id":"int-cc87d51843f7ee62beb6445939aa30d5","kind":"field_change","created_at":"2026-09-13T06:35:52.804580049Z","actor":"Shinuwa","issue_id":"sokko-g-zsd","extra":{"field":"status","new_value":"open","old_value":"in_progress"}} {"id":"int-e554bed2958d709f2965a55fea9aa956","kind":"field_change","created_at":"2026-09-13T06:53:17.211928645Z","actor":"Shinuwa","issue_id":"sokko-g-zsd","extra":{"field":"status","new_value":"open","old_value":"in_progress"}} +{"id":"int-6f399629fa8cc8212d36ca21853eaa52","kind":"field_change","created_at":"2026-09-13T17:46:00.430152831Z","actor":"Shinuwa","issue_id":"sokko-g-zsd","extra":{"field":"status","new_value":"open","old_value":"in_progress"}} diff --git a/e2e/fixtures/grouped-reorder-probe.js b/e2e/fixtures/grouped-reorder-probe.js new file mode 100644 index 0000000..38ba617 --- /dev/null +++ b/e2e/fixtures/grouped-reorder-probe.js @@ -0,0 +1,49 @@ +// Rôle : expose la politique réelle de réorganisation dans une géométrie stable sous StrictMode. +import { createElement as h, StrictMode } from "react"; +import { createRoot } from "react-dom/client"; +import { flushSync } from "react-dom"; +import { useGroupedReorder } from "../../website/src/hooks/useGroupedReorder.js"; + +const items = [ + { id: "a", group: "A", parent: "p" }, { id: "b", group: "A", parent: "p" }, + { id: "c", group: "B", parent: "p" }, { id: "d", group: "B", parent: "q" } +]; + +export function mountGroupedReorderProbe(element) { + const root = createRoot(element); + const events = []; + let options = { + orientation: "vertical", hierarchy: { enabled: true, stickyParents: true }, + reorderFeatures: { item: { groupChange: true }, group: { reorder: true } }, veto: false + }; + function Probe({ config }) { + const reorder = useGroupedReorder({ + ...config, namespace: "probe", items, + getItemGroup: (item) => item.group, getParentId: (item) => item.parent, + canMoveItem: () => !config.veto, canMoveGroup: () => !config.veto, + onItemMove: (operation) => events.push(operation), onGroupMove: (operation) => events.push(operation) + }); + const tile = (id, props, child) => h("div", { + ...props, key: id, "data-testid": id, + style: { height: 96, width: 220, border: "1px solid" } + }, child); + return h("section", null, + h("output", { "aria-label": "Dragging", style: { display: "block", height: 24 } }, + reorder.itemReorder.draggingId || reorder.groupReorder.draggingId ? "active" : ""), + h("div", { style: { display: "grid", gridTemplateColumns: "repeat(3, 222px)", gap: 12 } }, + ...items.map((item) => tile(item.id, reorder.getItemProps({ itemId: item.id, groupId: item.group, parentId: item.parent }), + h("button", { type: "button", onPointerDown: (event) => reorder.itemReorder.startDrag(event, item.id) }, `Item ${item.id}`))), + ...["A", "B"].map((groupId) => tile(`group-${groupId}`, reorder.getGroupProps({ groupId, parentId: "p" }), + h("button", { type: "button", onPointerDown: (event) => reorder.groupReorder.startDrag(event, { groupId, parentId: "p" }) }, `Group ${groupId}`))), + tile("boundary", reorder.getGroupBoundaryProps({ groupId: "B", parentId: "p", placement: "after" }), "Boundary"), + tile("ungrouped", reorder.getUngroupedDropProps({ parentId: "p" }), "Ungrouped"), + tile("alien", { ...reorder.getItemProps({ itemId: "alien", groupId: "A", parentId: "p" }), "data-reorder-namespace": "other" }, "Other namespace") + )); + } + function update(patch = {}) { + options = { ...options, ...patch }; + flushSync(() => root.render(h(StrictMode, null, h(Probe, { config: options })))); + } + update(); + return { events, update, unmount: () => root.unmount() }; +} diff --git a/e2e/grouped-reorder.spec.js b/e2e/grouped-reorder.spec.js new file mode 100644 index 0000000..97d0c9a --- /dev/null +++ b/e2e/grouped-reorder.spec.js @@ -0,0 +1,74 @@ +// Rôle : vérifie les contraintes de groupes, de parents et d'orientation avec de vrais gestes pointeur. +/* global window, document */ +import { test, expect } from "@playwright/test"; +import { fileURLToPath, URL } from "node:url"; + +const probeUrl = `/@fs/${fileURLToPath(new URL("./fixtures/grouped-reorder-probe.js", import.meta.url))}`; +test.beforeEach(async ({ page }) => { + await page.goto("/"); + await page.evaluate(async (url) => { + const { mountGroupedReorderProbe } = await import(url); + document.getElementById("app").style.display = "none"; + const element = document.createElement("div"); + document.body.prepend(element); + window.groupedProbe = mountGroupedReorderProbe(element); + }, probeUrl); +}); + +async function configure(page, options) { + await page.evaluate((options) => window.groupedProbe.update(options), options); +} + +async function drop(page, source, target, x = 0.2, y = 0.8) { + await page.getByRole("button", { name: source, exact: true }).hover(); + await page.mouse.down(); + await expect(page.getByLabel("Dragging")).toHaveText("active"); + const box = await page.getByTestId(target).boundingBox(); + await page.mouse.move(box.x + box.width * x, box.y + box.height * y, { steps: 3 }); + await page.mouse.up(); + await expect(page.getByLabel("Dragging")).toBeEmpty(); + return page.evaluate(() => window.groupedProbe.events.splice(0)); +} + +test("item moves obey parent boundaries, group changes, namespace isolation and custom vetoes", async ({ page }) => { + expect(await drop(page, "Item a", "b")).toEqual([{ + type: "item", sourceId: "a", targetId: "b", targetType: "item", placement: "after", + sourceGroup: "A", targetGroup: "A", sourceParentId: "p", targetParentId: "p" + }]); + expect(await drop(page, "Item a", "d")).toEqual([]); + expect(await drop(page, "Item a", "alien")).toEqual([]); + await configure(page, { reorderFeatures: { item: { groupChange: false } } }); + expect(await drop(page, "Item a", "c")).toEqual([]); + await configure(page, { reorderFeatures: { item: { groupChange: true } } }); + expect(await drop(page, "Item a", "c")).toMatchObject([{ targetId: "c", targetGroup: "B" }]); + await configure(page, { hierarchy: { enabled: true, stickyParents: false } }); + expect(await drop(page, "Item a", "d")).toMatchObject([{ sourceParentId: "p", targetParentId: "q" }]); + await configure(page, { veto: true }); + expect(await drop(page, "Item a", "b")).toEqual([]); +}); + +test("orientation changes placement while explicit boundaries and ungrouped drops require permission", async ({ page }) => { + expect(await drop(page, "Item a", "b")).toMatchObject([{ placement: "after" }]); + await configure(page, { orientation: "horizontal" }); + expect(await drop(page, "Item a", "b")).toMatchObject([{ placement: "before" }]); + expect(await drop(page, "Item a", "b", 0.8, 0.2)).toMatchObject([{ placement: "after" }]); + expect(await drop(page, "Item a", "boundary")).toEqual([]); + expect(await drop(page, "Item a", "ungrouped")).toEqual([]); + await configure(page, { reorderFeatures: { item: { boundaryDrop: true, ungroupedDrop: true } } }); + expect(await drop(page, "Item a", "boundary", 0.1, 0.1)).toMatchObject([{ targetType: "boundary", targetGroup: "B", placement: "after" }]); + expect(await drop(page, "Item a", "ungrouped")).toMatchObject([{ targetType: "ungrouped", targetGroup: "" }]); +}); + +test("group dragging rejects itself, disabled and root-only moves and respects custom vetoes", async ({ page }) => { + expect(await drop(page, "Group A", "group-A")).toEqual([]); + expect(await drop(page, "Group A", "group-B")).toEqual([{ + type: "group", sourceId: "A", targetId: "B", targetType: "group", placement: "after", + sourceGroup: "A", targetGroup: "B", sourceParentId: "p", targetParentId: "p" + }]); + await configure(page, { reorderFeatures: { group: { reorder: false } } }); + expect(await drop(page, "Group A", "group-B")).toEqual([]); + await configure(page, { reorderFeatures: { group: { reorder: true, rootOnly: true } } }); + expect(await drop(page, "Group A", "group-B")).toEqual([]); + await configure(page, { reorderFeatures: { group: { reorder: true } }, veto: true }); + expect(await drop(page, "Group A", "group-B")).toEqual([]); +}); diff --git a/tests/static-toolboxes.test.mjs b/tests/static-toolboxes.test.mjs index 4d71313..af63ce5 100644 --- a/tests/static-toolboxes.test.mjs +++ b/tests/static-toolboxes.test.mjs @@ -29,7 +29,6 @@ test("toolbox cards and pages retain their public UI conventions", async () => { const storageQuota = await readFile("website/src/components/StorageQuota.jsx", "utf8"); const importButton = await readFile("website/src/components/ImportButton.jsx", "utf8"); const guidedTooltipTour = await readFile("website/src/components/GuidedTooltipTour.jsx", "utf8"); - const groupedReorderHook = await readFile("website/src/hooks/useGroupedReorder.js", "utf8"); assert.match(toolboxActions, /creationTemplate/); assert.match(toolboxActions, /type: "memos"/); @@ -99,8 +98,6 @@ test("toolbox cards and pages retain their public UI conventions", async () => { assert.match(storageQuota, /role="progressbar"/); assert.match(importButton, /export function ImportButton/); assert.match(importButton, /accept="application\/json"/); - assert.match(groupedReorderHook, /data-reorder-orientation/); - assert.match(groupedReorderHook, /operation\.sourceParentId === operation\.targetParentId/); assert.match(toolboxCoverEditor, /image\/webp/); assert.match(toolboxCoverEditor, /OUTPUT_WIDTH = 620/); assert.match(toolboxCoverEditor, /OUTPUT_HEIGHT = 248/);