@reqord/web 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (138) hide show
  1. package/LICENSE +661 -0
  2. package/next-env.d.ts +6 -0
  3. package/next.config.ts +7 -0
  4. package/package.json +59 -0
  5. package/postcss.config.mjs +7 -0
  6. package/src/__tests__/components/dashboard/critical-path-display.test.tsx +129 -0
  7. package/src/__tests__/components/dashboard/progress-bar.test.tsx +87 -0
  8. package/src/__tests__/components/dashboard/project-health.test.tsx +57 -0
  9. package/src/__tests__/components/dashboard/warning-alert.test.tsx +75 -0
  10. package/src/__tests__/components/feedback/feedback-client-view.test.tsx +84 -0
  11. package/src/__tests__/components/feedback/feedback-filters.test.tsx +51 -0
  12. package/src/__tests__/components/feedback/feedback-linked-items.test.tsx +131 -0
  13. package/src/__tests__/components/feedback/feedback-list.test.tsx +49 -0
  14. package/src/__tests__/components/feedback/feedback-table.test.tsx +165 -0
  15. package/src/__tests__/components/flags/flag-badge.test.tsx +41 -0
  16. package/src/__tests__/components/flags/flag-list.test.tsx +51 -0
  17. package/src/__tests__/components/gantt/gantt-bar.test.tsx +190 -0
  18. package/src/__tests__/components/gantt/gantt-chart.test.tsx +141 -0
  19. package/src/__tests__/components/gantt/gantt-header.test.tsx +84 -0
  20. package/src/__tests__/components/gantt/gantt-legend.test.tsx +52 -0
  21. package/src/__tests__/components/graph/dag-layout.test.ts +129 -0
  22. package/src/__tests__/components/graph/dependency-graph.test.tsx +94 -0
  23. package/src/__tests__/components/graph/drilldown-breadcrumb.test.tsx +70 -0
  24. package/src/__tests__/components/graph/drilldown-graph.test.tsx +108 -0
  25. package/src/__tests__/components/graph/edge-styles.test.ts +27 -0
  26. package/src/__tests__/components/graph/graph-page-client.test.tsx +124 -0
  27. package/src/__tests__/components/graph/issue-node.test.tsx +173 -0
  28. package/src/__tests__/components/graph/requirement-node.test.tsx +151 -0
  29. package/src/__tests__/components/graph/specification-node.test.tsx +140 -0
  30. package/src/__tests__/components/specification/spec-tabs.test.tsx +153 -0
  31. package/src/__tests__/components/specification/tab-coverage.test.tsx +70 -0
  32. package/src/__tests__/components/specification/tab-design.test.tsx +42 -0
  33. package/src/__tests__/components/specification/tab-history.test.tsx +118 -0
  34. package/src/__tests__/components/specification/tab-issues.test.tsx +126 -0
  35. package/src/__tests__/components/specification/tab-research.test.tsx +42 -0
  36. package/src/__tests__/lib/dashboard-data.test.ts +334 -0
  37. package/src/__tests__/lib/drilldown-graph-data.test.ts +267 -0
  38. package/src/__tests__/lib/gantt-data.test.ts +299 -0
  39. package/src/__tests__/lib/graph-data.test.ts +309 -0
  40. package/src/__tests__/lib/local-feedback-repository.test.ts +74 -0
  41. package/src/__tests__/lib/local-specification-repository.test.ts +194 -0
  42. package/src/__tests__/lib/reqord-root.test.ts +31 -0
  43. package/src/__tests__/lib/specification-file.test.ts +63 -0
  44. package/src/__tests__/lib/tasks-data.test.ts +104 -0
  45. package/src/app/dashboard/loading.tsx +21 -0
  46. package/src/app/dashboard/page.tsx +50 -0
  47. package/src/app/error.tsx +22 -0
  48. package/src/app/feedback/loading.tsx +13 -0
  49. package/src/app/feedback/page.tsx +48 -0
  50. package/src/app/globals.css +2 -0
  51. package/src/app/graph/page.tsx +32 -0
  52. package/src/app/layout.tsx +25 -0
  53. package/src/app/page.tsx +5 -0
  54. package/src/app/requirements/[id]/edit/page.tsx +40 -0
  55. package/src/app/requirements/[id]/loading.tsx +14 -0
  56. package/src/app/requirements/[id]/not-found.tsx +18 -0
  57. package/src/app/requirements/[id]/page.tsx +43 -0
  58. package/src/app/requirements/loading.tsx +13 -0
  59. package/src/app/requirements/new/page.tsx +14 -0
  60. package/src/app/requirements/page.tsx +35 -0
  61. package/src/app/specifications/[id]/loading.tsx +14 -0
  62. package/src/app/specifications/[id]/not-found.tsx +18 -0
  63. package/src/app/specifications/[id]/page.tsx +52 -0
  64. package/src/app/specifications/loading.tsx +13 -0
  65. package/src/app/specifications/page.tsx +42 -0
  66. package/src/components/dashboard/critical-path-display.tsx +76 -0
  67. package/src/components/dashboard/progress-bar.tsx +45 -0
  68. package/src/components/dashboard/progress-section.tsx +57 -0
  69. package/src/components/dashboard/project-health.tsx +35 -0
  70. package/src/components/dashboard/status-card.tsx +27 -0
  71. package/src/components/dashboard/status-cards.tsx +28 -0
  72. package/src/components/dashboard/warning-alert.tsx +33 -0
  73. package/src/components/dashboard/warning-alerts.tsx +24 -0
  74. package/src/components/feedback/feedback-badge.tsx +48 -0
  75. package/src/components/feedback/feedback-client-view.tsx +38 -0
  76. package/src/components/feedback/feedback-filters.tsx +86 -0
  77. package/src/components/feedback/feedback-linked-items.tsx +93 -0
  78. package/src/components/feedback/feedback-list.tsx +40 -0
  79. package/src/components/feedback/feedback-table.tsx +115 -0
  80. package/src/components/gantt/gantt-bar.tsx +65 -0
  81. package/src/components/gantt/gantt-chart.tsx +88 -0
  82. package/src/components/gantt/gantt-constants.ts +15 -0
  83. package/src/components/gantt/gantt-critical-path.tsx +38 -0
  84. package/src/components/gantt/gantt-group.tsx +25 -0
  85. package/src/components/gantt/gantt-header.tsx +47 -0
  86. package/src/components/gantt/gantt-legend.tsx +26 -0
  87. package/src/components/graph/dag-layout.ts +131 -0
  88. package/src/components/graph/dependency-graph.tsx +88 -0
  89. package/src/components/graph/drilldown-breadcrumb.tsx +35 -0
  90. package/src/components/graph/drilldown-graph.tsx +45 -0
  91. package/src/components/graph/edge-styles.ts +16 -0
  92. package/src/components/graph/graph-loader.tsx +25 -0
  93. package/src/components/graph/graph-page-client.tsx +98 -0
  94. package/src/components/graph/issue-node.tsx +46 -0
  95. package/src/components/graph/multi-level-graph.tsx +91 -0
  96. package/src/components/graph/requirement-node.tsx +69 -0
  97. package/src/components/graph/specification-node.tsx +39 -0
  98. package/src/components/requirement/delete-button.tsx +46 -0
  99. package/src/components/requirement/dependency-editor.tsx +79 -0
  100. package/src/components/requirement/markdown-editor.tsx +47 -0
  101. package/src/components/requirement/markdown-renderer.tsx +12 -0
  102. package/src/components/requirement/requirement-detail.tsx +228 -0
  103. package/src/components/requirement/requirement-form.tsx +390 -0
  104. package/src/components/requirement/requirement-table.tsx +203 -0
  105. package/src/components/requirement/requirement-tabs.tsx +65 -0
  106. package/src/components/requirement/success-criteria-editor.tsx +53 -0
  107. package/src/components/specification/spec-detail.tsx +103 -0
  108. package/src/components/specification/spec-tabs.tsx +66 -0
  109. package/src/components/specification/specification-table.tsx +193 -0
  110. package/src/components/specification/tab-coverage.tsx +52 -0
  111. package/src/components/specification/tab-design.tsx +16 -0
  112. package/src/components/specification/tab-history.tsx +61 -0
  113. package/src/components/specification/tab-issues.tsx +111 -0
  114. package/src/components/specification/tab-research.tsx +16 -0
  115. package/src/components/ui/badge.tsx +64 -0
  116. package/src/components/ui/nav.tsx +49 -0
  117. package/src/components/ui/tabs.tsx +39 -0
  118. package/src/lib/actions.ts +222 -0
  119. package/src/lib/dashboard-data.ts +224 -0
  120. package/src/lib/data.ts +21 -0
  121. package/src/lib/drilldown-graph-data.ts +98 -0
  122. package/src/lib/feedback-data.ts +33 -0
  123. package/src/lib/feedback-repository.ts +6 -0
  124. package/src/lib/file-system.ts +167 -0
  125. package/src/lib/gantt-data.ts +168 -0
  126. package/src/lib/get-repository.ts +43 -0
  127. package/src/lib/graph-data.ts +161 -0
  128. package/src/lib/id-generator.ts +23 -0
  129. package/src/lib/local-feedback-repository.ts +36 -0
  130. package/src/lib/local-repository.ts +78 -0
  131. package/src/lib/local-specification-repository.ts +61 -0
  132. package/src/lib/repository.ts +11 -0
  133. package/src/lib/reqord-root.ts +33 -0
  134. package/src/lib/specification-data.ts +28 -0
  135. package/src/lib/specification-file.ts +12 -0
  136. package/src/lib/specification-repository.ts +8 -0
  137. package/src/lib/tasks-data.ts +32 -0
  138. package/tsconfig.json +27 -0
