shipwright-cli 2.3.1 → 3.0.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 (162) hide show
  1. package/README.md +95 -28
  2. package/completions/_shipwright +1 -1
  3. package/completions/shipwright.bash +3 -8
  4. package/completions/shipwright.fish +1 -1
  5. package/config/defaults.json +111 -0
  6. package/config/event-schema.json +81 -0
  7. package/config/policy.json +155 -2
  8. package/config/policy.schema.json +162 -1
  9. package/dashboard/coverage/coverage-summary.json +14 -0
  10. package/dashboard/public/index.html +1 -1
  11. package/dashboard/server.ts +306 -17
  12. package/dashboard/src/components/charts/bar.test.ts +79 -0
  13. package/dashboard/src/components/charts/donut.test.ts +68 -0
  14. package/dashboard/src/components/charts/pipeline-rail.test.ts +117 -0
  15. package/dashboard/src/components/charts/sparkline.test.ts +125 -0
  16. package/dashboard/src/core/api.test.ts +309 -0
  17. package/dashboard/src/core/helpers.test.ts +301 -0
  18. package/dashboard/src/core/router.test.ts +307 -0
  19. package/dashboard/src/core/router.ts +7 -0
  20. package/dashboard/src/core/sse.test.ts +144 -0
  21. package/dashboard/src/views/metrics.test.ts +186 -0
  22. package/dashboard/src/views/overview.test.ts +173 -0
  23. package/dashboard/src/views/pipelines.test.ts +183 -0
  24. package/dashboard/src/views/team.test.ts +253 -0
  25. package/dashboard/vitest.config.ts +14 -5
  26. package/docs/TIPS.md +1 -1
  27. package/docs/patterns/README.md +1 -1
  28. package/package.json +15 -5
  29. package/scripts/adapters/docker-deploy.sh +1 -1
  30. package/scripts/adapters/tmux-adapter.sh +11 -1
  31. package/scripts/adapters/wezterm-adapter.sh +1 -1
  32. package/scripts/check-version-consistency.sh +1 -1
  33. package/scripts/lib/architecture.sh +126 -0
  34. package/scripts/lib/bootstrap.sh +75 -0
  35. package/scripts/lib/compat.sh +89 -6
  36. package/scripts/lib/config.sh +91 -0
  37. package/scripts/lib/daemon-adaptive.sh +3 -3
  38. package/scripts/lib/daemon-dispatch.sh +39 -16
  39. package/scripts/lib/daemon-health.sh +1 -1
  40. package/scripts/lib/daemon-patrol.sh +24 -12
  41. package/scripts/lib/daemon-poll.sh +37 -25
  42. package/scripts/lib/daemon-state.sh +115 -23
  43. package/scripts/lib/daemon-triage.sh +30 -8
  44. package/scripts/lib/fleet-failover.sh +63 -0
  45. package/scripts/lib/helpers.sh +30 -6
  46. package/scripts/lib/pipeline-detection.sh +2 -2
  47. package/scripts/lib/pipeline-github.sh +9 -9
  48. package/scripts/lib/pipeline-intelligence.sh +85 -35
  49. package/scripts/lib/pipeline-quality-checks.sh +16 -16
  50. package/scripts/lib/pipeline-quality.sh +1 -1
  51. package/scripts/lib/pipeline-stages.sh +242 -28
  52. package/scripts/lib/pipeline-state.sh +40 -4
  53. package/scripts/lib/test-helpers.sh +247 -0
  54. package/scripts/postinstall.mjs +3 -11
  55. package/scripts/sw +10 -4
  56. package/scripts/sw-activity.sh +1 -11
  57. package/scripts/sw-adaptive.sh +109 -85
  58. package/scripts/sw-adversarial.sh +4 -14
  59. package/scripts/sw-architecture-enforcer.sh +1 -11
  60. package/scripts/sw-auth.sh +8 -17
  61. package/scripts/sw-autonomous.sh +111 -49
  62. package/scripts/sw-changelog.sh +1 -11
  63. package/scripts/sw-checkpoint.sh +144 -20
  64. package/scripts/sw-ci.sh +2 -12
  65. package/scripts/sw-cleanup.sh +13 -17
  66. package/scripts/sw-code-review.sh +16 -36
  67. package/scripts/sw-connect.sh +5 -12
  68. package/scripts/sw-context.sh +9 -26
  69. package/scripts/sw-cost.sh +6 -16
  70. package/scripts/sw-daemon.sh +75 -70
  71. package/scripts/sw-dashboard.sh +57 -17
  72. package/scripts/sw-db.sh +506 -15
  73. package/scripts/sw-decompose.sh +1 -11
  74. package/scripts/sw-deps.sh +15 -25
  75. package/scripts/sw-developer-simulation.sh +1 -11
  76. package/scripts/sw-discovery.sh +112 -30
  77. package/scripts/sw-doc-fleet.sh +7 -17
  78. package/scripts/sw-docs-agent.sh +6 -16
  79. package/scripts/sw-docs.sh +4 -12
  80. package/scripts/sw-doctor.sh +134 -43
  81. package/scripts/sw-dora.sh +11 -19
  82. package/scripts/sw-durable.sh +35 -52
  83. package/scripts/sw-e2e-orchestrator.sh +11 -27
  84. package/scripts/sw-eventbus.sh +115 -115
  85. package/scripts/sw-evidence.sh +748 -0
  86. package/scripts/sw-feedback.sh +3 -13
  87. package/scripts/sw-fix.sh +2 -20
  88. package/scripts/sw-fleet-discover.sh +1 -11
  89. package/scripts/sw-fleet-viz.sh +10 -18
  90. package/scripts/sw-fleet.sh +13 -17
  91. package/scripts/sw-github-app.sh +6 -16
  92. package/scripts/sw-github-checks.sh +1 -11
  93. package/scripts/sw-github-deploy.sh +1 -11
  94. package/scripts/sw-github-graphql.sh +2 -12
  95. package/scripts/sw-guild.sh +1 -11
  96. package/scripts/sw-heartbeat.sh +49 -12
  97. package/scripts/sw-hygiene.sh +45 -43
  98. package/scripts/sw-incident.sh +284 -67
  99. package/scripts/sw-init.sh +35 -37
  100. package/scripts/sw-instrument.sh +1 -11
  101. package/scripts/sw-intelligence.sh +362 -51
  102. package/scripts/sw-jira.sh +5 -14
  103. package/scripts/sw-launchd.sh +2 -12
  104. package/scripts/sw-linear.sh +8 -17
  105. package/scripts/sw-logs.sh +4 -12
  106. package/scripts/sw-loop.sh +641 -90
  107. package/scripts/sw-memory.sh +243 -17
  108. package/scripts/sw-mission-control.sh +2 -12
  109. package/scripts/sw-model-router.sh +73 -34
  110. package/scripts/sw-otel.sh +11 -21
  111. package/scripts/sw-oversight.sh +1 -11
  112. package/scripts/sw-patrol-meta.sh +5 -11
  113. package/scripts/sw-pipeline-composer.sh +7 -17
  114. package/scripts/sw-pipeline-vitals.sh +1 -11
  115. package/scripts/sw-pipeline.sh +478 -122
  116. package/scripts/sw-pm.sh +2 -12
  117. package/scripts/sw-pr-lifecycle.sh +203 -29
  118. package/scripts/sw-predictive.sh +16 -22
  119. package/scripts/sw-prep.sh +6 -16
  120. package/scripts/sw-ps.sh +1 -11
  121. package/scripts/sw-public-dashboard.sh +2 -12
  122. package/scripts/sw-quality.sh +77 -10
  123. package/scripts/sw-reaper.sh +1 -11
  124. package/scripts/sw-recruit.sh +15 -25
  125. package/scripts/sw-regression.sh +11 -21
  126. package/scripts/sw-release-manager.sh +19 -28
  127. package/scripts/sw-release.sh +8 -16
  128. package/scripts/sw-remote.sh +1 -11
  129. package/scripts/sw-replay.sh +48 -44
  130. package/scripts/sw-retro.sh +70 -92
  131. package/scripts/sw-review-rerun.sh +220 -0
  132. package/scripts/sw-scale.sh +109 -32
  133. package/scripts/sw-security-audit.sh +12 -22
  134. package/scripts/sw-self-optimize.sh +239 -23
  135. package/scripts/sw-session.sh +3 -13
  136. package/scripts/sw-setup.sh +8 -18
  137. package/scripts/sw-standup.sh +5 -15
  138. package/scripts/sw-status.sh +32 -23
  139. package/scripts/sw-strategic.sh +129 -13
  140. package/scripts/sw-stream.sh +1 -11
  141. package/scripts/sw-swarm.sh +76 -36
  142. package/scripts/sw-team-stages.sh +10 -20
  143. package/scripts/sw-templates.sh +4 -14
  144. package/scripts/sw-testgen.sh +3 -13
  145. package/scripts/sw-tmux-pipeline.sh +1 -19
  146. package/scripts/sw-tmux-role-color.sh +0 -10
  147. package/scripts/sw-tmux-status.sh +3 -11
  148. package/scripts/sw-tmux.sh +2 -20
  149. package/scripts/sw-trace.sh +1 -19
  150. package/scripts/sw-tracker-github.sh +0 -10
  151. package/scripts/sw-tracker-jira.sh +1 -11
  152. package/scripts/sw-tracker-linear.sh +1 -11
  153. package/scripts/sw-tracker.sh +7 -24
  154. package/scripts/sw-triage.sh +24 -34
  155. package/scripts/sw-upgrade.sh +5 -23
  156. package/scripts/sw-ux.sh +1 -19
  157. package/scripts/sw-webhook.sh +18 -32
  158. package/scripts/sw-widgets.sh +3 -21
  159. package/scripts/sw-worktree.sh +11 -27
  160. package/scripts/update-homebrew-sha.sh +67 -0
  161. package/templates/pipelines/tdd.json +72 -0
  162. package/scripts/sw-pipeline.sh.mock +0 -7
