@handled-ai/design-system 0.9.23 → 0.9.25

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.
@@ -0,0 +1,152 @@
1
+ /**
2
+ * Shared test fixture factories for Design System component tests.
3
+ *
4
+ * Covers:
5
+ * - ScoreFactor (used by ScoreBreakdown)
6
+ * - initialFeedback shapes for ScoreFeedback.Root
7
+ * - initialFeedback shapes for ScoreBreakdown
8
+ *
9
+ * These are plain data factories — no React, no vitest imports.
10
+ */
11
+
12
+ import type { ScoreFactor } from "../components/score-breakdown";
13
+
14
+ // ---------------------------------------------------------------------------
15
+ // ScoreFactor factory
16
+ // ---------------------------------------------------------------------------
17
+
18
+ /**
19
+ * Build a ScoreFactor with sane defaults.
20
+ * Override individual fields to test specific rendering paths.
21
+ */
22
+ export function makeScoreFactor(
23
+ overrides: Partial<ScoreFactor> = {},
24
+ ): ScoreFactor {
25
+ return {
26
+ key: "revenue_change",
27
+ label: "Revenue Change",
28
+ score: 72,
29
+ risk: "Low",
30
+ why: "Revenue has grown 12% YoY, above the 8% industry benchmark.",
31
+ ...overrides,
32
+ };
33
+ }
34
+
35
+ /** A high-risk (red bar) factor. */
36
+ export function makeHighRiskFactor(
37
+ overrides: Partial<ScoreFactor> = {},
38
+ ): ScoreFactor {
39
+ return makeScoreFactor({
40
+ key: "cash_runway",
41
+ label: "Cash Runway",
42
+ score: 28,
43
+ risk: "High",
44
+ why: "Cash runway is less than 6 months at current burn rate.",
45
+ ...overrides,
46
+ });
47
+ }
48
+
49
+ /** A medium-risk (amber bar) factor. */
50
+ export function makeMediumRiskFactor(
51
+ overrides: Partial<ScoreFactor> = {},
52
+ ): ScoreFactor {
53
+ return makeScoreFactor({
54
+ key: "customer_churn",
55
+ label: "Customer Churn",
56
+ score: 55,
57
+ risk: "Medium",
58
+ why: "Churn rate is elevated but within recoverable range.",
59
+ ...overrides,
60
+ });
61
+ }
62
+
63
+ /** A factor with null score (no score bar rendered). */
64
+ export function makeNullScoreFactor(
65
+ overrides: Partial<ScoreFactor> = {},
66
+ ): ScoreFactor {
67
+ return makeScoreFactor({
68
+ key: "pending_factor",
69
+ label: "Pending Analysis",
70
+ score: null,
71
+ risk: undefined,
72
+ why: "Analysis not yet available.",
73
+ ...overrides,
74
+ });
75
+ }
76
+
77
+ // ---------------------------------------------------------------------------
78
+ // initialFeedback shapes for ScoreFeedback.Root
79
+ // ---------------------------------------------------------------------------
80
+
81
+ /**
82
+ * Build an `initialFeedback` object for `ScoreFeedback.Root`.
83
+ * Matches the `{ type, pills, detail }` shape accepted by the `initialFeedback` prop.
84
+ */
85
+ export function makeInitialScoreFeedback(
86
+ overrides: Partial<{ type: "up" | "down"; pills: string[]; detail: string }> = {},
87
+ ): { type: "up" | "down"; pills: string[]; detail: string } {
88
+ return {
89
+ type: "up",
90
+ pills: ["Right timing", "Accurate data"],
91
+ detail: "Score looks correct.",
92
+ ...overrides,
93
+ };
94
+ }
95
+
96
+ /** Pre-populated negative (thumbs-down) score feedback. */
97
+ export function makeNegativeInitialScoreFeedback(
98
+ overrides: Partial<{ type: "up" | "down"; pills: string[]; detail: string }> = {},
99
+ ): { type: "up" | "down"; pills: string[]; detail: string } {
100
+ return makeInitialScoreFeedback({
101
+ type: "down",
102
+ pills: ["Bad timing", "Inaccurate data"],
103
+ detail: "Data sources seem stale.",
104
+ ...overrides,
105
+ });
106
+ }
107
+
108
+ /**
109
+ * Score feedback with empty pills and no detail text.
110
+ * Represents the minimal possible submission (just a thumb direction).
111
+ */
112
+ export function makeMinimalInitialScoreFeedback(
113
+ type: "up" | "down" = "up",
114
+ ): { type: "up" | "down"; pills: string[]; detail: string } {
115
+ return { type, pills: [], detail: "" };
116
+ }
117
+
118
+ // ---------------------------------------------------------------------------
119
+ // initialFeedback shapes for ScoreBreakdown
120
+ // ---------------------------------------------------------------------------
121
+
122
+ /**
123
+ * Build an `initialFeedback` map for `ScoreBreakdown`.
124
+ * Keys are factor keys; values are `{ type, detail }`.
125
+ */
126
+ export function makeInitialFactorFeedback(
127
+ overrides: Record<string, { type: "up" | "down"; detail: string }> = {},
128
+ ): Record<string, { type: "up" | "down"; detail: string }> {
129
+ return {
130
+ revenue_change: { type: "up", detail: "Revenue data looks accurate." },
131
+ cash_runway: { type: "down", detail: "Cash figures seem too optimistic." },
132
+ ...overrides,
133
+ };
134
+ }
135
+
136
+ /**
137
+ * Single-factor initialFeedback.
138
+ * Common in tests that focus on one particular factor's pre-population.
139
+ */
140
+ export function makeSingleFactorFeedback(
141
+ key: string,
142
+ type: "up" | "down",
143
+ detail = "",
144
+ ): Record<string, { type: "up" | "down"; detail: string }> {
145
+ return { [key]: { type, detail } };
146
+ }
147
+
148
+ /** Empty factor feedback map — no prior feedback for any factor. */
149
+ export const EMPTY_FACTOR_FEEDBACK: Record<
150
+ string,
151
+ { type: "up" | "down"; detail: string }
152
+ > = {};
@@ -0,0 +1,310 @@
1
+ /**
2
+ * Tests for ScoreBreakdown's `initialFeedback` prop and related behaviors.
3
+ *
4
+ * Covers:
5
+ * - deriveInitialState helper (via observable rendered state)
6
+ * - Pre-populated feedback state (thumb shown as active)
7
+ * - Pre-populated feedbackText / savedText
8
+ * - useEffect sync on prop change (with editingKey guard)
9
+ * - submitFeedbackText calls onFactorFeedback with text ?? "" (not undefined)
10
+ */
11
+
12
+ import { describe, it, expect, vi } from "vitest";
13
+ import React from "react";
14
+ import { render, screen, fireEvent, act } from "@testing-library/react";
15
+ import { ScoreBreakdown } from "../score-breakdown";
16
+ import {
17
+ makeScoreFactor,
18
+ makeHighRiskFactor,
19
+ makeInitialFactorFeedback,
20
+ makeSingleFactorFeedback,
21
+ EMPTY_FACTOR_FEEDBACK,
22
+ } from "../../__test-helpers__/fixtures";
23
+
24
+ // ─── Helpers ─────────────────────────────────────────────────────────────────
25
+
26
+ function makeFactors() {
27
+ return [
28
+ makeScoreFactor({ key: "revenue_change", label: "Revenue Change" }),
29
+ makeHighRiskFactor({ key: "cash_runway", label: "Cash Runway" }),
30
+ ];
31
+ }
32
+
33
+ // ─── Tests ────────────────────────────────────────────────────────────────────
34
+
35
+ describe("ScoreBreakdown — initialFeedback prop (deriveInitialState)", () => {
36
+ it("renders factors without initialFeedback (no pre-populated state)", () => {
37
+ render(
38
+ <ScoreBreakdown
39
+ factors={makeFactors()}
40
+ initialFeedback={undefined}
41
+ />,
42
+ );
43
+ expect(screen.getByText("Revenue Change")).toBeDefined();
44
+ expect(screen.getByText("Cash Runway")).toBeDefined();
45
+ });
46
+
47
+ it("with EMPTY_FACTOR_FEEDBACK, renders like undefined (no active thumbs)", () => {
48
+ // Neither factor should show a saved text or different thumb style
49
+ const { container } = render(
50
+ <ScoreBreakdown
51
+ factors={makeFactors()}
52
+ initialFeedback={EMPTY_FACTOR_FEEDBACK}
53
+ />,
54
+ );
55
+ // No saved text paragraphs — just label and score bar
56
+ expect(screen.queryByText("Revenue data looks accurate.")).toBeNull();
57
+ expect(container).toBeDefined();
58
+ });
59
+
60
+ it("pre-populates thumb state for revenue_change as 'up'", () => {
61
+ // When feedback[key] = 'up', the thumb-up button gets emerald styling
62
+ // We can observe this by checking that the saved text from detail is rendered
63
+ render(
64
+ <ScoreBreakdown
65
+ factors={makeFactors()}
66
+ initialFeedback={makeSingleFactorFeedback("revenue_change", "up", "Revenue data looks accurate.")}
67
+ />,
68
+ );
69
+ // savedText["revenue_change"] = "Revenue data looks accurate." is rendered as an edit button
70
+ expect(screen.getByText("Revenue data looks accurate.")).toBeDefined();
71
+ });
72
+
73
+ it("pre-populates thumb state for cash_runway as 'down' with detail", () => {
74
+ render(
75
+ <ScoreBreakdown
76
+ factors={makeFactors()}
77
+ initialFeedback={makeSingleFactorFeedback("cash_runway", "down", "Cash figures seem too optimistic.")}
78
+ />,
79
+ );
80
+ expect(screen.getByText("Cash figures seem too optimistic.")).toBeDefined();
81
+ });
82
+
83
+ it("pre-populates multiple factors from initialFeedback", () => {
84
+ render(
85
+ <ScoreBreakdown
86
+ factors={makeFactors()}
87
+ initialFeedback={makeInitialFactorFeedback()}
88
+ />,
89
+ );
90
+ expect(screen.getByText("Revenue data looks accurate.")).toBeDefined();
91
+ expect(screen.getByText("Cash figures seem too optimistic.")).toBeDefined();
92
+ });
93
+
94
+ it("factors with no matching key in initialFeedback are unaffected", () => {
95
+ // Only revenue_change is in the initial feedback; cash_runway should have no saved text
96
+ render(
97
+ <ScoreBreakdown
98
+ factors={makeFactors()}
99
+ initialFeedback={makeSingleFactorFeedback("revenue_change", "up", "Revenue data looks accurate.")}
100
+ />,
101
+ );
102
+ expect(screen.getByText("Revenue data looks accurate.")).toBeDefined();
103
+ expect(screen.queryByText("Cash figures seem too optimistic.")).toBeNull();
104
+ });
105
+
106
+ it("detail = '' does not produce a savedText entry (filterFn filters it)", () => {
107
+ // makeMinimalInitialScoreFeedback analogue for factor: detail = ""
108
+ render(
109
+ <ScoreBreakdown
110
+ factors={makeFactors()}
111
+ initialFeedback={makeSingleFactorFeedback("revenue_change", "up", "")}
112
+ />,
113
+ );
114
+ // No saved text button rendered (detail is empty, filterFn(v => !!v.detail) excludes it)
115
+ // But the thumb IS pre-populated — factor shows "up" state
116
+ // We can't easily assert CSS classes, but we can assert no saved text renders
117
+ // (the "revenue data looks accurate" text should NOT be here since detail = "")
118
+ expect(screen.queryByText("Revenue data looks accurate.")).toBeNull();
119
+ });
120
+ });
121
+
122
+ describe("ScoreBreakdown — useEffect sync on prop change", () => {
123
+ it("syncs state when initialFeedback changes from undefined to a value", async () => {
124
+ const { rerender } = render(
125
+ <ScoreBreakdown
126
+ factors={makeFactors()}
127
+ initialFeedback={undefined}
128
+ />,
129
+ );
130
+
131
+ // No saved text initially
132
+ expect(screen.queryByText("Revenue data looks accurate.")).toBeNull();
133
+
134
+ // Now provide initialFeedback
135
+ await act(async () => {
136
+ rerender(
137
+ <ScoreBreakdown
138
+ factors={makeFactors()}
139
+ initialFeedback={makeSingleFactorFeedback(
140
+ "revenue_change",
141
+ "up",
142
+ "Revenue data looks accurate.",
143
+ )}
144
+ />,
145
+ );
146
+ });
147
+
148
+ expect(screen.getByText("Revenue data looks accurate.")).toBeDefined();
149
+ });
150
+
151
+ it("syncs state when initialFeedback changes from one value to another", async () => {
152
+ const { rerender } = render(
153
+ <ScoreBreakdown
154
+ factors={makeFactors()}
155
+ initialFeedback={makeSingleFactorFeedback("revenue_change", "up", "Old detail.")}
156
+ />,
157
+ );
158
+
159
+ expect(screen.getByText("Old detail.")).toBeDefined();
160
+
161
+ await act(async () => {
162
+ rerender(
163
+ <ScoreBreakdown
164
+ factors={makeFactors()}
165
+ initialFeedback={makeSingleFactorFeedback("revenue_change", "up", "New detail.")}
166
+ />,
167
+ );
168
+ });
169
+
170
+ expect(screen.queryByText("Old detail.")).toBeNull();
171
+ expect(screen.getByText("New detail.")).toBeDefined();
172
+ });
173
+
174
+ it("guard: preserves actively-edited key when initialFeedback changes", async () => {
175
+ // Start with one value
176
+ const { rerender, container } = render(
177
+ <ScoreBreakdown
178
+ factors={makeFactors()}
179
+ initialFeedback={makeSingleFactorFeedback("revenue_change", "up", "Old detail.")}
180
+ />,
181
+ );
182
+
183
+ // Click the saved text to enter edit mode (sets editingKey = "revenue_change")
184
+ const savedTextButton = screen.getByText("Old detail.");
185
+ await act(async () => {
186
+ fireEvent.click(savedTextButton);
187
+ });
188
+
189
+ // Now an input should be visible
190
+ const input = container.querySelector("input");
191
+ expect(input).not.toBeNull();
192
+
193
+ // Change the input text to something different
194
+ await act(async () => {
195
+ fireEvent.change(input!, { target: { value: "My in-progress edit" } });
196
+ });
197
+
198
+ // Now the prop changes — sync should skip the editing key
199
+ await act(async () => {
200
+ rerender(
201
+ <ScoreBreakdown
202
+ factors={makeFactors()}
203
+ initialFeedback={makeSingleFactorFeedback("revenue_change", "up", "Async update.")}
204
+ />,
205
+ );
206
+ });
207
+
208
+ // The input should still have the user's in-progress value
209
+ const inputAfter = container.querySelector("input") as HTMLInputElement;
210
+ expect(inputAfter?.value).toBe("My in-progress edit");
211
+ });
212
+ });
213
+
214
+ describe("ScoreBreakdown — submitFeedbackText: text ?? '' fix", () => {
215
+ it("calls onFactorFeedback with empty string when no text entered (not undefined)", async () => {
216
+ const onFactorFeedback = vi.fn();
217
+ const { container } = render(
218
+ <ScoreBreakdown
219
+ factors={[makeScoreFactor({ key: "revenue_change", label: "Revenue Change" })]}
220
+ onFactorFeedback={onFactorFeedback}
221
+ initialFeedback={undefined}
222
+ />,
223
+ );
224
+
225
+ // Click thumbs-up to set feedback[revenue_change] = "up" and enter edit mode
226
+ const thumbUpButton = container.querySelectorAll("button")[0];
227
+ await act(async () => {
228
+ fireEvent.click(thumbUpButton);
229
+ });
230
+
231
+ // An input should appear (editingKey = "revenue_change")
232
+ const input = container.querySelector("input");
233
+ expect(input).not.toBeNull();
234
+
235
+ // Blur the input without typing (feedbackText["revenue_change"] = "")
236
+ await act(async () => {
237
+ fireEvent.blur(input!);
238
+ });
239
+
240
+ // onFactorFeedback should have been called twice:
241
+ // 1. from handleFeedback when thumbs clicked: (key, "up")
242
+ // 2. from submitFeedbackText on blur: (key, "up", "" ) ← the fix
243
+ const calls = onFactorFeedback.mock.calls;
244
+ const submitCall = calls.find(
245
+ (c: unknown[]) => c.length === 3,
246
+ );
247
+ expect(submitCall).toBeDefined();
248
+ expect(submitCall![2]).toBe(""); // "" not undefined
249
+ });
250
+
251
+ it("calls onFactorFeedback with trimmed text when text is entered", async () => {
252
+ const onFactorFeedback = vi.fn();
253
+ const { container } = render(
254
+ <ScoreBreakdown
255
+ factors={[makeScoreFactor({ key: "revenue_change", label: "Revenue Change" })]}
256
+ onFactorFeedback={onFactorFeedback}
257
+ initialFeedback={undefined}
258
+ />,
259
+ );
260
+
261
+ const thumbUpButton = container.querySelectorAll("button")[0];
262
+ await act(async () => {
263
+ fireEvent.click(thumbUpButton);
264
+ });
265
+
266
+ const input = container.querySelector("input");
267
+ await act(async () => {
268
+ fireEvent.change(input!, { target: { value: " Revenue is accurate " } });
269
+ });
270
+
271
+ await act(async () => {
272
+ fireEvent.blur(input!);
273
+ });
274
+
275
+ const calls = onFactorFeedback.mock.calls;
276
+ const submitCall = calls.find(
277
+ (c: unknown[]) => c.length === 3,
278
+ );
279
+ expect(submitCall).toBeDefined();
280
+ expect(submitCall![2]).toBe("Revenue is accurate"); // trimmed
281
+ });
282
+
283
+ it("pressing Enter submits with text ?? '' (empty string for blank input)", async () => {
284
+ const onFactorFeedback = vi.fn();
285
+ const { container } = render(
286
+ <ScoreBreakdown
287
+ factors={[makeScoreFactor({ key: "revenue_change", label: "Revenue Change" })]}
288
+ onFactorFeedback={onFactorFeedback}
289
+ initialFeedback={undefined}
290
+ />,
291
+ );
292
+
293
+ const thumbUpButton = container.querySelectorAll("button")[0];
294
+ await act(async () => {
295
+ fireEvent.click(thumbUpButton);
296
+ });
297
+
298
+ const input = container.querySelector("input");
299
+ await act(async () => {
300
+ fireEvent.keyDown(input!, { key: "Enter" });
301
+ });
302
+
303
+ const calls = onFactorFeedback.mock.calls;
304
+ const submitCall = calls.find(
305
+ (c: unknown[]) => c.length === 3,
306
+ );
307
+ expect(submitCall).toBeDefined();
308
+ expect(submitCall![2]).toBe(""); // empty string, not undefined
309
+ });
310
+ });