| 1 | // Run: npx tsx src/__tests__/project-tree-window.test.ts |
| 2 | import assert from "node:assert/strict"; |
| 3 | import { |
| 4 | PROJECT_TREE_WINDOW_INITIAL, |
| 5 | PROJECT_TREE_WINDOW_STEP, |
| 6 | createProjectTreeRequestLimiter, |
| 7 | forgetProjectTreeWindowLimits, |
| 8 | loadProjectTreePageWindow, |
| 9 | projectTreeListKey, |
| 10 | projectTreeKnownGroupIDs, |
| 11 | projectTreeProjectsNeedingInitialLoad, |
| 12 | projectTreeRuntimeWindowLimits, |
| 13 | projectTreeWindowProjection, |
| 14 | projectTreeWindowRows, |
| 15 | reloadProjectTreeTopicLists, |
| 16 | rememberProjectTreeWindowLimit, |
| 17 | resetProjectTreeRuntimeWindowLimits, |
| 18 | } from "../lib/projectTreeWindow"; |
| 19 | import type { ProjectNode } from "../lib/types"; |
| 20 | |
| 21 | function topics(count: number): ProjectNode[] { |
| 22 | return Array.from({ length: count }, (_, index) => ({ |
| 23 | key: `topic_${index + 1}`, |
| 24 | kind: "topic", |
| 25 | label: `Topic ${index + 1}`, |
| 26 | topicId: `topic-${index + 1}`, |
| 27 | children: [], |
| 28 | })); |
| 29 | } |
| 30 | |
| 31 | for (const count of [0, 1, 5, 6, 15, 16]) { |
| 32 | const rows = topics(count); |
| 33 | assert.equal( |
| 34 | projectTreeWindowRows(rows, PROJECT_TREE_WINDOW_INITIAL, () => false).length, |
| 35 | Math.min(count, PROJECT_TREE_WINDOW_INITIAL), |
| 36 | `default window for ${count}`, |
| 37 | ); |
| 38 | } |
| 39 | |
| 40 | const many = topics(30); |
| 41 | assert.deepEqual( |
| 42 | [ |
| 43 | PROJECT_TREE_WINDOW_INITIAL, |
| 44 | PROJECT_TREE_WINDOW_INITIAL + PROJECT_TREE_WINDOW_STEP, |
| 45 | PROJECT_TREE_WINDOW_INITIAL + PROJECT_TREE_WINDOW_STEP * 2, |
| 46 | ].map((limit) => projectTreeWindowRows(many, limit, () => false).length), |
| 47 | [5, 10, 15], |
| 48 | ); |
| 49 | |
| 50 | const activeOutside = projectTreeWindowProjection(many, 5, (node) => node.topicId === "topic-30"); |
| 51 | assert.equal(activeOutside.rows.length, 6); |
| 52 | assert.equal(activeOutside.rows[activeOutside.rows.length - 1]?.topicId, "topic-30"); |
| 53 | assert.equal(new Set(activeOutside.rows.map((node) => node.key)).size, activeOutside.rows.length); |
| 54 | assert.equal(activeOutside.hasHiddenLoadedRows, true); |
| 55 | |
| 56 | const activeOnlyOverflow = projectTreeWindowProjection(topics(6), 5, (node) => node.topicId === "topic-6"); |
| 57 | assert.equal(activeOnlyOverflow.rows.length, 6); |
| 58 | assert.equal(activeOnlyOverflow.hasHiddenLoadedRows, false, "an active fallback row alone does not imply hidden loaded rows"); |
| 59 | |
| 60 | const activeInside = projectTreeWindowRows(many, 5, (node) => node.topicId === "topic-3"); |
| 61 | assert.equal(activeInside.length, 5); |
| 62 | assert.equal(activeInside.filter((node) => node.topicId === "topic-3").length, 1); |
| 63 | |
| 64 | assert.notEqual(projectTreeListKey("project", "group-a"), projectTreeListKey("project", "group-b")); |
| 65 | assert.notEqual(projectTreeListKey("project", "", "needle"), projectTreeListKey("project", "")); |
| 66 | assert.equal(projectTreeListKey("project", "", " Needle "), projectTreeListKey("project", "", "needle")); |
| 67 | |
| 68 | const knownGroupStates = { |
| 69 | [projectTreeListKey("project", "bugs")]: { loading: false, initialized: true }, |
| 70 | [projectTreeListKey("project", "feature")]: { loading: false, initialized: true }, |
| 71 | [projectTreeListKey("project", "", "needle")]: { loading: false, initialized: true }, |
| 72 | [projectTreeListKey("other", "ignored")]: { loading: false, initialized: true }, |
| 73 | }; |
| 74 | assert.deepEqual(projectTreeKnownGroupIDs(knownGroupStates, "project"), ["bugs", "feature"]); |
| 75 | |
| 76 | const cachedProjects = [ |
| 77 | { key: "project-a", kind: "project", label: "A", children: [] }, |
| 78 | { key: "project-b", kind: "project", label: "B", children: [] }, |
| 79 | ] satisfies ProjectNode[]; |
| 80 | const cachedPageStates = { |
| 81 | [projectTreeListKey("project-a")]: { loading: false, initialized: true, itemKeys: ["a-1", "a-2"] }, |
| 82 | [projectTreeListKey("project-b")]: { loading: false, initialized: true, itemKeys: ["b-1", "b-2"] }, |
| 83 | }; |
| 84 | const scheduledLoads: string[] = []; |
| 85 | const scheduleInitialLoads = (expandedKeys: ReadonlySet<string>, pageStates = cachedPageStates) => { |
| 86 | for (const project of projectTreeProjectsNeedingInitialLoad(cachedProjects, expandedKeys, "", pageStates, (item) => item.key)) { |
| 87 | scheduledLoads.push(project.key); |
| 88 | } |
| 89 | }; |
| 90 | scheduleInitialLoads(new Set(["project-b"])); |
| 91 | assert.deepEqual(scheduledLoads, [], "collapsing one project does not reload an initialized sibling"); |
| 92 | scheduleInitialLoads(new Set(["project-a", "project-b"])); |
| 93 | assert.deepEqual(scheduledLoads, [], "reopening one project or restoring all projects reuses initialized pages"); |
| 94 | scheduleInitialLoads(new Set(["project-a", "project-b"]), { |
| 95 | ...cachedPageStates, |
| 96 | [projectTreeListKey("project-a")]: { ...cachedPageStates[projectTreeListKey("project-a")], initialized: false }, |
| 97 | }); |
| 98 | assert.deepEqual(scheduledLoads, ["project-a"], "only an explicitly invalidated expanded project requests its first page"); |
| 99 | |
| 100 | const reloadCalls: string[] = []; |
| 101 | const reloadProject = { key: "project", kind: "project", label: "Project", children: [] } satisfies ProjectNode; |
| 102 | await reloadProjectTreeTopicLists(reloadProject, "", knownGroupStates, async (_project, groupID) => { |
| 103 | reloadCalls.push(groupID || "ungrouped"); |
| 104 | }); |
| 105 | assert.deepEqual(reloadCalls, ["ungrouped", "bugs", "feature"], "ordinary refresh replenishes every known group list"); |
| 106 | reloadCalls.length = 0; |
| 107 | await reloadProjectTreeTopicLists(reloadProject, "needle", knownGroupStates, async (_project, groupID) => { |
| 108 | reloadCalls.push(groupID || "search"); |
| 109 | }); |
| 110 | assert.deepEqual(reloadCalls, ["search"], "search refresh reloads only the project search list"); |
| 111 | |
| 112 | const limiter = createProjectTreeRequestLimiter(4); |
| 113 | let running = 0; |
| 114 | let peak = 0; |
| 115 | const releases: Array<() => void> = []; |
| 116 | const tasks = Array.from({ length: 8 }, () => limiter.run(() => new Promise<void>((resolve) => { |
| 117 | running += 1; |
| 118 | peak = Math.max(peak, running); |
| 119 | releases.push(() => { |
| 120 | running -= 1; |
| 121 | resolve(); |
| 122 | }); |
| 123 | }))); |
| 124 | await Promise.resolve(); |
| 125 | assert.equal(peak, 4, "sidebar pagination is capped at four concurrent requests"); |
| 126 | while (releases.length > 0) { |
| 127 | releases.shift()?.(); |
| 128 | await Promise.resolve(); |
| 129 | } |
| 130 | await Promise.all(tasks); |
| 131 | |
| 132 | const pageCalls: Array<{ cursor: string; limit: number }> = []; |
| 133 | const pagedTopics = topics(250); |
| 134 | const restored = await loadProjectTreePageWindow("", 205, async (cursor, limit) => { |
| 135 | pageCalls.push({ cursor, limit }); |
| 136 | const start = cursor ? Number(cursor) : 0; |
| 137 | const end = Math.min(start + limit, 250); |
| 138 | return { |
| 139 | items: pagedTopics.slice(start, end), |
| 140 | nextCursor: end < 250 ? String(end) : undefined, |
| 141 | revision: pageCalls.length, |
| 142 | complete: true, |
| 143 | }; |
| 144 | }); |
| 145 | assert.deepEqual(pageCalls, [{ cursor: "", limit: 200 }, { cursor: "200", limit: 5 }]); |
| 146 | assert.equal(restored.items.length, 205, "refresh refills the complete expanded quota"); |
| 147 | assert.equal(restored.nextCursor, "205"); |
| 148 | assert.equal(restored.revision, 2); |
| 149 | |
| 150 | const rememberedKey = projectTreeListKey("remembered-project", "feature"); |
| 151 | const siblingKey = projectTreeListKey("sibling-project", "feature"); |
| 152 | rememberProjectTreeWindowLimit(rememberedKey, 15); |
| 153 | rememberProjectTreeWindowLimit(siblingKey, 10); |
| 154 | assert.equal(projectTreeRuntimeWindowLimits()[rememberedKey], 15, "expanded quota survives a component remount"); |
| 155 | resetProjectTreeRuntimeWindowLimits("remembered-project"); |
| 156 | assert.equal(projectTreeRuntimeWindowLimits()[rememberedKey], undefined, "collapsing one project resets all of its window quotas"); |
| 157 | assert.equal(projectTreeRuntimeWindowLimits()[siblingKey], 10, "collapsing one project preserves sibling quotas"); |
| 158 | resetProjectTreeRuntimeWindowLimits(); |
| 159 | assert.deepEqual(projectTreeRuntimeWindowLimits(), {}, "collapsing the whole tree resets every window quota"); |
| 160 | rememberProjectTreeWindowLimit(rememberedKey, 15); |
| 161 | forgetProjectTreeWindowLimits(new Set(["other-project"])); |
| 162 | assert.equal(projectTreeRuntimeWindowLimits()[rememberedKey], undefined, "deleted projects release runtime quota state"); |
| 163 | |
| 164 | console.log(" PASS project tree window contract"); |
| 165 |