@lovett/ui 0.0.10 → 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.
- package/dist/chunk-RBYWGBQ2.js +2752 -0
- package/dist/chunk-RBYWGBQ2.js.map +1 -0
- package/dist/index.d.ts +6064 -80
- package/dist/index.js +21561 -9696
- package/dist/index.js.map +1 -1
- package/dist/rich-composer-impl-5NO443A6.js +1859 -0
- package/dist/rich-composer-impl-5NO443A6.js.map +1 -0
- package/dist/styles.css +1586 -2
- package/dist/theme-v2.css +228 -0
- package/dist/tokens.css +121 -0
- package/package.json +8 -1
- package/src/__tests__/anchor.test.tsx +422 -0
- package/src/__tests__/avatar.test.tsx +272 -0
- package/src/__tests__/bar-chart.test.tsx +809 -0
- package/src/__tests__/board.test.tsx +420 -0
- package/src/__tests__/chart-math.test.ts +922 -0
- package/src/__tests__/chart-series.test.ts +339 -0
- package/src/__tests__/code-block.test.tsx +134 -0
- package/src/__tests__/combobox.test.tsx +677 -0
- package/src/__tests__/display-popover.test.tsx +195 -0
- package/src/__tests__/display-store.test.tsx +208 -0
- package/src/__tests__/donut-chart.test.tsx +397 -0
- package/src/__tests__/dropdown-menu.test.tsx +572 -0
- package/src/__tests__/filter-menu.test.tsx +175 -0
- package/src/__tests__/gauge-ring.test.tsx +233 -0
- package/src/__tests__/helpers/geometry.ts +58 -0
- package/src/__tests__/layer-stack.test.tsx +228 -0
- package/src/__tests__/line-chart.test.tsx +612 -0
- package/src/__tests__/popover.test.tsx +460 -0
- package/src/__tests__/ranked-bars.test.tsx +343 -0
- package/src/__tests__/remark-underline.test.ts +194 -0
- package/src/__tests__/select.test.tsx +543 -0
- package/src/__tests__/sparkline.test.tsx +368 -0
- package/src/__tests__/stat-layer.test.tsx +271 -0
- package/src/__tests__/stat-strip.test.tsx +175 -0
- package/src/__tests__/status.test.tsx +293 -0
- package/src/__tests__/tabs.test.tsx +303 -0
- package/src/__tests__/token-shape.test.ts +132 -2
- package/src/__tests__/tooltip.test.tsx +355 -0
- package/src/avatar.tsx +352 -0
- package/src/bar-chart.tsx +1214 -0
- package/src/board.tsx +658 -0
- package/src/calculator-shell-v2.tsx +19 -39
- package/src/chart-frame.tsx +960 -0
- package/src/chart-legend.tsx +304 -0
- package/src/chart-tooltip.tsx +267 -0
- package/src/code-block.tsx +62 -8
- package/src/combobox.tsx +796 -0
- package/src/delta-chip.tsx +263 -0
- package/src/detail/__tests__/activity-pane.test.tsx +186 -0
- package/src/detail/__tests__/detail-chrome.test.tsx +134 -0
- package/src/detail/__tests__/detail-surface.test.tsx +529 -0
- package/src/detail/__tests__/field-row.test.tsx +357 -0
- package/src/detail/activity-pane.tsx +507 -0
- package/src/detail/calendar.tsx +355 -0
- package/src/detail/detail-divider.tsx +261 -0
- package/src/detail/detail-header.tsx +287 -0
- package/src/detail/detail-menu.tsx +254 -0
- package/src/detail/detail-surface.tsx +1110 -0
- package/src/detail/field-list.tsx +196 -0
- package/src/detail/field-row.tsx +1131 -0
- package/src/detail/index.ts +58 -0
- package/src/detail/segmented-choice.tsx +94 -0
- package/src/detail/types.ts +129 -0
- package/src/display-popover.tsx +487 -0
- package/src/display-store.tsx +242 -0
- package/src/donut-chart.tsx +988 -0
- package/src/dropdown-menu.tsx +412 -151
- package/src/filter-core/EXPORTS.md +85 -0
- package/src/filter-core/__tests__/columns.test.ts +159 -0
- package/src/filter-core/__tests__/faceting.test.ts +193 -0
- package/src/filter-core/__tests__/filter-fns.test.ts +519 -0
- package/src/filter-core/__tests__/operators.test.ts +235 -0
- package/src/filter-core/__tests__/state.test.ts +268 -0
- package/src/filter-core/__tests__/url.test.ts +350 -0
- package/src/filter-core/columns.ts +134 -0
- package/src/filter-core/date-utils.ts +38 -0
- package/src/filter-core/examples/task-filter-columns.ts +121 -0
- package/src/filter-core/faceting.ts +120 -0
- package/src/filter-core/filter-fns.ts +335 -0
- package/src/filter-core/index.ts +105 -0
- package/src/filter-core/operators.ts +433 -0
- package/src/filter-core/state.ts +280 -0
- package/src/filter-core/types.ts +247 -0
- package/src/filter-core/url.ts +261 -0
- package/src/filter-dropdown.tsx +12 -0
- package/src/filter-menu.tsx +649 -0
- package/src/floating-drawer.tsx +19 -1
- package/src/gauge-ring.tsx +435 -0
- package/src/hue.ts +52 -0
- package/src/index.ts +402 -0
- package/src/kbd.tsx +27 -4
- package/src/lib/anchor.ts +427 -0
- package/src/lib/chart.ts +866 -0
- package/src/lib/focus.ts +74 -0
- package/src/lib/layer-stack.ts +215 -0
- package/src/lib/refs.ts +31 -0
- package/src/lib/remark-underline.ts +443 -0
- package/src/lib/series.ts +169 -0
- package/src/line-chart.tsx +1176 -0
- package/src/markdown.tsx +26 -7
- package/src/metric-card.tsx +57 -22
- package/src/modal.tsx +73 -66
- package/src/popover.tsx +407 -0
- package/src/progress-ledger.tsx +304 -0
- package/src/ranked-bars.tsx +386 -0
- package/src/segmented-pill.tsx +32 -9
- package/src/select.tsx +646 -0
- package/src/sortable.tsx +315 -1
- package/src/sparkline.tsx +416 -0
- package/src/stat-card.tsx +376 -0
- package/src/stat-row.tsx +108 -70
- package/src/stat-strip.tsx +327 -0
- package/src/status.tsx +215 -0
- package/src/styles.css +1586 -2
- package/src/tabs.tsx +206 -25
- package/src/task-card.tsx +610 -0
- package/src/thread/__tests__/comment-body-hostile.test.tsx +331 -0
- package/src/thread/__tests__/comment-tree.test.ts +151 -0
- package/src/thread/__tests__/emoji.test.ts +187 -0
- package/src/thread/__tests__/fixtures/thread-fixture.ts +235 -0
- package/src/thread/__tests__/link-preview-source.test.ts +120 -0
- package/src/thread/__tests__/link-preview.test.tsx +600 -0
- package/src/thread/__tests__/markdown-format.test.ts +82 -0
- package/src/thread/__tests__/markdown-spec.test.ts +469 -0
- package/src/thread/__tests__/relative-time.test.ts +71 -0
- package/src/thread/__tests__/rich-composer.test.tsx +799 -0
- package/src/thread/__tests__/scroll-caret.test.ts +58 -0
- package/src/thread/__tests__/suggestion-list.test.tsx +648 -0
- package/src/thread/__tests__/thread-scroll-ownership.test.tsx +88 -0
- package/src/thread/__tests__/thread.test.tsx +662 -0
- package/src/thread/__tests__/use-attachments.test.tsx +679 -0
- package/src/thread/actions.tsx +196 -0
- package/src/thread/attachments.tsx +1071 -0
- package/src/thread/comment-body.tsx +148 -0
- package/src/thread/comment-tree.ts +182 -0
- package/src/thread/comment.tsx +915 -0
- package/src/thread/composer-footer.tsx +125 -0
- package/src/thread/composer.tsx +319 -0
- package/src/thread/emoji.ts +283 -0
- package/src/thread/index.ts +153 -0
- package/src/thread/link-preview.tsx +341 -0
- package/src/thread/markdown-format.ts +155 -0
- package/src/thread/markdown-spec.ts +754 -0
- package/src/thread/rail.tsx +372 -0
- package/src/thread/reactions.tsx +415 -0
- package/src/thread/relative-time.tsx +94 -0
- package/src/thread/rich-composer-impl.tsx +1601 -0
- package/src/thread/rich-composer.tsx +195 -0
- package/src/thread/scroll-caret.ts +37 -0
- package/src/thread/suggestion-list.tsx +182 -0
- package/src/thread/thread.tsx +718 -0
- package/src/thread/types.ts +218 -0
- package/src/thread/use-attachments.ts +598 -0
- package/src/thread/use-now.ts +73 -0
- package/src/thread/use-thread.ts +316 -0
- package/src/tokens.css +121 -0
- package/src/tooltip.tsx +297 -0
|
@@ -0,0 +1,679 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Composer attachments — ADR-151 D1, D3 (client-side courtesy caps) and D5.
|
|
3
|
+
*
|
|
4
|
+
* The tests that matter here are AC#6 and AC#7, and they are a pair: a paste at
|
|
5
|
+
* or above 2,500 characters becomes a file **and can be turned back**, and a
|
|
6
|
+
* paste below it is untouched. Threshold behaviour is exactly the kind of thing
|
|
7
|
+
* that ships subtly wrong — off by one, or intercepting but losing the text —
|
|
8
|
+
* so both directions and both boundaries are pinned, and the restore is
|
|
9
|
+
* asserted byte for byte rather than "roughly the same".
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
13
|
+
import { act, cleanup, fireEvent, render, renderHook, screen, waitFor } from '@testing-library/react'
|
|
14
|
+
import userEvent from '@testing-library/user-event'
|
|
15
|
+
import {
|
|
16
|
+
GIF_SEARCH_DEBOUNCE_MS,
|
|
17
|
+
PASTE_ATTACHMENT_THRESHOLD,
|
|
18
|
+
pastedTextFilename,
|
|
19
|
+
shouldAttachPaste,
|
|
20
|
+
useAttachmentUrl,
|
|
21
|
+
useAttachments,
|
|
22
|
+
type AttachmentTicketLike,
|
|
23
|
+
} from '../use-attachments'
|
|
24
|
+
import { AttachmentGif, AttachmentImage, AttachmentTray, GifPicker } from '../attachments'
|
|
25
|
+
|
|
26
|
+
afterEach(cleanup)
|
|
27
|
+
|
|
28
|
+
function ticket(over: Partial<AttachmentTicketLike> = {}): AttachmentTicketLike {
|
|
29
|
+
return {
|
|
30
|
+
id: 'tup_1',
|
|
31
|
+
kind: 'file',
|
|
32
|
+
name: 'pasted-text-1.txt',
|
|
33
|
+
sizeBytes: 10,
|
|
34
|
+
mime: 'text/plain',
|
|
35
|
+
aspectRatio: null,
|
|
36
|
+
...over,
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** A resolved uploader that records the Blob it was handed. */
|
|
41
|
+
function recordingUploader() {
|
|
42
|
+
const seen: { blob: Blob; filename: string }[] = []
|
|
43
|
+
const upload = vi.fn(async (blob: Blob, opts: { filename: string }) => {
|
|
44
|
+
seen.push({ blob, filename: opts.filename })
|
|
45
|
+
return ticket({ id: `tup_${String(seen.length)}`, name: opts.filename, sizeBytes: blob.size })
|
|
46
|
+
})
|
|
47
|
+
return { upload, seen }
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* jsdom has no media queries, so `matchMedia` is stubbed rather than mocked
|
|
52
|
+
* away: the component subscribes to `change`, and a stub that returned a bare
|
|
53
|
+
* boolean would let a regression in the subscription pass unnoticed.
|
|
54
|
+
*/
|
|
55
|
+
function stubReducedMotion(reduce: boolean): () => void {
|
|
56
|
+
const listeners = new Set<(e: MediaQueryListEvent) => void>()
|
|
57
|
+
const original = window.matchMedia
|
|
58
|
+
Object.defineProperty(window, 'matchMedia', {
|
|
59
|
+
configurable: true,
|
|
60
|
+
writable: true,
|
|
61
|
+
value: (queryText: string) => ({
|
|
62
|
+
matches: reduce && queryText.includes('prefers-reduced-motion'),
|
|
63
|
+
media: queryText,
|
|
64
|
+
addEventListener: (_: string, fn: (e: MediaQueryListEvent) => void) => listeners.add(fn),
|
|
65
|
+
removeEventListener: (_: string, fn: (e: MediaQueryListEvent) => void) =>
|
|
66
|
+
listeners.delete(fn),
|
|
67
|
+
onchange: null,
|
|
68
|
+
addListener: () => undefined,
|
|
69
|
+
removeListener: () => undefined,
|
|
70
|
+
dispatchEvent: () => false,
|
|
71
|
+
}),
|
|
72
|
+
})
|
|
73
|
+
return () => {
|
|
74
|
+
Object.defineProperty(window, 'matchMedia', {
|
|
75
|
+
configurable: true,
|
|
76
|
+
writable: true,
|
|
77
|
+
value: original,
|
|
78
|
+
})
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
describe('D5 — the paste threshold, in both directions', () => {
|
|
83
|
+
it('AC#7: a paste below 2,500 characters is not intercepted at all', async () => {
|
|
84
|
+
const { upload } = recordingUploader()
|
|
85
|
+
const { result } = renderHook(() => useAttachments({ upload }))
|
|
86
|
+
|
|
87
|
+
let intercepted = true
|
|
88
|
+
act(() => {
|
|
89
|
+
intercepted = result.current.addPastedText('x'.repeat(PASTE_ATTACHMENT_THRESHOLD - 1))
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
// `false` is the whole contract: the caller does NOT preventDefault, so
|
|
93
|
+
// ADR-148 D8's paste handling runs exactly as it did before this ADR.
|
|
94
|
+
expect(intercepted).toBe(false)
|
|
95
|
+
expect(upload).not.toHaveBeenCalled()
|
|
96
|
+
expect(result.current.attachments).toHaveLength(0)
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
it('AC#6: a paste at exactly 2,500 characters becomes an attachment', async () => {
|
|
100
|
+
const { upload, seen } = recordingUploader()
|
|
101
|
+
const { result } = renderHook(() => useAttachments({ upload }))
|
|
102
|
+
const text = 'y'.repeat(PASTE_ATTACHMENT_THRESHOLD)
|
|
103
|
+
|
|
104
|
+
act(() => {
|
|
105
|
+
expect(result.current.addPastedText(text)).toBe(true)
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
// The boundary is inclusive, and it is where it says it is.
|
|
109
|
+
expect(shouldAttachPaste(text)).toBe(true)
|
|
110
|
+
expect(shouldAttachPaste(text.slice(1))).toBe(false)
|
|
111
|
+
|
|
112
|
+
await waitFor(() => {
|
|
113
|
+
expect(result.current.attachments[0]?.state).toBe('ready')
|
|
114
|
+
})
|
|
115
|
+
// Derived and honest — never a fabricated filename.
|
|
116
|
+
expect(result.current.attachments[0]?.name).toBe(pastedTextFilename(1))
|
|
117
|
+
// It went through the FILE path: a Blob, the same uploader, no second
|
|
118
|
+
// mechanism. That is the whole reason D5 is cheap.
|
|
119
|
+
expect(seen[0]?.blob).toBeInstanceOf(Blob)
|
|
120
|
+
expect(seen[0]?.blob.type).toBe('text/plain')
|
|
121
|
+
expect(await seen[0]?.blob.text()).toBe(text)
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
it('AC#6: "Insert as text instead" restores the paste byte for byte', async () => {
|
|
125
|
+
const { upload } = recordingUploader()
|
|
126
|
+
const { result } = renderHook(() => useAttachments({ upload }))
|
|
127
|
+
// Deliberately awkward content: a trailing newline, a tab, a non-BMP
|
|
128
|
+
// codepoint, and CRLF. "Byte for byte" is only a claim worth testing on
|
|
129
|
+
// text that a lossy round trip would visibly damage.
|
|
130
|
+
const text = `${'z'.repeat(PASTE_ATTACHMENT_THRESHOLD)}\r\n\tEnd \u{1F600}\n`
|
|
131
|
+
|
|
132
|
+
act(() => {
|
|
133
|
+
result.current.addPastedText(text)
|
|
134
|
+
})
|
|
135
|
+
await waitFor(() => {
|
|
136
|
+
expect(result.current.attachments[0]?.state).toBe('ready')
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
const localId = result.current.attachments[0]?.localId ?? ''
|
|
140
|
+
let restored: string | null = null
|
|
141
|
+
act(() => {
|
|
142
|
+
restored = result.current.insertAsText(localId)
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
expect(restored).toBe(text)
|
|
146
|
+
expect(result.current.attachments).toHaveLength(0)
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
it('names successive pastes in order', async () => {
|
|
150
|
+
const { upload } = recordingUploader()
|
|
151
|
+
const { result } = renderHook(() => useAttachments({ upload }))
|
|
152
|
+
const text = 'q'.repeat(PASTE_ATTACHMENT_THRESHOLD)
|
|
153
|
+
|
|
154
|
+
act(() => {
|
|
155
|
+
result.current.addPastedText(text)
|
|
156
|
+
result.current.addPastedText(text)
|
|
157
|
+
})
|
|
158
|
+
await waitFor(() => {
|
|
159
|
+
expect(result.current.attachments).toHaveLength(2)
|
|
160
|
+
})
|
|
161
|
+
expect(result.current.attachments.map((a) => a.name)).toEqual([
|
|
162
|
+
pastedTextFilename(1),
|
|
163
|
+
pastedTextFilename(2),
|
|
164
|
+
])
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
it('leaves an over-large paste as prose rather than dropping it', async () => {
|
|
168
|
+
// The refusal path must not eat the text: `false` means the caller lets the
|
|
169
|
+
// paste land in the body, which is the honest failure for "too big to
|
|
170
|
+
// attach".
|
|
171
|
+
const { upload } = recordingUploader()
|
|
172
|
+
const { result } = renderHook(() =>
|
|
173
|
+
useAttachments({ upload, maxFileBytes: PASTE_ATTACHMENT_THRESHOLD - 1 }),
|
|
174
|
+
)
|
|
175
|
+
act(() => {
|
|
176
|
+
expect(result.current.addPastedText('w'.repeat(PASTE_ATTACHMENT_THRESHOLD))).toBe(false)
|
|
177
|
+
})
|
|
178
|
+
expect(result.current.attachments).toHaveLength(0)
|
|
179
|
+
expect(upload).not.toHaveBeenCalled()
|
|
180
|
+
})
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
describe('D1 — each file is its own unit', () => {
|
|
184
|
+
it('reports per-file progress and hands back tickets in order', async () => {
|
|
185
|
+
const progressSinks: ((f: number) => void)[] = []
|
|
186
|
+
const upload = vi.fn(
|
|
187
|
+
(blob: Blob, opts: { filename: string; onProgress: (f: number) => void }) => {
|
|
188
|
+
progressSinks.push(opts.onProgress)
|
|
189
|
+
return new Promise<AttachmentTicketLike>((resolve) => {
|
|
190
|
+
setTimeout(() => {
|
|
191
|
+
resolve(ticket({ id: `tup_${opts.filename}`, name: opts.filename, sizeBytes: blob.size }))
|
|
192
|
+
}, 0)
|
|
193
|
+
})
|
|
194
|
+
},
|
|
195
|
+
)
|
|
196
|
+
const { result } = renderHook(() => useAttachments({ upload }))
|
|
197
|
+
|
|
198
|
+
act(() => {
|
|
199
|
+
result.current.addFiles([
|
|
200
|
+
new File(['a'], 'a.png', { type: 'image/png' }),
|
|
201
|
+
new File(['bb'], 'b.png', { type: 'image/png' }),
|
|
202
|
+
])
|
|
203
|
+
})
|
|
204
|
+
expect(result.current.attachments).toHaveLength(2)
|
|
205
|
+
expect(result.current.isUploading).toBe(true)
|
|
206
|
+
|
|
207
|
+
act(() => {
|
|
208
|
+
progressSinks[0]?.(0.5)
|
|
209
|
+
})
|
|
210
|
+
expect(result.current.attachments[0]?.progress).toBe(0.5)
|
|
211
|
+
// Progress on one file does not touch the other.
|
|
212
|
+
expect(result.current.attachments[1]?.progress).toBe(0)
|
|
213
|
+
|
|
214
|
+
await waitFor(() => {
|
|
215
|
+
expect(result.current.isUploading).toBe(false)
|
|
216
|
+
})
|
|
217
|
+
expect(result.current.ticketIds).toEqual(['tup_a.png', 'tup_b.png'])
|
|
218
|
+
})
|
|
219
|
+
|
|
220
|
+
it('cancel aborts the transfer, not just the chip', async () => {
|
|
221
|
+
let captured: AbortSignal | undefined
|
|
222
|
+
const upload = vi.fn(
|
|
223
|
+
(_blob: Blob, opts: { signal: AbortSignal }) =>
|
|
224
|
+
new Promise<AttachmentTicketLike>((_resolve, reject) => {
|
|
225
|
+
captured = opts.signal
|
|
226
|
+
opts.signal.addEventListener('abort', () => {
|
|
227
|
+
reject(new DOMException('aborted', 'AbortError'))
|
|
228
|
+
})
|
|
229
|
+
}),
|
|
230
|
+
)
|
|
231
|
+
const { result } = renderHook(() => useAttachments({ upload }))
|
|
232
|
+
|
|
233
|
+
act(() => {
|
|
234
|
+
result.current.addFiles([new File(['a'], 'a.png', { type: 'image/png' })])
|
|
235
|
+
})
|
|
236
|
+
const localId = result.current.attachments[0]?.localId ?? ''
|
|
237
|
+
act(() => {
|
|
238
|
+
result.current.remove(localId)
|
|
239
|
+
})
|
|
240
|
+
|
|
241
|
+
expect(captured?.aborted).toBe(true)
|
|
242
|
+
// And an abort is not a failure: no red chip is left behind.
|
|
243
|
+
await waitFor(() => {
|
|
244
|
+
expect(result.current.attachments).toHaveLength(0)
|
|
245
|
+
})
|
|
246
|
+
})
|
|
247
|
+
|
|
248
|
+
it('skips a refused file and keeps going, rather than dropping the rest silently', async () => {
|
|
249
|
+
// `break` here would upload #1, report #2, and make #3-#5 vanish with no
|
|
250
|
+
// message — D3 requires every refusal be named and shown.
|
|
251
|
+
const { upload } = recordingUploader()
|
|
252
|
+
const errors: string[] = []
|
|
253
|
+
const { result } = renderHook(() =>
|
|
254
|
+
useAttachments({
|
|
255
|
+
upload,
|
|
256
|
+
maxFileBytes: 10,
|
|
257
|
+
onError: (m) => {
|
|
258
|
+
errors.push(m)
|
|
259
|
+
},
|
|
260
|
+
}),
|
|
261
|
+
)
|
|
262
|
+
act(() => {
|
|
263
|
+
result.current.addFiles([
|
|
264
|
+
new File(['ok'], 'a.png', { type: 'image/png' }),
|
|
265
|
+
new File(['way too long for the cap'], 'big.png', { type: 'image/png' }),
|
|
266
|
+
new File(['ok'], 'c.png', { type: 'image/png' }),
|
|
267
|
+
])
|
|
268
|
+
})
|
|
269
|
+
await waitFor(() => {
|
|
270
|
+
expect(result.current.attachments).toHaveLength(2)
|
|
271
|
+
})
|
|
272
|
+
expect(result.current.attachments.map((a) => a.name)).toEqual(['a.png', 'c.png'])
|
|
273
|
+
expect(errors).toHaveLength(1)
|
|
274
|
+
expect(errors[0]).toContain('big.png')
|
|
275
|
+
})
|
|
276
|
+
|
|
277
|
+
it('refuses the eleventh file in a single drop', async () => {
|
|
278
|
+
const { upload } = recordingUploader()
|
|
279
|
+
const errors: string[] = []
|
|
280
|
+
const { result } = renderHook(() =>
|
|
281
|
+
useAttachments({
|
|
282
|
+
upload,
|
|
283
|
+
onError: (m) => {
|
|
284
|
+
errors.push(m)
|
|
285
|
+
},
|
|
286
|
+
}),
|
|
287
|
+
)
|
|
288
|
+
|
|
289
|
+
const files = Array.from(
|
|
290
|
+
{ length: 11 },
|
|
291
|
+
(_, i) => new File(['x'], `f${String(i)}.png`, { type: 'image/png' }),
|
|
292
|
+
)
|
|
293
|
+
act(() => {
|
|
294
|
+
result.current.addFiles(files)
|
|
295
|
+
})
|
|
296
|
+
|
|
297
|
+
// The ceiling has to see the batch it is inside — eleven at once must stop
|
|
298
|
+
// at ten, not start all eleven against a list that has not re-rendered.
|
|
299
|
+
expect(result.current.attachments).toHaveLength(10)
|
|
300
|
+
expect(errors[0]).toContain('up to 10')
|
|
301
|
+
})
|
|
302
|
+
|
|
303
|
+
it('surfaces the server’s own message on a refusal', async () => {
|
|
304
|
+
const upload = vi.fn(() => Promise.reject(new Error('Files can be up to 25 MB.')))
|
|
305
|
+
const errors: string[] = []
|
|
306
|
+
const { result } = renderHook(() =>
|
|
307
|
+
useAttachments({
|
|
308
|
+
upload,
|
|
309
|
+
onError: (m) => {
|
|
310
|
+
errors.push(m)
|
|
311
|
+
},
|
|
312
|
+
}),
|
|
313
|
+
)
|
|
314
|
+
act(() => {
|
|
315
|
+
result.current.addFiles([new File(['x'], 'big.png', { type: 'image/png' })])
|
|
316
|
+
})
|
|
317
|
+
await waitFor(() => {
|
|
318
|
+
expect(result.current.attachments[0]?.state).toBe('failed')
|
|
319
|
+
})
|
|
320
|
+
// Not re-worded here: the server already said it in the product's voice.
|
|
321
|
+
expect(result.current.attachments[0]?.error).toBe('Files can be up to 25 MB.')
|
|
322
|
+
expect(errors).toEqual(['Files can be up to 25 MB.'])
|
|
323
|
+
})
|
|
324
|
+
})
|
|
325
|
+
|
|
326
|
+
describe('the tray', () => {
|
|
327
|
+
it('offers the undo only on a pasted chip, and only when a handler exists', async () => {
|
|
328
|
+
const onInsertAsText = vi.fn()
|
|
329
|
+
render(
|
|
330
|
+
<AttachmentTray
|
|
331
|
+
attachments={[
|
|
332
|
+
{
|
|
333
|
+
localId: 'a',
|
|
334
|
+
name: 'pasted-text-1.txt',
|
|
335
|
+
sizeBytes: 3000,
|
|
336
|
+
kind: 'file',
|
|
337
|
+
state: 'ready',
|
|
338
|
+
progress: 1,
|
|
339
|
+
ticketId: 'tup_1',
|
|
340
|
+
pastedText: 'hello',
|
|
341
|
+
},
|
|
342
|
+
{
|
|
343
|
+
localId: 'b',
|
|
344
|
+
name: 'shot.png',
|
|
345
|
+
sizeBytes: 1200,
|
|
346
|
+
kind: 'image',
|
|
347
|
+
state: 'ready',
|
|
348
|
+
progress: 1,
|
|
349
|
+
ticketId: 'tup_2',
|
|
350
|
+
},
|
|
351
|
+
]}
|
|
352
|
+
onRemove={vi.fn()}
|
|
353
|
+
onInsertAsText={onInsertAsText}
|
|
354
|
+
/>,
|
|
355
|
+
)
|
|
356
|
+
|
|
357
|
+
// One chip has something to restore; the other does not, so it gets no
|
|
358
|
+
// action rather than a disabled one.
|
|
359
|
+
const restore = screen.getAllByRole('button', { name: 'Insert as text instead' })
|
|
360
|
+
expect(restore).toHaveLength(1)
|
|
361
|
+
|
|
362
|
+
await userEvent.click(restore[0] as HTMLElement)
|
|
363
|
+
expect(onInsertAsText).toHaveBeenCalledWith('a')
|
|
364
|
+
})
|
|
365
|
+
|
|
366
|
+
it('shows a CHARACTER count on a pasted chip, not a byte size (D5)', () => {
|
|
367
|
+
render(
|
|
368
|
+
<AttachmentTray
|
|
369
|
+
attachments={[
|
|
370
|
+
{
|
|
371
|
+
localId: 'a',
|
|
372
|
+
name: 'pasted-text-1.txt',
|
|
373
|
+
sizeBytes: 3000,
|
|
374
|
+
kind: 'file',
|
|
375
|
+
state: 'ready',
|
|
376
|
+
progress: 1,
|
|
377
|
+
ticketId: 'tup_1',
|
|
378
|
+
pastedText: 'x'.repeat(2600),
|
|
379
|
+
charCount: 2600,
|
|
380
|
+
},
|
|
381
|
+
]}
|
|
382
|
+
onRemove={vi.fn()}
|
|
383
|
+
/>,
|
|
384
|
+
)
|
|
385
|
+
// D5 says "with the character count shown on the chip" — the person pasted
|
|
386
|
+
// characters, and for anything non-ASCII a byte size is a different number.
|
|
387
|
+
expect(screen.getByText('2,600 characters')).toBeInTheDocument()
|
|
388
|
+
expect(screen.queryByText('2.9 KB')).toBeNull()
|
|
389
|
+
})
|
|
390
|
+
|
|
391
|
+
it('carries the character count through the paste path end to end', async () => {
|
|
392
|
+
const { upload } = recordingUploader()
|
|
393
|
+
const { result } = renderHook(() => useAttachments({ upload }))
|
|
394
|
+
const text = 'e'.repeat(PASTE_ATTACHMENT_THRESHOLD + 17)
|
|
395
|
+
act(() => {
|
|
396
|
+
result.current.addPastedText(text)
|
|
397
|
+
})
|
|
398
|
+
await waitFor(() => {
|
|
399
|
+
expect(result.current.attachments[0]?.state).toBe('ready')
|
|
400
|
+
})
|
|
401
|
+
expect(result.current.attachments[0]?.charCount).toBe(text.length)
|
|
402
|
+
})
|
|
403
|
+
|
|
404
|
+
it('exposes upload progress as a real progressbar', () => {
|
|
405
|
+
render(
|
|
406
|
+
<AttachmentTray
|
|
407
|
+
attachments={[
|
|
408
|
+
{
|
|
409
|
+
localId: 'a',
|
|
410
|
+
name: 'shot.png',
|
|
411
|
+
sizeBytes: 1200,
|
|
412
|
+
kind: 'image',
|
|
413
|
+
state: 'uploading',
|
|
414
|
+
progress: 0.42,
|
|
415
|
+
},
|
|
416
|
+
]}
|
|
417
|
+
onRemove={vi.fn()}
|
|
418
|
+
/>,
|
|
419
|
+
)
|
|
420
|
+
const bar = screen.getByRole('progressbar', { name: 'Uploading shot.png' })
|
|
421
|
+
expect(bar).toHaveAttribute('aria-valuenow', '42')
|
|
422
|
+
})
|
|
423
|
+
|
|
424
|
+
it('labels the remove control by what it will do', () => {
|
|
425
|
+
render(
|
|
426
|
+
<AttachmentTray
|
|
427
|
+
attachments={[
|
|
428
|
+
{
|
|
429
|
+
localId: 'a',
|
|
430
|
+
name: 'shot.png',
|
|
431
|
+
sizeBytes: 1200,
|
|
432
|
+
kind: 'image',
|
|
433
|
+
state: 'uploading',
|
|
434
|
+
progress: 0.1,
|
|
435
|
+
},
|
|
436
|
+
]}
|
|
437
|
+
onRemove={vi.fn()}
|
|
438
|
+
/>,
|
|
439
|
+
)
|
|
440
|
+
// Cancelling an upload and removing a finished file are different acts, and
|
|
441
|
+
// a screen reader should hear which one this is.
|
|
442
|
+
expect(screen.getByRole('button', { name: 'Cancel shot.png' })).toBeInTheDocument()
|
|
443
|
+
})
|
|
444
|
+
})
|
|
445
|
+
|
|
446
|
+
describe('the render-loop guard', () => {
|
|
447
|
+
/**
|
|
448
|
+
* The failure this pins does not look like a failure.
|
|
449
|
+
*
|
|
450
|
+
* A host writing `resolveAttachmentUrl={(id) => api.attachmentUrl(brandId, …, id)}`
|
|
451
|
+
* — the obvious way to write it — hands over a NEW function identity on every
|
|
452
|
+
* render. If that identity were an effect dependency, the effect would re-run,
|
|
453
|
+
* set state, re-render and re-run forever: a hung tab, or a test the runner
|
|
454
|
+
* reports as an out-of-memory "environment" problem rather than as a bug.
|
|
455
|
+
*
|
|
456
|
+
* So both assertions are about CALL COUNT under deliberate identity churn,
|
|
457
|
+
* not about the resolved value.
|
|
458
|
+
*/
|
|
459
|
+
it('resolves once even when the resolver identity changes every render', async () => {
|
|
460
|
+
// Typed to take the id, because the hook passes one — a zero-arg mock made
|
|
461
|
+
// `resolve(id)` a type error while the production signature was correct.
|
|
462
|
+
const resolve = vi.fn((_id: string) => Promise.resolve('https://example.test/a.png'))
|
|
463
|
+
let renders = 0
|
|
464
|
+
|
|
465
|
+
function Probe({ tick }: { tick: number }) {
|
|
466
|
+
renders += 1
|
|
467
|
+
// A fresh closure on every render, exactly as a host would write it.
|
|
468
|
+
const { url } = useAttachmentUrl('att_1', (id) => resolve(id))
|
|
469
|
+
return <span data-testid="url">{`${String(tick)}:${url ?? ''}`}</span>
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
const { rerender } = render(<Probe tick={0} />)
|
|
473
|
+
await waitFor(() => {
|
|
474
|
+
expect(screen.getByTestId('url').textContent).toContain('example.test')
|
|
475
|
+
})
|
|
476
|
+
|
|
477
|
+
const after = renders
|
|
478
|
+
rerender(<Probe tick={1} />)
|
|
479
|
+
rerender(<Probe tick={2} />)
|
|
480
|
+
await waitFor(() => {
|
|
481
|
+
expect(screen.getByTestId('url').textContent).toContain('2:')
|
|
482
|
+
})
|
|
483
|
+
|
|
484
|
+
// Three more renders, still one fetch — and the render count is bounded,
|
|
485
|
+
// which is what "no loop" actually means.
|
|
486
|
+
expect(resolve).toHaveBeenCalledTimes(1)
|
|
487
|
+
expect(renders).toBeLessThanOrEqual(after + 4)
|
|
488
|
+
})
|
|
489
|
+
|
|
490
|
+
it('re-resolves when the ATTACHMENT changes, which is the thing that identifies a fetch', async () => {
|
|
491
|
+
const resolve = vi.fn((id: string) => Promise.resolve(`https://example.test/${id}`))
|
|
492
|
+
|
|
493
|
+
function Probe({ id }: { id: string }) {
|
|
494
|
+
const { url } = useAttachmentUrl(id, (next) => resolve(next))
|
|
495
|
+
return <span data-testid="url">{url ?? ''}</span>
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
const { rerender } = render(<Probe id="att_1" />)
|
|
499
|
+
await waitFor(() => {
|
|
500
|
+
expect(screen.getByTestId('url').textContent).toBe('https://example.test/att_1')
|
|
501
|
+
})
|
|
502
|
+
rerender(<Probe id="att_2" />)
|
|
503
|
+
await waitFor(() => {
|
|
504
|
+
expect(screen.getByTestId('url').textContent).toBe('https://example.test/att_2')
|
|
505
|
+
})
|
|
506
|
+
expect(resolve).toHaveBeenCalledTimes(2)
|
|
507
|
+
})
|
|
508
|
+
|
|
509
|
+
it('renders the placeholder, never a collapsed box, when the URL cannot be had', async () => {
|
|
510
|
+
render(
|
|
511
|
+
<AttachmentGif
|
|
512
|
+
attachment={{ id: 'att_x', kind: 'gif', name: 'nope.mp4', aspectRatio: 2 }}
|
|
513
|
+
resolveUrl={() => Promise.resolve(null)}
|
|
514
|
+
/>,
|
|
515
|
+
)
|
|
516
|
+
// The placeholder keeps the DECLARED ratio, so the comment's layout is the
|
|
517
|
+
// same whether the bytes arrived or not.
|
|
518
|
+
await waitFor(() => {
|
|
519
|
+
const box = document.querySelector('span[style*="aspect-ratio"]')
|
|
520
|
+
expect(box).not.toBeNull()
|
|
521
|
+
// jsdom normalizes the shorthand to `<w> / <h>`; what matters is that the
|
|
522
|
+
// box declares the ratio at all, and that it is the one the attachment
|
|
523
|
+
// carried rather than the 16/10 fallback.
|
|
524
|
+
expect((box as HTMLElement).style.aspectRatio.replace(/\s/g, '')).toBe('2/1')
|
|
525
|
+
expect(box?.querySelector('svg')).not.toBeNull()
|
|
526
|
+
})
|
|
527
|
+
})
|
|
528
|
+
})
|
|
529
|
+
|
|
530
|
+
describe('AC#5 — a GIF under prefers-reduced-motion', () => {
|
|
531
|
+
const GIF = {
|
|
532
|
+
id: 'att_g',
|
|
533
|
+
kind: 'gif' as const,
|
|
534
|
+
name: 'a cat',
|
|
535
|
+
aspectRatio: 16 / 9,
|
|
536
|
+
}
|
|
537
|
+
const resolveUrl = () => Promise.resolve('https://media.test/cat.mp4')
|
|
538
|
+
|
|
539
|
+
it('shows a still frame with an explicit play control', async () => {
|
|
540
|
+
const restore = stubReducedMotion(true)
|
|
541
|
+
try {
|
|
542
|
+
render(<AttachmentGif attachment={GIF} resolveUrl={resolveUrl} />)
|
|
543
|
+
const video = await waitFor(() => {
|
|
544
|
+
const found = document.querySelector('video')
|
|
545
|
+
expect(found).not.toBeNull()
|
|
546
|
+
return found as HTMLVideoElement
|
|
547
|
+
})
|
|
548
|
+
// Not playing, and it says so in the DOM rather than only in a ref.
|
|
549
|
+
expect(video.hasAttribute('autoplay')).toBe(false)
|
|
550
|
+
// The way OUT of the still is an explicit, labelled control — the point
|
|
551
|
+
// of the decision, not decoration.
|
|
552
|
+
expect(screen.getByRole('button', { name: 'Play a cat' })).toBeInTheDocument()
|
|
553
|
+
} finally {
|
|
554
|
+
restore()
|
|
555
|
+
}
|
|
556
|
+
})
|
|
557
|
+
|
|
558
|
+
it('autoplays, with no play control, when motion is not restricted', async () => {
|
|
559
|
+
const restore = stubReducedMotion(false)
|
|
560
|
+
try {
|
|
561
|
+
render(<AttachmentGif attachment={GIF} resolveUrl={resolveUrl} />)
|
|
562
|
+
const video = await waitFor(() => {
|
|
563
|
+
const found = document.querySelector('video')
|
|
564
|
+
expect(found).not.toBeNull()
|
|
565
|
+
return found as HTMLVideoElement
|
|
566
|
+
})
|
|
567
|
+
expect(video.hasAttribute('autoplay')).toBe(true)
|
|
568
|
+
expect(screen.queryByRole('button', { name: 'Play a cat' })).toBeNull()
|
|
569
|
+
} finally {
|
|
570
|
+
restore()
|
|
571
|
+
}
|
|
572
|
+
})
|
|
573
|
+
})
|
|
574
|
+
|
|
575
|
+
describe('open edge #7 - an uploaded GIF under prefers-reduced-motion', () => {
|
|
576
|
+
it('shows one captured frame until the person explicitly plays the live image', async () => {
|
|
577
|
+
const restoreMotion = stubReducedMotion(true)
|
|
578
|
+
const drawImage = vi.fn()
|
|
579
|
+
const context = { drawImage } as unknown as CanvasRenderingContext2D
|
|
580
|
+
const getContext = vi
|
|
581
|
+
.spyOn(HTMLCanvasElement.prototype, 'getContext')
|
|
582
|
+
.mockImplementation(() => context)
|
|
583
|
+
|
|
584
|
+
try {
|
|
585
|
+
render(
|
|
586
|
+
<AttachmentImage
|
|
587
|
+
attachment={{
|
|
588
|
+
id: 'att_uploaded_gif',
|
|
589
|
+
kind: 'image',
|
|
590
|
+
mime: 'image/gif',
|
|
591
|
+
name: 'uploaded-loop.gif',
|
|
592
|
+
url: 'https://uploads.test/uploaded-loop.gif',
|
|
593
|
+
aspectRatio: 16 / 9,
|
|
594
|
+
}}
|
|
595
|
+
/>,
|
|
596
|
+
)
|
|
597
|
+
|
|
598
|
+
const image = screen.getByAltText('uploaded-loop.gif') as HTMLImageElement
|
|
599
|
+
Object.defineProperties(image, {
|
|
600
|
+
naturalWidth: { configurable: true, value: 320 },
|
|
601
|
+
naturalHeight: { configurable: true, value: 180 },
|
|
602
|
+
})
|
|
603
|
+
fireEvent.load(image)
|
|
604
|
+
|
|
605
|
+
const canvas = document.querySelector('canvas')
|
|
606
|
+
expect(canvas).not.toBeNull()
|
|
607
|
+
await waitFor(() => {
|
|
608
|
+
expect(drawImage).toHaveBeenCalledTimes(1)
|
|
609
|
+
expect(canvas).not.toHaveAttribute('hidden')
|
|
610
|
+
expect(image.style.visibility).toBe('hidden')
|
|
611
|
+
})
|
|
612
|
+
expect(drawImage).toHaveBeenCalledWith(image, 0, 0, 320, 180)
|
|
613
|
+
expect(canvas).toHaveProperty('width', 320)
|
|
614
|
+
expect(canvas).toHaveProperty('height', 180)
|
|
615
|
+
|
|
616
|
+
// A second load notification must not take a second snapshot. The
|
|
617
|
+
// captured still is stable rather than following the animation.
|
|
618
|
+
fireEvent.load(image)
|
|
619
|
+
expect(drawImage).toHaveBeenCalledTimes(1)
|
|
620
|
+
|
|
621
|
+
fireEvent.click(screen.getByRole('button', { name: 'Play uploaded-loop.gif' }))
|
|
622
|
+
expect(screen.queryByRole('button', { name: 'Play uploaded-loop.gif' })).toBeNull()
|
|
623
|
+
expect(canvas).toHaveAttribute('hidden')
|
|
624
|
+
expect(image.style.visibility).toBe('visible')
|
|
625
|
+
} finally {
|
|
626
|
+
getContext.mockRestore()
|
|
627
|
+
restoreMotion()
|
|
628
|
+
}
|
|
629
|
+
})
|
|
630
|
+
})
|
|
631
|
+
|
|
632
|
+
describe('D4 — the GIF picker', () => {
|
|
633
|
+
it('debounces, so typing six characters costs one search and not six', async () => {
|
|
634
|
+
vi.useFakeTimers()
|
|
635
|
+
try {
|
|
636
|
+
const search = vi.fn(() => Promise.resolve([]))
|
|
637
|
+
const { rerender } = render(<GifPicker search={search} onPick={vi.fn()} />)
|
|
638
|
+
|
|
639
|
+
// Simulate the keystrokes as controlled-input updates rather than through
|
|
640
|
+
// userEvent, which does not cooperate with fake timers.
|
|
641
|
+
for (const q of ['s', 'sh', 'shi', 'ship', 'shipp', 'shippi']) {
|
|
642
|
+
const field = screen.getByRole('textbox', { name: 'Search GIFs' })
|
|
643
|
+
act(() => {
|
|
644
|
+
fireEvent.change(field, { target: { value: q } })
|
|
645
|
+
})
|
|
646
|
+
}
|
|
647
|
+
// Nothing has gone out yet — the debounce is the whole point, because
|
|
648
|
+
// Giphy's free tier is 100 requests/hour.
|
|
649
|
+
expect(search).not.toHaveBeenCalled()
|
|
650
|
+
|
|
651
|
+
await act(async () => {
|
|
652
|
+
vi.advanceTimersByTime(GIF_SEARCH_DEBOUNCE_MS + 10)
|
|
653
|
+
})
|
|
654
|
+
expect(search).toHaveBeenCalledTimes(1)
|
|
655
|
+
expect(search).toHaveBeenCalledWith('shippi')
|
|
656
|
+
rerender(<GifPicker search={search} onPick={vi.fn()} />)
|
|
657
|
+
} finally {
|
|
658
|
+
vi.useRealTimers()
|
|
659
|
+
}
|
|
660
|
+
})
|
|
661
|
+
|
|
662
|
+
it('carries the attribution Giphy’s terms require', () => {
|
|
663
|
+
render(<GifPicker search={() => Promise.resolve([])} onPick={vi.fn()} />)
|
|
664
|
+
expect(screen.getByText('Powered by GIPHY')).toBeInTheDocument()
|
|
665
|
+
})
|
|
666
|
+
|
|
667
|
+
it('says "switched off" differently from "nothing matched"', async () => {
|
|
668
|
+
const failing = () => Promise.reject(new Error('503'))
|
|
669
|
+
render(<GifPicker search={failing} onPick={vi.fn()} />)
|
|
670
|
+
act(() => {
|
|
671
|
+
fireEvent.change(screen.getByRole('textbox', { name: 'Search GIFs' }), {
|
|
672
|
+
target: { value: 'cat' },
|
|
673
|
+
})
|
|
674
|
+
})
|
|
675
|
+
await waitFor(() => {
|
|
676
|
+
expect(screen.getByText("GIF search isn't available right now.")).toBeInTheDocument()
|
|
677
|
+
})
|
|
678
|
+
})
|
|
679
|
+
})
|