@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.
- package/dist/AppProviders.js +2 -2
- package/dist/components/ConfirmDialog.js +1 -1
- package/dist/components/CronInput.js +3 -3
- package/dist/components/DataTable.js +4 -4
- package/dist/components/FormField.js +1 -1
- package/dist/components/ImpersonationBanner.js +1 -1
- package/dist/components/NotificationBell.js +1 -1
- package/dist/components/OnboardingChecklist.js +3 -3
- package/dist/components/Page.js +2 -2
- package/dist/components/ReauthDialog.js +4 -4
- package/dist/components/SecretModal.js +2 -2
- package/dist/components/SettingsCard.js +2 -2
- package/dist/components/SiteBanner.js +1 -1
- package/dist/components/SiteBannerLayer.js +1 -1
- package/dist/components/StatusBadge.js +1 -1
- package/dist/components/UserMenu.js +3 -3
- package/dist/components/WorkspaceSwitcher.js +7 -7
- package/dist/components/ui/alert-dialog.js +2 -2
- package/dist/components/ui/avatar.js +1 -1
- package/dist/components/ui/badge.js +1 -1
- package/dist/components/ui/breadcrumb.js +1 -1
- package/dist/components/ui/button.js +1 -1
- package/dist/components/ui/card.js +1 -1
- package/dist/components/ui/checkbox.js +1 -1
- package/dist/components/ui/dialog.js +2 -2
- package/dist/components/ui/dropdown-menu.js +1 -1
- package/dist/components/ui/empty.js +1 -1
- package/dist/components/ui/field.js +3 -3
- package/dist/components/ui/input.js +1 -1
- package/dist/components/ui/label.js +1 -1
- package/dist/components/ui/popover.js +1 -1
- package/dist/components/ui/progress.js +1 -1
- package/dist/components/ui/select.js +1 -1
- package/dist/components/ui/separator.js +1 -1
- package/dist/components/ui/sheet.js +2 -2
- package/dist/components/ui/sidebar.js +8 -8
- package/dist/components/ui/skeleton.js +1 -1
- package/dist/components/ui/switch.js +1 -1
- package/dist/components/ui/tabs.js +1 -1
- package/dist/components/ui/textarea.js +1 -1
- package/dist/components/ui/tooltip.js +1 -1
- package/dist/index.js +78 -78
- package/dist/layouts/AppShell.js +4 -4
- package/dist/layouts/SettingsShell.js +4 -4
- package/dist/pages/LoginPage.js +5 -5
- package/dist/pages/account/NotificationPreferencesPage.js +3 -3
- package/dist/pages/account/ProfileSettingsPage.js +9 -9
- package/dist/pages/account/SecuritySettingsPage.js +13 -13
- package/dist/pages/account/TokensPage.js +12 -12
- package/dist/pages/admin/AdminSettingsPage.js +11 -11
- package/dist/pages/admin/AdminShell.js +3 -3
- package/dist/pages/admin/AuditPage.js +8 -8
- package/dist/pages/admin/OrdersPage.js +12 -12
- package/dist/pages/admin/OverviewPage.js +13 -13
- package/dist/pages/admin/SSOPage.js +6 -6
- package/dist/pages/admin/UserDetailPage.js +13 -13
- package/dist/pages/admin/UsersPage.js +13 -13
- package/dist/pages/admin/WebhookDeliveriesPage.js +9 -9
- package/dist/pages/admin/WorkspaceDetailPage.js +14 -14
- package/dist/pages/admin/WorkspacesPage.js +9 -9
- package/dist/pages/admin/shared.js +1 -1
- package/dist/pages/auth/ForgotPasswordPage.js +4 -4
- package/dist/pages/auth/InviteAcceptPage.js +6 -6
- package/dist/pages/auth/RegisterPage.js +7 -7
- package/dist/pages/auth/ResetPasswordPage.js +5 -5
- package/dist/pages/auth/VerifyEmailPage.js +3 -3
- package/dist/pages/workspace/BillingPage.js +11 -11
- package/dist/pages/workspace/BillingSuccessPage.js +2 -2
- package/dist/pages/workspace/MembersPage.js +15 -15
- package/dist/pages/workspace/RolePermissionsPage.js +9 -9
- package/dist/pages/workspace/SchedulesPage.js +12 -12
- package/dist/pages/workspace/UsagePage.js +6 -6
- package/dist/pages/workspace/WebhooksPage.js +16 -16
- package/dist/pages/workspace/WorkspaceAuditPage.js +7 -7
- package/dist/pages/workspace/WorkspaceGeneralPage.js +9 -9
- package/package.json +6 -5
- package/src/AppProviders.tsx +29 -0
- package/src/components/ConfirmDialog.tsx +77 -0
- package/src/components/CronInput.tsx +195 -0
- package/src/components/DataTable.tsx +152 -0
- package/src/components/ErrorBoundary.tsx +46 -0
- package/src/components/FormField.tsx +56 -0
- package/src/components/ImpersonationBanner.tsx +23 -0
- package/src/components/NotificationBell.tsx +135 -0
- package/src/components/OnboardingChecklist.tsx +114 -0
- package/src/components/Page.tsx +67 -0
- package/src/components/ReauthDialog.tsx +94 -0
- package/src/components/SecretModal.tsx +147 -0
- package/src/components/SettingsCard.tsx +39 -0
- package/src/components/SiteBanner.tsx +32 -0
- package/src/components/SiteBannerLayer.tsx +29 -0
- package/src/components/StatusBadge.tsx +12 -0
- package/src/components/UserMenu.tsx +103 -0
- package/src/components/WorkspaceSwitcher.tsx +159 -0
- package/src/components/__tests__/AppProviders.test.tsx +111 -0
- package/src/components/__tests__/CronInput.test.tsx +180 -0
- package/src/components/__tests__/DataTable.test.tsx +208 -0
- package/src/components/__tests__/ErrorBoundary.test.tsx +61 -0
- package/src/components/__tests__/ImpersonationBanner.test.tsx +59 -0
- package/src/components/__tests__/NotificationBell.test.tsx +207 -0
- package/src/components/__tests__/OnboardingChecklist.test.tsx +97 -0
- package/src/components/__tests__/SecretModal.test.tsx +112 -0
- package/src/components/__tests__/SiteBannerLayer.test.tsx +44 -0
- package/src/components/__tests__/UserMenu.test.tsx +98 -0
- package/src/components/__tests__/WorkspaceSwitcher.test.tsx +162 -0
- package/src/components/__tests__/form.test.tsx +133 -0
- package/src/components/__tests__/semantic-tokens.test.ts +41 -0
- package/src/components/__tests__/smoke.test.tsx +11 -0
- package/src/components/__tests__/toast.test.tsx +27 -0
- package/src/components/toast.tsx +23 -0
- package/src/components/ui/__tests__/alert-dialog.test.tsx +66 -0
- package/src/components/ui/__tests__/avatar.test.tsx +30 -0
- package/src/components/ui/__tests__/checkbox.test.tsx +18 -0
- package/src/components/ui/__tests__/dropdown-menu.test.tsx +33 -0
- package/src/components/ui/__tests__/field.test.tsx +43 -0
- package/src/components/ui/__tests__/overlay-size.test.tsx +84 -0
- package/src/components/ui/__tests__/popover.test.tsx +32 -0
- package/src/components/ui/__tests__/progress.test.tsx +24 -0
- package/src/components/ui/__tests__/select-callback.test.tsx +72 -0
- package/src/components/ui/__tests__/select.test.tsx +48 -0
- package/src/components/ui/__tests__/sidebar.test.tsx +121 -0
- package/src/components/ui/__tests__/stock-mirror.test.ts +39 -0
- package/src/components/ui/__tests__/subpath.test.tsx +38 -0
- package/src/components/ui/__tests__/switch.test.tsx +24 -0
- package/src/components/ui/__tests__/tabs.test.tsx +24 -0
- package/src/components/ui/__tests__/textarea.test.tsx +41 -0
- package/src/components/ui/alert-dialog.tsx +187 -0
- package/src/components/ui/avatar.tsx +108 -0
- package/src/components/ui/badge.tsx +51 -0
- package/src/components/ui/breadcrumb.tsx +124 -0
- package/src/components/ui/button.tsx +57 -0
- package/src/components/ui/card.tsx +102 -0
- package/src/components/ui/checkbox.tsx +28 -0
- package/src/components/ui/dialog.tsx +166 -0
- package/src/components/ui/dropdown-menu.tsx +265 -0
- package/src/components/ui/empty.tsx +103 -0
- package/src/components/ui/field.tsx +238 -0
- package/src/components/ui/input.tsx +19 -0
- package/src/components/ui/label.tsx +17 -0
- package/src/components/ui/popover.tsx +89 -0
- package/src/components/ui/progress.tsx +82 -0
- package/src/components/ui/select.tsx +235 -0
- package/src/components/ui/separator.tsx +24 -0
- package/src/components/ui/sheet.tsx +146 -0
- package/src/components/ui/sidebar.tsx +723 -0
- package/src/components/ui/skeleton.tsx +13 -0
- package/src/components/ui/switch.tsx +29 -0
- package/src/components/ui/tabs.tsx +79 -0
- package/src/components/ui/textarea.tsx +26 -0
- package/src/components/ui/tooltip.tsx +63 -0
- package/src/dev.css +1 -0
- package/src/hooks/use-mobile.ts +19 -0
- package/src/index.ts +150 -0
- package/src/layouts/AppShell.tsx +163 -0
- package/src/layouts/AuthShell.tsx +24 -0
- package/src/layouts/SettingsShell.tsx +129 -0
- package/src/layouts/__tests__/AppShell.test.tsx +163 -0
- package/src/layouts/__tests__/SettingsShell.test.tsx +146 -0
- package/src/lib/__tests__/api-error.test.ts +163 -0
- package/src/lib/__tests__/utils.test.ts +21 -0
- package/src/lib/api-error.ts +98 -0
- package/src/lib/utils.ts +109 -0
- package/src/pages/LandingPage.tsx +88 -0
- package/src/pages/LoginPage.tsx +291 -0
- package/src/pages/__tests__/LandingPage.test.tsx +58 -0
- package/src/pages/__tests__/LoginPage.test.tsx +229 -0
- package/src/pages/__tests__/RegisterPage.test.tsx +138 -0
- package/src/pages/__tests__/ssr-smoke.test.tsx +99 -0
- package/src/pages/account/NotificationPreferencesPage.tsx +138 -0
- package/src/pages/account/ProfileSettingsPage.tsx +189 -0
- package/src/pages/account/SecuritySettingsPage.tsx +214 -0
- package/src/pages/account/TokensPage.tsx +211 -0
- package/src/pages/account/__tests__/NotificationPreferencesPage.test.tsx +150 -0
- package/src/pages/account/__tests__/ProfileSettingsPage.test.tsx +173 -0
- package/src/pages/account/__tests__/SecuritySettingsPage.test.tsx +212 -0
- package/src/pages/account/__tests__/TokensPage.test.tsx +180 -0
- package/src/pages/admin/AdminSettingsPage.tsx +564 -0
- package/src/pages/admin/AdminShell.tsx +199 -0
- package/src/pages/admin/AuditPage.tsx +338 -0
- package/src/pages/admin/OrdersPage.tsx +642 -0
- package/src/pages/admin/OverviewPage.tsx +559 -0
- package/src/pages/admin/SSOPage.tsx +166 -0
- package/src/pages/admin/UserDetailPage.tsx +641 -0
- package/src/pages/admin/UsersPage.tsx +462 -0
- package/src/pages/admin/WebhookDeliveriesPage.tsx +318 -0
- package/src/pages/admin/WorkspaceDetailPage.tsx +551 -0
- package/src/pages/admin/WorkspacesPage.tsx +299 -0
- package/src/pages/admin/__tests__/AdminSettingsPage.test.tsx +260 -0
- package/src/pages/admin/__tests__/OrdersPageMarkPaid.test.tsx +207 -0
- package/src/pages/admin/__tests__/OverviewPageActivity.test.tsx +213 -0
- package/src/pages/admin/__tests__/SSOPage.test.tsx +128 -0
- package/src/pages/admin/__tests__/UsersPageActions.test.tsx +188 -0
- package/src/pages/admin/__tests__/WorkspaceDetailPageTransfer.test.tsx +170 -0
- package/src/pages/admin/__tests__/WorkspacesPagePlans.test.tsx +138 -0
- package/src/pages/admin/shared.ts +42 -0
- package/src/pages/auth/ForgotPasswordPage.tsx +83 -0
- package/src/pages/auth/InviteAcceptPage.tsx +105 -0
- package/src/pages/auth/RegisterPage.tsx +204 -0
- package/src/pages/auth/ResetPasswordPage.tsx +150 -0
- package/src/pages/auth/VerifyEmailPage.tsx +76 -0
- package/src/pages/auth/__tests__/ForgotPasswordPage.test.tsx +67 -0
- package/src/pages/auth/__tests__/InviteAcceptPage.test.tsx +86 -0
- package/src/pages/auth/__tests__/ResetPasswordPage.test.tsx +115 -0
- package/src/pages/auth/__tests__/VerifyEmailPage.test.tsx +74 -0
- package/src/pages/workspace/BillingPage.tsx +361 -0
- package/src/pages/workspace/BillingSuccessPage.tsx +115 -0
- package/src/pages/workspace/MembersPage.tsx +568 -0
- package/src/pages/workspace/RolePermissionsPage.tsx +202 -0
- package/src/pages/workspace/SchedulesPage.tsx +387 -0
- package/src/pages/workspace/UsagePage.tsx +207 -0
- package/src/pages/workspace/WebhooksPage.tsx +506 -0
- package/src/pages/workspace/WorkspaceAuditPage.tsx +229 -0
- package/src/pages/workspace/WorkspaceGeneralPage.tsx +184 -0
- package/src/pages/workspace/__tests__/BillingPage.test.tsx +302 -0
- package/src/pages/workspace/__tests__/BillingSuccessPage.test.tsx +134 -0
- package/src/pages/workspace/__tests__/MembersPage.test.tsx +347 -0
- package/src/pages/workspace/__tests__/RolePermissionsPage.test.tsx +138 -0
- package/src/pages/workspace/__tests__/SchedulesPage.test.tsx +239 -0
- package/src/pages/workspace/__tests__/UsagePage.test.tsx +223 -0
- package/src/pages/workspace/__tests__/WebhooksPage.test.tsx +309 -0
- package/src/pages/workspace/__tests__/WorkspaceAuditPage.test.tsx +112 -0
- package/src/pages/workspace/__tests__/WorkspaceGeneralPage.test.tsx +164 -0
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import '@testing-library/jest-dom/vitest'
|
|
2
|
+
import { render, screen, fireEvent } from '@testing-library/react'
|
|
3
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
4
|
+
import { ErrorBoundary } from '../ErrorBoundary'
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
let shouldThrow = false
|
|
8
|
+
function Bomb() {
|
|
9
|
+
if (shouldThrow) throw new Error('炸弹爆炸')
|
|
10
|
+
return <div>正常内容</div>
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
describe('ErrorBoundary', () => {
|
|
14
|
+
beforeEach(() => {
|
|
15
|
+
shouldThrow = false
|
|
16
|
+
vi.spyOn(console, 'error').mockImplementation(() => {})
|
|
17
|
+
})
|
|
18
|
+
afterEach(() => {
|
|
19
|
+
vi.restoreAllMocks()
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
it('子组件 throw 时渲染 fallback 文案', () => {
|
|
23
|
+
shouldThrow = true
|
|
24
|
+
render(
|
|
25
|
+
<ErrorBoundary fallback={<div>自定义兜底</div>}>
|
|
26
|
+
<Bomb />
|
|
27
|
+
</ErrorBoundary>,
|
|
28
|
+
)
|
|
29
|
+
expect(screen.getByText('自定义兜底')).toBeInTheDocument()
|
|
30
|
+
expect(screen.queryByText('正常内容')).not.toBeInTheDocument()
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it('函数式 fallback 的 reset() 后恢复渲染 children', () => {
|
|
34
|
+
shouldThrow = true
|
|
35
|
+
render(
|
|
36
|
+
<ErrorBoundary fallback={(_error, reset) => <button onClick={reset}>重试</button>}>
|
|
37
|
+
<Bomb />
|
|
38
|
+
</ErrorBoundary>,
|
|
39
|
+
)
|
|
40
|
+
shouldThrow = false
|
|
41
|
+
fireEvent.click(screen.getByRole('button', { name: '重试' }))
|
|
42
|
+
expect(screen.getByText('正常内容')).toBeInTheDocument()
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('无 fallback 时显示默认错误态(含"出错了"与重试按钮)', () => {
|
|
46
|
+
shouldThrow = true
|
|
47
|
+
render(<ErrorBoundary><Bomb /></ErrorBoundary>)
|
|
48
|
+
expect(screen.getByText('出错了')).toBeInTheDocument()
|
|
49
|
+
expect(screen.getByRole('button', { name: '重试' })).toBeInTheDocument()
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('componentDidCatch 将错误记入 console.error', () => {
|
|
53
|
+
shouldThrow = true
|
|
54
|
+
render(
|
|
55
|
+
<ErrorBoundary fallback={<div>ok</div>}>
|
|
56
|
+
<Bomb />
|
|
57
|
+
</ErrorBoundary>,
|
|
58
|
+
)
|
|
59
|
+
expect(console.error).toHaveBeenCalled()
|
|
60
|
+
})
|
|
61
|
+
})
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import '@testing-library/jest-dom/vitest'
|
|
2
|
+
import { render, screen } from '@testing-library/react'
|
|
3
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
4
|
+
import { ImpersonationBanner } from '../ImpersonationBanner'
|
|
5
|
+
import type { PKUser } from '../../provider/PloykitProvider'
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
const mocks = vi.hoisted(() => ({ impersonatedBy: undefined as string | undefined, user: null as PKUser | null }))
|
|
10
|
+
vi.mock('../../../../hooks/src/hooks/useAuth', () => ({
|
|
11
|
+
useAuth: () => ({
|
|
12
|
+
user: mocks.user,
|
|
13
|
+
workspaces: [],
|
|
14
|
+
loading: false,
|
|
15
|
+
refresh: vi.fn(),
|
|
16
|
+
logout: vi.fn(async () => {}),
|
|
17
|
+
}),
|
|
18
|
+
}))
|
|
19
|
+
|
|
20
|
+
function impersonatedUser(impersonatedBy?: string): PKUser {
|
|
21
|
+
return {
|
|
22
|
+
email: 'bob@example.com',
|
|
23
|
+
display_name: 'Bob',
|
|
24
|
+
impersonated_by: impersonatedBy,
|
|
25
|
+
} as PKUser
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
describe('ImpersonationBanner(ADR 0008 模拟会话横幅)', () => {
|
|
29
|
+
beforeEach(() => {
|
|
30
|
+
mocks.impersonatedBy = undefined
|
|
31
|
+
mocks.user = null
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it('模拟会话(impersonated_by 非空):显示"您正处于管理员模拟会话"警示条', () => {
|
|
35
|
+
mocks.user = impersonatedUser('11111111-2222-3333-4444-555555555555')
|
|
36
|
+
render(<ImpersonationBanner />)
|
|
37
|
+
expect(screen.getByText('您正处于管理员模拟会话')).toBeInTheDocument()
|
|
38
|
+
expect(screen.getByText('您正处于管理员模拟会话')).toBeVisible()
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('普通会话(无 impersonated_by 字段):不渲染横幅、不留占位', () => {
|
|
42
|
+
mocks.user = impersonatedUser(undefined)
|
|
43
|
+
const { container } = render(<ImpersonationBanner />)
|
|
44
|
+
expect(screen.queryByText('您正处于管理员模拟会话')).not.toBeInTheDocument()
|
|
45
|
+
expect(container.querySelector('[data-slot="impersonation-banner"]')).not.toBeInTheDocument()
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it('未登录(user=null):不渲染横幅', () => {
|
|
49
|
+
mocks.user = null
|
|
50
|
+
render(<ImpersonationBanner />)
|
|
51
|
+
expect(screen.queryByText('您正处于管理员模拟会话')).not.toBeInTheDocument()
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it('横幅不提供退出按钮:退出由发起管理员控制(ADR 0008 裁决)', () => {
|
|
55
|
+
mocks.user = impersonatedUser('11111111-2222-3333-4444-555555555555')
|
|
56
|
+
render(<ImpersonationBanner />)
|
|
57
|
+
expect(screen.queryByRole('button')).not.toBeInTheDocument()
|
|
58
|
+
})
|
|
59
|
+
})
|
|
@@ -0,0 +1,207 @@
|
|
|
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 { NotificationBell } from '../NotificationBell'
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
const mockedGet = vi.hoisted(() => vi.fn())
|
|
14
|
+
const mockedPost = vi.hoisted(() => vi.fn())
|
|
15
|
+
vi.mock('@ploykit/client', () => ({
|
|
16
|
+
api: { get: mockedGet, post: mockedPost },
|
|
17
|
+
setWorkspaceIdProvider: vi.fn(),
|
|
18
|
+
}))
|
|
19
|
+
|
|
20
|
+
type N = {
|
|
21
|
+
id: string; type: string; title: string; body: string
|
|
22
|
+
link?: string | null; count: number; read_at?: string | null; created_at: string
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const now = '2026-10-06T08:00:00Z'
|
|
26
|
+
const unreadItem: N = {
|
|
27
|
+
id: 'n-1', type: 'quota_near_limit', title: '用量接近上限',
|
|
28
|
+
body: '本月任务数已用 90%', link: '/settings/workspace/usage', count: 1,
|
|
29
|
+
read_at: null, created_at: now,
|
|
30
|
+
}
|
|
31
|
+
const readItem: N = {
|
|
32
|
+
id: 'n-2', type: 'task_comment', title: '已读的历史通知', body: '',
|
|
33
|
+
count: 2, read_at: '2026-10-05T08:00:00Z', created_at: '2026-10-05T07:00:00Z',
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
function dispatchGet(badgeCount: number, list: N[]) {
|
|
38
|
+
return (url: string) => {
|
|
39
|
+
if (url === '/api/notifications/badge') return Promise.resolve({ count: badgeCount })
|
|
40
|
+
if (url.startsWith('/api/notifications?')) return Promise.resolve(list)
|
|
41
|
+
return Promise.reject(new Error(`unexpected GET ${url}`))
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function countCalls(urlPrefix: string) {
|
|
46
|
+
return mockedGet.mock.calls.filter(([u]) => (u as string).startsWith(urlPrefix)).length
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function renderBell(onNavigate?: (link: string) => void) {
|
|
50
|
+
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
|
51
|
+
return render(
|
|
52
|
+
<QueryClientProvider client={qc}>
|
|
53
|
+
<NotificationBell onNavigate={onNavigate} />
|
|
54
|
+
</QueryClientProvider>,
|
|
55
|
+
)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
beforeEach(() => {
|
|
59
|
+
mockedGet.mockReset()
|
|
60
|
+
mockedPost.mockReset()
|
|
61
|
+
mockedPost.mockResolvedValue(undefined)
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
describe('NotificationBell', () => {
|
|
65
|
+
it('未读角标:badge 查询渲染服务端未读数;0 时无角标', async () => {
|
|
66
|
+
mockedGet.mockImplementation(dispatchGet(3, []))
|
|
67
|
+
const { unmount } = renderBell()
|
|
68
|
+
await waitFor(() => expect(screen.getByText('3')).toBeInTheDocument())
|
|
69
|
+
unmount()
|
|
70
|
+
|
|
71
|
+
mockedGet.mockImplementation(dispatchGet(0, []))
|
|
72
|
+
renderBell()
|
|
73
|
+
|
|
74
|
+
await waitFor(() => expect(countCalls('/api/notifications/badge')).toBe(2))
|
|
75
|
+
|
|
76
|
+
expect(screen.getByRole('button', { name: '通知' })).toBeInTheDocument()
|
|
77
|
+
expect(screen.queryByText('0')).not.toBeInTheDocument()
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it('未读数超 99 截断显示 99+', async () => {
|
|
81
|
+
mockedGet.mockImplementation(dispatchGet(120, []))
|
|
82
|
+
renderBell()
|
|
83
|
+
await waitFor(() => expect(screen.getByText('99+')).toBeInTheDocument())
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
it('打开下拉加载最近通知(limit=8);关闭态不拉列表', async () => {
|
|
87
|
+
const get = dispatchGet(1, [unreadItem, readItem])
|
|
88
|
+
mockedGet.mockImplementation(get)
|
|
89
|
+
renderBell()
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
await waitFor(() => expect(countCalls('/api/notifications?')).toBe(0))
|
|
93
|
+
|
|
94
|
+
fireEvent.click(screen.getByRole('button', { name: '通知' }))
|
|
95
|
+
expect(await screen.findByText('用量接近上限')).toBeInTheDocument()
|
|
96
|
+
expect(screen.getByText('已读的历史通知')).toBeInTheDocument()
|
|
97
|
+
expect(mockedGet).toHaveBeenCalledWith('/api/notifications?limit=8')
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
it('单条已读:POST /:id/read 成功后按前缀失效 badge(立即重拉)与 list(重开面板时重拉)', async () => {
|
|
101
|
+
mockedGet.mockImplementation(dispatchGet(2, [unreadItem]))
|
|
102
|
+
renderBell()
|
|
103
|
+
fireEvent.click(screen.getByRole('button', { name: '通知' }))
|
|
104
|
+
await screen.findByText('用量接近上限')
|
|
105
|
+
const badgeCallsBefore = countCalls('/api/notifications/badge')
|
|
106
|
+
const listCallsBefore = countCalls('/api/notifications?')
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
fireEvent.click(screen.getByText('用量接近上限'))
|
|
111
|
+
|
|
112
|
+
await waitFor(() => expect(mockedPost).toHaveBeenCalledWith('/api/notifications/n-1/read'))
|
|
113
|
+
await waitFor(() => {
|
|
114
|
+
expect(countCalls('/api/notifications/badge')).toBeGreaterThan(badgeCallsBefore)
|
|
115
|
+
}, { timeout: 2000 })
|
|
116
|
+
expect(mockedPost).not.toHaveBeenCalledWith('/api/notifications/read-all')
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
fireEvent.click(screen.getByRole('button', { name: '通知' }))
|
|
120
|
+
await waitFor(() => {
|
|
121
|
+
expect(countCalls('/api/notifications?')).toBeGreaterThan(listCallsBefore)
|
|
122
|
+
}, { timeout: 2000 })
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
it('全部已读:POST /read-all 并失效查询', async () => {
|
|
126
|
+
mockedGet.mockImplementation(dispatchGet(2, [unreadItem, readItem]))
|
|
127
|
+
renderBell()
|
|
128
|
+
fireEvent.click(screen.getByRole('button', { name: '通知' }))
|
|
129
|
+
fireEvent.click(await screen.findByText('全部已读'))
|
|
130
|
+
|
|
131
|
+
await waitFor(() => expect(mockedPost).toHaveBeenCalledWith('/api/notifications/read-all'))
|
|
132
|
+
await waitFor(() => {
|
|
133
|
+
expect(countCalls('/api/notifications/badge')).toBeGreaterThanOrEqual(2)
|
|
134
|
+
}, { timeout: 2000 })
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
it('外点关闭:面板外 mousedown 关闭下拉', async () => {
|
|
138
|
+
mockedGet.mockImplementation(dispatchGet(1, [unreadItem]))
|
|
139
|
+
renderBell()
|
|
140
|
+
fireEvent.click(screen.getByRole('button', { name: '通知' }))
|
|
141
|
+
await screen.findByText('用量接近上限')
|
|
142
|
+
|
|
143
|
+
fireEvent.mouseDown(document.body)
|
|
144
|
+
expect(screen.queryByText('用量接近上限')).not.toBeInTheDocument()
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
fireEvent.click(screen.getByRole('button', { name: '通知' }))
|
|
148
|
+
await screen.findByText('用量接近上限')
|
|
149
|
+
fireEvent.mouseDown(screen.getByText('用量接近上限'))
|
|
150
|
+
expect(screen.getByText('用量接近上限')).toBeInTheDocument()
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
it('点击带 link 的通知:onNavigate(link) 回调触发且面板关闭', async () => {
|
|
154
|
+
mockedGet.mockImplementation(dispatchGet(1, [unreadItem]))
|
|
155
|
+
const onNavigate = vi.fn()
|
|
156
|
+
renderBell(onNavigate)
|
|
157
|
+
fireEvent.click(screen.getByRole('button', { name: '通知' }))
|
|
158
|
+
fireEvent.click(await screen.findByText('用量接近上限'))
|
|
159
|
+
|
|
160
|
+
await waitFor(() => expect(onNavigate).toHaveBeenCalledWith('/settings/workspace/usage'))
|
|
161
|
+
expect(screen.queryByText('用量接近上限')).not.toBeInTheDocument()
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
it('点击无 link 的通知:不触发 onNavigate,仅走已读', async () => {
|
|
165
|
+
mockedGet.mockImplementation(dispatchGet(1, [{ ...unreadItem, link: null }]))
|
|
166
|
+
const onNavigate = vi.fn()
|
|
167
|
+
renderBell(onNavigate)
|
|
168
|
+
fireEvent.click(screen.getByRole('button', { name: '通知' }))
|
|
169
|
+
fireEvent.click(await screen.findByText('用量接近上限'))
|
|
170
|
+
|
|
171
|
+
await waitFor(() => expect(mockedPost).toHaveBeenCalledWith('/api/notifications/n-1/read'))
|
|
172
|
+
expect(onNavigate).not.toHaveBeenCalled()
|
|
173
|
+
})
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
describe('P2-7/P2-19:加载态与键盘化', () => {
|
|
177
|
+
it('P2-7:列表加载中显示"加载中…"而非"暂无通知"空态', async () => {
|
|
178
|
+
let resolveList!: (v: N[]) => void
|
|
179
|
+
mockedGet.mockImplementation((url: string) => {
|
|
180
|
+
if (url === '/api/notifications/badge') return Promise.resolve({ count: 0 })
|
|
181
|
+
if (url.startsWith('/api/notifications?')) return new Promise<N[]>((res) => { resolveList = res })
|
|
182
|
+
return Promise.reject(new Error(`unexpected GET ${url}`))
|
|
183
|
+
})
|
|
184
|
+
renderBell()
|
|
185
|
+
fireEvent.click(screen.getByRole('button', { name: '通知' }))
|
|
186
|
+
expect(await screen.findByText('加载中…')).toBeInTheDocument()
|
|
187
|
+
expect(screen.queryByText('暂无通知')).not.toBeInTheDocument()
|
|
188
|
+
|
|
189
|
+
resolveList([])
|
|
190
|
+
await waitFor(() => expect(screen.getByText('暂无通知')).toBeInTheDocument())
|
|
191
|
+
})
|
|
192
|
+
|
|
193
|
+
it('P2-19:条目为 button(Tab/Enter 可操作);触发钮带 aria-haspopup/expanded', async () => {
|
|
194
|
+
mockedGet.mockImplementation(dispatchGet(1, [unreadItem]))
|
|
195
|
+
renderBell()
|
|
196
|
+
const trigger = screen.getByRole('button', { name: '通知' })
|
|
197
|
+
expect(trigger).toHaveAttribute('aria-haspopup', 'true')
|
|
198
|
+
expect(trigger).toHaveAttribute('aria-expanded', 'false')
|
|
199
|
+
|
|
200
|
+
fireEvent.click(trigger)
|
|
201
|
+
expect(trigger).toHaveAttribute('aria-expanded', 'true')
|
|
202
|
+
|
|
203
|
+
const item = await screen.findByRole('button', { name: /用量接近上限/ })
|
|
204
|
+
fireEvent.click(item)
|
|
205
|
+
await waitFor(() => expect(mockedPost).toHaveBeenCalledWith('/api/notifications/n-1/read'))
|
|
206
|
+
})
|
|
207
|
+
})
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import '@testing-library/jest-dom/vitest'
|
|
2
|
+
import { fireEvent, render, screen } from '@testing-library/react'
|
|
3
|
+
import { renderToString } from 'react-dom/server'
|
|
4
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
5
|
+
import { OnboardingChecklist } from '../OnboardingChecklist'
|
|
6
|
+
|
|
7
|
+
vi.mock('../../../../hooks/src/hooks/useAuth', () => ({
|
|
8
|
+
useAuth: () => ({
|
|
9
|
+
user: { email: 'alice@example.com', display_name: 'Alice' },
|
|
10
|
+
workspaces: [{ id: 'ws-1', name: 'Acme' }],
|
|
11
|
+
loading: false,
|
|
12
|
+
refresh: vi.fn(),
|
|
13
|
+
logout: vi.fn(),
|
|
14
|
+
}),
|
|
15
|
+
}))
|
|
16
|
+
|
|
17
|
+
describe('OnboardingChecklist', () => {
|
|
18
|
+
beforeEach(() => localStorage.clear())
|
|
19
|
+
|
|
20
|
+
it('按 items 渲染步骤与进度值', () => {
|
|
21
|
+
render(
|
|
22
|
+
<OnboardingChecklist
|
|
23
|
+
items={[
|
|
24
|
+
{ key: 'ws', label: '创建工作区', done: true },
|
|
25
|
+
{ key: 'invite', label: '邀请成员', done: false, onClick: () => {} },
|
|
26
|
+
]}
|
|
27
|
+
/>,
|
|
28
|
+
)
|
|
29
|
+
expect(screen.getByText('创建工作区')).toBeInTheDocument()
|
|
30
|
+
expect(screen.getByText('邀请成员')).toBeInTheDocument()
|
|
31
|
+
expect(screen.getByText('50%')).toBeInTheDocument()
|
|
32
|
+
expect(screen.getByText('已完成 1/2 项')).toBeInTheDocument()
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it('点击未完成且带 onClick 的步骤触发回调;已完成项不触发', () => {
|
|
36
|
+
const onClick = vi.fn()
|
|
37
|
+
render(
|
|
38
|
+
<OnboardingChecklist
|
|
39
|
+
items={[
|
|
40
|
+
{ key: 'ws', label: '创建工作区', done: true },
|
|
41
|
+
{ key: 'invite', label: '邀请成员', done: false, onClick },
|
|
42
|
+
]}
|
|
43
|
+
/>,
|
|
44
|
+
)
|
|
45
|
+
fireEvent.click(screen.getByRole('button', { name: /邀请成员/ }))
|
|
46
|
+
expect(onClick).toHaveBeenCalledTimes(1)
|
|
47
|
+
|
|
48
|
+
fireEvent.click(screen.getByRole('button', { name: /创建工作区/ }))
|
|
49
|
+
expect(screen.getByRole('button', { name: /创建工作区/ })).toBeDisabled()
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('关闭后写入 localStorage 并不再渲染', () => {
|
|
53
|
+
const { rerender } = render(<OnboardingChecklist items={[{ key: 'a', label: 'A', done: false }]} />)
|
|
54
|
+
fireEvent.click(screen.getByRole('button', { name: '关闭引导清单' }))
|
|
55
|
+
expect(localStorage.getItem('pk_onboarding_dismissed')).toBe('1')
|
|
56
|
+
rerender(<OnboardingChecklist items={[{ key: 'a', label: 'A', done: false }]} />)
|
|
57
|
+
expect(screen.queryByText('A')).not.toBeInTheDocument()
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('不传 items 时用上下文默认项(已有工作区 → 完成)', () => {
|
|
61
|
+
render(<OnboardingChecklist />)
|
|
62
|
+
expect(screen.getByText('创建第一个工作区')).toBeInTheDocument()
|
|
63
|
+
expect(screen.getByText('100%')).toBeInTheDocument()
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
it('localStorage 已有关闭标记:渲染期输出与标记无关(双端一致),mount 后同步为关闭', () => {
|
|
69
|
+
localStorage.setItem('pk_onboarding_dismissed', '1')
|
|
70
|
+
|
|
71
|
+
const html = renderToString(
|
|
72
|
+
<OnboardingChecklist items={[{ key: 'a', label: '引导项A', done: false }]} />,
|
|
73
|
+
)
|
|
74
|
+
expect(html).toContain('引导项A')
|
|
75
|
+
|
|
76
|
+
render(<OnboardingChecklist items={[{ key: 'a', label: '引导项A', done: false }]} />)
|
|
77
|
+
expect(screen.queryByText('引导项A')).not.toBeInTheDocument()
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it('localStorage 不可用:渲染与关闭都不炸(effect/事件期兜底),按未关闭处理', () => {
|
|
81
|
+
const desc = Object.getOwnPropertyDescriptor(globalThis, 'localStorage')
|
|
82
|
+
Object.defineProperty(globalThis, 'localStorage', {
|
|
83
|
+
configurable: true,
|
|
84
|
+
get() {
|
|
85
|
+
throw new Error('blocked')
|
|
86
|
+
},
|
|
87
|
+
})
|
|
88
|
+
try {
|
|
89
|
+
render(<OnboardingChecklist items={[{ key: 'a', label: 'A', done: false }]} />)
|
|
90
|
+
expect(screen.getByText('A')).toBeInTheDocument()
|
|
91
|
+
fireEvent.click(screen.getByRole('button', { name: '关闭引导清单' }))
|
|
92
|
+
expect(screen.queryByText('A')).not.toBeInTheDocument()
|
|
93
|
+
} finally {
|
|
94
|
+
Object.defineProperty(globalThis, 'localStorage', desc!)
|
|
95
|
+
}
|
|
96
|
+
})
|
|
97
|
+
})
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import '@testing-library/jest-dom/vitest'
|
|
2
|
+
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
|
3
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
4
|
+
import { SecretModal } from '../SecretModal'
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
describe('SecretModal', () => {
|
|
10
|
+
beforeEach(() => {
|
|
11
|
+
vi.stubGlobal('navigator', {
|
|
12
|
+
clipboard: { writeText: vi.fn().mockResolvedValue(undefined) },
|
|
13
|
+
})
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
function setup(secretValue: string, open: boolean, onOpenChange: (o: boolean) => void) {
|
|
17
|
+
return render(
|
|
18
|
+
<SecretModal
|
|
19
|
+
open={open}
|
|
20
|
+
onOpenChange={onOpenChange}
|
|
21
|
+
secretName="个人访问令牌"
|
|
22
|
+
secretValue={secretValue}
|
|
23
|
+
/>,
|
|
24
|
+
)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
it('轮换双密钥:确认第一个后展示第二个,复选框与关闭钮必须回到初始禁用态', async () => {
|
|
28
|
+
const onOpenChange = vi.fn()
|
|
29
|
+
const { rerender } = setup('tk_first', true, onOpenChange)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
fireEvent.click(screen.getByRole('checkbox'))
|
|
33
|
+
expect(screen.getByRole('button', { name: '我已保存' })).toBeEnabled()
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
rerender(
|
|
37
|
+
<SecretModal
|
|
38
|
+
open
|
|
39
|
+
onOpenChange={onOpenChange}
|
|
40
|
+
secretName="个人访问令牌"
|
|
41
|
+
secretValue="tk_second"
|
|
42
|
+
/>,
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
expect(await screen.findByText('tk_second')).toBeInTheDocument()
|
|
47
|
+
expect(screen.getByRole('checkbox')).not.toBeChecked()
|
|
48
|
+
expect(screen.getByRole('button', { name: '请先确认已保存' })).toBeDisabled()
|
|
49
|
+
expect(screen.queryByText('tk_first')).not.toBeInTheDocument()
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('关闭再打开同一组件:confirmed 同样重置', async () => {
|
|
53
|
+
const onOpenChange = vi.fn()
|
|
54
|
+
const { rerender } = setup('tk_first', true, onOpenChange)
|
|
55
|
+
|
|
56
|
+
fireEvent.click(screen.getByRole('checkbox'))
|
|
57
|
+
expect(screen.getByRole('button', { name: '我已保存' })).toBeEnabled()
|
|
58
|
+
|
|
59
|
+
rerender(
|
|
60
|
+
<SecretModal
|
|
61
|
+
open={false}
|
|
62
|
+
onOpenChange={onOpenChange}
|
|
63
|
+
secretName="个人访问令牌"
|
|
64
|
+
secretValue="tk_first"
|
|
65
|
+
/>,
|
|
66
|
+
)
|
|
67
|
+
rerender(
|
|
68
|
+
<SecretModal
|
|
69
|
+
open
|
|
70
|
+
onOpenChange={onOpenChange}
|
|
71
|
+
secretName="个人访问令牌"
|
|
72
|
+
secretValue="tk_first"
|
|
73
|
+
/>,
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
expect(await screen.findByText('tk_first')).toBeInTheDocument()
|
|
77
|
+
expect(screen.getByRole('checkbox')).not.toBeChecked()
|
|
78
|
+
expect(screen.getByRole('button', { name: '请先确认已保存' })).toBeDisabled()
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it('复制后 copied 状态随 secretValue 变化重置', async () => {
|
|
82
|
+
const onOpenChange = vi.fn()
|
|
83
|
+
const { rerender } = setup('tk_first', true, onOpenChange)
|
|
84
|
+
|
|
85
|
+
fireEvent.click(screen.getByRole('button', { name: '复制' }))
|
|
86
|
+
await waitFor(() => expect(screen.getByRole('button', { name: '已复制' })).toBeInTheDocument())
|
|
87
|
+
|
|
88
|
+
rerender(
|
|
89
|
+
<SecretModal
|
|
90
|
+
open
|
|
91
|
+
onOpenChange={onOpenChange}
|
|
92
|
+
secretName="个人访问令牌"
|
|
93
|
+
secretValue="tk_second"
|
|
94
|
+
/>,
|
|
95
|
+
)
|
|
96
|
+
expect(await screen.findByRole('button', { name: '复制' })).toBeInTheDocument()
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
it('复制失败如实提示手动复制,不显示"已复制"', async () => {
|
|
101
|
+
vi.stubGlobal('navigator', {
|
|
102
|
+
clipboard: { writeText: vi.fn().mockRejectedValue(new Error('denied')) },
|
|
103
|
+
})
|
|
104
|
+
setup('tk_first', true, vi.fn())
|
|
105
|
+
|
|
106
|
+
fireEvent.click(screen.getByRole('button', { name: '复制' }))
|
|
107
|
+
await waitFor(() =>
|
|
108
|
+
expect(screen.getByRole('button', { name: '复制失败,请手动复制' })).toBeInTheDocument(),
|
|
109
|
+
)
|
|
110
|
+
expect(screen.queryByRole('button', { name: '已复制' })).not.toBeInTheDocument()
|
|
111
|
+
})
|
|
112
|
+
})
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import '@testing-library/jest-dom/vitest'
|
|
2
|
+
import { render, screen } from '@testing-library/react'
|
|
3
|
+
import { describe, it, expect, vi } from 'vitest'
|
|
4
|
+
import { SiteBannerLayer } from '../SiteBannerLayer'
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
const mocked = vi.hoisted(() => ({ data: undefined as Record<string, string> | undefined }))
|
|
8
|
+
vi.mock('../../../../hooks/src/hooks/useSiteConfig', () => ({
|
|
9
|
+
useSiteConfig: () => ({ data: mocked.data }),
|
|
10
|
+
}))
|
|
11
|
+
|
|
12
|
+
describe('SiteBannerLayer', () => {
|
|
13
|
+
it('无数据(/config 拉取失败/未返回)整层不渲染', () => {
|
|
14
|
+
mocked.data = undefined
|
|
15
|
+
const { container } = render(<SiteBannerLayer />)
|
|
16
|
+
expect(container.firstChild).toBeNull()
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
it('维护模式开 → warn 条 + 公告条(kind 跟随 banner_kind)', () => {
|
|
20
|
+
mocked.data = {
|
|
21
|
+
maintenance_mode: '1',
|
|
22
|
+
banner_text: '今晚 22:00 例行维护',
|
|
23
|
+
banner_kind: 'info',
|
|
24
|
+
}
|
|
25
|
+
render(<SiteBannerLayer />)
|
|
26
|
+
expect(screen.getByText(/维护中:系统维护中/)).toBeInTheDocument()
|
|
27
|
+
expect(screen.getByText('今晚 22:00 例行维护')).toBeInTheDocument()
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it('公告为空 → 只渲染维护条;maintenanceText 可换文案', () => {
|
|
31
|
+
mocked.data = { maintenance_mode: '1', banner_text: '', banner_kind: 'info' }
|
|
32
|
+
render(<SiteBannerLayer maintenanceText="MyProduct 维护窗口中" />)
|
|
33
|
+
expect(screen.getByText('MyProduct 维护窗口中')).toBeInTheDocument()
|
|
34
|
+
|
|
35
|
+
expect(screen.queryAllByRole('status')).toHaveLength(1)
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('维护关 + 公告 warn → 单公告条,无维护条', () => {
|
|
39
|
+
mocked.data = { maintenance_mode: '0', banner_text: '紧急公告', banner_kind: 'warn' }
|
|
40
|
+
render(<SiteBannerLayer />)
|
|
41
|
+
expect(screen.getByText('紧急公告')).toBeInTheDocument()
|
|
42
|
+
expect(screen.queryByText(/维护中/)).not.toBeInTheDocument()
|
|
43
|
+
})
|
|
44
|
+
})
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import '@testing-library/jest-dom/vitest'
|
|
2
|
+
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
|
3
|
+
import { MemoryRouter, Routes, Route } from 'react-router'
|
|
4
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
5
|
+
import { UserMenu } from '../UserMenu'
|
|
6
|
+
import { Toaster } from '../toast'
|
|
7
|
+
import type { PKUser } from '../../provider/PloykitProvider'
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
const logout = vi.fn(async () => {})
|
|
11
|
+
const mocks = vi.hoisted(() => ({ isPlatformAdmin: false }))
|
|
12
|
+
vi.mock('../../../../hooks/src/hooks/useAuth', () => ({
|
|
13
|
+
useAuth: () => ({
|
|
14
|
+
user: {
|
|
15
|
+
email: 'alice@example.com',
|
|
16
|
+
display_name: 'Alice',
|
|
17
|
+
avatar_url: undefined,
|
|
18
|
+
is_platform_admin: mocks.isPlatformAdmin,
|
|
19
|
+
} as PKUser,
|
|
20
|
+
workspaces: [],
|
|
21
|
+
loading: false,
|
|
22
|
+
refresh: vi.fn(),
|
|
23
|
+
logout,
|
|
24
|
+
}),
|
|
25
|
+
}))
|
|
26
|
+
|
|
27
|
+
function renderAt(path: string) {
|
|
28
|
+
return render(
|
|
29
|
+
<MemoryRouter initialEntries={[path]}>
|
|
30
|
+
{/* Toaster 在路由外常驻:logout 失败兜底用例断言 toast 文案实际可见
|
|
31
|
+
(若随切换路由卸载,跳转后 toast 不再渲染) */}
|
|
32
|
+
<Toaster />
|
|
33
|
+
<Routes>
|
|
34
|
+
<Route path="/login" element={<div>login-page</div>} />
|
|
35
|
+
<Route path="/account/profile" element={<div>account-profile-page</div>} />
|
|
36
|
+
<Route path="/account/notifications" element={<div>notifications-page</div>} />
|
|
37
|
+
<Route path="/admin" element={<div>admin-page</div>} />
|
|
38
|
+
<Route path="*" element={<UserMenu />} />
|
|
39
|
+
</Routes>
|
|
40
|
+
</MemoryRouter>,
|
|
41
|
+
)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
describe('UserMenu', () => {
|
|
45
|
+
beforeEach(() => {
|
|
46
|
+
logout.mockClear()
|
|
47
|
+
mocks.isPlatformAdmin = false
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it('渲染头像 initials fallback', () => {
|
|
51
|
+
renderAt('/app')
|
|
52
|
+
expect(screen.getByRole('button', { name: '用户菜单' })).toHaveTextContent('A')
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it('默认项:账户设置(→/account/profile);普通用户无管理控制台', async () => {
|
|
56
|
+
renderAt('/app')
|
|
57
|
+
fireEvent.click(screen.getByRole('button', { name: '用户菜单' }))
|
|
58
|
+
expect(await screen.findByRole('menu')).toBeInTheDocument()
|
|
59
|
+
expect(screen.getByRole('menuitem', { name: '账户设置' })).toBeVisible()
|
|
60
|
+
expect(screen.queryByRole('menuitem', { name: '管理控制台' })).not.toBeInTheDocument()
|
|
61
|
+
expect(screen.queryByRole('menuitem', { name: '个人设置' })).not.toBeInTheDocument()
|
|
62
|
+
expect(screen.queryByRole('menuitem', { name: '通知偏好' })).not.toBeInTheDocument()
|
|
63
|
+
expect(screen.getByRole('menuitem', { name: '退出登录' })).toBeVisible()
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it('账户设置点击跳 /account/profile', async () => {
|
|
67
|
+
renderAt('/app')
|
|
68
|
+
fireEvent.click(screen.getByRole('button', { name: '用户菜单' }))
|
|
69
|
+
fireEvent.click(await screen.findByRole('menuitem', { name: '账户设置' }))
|
|
70
|
+
await waitFor(() => expect(screen.getByText('account-profile-page')).toBeInTheDocument())
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
it('is_platform_admin:出现管理控制台(→/admin)', async () => {
|
|
74
|
+
mocks.isPlatformAdmin = true
|
|
75
|
+
renderAt('/app')
|
|
76
|
+
fireEvent.click(screen.getByRole('button', { name: '用户菜单' }))
|
|
77
|
+
fireEvent.click(await screen.findByRole('menuitem', { name: '管理控制台' }))
|
|
78
|
+
await waitFor(() => expect(screen.getByText('admin-page')).toBeInTheDocument())
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it('退出登录调用 logout 并跳 /login', async () => {
|
|
82
|
+
renderAt('/app')
|
|
83
|
+
fireEvent.click(screen.getByRole('button', { name: '用户菜单' }))
|
|
84
|
+
fireEvent.click(await screen.findByRole('menuitem', { name: '退出登录' }))
|
|
85
|
+
await waitFor(() => expect(logout).toHaveBeenCalledTimes(1))
|
|
86
|
+
await waitFor(() => expect(screen.getByText('login-page')).toBeInTheDocument())
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
it('P3-11:logout 抛错不困死用户——toast 提示失败仍照常跳转登录页', async () => {
|
|
90
|
+
logout.mockRejectedValueOnce(new Error('network down'))
|
|
91
|
+
renderAt('/app')
|
|
92
|
+
fireEvent.click(screen.getByRole('button', { name: '用户菜单' }))
|
|
93
|
+
fireEvent.click(await screen.findByRole('menuitem', { name: '退出登录' }))
|
|
94
|
+
|
|
95
|
+
expect(await screen.findByText('退出登录失败,请稍后在登录页重试')).toBeInTheDocument()
|
|
96
|
+
await waitFor(() => expect(screen.getByText('login-page')).toBeInTheDocument())
|
|
97
|
+
})
|
|
98
|
+
})
|