@skopon-cool/form-sdk 0.5.1 → 0.6.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/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "publishConfig": {
4
4
  "access": "public"
5
5
  },
6
- "version": "0.5.1",
6
+ "version": "0.6.0",
7
7
  "description": "Skopon form rendering SDK (A2UI + form_definition) with submit helpers",
8
8
  "type": "module",
9
9
  "sideEffects": [
@@ -59,6 +59,25 @@ describe('a2uiAdapter blocks <-> surface', () => {
59
59
  }
60
60
  })
61
61
 
62
+ it('stores file defaults as URL arrays in the A2UI data model', () => {
63
+ const doc = blocksToA2ui(
64
+ {
65
+ title: '',
66
+ description: '',
67
+ blocks: [
68
+ {
69
+ id: 'file',
70
+ type: 'file',
71
+ name: 'attachments',
72
+ defaultValue: 'https://cdn.example.com/a.pdf',
73
+ },
74
+ ],
75
+ },
76
+ { includeHeader: false },
77
+ )
78
+ expect(doc.dataModel?.attachments).toEqual(['https://cdn.example.com/a.pdf'])
79
+ })
80
+
62
81
  it('round-trips block types through a2uiToBlocks', () => {
63
82
  const doc = blocksToA2ui(makeDefinition())
64
83
  const back = a2uiToBlocks(doc)
@@ -137,6 +156,31 @@ describe('a2uiAdapter blocks <-> surface', () => {
137
156
  expect(back.blocks[0]?.options).toEqual([{ value: '0', label: '123' }])
138
157
  })
139
158
 
159
+ it('sanitizes untrusted FormBlock options while building an A2UI document', () => {
160
+ const doc = blocksToA2ui(
161
+ {
162
+ title: '',
163
+ description: '',
164
+ blocks: [
165
+ {
166
+ id: 'choice',
167
+ type: 'select',
168
+ name: 'choice',
169
+ options: [
170
+ { value: 0, label: 123 },
171
+ { value: {}, label: '异常' },
172
+ ] as never,
173
+ },
174
+ ],
175
+ },
176
+ { includeHeader: false },
177
+ )
178
+
179
+ expect(doc.components.find((component) => component.id === 'choice')?.options).toEqual([
180
+ { value: '0', label: '123' },
181
+ ])
182
+ })
183
+
140
184
  it('blocksToA2ui emits SkoponMedia for image blocks', () => {
141
185
  const doc = blocksToA2ui(makeDefinition(), { includeHeader: false })
142
186
  const imgNode = doc.components.find((c) => c.id === 'b-img')
@@ -100,6 +100,7 @@ function defaultValueForBlock(block: FormBlock): unknown {
100
100
  return coerceToggleValue(block.defaultValue)
101
101
  case 'multiselect':
102
102
  case 'checkbox':
103
+ case 'file':
103
104
  case 'resume_multiselect':
104
105
  case 'case_multiselect':
105
106
  if (Array.isArray(block.defaultValue)) return block.defaultValue
@@ -127,7 +128,7 @@ function blockToComponent(block: FormBlock): {
127
128
  const label = block.label ?? ''
128
129
  const name = block.name?.trim() || ''
129
130
  const path = name ? pathFor(name) : undefined
130
- const options = (block.options ?? []).map((o) => ({ label: o.label, value: o.value }))
131
+ const options = normalizeFormOptions(block.options)
131
132
 
132
133
  const withData = (node: A2uiComponentNode): {
133
134
  node: A2uiComponentNode
@@ -76,6 +76,40 @@ describe('normalizeFormDefinition toggle default', () => {
76
76
  })
77
77
  })
78
78
 
79
+ describe('file value schema', () => {
80
+ it('uses a URL array schema even when maxCount is one', () => {
81
+ const schema = blocksToJsonSchema([
82
+ {
83
+ id: 'file-1',
84
+ type: 'file',
85
+ name: 'attachment',
86
+ fileMinCount: 1,
87
+ fileMaxCount: 1,
88
+ defaultValue: 'https://cdn.example.com/legacy.pdf',
89
+ },
90
+ ])
91
+ expect(schema.properties).toMatchObject({
92
+ attachment: {
93
+ type: 'array',
94
+ items: { type: 'string' },
95
+ minItems: 1,
96
+ maxItems: 1,
97
+ default: ['https://cdn.example.com/legacy.pdf'],
98
+ },
99
+ })
100
+ })
101
+
102
+ it('omits maxItems when maxCount is unlimited', () => {
103
+ const schema = blocksToJsonSchema([
104
+ { id: 'file-1', type: 'file', name: 'attachments', fileMinCount: 0, fileMaxCount: 0 },
105
+ ])
106
+ expect(schema.properties).toMatchObject({
107
+ attachments: { type: 'array', items: { type: 'string' }, minItems: 0 },
108
+ })
109
+ expect((schema.properties as Record<string, Record<string, unknown>>).attachments).not.toHaveProperty('maxItems')
110
+ })
111
+ })
112
+
79
113
  describe('normalizeFormDefinition static options', () => {
80
114
  it('keeps an empty static question for editing but omits it from jsonSchema', () => {
81
115
  const def = normalizeFormDefinition({
@@ -63,6 +63,7 @@ function normalizeDefaultValue(
63
63
  if (
64
64
  type === 'multiselect' ||
65
65
  type === 'checkbox' ||
66
+ type === 'file' ||
66
67
  type === 'resume_multiselect' ||
67
68
  type === 'case_multiselect'
68
69
  ) {
@@ -221,7 +222,7 @@ function normalizeBlock(raw: FormBlock): FormBlock {
221
222
  }
222
223
 
223
224
  function getOptionValueSet(block: FormBlock): Set<string> {
224
- return new Set((block.options ?? []).map((opt) => opt.value))
225
+ return new Set(normalizeFormOptions(block.options).map((opt) => opt.value))
225
226
  }
226
227
 
227
228
  function buildMultiselectCountJsonSchema(
@@ -271,6 +272,12 @@ export function resolveSchemaDefaultValue(block: FormBlock): unknown | undefined
271
272
  : []
272
273
  return values.length > 0 ? values : undefined
273
274
  }
275
+ case 'file': {
276
+ const values = Array.isArray(normalized)
277
+ ? (normalized as string[]).map(String).map((item) => item.trim()).filter(Boolean)
278
+ : []
279
+ return values.length > 0 ? values : undefined
280
+ }
274
281
  default:
275
282
  return normalized
276
283
  }
@@ -292,13 +299,13 @@ function blockToJsonSchemaProperty(block: FormBlock): Record<string, unknown> |
292
299
  case 'select':
293
300
  case 'radio': {
294
301
  property = { type: 'string' }
295
- const enumValues = (block.options ?? []).map((opt) => opt.value).filter(Boolean)
302
+ const enumValues = normalizeFormOptions(block.options).map((opt) => opt.value)
296
303
  if (enumValues.length > 0) property.enum = enumValues
297
304
  break
298
305
  }
299
306
  case 'multiselect':
300
307
  case 'checkbox': {
301
- const enumValues = (block.options ?? []).map((opt) => opt.value).filter(Boolean)
308
+ const enumValues = normalizeFormOptions(block.options).map((opt) => opt.value)
302
309
  property = {
303
310
  type: 'array',
304
311
  items:
@@ -311,15 +318,11 @@ function blockToJsonSchemaProperty(block: FormBlock): Record<string, unknown> |
311
318
  case 'file': {
312
319
  const minCount = normalizeFileUploadCount(block.fileMinCount, 1)
313
320
  const maxCount = normalizeFileUploadCount(block.fileMaxCount, 1)
314
- if (maxCount <= 1) {
315
- property = { type: 'string' }
316
- } else {
317
- property = {
318
- type: 'array',
319
- items: { type: 'string' },
320
- minItems: minCount,
321
- maxItems: maxCount,
322
- }
321
+ property = {
322
+ type: 'array',
323
+ items: { type: 'string' },
324
+ minItems: minCount,
325
+ ...(maxCount > 0 ? { maxItems: maxCount } : {}),
323
326
  }
324
327
  break
325
328
  }
@@ -1,5 +1,5 @@
1
1
  import { z } from 'zod'
2
- import { useEffect, useState, type ReactNode } from 'react'
2
+ import { useCallback, useEffect, useRef, useState, type ReactNode } from 'react'
3
3
  import { Catalog, type ComponentContext } from '@a2ui/web_core/v0_9'
4
4
  import {
5
5
  basicCatalog,
@@ -17,6 +17,7 @@ import {
17
17
  TimePicker,
18
18
  Typography,
19
19
  Upload,
20
+ type UploadFile,
20
21
  } from 'antd'
21
22
  import dayjs, { type Dayjs } from 'dayjs'
22
23
  import type { FormFilePlaceholderIcon, FormMediaSize } from '../types/index'
@@ -33,12 +34,16 @@ import {
33
34
  getRegisteredCatalogComponents,
34
35
  } from '../blocks/registry'
35
36
  import FilePlaceholderIcon from '../icons/FilePlaceholderIcon'
37
+ import {
38
+ isUploadCancelledError,
39
+ uploadFileToCdn,
40
+ } from '../client/uploadClient'
36
41
  import { useA2uiPreviewMode } from './a2uiPreviewContext'
42
+ import { useFileUploadStatus } from './fileUploadStatusContext'
37
43
  import {
38
44
  asOptionalString,
39
45
  asStringArray,
40
46
  normalizeSelectStoredValue,
41
- readBoundFieldValue,
42
47
  useSkoponBoundField,
43
48
  } from './useSkoponBoundField'
44
49
 
@@ -506,9 +511,36 @@ const FileUploadApi = {
506
511
  .passthrough(),
507
512
  }
508
513
 
514
+ interface FileUploadListItem {
515
+ uid: string
516
+ name: string
517
+ status: 'uploading' | 'done' | 'error'
518
+ url?: string
519
+ error?: string
520
+ }
521
+
522
+ function normalizeFileUrls(value: unknown): string[] {
523
+ return asStringArray(value).map((url) => url.trim()).filter(Boolean)
524
+ }
525
+
526
+ function fileNameFromUrl(url: string): string {
527
+ const withoutQuery = url.split(/[?#]/, 1)[0]
528
+ const tail = withoutQuery.split('/').filter(Boolean).pop() || url
529
+ try {
530
+ return decodeURIComponent(tail)
531
+ } catch {
532
+ return tail
533
+ }
534
+ }
535
+
536
+ function fileItemFromUrl(url: string, uid: string): FileUploadListItem {
537
+ return { uid, name: fileNameFromUrl(url), status: 'done', url }
538
+ }
539
+
509
540
  function FileUploadPreview({ context }: { context: ComponentContext }) {
510
541
  const { interactive } = useA2uiPreviewMode()
511
- const { value, setValue, fieldName } = useSkoponBoundField(context, 'value', {
542
+ const { beginUpload } = useFileUploadStatus()
543
+ const { value, setValue, setValueSilent, fieldName } = useSkoponBoundField(context, 'value', {
512
544
  component: 'FileUpload',
513
545
  changeAction: 'change',
514
546
  })
@@ -532,47 +564,162 @@ function FileUploadPreview({ context }: { context: ComponentContext }) {
532
564
  const minCount = typeof props.minCount === 'number' ? Math.max(0, props.minCount) : 0
533
565
  const maxCount = typeof props.maxCount === 'number' ? props.maxCount : 1
534
566
  const hasMaxLimit = maxCount > 0
535
- const selectedNames = asStringArray(value)
536
- const atMaxLimit = hasMaxLimit && selectedNames.length >= maxCount
537
- const belowMinCount = selectedNames.length < minCount
567
+ const initialUrls = normalizeFileUrls(value)
568
+ const externalUidRef = useRef(0)
569
+ const mountedRef = useRef(true)
570
+ const controllersRef = useRef(new Map<string, AbortController>())
571
+ const setValueRef = useRef(setValue)
572
+ setValueRef.current = setValue
573
+ const [items, setItems] = useState<FileUploadListItem[]>(() =>
574
+ initialUrls.map((url) => fileItemFromUrl(url, `external-${externalUidRef.current++}`)),
575
+ )
576
+ const itemsRef = useRef(items)
577
+ itemsRef.current = items
578
+
579
+ const commitItems = useCallback(
580
+ (
581
+ updater: (current: FileUploadListItem[]) => FileUploadListItem[],
582
+ writeUrls: boolean,
583
+ ) => {
584
+ const next = updater(itemsRef.current)
585
+ itemsRef.current = next
586
+ if (mountedRef.current) setItems(next)
587
+ if (writeUrls) {
588
+ setValueRef.current(
589
+ next
590
+ .filter((item) => item.status === 'done' && item.url)
591
+ .map((item) => item.url as string),
592
+ )
593
+ }
594
+ return next
595
+ },
596
+ [],
597
+ )
598
+
599
+ useEffect(() => {
600
+ mountedRef.current = true
601
+ return () => {
602
+ mountedRef.current = false
603
+ for (const controller of controllersRef.current.values()) controller.abort()
604
+ controllersRef.current.clear()
605
+ }
606
+ }, [])
607
+
608
+ useEffect(() => {
609
+ const urls = normalizeFileUrls(value)
610
+ const rawIsNormalizedArray =
611
+ Array.isArray(value) &&
612
+ value.length === urls.length &&
613
+ value.every((entry, index) => typeof entry === 'string' && entry === urls[index])
614
+ if (!rawIsNormalizedArray) setValueSilent(urls)
615
+
616
+ const consumed = new Set<number>()
617
+ commitItems(
618
+ (current) => {
619
+ const next = current.filter((item) => {
620
+ if (item.status !== 'done' || !item.url) return true
621
+ const index = urls.findIndex((url, i) => !consumed.has(i) && url === item.url)
622
+ if (index < 0) return false
623
+ consumed.add(index)
624
+ return true
625
+ })
626
+ urls.forEach((url, index) => {
627
+ if (!consumed.has(index)) {
628
+ next.push(fileItemFromUrl(url, `external-${externalUidRef.current++}`))
629
+ }
630
+ })
631
+ return next
632
+ },
633
+ false,
634
+ )
635
+ }, [value, setValueSilent, commitItems])
636
+
637
+ const startUpload = useCallback(
638
+ (file: File & { uid?: string }) => {
639
+ if (!interactive) return
640
+ if (hasMaxLimit && itemsRef.current.length >= maxCount) return
641
+
642
+ const uid = `${file.uid ?? 'file'}-${externalUidRef.current++}`
643
+ const controller = new AbortController()
644
+ controllersRef.current.set(uid, controller)
645
+ commitItems(
646
+ (current) => [...current, { uid, name: file.name, status: 'uploading' }],
647
+ false,
648
+ )
649
+ const endUpload = beginUpload()
650
+
651
+ void uploadFileToCdn(file, { signal: controller.signal })
652
+ .then((result) => {
653
+ if (!mountedRef.current || controller.signal.aborted) return
654
+ commitItems(
655
+ (current) =>
656
+ current.map((item) =>
657
+ item.uid === uid
658
+ ? { ...item, status: 'done', url: result.url, error: undefined }
659
+ : item,
660
+ ),
661
+ true,
662
+ )
663
+ })
664
+ .catch((error: unknown) => {
665
+ if (!mountedRef.current || isUploadCancelledError(error)) return
666
+ const detail = error instanceof Error ? error.message : '上传失败'
667
+ commitItems(
668
+ (current) =>
669
+ current.map((item) =>
670
+ item.uid === uid ? { ...item, status: 'error', error: detail } : item,
671
+ ),
672
+ false,
673
+ )
674
+ })
675
+ .finally(() => {
676
+ controllersRef.current.delete(uid)
677
+ endUpload()
678
+ })
679
+ },
680
+ [beginUpload, commitItems, hasMaxLimit, interactive, maxCount],
681
+ )
682
+
683
+ const handleRemove = useCallback(
684
+ (uid: string) => {
685
+ controllersRef.current.get(uid)?.abort()
686
+ controllersRef.current.delete(uid)
687
+ commitItems((current) => current.filter((item) => item.uid !== uid), true)
688
+ },
689
+ [commitItems],
690
+ )
691
+
692
+ const uploadedCount = items.filter((item) => item.status === 'done').length
693
+ const atMaxLimit = hasMaxLimit && items.length >= maxCount
694
+ const belowMinCount = uploadedCount < minCount
695
+ const latestError = [...items].reverse().find((item) => item.error)?.error
696
+ const fileList: UploadFile[] = items.map((item) => ({
697
+ uid: item.uid,
698
+ name: item.name,
699
+ status: item.status,
700
+ url: item.url,
701
+ response: item.error,
702
+ }))
538
703
 
539
704
  return (
540
705
  <div className="form-block-preview" data-skopon-field={fieldName || undefined}>
541
706
  {label ? <div className="form-block-preview-label">{label}</div> : null}
542
707
  <Upload.Dragger
543
- disabled={!interactive || atMaxLimit}
708
+ disabled={!interactive}
709
+ openFileDialogOnClick={!atMaxLimit}
544
710
  accept={buildFileAcceptAttribute(fileAcceptTypes, fileAcceptExtensions)}
545
711
  className="form-file-upload-preview"
546
- showUploadList={interactive && selectedNames.length > 0}
547
- fileList={
548
- interactive
549
- ? selectedNames.map((name, index) => ({
550
- uid: `${index}-${name}`,
551
- name,
552
- status: 'done' as const,
553
- }))
554
- : []
555
- }
712
+ multiple={!hasMaxLimit || maxCount > 1}
713
+ showUploadList={interactive && items.length > 0}
714
+ fileList={interactive ? fileList : []}
556
715
  beforeUpload={(file) => {
557
716
  if (!interactive) return Upload.LIST_IGNORE
558
- const currentNames = asStringArray(readBoundFieldValue(context))
559
- if (hasMaxLimit && currentNames.length >= maxCount) return Upload.LIST_IGNORE
560
- const next = hasMaxLimit
561
- ? [...currentNames, file.name].slice(0, maxCount)
562
- : [...currentNames, file.name]
563
- setValue(next)
564
- return false
717
+ startUpload(file)
718
+ return Upload.LIST_IGNORE
565
719
  }}
566
720
  onRemove={(file) => {
567
- const currentNames = asStringArray(readBoundFieldValue(context))
568
- const indexMatch = /^(\d+)-/.exec(file.uid ?? '')
569
- const index = indexMatch ? Number(indexMatch[1]) : -1
570
- const next =
571
- index >= 0 && index < currentNames.length
572
- ? currentNames.filter((_, i) => i !== index)
573
- : currentNames.filter((name) => name !== file.name)
574
- if (next.length < minCount) return false
575
- setValue(next)
721
+ handleRemove(file.uid)
722
+ return true
576
723
  }}
577
724
  >
578
725
  <p className="form-file-upload-preview-icon">
@@ -584,9 +731,14 @@ function FileUploadPreview({ context }: { context: ComponentContext }) {
584
731
  允许:{acceptSummary ?? '全部类型'}
585
732
  {minCount > 0 ? `;至少 ${minCount} 个文件` : ''}
586
733
  {hasMaxLimit ? `;最多 ${maxCount} 个文件` : ''}
587
- ;当前仅收集文件名(不含文件内容,需宿主另行上传)
588
- {belowMinCount && interactive ? `(当前 ${selectedNames.length} 个,未达下限)` : ''}
734
+ ;选择后自动上传至 CDN
735
+ {belowMinCount && interactive ? `(当前 ${uploadedCount} 个,未达下限)` : ''}
589
736
  </Typography.Text>
737
+ {latestError ? (
738
+ <Typography.Text type="danger" className="form-block-preview-help">
739
+ {latestError}
740
+ </Typography.Text>
741
+ ) : null}
590
742
  </div>
591
743
  )
592
744
  }
@@ -0,0 +1,177 @@
1
+ // @vitest-environment happy-dom
2
+ import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
3
+
4
+ const uploadMock = vi.hoisted(() => vi.fn())
5
+
6
+ vi.mock('@a2ui/web_core/v0_9/basic_catalog', async (importOriginal) => {
7
+ const actual = await importOriginal<typeof import('@a2ui/web_core/v0_9/basic_catalog')>()
8
+ return { ...actual, injectBasicCatalogStyles: vi.fn() }
9
+ })
10
+
11
+ vi.mock('@a2ui/react/styles', () => ({
12
+ injectStyles: vi.fn(),
13
+ removeStyles: vi.fn(),
14
+ }))
15
+
16
+ vi.mock('../client/uploadClient', () => ({
17
+ uploadFileToCdn: uploadMock,
18
+ isUploadCancelledError: (error: unknown) =>
19
+ error instanceof Error && error.name === 'UploadCancelledError',
20
+ }))
21
+
22
+ import { createElement, createRef } from 'react'
23
+ import { act, cleanup, fireEvent, render, waitFor } from '@testing-library/react'
24
+ import { blocksToA2ui } from '../adapter/a2uiAdapter'
25
+ import SkoponFormRenderer, {
26
+ type SkoponFormRendererRef,
27
+ } from '../components/SkoponFormRenderer'
28
+
29
+ beforeAll(() => {
30
+ class ResizeObserverMock {
31
+ observe() {}
32
+ unobserve() {}
33
+ disconnect() {}
34
+ }
35
+ vi.stubGlobal('ResizeObserver', ResizeObserverMock)
36
+ })
37
+
38
+ beforeEach(() => {
39
+ uploadMock.mockReset()
40
+ })
41
+ afterEach(cleanup)
42
+
43
+ function deferred<T>() {
44
+ let resolve!: (value: T) => void
45
+ let reject!: (reason?: unknown) => void
46
+ const promise = new Promise<T>((res, rej) => {
47
+ resolve = res
48
+ reject = rej
49
+ })
50
+ return { promise, resolve, reject }
51
+ }
52
+
53
+ function renderFileSurface(options: {
54
+ maxCount?: number
55
+ minCount?: number
56
+ defaultValue?: string | string[]
57
+ legacyRawValue?: string
58
+ onUploadingChange?: (uploading: boolean) => void
59
+ } = {}) {
60
+ const doc = blocksToA2ui(
61
+ {
62
+ title: '',
63
+ description: '',
64
+ blocks: [
65
+ {
66
+ id: 'file',
67
+ type: 'file',
68
+ name: 'attachments',
69
+ label: '附件',
70
+ fileMinCount: options.minCount ?? 0,
71
+ fileMaxCount: options.maxCount ?? 1,
72
+ defaultValue: options.defaultValue,
73
+ },
74
+ ],
75
+ },
76
+ { includeHeader: false },
77
+ )
78
+ if (options.legacyRawValue !== undefined && doc.dataModel) {
79
+ doc.dataModel.attachments = options.legacyRawValue
80
+ }
81
+ const ref = createRef<SkoponFormRendererRef>()
82
+ const view = render(
83
+ createElement(SkoponFormRenderer, {
84
+ ref,
85
+ doc,
86
+ surfaceId: 'upload-test',
87
+ fieldNames: ['attachments'],
88
+ onUploadingChange: options.onUploadingChange,
89
+ }),
90
+ )
91
+ const input = view.container.querySelector('input[type="file"]') as HTMLInputElement
92
+ expect(input).toBeTruthy()
93
+ return { ref, input, ...view }
94
+ }
95
+
96
+ describe('FileUploadPreview', () => {
97
+ it('stores successful CDN URLs in user selection order', async () => {
98
+ const first = deferred<{ url: string }>()
99
+ const second = deferred<{ url: string }>()
100
+ uploadMock
101
+ .mockImplementationOnce(() => first.promise)
102
+ .mockImplementationOnce(() => second.promise)
103
+ const uploadingChanges: boolean[] = []
104
+ const { input, ref, container } = renderFileSurface({
105
+ maxCount: 2,
106
+ onUploadingChange: (uploading) => uploadingChanges.push(uploading),
107
+ })
108
+
109
+ fireEvent.change(input, { target: { files: [new File(['1'], 'first.pdf')] } })
110
+ await waitFor(() => expect(uploadMock).toHaveBeenCalledTimes(1))
111
+ const nextInput = container.querySelector('input[type="file"]') as HTMLInputElement
112
+ fireEvent.change(nextInput, { target: { files: [new File(['2'], 'second.pdf')] } })
113
+ await waitFor(() => expect(uploadMock).toHaveBeenCalledTimes(2))
114
+ expect(uploadingChanges).toContain(true)
115
+
116
+ await act(async () => second.resolve({ url: 'https://cdn.example.com/second.pdf' }))
117
+ expect(ref.current?.getValues()).toEqual({
118
+ attachments: ['https://cdn.example.com/second.pdf'],
119
+ })
120
+ await act(async () => first.resolve({ url: 'https://cdn.example.com/first.pdf' }))
121
+ await waitFor(() =>
122
+ expect(ref.current?.getValues()).toEqual({
123
+ attachments: [
124
+ 'https://cdn.example.com/first.pdf',
125
+ 'https://cdn.example.com/second.pdf',
126
+ ],
127
+ }),
128
+ )
129
+ await waitFor(() => expect(uploadingChanges.at(-1)).toBe(false))
130
+ })
131
+
132
+ it('keeps failed uploads out of the bound value and shows the error', async () => {
133
+ uploadMock.mockRejectedValueOnce(new Error('七牛上传失败'))
134
+ const { input, ref, getByText } = renderFileSurface()
135
+ fireEvent.change(input, { target: { files: [new File(['x'], 'bad.pdf')] } })
136
+ await waitFor(() => expect(getByText('七牛上传失败')).toBeTruthy())
137
+ expect(ref.current?.getValues()).toEqual({ attachments: [] })
138
+ })
139
+
140
+ it('allows removing below minCount and aborts an in-flight request', async () => {
141
+ let signal: AbortSignal | undefined
142
+ uploadMock.mockImplementation(
143
+ (_file: File, options?: { signal?: AbortSignal }) => {
144
+ signal = options?.signal
145
+ return new Promise<{ url: string }>((resolve) => {
146
+ signal?.addEventListener('abort', () => {
147
+ resolve({ url: 'https://cdn.example.com/ignored.pdf' })
148
+ })
149
+ })
150
+ },
151
+ )
152
+ const { input, container, ref } = renderFileSurface({ minCount: 1, maxCount: 1 })
153
+ fireEvent.change(input, { target: { files: [new File(['x'], 'pending.pdf')] } })
154
+ await waitFor(() => expect(uploadMock).toHaveBeenCalledTimes(1))
155
+ const remove = await waitFor(() => {
156
+ const button = container.querySelector('.ant-upload-list-item-actions button') as HTMLElement
157
+ expect(button).toBeTruthy()
158
+ return button
159
+ })
160
+ fireEvent.click(remove)
161
+ await waitFor(() => expect(signal?.aborted).toBe(true))
162
+ expect(ref.current?.getValues()).toEqual({ attachments: [] })
163
+ expect(container.textContent).toContain('当前 0 个,未达下限')
164
+ })
165
+
166
+ it('normalizes a legacy string value to a URL array', async () => {
167
+ const { ref, getByText } = renderFileSurface({
168
+ legacyRawValue: 'https://cdn.example.com/legacy%20file.pdf',
169
+ })
170
+ await waitFor(() =>
171
+ expect(ref.current?.getValues()).toEqual({
172
+ attachments: ['https://cdn.example.com/legacy%20file.pdf'],
173
+ }),
174
+ )
175
+ expect(getByText('legacy file.pdf')).toBeTruthy()
176
+ })
177
+ })