| 1 | // Run: tsx src/__tests__/project-tree-organization-group-drop.test.tsx |
| 2 | |
| 3 | import { JSDOM } from "jsdom"; |
| 4 | import React from "react"; |
| 5 | import { act } from "react"; |
| 6 | import { createRoot } from "react-dom/client"; |
| 7 | import { useProjectTreeOrganization } from "../components/ProjectTreeOrganization"; |
| 8 | import type { ProjectNode, ProjectTreeOrganizationBindings, SessionGroup } from "../lib/types"; |
| 9 | import type { SessionOrganizationMutation, SessionOrganizationSnapshot } from "../generated/desktopContract.generated"; |
| 10 | import { ToastProvider } from "../lib/toast"; |
| 11 | import { LocaleProvider } from "../lib/i18n"; |
| 12 | |
| 13 | let passed = 0; |
| 14 | let failed = 0; |
| 15 | |
| 16 | function ok(value: boolean, label: string) { |
| 17 | process.stdout.write(` ${value ? "PASS" : "FAIL"} ${label}\n`); |
| 18 | if (value) passed += 1; else failed += 1; |
| 19 | } |
| 20 | |
| 21 | function installDom() { |
| 22 | const dom = new JSDOM("<!doctype html><html><body><div id=\"root\"></div></body></html>", { |
| 23 | pretendToBeVisual: true, |
| 24 | url: "http://localhost/", |
| 25 | }); |
| 26 | (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; |
| 27 | globalThis.window = dom.window as unknown as Window & typeof globalThis; |
| 28 | globalThis.document = dom.window.document; |
| 29 | globalThis.Node = dom.window.Node; |
| 30 | globalThis.Element = dom.window.Element; |
| 31 | globalThis.HTMLElement = dom.window.HTMLElement; |
| 32 | globalThis.Event = dom.window.Event; |
| 33 | globalThis.MouseEvent = dom.window.MouseEvent; |
| 34 | return dom; |
| 35 | } |
| 36 | |
| 37 | function topic(id: string): ProjectNode { |
| 38 | return { key: `topic-${id}`, kind: "topic", label: id, root: "/repo", topicId: id, session: { hostId: "local", sessionId: id } } as ProjectNode; |
| 39 | } |
| 40 | |
| 41 | const inside = topic("inside"); |
| 42 | const sibling = topic("sibling"); |
| 43 | const outside = topic("outside"); |
| 44 | const loose = topic("loose"); |
| 45 | const folder: ProjectNode = { key: "project-/repo", kind: "project", label: "Repo", root: "/repo", children: [inside, sibling, outside, loose] }; |
| 46 | const groups: SessionGroup[] = [{ id: "g1", title: "Group", sessionKeys: ["ref\0local\0inside", "ref\0local\0sibling"] }]; |
| 47 | |
| 48 | function Harness({ bindings }: { bindings: ProjectTreeOrganizationBindings }) { |
| 49 | const organization = useProjectTreeOrganization({ tree: [folder], refresh: async () => {}, sortMode: "created", bindings }); |
| 50 | const loaded = organization.groupsFor(folder).length > 0; |
| 51 | return <> |
| 52 | <output id="loaded">{loaded ? "yes" : "no"}</output> |
| 53 | {folder.children!.map((node) => { |
| 54 | const row = organization.topicRow(node, false); |
| 55 | return <div key={node.key} id={`row-${node.topicId}`} {...row.props}>{node.label}</div>; |
| 56 | })} |
| 57 | </>; |
| 58 | } |
| 59 | |
| 60 | async function flush() { |
| 61 | await new Promise((resolve) => setTimeout(resolve, 0)); |
| 62 | } |
| 63 | |
| 64 | function fireDrag(type: string, target: Element, data: Map<string, string>) { |
| 65 | const event = new window.Event(type, { bubbles: true, cancelable: true }); |
| 66 | const dataTransfer = { |
| 67 | setData: (format: string, value: string) => { data.set(format, value); }, |
| 68 | getData: (format: string) => data.get(format) ?? "", |
| 69 | effectAllowed: "all", |
| 70 | dropEffect: "none", |
| 71 | }; |
| 72 | Object.defineProperty(event, "dataTransfer", { value: dataTransfer }); |
| 73 | Object.defineProperty(event, "clientY", { value: 10 }); |
| 74 | target.dispatchEvent(event); |
| 75 | } |
| 76 | |
| 77 | async function dropOnto(draggedID: string, targetID: string): Promise<SessionOrganizationMutation[]> { |
| 78 | const dom = installDom(); |
| 79 | const mutations: SessionOrganizationMutation[] = []; |
| 80 | const snapshot = (): SessionOrganizationSnapshot => ({ groups: structuredClone(groups), revision: 1, applied: true, order: [], manualOrderEnabled: false }); |
| 81 | const bindings: ProjectTreeOrganizationBindings = { |
| 82 | ReorderTopics: async () => {}, |
| 83 | ListProjectGroups: async () => [], |
| 84 | SaveSessionGroups: async () => {}, |
| 85 | GetSessionOrganization: async () => snapshot(), |
| 86 | UpdateSessionOrganization: async (_workspace, _revision, mutation) => { mutations.push(mutation); return snapshot(); }, |
| 87 | }; |
| 88 | const root = createRoot(document.getElementById("root")!); |
| 89 | await act(async () => { |
| 90 | root.render(<LocaleProvider><ToastProvider><Harness bindings={bindings} /></ToastProvider></LocaleProvider>); |
| 91 | await flush(); |
| 92 | }); |
| 93 | for (let attempt = 0; attempt < 30 && document.getElementById("loaded")?.textContent !== "yes"; attempt += 1) await act(flush); |
| 94 | const data = new Map<string, string>(); |
| 95 | await act(async () => { fireDrag("dragstart", document.getElementById(`row-${draggedID}`)!, data); await flush(); }); |
| 96 | await act(async () => { fireDrag("dragover", document.getElementById(`row-${targetID}`)!, data); await flush(); }); |
| 97 | await act(async () => { fireDrag("drop", document.getElementById(`row-${targetID}`)!, data); await flush(); }); |
| 98 | for (let attempt = 0; attempt < 10; attempt += 1) await act(flush); |
| 99 | await act(async () => root.unmount()); |
| 100 | dom.window.close(); |
| 101 | return mutations; |
| 102 | } |
| 103 | |
| 104 | console.log("\nproject tree drop onto a group member row"); |
| 105 | |
| 106 | { |
| 107 | const mutations = await dropOnto("outside", "inside"); |
| 108 | ok(mutations.length === 1 && mutations[0].kind === "set-group" && mutations[0].groupId === "g1", |
| 109 | `dropping a non-member on a member row adds it to that group (got ${JSON.stringify(mutations.map((m) => [m.kind, m.groupId ?? ""]))})`); |
| 110 | } |
| 111 | |
| 112 | { |
| 113 | const mutations = await dropOnto("sibling", "inside"); |
| 114 | ok(mutations.length === 1 && mutations[0].kind === "move", |
| 115 | `dropping a member on another member of its group reorders (got ${JSON.stringify(mutations.map((m) => m.kind))})`); |
| 116 | } |
| 117 | |
| 118 | { |
| 119 | const mutations = await dropOnto("outside", "loose"); |
| 120 | ok(mutations.length === 1 && mutations[0].kind === "move", |
| 121 | `dropping on an ungrouped row reorders (got ${JSON.stringify(mutations.map((m) => m.kind))})`); |
| 122 | } |
| 123 | |
| 124 | { |
| 125 | const mutations = await dropOnto("outside", "loose"); |
| 126 | ok(mutations.length === 1 && mutations[0].sortMode === "created", |
| 127 | `a move carries the sort order on screen, so a first manual move starts from it (got ${JSON.stringify(mutations.map((m) => m.sortMode ?? ""))})`); |
| 128 | } |
| 129 | |
| 130 | process.stdout.write(`\nproject-tree-organization-group-drop: ${passed} passed, ${failed} failed\n`); |
| 131 | if (failed > 0) process.exit(1); |
| 132 |