test: enhance ModelSelectorTrigger tests and integrate credential panel state

- Added tests for ModelSelectorTrigger to validate rendering based on credential panel state, including handling of credits exhausted scenarios.
- Updated ModelSelectorTrigger component to utilize useCredentialPanelState for determining status and rendering appropriate UI elements.
- Adjusted related tests to ensure correct behavior when model quota is exceeded and when the selected model is readonly.
- Improved styling for credits exhausted badge in the component.
This commit is contained in:
CodingOnStar
2026-03-11 11:09:03 +08:00
parent e8ade9ad64
commit 5709a34a7f
5 changed files with 130 additions and 11 deletions

View File

@ -10,9 +10,13 @@ import {
import ModelSelectorTrigger from './model-selector-trigger'
const mockUseProviderContext = vi.hoisted(() => vi.fn())
const mockUseCredentialPanelState = vi.hoisted(() => vi.fn())
vi.mock('@/context/provider-context', () => ({
useProviderContext: mockUseProviderContext,
}))
vi.mock('../provider-added-card/use-credential-panel-state', () => ({
useCredentialPanelState: mockUseCredentialPanelState,
}))
const createModelItem = (overrides: Partial<ModelItem> = {}): ModelItem => ({
model: 'gpt-4',
@ -48,6 +52,16 @@ describe('ModelSelectorTrigger', () => {
mockUseProviderContext.mockReturnValue({
modelProviders: [createModel()],
})
mockUseCredentialPanelState.mockReturnValue({
variant: 'credits-active',
priority: 'credits',
supportsCredits: true,
showPrioritySwitcher: true,
hasCredentials: false,
isCreditsExhausted: false,
credentialName: undefined,
credits: 100,
})
})
describe('Rendering', () => {
@ -132,6 +146,28 @@ describe('ModelSelectorTrigger', () => {
expect(screen.getByText('common.modelProvider.selector.configureRequired')).toBeInTheDocument()
})
it('should apply credits exhausted badge style when model quota is exceeded', () => {
mockUseCredentialPanelState.mockReturnValue({
variant: 'credits-exhausted',
priority: 'credits',
supportsCredits: true,
showPrioritySwitcher: true,
hasCredentials: false,
isCreditsExhausted: true,
credentialName: undefined,
credits: 0,
})
render(
<ModelSelectorTrigger
currentProvider={createModel()}
currentModel={createModelItem()}
/>,
)
expect(screen.getByText('common.modelProvider.selector.creditsExhausted').parentElement).toHaveClass('bg-components-badge-bg-dimm')
})
it('should not show status badge when selected model is readonly', () => {
render(
<ModelSelectorTrigger

View File

@ -11,6 +11,7 @@ import { cn } from '@/utils/classnames'
import { ModelStatusEnum } from '../declarations'
import ModelIcon from '../model-icon'
import ModelName from '../model-name'
import { useCredentialPanelState } from '../provider-added-card/use-credential-panel-state'
const STATUS_I18N_KEY: Partial<Record<ModelStatusEnum, string>> = {
[ModelStatusEnum.quotaExceeded]: 'modelProvider.selector.creditsExhausted',
@ -47,10 +48,22 @@ const ModelSelectorTrigger: FC<ModelSelectorTriggerProps> = ({
const isSelected = !!currentProvider && !!currentModel
const isDeprecated = !isSelected && !!defaultModel
const isEmpty = !isSelected && !defaultModel
const selectedProvider = isSelected
? modelProviders.find(provider => provider.provider === currentProvider.provider)
: undefined
const selectedProviderState = useCredentialPanelState(selectedProvider)
const shouldShowCreditsExhausted = isSelected
&& selectedProviderState.priority === 'credits'
&& selectedProviderState.supportsCredits
&& selectedProviderState.isCreditsExhausted
const effectiveStatus = shouldShowCreditsExhausted
? ModelStatusEnum.quotaExceeded
: currentModel?.status
const isActive = isSelected && currentModel.status === ModelStatusEnum.active
const isActive = isSelected && effectiveStatus === ModelStatusEnum.active
const isDisabled = isDeprecated || (isSelected && !isActive)
const statusI18nKey = isSelected ? STATUS_I18N_KEY[currentModel.status] : undefined
const statusI18nKey = isSelected && effectiveStatus ? STATUS_I18N_KEY[effectiveStatus] : undefined
const isCreditsExhausted = isSelected && effectiveStatus === ModelStatusEnum.quotaExceeded
const deprecatedProvider = isDeprecated
? modelProviders.find(p => p.provider === defaultModel.provider)
@ -107,9 +120,14 @@ const ModelSelectorTrigger: FC<ModelSelectorTriggerProps> = ({
{isSelected && !readonly && !isActive && statusI18nKey && (
<Tooltip>
<TooltipTrigger
disabled={currentModel.status !== ModelStatusEnum.noPermission}
disabled={effectiveStatus !== ModelStatusEnum.noPermission}
render={(
<div className="flex shrink-0 items-center gap-[3px] rounded-md border border-text-warning px-[5px] py-0.5">
<div
className={cn(
'flex shrink-0 items-center gap-[3px] rounded-md border border-text-warning px-[5px] py-0.5',
isCreditsExhausted && 'min-w-[20px] justify-center bg-components-badge-bg-dimm',
)}
>
<span className="i-ri-alert-fill h-3 w-3 text-text-warning" />
<span className="whitespace-nowrap text-text-warning system-xs-medium">
{t(statusI18nKey as 'modelProvider.selector.creditsExhausted', { ns: 'common' })}

View File

@ -96,7 +96,7 @@ function StatusLabel({ variant, credentialName }: {
{credentialName}
</span>
{showWarning && (
<Warning className="h-3 w-3 shrink-0 text-text-warning" />
<Warning className="ml-auto h-3 w-3 shrink-0 text-text-warning" />
)}
{variant === 'api-unavailable' && (
<span className="shrink-0 text-text-destructive system-2xs-medium">

View File

@ -701,8 +701,8 @@ describe('update-plugin', () => {
})
})
it('should show error toast when task status is failed', async () => {
// Arrange - covers lines 99-100
it('should reset loading state when task status check fails', async () => {
// Arrange
const mockToastNotify = vi.fn()
vi.mocked(await import('../../../base/toast')).default.notify = mockToastNotify
@ -739,6 +739,53 @@ describe('update-plugin', () => {
})
// onSave should NOT be called when task fails
expect(onSave).not.toHaveBeenCalled()
await waitFor(() => {
expect(screen.getByRole('button', { name: 'plugin.upgrade.upgrade' })).toBeInTheDocument()
})
expect(screen.getByRole('button', { name: 'common.operation.cancel' })).toBeInTheDocument()
})
it('should stop loading when upgrade API returns failed task directly', async () => {
// Arrange
const mockToastNotify = vi.fn()
vi.mocked(await import('../../../base/toast')).default.notify = mockToastNotify
mockUpdateFromMarketPlace.mockResolvedValue({
task: {
status: TaskStatus.failed,
plugins: [{
plugin_unique_identifier: 'test-target-id',
status: TaskStatus.failed,
message: 'failed to init environment',
}],
},
})
const onSave = vi.fn()
const payload = createMockMarketPlacePayload()
// Act
renderWithQueryClient(
<UpdateFromMarketplace
payload={payload}
onSave={onSave}
onCancel={vi.fn()}
/>,
)
fireEvent.click(screen.getByRole('button', { name: 'plugin.upgrade.upgrade' }))
// Assert
await waitFor(() => {
expect(mockToastNotify).toHaveBeenCalledWith({
type: 'error',
message: 'failed to init environment',
})
})
expect(mockCheck).not.toHaveBeenCalled()
expect(onSave).not.toHaveBeenCalled()
await waitFor(() => {
expect(screen.getByRole('button', { name: 'plugin.upgrade.upgrade' })).toBeInTheDocument()
})
expect(screen.getByRole('button', { name: 'common.operation.cancel' })).toBeInTheDocument()
})
})

View File

@ -33,6 +33,16 @@ type Props = {
isShowDowngradeWarningModal?: boolean
}
type FailedUpgradeResponse = {
task?: {
status?: TaskStatus
plugins?: Array<{
plugin_unique_identifier: string
message: string
}>
}
}
enum UploadStep {
notStarted = 'notStarted',
upgrading = 'upgrading',
@ -83,13 +93,20 @@ const UpdatePluginModal: FC<Props> = ({
if (uploadStep === UploadStep.notStarted) {
setUploadStep(UploadStep.upgrading)
try {
const response = await updateFromMarketPlace({
original_plugin_unique_identifier: originalPackageInfo.id,
new_plugin_unique_identifier: targetPackageInfo.id,
}) as Awaited<ReturnType<typeof updateFromMarketPlace>> & FailedUpgradeResponse
if (response.task?.status === TaskStatus.failed) {
setUploadStep(UploadStep.notStarted)
return
}
const {
all_installed: isInstalled,
task_id: taskId,
} = await updateFromMarketPlace({
original_plugin_unique_identifier: originalPackageInfo.id,
new_plugin_unique_identifier: targetPackageInfo.id,
})
} = response
if (isInstalled) {
onSave()
@ -102,6 +119,7 @@ const UpdatePluginModal: FC<Props> = ({
})
if (status === TaskStatus.failed) {
Toast.notify({ type: 'error', message: error! })
setUploadStep(UploadStep.notStarted)
return
}
onSave()