@ploykit/ui 0.5.0 → 0.6.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (222) hide show
  1. package/dist/AppProviders.js +2 -2
  2. package/dist/components/ConfirmDialog.js +1 -1
  3. package/dist/components/CronInput.js +3 -3
  4. package/dist/components/DataTable.js +4 -4
  5. package/dist/components/FormField.js +1 -1
  6. package/dist/components/ImpersonationBanner.js +1 -1
  7. package/dist/components/NotificationBell.js +1 -1
  8. package/dist/components/OnboardingChecklist.js +3 -3
  9. package/dist/components/Page.js +2 -2
  10. package/dist/components/ReauthDialog.js +4 -4
  11. package/dist/components/SecretModal.js +2 -2
  12. package/dist/components/SettingsCard.js +2 -2
  13. package/dist/components/SiteBanner.js +1 -1
  14. package/dist/components/SiteBannerLayer.js +1 -1
  15. package/dist/components/StatusBadge.js +1 -1
  16. package/dist/components/UserMenu.js +3 -3
  17. package/dist/components/WorkspaceSwitcher.js +7 -7
  18. package/dist/components/ui/alert-dialog.js +2 -2
  19. package/dist/components/ui/avatar.js +1 -1
  20. package/dist/components/ui/badge.js +1 -1
  21. package/dist/components/ui/breadcrumb.js +1 -1
  22. package/dist/components/ui/button.js +1 -1
  23. package/dist/components/ui/card.js +1 -1
  24. package/dist/components/ui/checkbox.js +1 -1
  25. package/dist/components/ui/dialog.js +2 -2
  26. package/dist/components/ui/dropdown-menu.js +1 -1
  27. package/dist/components/ui/empty.js +1 -1
  28. package/dist/components/ui/field.js +3 -3
  29. package/dist/components/ui/input.js +1 -1
  30. package/dist/components/ui/label.js +1 -1
  31. package/dist/components/ui/popover.js +1 -1
  32. package/dist/components/ui/progress.js +1 -1
  33. package/dist/components/ui/select.js +1 -1
  34. package/dist/components/ui/separator.js +1 -1
  35. package/dist/components/ui/sheet.js +2 -2
  36. package/dist/components/ui/sidebar.js +8 -8
  37. package/dist/components/ui/skeleton.js +1 -1
  38. package/dist/components/ui/switch.js +1 -1
  39. package/dist/components/ui/tabs.js +1 -1
  40. package/dist/components/ui/textarea.js +1 -1
  41. package/dist/components/ui/tooltip.js +1 -1
  42. package/dist/index.js +78 -78
  43. package/dist/layouts/AppShell.js +4 -4
  44. package/dist/layouts/SettingsShell.js +4 -4
  45. package/dist/pages/LoginPage.js +5 -5
  46. package/dist/pages/account/NotificationPreferencesPage.js +3 -3
  47. package/dist/pages/account/ProfileSettingsPage.js +9 -9
  48. package/dist/pages/account/SecuritySettingsPage.js +13 -13
  49. package/dist/pages/account/TokensPage.js +12 -12
  50. package/dist/pages/admin/AdminSettingsPage.js +11 -11
  51. package/dist/pages/admin/AdminShell.js +3 -3
  52. package/dist/pages/admin/AuditPage.js +8 -8
  53. package/dist/pages/admin/OrdersPage.js +12 -12
  54. package/dist/pages/admin/OverviewPage.js +13 -13
  55. package/dist/pages/admin/SSOPage.js +6 -6
  56. package/dist/pages/admin/UserDetailPage.js +13 -13
  57. package/dist/pages/admin/UsersPage.js +13 -13
  58. package/dist/pages/admin/WebhookDeliveriesPage.js +9 -9
  59. package/dist/pages/admin/WorkspaceDetailPage.js +14 -14
  60. package/dist/pages/admin/WorkspacesPage.js +9 -9
  61. package/dist/pages/admin/shared.js +1 -1
  62. package/dist/pages/auth/ForgotPasswordPage.js +4 -4
  63. package/dist/pages/auth/InviteAcceptPage.js +6 -6
  64. package/dist/pages/auth/RegisterPage.js +7 -7
  65. package/dist/pages/auth/ResetPasswordPage.js +5 -5
  66. package/dist/pages/auth/VerifyEmailPage.js +3 -3
  67. package/dist/pages/workspace/BillingPage.js +11 -11
  68. package/dist/pages/workspace/BillingSuccessPage.js +2 -2
  69. package/dist/pages/workspace/MembersPage.js +15 -15
  70. package/dist/pages/workspace/RolePermissionsPage.js +9 -9
  71. package/dist/pages/workspace/SchedulesPage.js +12 -12
  72. package/dist/pages/workspace/UsagePage.js +6 -6
  73. package/dist/pages/workspace/WebhooksPage.js +16 -16
  74. package/dist/pages/workspace/WorkspaceAuditPage.js +7 -7
  75. package/dist/pages/workspace/WorkspaceGeneralPage.js +9 -9
  76. package/package.json +6 -5
  77. package/src/AppProviders.tsx +29 -0
  78. package/src/components/ConfirmDialog.tsx +77 -0
  79. package/src/components/CronInput.tsx +195 -0
  80. package/src/components/DataTable.tsx +152 -0
  81. package/src/components/ErrorBoundary.tsx +46 -0
  82. package/src/components/FormField.tsx +56 -0
  83. package/src/components/ImpersonationBanner.tsx +23 -0
  84. package/src/components/NotificationBell.tsx +135 -0
  85. package/src/components/OnboardingChecklist.tsx +114 -0
  86. package/src/components/Page.tsx +67 -0
  87. package/src/components/ReauthDialog.tsx +94 -0
  88. package/src/components/SecretModal.tsx +147 -0
  89. package/src/components/SettingsCard.tsx +39 -0
  90. package/src/components/SiteBanner.tsx +32 -0
  91. package/src/components/SiteBannerLayer.tsx +29 -0
  92. package/src/components/StatusBadge.tsx +12 -0
  93. package/src/components/UserMenu.tsx +103 -0
  94. package/src/components/WorkspaceSwitcher.tsx +159 -0
  95. package/src/components/__tests__/AppProviders.test.tsx +111 -0
  96. package/src/components/__tests__/CronInput.test.tsx +180 -0
  97. package/src/components/__tests__/DataTable.test.tsx +208 -0
  98. package/src/components/__tests__/ErrorBoundary.test.tsx +61 -0
  99. package/src/components/__tests__/ImpersonationBanner.test.tsx +59 -0
  100. package/src/components/__tests__/NotificationBell.test.tsx +207 -0
  101. package/src/components/__tests__/OnboardingChecklist.test.tsx +97 -0
  102. package/src/components/__tests__/SecretModal.test.tsx +112 -0
  103. package/src/components/__tests__/SiteBannerLayer.test.tsx +44 -0
  104. package/src/components/__tests__/UserMenu.test.tsx +98 -0
  105. package/src/components/__tests__/WorkspaceSwitcher.test.tsx +162 -0
  106. package/src/components/__tests__/form.test.tsx +133 -0
  107. package/src/components/__tests__/semantic-tokens.test.ts +41 -0
  108. package/src/components/__tests__/smoke.test.tsx +11 -0
  109. package/src/components/__tests__/toast.test.tsx +27 -0
  110. package/src/components/toast.tsx +23 -0
  111. package/src/components/ui/__tests__/alert-dialog.test.tsx +66 -0
  112. package/src/components/ui/__tests__/avatar.test.tsx +30 -0
  113. package/src/components/ui/__tests__/checkbox.test.tsx +18 -0
  114. package/src/components/ui/__tests__/dropdown-menu.test.tsx +33 -0
  115. package/src/components/ui/__tests__/field.test.tsx +43 -0
  116. package/src/components/ui/__tests__/overlay-size.test.tsx +84 -0
  117. package/src/components/ui/__tests__/popover.test.tsx +32 -0
  118. package/src/components/ui/__tests__/progress.test.tsx +24 -0
  119. package/src/components/ui/__tests__/select-callback.test.tsx +72 -0
  120. package/src/components/ui/__tests__/select.test.tsx +48 -0
  121. package/src/components/ui/__tests__/sidebar.test.tsx +121 -0
  122. package/src/components/ui/__tests__/stock-mirror.test.ts +39 -0
  123. package/src/components/ui/__tests__/subpath.test.tsx +38 -0
  124. package/src/components/ui/__tests__/switch.test.tsx +24 -0
  125. package/src/components/ui/__tests__/tabs.test.tsx +24 -0
  126. package/src/components/ui/__tests__/textarea.test.tsx +41 -0
  127. package/src/components/ui/alert-dialog.tsx +187 -0
  128. package/src/components/ui/avatar.tsx +108 -0
  129. package/src/components/ui/badge.tsx +51 -0
  130. package/src/components/ui/breadcrumb.tsx +124 -0
  131. package/src/components/ui/button.tsx +57 -0
  132. package/src/components/ui/card.tsx +102 -0
  133. package/src/components/ui/checkbox.tsx +28 -0
  134. package/src/components/ui/dialog.tsx +166 -0
  135. package/src/components/ui/dropdown-menu.tsx +265 -0
  136. package/src/components/ui/empty.tsx +103 -0
  137. package/src/components/ui/field.tsx +238 -0
  138. package/src/components/ui/input.tsx +19 -0
  139. package/src/components/ui/label.tsx +17 -0
  140. package/src/components/ui/popover.tsx +89 -0
  141. package/src/components/ui/progress.tsx +82 -0
  142. package/src/components/ui/select.tsx +235 -0
  143. package/src/components/ui/separator.tsx +24 -0
  144. package/src/components/ui/sheet.tsx +146 -0
  145. package/src/components/ui/sidebar.tsx +723 -0
  146. package/src/components/ui/skeleton.tsx +13 -0
  147. package/src/components/ui/switch.tsx +29 -0
  148. package/src/components/ui/tabs.tsx +79 -0
  149. package/src/components/ui/textarea.tsx +26 -0
  150. package/src/components/ui/tooltip.tsx +63 -0
  151. package/src/dev.css +1 -0
  152. package/src/hooks/use-mobile.ts +19 -0
  153. package/src/index.ts +150 -0
  154. package/src/layouts/AppShell.tsx +163 -0
  155. package/src/layouts/AuthShell.tsx +24 -0
  156. package/src/layouts/SettingsShell.tsx +129 -0
  157. package/src/layouts/__tests__/AppShell.test.tsx +163 -0
  158. package/src/layouts/__tests__/SettingsShell.test.tsx +146 -0
  159. package/src/lib/__tests__/api-error.test.ts +163 -0
  160. package/src/lib/__tests__/utils.test.ts +21 -0
  161. package/src/lib/api-error.ts +98 -0
  162. package/src/lib/utils.ts +109 -0
  163. package/src/pages/LandingPage.tsx +88 -0
  164. package/src/pages/LoginPage.tsx +291 -0
  165. package/src/pages/__tests__/LandingPage.test.tsx +58 -0
  166. package/src/pages/__tests__/LoginPage.test.tsx +229 -0
  167. package/src/pages/__tests__/RegisterPage.test.tsx +138 -0
  168. package/src/pages/__tests__/ssr-smoke.test.tsx +99 -0
  169. package/src/pages/account/NotificationPreferencesPage.tsx +138 -0
  170. package/src/pages/account/ProfileSettingsPage.tsx +189 -0
  171. package/src/pages/account/SecuritySettingsPage.tsx +214 -0
  172. package/src/pages/account/TokensPage.tsx +211 -0
  173. package/src/pages/account/__tests__/NotificationPreferencesPage.test.tsx +150 -0
  174. package/src/pages/account/__tests__/ProfileSettingsPage.test.tsx +173 -0
  175. package/src/pages/account/__tests__/SecuritySettingsPage.test.tsx +212 -0
  176. package/src/pages/account/__tests__/TokensPage.test.tsx +180 -0
  177. package/src/pages/admin/AdminSettingsPage.tsx +564 -0
  178. package/src/pages/admin/AdminShell.tsx +199 -0
  179. package/src/pages/admin/AuditPage.tsx +338 -0
  180. package/src/pages/admin/OrdersPage.tsx +642 -0
  181. package/src/pages/admin/OverviewPage.tsx +559 -0
  182. package/src/pages/admin/SSOPage.tsx +166 -0
  183. package/src/pages/admin/UserDetailPage.tsx +641 -0
  184. package/src/pages/admin/UsersPage.tsx +462 -0
  185. package/src/pages/admin/WebhookDeliveriesPage.tsx +318 -0
  186. package/src/pages/admin/WorkspaceDetailPage.tsx +551 -0
  187. package/src/pages/admin/WorkspacesPage.tsx +299 -0
  188. package/src/pages/admin/__tests__/AdminSettingsPage.test.tsx +260 -0
  189. package/src/pages/admin/__tests__/OrdersPageMarkPaid.test.tsx +207 -0
  190. package/src/pages/admin/__tests__/OverviewPageActivity.test.tsx +213 -0
  191. package/src/pages/admin/__tests__/SSOPage.test.tsx +128 -0
  192. package/src/pages/admin/__tests__/UsersPageActions.test.tsx +188 -0
  193. package/src/pages/admin/__tests__/WorkspaceDetailPageTransfer.test.tsx +170 -0
  194. package/src/pages/admin/__tests__/WorkspacesPagePlans.test.tsx +138 -0
  195. package/src/pages/admin/shared.ts +42 -0
  196. package/src/pages/auth/ForgotPasswordPage.tsx +83 -0
  197. package/src/pages/auth/InviteAcceptPage.tsx +105 -0
  198. package/src/pages/auth/RegisterPage.tsx +204 -0
  199. package/src/pages/auth/ResetPasswordPage.tsx +150 -0
  200. package/src/pages/auth/VerifyEmailPage.tsx +76 -0
  201. package/src/pages/auth/__tests__/ForgotPasswordPage.test.tsx +67 -0
  202. package/src/pages/auth/__tests__/InviteAcceptPage.test.tsx +86 -0
  203. package/src/pages/auth/__tests__/ResetPasswordPage.test.tsx +115 -0
  204. package/src/pages/auth/__tests__/VerifyEmailPage.test.tsx +74 -0
  205. package/src/pages/workspace/BillingPage.tsx +361 -0
  206. package/src/pages/workspace/BillingSuccessPage.tsx +115 -0
  207. package/src/pages/workspace/MembersPage.tsx +568 -0
  208. package/src/pages/workspace/RolePermissionsPage.tsx +202 -0
  209. package/src/pages/workspace/SchedulesPage.tsx +387 -0
  210. package/src/pages/workspace/UsagePage.tsx +207 -0
  211. package/src/pages/workspace/WebhooksPage.tsx +506 -0
  212. package/src/pages/workspace/WorkspaceAuditPage.tsx +229 -0
  213. package/src/pages/workspace/WorkspaceGeneralPage.tsx +184 -0
  214. package/src/pages/workspace/__tests__/BillingPage.test.tsx +302 -0
  215. package/src/pages/workspace/__tests__/BillingSuccessPage.test.tsx +134 -0
  216. package/src/pages/workspace/__tests__/MembersPage.test.tsx +347 -0
  217. package/src/pages/workspace/__tests__/RolePermissionsPage.test.tsx +138 -0
  218. package/src/pages/workspace/__tests__/SchedulesPage.test.tsx +239 -0
  219. package/src/pages/workspace/__tests__/UsagePage.test.tsx +223 -0
  220. package/src/pages/workspace/__tests__/WebhooksPage.test.tsx +309 -0
  221. package/src/pages/workspace/__tests__/WorkspaceAuditPage.test.tsx +112 -0
  222. package/src/pages/workspace/__tests__/WorkspaceGeneralPage.test.tsx +164 -0
