@ploykit/ui 0.6.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,239 @@
1
+ import '@testing-library/jest-dom/vitest'
2
+ import { fireEvent, render, screen, waitFor } from '@testing-library/react'
3
+ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
4
+ import { describe, it, expect, vi, beforeEach } from 'vitest'
5
+ import { SchedulesPage } from '../SchedulesPage'
6
+ import { browserTzLabel, formatDateTimeTz } from '../../../lib/utils'
7
+
8
+
9
+
10
+ const apiMocks = vi.hoisted(() => ({
11
+ get: vi.fn(), post: vi.fn(), put: vi.fn(), patch: vi.fn(), delete: vi.fn(),
12
+ }))
13
+ vi.mock('../../../../../hooks/src/hooks/useApi', () => ({ useApi: () => apiMocks }))
14
+
15
+ const clientPost = vi.hoisted(() => vi.fn())
16
+ vi.mock('@ploykit/client', () => {
17
+ class ApiError extends Error {
18
+ status: number
19
+ code?: string
20
+ constructor(status: number, code: string | undefined, message: string) {
21
+ super(message)
22
+ this.status = status
23
+ this.code = code
24
+ }
25
+ }
26
+ return {
27
+ api: { post: clientPost },
28
+ ApiError,
29
+ setWorkspaceIdProvider: vi.fn(),
30
+ }
31
+ })
32
+
33
+ vi.mock('../../../../../hooks/src/hooks/useWorkspace', () => ({
34
+ useWorkspace: () => ({
35
+ current: { id: 'ws-1', name: 'Acme', slug: 'acme', plan_code: 'free', role: 'owner' },
36
+ switchTo: vi.fn(),
37
+ clear: vi.fn(),
38
+ }),
39
+ }))
40
+
41
+
42
+
43
+ const confirmMock = vi.hoisted(() => ({ calls: [] as Array<{ title?: string; description?: string }>, ret: true }))
44
+ vi.mock('../../../components/ConfirmDialog', () => ({
45
+ useConfirm: () => async (opts: { title?: string; description?: string }) => {
46
+ confirmMock.calls.push(opts)
47
+ return confirmMock.ret
48
+ },
49
+ }))
50
+
51
+ const toastMocks = vi.hoisted(() => ({
52
+ success: vi.fn(), error: vi.fn(), info: vi.fn(),
53
+ }))
54
+ vi.mock('../../../components/toast', () => ({ toast: toastMocks }))
55
+
56
+ function renderPage() {
57
+ const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } })
58
+ return render(
59
+ <QueryClientProvider client={qc}>
60
+ <SchedulesPage />
61
+ </QueryClientProvider>,
62
+ )
63
+ }
64
+
65
+ const plan = {
66
+ id: 'plan-1', workspace_id: 'ws-1', kind: 'task.cleanup',
67
+ cron_expr: '0 9 * * *', timezone: 'Asia/Shanghai',
68
+ next_fire_at: '2026-10-07T01:00:00Z', misfire: 'skip',
69
+ last_fired_at: null, created_at: '2026-10-06T00:00:00Z', enabled: true,
70
+ }
71
+
72
+ beforeEach(() => {
73
+ vi.clearAllMocks()
74
+ confirmMock.calls.length = 0
75
+ confirmMock.ret = true
76
+ apiMocks.get.mockImplementation(async (path: string) => {
77
+ if (path === '/api/schedules') return { items: [plan] }
78
+ throw new Error('unexpected GET ' + path)
79
+ })
80
+ apiMocks.post.mockResolvedValue(plan)
81
+ apiMocks.patch.mockResolvedValue({ ...plan, enabled: false })
82
+ apiMocks.delete.mockResolvedValue(undefined)
83
+ clientPost.mockResolvedValue({ items: ['2026-10-07T01:00:00Z', '2026-10-08T01:00:00Z', '2026-10-09T01:00:00Z'] })
84
+ })
85
+
86
+ describe('SchedulesPage(调度页:列表/启停/新建)', () => {
87
+ it('渲染计划表(kind/表达式/时区/错过策略/下次触发/Switch)', async () => {
88
+ renderPage()
89
+ expect(await screen.findByText('task.cleanup')).toBeInTheDocument()
90
+ expect(screen.getByText('0 9 * * *')).toBeInTheDocument()
91
+ expect(screen.getByText('Asia/Shanghai')).toBeInTheDocument()
92
+ expect(screen.getByText('错过跳过')).toBeInTheDocument()
93
+ expect(screen.getByText('每天 09:00')).toBeInTheDocument()
94
+ expect(screen.getByRole('switch', { name: /停用计划 task.cleanup/ })).toHaveAttribute('aria-checked', 'true')
95
+ })
96
+
97
+ it('Switch 停用 → PATCH /api/schedules/{id} {enabled:false}', async () => {
98
+ renderPage()
99
+ fireEvent.click(await screen.findByRole('switch', { name: /停用计划/ }))
100
+ await waitFor(() =>
101
+ expect(apiMocks.patch).toHaveBeenCalledWith('/api/schedules/plan-1', { enabled: false }),
102
+ )
103
+ await waitFor(() => expect(toastMocks.success).toHaveBeenCalled())
104
+ })
105
+
106
+ it('新建计划:填 kind + CronInput 预览 + 提交 POST /api/schedules', async () => {
107
+ renderPage()
108
+ fireEvent.click(await screen.findByRole('button', { name: /创建计划/ }))
109
+
110
+ fireEvent.change(await screen.findByLabelText('触发标识(kind)'), {
111
+ target: { value: 'task.cleanup' },
112
+ })
113
+
114
+
115
+ fireEvent.change(screen.getByLabelText('Cron 表达式'), { target: { value: '0 9 * * *' } })
116
+ fireEvent.click(screen.getByRole('button', { name: /预览下次 3 次/ }))
117
+ await waitFor(() =>
118
+ expect(clientPost).toHaveBeenCalledWith('/api/schedules/preview', {
119
+ cron_expr: '0 9 * * *',
120
+ timezone: expect.any(String),
121
+ count: 3,
122
+ }),
123
+ )
124
+
125
+ fireEvent.click(screen.getByRole('button', { name: '创建计划' }))
126
+ await waitFor(() =>
127
+ expect(apiMocks.post).toHaveBeenCalledWith('/api/schedules', {
128
+ kind: 'task.cleanup',
129
+ cron_expr: '0 9 * * *',
130
+ timezone: expect.any(String),
131
+ misfire: 'skip',
132
+ }),
133
+ )
134
+ await waitFor(() => expect(toastMocks.success).toHaveBeenCalled())
135
+ })
136
+
137
+ it('空态:无计划时显示引导', async () => {
138
+ apiMocks.get.mockResolvedValue({ items: [] })
139
+ renderPage()
140
+ expect(await screen.findByText('暂无调度计划')).toBeInTheDocument()
141
+ })
142
+
143
+
144
+
145
+ it('删除:确认弹窗回显 kind+下次触发+不可恢复,放行后 DELETE /api/schedules/{id}', async () => {
146
+ renderPage()
147
+ fireEvent.click(await screen.findByRole('button', { name: '删除' }))
148
+
149
+
150
+ expect(confirmMock.calls.length).toBe(1)
151
+ expect(confirmMock.calls[0].title).toBe('删除调度计划')
152
+ expect(confirmMock.calls[0].description).toContain('task.cleanup')
153
+ expect(confirmMock.calls[0].description).toContain('每天 09:00')
154
+ expect(confirmMock.calls[0].description).toContain('Asia/Shanghai')
155
+ expect(confirmMock.calls[0].description).toContain('不可恢复')
156
+
157
+ await waitFor(() => expect(apiMocks.delete).toHaveBeenCalledWith('/api/schedules/plan-1'))
158
+ await waitFor(() => expect(toastMocks.success).toHaveBeenCalledWith('计划 task.cleanup 已删除'))
159
+ })
160
+
161
+ it('删除:确认取消时不发 DELETE', async () => {
162
+ confirmMock.ret = false
163
+ renderPage()
164
+ fireEvent.click(await screen.findByRole('button', { name: '删除' }))
165
+ await waitFor(() => expect(confirmMock.calls.length).toBe(1))
166
+ expect(apiMocks.delete).not.toHaveBeenCalled()
167
+ })
168
+
169
+
170
+
171
+ it('编辑:对话框回填行值(kind 只读),改 cron 提交 PATCH 只含改动字段', async () => {
172
+ renderPage()
173
+ fireEvent.click(await screen.findByRole('button', { name: '编辑' }))
174
+
175
+
176
+ const kindInput = await screen.findByLabelText('触发标识(kind,不可修改)')
177
+ expect(kindInput).toHaveValue('task.cleanup')
178
+ expect(kindInput).toBeDisabled()
179
+ expect(screen.getByLabelText('Cron 表达式')).toHaveValue('0 9 * * *')
180
+
181
+
182
+ fireEvent.change(screen.getByLabelText('Cron 表达式'), { target: { value: '30 8 * * 1' } })
183
+ fireEvent.click(screen.getByRole('button', { name: '保存修改' }))
184
+
185
+ await waitFor(() =>
186
+ expect(apiMocks.patch).toHaveBeenCalledWith('/api/schedules/plan-1', { cron_expr: '30 8 * * 1' }),
187
+ )
188
+ await waitFor(() => expect(toastMocks.success).toHaveBeenCalled())
189
+ })
190
+
191
+ it('编辑:无改动提交直接关窗,不发 PATCH', async () => {
192
+ renderPage()
193
+ fireEvent.click(await screen.findByRole('button', { name: '编辑' }))
194
+ await screen.findByLabelText('触发标识(kind,不可修改)')
195
+ fireEvent.click(screen.getByRole('button', { name: '保存修改' }))
196
+ await waitFor(() => expect(screen.queryByLabelText('触发标识(kind,不可修改)')).not.toBeInTheDocument())
197
+ expect(apiMocks.patch).not.toHaveBeenCalled()
198
+ })
199
+
200
+
201
+
202
+ it('下次触发正文带 (GMT±X) 口径,title 提示计划时区与显示口径', async () => {
203
+ renderPage()
204
+
205
+ expect(await screen.findByText(formatDateTimeTz(plan.next_fire_at))).toBeInTheDocument()
206
+
207
+
208
+ const labeled = screen.getByTitle(`计划时区 Asia/Shanghai;此处按浏览器本地时区(${browserTzLabel()})显示`)
209
+ expect(labeled).toHaveTextContent(formatDateTimeTz(plan.next_fire_at))
210
+ })
211
+
212
+ it('最近触发同样带口径标注(有值行)', async () => {
213
+ apiMocks.get.mockImplementation(async (path: string) => {
214
+ if (path === '/api/schedules') {
215
+ return { items: [{ ...plan, last_fired_at: '2026-10-06T01:00:00Z' }] }
216
+ }
217
+ throw new Error('unexpected GET ' + path)
218
+ })
219
+ renderPage()
220
+ expect(await screen.findByText(formatDateTimeTz('2026-10-06T01:00:00Z'))).toBeInTheDocument()
221
+ })
222
+
223
+
224
+
225
+ it('kind code 带 break-all + max-w 收纳(100 字符边界不再把列推出视口)', async () => {
226
+ apiMocks.get.mockImplementation(async (path: string) => {
227
+ if (path === '/api/schedules') {
228
+ const longKind = 'a'.repeat(100)
229
+ return { items: [{ ...plan, kind: longKind }] }
230
+ }
231
+ throw new Error('unexpected GET ' + path)
232
+ })
233
+ renderPage()
234
+ const code = await screen.findByText('a'.repeat(100))
235
+ expect(code.tagName).toBe('CODE')
236
+ expect(code).toHaveClass('break-all')
237
+ expect(code.className).toContain('max-w-')
238
+ })
239
+ })
@@ -0,0 +1,223 @@
1
+ import '@testing-library/jest-dom/vitest'
2
+ import { fireEvent, render, screen, within } from '@testing-library/react'
3
+ import { describe, it, expect, vi, beforeEach } from 'vitest'
4
+ import { MemoryRouter } from 'react-router'
5
+ import { UsagePage } from '../UsagePage'
6
+
7
+ const usageMocks = vi.hoisted(() => ({ useUsage: vi.fn() }))
8
+ vi.mock('../../../../../hooks/src/hooks/useUsage', () => ({ useUsage: usageMocks.useUsage }))
9
+
10
+ const usage = {
11
+ workspace_id: 'ws-1',
12
+ plan_code: 'starter',
13
+ period: '2026-10',
14
+ items: [
15
+ { key: 'tasks_monthly', limit: 1000, used: 250, granted: 0, period: '2026-10' },
16
+ { key: 'seats', limit: -1, used: 3, granted: 0, period: '2026-10' },
17
+ ],
18
+ }
19
+
20
+
21
+ const usageWithReserved = {
22
+ workspace_id: 'ws-1',
23
+ plan_code: 'pro',
24
+ period: '2026-10',
25
+ items: [
26
+ {
27
+ key: 'tasks_monthly', limit: 1000, used: 250, granted: 0, reserved: 120, period: '2026-10',
28
+ reservations: [
29
+ { id: 'rsv-1', amount: 80, status: 'reserved', expires_at: '2026-10-07T12:34:56Z' },
30
+ { id: 'rsv-2', amount: 40, status: 'reserved', expires_at: '2026-10-07T09:00:00Z' },
31
+ ],
32
+ },
33
+
34
+ { key: 'seats', limit: 10, used: 2, granted: 0, reserved: 0, period: '2026-10' },
35
+ ],
36
+ }
37
+
38
+ beforeEach(() => {
39
+ vi.clearAllMocks()
40
+ })
41
+
42
+ describe('UsagePage', () => {
43
+ it('加载中显示 PageLoading', () => {
44
+ usageMocks.useUsage.mockReturnValue({
45
+ wsId: 'ws-1', usage: null, loading: true, error: null, refetch: vi.fn(),
46
+ })
47
+ render(<UsagePage />)
48
+ expect(screen.getByText('加载用量数据…')).toBeInTheDocument()
49
+ })
50
+
51
+ it('按维度渲染卡片:used/limit 进度,limit=-1 显示“不限量”', () => {
52
+ usageMocks.useUsage.mockReturnValue({
53
+ wsId: 'ws-1', usage, loading: false, error: null, refetch: vi.fn(),
54
+ })
55
+ render(
56
+ <MemoryRouter>
57
+ <UsagePage />
58
+ </MemoryRouter>,
59
+ )
60
+
61
+
62
+ expect(screen.getAllByText('任务数(本月)').length).toBeGreaterThanOrEqual(1)
63
+
64
+ expect(screen.getByText(/250 \/ 1,000/)).toBeInTheDocument()
65
+ expect(screen.getAllByRole('progressbar')).toHaveLength(2)
66
+
67
+
68
+ expect(screen.getByText('不限量')).toBeInTheDocument()
69
+ expect(screen.getByText(/3 \/ ∞/)).toBeInTheDocument()
70
+
71
+
72
+ expect(screen.getByText(/starter/)).toBeInTheDocument()
73
+ expect(screen.getByText(/2026-10/)).toBeInTheDocument()
74
+ })
75
+
76
+ it('B2:卡内正文不出现原始 API 键,中文标签进卡题与进度行,原始键留 title 悬停', () => {
77
+ usageMocks.useUsage.mockReturnValue({
78
+ wsId: 'ws-1', usage, loading: false, error: null, refetch: vi.fn(),
79
+ })
80
+ render(
81
+ <MemoryRouter>
82
+ <UsagePage />
83
+ </MemoryRouter>,
84
+ )
85
+
86
+
87
+ expect(screen.queryByText('tasks_monthly')).not.toBeInTheDocument()
88
+ expect(screen.queryByText('seats')).not.toBeInTheDocument()
89
+
90
+
91
+ expect(screen.getByTitle('tasks_monthly')).toBeInTheDocument()
92
+
93
+
94
+ const card = screen.getByTitle('tasks_monthly').closest('div[data-slot="card"]')
95
+ expect(card).toBeTruthy()
96
+ expect(within(card as HTMLElement).getAllByText('任务数(本月)').length).toBeGreaterThanOrEqual(2)
97
+ })
98
+
99
+ it('B6:大数字千分位(12,500 / 50,000)', () => {
100
+ usageMocks.useUsage.mockReturnValue({
101
+ wsId: 'ws-1',
102
+ usage: {
103
+ workspace_id: 'ws-1',
104
+ plan_code: 'free',
105
+ period: '2026-10',
106
+ items: [
107
+ { key: 'tasks_monthly', limit: 50000, used: 12500, granted: 0, period: '2026-10' },
108
+ ],
109
+ },
110
+ loading: false, error: null, refetch: vi.fn(),
111
+ })
112
+ render(
113
+ <MemoryRouter>
114
+ <UsagePage />
115
+ </MemoryRouter>,
116
+ )
117
+ expect(screen.getByText(/12,500 \/ 50,000/)).toBeInTheDocument()
118
+ })
119
+
120
+ it('已满卡片:已用尽徽标 + 升级 CTA(文案不承诺“将被拒绝”)+ 命中区 ≥24px 补偿', () => {
121
+ usageMocks.useUsage.mockReturnValue({
122
+ wsId: 'ws-1',
123
+ usage: {
124
+ workspace_id: 'ws-1',
125
+ plan_code: 'free',
126
+ period: '2026-10',
127
+ items: [
128
+
129
+ { key: 'workspaces', limit: 1, used: 3, granted: 0, period: '2026-10' },
130
+ ],
131
+ },
132
+ loading: false, error: null, refetch: vi.fn(),
133
+ })
134
+ render(
135
+ <MemoryRouter>
136
+ <UsagePage />
137
+ </MemoryRouter>,
138
+ )
139
+
140
+ expect(screen.getByText('已用尽')).toBeInTheDocument()
141
+ expect(screen.getByText(/已用尽,可升级套餐提高上限/)).toBeInTheDocument()
142
+ expect(screen.queryByText(/超额操作将被拒绝/)).not.toBeInTheDocument()
143
+
144
+
145
+ const link = screen.getByRole('link', { name: '升级套餐' })
146
+ expect(link.getAttribute('href')).toBe('/settings/workspace/billing')
147
+ expect(link.className).toContain('py-1.5')
148
+ expect(link.className).toContain('-my-1.5')
149
+ })
150
+
151
+ it('reserved 缺省(老后端/未重生成类型)→ 回退两段展示,无“预留中”段落', () => {
152
+ usageMocks.useUsage.mockReturnValue({
153
+ wsId: 'ws-1', usage, loading: false, error: null, refetch: vi.fn(),
154
+ })
155
+ render(<UsagePage />)
156
+
157
+ expect(screen.queryByText(/预留中/)).not.toBeInTheDocument()
158
+ expect(screen.queryByText(/预留明细/)).not.toBeInTheDocument()
159
+ expect(screen.getByText(/250 \/ 1,000/)).toBeInTheDocument()
160
+ })
161
+
162
+ it('三段展示:已用 / 预留中 / 上限(reserved>0 的维度渲染分段条与第三段数值)', () => {
163
+ usageMocks.useUsage.mockReturnValue({
164
+ wsId: 'ws-1', usage: usageWithReserved, loading: false, error: null, refetch: vi.fn(),
165
+ })
166
+ render(
167
+ <MemoryRouter>
168
+ <UsagePage />
169
+ </MemoryRouter>,
170
+ )
171
+
172
+
173
+ const card = screen.getByTitle('tasks_monthly').closest('div[data-slot="card"]')
174
+ expect(card).toBeTruthy()
175
+ const scope = within(card as HTMLElement)
176
+
177
+
178
+ expect(scope.getByText(/250/)).toBeInTheDocument()
179
+ expect(scope.getByText(/120/)).toBeInTheDocument()
180
+ expect(scope.getByText(/1,000/)).toBeInTheDocument()
181
+ expect(scope.getAllByText(/预留中/).length).toBeGreaterThan(0)
182
+
183
+
184
+ expect(scope.getAllByRole('progressbar')).toHaveLength(1)
185
+ expect(scope.getByTestId('progress-indicator-used')).toBeInTheDocument()
186
+ expect(scope.getByTestId('progress-indicator-reserved')).toBeInTheDocument()
187
+
188
+
189
+ const seats = screen.getByTitle('seats').closest('div[data-slot="card"]')
190
+ expect(within(seats as HTMLElement).queryByTestId('progress-indicator-reserved')).not.toBeInTheDocument()
191
+ })
192
+
193
+ it('预留明细可展开:数量/状态/到期时间(DataTable 复用)', () => {
194
+ usageMocks.useUsage.mockReturnValue({
195
+ wsId: 'ws-1', usage: usageWithReserved, loading: false, error: null, refetch: vi.fn(),
196
+ })
197
+ render(<UsagePage />)
198
+
199
+ const summary = screen.getByText('预留明细(2 笔)')
200
+ const details = summary.closest('details') as HTMLDetailsElement
201
+ expect(details).toBeTruthy()
202
+ expect(details.open).toBe(false)
203
+
204
+
205
+ expect(screen.getByText('80')).toBeInTheDocument()
206
+ expect(screen.getByText('40')).toBeInTheDocument()
207
+ expect(screen.getByText('2026-10-07 12:34')).toBeInTheDocument()
208
+ expect(screen.getByText('2026-10-07 09:00')).toBeInTheDocument()
209
+
210
+ expect(screen.getAllByText('预留中').length).toBeGreaterThanOrEqual(2)
211
+
212
+ fireEvent.click(summary)
213
+ expect(details.open).toBe(true)
214
+ })
215
+
216
+ it('无工作区上下文 → 引导空态', () => {
217
+ usageMocks.useUsage.mockReturnValue({
218
+ wsId: '', usage: null, loading: false, error: null, refetch: vi.fn(),
219
+ })
220
+ render(<UsagePage />)
221
+ expect(screen.getByText('请先创建并选择一个工作区')).toBeInTheDocument()
222
+ })
223
+ })