| 1 | // Run: npx tsx src/__tests__/project-tree-group-window.test.tsx |
| 2 | import assert from "node:assert/strict"; |
| 3 | import { JSDOM } from "jsdom"; |
| 4 | import React, { act, useState } from "react"; |
| 5 | import { createRoot } from "react-dom/client"; |
| 6 | import type { Translator } from "../lib/i18n"; |
| 7 | import type { ProjectNode, SessionGroup } from "../lib/types"; |
| 8 | import type { ProjectTreeOrganizationController } from "../components/ProjectTreeOrganization"; |
| 9 | |
| 10 | const dom = new JSDOM('<!doctype html><html><body><div id="root"></div></body></html>', { url: "http://localhost/" }); |
| 11 | Object.assign(globalThis, { |
| 12 | window: dom.window, |
| 13 | document: dom.window.document, |
| 14 | HTMLElement: dom.window.HTMLElement, |
| 15 | Element: dom.window.Element, |
| 16 | requestAnimationFrame: (callback: FrameRequestCallback) => setTimeout(() => callback(Date.now()), 0), |
| 17 | cancelAnimationFrame: (id: number) => clearTimeout(id), |
| 18 | IS_REACT_ACT_ENVIRONMENT: true, |
| 19 | }); |
| 20 | Object.defineProperty(globalThis, "navigator", { value: dom.window.navigator, configurable: true }); |
| 21 | |
| 22 | const { ProjectTreeGroupRows } = await import("../components/ProjectTreeOrganization"); |
| 23 | |
| 24 | const folder: ProjectNode = { key: "project", kind: "project", label: "Project", root: "/project", children: [] }; |
| 25 | const groups: SessionGroup[] = [ |
| 26 | { id: "feature", title: "Feature", topicIds: Array.from({ length: 16 }, (_, index) => `feature-${index}`) }, |
| 27 | { id: "bugs", title: "Bugs", topicIds: Array.from({ length: 16 }, (_, index) => `bugs-${index}`) }, |
| 28 | ]; |
| 29 | const topic = (id: string): ProjectNode => ({ key: `topic-${id}`, kind: "topic", label: id, topicId: id, children: [] }); |
| 30 | const children = [ |
| 31 | ...Array.from({ length: 16 }, (_, index) => topic(`plain-${index}`)), |
| 32 | ...groups.flatMap((group) => (group.topicIds ?? []).map(topic)), |
| 33 | ]; |
| 34 | |
| 35 | const organization: ProjectTreeOrganizationController = { |
| 36 | topicRow: () => ({ className: "", props: {} }), |
| 37 | topicMenuItems: () => [], |
| 38 | createGroup: () => {}, |
| 39 | groupsFor: () => groups, |
| 40 | groupCollapsed: () => false, |
| 41 | toggleGroup: () => {}, |
| 42 | renameGroup: () => {}, |
| 43 | deleteGroup: () => {}, |
| 44 | canDropTopicInto: () => false, |
| 45 | dropTopicInto: () => {}, |
| 46 | }; |
| 47 | |
| 48 | const t = ((key: string, values?: Record<string, string>) => { |
| 49 | if (key === "projectTree.expandDisplay") return "Show more"; |
| 50 | if (key === "projectTree.expandGroup") return `Show more in ${values?.name}`; |
| 51 | return key; |
| 52 | }) as Translator; |
| 53 | |
| 54 | function Harness() { |
| 55 | const [limits, setLimits] = useState<Record<string, number>>({ "": 5, feature: 5, bugs: 5 }); |
| 56 | return <ProjectTreeGroupRows |
| 57 | folder={folder} |
| 58 | children={children} |
| 59 | depth={1} |
| 60 | section="projects" |
| 61 | visible |
| 62 | organization={organization} |
| 63 | renderNode={(node) => <div data-topic={node.topicId} key={node.key} />} |
| 64 | t={t} |
| 65 | queryActive={false} |
| 66 | remote={false} |
| 67 | activeTopicId={undefined} |
| 68 | isActive={() => false} |
| 69 | listState={(groupID) => ({ itemKeys: (groupID ? groups.find((group) => group.id === groupID)?.topicIds : children.filter((node) => node.topicId?.startsWith("plain-")).map((node) => node.key))?.map((id) => id.startsWith("topic-") ? id : `topic-${id}`) ?? [], loading: false, initialized: true })} |
| 70 | listLimit={(groupID) => limits[groupID] ?? 5} |
| 71 | onEnsureList={() => {}} |
| 72 | onExpandList={(groupID) => setLimits((current) => ({ ...current, [groupID]: (current[groupID] ?? 5) + 5 }))} |
| 73 | onRetryList={() => {}} |
| 74 | onForgetList={() => {}} |
| 75 | />; |
| 76 | } |
| 77 | |
| 78 | const container = document.getElementById("root")!; |
| 79 | const root = createRoot(container); |
| 80 | await act(async () => root.render(<Harness />)); |
| 81 | const rows = (prefix: string) => container.querySelectorAll(`[data-topic^="${prefix}"]`).length; |
| 82 | assert.equal(rows("plain-"), 5); |
| 83 | assert.equal(rows("feature-"), 5); |
| 84 | assert.equal(rows("bugs-"), 5); |
| 85 | |
| 86 | await act(async () => (container.querySelector('[aria-label="Show more in Feature"]') as HTMLButtonElement).click()); |
| 87 | assert.equal(rows("feature-"), 10); |
| 88 | assert.equal(rows("bugs-"), 5, "expanding one group leaves the other group unchanged"); |
| 89 | assert.equal(rows("plain-"), 5, "expanding a group leaves ungrouped rows unchanged"); |
| 90 | assert.equal(container.querySelectorAll('[aria-label^="Show less in "]').length, 0, "session windows expose no competing collapse action"); |
| 91 | |
| 92 | await act(async () => (container.querySelector('[aria-label="Show more in Feature"]') as HTMLButtonElement).click()); |
| 93 | assert.equal(rows("feature-"), 15); |
| 94 | await act(async () => (container.querySelector('[aria-label="Show more in Feature"]') as HTMLButtonElement).click()); |
| 95 | assert.equal(rows("feature-"), 16); |
| 96 | assert.equal(container.querySelector('[aria-label="Show more in Feature"]'), null, "the final loaded row removes the one-way disclosure"); |
| 97 | |
| 98 | await act(async () => root.unmount()); |
| 99 | |
| 100 | let openedActiveGroup = ""; |
| 101 | const collapsedOrganization: ProjectTreeOrganizationController = { |
| 102 | ...organization, |
| 103 | groupCollapsed: (_key, groupID) => groupID === "feature", |
| 104 | toggleGroup: (_key, groupID) => { openedActiveGroup = groupID; }, |
| 105 | }; |
| 106 | const secondRoot = createRoot(container); |
| 107 | await act(async () => secondRoot.render(<ProjectTreeGroupRows |
| 108 | folder={folder} |
| 109 | children={children} |
| 110 | depth={1} |
| 111 | section="projects" |
| 112 | visible |
| 113 | organization={collapsedOrganization} |
| 114 | renderNode={(node) => <div data-topic={node.topicId} key={node.key} />} |
| 115 | t={t} |
| 116 | queryActive={false} |
| 117 | remote={false} |
| 118 | activeTopicId="feature-10" |
| 119 | isActive={(node) => node.topicId === "feature-10"} |
| 120 | listState={(groupID) => ({ itemKeys: groupID === "feature" ? groups[0].topicIds?.map((id) => `topic-${id}`) : [], loading: false, initialized: true })} |
| 121 | listLimit={() => 5} |
| 122 | onEnsureList={() => {}} |
| 123 | onExpandList={() => {}} |
| 124 | onRetryList={() => {}} |
| 125 | onForgetList={() => {}} |
| 126 | />)); |
| 127 | assert.equal(openedActiveGroup, "feature", "initial active navigation opens its collapsed group once"); |
| 128 | await act(async () => secondRoot.unmount()); |
| 129 | |
| 130 | const ensuredGroups: string[] = []; |
| 131 | function LazyGroupHarness() { |
| 132 | const [featureCollapsed, setFeatureCollapsed] = useState(true); |
| 133 | const lazyOrganization: ProjectTreeOrganizationController = { |
| 134 | ...organization, |
| 135 | groupCollapsed: (_key, groupID) => featureCollapsed && groupID === "feature", |
| 136 | toggleGroup: (_key, groupID) => { if (groupID === "feature") setFeatureCollapsed((value) => !value); }, |
| 137 | }; |
| 138 | return <ProjectTreeGroupRows |
| 139 | folder={folder} |
| 140 | children={children} |
| 141 | depth={1} |
| 142 | section="projects" |
| 143 | visible |
| 144 | organization={lazyOrganization} |
| 145 | renderNode={(node) => <div data-topic={node.topicId} key={node.key} />} |
| 146 | t={t} |
| 147 | queryActive={false} |
| 148 | remote={false} |
| 149 | activeTopicId={undefined} |
| 150 | isActive={() => false} |
| 151 | listState={() => ({ loading: false, initialized: false })} |
| 152 | listLimit={() => 5} |
| 153 | onEnsureList={(groupID) => { ensuredGroups.push(groupID); }} |
| 154 | onExpandList={() => {}} |
| 155 | onRetryList={() => {}} |
| 156 | onForgetList={() => {}} |
| 157 | />; |
| 158 | } |
| 159 | const thirdRoot = createRoot(container); |
| 160 | await act(async () => thirdRoot.render(<LazyGroupHarness />)); |
| 161 | assert.deepEqual(ensuredGroups, ["", "bugs"], "collapsed groups defer their first page until they are opened"); |
| 162 | await act(async () => (container.querySelector(".project-tree__group-main") as HTMLElement).click()); |
| 163 | assert.ok(ensuredGroups.includes("feature"), "opening a collapsed group requests its first page"); |
| 164 | await act(async () => thirdRoot.unmount()); |
| 165 | console.log(" PASS project tree group windows are independent"); |
| 166 |