@@ -0,0 +1,79 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { renderSVGBarChart } from "./bar";
3
+
4
+ describe("bar", () => {
5
+ describe("renderSVGBarChart", () => {
6
+ it("returns empty string for empty data", () => {
7
+ expect(renderSVGBarChart([])).toBe("");
8
+ });
9
+
10
+ it("returns empty string for null/undefined", () => {
11
+ expect(renderSVGBarChart(null as unknown as never[])).toBe("");
12
+ });
13
+
14
+ it("returns SVG with rect for single bar", () => {
15
+ const data = [{ date: "2025-02-17", completed: 5, failed: 2 }];
16
+ const svg = renderSVGBarChart(data);
17
+ expect(svg).toContain('<svg class="svg-bar-chart"');
18
+ expect(svg).toContain("<rect");
19
+ expect(svg).toContain('fill="#4ade80"');
20
+ expect(svg).toContain('fill="#f43f5e"');
21
+ });
22
+
23
+ it("returns SVG with multiple rects for multiple bars", () => {
24
+ const data = [
25
+ { date: "2025-02-15", completed: 3, failed: 0 },
26
+ { date: "2025-02-16", completed: 5, failed: 1 },
27
+ { date: "2025-02-17", completed: 2, failed: 3 },
28
+ ];
29
+ const svg = renderSVGBarChart(data);
30
+ expect(svg).toContain('<svg class="svg-bar-chart"');
31
+ const rectCount = (svg.match(/<rect/g) || []).length;
32
+ expect(rectCount).toBeGreaterThanOrEqual(3);
33
+ const textCount = (svg.match(/<text/g) || []).length;
34
+ expect(textCount).toBe(3);
35
+ });
36
+
37
+ it("renders zero-value bars as thin rect (1px)", () => {
38
+ const data = [{ date: "2025-02-17", completed: 0, failed: 0 }];
39
+ const svg = renderSVGBarChart(data);
40
+ expect(svg).toContain("<rect");
41
+ expect(svg).toContain('height="1"');
42
+ expect(svg).toContain('fill="#0d1f3c"');
43
+ });
44
+
45
+ it("renders labels from date (MM/DD format)", () => {
46
+ const data = [{ date: "2025-02-17", completed: 1, failed: 0 }];
47
+ const svg = renderSVGBarChart(data);
48
+ expect(svg).toContain("02/17");
49
+ });
50
+
51
+ it("handles dates without enough parts (falls back to full date)", () => {
52
+ const data = [{ date: "2025", completed: 1, failed: 0 }];
53
+ const svg = renderSVGBarChart(data);
54
+ expect(svg).toContain("2025");
55
+ });
56
+
57
+ it("escapes HTML in labels", () => {
58
+ const data = [{ date: "2025-02-17<script>", completed: 1, failed: 0 }];
59
+ const svg = renderSVGBarChart(data);
60
+ expect(svg).toContain("&lt;script&gt;");
61
+ expect(svg).not.toContain("<script>");
62
+ });
63
+
64
+ it("handles missing completed/failed (treats as 0)", () => {
65
+ const data = [
66
+ { date: "2025-02-17", completed: undefined, failed: undefined },
67
+ ];
68
+ const svg = renderSVGBarChart(data as never[]);
69
+ expect(svg).toContain("<rect");
70
+ expect(svg).toContain('height="1"');
71
+ });
72
+
73
+ it("has correct viewBox dimensions", () => {
74
+ const data = [{ date: "2025-02-17", completed: 5, failed: 2 }];
75
+ const svg = renderSVGBarChart(data);
76
+ expect(svg).toContain('viewBox="0 0 700 120"');
77
+ });
78
+ });
79
+ });
@@ -0,0 +1,68 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { renderSVGDonut } from "./donut";
3
+
4
+ describe("donut", () => {
5
+ describe("renderSVGDonut", () => {
6
+ it("renders SVG with zero rate", () => {
7
+ const svg = renderSVGDonut(0);
8
+ expect(svg).toContain('<svg class="svg-donut"');
9
+ expect(svg).toContain('viewBox="0 0 120 120"');
10
+ expect(svg).toContain("<circle");
11
+ expect(svg).toContain("0.0%");
12
+ });
13
+
14
+ it("renders SVG with single segment (partial fill)", () => {
15
+ const svg = renderSVGDonut(50);
16
+ expect(svg).toContain('<svg class="svg-donut"');
17
+ expect(svg).toContain("<circle");
18
+ expect(svg).toContain("50.0%");
19
+ expect(svg).toContain("stroke-dasharray");
20
+ expect(svg).toContain("stroke-dashoffset");
21
+ });
22
+
23
+ it("renders SVG with full segment (100%)", () => {
24
+ const svg = renderSVGDonut(100);
25
+ expect(svg).toContain('<svg class="svg-donut"');
26
+ expect(svg).toContain("100.0%");
27
+ });
28
+
29
+ it("contains expected SVG elements: circle, defs, text", () => {
30
+ const svg = renderSVGDonut(75);
31
+ expect(svg).toContain("<defs>");
32
+ expect(svg).toContain("linearGradient");
33
+ expect(svg).toContain("donut-grad");
34
+ expect(svg).toContain("<circle");
35
+ expect(svg).toContain("<text");
36
+ expect(svg).toContain('text-anchor="middle"');
37
+ });
38
+
39
+ it("clamps negative rate to 0", () => {
40
+ const svg = renderSVGDonut(-10);
41
+ expect(svg).toContain("0.0%");
42
+ });
43
+
44
+ it("clamps rate above 100 to 100", () => {
45
+ const svg = renderSVGDonut(150);
46
+ expect(svg).toContain("100.0%");
47
+ });
48
+
49
+ it("renders label with percentage", () => {
50
+ const svg = renderSVGDonut(33.5);
51
+ expect(svg).toContain("33.5%");
52
+ });
53
+
54
+ it("has fixed size 120x120", () => {
55
+ const svg = renderSVGDonut(25);
56
+ expect(svg).toContain('width="120"');
57
+ expect(svg).toContain('height="120"');
58
+ });
59
+
60
+ it("uses background circle and gradient stroke circle", () => {
61
+ const svg = renderSVGDonut(50);
62
+ const circleCount = (svg.match(/<circle/g) || []).length;
63
+ expect(circleCount).toBe(2);
64
+ expect(svg).toContain('stroke="#0d1f3c"');
65
+ expect(svg).toContain('stroke="url(#donut-grad)"');
66
+ });
67
+ });
68
+ });
@@ -0,0 +1,117 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { renderPipelineSVG, renderDoraGrades } from "./pipeline-rail";
3
+ import type { PipelineInfo } from "../../types/api";
4
+
5
+ describe("renderPipelineSVG", () => {
6
+ it("renders SVG for an empty pipeline", () => {
7
+ const pipeline = { status: "pending" } as PipelineInfo;
8
+ const svg = renderPipelineSVG(pipeline);
9
+ expect(svg).toContain("<svg");
10
+ expect(svg).toContain("</svg>");
11
+ expect(svg).toContain("pipeline-svg");
12
+ });
13
+
14
+ it("renders completed stages with green fill", () => {
15
+ const pipeline = {
16
+ stagesDone: ["intake", "plan"],
17
+ stage: "build",
18
+ status: "running",
19
+ } as PipelineInfo;
20
+ const svg = renderPipelineSVG(pipeline);
21
+ expect(svg).toContain("#4ade80"); // green for completed
22
+ expect(svg).toContain("#00d4ff"); // cyan for active
23
+ });
24
+
25
+ it("renders failed pipeline with red fill", () => {
26
+ const pipeline = {
27
+ stagesDone: ["intake", "plan"],
28
+ stage: "build",
29
+ status: "failed",
30
+ } as PipelineInfo;
31
+ const svg = renderPipelineSVG(pipeline);
32
+ expect(svg).toContain("#f43f5e"); // red for failed
33
+ });
34
+
35
+ it("renders stage labels", () => {
36
+ const pipeline = {
37
+ stagesDone: [],
38
+ stage: "intake",
39
+ status: "running",
40
+ } as PipelineInfo;
41
+ const svg = renderPipelineSVG(pipeline);
42
+ expect(svg).toContain("intake");
43
+ });
44
+
45
+ it("renders connecting lines between stages", () => {
46
+ const pipeline = { status: "pending" } as PipelineInfo;
47
+ const svg = renderPipelineSVG(pipeline);
48
+ expect(svg).toContain("<line");
49
+ });
50
+
51
+ it("renders animation for active stage", () => {
52
+ const pipeline = {
53
+ stagesDone: [],
54
+ stage: "intake",
55
+ status: "running",
56
+ } as PipelineInfo;
57
+ const svg = renderPipelineSVG(pipeline);
58
+ expect(svg).toContain("animate");
59
+ expect(svg).toContain("stage-node-active");
60
+ });
61
+
62
+ it("uses dashed lines for incomplete connections", () => {
63
+ const pipeline = {
64
+ stagesDone: [],
65
+ stage: "intake",
66
+ status: "running",
67
+ } as PipelineInfo;
68
+ const svg = renderPipelineSVG(pipeline);
69
+ expect(svg).toContain("stroke-dasharray");
70
+ });
71
+ });
72
+
73
+ describe("renderDoraGrades", () => {
74
+ it("returns empty string for null dora", () => {
75
+ expect(renderDoraGrades(null)).toBe("");
76
+ });
77
+
78
+ it("returns empty string for undefined dora", () => {
79
+ expect(renderDoraGrades(undefined)).toBe("");
80
+ });
81
+
82
+ it("renders DORA metrics cards", () => {
83
+ const dora = {
84
+ deploy_freq: { grade: "Elite", value: 7.5, unit: "/week" },
85
+ lead_time: { grade: "High", value: 2.1, unit: "hours" },
86
+ cfr: { grade: "Medium", value: 15.0, unit: "%" },
87
+ mttr: { grade: "Low", value: 24.0, unit: "hours" },
88
+ };
89
+ const html = renderDoraGrades(dora);
90
+ expect(html).toContain("dora-grades-row");
91
+ expect(html).toContain("Deploy Frequency");
92
+ expect(html).toContain("Lead Time");
93
+ expect(html).toContain("Change Failure Rate");
94
+ expect(html).toContain("Mean Time to Recovery");
95
+ expect(html).toContain("Elite");
96
+ expect(html).toContain("dora-elite");
97
+ expect(html).toContain("7.5");
98
+ expect(html).toContain("/week");
99
+ });
100
+
101
+ it("handles missing metrics gracefully", () => {
102
+ const dora = {
103
+ deploy_freq: { grade: "Elite", value: 5.0, unit: "/week" },
104
+ };
105
+ const html = renderDoraGrades(dora);
106
+ expect(html).toContain("Deploy Frequency");
107
+ expect(html).not.toContain("Lead Time");
108
+ });
109
+
110
+ it("handles null value with dash", () => {
111
+ const dora = {
112
+ cfr: { grade: "N/A", value: null as unknown as number, unit: "%" },
113
+ };
114
+ const html = renderDoraGrades(dora);
115
+ expect(html).toContain("\u2014");
116
+ });
117
+ });
@@ -0,0 +1,125 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { renderSparkline, renderSVGLineChart } from "./sparkline";
3
+
4
+ describe("sparkline", () => {
5
+ describe("renderSparkline", () => {
6
+ it("returns empty string for empty data", () => {
7
+ expect(renderSparkline([], "#fff", 100, 40)).toBe("");
8
+ });
9
+
10
+ it("returns empty string for single point", () => {
11
+ expect(renderSparkline([5], "#fff", 100, 40)).toBe("");
12
+ expect(renderSparkline([{ value: 10 }], "#fff", 100, 40)).toBe("");
13
+ });
14
+
15
+ it("returns empty string for null/undefined points", () => {
16
+ expect(
17
+ renderSparkline(null as unknown as number[], "#fff", 100, 40),
18
+ ).toBe("");
19
+ });
20
+
21
+ it("returns SVG with path for multiple points (number array)", () => {
22
+ const svg = renderSparkline([10, 20, 30, 40], "#00d4ff", 100, 40);
23
+ expect(svg).toContain('<svg class="sparkline"');
24
+ expect(svg).toContain('viewBox="0 0 100 40"');
25
+ expect(svg).toContain("<path");
26
+ expect(svg).toContain('stroke="#00d4ff"');
27
+ expect(svg).toContain('stroke-width="1.5"');
28
+ expect(svg).toContain('fill="none"');
29
+ expect(svg).toContain("d=");
30
+ expect(svg).toMatch(/M[\d.]+,[\d.]+ L[\d.]+,[\d.]+/);
31
+ });
32
+
33
+ it("returns SVG with path for object array with value property", () => {
34
+ const svg = renderSparkline(
35
+ [{ value: 5 }, { value: 15 }, { value: 25 }],
36
+ "#7c3aed",
37
+ 80,
38
+ 32,
39
+ );
40
+ expect(svg).toContain('<svg class="sparkline"');
41
+ expect(svg).toContain("<path");
42
+ expect(svg).toContain('stroke="#7c3aed"');
43
+ });
44
+
45
+ it("handles object values with missing value (treats as 0)", () => {
46
+ const svg = renderSparkline([{ value: 0 }, { value: 0 }], "#333", 50, 20);
47
+ expect(svg).toContain('<svg class="sparkline"');
48
+ expect(svg).toContain("<path");
49
+ });
50
+
51
+ it("uses correct dimensions in output", () => {
52
+ const svg = renderSparkline([1, 2, 3], "#000", 200, 60);
53
+ expect(svg).toContain('width="200"');
54
+ expect(svg).toContain('height="60"');
55
+ });
56
+ });
57
+
58
+ describe("renderSVGLineChart", () => {
59
+ it("returns empty-state div for empty points", () => {
60
+ const out = renderSVGLineChart([], "value", "#00d4ff", 100, 40);
61
+ expect(out).toContain('<div class="empty-state">');
62
+ expect(out).toContain("Not enough data");
63
+ });
64
+
65
+ it("returns empty-state div for single point", () => {
66
+ const out = renderSVGLineChart(
67
+ [{ value: 10 }],
68
+ "value",
69
+ "#00d4ff",
70
+ 100,
71
+ 40,
72
+ );
73
+ expect(out).toContain('<div class="empty-state">');
74
+ expect(out).toContain("Not enough data");
75
+ });
76
+
77
+ it("returns empty-state for null/undefined points", () => {
78
+ const out = renderSVGLineChart(
79
+ null as unknown as Record<string, number>[],
80
+ "value",
81
+ "#00d4ff",
82
+ 100,
83
+ 40,
84
+ );
85
+ expect(out).toContain("Not enough data");
86
+ });
87
+
88
+ it("returns SVG with path and grid lines for multiple points", () => {
89
+ const points = [
90
+ { value: 10 },
91
+ { value: 30 },
92
+ { value: 20 },
93
+ { value: 50 },
94
+ ];
95
+ const svg = renderSVGLineChart(points, "value", "#4ade80", 300, 120);
96
+ expect(svg).toContain('<svg class="svg-line-chart"');
97
+ expect(svg).toContain('viewBox="0 0 300 120"');
98
+ expect(svg).toContain("<path");
99
+ expect(svg).toContain('stroke="#4ade80"');
100
+ expect(svg).toContain("<line");
101
+ expect(svg).toContain('stroke="#1a3a6a"');
102
+ });
103
+
104
+ it("uses custom valueKey when provided", () => {
105
+ const points = [{ count: 5 }, { count: 15 }, { count: 25 }];
106
+ const svg = renderSVGLineChart(points, "count", "#ff0000", 200, 80);
107
+ expect(svg).toContain('<svg class="svg-line-chart"');
108
+ expect(svg).toContain("<path");
109
+ expect(svg).toContain('stroke="#ff0000"');
110
+ });
111
+
112
+ it("falls back to value when valueKey missing", () => {
113
+ const points = [{ value: 1 }, { value: 2 }];
114
+ const svg = renderSVGLineChart(points, "missing", "#000", 100, 50);
115
+ expect(svg).toContain("<path");
116
+ });
117
+
118
+ it("handles all-zero max (uses maxVal=1 to avoid div by zero)", () => {
119
+ const points = [{ value: 0 }, { value: 0 }];
120
+ const svg = renderSVGLineChart(points, "value", "#000", 100, 50);
121
+ expect(svg).toContain("<svg");
122
+ expect(svg).not.toContain("Not enough data");
123
+ });
124
+ });
125
+ });