@@ -0,0 +1,84 @@
1
+ // @vitest-environment jsdom
2
+ import React from "react";
3
+ import { describe, it, expect } from "vitest";
4
+ import { render } from "@testing-library/react";
5
+ import "@testing-library/jest-dom/vitest";
6
+ import { GanttHeader } from "@/components/gantt/gantt-header";
7
+
8
+ describe("GanttHeader", () => {
9
+ it("renders hour markers from 0 to timelineEnd", () => {
10
+ const { container } = render(
11
+ <svg>
12
+ <GanttHeader timelineEnd={16} hourWidth={60} leftLabelWidth={200} />
13
+ </svg>
14
+ );
15
+
16
+ const texts = container.querySelectorAll("text");
17
+ expect(texts.length).toBeGreaterThan(0);
18
+ });
19
+
20
+ it("renders hour labels at 4-hour intervals", () => {
21
+ const { container } = render(
22
+ <svg>
23
+ <GanttHeader timelineEnd={16} hourWidth={60} leftLabelWidth={200} />
24
+ </svg>
25
+ );
26
+
27
+ const texts = Array.from(container.querySelectorAll("text")).map(
28
+ (el) => el.textContent
29
+ );
30
+ expect(texts).toContain("0h");
31
+ expect(texts).toContain("4h");
32
+ expect(texts).toContain("8h");
33
+ expect(texts).toContain("12h");
34
+ expect(texts).toContain("16h");
35
+ });
36
+
37
+ it("renders correct number of markers for timelineEnd=16", () => {
38
+ const { container } = render(
39
+ <svg>
40
+ <GanttHeader timelineEnd={16} hourWidth={60} leftLabelWidth={200} />
41
+ </svg>
42
+ );
43
+
44
+ const texts = Array.from(container.querySelectorAll("text"));
45
+ expect(texts.length).toBe(5); // 0, 4, 8, 12, 16
46
+ });
47
+
48
+ it("positions hour markers correctly with hourWidth", () => {
49
+ const { container } = render(
50
+ <svg>
51
+ <GanttHeader timelineEnd={8} hourWidth={60} leftLabelWidth={200} />
52
+ </svg>
53
+ );
54
+
55
+ const texts = Array.from(container.querySelectorAll("text"));
56
+ const firstText = texts[0];
57
+ expect(firstText).toHaveAttribute("x", "200"); // leftLabelWidth + 0 * hourWidth
58
+
59
+ const secondText = texts[1];
60
+ expect(secondText).toHaveAttribute("x", "440"); // leftLabelWidth + 4 * hourWidth
61
+ });
62
+
63
+ it("renders vertical lines for each marker", () => {
64
+ const { container } = render(
65
+ <svg>
66
+ <GanttHeader timelineEnd={8} hourWidth={60} leftLabelWidth={200} />
67
+ </svg>
68
+ );
69
+
70
+ const lines = container.querySelectorAll("line");
71
+ expect(lines.length).toBe(3); // 0, 4, 8
72
+ });
73
+
74
+ it("handles timelineEnd=0 gracefully", () => {
75
+ const { container } = render(
76
+ <svg>
77
+ <GanttHeader timelineEnd={0} hourWidth={60} leftLabelWidth={200} />
78
+ </svg>
79
+ );
80
+
81
+ const texts = container.querySelectorAll("text");
82
+ expect(texts.length).toBe(1); // Only 0h marker
83
+ });
84
+ });
@@ -0,0 +1,52 @@
1
+ // @vitest-environment jsdom
2
+ import React from "react";
3
+ import { describe, it, expect } from "vitest";
4
+ import { render, screen } from "@testing-library/react";
5
+ import "@testing-library/jest-dom/vitest";
6
+ import { GanttLegend } from "@/components/gantt/gantt-legend";
7
+
8
+ describe("GanttLegend", () => {
9
+ it("renders all 4 state labels", () => {
10
+ render(<GanttLegend />);
11
+
12
+ expect(screen.getByText("Completed")).toBeInTheDocument();
13
+ expect(screen.getByText("In Progress")).toBeInTheDocument();
14
+ expect(screen.getByText("Blocked")).toBeInTheDocument();
15
+ expect(screen.getByText("Pending")).toBeInTheDocument();
16
+ });
17
+
18
+ it("renders colored indicators for each state", () => {
19
+ const { container } = render(<GanttLegend />);
20
+
21
+ const indicators = container.querySelectorAll('[data-testid^="legend-indicator-"]');
22
+ expect(indicators.length).toBe(4);
23
+ });
24
+
25
+ it("renders Completed with green color", () => {
26
+ const { container } = render(<GanttLegend />);
27
+
28
+ const completed = container.querySelector('[data-testid="legend-indicator-closed"]');
29
+ expect(completed).toHaveStyle({ backgroundColor: "#22c55e" });
30
+ });
31
+
32
+ it("renders In Progress with blue color", () => {
33
+ const { container } = render(<GanttLegend />);
34
+
35
+ const inProgress = container.querySelector('[data-testid="legend-indicator-in_progress"]');
36
+ expect(inProgress).toHaveStyle({ backgroundColor: "#3b82f6" });
37
+ });
38
+
39
+ it("renders Blocked with red color", () => {
40
+ const { container } = render(<GanttLegend />);
41
+
42
+ const blocked = container.querySelector('[data-testid="legend-indicator-blocked"]');
43
+ expect(blocked).toHaveStyle({ backgroundColor: "#ef4444" });
44
+ });
45
+
46
+ it("renders Pending with gray color", () => {
47
+ const { container } = render(<GanttLegend />);
48
+
49
+ const pending = container.querySelector('[data-testid="legend-indicator-open"]');
50
+ expect(pending).toHaveStyle({ backgroundColor: "#9ca3af" });
51
+ });
52
+ });
@@ -0,0 +1,129 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import type { Requirement } from "@reqord/shared";
3
+ import { computeDagLayout } from "../../../components/graph/dag-layout";
4
+
5
+ function makeReq(
6
+ id: string,
7
+ blockedBy: string[] = [],
8
+ blocks: string[] = [],
9
+ ): Requirement {
10
+ return {
11
+ id,
12
+ version: "1.0.0",
13
+ title: `Requirement ${id}`,
14
+ status: "draft",
15
+ priority: "medium",
16
+ createdAt: "2025-01-01T00:00:00Z",
17
+ updatedAt: "2025-01-01T00:00:00Z",
18
+ versionHistory: [],
19
+ files: { description: `${id}/description.md`, supplementary: [] },
20
+ successCriteria: [],
21
+ format: { type: "free-form" },
22
+ dependencies: { blockedBy, blocks, relatedTo: [] },
23
+ };
24
+ }
25
+
26
+ describe("computeDagLayout", () => {
27
+ it("returns empty layout for empty input", () => {
28
+ const { nodes, edges } = computeDagLayout([]);
29
+ expect(nodes).toEqual([]);
30
+ expect(edges).toEqual([]);
31
+ });
32
+
33
+ it("places a single node at origin", () => {
34
+ const { nodes, edges } = computeDagLayout([makeReq("req-000001")]);
35
+ expect(nodes).toHaveLength(1);
36
+ expect(nodes[0].x).toBe(0);
37
+ expect(nodes[0].y).toBe(0);
38
+ expect(edges).toHaveLength(0);
39
+ });
40
+
41
+ it("places dependent node in a later column", () => {
42
+ const reqs = [
43
+ makeReq("req-000001"),
44
+ makeReq("req-000002", ["req-000001"]),
45
+ ];
46
+ const { nodes, edges } = computeDagLayout(reqs);
47
+
48
+ const node1 = nodes.find((n) => n.id === "req-000001")!;
49
+ const node2 = nodes.find((n) => n.id === "req-000002")!;
50
+
51
+ expect(node1.x).toBeLessThan(node2.x);
52
+ expect(edges).toHaveLength(1);
53
+ expect(edges[0]).toMatchObject({
54
+ source: "req-000001",
55
+ target: "req-000002",
56
+ });
57
+ });
58
+
59
+ it("handles a chain of dependencies", () => {
60
+ const reqs = [
61
+ makeReq("req-000001"),
62
+ makeReq("req-000002", ["req-000001"]),
63
+ makeReq("req-000003", ["req-000002"]),
64
+ ];
65
+ const { nodes } = computeDagLayout(reqs);
66
+
67
+ const n1 = nodes.find((n) => n.id === "req-000001")!;
68
+ const n2 = nodes.find((n) => n.id === "req-000002")!;
69
+ const n3 = nodes.find((n) => n.id === "req-000003")!;
70
+
71
+ expect(n1.x).toBeLessThan(n2.x);
72
+ expect(n2.x).toBeLessThan(n3.x);
73
+ });
74
+
75
+ it("places independent nodes in the same column", () => {
76
+ const reqs = [makeReq("req-000001"), makeReq("req-000002")];
77
+ const { nodes } = computeDagLayout(reqs);
78
+
79
+ const n1 = nodes.find((n) => n.id === "req-000001")!;
80
+ const n2 = nodes.find((n) => n.id === "req-000002")!;
81
+
82
+ expect(n1.x).toBe(n2.x);
83
+ expect(n1.y).not.toBe(n2.y);
84
+ });
85
+
86
+ it("ignores edges to non-existent requirements", () => {
87
+ const reqs = [makeReq("req-000001", ["req-999999"])];
88
+ const { nodes, edges } = computeDagLayout(reqs);
89
+
90
+ expect(nodes).toHaveLength(1);
91
+ expect(edges).toHaveLength(0);
92
+ });
93
+
94
+ it("uses longest path for depth calculation (diamond dependency)", () => {
95
+ // A -> B -> D
96
+ // A -> C -> D
97
+ const reqs = [
98
+ makeReq("req-000001"),
99
+ makeReq("req-000002", ["req-000001"]),
100
+ makeReq("req-000003", ["req-000001"]),
101
+ makeReq("req-000004", ["req-000002", "req-000003"]),
102
+ ];
103
+ const { nodes } = computeDagLayout(reqs);
104
+
105
+ const nA = nodes.find((n) => n.id === "req-000001")!;
106
+ const nD = nodes.find((n) => n.id === "req-000004")!;
107
+
108
+ // D should be 2 columns after A
109
+ expect(nD.x).toBeGreaterThan(nA.x);
110
+ // B and C should be at the same depth (1)
111
+ const nB = nodes.find((n) => n.id === "req-000002")!;
112
+ const nC = nodes.find((n) => n.id === "req-000003")!;
113
+ expect(nB.x).toBe(nC.x);
114
+ });
115
+
116
+ it("preserves requirement metadata in layout nodes", () => {
117
+ const req = makeReq("req-000001");
118
+ req.status = "approved";
119
+ req.priority = "high";
120
+ const { nodes } = computeDagLayout([req]);
121
+
122
+ expect(nodes[0]).toMatchObject({
123
+ id: "req-000001",
124
+ title: "Requirement req-000001",
125
+ status: "approved",
126
+ priority: "high",
127
+ });
128
+ });
129
+ });
@@ -0,0 +1,94 @@
1
+ // @vitest-environment jsdom
2
+ import React from "react";
3
+ import { describe, it, expect, afterEach, vi } from "vitest";
4
+ import { render, cleanup } from "@testing-library/react";
5
+ import "@testing-library/jest-dom/vitest";
6
+ import type { Requirement } from "@reqord/shared";
7
+
8
+ // Capture nodes passed to ReactFlow
9
+ let capturedNodes: any[] = [];
10
+
11
+ vi.mock("@xyflow/react", () => ({
12
+ ReactFlow: ({ nodes, edges, children }: any) => {
13
+ capturedNodes = nodes;
14
+ return (
15
+ <div data-testid="react-flow" data-nodes={nodes.length} data-edges={edges.length}>
16
+ {children}
17
+ </div>
18
+ );
19
+ },
20
+ Background: () => <div data-testid="background" />,
21
+ Controls: () => <div data-testid="controls" />,
22
+ MiniMap: () => <div data-testid="minimap" />,
23
+ useNodesState: (initial: any[]) => [initial, vi.fn(), vi.fn()],
24
+ useEdgesState: (initial: any[]) => [initial, vi.fn(), vi.fn()],
25
+ Handle: ({ type }: any) => <div data-testid={`handle-${type}`} />,
26
+ Position: { Left: "left", Right: "right", Top: "top", Bottom: "bottom" },
27
+ }));
28
+
29
+ vi.mock("next/navigation", () => ({
30
+ useRouter: () => ({ push: vi.fn() }),
31
+ }));
32
+
33
+ import { DependencyGraph } from "../../../components/graph/dependency-graph";
34
+
35
+ function makeReq(
36
+ id: string,
37
+ overrides: Partial<Requirement> = {},
38
+ ): Requirement {
39
+ return {
40
+ id,
41
+ version: "1.0.0",
42
+ title: `Requirement ${id}`,
43
+ status: "draft",
44
+ priority: "medium",
45
+ createdAt: "2026-01-01T00:00:00Z",
46
+ updatedAt: "2026-01-01T00:00:00Z",
47
+ versionHistory: [],
48
+ files: {
49
+ description: `requirements/${id}/description.md`,
50
+ supplementary: [],
51
+ },
52
+ successCriteria: [],
53
+ format: { type: "free-form" },
54
+ dependencies: { blockedBy: [], blocks: [], relatedTo: [] },
55
+ ...overrides,
56
+ } as Requirement;
57
+ }
58
+
59
+ describe("DependencyGraph", () => {
60
+ afterEach(() => {
61
+ cleanup();
62
+ capturedNodes = [];
63
+ });
64
+
65
+ it("passes onDrillDown in node data when onRequirementClick is provided", () => {
66
+ const handleClick = vi.fn();
67
+ const req = makeReq("req-000001");
68
+
69
+ render(
70
+ <DependencyGraph
71
+ requirements={[req]}
72
+ specCountMap={{ "req-000001": 2 }}
73
+ onRequirementClick={handleClick}
74
+ />
75
+ );
76
+
77
+ expect(capturedNodes).toHaveLength(1);
78
+ expect(capturedNodes[0].data.onDrillDown).toBe(handleClick);
79
+ });
80
+
81
+ it("does not include onDrillDown in node data when onRequirementClick is not provided", () => {
82
+ const req = makeReq("req-000001");
83
+
84
+ render(
85
+ <DependencyGraph
86
+ requirements={[req]}
87
+ specCountMap={{ "req-000001": 2 }}
88
+ />
89
+ );
90
+
91
+ expect(capturedNodes).toHaveLength(1);
92
+ expect(capturedNodes[0].data.onDrillDown).toBeUndefined();
93
+ });
94
+ });
@@ -0,0 +1,70 @@
1
+ // @vitest-environment jsdom
2
+ import React from "react";
3
+ import { describe, it, expect, afterEach, vi } from "vitest";
4
+ import { render, screen, cleanup, fireEvent } from "@testing-library/react";
5
+ import "@testing-library/jest-dom/vitest";
6
+ import { DrillDownBreadcrumb } from "../../../components/graph/drilldown-breadcrumb";
7
+
8
+ describe("DrillDownBreadcrumb", () => {
9
+ afterEach(() => {
10
+ cleanup();
11
+ });
12
+
13
+ it("renders requirement title", () => {
14
+ const onBack = vi.fn();
15
+
16
+ render(
17
+ <DrillDownBreadcrumb requirementTitle="User Login" onBack={onBack} />
18
+ );
19
+
20
+ expect(screen.getByText("User Login")).toBeInTheDocument();
21
+ });
22
+
23
+ it("calls onBack when back button is clicked", () => {
24
+ const onBack = vi.fn();
25
+
26
+ render(
27
+ <DrillDownBreadcrumb requirementTitle="User Login" onBack={onBack} />
28
+ );
29
+
30
+ fireEvent.click(screen.getByRole("button", { name: /back to overview/i }));
31
+
32
+ expect(onBack).toHaveBeenCalledTimes(1);
33
+ });
34
+
35
+ it("calls onBack when Escape key is pressed", () => {
36
+ const onBack = vi.fn();
37
+
38
+ render(
39
+ <DrillDownBreadcrumb requirementTitle="User Login" onBack={onBack} />
40
+ );
41
+
42
+ fireEvent.keyDown(window, { key: "Escape" });
43
+
44
+ expect(onBack).toHaveBeenCalledTimes(1);
45
+ });
46
+
47
+ it("shows Esc hint text", () => {
48
+ const onBack = vi.fn();
49
+
50
+ render(
51
+ <DrillDownBreadcrumb requirementTitle="User Login" onBack={onBack} />
52
+ );
53
+
54
+ expect(screen.getByText("(Press Esc to go back)")).toBeInTheDocument();
55
+ });
56
+
57
+ it("cleans up event listener on unmount", () => {
58
+ const onBack = vi.fn();
59
+
60
+ const { unmount } = render(
61
+ <DrillDownBreadcrumb requirementTitle="User Login" onBack={onBack} />
62
+ );
63
+
64
+ unmount();
65
+
66
+ fireEvent.keyDown(window, { key: "Escape" });
67
+
68
+ expect(onBack).not.toHaveBeenCalled();
69
+ });
70
+ });
@@ -0,0 +1,108 @@
1
+ // @vitest-environment jsdom
2
+ import React from "react";
3
+ import { describe, it, expect, afterEach, vi } from "vitest";
4
+ import { render, cleanup } from "@testing-library/react";
5
+ import "@testing-library/jest-dom/vitest";
6
+ import type { Requirement, Specification, TaskEntry } from "@reqord/shared";
7
+
8
+ vi.mock("@xyflow/react", () => ({
9
+ ReactFlow: ({ nodes, edges, children }: any) => (
10
+ <div data-testid="react-flow" data-nodes={nodes.length} data-edges={edges.length}>
11
+ {nodes.map((n: any) => (
12
+ <div key={n.id} data-testid={`node-${n.id}`} />
13
+ ))}
14
+ {children}
15
+ </div>
16
+ ),
17
+ Background: () => <div data-testid="background" />,
18
+ Controls: () => <div data-testid="controls" />,
19
+ Handle: ({ type }: any) => <div data-testid={`handle-${type}`} />,
20
+ Position: { Left: "left", Right: "right", Top: "top", Bottom: "bottom" },
21
+ }));
22
+
23
+ vi.mock("next/navigation", () => ({
24
+ useRouter: () => ({ push: vi.fn() }),
25
+ }));
26
+
27
+ import { DrillDownGraph } from "../../../components/graph/drilldown-graph";
28
+
29
+ function makeReq(id: string, overrides: Partial<Requirement> = {}): Requirement {
30
+ return {
31
+ id, version: "1.0.0", title: `Requirement ${id}`, status: "draft", priority: "medium",
32
+ createdAt: "2026-01-01T00:00:00Z", updatedAt: "2026-01-01T00:00:00Z", versionHistory: [],
33
+ files: { description: `requirements/${id}/description.md`, supplementary: [] },
34
+ successCriteria: [], format: { type: "free-form" },
35
+ dependencies: { blockedBy: [], blocks: [], relatedTo: [] },
36
+ ...overrides,
37
+ } as Requirement;
38
+ }
39
+
40
+ function makeSpec(id: string, reqId: string, overrides: Partial<Specification> = {}): Specification {
41
+ return {
42
+ id, requirementId: reqId, version: "1.0.0", status: "draft",
43
+ createdAt: "2026-01-01T00:00:00Z", updatedAt: "2026-01-01T00:00:00Z", versionHistory: [],
44
+ files: { design: `specifications/${id}/design.md`, supplementary: [] },
45
+ ...overrides,
46
+ } as Specification;
47
+ }
48
+
49
+ function makeTask(number: number, specIds: string[], overrides: Partial<TaskEntry> = {}): TaskEntry {
50
+ return {
51
+ number, title: `Task ${number}`, url: `https://github.com/test/repo/issues/${number}`,
52
+ linkedTo: { specifications: specIds }, status: "open", syncedAt: "2026-01-01T00:00:00Z",
53
+ ...overrides,
54
+ };
55
+ }
56
+
57
+ describe("DrillDownGraph", () => {
58
+ afterEach(() => { cleanup(); });
59
+
60
+ it("renders ReactFlow with nodes from requirement and specifications", () => {
61
+ const req = makeReq("req-000001");
62
+ const spec1 = makeSpec("spec-000001", "req-000001");
63
+ const spec2 = makeSpec("spec-000002", "req-000001");
64
+
65
+ const { getByTestId } = render(
66
+ <DrillDownGraph requirement={req} specifications={[spec1, spec2]} tasks={[]} />
67
+ );
68
+
69
+ const flow = getByTestId("react-flow");
70
+ expect(flow.getAttribute("data-nodes")).toBe("3");
71
+ expect(flow.getAttribute("data-edges")).toBe("2");
72
+ });
73
+
74
+ it("renders ReactFlow with Background and Controls", () => {
75
+ const req = makeReq("req-000001");
76
+
77
+ const { getByTestId } = render(
78
+ <DrillDownGraph requirement={req} specifications={[]} tasks={[]} />
79
+ );
80
+
81
+ expect(getByTestId("background")).toBeInTheDocument();
82
+ expect(getByTestId("controls")).toBeInTheDocument();
83
+ });
84
+
85
+ it("renders requirement node", () => {
86
+ const req = makeReq("req-000001");
87
+
88
+ const { getByTestId } = render(
89
+ <DrillDownGraph requirement={req} specifications={[]} tasks={[]} />
90
+ );
91
+
92
+ expect(getByTestId("node-req-000001")).toBeInTheDocument();
93
+ });
94
+
95
+ it("renders ReactFlow with issue nodes when tasks are linked to specs", () => {
96
+ const req = makeReq("req-000001");
97
+ const spec1 = makeSpec("spec-000001", "req-000001");
98
+ const task = makeTask(42, ["spec-000001"]);
99
+
100
+ const { getByTestId } = render(
101
+ <DrillDownGraph requirement={req} specifications={[spec1]} tasks={[task]} />
102
+ );
103
+
104
+ const flow = getByTestId("react-flow");
105
+ expect(flow.getAttribute("data-nodes")).toBe("3");
106
+ expect(flow.getAttribute("data-edges")).toBe("2");
107
+ });
108
+ });
@@ -0,0 +1,27 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { EDGE_STYLES } from "../../../components/graph/edge-styles";
3
+
4
+ describe("EDGE_STYLES", () => {
5
+ it("dependency has stroke #64748b and strokeWidth 2", () => {
6
+ expect(EDGE_STYLES.dependency).toEqual({
7
+ stroke: "#64748b",
8
+ strokeWidth: 2,
9
+ });
10
+ });
11
+
12
+ it("implements has stroke #3b82f6, strokeWidth 2, and strokeDasharray '5,5'", () => {
13
+ expect(EDGE_STYLES.implements).toEqual({
14
+ stroke: "#3b82f6",
15
+ strokeWidth: 2,
16
+ strokeDasharray: "5,5",
17
+ });
18
+ });
19
+
20
+ it("tracks has stroke #22c55e, strokeWidth 1.5, and strokeDasharray '2,2'", () => {
21
+ expect(EDGE_STYLES.tracks).toEqual({
22
+ stroke: "#22c55e",
23
+ strokeWidth: 1.5,
24
+ strokeDasharray: "2,2",
25
+ });
26
+ });
27
+ });
@@ -0,0 +1,124 @@
1
+ // @vitest-environment jsdom
2
+ import React from "react";
3
+ import { describe, it, expect, afterEach, vi, beforeEach } from "vitest";
4
+ import { render, screen, cleanup } from "@testing-library/react";
5
+ import "@testing-library/jest-dom/vitest";
6
+ import type { Requirement, Specification, TaskEntry } from "@reqord/shared";
7
+
8
+ const mockPush = vi.fn();
9
+ const mockGet = vi.fn();
10
+
11
+ vi.mock("next/navigation", () => ({
12
+ useSearchParams: () => ({ get: mockGet }),
13
+ useRouter: () => ({ push: mockPush }),
14
+ }));
15
+
16
+ vi.mock("next/dynamic", () => ({
17
+ default: (loader: () => Promise<any>) => {
18
+ let Component: any = () => <div data-testid="loading" />;
19
+ loader().then((mod: any) => {
20
+ Component = mod.default || mod.DependencyGraph || mod.DrillDownGraph || mod;
21
+ });
22
+ return (props: any) => <Component {...props} />;
23
+ },
24
+ }));
25
+
26
+ vi.mock("@xyflow/react", () => ({
27
+ ReactFlow: ({ nodes, edges, children }: any) => (
28
+ <div data-testid="react-flow" data-nodes={nodes?.length} data-edges={edges?.length}>
29
+ {children}
30
+ </div>
31
+ ),
32
+ default: ({ nodes, edges, children }: any) => (
33
+ <div data-testid="react-flow" data-nodes={nodes?.length} data-edges={edges?.length}>
34
+ {children}
35
+ </div>
36
+ ),
37
+ Background: () => <div data-testid="background" />,
38
+ Controls: () => <div data-testid="controls" />,
39
+ MiniMap: () => <div data-testid="minimap" />,
40
+ useNodesState: (initial: any[]) => [initial, vi.fn(), vi.fn()],
41
+ useEdgesState: (initial: any[]) => [initial, vi.fn(), vi.fn()],
42
+ Handle: ({ type }: any) => <div data-testid={`handle-${type}`} />,
43
+ Position: { Left: "left", Right: "right", Top: "top", Bottom: "bottom" },
44
+ }));
45
+
46
+ import { GraphPageClient } from "../../../components/graph/graph-page-client";
47
+
48
+ function makeReq(id: string, overrides: Partial<Requirement> = {}): Requirement {
49
+ return {
50
+ id, version: "1.0.0", title: `Requirement ${id}`, status: "draft", priority: "medium",
51
+ createdAt: "2026-01-01T00:00:00Z", updatedAt: "2026-01-01T00:00:00Z", versionHistory: [],
52
+ files: { description: `requirements/${id}/description.md`, supplementary: [] },
53
+ successCriteria: [], format: { type: "free-form" },
54
+ dependencies: { blockedBy: [], blocks: [], relatedTo: [] },
55
+ ...overrides,
56
+ } as Requirement;
57
+ }
58
+
59
+ function makeSpec(id: string, reqId: string, overrides: Partial<Specification> = {}): Specification {
60
+ return {
61
+ id, requirementId: reqId, version: "1.0.0", status: "draft",
62
+ createdAt: "2026-01-01T00:00:00Z", updatedAt: "2026-01-01T00:00:00Z", versionHistory: [],
63
+ files: { design: `specifications/${id}/design.md`, supplementary: [] },
64
+ ...overrides,
65
+ } as Specification;
66
+ }
67
+
68
+ function makeTask(number: number, specIds: string[], overrides: Partial<TaskEntry> = {}): TaskEntry {
69
+ return {
70
+ number, title: `Task ${number}`, url: `https://github.com/test/repo/issues/${number}`,
71
+ linkedTo: { specifications: specIds }, status: "open", syncedAt: "2026-01-01T00:00:00Z",
72
+ ...overrides,
73
+ };
74
+ }
75
+
76
+ const defaultProps = {
77
+ requirements: [makeReq("req-000001"), makeReq("req-000002")],
78
+ specifications: [makeSpec("spec-000001", "req-000001")],
79
+ specCountMap: { "req-000001": 1, "req-000002": 0 } as Record<string, number>,
80
+ tasks: [makeTask(42, ["spec-000001"])],
81
+ };
82
+
83
+ describe("GraphPageClient", () => {
84
+ beforeEach(() => { vi.clearAllMocks(); });
85
+ afterEach(() => { cleanup(); });
86
+
87
+ describe("when no ?req= param (overview mode)", () => {
88
+ it("renders overview heading", () => {
89
+ mockGet.mockReturnValue(null);
90
+ render(<GraphPageClient {...defaultProps} />);
91
+ expect(screen.getByText("Dependency Graph")).toBeInTheDocument();
92
+ });
93
+
94
+ it("does not render DrillDownBreadcrumb", () => {
95
+ mockGet.mockReturnValue(null);
96
+ render(<GraphPageClient {...defaultProps} />);
97
+ expect(screen.queryByText("← Back to overview")).not.toBeInTheDocument();
98
+ });
99
+ });
100
+
101
+ describe("when ?req= has a valid id (drilldown mode)", () => {
102
+ it("renders DrillDownBreadcrumb with requirement title", () => {
103
+ mockGet.mockReturnValue("req-000001");
104
+ render(<GraphPageClient {...defaultProps} />);
105
+ expect(screen.getByText("← Back to overview")).toBeInTheDocument();
106
+ expect(screen.getByText("Requirement req-000001")).toBeInTheDocument();
107
+ });
108
+
109
+ it("does not render the overview heading", () => {
110
+ mockGet.mockReturnValue("req-000001");
111
+ render(<GraphPageClient {...defaultProps} />);
112
+ expect(screen.queryByText("Dependency Graph")).not.toBeInTheDocument();
113
+ });
114
+ });
115
+
116
+ describe("when ?req= has an invalid id (fallback)", () => {
117
+ it("falls back to DependencyGraph overview", () => {
118
+ mockGet.mockReturnValue("req-999999");
119
+ render(<GraphPageClient {...defaultProps} />);
120
+ expect(screen.getByText("Dependency Graph")).toBeInTheDocument();
121
+ expect(screen.queryByText("← Back to overview")).not.toBeInTheDocument();
122
+ });
123
+ });
124
+ });