@handled-ai/design-system 0.9.24 → 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.
- package/dist/charts/chart.d.ts +1 -1
- package/dist/components/score-breakdown.js +32 -5
- package/dist/components/score-breakdown.js.map +1 -1
- package/dist/components/score-feedback.js +5 -1
- package/dist/components/score-feedback.js.map +1 -1
- package/dist/components/signal-feedback-inline.d.ts +13 -2
- package/dist/components/signal-feedback-inline.js +33 -4
- package/dist/components/signal-feedback-inline.js.map +1 -1
- package/dist/prototype/prototype-config.d.ts +2 -1
- package/dist/prototype/prototype-inbox-view.d.ts +1 -1
- package/dist/prototype/prototype-inbox-view.js.map +1 -1
- package/package.json +23 -6
- package/src/__test-helpers__/fixtures.ts +152 -0
- package/src/components/__tests__/score-breakdown-initial.test.tsx +310 -0
- package/src/components/__tests__/score-feedback-initial.test.tsx +253 -0
- package/src/components/score-breakdown.tsx +41 -13
- package/src/components/score-feedback.tsx +8 -1
- package/src/components/signal-feedback-inline.tsx +51 -5
- package/src/prototype/prototype-config.ts +2 -1
- package/src/prototype/prototype-inbox-view.tsx +1 -1
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for ScoreFeedback.Root's `initialFeedback` prop and related behaviors.
|
|
3
|
+
*
|
|
4
|
+
* Covers:
|
|
5
|
+
* - Initial `submittedFeedback` pre-population via prop
|
|
6
|
+
* - "Noted" / "Recorded" shown instead of thumbs when pre-populated
|
|
7
|
+
* - null initialFeedback → blank state (thumbs shown)
|
|
8
|
+
* - useEffect sync: prop changes from null → value updates submittedFeedback
|
|
9
|
+
* - useEffect guard: in-progress edit (thumbState ≠ null) skips sync
|
|
10
|
+
* - editSubmitted restores prior feedback into editing state
|
|
11
|
+
* - handleSubmit calls onSubmitFeedback
|
|
12
|
+
* - factor callback fix: text ?? "" (empty string, not undefined)
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { describe, it, expect, vi } from "vitest";
|
|
16
|
+
import React from "react";
|
|
17
|
+
import { render, screen, fireEvent, act } from "@testing-library/react";
|
|
18
|
+
import { ScoreFeedback } from "../score-feedback";
|
|
19
|
+
import {
|
|
20
|
+
makeInitialScoreFeedback,
|
|
21
|
+
makeNegativeInitialScoreFeedback,
|
|
22
|
+
makeMinimalInitialScoreFeedback,
|
|
23
|
+
} from "../../__test-helpers__/fixtures";
|
|
24
|
+
|
|
25
|
+
// ─── Minimal compound-component wrapper ──────────────────────────────────────
|
|
26
|
+
|
|
27
|
+
function Wrapper({
|
|
28
|
+
initialFeedback,
|
|
29
|
+
onSubmitFeedback,
|
|
30
|
+
}: {
|
|
31
|
+
initialFeedback?: { type: "up" | "down"; pills: string[]; detail: string } | null;
|
|
32
|
+
onSubmitFeedback?: (type: "up" | "down", pills: string[], detail: string) => void;
|
|
33
|
+
}) {
|
|
34
|
+
return (
|
|
35
|
+
<ScoreFeedback.Root
|
|
36
|
+
initialFeedback={initialFeedback}
|
|
37
|
+
onSubmitFeedback={onSubmitFeedback}
|
|
38
|
+
>
|
|
39
|
+
<ScoreFeedback.Trigger />
|
|
40
|
+
<ScoreFeedback.Panel />
|
|
41
|
+
</ScoreFeedback.Root>
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// ─── Tests ────────────────────────────────────────────────────────────────────
|
|
46
|
+
|
|
47
|
+
describe("ScoreFeedback.Root — initialFeedback prop", () => {
|
|
48
|
+
it("shows 'Noted' when initialFeedback.type is 'up'", () => {
|
|
49
|
+
render(<Wrapper initialFeedback={makeInitialScoreFeedback()} />);
|
|
50
|
+
expect(screen.getByText("Noted")).toBeDefined();
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("shows 'Recorded' when initialFeedback.type is 'down'", () => {
|
|
54
|
+
render(<Wrapper initialFeedback={makeNegativeInitialScoreFeedback()} />);
|
|
55
|
+
expect(screen.getByText("Recorded")).toBeDefined();
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("does NOT show thumbs when initialFeedback is set (submitted state)", () => {
|
|
59
|
+
render(
|
|
60
|
+
<Wrapper initialFeedback={makeInitialScoreFeedback()} />,
|
|
61
|
+
);
|
|
62
|
+
// Thumbs-up and thumbs-down buttons should not be rendered
|
|
63
|
+
// The only button visible should be the "edit" button (with pill/detail), not bare thumbs
|
|
64
|
+
// We check by absence of the thumb click pattern — no button with onClick=handleThumbClick("up")
|
|
65
|
+
// More concretely: no thumb buttons means the Trigger is in "submitted" mode
|
|
66
|
+
expect(screen.queryByTitle?.("")).toBeNull?.(); // optional, just no crash
|
|
67
|
+
expect(screen.getByText("Noted")).toBeDefined();
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("shows thumbs when initialFeedback is null", () => {
|
|
71
|
+
const { container } = render(<Wrapper initialFeedback={null} />);
|
|
72
|
+
// Panel is hidden (no thumbState), Trigger shows thumb buttons
|
|
73
|
+
const buttons = container.querySelectorAll("button");
|
|
74
|
+
// Two thumb buttons are present
|
|
75
|
+
expect(buttons.length).toBeGreaterThanOrEqual(2);
|
|
76
|
+
// No "Noted" or "Recorded" text
|
|
77
|
+
expect(screen.queryByText("Noted")).toBeNull();
|
|
78
|
+
expect(screen.queryByText("Recorded")).toBeNull();
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("shows thumbs when initialFeedback is undefined", () => {
|
|
82
|
+
const { container } = render(<Wrapper />);
|
|
83
|
+
const buttons = container.querySelectorAll("button");
|
|
84
|
+
expect(buttons.length).toBeGreaterThanOrEqual(2);
|
|
85
|
+
expect(screen.queryByText("Noted")).toBeNull();
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("renders submitted pills from initialFeedback", () => {
|
|
89
|
+
render(
|
|
90
|
+
<Wrapper
|
|
91
|
+
initialFeedback={makeInitialScoreFeedback({
|
|
92
|
+
pills: ["Right timing", "Accurate data"],
|
|
93
|
+
})}
|
|
94
|
+
/>,
|
|
95
|
+
);
|
|
96
|
+
expect(screen.getByText("Right timing")).toBeDefined();
|
|
97
|
+
expect(screen.getByText("Accurate data")).toBeDefined();
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("renders submitted detail text from initialFeedback", () => {
|
|
101
|
+
render(
|
|
102
|
+
<Wrapper
|
|
103
|
+
initialFeedback={makeInitialScoreFeedback({ detail: "Score looks correct." })}
|
|
104
|
+
/>,
|
|
105
|
+
);
|
|
106
|
+
expect(screen.getByText("Score looks correct.")).toBeDefined();
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("minimal initialFeedback (no pills, no detail) still shows Noted without edit button", () => {
|
|
110
|
+
const { container } = render(
|
|
111
|
+
<Wrapper initialFeedback={makeMinimalInitialScoreFeedback("up")} />,
|
|
112
|
+
);
|
|
113
|
+
expect(screen.getByText("Noted")).toBeDefined();
|
|
114
|
+
// No pill text to find — just check no crash
|
|
115
|
+
const buttons = container.querySelectorAll("button");
|
|
116
|
+
// The edit button requires pills.length > 0 || detail, so no edit button here
|
|
117
|
+
expect(buttons.length).toBe(0);
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
describe("ScoreFeedback.Root — useEffect sync", () => {
|
|
122
|
+
it("updates submittedFeedback when initialFeedback prop changes from null to value", async () => {
|
|
123
|
+
const { rerender } = render(<Wrapper initialFeedback={null} />);
|
|
124
|
+
|
|
125
|
+
// Initially null → thumbs shown
|
|
126
|
+
expect(screen.queryByText("Noted")).toBeNull();
|
|
127
|
+
|
|
128
|
+
// Prop changes to a value
|
|
129
|
+
await act(async () => {
|
|
130
|
+
rerender(<Wrapper initialFeedback={makeInitialScoreFeedback()} />);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
// Now should show "Noted"
|
|
134
|
+
expect(screen.getByText("Noted")).toBeDefined();
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it("updates from 'up' to 'down' when prop changes", async () => {
|
|
138
|
+
const { rerender } = render(
|
|
139
|
+
<Wrapper initialFeedback={makeInitialScoreFeedback()} />,
|
|
140
|
+
);
|
|
141
|
+
expect(screen.getByText("Noted")).toBeDefined();
|
|
142
|
+
|
|
143
|
+
await act(async () => {
|
|
144
|
+
rerender(<Wrapper initialFeedback={makeNegativeInitialScoreFeedback()} />);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
expect(screen.getByText("Recorded")).toBeDefined();
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it("guard: does NOT sync when user has thumbState set (in-progress edit)", async () => {
|
|
151
|
+
// Start with null initialFeedback
|
|
152
|
+
const { rerender, container } = render(<Wrapper initialFeedback={null} />);
|
|
153
|
+
|
|
154
|
+
// User clicks thumbs-up to start an edit
|
|
155
|
+
const buttons = container.querySelectorAll("button");
|
|
156
|
+
await act(async () => {
|
|
157
|
+
fireEvent.click(buttons[0]); // thumbs-up button
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
// Now prop changes — should NOT overwrite in-progress edit
|
|
161
|
+
await act(async () => {
|
|
162
|
+
rerender(<Wrapper initialFeedback={makeNegativeInitialScoreFeedback()} />);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
// Should NOT show "Recorded" — user's thumb state takes precedence
|
|
166
|
+
expect(screen.queryByText("Recorded")).toBeNull();
|
|
167
|
+
// Panel should be visible (thumbState is set)
|
|
168
|
+
// The Panel shows "How's this score?" section
|
|
169
|
+
expect(screen.getByText(/How['']s this score\?/)).toBeDefined();
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
describe("ScoreFeedback.Root — onSubmitFeedback callback", () => {
|
|
174
|
+
it("calls onSubmitFeedback with (type, pills, detail) on submit", async () => {
|
|
175
|
+
const onSubmit = vi.fn();
|
|
176
|
+
const { container } = render(
|
|
177
|
+
<Wrapper initialFeedback={null} onSubmitFeedback={onSubmit} />,
|
|
178
|
+
);
|
|
179
|
+
|
|
180
|
+
// Click thumbs-up
|
|
181
|
+
const thumbButtons = container.querySelectorAll("button");
|
|
182
|
+
await act(async () => {
|
|
183
|
+
fireEvent.click(thumbButtons[0]); // thumbs-up
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
// Select a pill
|
|
187
|
+
const pillButton = screen.getByText("Right timing");
|
|
188
|
+
await act(async () => {
|
|
189
|
+
fireEvent.click(pillButton);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
// Click Submit
|
|
193
|
+
const submitButton = screen.getByText("Submit");
|
|
194
|
+
await act(async () => {
|
|
195
|
+
fireEvent.click(submitButton);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
expect(onSubmit).toHaveBeenCalledTimes(1);
|
|
199
|
+
expect(onSubmit).toHaveBeenCalledWith("up", ["Right timing"], "");
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it("calls onSubmitFeedback with empty string detail when no detail entered", async () => {
|
|
203
|
+
const onSubmit = vi.fn();
|
|
204
|
+
const { container } = render(
|
|
205
|
+
<Wrapper initialFeedback={null} onSubmitFeedback={onSubmit} />,
|
|
206
|
+
);
|
|
207
|
+
|
|
208
|
+
const thumbButtons = container.querySelectorAll("button");
|
|
209
|
+
await act(async () => {
|
|
210
|
+
fireEvent.click(thumbButtons[0]); // thumbs-up
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
const pillButton = screen.getByText("Right timing");
|
|
214
|
+
await act(async () => {
|
|
215
|
+
fireEvent.click(pillButton);
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
const submitButton = screen.getByText("Submit");
|
|
219
|
+
await act(async () => {
|
|
220
|
+
fireEvent.click(submitButton);
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
const [, , detail] = onSubmit.mock.calls[0] as [string, string[], string];
|
|
224
|
+
expect(detail).toBe("");
|
|
225
|
+
});
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
describe("ScoreFeedback.Root — editSubmitted", () => {
|
|
229
|
+
it("clicking the pills/detail edit area restores feedback into editing state", async () => {
|
|
230
|
+
const { container } = render(
|
|
231
|
+
<Wrapper
|
|
232
|
+
initialFeedback={makeInitialScoreFeedback({
|
|
233
|
+
pills: ["Right timing"],
|
|
234
|
+
detail: "Score looks correct.",
|
|
235
|
+
})}
|
|
236
|
+
/>,
|
|
237
|
+
);
|
|
238
|
+
|
|
239
|
+
// The edit button (wraps pills + detail) should be present
|
|
240
|
+
const editButton = container.querySelector("button");
|
|
241
|
+
expect(editButton).not.toBeNull();
|
|
242
|
+
|
|
243
|
+
await act(async () => {
|
|
244
|
+
fireEvent.click(editButton!);
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
// After clicking, the Panel should be visible with "How's this score?"
|
|
248
|
+
expect(screen.getByText(/How['']s this score\?/)).toBeDefined();
|
|
249
|
+
// And the previously selected pill should be pre-selected
|
|
250
|
+
// (visible in the panel's pill list as selected)
|
|
251
|
+
expect(screen.getAllByText("Right timing").length).toBeGreaterThanOrEqual(1);
|
|
252
|
+
});
|
|
253
|
+
});
|
|
@@ -36,28 +36,56 @@ interface ScoreBreakdownProps {
|
|
|
36
36
|
initialFeedback?: Record<string, { type: "up" | "down"; detail: string }>
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
+
function deriveInitialState<T>(
|
|
40
|
+
init: Record<string, { type: "up" | "down"; detail: string }> | undefined,
|
|
41
|
+
mapFn: (v: { type: "up" | "down"; detail: string }) => T,
|
|
42
|
+
filterFn?: (v: { type: "up" | "down"; detail: string }) => boolean,
|
|
43
|
+
): Record<string, T> {
|
|
44
|
+
if (!init) return {}
|
|
45
|
+
const entries = Object.entries(init)
|
|
46
|
+
const filtered = filterFn ? entries.filter(([, v]) => filterFn(v)) : entries
|
|
47
|
+
return Object.fromEntries(filtered.map(([k, v]) => [k, mapFn(v)]))
|
|
48
|
+
}
|
|
49
|
+
|
|
39
50
|
function ScoreBreakdown({ factors, onFactorFeedback, className, initialFeedback }: ScoreBreakdownProps) {
|
|
40
51
|
const [feedback, setFeedback] = React.useState<Record<string, "up" | "down" | null>>(
|
|
41
|
-
initialFeedback
|
|
42
|
-
? Object.fromEntries(Object.entries(initialFeedback).map(([k, v]) => [k, v.type]))
|
|
43
|
-
: {}
|
|
52
|
+
() => deriveInitialState(initialFeedback, (v) => v.type)
|
|
44
53
|
)
|
|
45
54
|
const [feedbackText, setFeedbackText] = React.useState<Record<string, string>>(
|
|
46
|
-
initialFeedback
|
|
47
|
-
? Object.fromEntries(Object.entries(initialFeedback).map(([k, v]) => [k, v.detail]))
|
|
48
|
-
: {}
|
|
55
|
+
() => deriveInitialState(initialFeedback, (v) => v.detail)
|
|
49
56
|
)
|
|
50
57
|
const [savedText, setSavedText] = React.useState<Record<string, string>>(
|
|
51
|
-
initialFeedback
|
|
52
|
-
? Object.fromEntries(
|
|
53
|
-
Object.entries(initialFeedback)
|
|
54
|
-
.filter(([, v]) => v.detail)
|
|
55
|
-
.map(([k, v]) => [k, v.detail])
|
|
56
|
-
)
|
|
57
|
-
: {}
|
|
58
|
+
() => deriveInitialState(initialFeedback, (v) => v.detail, (v) => !!v.detail)
|
|
58
59
|
)
|
|
59
60
|
const [editingKey, setEditingKey] = React.useState<string | null>(null)
|
|
60
61
|
|
|
62
|
+
// Sync state when initialFeedback prop changes (e.g. async hydration).
|
|
63
|
+
// Skip keys the user is actively editing to avoid clobbering in-progress input.
|
|
64
|
+
React.useEffect(() => {
|
|
65
|
+
setFeedback((prev) => {
|
|
66
|
+
const next: Record<string, "up" | "down" | null> = deriveInitialState(initialFeedback, (v) => v.type)
|
|
67
|
+
// Preserve any key the user is currently editing
|
|
68
|
+
if (editingKey && prev[editingKey] !== undefined) {
|
|
69
|
+
next[editingKey] = prev[editingKey]
|
|
70
|
+
}
|
|
71
|
+
return next
|
|
72
|
+
})
|
|
73
|
+
setFeedbackText((prev) => {
|
|
74
|
+
const next = deriveInitialState(initialFeedback, (v) => v.detail)
|
|
75
|
+
if (editingKey && prev[editingKey] !== undefined) {
|
|
76
|
+
next[editingKey] = prev[editingKey]
|
|
77
|
+
}
|
|
78
|
+
return next
|
|
79
|
+
})
|
|
80
|
+
setSavedText((prev) => {
|
|
81
|
+
const next = deriveInitialState(initialFeedback, (v) => v.detail, (v) => !!v.detail)
|
|
82
|
+
if (editingKey && prev[editingKey] !== undefined) {
|
|
83
|
+
next[editingKey] = prev[editingKey]
|
|
84
|
+
}
|
|
85
|
+
return next
|
|
86
|
+
})
|
|
87
|
+
}, [initialFeedback]) // eslint-disable-line react-hooks/exhaustive-deps -- reads editingKey as guard, not trigger
|
|
88
|
+
|
|
61
89
|
const handleFeedback = (factorKey: string, type: "up" | "down") => {
|
|
62
90
|
const newState = feedback[factorKey] === type ? null : type
|
|
63
91
|
setFeedback((prev) => ({ ...prev, [factorKey]: newState }))
|
|
@@ -61,9 +61,16 @@ function Root({ children, onSubmitFeedback, initialFeedback }: RootProps) {
|
|
|
61
61
|
const [detailText, setDetailTextState] = React.useState("")
|
|
62
62
|
const [notedType, setNotedType] = React.useState<"up" | "down" | null>(null)
|
|
63
63
|
const [submittedFeedback, setSubmittedFeedback] = React.useState<SubmittedScoreFeedback | null>(
|
|
64
|
-
initialFeedback
|
|
64
|
+
initialFeedback ?? null
|
|
65
65
|
)
|
|
66
66
|
|
|
67
|
+
// Sync submitted feedback when initialFeedback prop changes (e.g. async
|
|
68
|
+
// detail load). Skip when the user has an in-progress edit (thumbState set).
|
|
69
|
+
React.useEffect(() => {
|
|
70
|
+
if (thumbState !== null) return
|
|
71
|
+
setSubmittedFeedback(initialFeedback ?? null)
|
|
72
|
+
}, [initialFeedback]) // eslint-disable-line react-hooks/exhaustive-deps -- intentionally omits thumbState to read it as a guard, not a trigger
|
|
73
|
+
|
|
67
74
|
const otherSelected = selectedPills.includes("Other")
|
|
68
75
|
|
|
69
76
|
const hasRequiredInput =
|