@@ -0,0 +1,162 @@
1
+ import '@testing-library/jest-dom/vitest'
2
+ import { fireEvent, render, screen, waitFor } from '@testing-library/react'
3
+ import { MemoryRouter } from 'react-router'
4
+ import { describe, it, expect, vi, beforeAll, beforeEach } from 'vitest'
5
+ import { ApiError } from '@ploykit/client'
6
+ import { WorkspaceSwitcher } from '../WorkspaceSwitcher'
7
+ import { SidebarProvider } from '../ui/sidebar'
8
+
9
+
10
+
11
+ const switchTo = vi.fn()
12
+ const create = vi.fn(async (name: string, slug: string) => ({ id: 'ws-new', name, slug }))
13
+ let mockError: unknown = null
14
+ vi.mock('../../../../hooks/src/hooks/useWorkspace', () => ({
15
+ useWorkspaceSwitch: () => ({
16
+ current: { id: 'ws-1', name: 'Acme', plan_code: 'free', role: 'owner' },
17
+ list: [
18
+ { id: 'ws-1', name: 'Acme', plan_code: 'free', role: 'owner' },
19
+ { id: 'ws-2', name: 'Beta', plan_code: 'pro', role: 'admin' },
20
+ ],
21
+ switchTo,
22
+ create,
23
+ creating: false,
24
+ error: mockError,
25
+ }),
26
+ }))
27
+
28
+ function renderSwitcher() {
29
+ return render(
30
+ <MemoryRouter>
31
+ <SidebarProvider>
32
+ <WorkspaceSwitcher />
33
+ </SidebarProvider>
34
+ </MemoryRouter>,
35
+ )
36
+ }
37
+
38
+
39
+ beforeAll(() => {
40
+ Object.defineProperty(window, 'matchMedia', {
41
+ writable: true,
42
+ value: (query: string) => ({
43
+ matches: false,
44
+ media: query,
45
+ addEventListener: () => {},
46
+ removeEventListener: () => {},
47
+ addListener: () => {},
48
+ removeListener: () => {},
49
+ dispatchEvent: () => false,
50
+ }),
51
+ })
52
+ })
53
+
54
+ describe('WorkspaceSwitcher(Base UI Menu 重建)', () => {
55
+ beforeEach(() => {
56
+ switchTo.mockClear()
57
+ create.mockClear()
58
+ mockError = null
59
+ })
60
+
61
+ it('渲染当前工作区,打开菜单列出工作区与新建入口', async () => {
62
+ renderSwitcher()
63
+ const trigger = screen.getByRole('button', { name: '切换工作区' })
64
+ expect(trigger).toHaveTextContent('Acme')
65
+
66
+
67
+ fireEvent.click(trigger)
68
+
69
+ expect(await screen.findByRole('menu')).toBeInTheDocument()
70
+ expect(screen.getByRole('menuitem', { name: /Beta/ })).toBeVisible()
71
+ expect(screen.getByRole('menuitem', { name: /新建工作区/ })).toBeVisible()
72
+ })
73
+
74
+ it('点击工作区项调用 switchTo', async () => {
75
+ renderSwitcher()
76
+ const trigger = screen.getByRole('button', { name: '切换工作区' })
77
+ fireEvent.click(trigger)
78
+ fireEvent.click(await screen.findByRole('menuitem', { name: /Beta/ }))
79
+ expect(switchTo).toHaveBeenCalledWith('ws-2')
80
+ })
81
+
82
+ it('新建工作区:打开内联创建,提交调用 create(name, slug)', async () => {
83
+ renderSwitcher()
84
+ const trigger = screen.getByRole('button', { name: '切换工作区' })
85
+ fireEvent.click(trigger)
86
+ fireEvent.click(await screen.findByRole('menuitem', { name: /新建工作区/ }))
87
+
88
+ const input = await screen.findByLabelText('新工作区名称')
89
+ fireEvent.change(input, { target: { value: 'Gamma Workspace' } })
90
+ fireEvent.click(screen.getByRole('button', { name: '创建' }))
91
+
92
+ await waitFor(() => expect(create).toHaveBeenCalledWith('Gamma Workspace', 'gamma-workspace'))
93
+ })
94
+
95
+ it('RE2-1:创建失败(配额满 402/E_QUOTA_EXCEEDED)显示中文错误与"升级套餐"指引,不再静默', async () => {
96
+ mockError = new ApiError(402, 'E_QUOTA_EXCEEDED', 'quota exceeded for workspaces')
97
+ renderSwitcher()
98
+ const trigger = screen.getByRole('button', { name: '切换工作区' })
99
+ fireEvent.click(trigger)
100
+ fireEvent.click(await screen.findByRole('menuitem', { name: /新建工作区/ }))
101
+
102
+ expect(await screen.findByRole('alert')).toHaveTextContent('配额已用尽')
103
+
104
+
105
+ expect(screen.getByText(/已达当前工作区数上限/)).toBeInTheDocument()
106
+ const billing = screen.getByRole('link', { name: '设置→计费' })
107
+ expect(billing).toHaveAttribute('href', '/settings/workspace/billing')
108
+ })
109
+
110
+ it('RE2-1:slug 冲突等其他失败显示中文文案,且不出现升级指引', async () => {
111
+ mockError = new ApiError(409, 'E_CONFLICT', 'slug already taken')
112
+ renderSwitcher()
113
+ const trigger = screen.getByRole('button', { name: '切换工作区' })
114
+ fireEvent.click(trigger)
115
+ fireEvent.click(await screen.findByRole('menuitem', { name: /新建工作区/ }))
116
+
117
+ expect(await screen.findByRole('alert')).toHaveTextContent('该标识已被占用,请换个工作区名称重试')
118
+ expect(screen.queryByRole('link', { name: '升级套餐' })).not.toBeInTheDocument()
119
+ })
120
+
121
+ it('RE2-1:创建成功(无 error)时面板内无错误提示', async () => {
122
+ renderSwitcher()
123
+ const trigger = screen.getByRole('button', { name: '切换工作区' })
124
+ fireEvent.click(trigger)
125
+ fireEvent.click(await screen.findByRole('menuitem', { name: /新建工作区/ }))
126
+
127
+ await screen.findByLabelText('新工作区名称')
128
+ expect(screen.queryByRole('alert')).not.toBeInTheDocument()
129
+ })
130
+
131
+
132
+
133
+ async function submitName(name: string): Promise<unknown[]> {
134
+ const trigger = screen.getByRole('button', { name: '切换工作区' })
135
+ fireEvent.click(trigger)
136
+ fireEvent.click(await screen.findByRole('menuitem', { name: /新建工作区/ }))
137
+ fireEvent.change(await screen.findByLabelText('新工作区名称'), { target: { value: name } })
138
+ fireEvent.click(screen.getByRole('button', { name: '创建' }))
139
+ await waitFor(() => expect(create).toHaveBeenCalledTimes(1))
140
+ return create.mock.calls[0]
141
+ }
142
+
143
+ it('G2:纯中文名生成合法 slug(ws-随机后缀兜底,不以 - 开头/结尾、≥4 字符)', async () => {
144
+ renderSwitcher()
145
+ const [name, slug] = (await submitName('审计账单')) as [string, string]
146
+ expect(name).toBe('审计账单')
147
+
148
+ expect(slug).toMatch(/^ws-[a-z0-9]{4}$/)
149
+ })
150
+
151
+ it('G2:中文-英文混合名剥离首尾横线(审计-Webhooks- → webhooks)', async () => {
152
+ renderSwitcher()
153
+ const [, slug] = (await submitName('审计-Webhooks-')) as [string, string]
154
+ expect(slug).toBe('webhooks')
155
+ })
156
+
157
+ it('G2:过短名补随机后缀达服务端 4 字符下限(ab → ab-xxxx)', async () => {
158
+ renderSwitcher()
159
+ const [, slug] = (await submitName('ab')) as [string, string]
160
+ expect(slug).toMatch(/^ab-[a-z0-9]{4}$/)
161
+ })
162
+ })
@@ -0,0 +1,133 @@
1
+ import '@testing-library/jest-dom/vitest'
2
+ import { fireEvent, render, screen, waitFor } from '@testing-library/react'
3
+ import { describe, expect, it } from 'vitest'
4
+ import { z } from 'zod'
5
+ import { FormField } from '../FormField'
6
+ import { useZodForm } from '@ploykit/hooks'
7
+
8
+ const schema = z.object({ email: z.string().email('邮箱格式无效') })
9
+ type FormValues = z.infer<typeof schema>
10
+
11
+ let submitted: string[] = []
12
+
13
+
14
+ function DemoForm() {
15
+ const { register, handleSubmit, formState } = useZodForm<FormValues>({ schema })
16
+ return (
17
+ <form
18
+ onSubmit={handleSubmit((values) => submitted.push(values.email))}
19
+ noValidate
20
+ >
21
+ <FormField label="邮箱" error={formState.errors.email?.message} htmlFor="email">
22
+ <input id="email" {...register('email')} />
23
+ </FormField>
24
+ <button type="submit">提交</button>
25
+ </form>
26
+ )
27
+ }
28
+
29
+ describe('useZodForm + FormField', () => {
30
+ it('空值提交 → formState.errors.email 非空且 FormField 显示错误文案', async () => {
31
+ submitted = []
32
+ render(<DemoForm />)
33
+ fireEvent.click(screen.getByRole('button', { name: '提交' }))
34
+ expect(await screen.findByText('邮箱格式无效')).toBeInTheDocument()
35
+ expect(submitted).toEqual([])
36
+ })
37
+
38
+ it('合法值提交 → 通过校验、无错误文案、onSubmit 收到值', async () => {
39
+ submitted = []
40
+ render(<DemoForm />)
41
+ fireEvent.change(screen.getByLabelText('邮箱'), { target: { value: 'a@b.com' } })
42
+ fireEvent.click(screen.getByRole('button', { name: '提交' }))
43
+ await waitFor(() => expect(submitted).toEqual(['a@b.com']))
44
+ expect(screen.queryByText('邮箱格式无效')).not.toBeInTheDocument()
45
+ })
46
+
47
+ it('FormField 渲染 label 并通过 htmlFor 关联控件', () => {
48
+ render(
49
+ <FormField label="名称" htmlFor="name">
50
+ <input id="name" />
51
+ </FormField>,
52
+ )
53
+ expect(screen.getByLabelText('名称')).toBe(screen.getByRole('textbox'))
54
+ })
55
+ })
56
+
57
+
58
+
59
+ describe('FormField aria 回连(G7.3 / B5)', () => {
60
+ it('出错时控件 aria-invalid=true 且 aria-describedby 同时指向说明与错误节点', () => {
61
+ render(
62
+ <FormField
63
+ label="昵称" htmlFor="f1" error="请输入昵称" description="对外展示的名称"
64
+ >
65
+ <input id="f1" />
66
+ </FormField>,
67
+ )
68
+ const input = screen.getByRole('textbox')
69
+ expect(input).toHaveAttribute('aria-invalid', 'true')
70
+ expect(input.getAttribute('aria-describedby')).toBe('f1-desc f1-error')
71
+
72
+ expect(document.getElementById('f1-desc')).toHaveTextContent('对外展示的名称')
73
+ const alert = document.getElementById('f1-error')
74
+ expect(alert).toHaveAttribute('role', 'alert')
75
+ expect(alert).toHaveTextContent('请输入昵称')
76
+ })
77
+
78
+ it('无错误时不设置 aria-invalid,aria-describedby 仅指向说明节点', () => {
79
+ render(
80
+ <FormField label="头像链接" htmlFor="f2" description="可选,需 http(s):// 开头">
81
+ <input id="f2" />
82
+ </FormField>,
83
+ )
84
+ const input = screen.getByRole('textbox')
85
+ expect(input).not.toHaveAttribute('aria-invalid')
86
+ expect(input.getAttribute('aria-describedby')).toBe('f2-desc')
87
+ expect(screen.queryByRole('alert')).toBeNull()
88
+ })
89
+
90
+ it('缺 htmlFor 时:aria-invalid 仍注入(自包含属性),aria-describedby 不注入(id 无锚点)', () => {
91
+ render(
92
+ <FormField label="名称" error="不能为空">
93
+ <input />
94
+ </FormField>,
95
+ )
96
+ const input = screen.getByRole('textbox')
97
+ expect(input).toHaveAttribute('aria-invalid', 'true')
98
+ expect(input).not.toHaveAttribute('aria-describedby')
99
+ expect(screen.getByRole('alert')).toHaveTextContent('不能为空')
100
+ })
101
+
102
+ it('调用方自带 aria-describedby 时拼接不覆盖', () => {
103
+ render(
104
+ <FormField label="名称" htmlFor="f3" error="不能为空">
105
+ <input id="f3" aria-describedby="custom-hint" />
106
+ </FormField>,
107
+ )
108
+ expect(screen.getByRole('textbox').getAttribute('aria-describedby')).toBe('custom-hint f3-error')
109
+ })
110
+
111
+ it('required:label 渲染 aria-hidden 星号(不进可访问名),控件 aria-required=true', () => {
112
+ render(
113
+ <FormField label="昵称" htmlFor="f4" required>
114
+ <input id="f4" />
115
+ </FormField>,
116
+ )
117
+ const star = screen.getByText('*')
118
+ expect(star).toHaveAttribute('aria-hidden', 'true')
119
+ expect(screen.getByRole('textbox')).toHaveAttribute('aria-required', 'true')
120
+
121
+ expect(screen.getByRole('textbox', { name: '昵称' })).toBeInTheDocument()
122
+ })
123
+
124
+ it('children 为字符串等非元素时安全渲染(不注入)', () => {
125
+ render(
126
+ <FormField label="区块" htmlFor="f5" error="不能为空">
127
+ 纯文本插槽
128
+ </FormField>,
129
+ )
130
+ expect(screen.getByText('纯文本插槽')).toBeInTheDocument()
131
+ expect(screen.getByRole('alert')).toHaveTextContent('不能为空')
132
+ })
133
+ })
@@ -0,0 +1,41 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { readFileSync, readdirSync } from 'node:fs'
3
+ import { join, dirname, relative } from 'node:path'
4
+ import { fileURLToPath } from 'node:url'
5
+
6
+ // 语义令牌合同:@ploykit/ui 全部源码只允许使用语义令牌类(bg-background /
7
+ // text-muted-foreground / text-(--success) 等),禁止经典调色板类
8
+ // (bg-gray-50、text-blue-600…)。经典类依赖消费方 CSS 保留 Tailwind 默认
9
+ // 色板——那不在令牌合同内,按 example 主题接入的产品会拿到裸页面
10
+ // (aiblog 实录问题 10)。状态色使用 CSS 变量直引语法 text-(--success),
11
+ // 与 lib/utils 的 STATUS_TONE_CLASS 同一词汇表。
12
+ const srcRoot = join(dirname(fileURLToPath(import.meta.url)), '../../..')
13
+ const CLASSIC = /(?:bg|text|border|ring|from|to|via|fill|stroke|outline|decoration|divide|accent|caret)-(?:gray|zinc|slate|neutral|stone|red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d/
14
+
15
+ function collect(dir: string): string[] {
16
+ const out: string[] = []
17
+ for (const e of readdirSync(dir, { withFileTypes: true })) {
18
+ const p = join(dir, e.name)
19
+ if (e.isDirectory()) { if (e.name !== '__tests__') out.push(...collect(p)) }
20
+ else if (/\.tsx?$/.test(e.name) && !/\.test\./.test(e.name)) out.push(p)
21
+ }
22
+ return out
23
+ }
24
+
25
+ describe('语义令牌合同:ui 包禁用经典调色板类', () => {
26
+ const files = collect(srcRoot)
27
+
28
+ it('扫描范围覆盖全部源码文件', () => {
29
+ expect(files.length).toBeGreaterThan(80)
30
+ })
31
+
32
+ for (const f of files) {
33
+ const rel = relative(srcRoot, f).replaceAll('\\', '/')
34
+ it(`${rel} 无经典调色板类`, () => {
35
+ const lines = readFileSync(f, 'utf8').split('\n')
36
+ lines.forEach((line, i) => {
37
+ expect(CLASSIC.test(line), `${rel}:${i + 1} 使用了经典调色板类(改用语义令牌或 (--var) 直引): ${line.trim().slice(0, 80)}`).toBe(false)
38
+ })
39
+ })
40
+ }
41
+ })
@@ -0,0 +1,11 @@
1
+ import '@testing-library/jest-dom/vitest'
2
+ import { render, screen } from '@testing-library/react'
3
+ import { describe, it, expect } from 'vitest'
4
+ import { Button } from '../ui/button'
5
+
6
+ describe('vitest 基建冒烟', () => {
7
+ it('渲染 Button 并断言在文档中', () => {
8
+ render(<Button>ok</Button>)
9
+ expect(screen.getByRole('button', { name: 'ok' })).toBeInTheDocument()
10
+ })
11
+ })
@@ -0,0 +1,27 @@
1
+ import '@testing-library/jest-dom/vitest'
2
+ import { render, screen } from '@testing-library/react'
3
+ import { describe, expect, it } from 'vitest'
4
+ import { toast, Toaster } from '../toast'
5
+
6
+ describe('toast / Toaster', () => {
7
+ it('toast.success 后 Toaster 渲染成功提示文案', async () => {
8
+ render(<Toaster />)
9
+ toast.success('保存成功')
10
+ expect(await screen.findByText('保存成功')).toBeInTheDocument()
11
+ })
12
+
13
+ it('toast.error 渲染错误提示文案(props 可透传覆盖默认值)', async () => {
14
+ render(<Toaster position="bottom-left" />)
15
+ toast.error('保存失败')
16
+ expect(await screen.findByText('保存失败')).toBeInTheDocument()
17
+ })
18
+
19
+
20
+
21
+
22
+
23
+
24
+ it('B1/G1:sonner 样式表随组件入口可解析(import 缺失即红)', async () => {
25
+ await expect(import('sonner/dist/styles.css')).resolves.toBeDefined()
26
+ })
27
+ })
@@ -0,0 +1,23 @@
1
+
2
+ import type { ComponentProps } from 'react'
3
+ import { Toaster as SonnerToaster, toast as sonnerToast } from 'sonner'
4
+
5
+
6
+
7
+
8
+ import 'sonner/dist/styles.css'
9
+
10
+ type SonnerToast = typeof sonnerToast
11
+
12
+
13
+ const originalError = sonnerToast.error
14
+ const errorWithLongerDuration: SonnerToast['error'] = (message, data) =>
15
+ originalError(message, { duration: 8000, ...data })
16
+
17
+ export const toast: SonnerToast = Object.assign(sonnerToast, {
18
+ error: errorWithLongerDuration,
19
+ })
20
+
21
+ export function Toaster(props: ComponentProps<typeof SonnerToaster>) {
22
+ return <SonnerToaster richColors position="top-right" containerAriaLabel="通知" {...props} />
23
+ }
@@ -0,0 +1,66 @@
1
+ import '@testing-library/jest-dom/vitest'
2
+ import { fireEvent, render, screen, waitFor } from '@testing-library/react'
3
+ import { useState } from 'react'
4
+ import { describe, it, expect } from 'vitest'
5
+ import {
6
+ AlertDialog, AlertDialogTrigger, AlertDialogContent, AlertDialogHeader,
7
+ AlertDialogTitle, AlertDialogDescription, AlertDialogFooter,
8
+ AlertDialogCancel, AlertDialogAction,
9
+ } from '../alert-dialog'
10
+
11
+
12
+ function Demo({ onConfirm }: { onConfirm?: () => void }) {
13
+ const [open, setOpen] = useState(false)
14
+ return (
15
+ <AlertDialog open={open} onOpenChange={setOpen}>
16
+ <AlertDialogTrigger>删除</AlertDialogTrigger>
17
+ <AlertDialogContent>
18
+ <AlertDialogHeader>
19
+ <AlertDialogTitle>确认删除?</AlertDialogTitle>
20
+ <AlertDialogDescription>此操作不可撤销</AlertDialogDescription>
21
+ </AlertDialogHeader>
22
+ <AlertDialogFooter>
23
+ <AlertDialogCancel>取消</AlertDialogCancel>
24
+ <AlertDialogAction
25
+ onClick={() => {
26
+ onConfirm?.()
27
+ setOpen(false)
28
+ }}
29
+ >
30
+ 确定
31
+ </AlertDialogAction>
32
+ </AlertDialogFooter>
33
+ </AlertDialogContent>
34
+ </AlertDialog>
35
+ )
36
+ }
37
+
38
+ describe('alert-dialog 冒烟', () => {
39
+ it('Trigger 打开 → Action 确认并关闭', async () => {
40
+ const confirmed: boolean[] = []
41
+ render(<Demo onConfirm={() => confirmed.push(true)} />)
42
+
43
+ fireEvent.click(screen.getByRole('button', { name: '删除' }))
44
+ expect(await screen.findByRole('alertdialog')).toBeInTheDocument()
45
+ expect(screen.getByText('确认删除?')).toBeVisible()
46
+ expect(screen.getByText('此操作不可撤销')).toBeVisible()
47
+
48
+ fireEvent.click(screen.getByRole('button', { name: '确定' }))
49
+ expect(confirmed).toEqual([true])
50
+ await waitFor(() =>
51
+ expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument(),
52
+ )
53
+ })
54
+
55
+ it('Cancel 关闭弹窗且不触发确认', async () => {
56
+ const confirmed: boolean[] = []
57
+ render(<Demo onConfirm={() => confirmed.push(true)} />)
58
+
59
+ fireEvent.click(screen.getByRole('button', { name: '删除' }))
60
+ fireEvent.click(await screen.findByRole('button', { name: '取消' }))
61
+ await waitFor(() =>
62
+ expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument(),
63
+ )
64
+ expect(confirmed).toEqual([])
65
+ })
66
+ })
@@ -0,0 +1,30 @@
1
+ import '@testing-library/jest-dom/vitest'
2
+ import { render, screen } from '@testing-library/react'
3
+ import { describe, it, expect } from 'vitest'
4
+ import { Avatar, AvatarImage, AvatarFallback, AvatarGroup, AvatarGroupCount, AvatarBadge } from '../avatar'
5
+
6
+ describe('avatar 冒烟', () => {
7
+ it('无图时渲染 AvatarFallback 内容', () => {
8
+ render(
9
+ <Avatar>
10
+ <AvatarImage src="" alt="张三" />
11
+ <AvatarFallback>张</AvatarFallback>
12
+ </Avatar>,
13
+ )
14
+ expect(screen.getByText('张')).toBeVisible()
15
+ })
16
+
17
+ it('AvatarGroup + AvatarBadge + AvatarGroupCount 组合渲染', () => {
18
+ render(
19
+ <AvatarGroup>
20
+ <Avatar>
21
+ <AvatarFallback>甲</AvatarFallback>
22
+ <AvatarBadge />
23
+ </Avatar>
24
+ <AvatarGroupCount>+3</AvatarGroupCount>
25
+ </AvatarGroup>,
26
+ )
27
+ expect(screen.getByText('甲')).toBeVisible()
28
+ expect(screen.getByText('+3')).toBeVisible()
29
+ })
30
+ })
@@ -0,0 +1,18 @@
1
+ import '@testing-library/jest-dom/vitest'
2
+ import { fireEvent, render, screen } from '@testing-library/react'
3
+ import { describe, it, expect, vi } from 'vitest'
4
+ import { Checkbox } from '../checkbox'
5
+
6
+ describe('checkbox 冒烟', () => {
7
+ it('点击切换 aria-checked 并触发 onCheckedChange', () => {
8
+ const onChange = vi.fn()
9
+ render(<Checkbox defaultChecked onCheckedChange={onChange} aria-label="同意条款" />)
10
+
11
+ const cb = screen.getByRole('checkbox', { name: '同意条款' })
12
+ expect(cb).toHaveAttribute('aria-checked', 'true')
13
+
14
+ fireEvent.click(cb)
15
+ expect(cb).toHaveAttribute('aria-checked', 'false')
16
+ expect(onChange).toHaveBeenCalledWith(false, expect.anything())
17
+ })
18
+ })
@@ -0,0 +1,33 @@
1
+ import '@testing-library/jest-dom/vitest'
2
+ import { fireEvent, render, screen, waitFor } from '@testing-library/react'
3
+ import { describe, it, expect } from 'vitest'
4
+ import {
5
+ DropdownMenu, DropdownMenuTrigger, DropdownMenuContent,
6
+ DropdownMenuItem, DropdownMenuSeparator,
7
+ } from '../dropdown-menu'
8
+
9
+ describe('dropdown-menu 冒烟', () => {
10
+ it('Trigger 打开菜单;点击 MenuItem 触发回调并关闭', async () => {
11
+ const picked: string[] = []
12
+ render(
13
+ <DropdownMenu>
14
+ <DropdownMenuTrigger>操作</DropdownMenuTrigger>
15
+ <DropdownMenuContent>
16
+ <DropdownMenuItem onClick={() => picked.push('edit')}>编辑</DropdownMenuItem>
17
+ <DropdownMenuSeparator />
18
+ <DropdownMenuItem variant="destructive">删除</DropdownMenuItem>
19
+ </DropdownMenuContent>
20
+ </DropdownMenu>,
21
+ )
22
+
23
+ fireEvent.click(screen.getByRole('button', { name: '操作' }))
24
+ expect(await screen.findByRole('menu')).toBeInTheDocument()
25
+ expect(screen.getByRole('menuitem', { name: '编辑' })).toBeVisible()
26
+
27
+ fireEvent.click(screen.getByRole('menuitem', { name: '编辑' }))
28
+ expect(picked).toEqual(['edit'])
29
+ await waitFor(() =>
30
+ expect(screen.queryByRole('menu')).not.toBeInTheDocument(),
31
+ )
32
+ })
33
+ })
@@ -0,0 +1,43 @@
1
+ import '@testing-library/jest-dom/vitest'
2
+ import { render, screen } from '@testing-library/react'
3
+ import { describe, it, expect } from 'vitest'
4
+ import {
5
+ Field, FieldLabel, FieldDescription, FieldError, FieldSeparator, FieldGroup,
6
+ } from '../field'
7
+
8
+ describe('field 体系冒烟', () => {
9
+ it('Field(role=group) + FieldLabel(htmlFor) 关联控件', () => {
10
+ render(
11
+ <Field>
12
+ <FieldLabel htmlFor="name">名称</FieldLabel>
13
+ <input id="name" />
14
+ </Field>,
15
+ )
16
+ expect(screen.getByRole('group')).toBeInTheDocument()
17
+ expect(screen.getByLabelText('名称')).toBe(screen.getByRole('textbox'))
18
+ })
19
+
20
+ it('FieldError 渲染 errors 去重后的 message,并保持 role=alert', () => {
21
+ render(
22
+ <FieldError
23
+ errors={[{ message: '必填' }, { message: '必填' }, { message: '邮箱格式无效' }]}
24
+ />,
25
+ )
26
+ const alert = screen.getByRole('alert')
27
+ expect(alert).toHaveTextContent('必填')
28
+ expect(alert).toHaveTextContent('邮箱格式无效')
29
+ })
30
+
31
+ it('无内容时 FieldError 不渲染;FieldDescription/FieldSeparator 正常出文案', () => {
32
+ render(
33
+ <FieldGroup>
34
+ <FieldError errors={[]} />
35
+ <FieldDescription>辅助说明文字</FieldDescription>
36
+ <FieldSeparator>Or continue with</FieldSeparator>
37
+ </FieldGroup>,
38
+ )
39
+ expect(screen.queryByRole('alert')).not.toBeInTheDocument()
40
+ expect(screen.getByText('辅助说明文字')).toBeInTheDocument()
41
+ expect(screen.getByText('Or continue with')).toBeInTheDocument()
42
+ })
43
+ })