import type { App, AppSSO } from '@/types/app' import { fireEvent, render, screen, waitFor } from '@testing-library/react' import { useStore as useAppStore } from '@/app/components/app/store' import CreateBlankSkillModal from './create-blank-skill-modal' type MockWorkflowState = { setUploadStatus: ReturnType setUploadProgress: ReturnType openTab: ReturnType } const mocks = vi.hoisted(() => ({ mutateAsync: vi.fn(), emitTreeUpdate: vi.fn(), prepareSkillUploadFile: vi.fn(), toastSuccess: vi.fn(), toastError: vi.fn(), existingNames: new Set(), workflowState: { setUploadStatus: vi.fn(), setUploadProgress: vi.fn(), openTab: vi.fn(), } as MockWorkflowState, })) vi.mock('@/service/use-app-asset', () => ({ useBatchUpload: () => ({ mutateAsync: mocks.mutateAsync, }), })) vi.mock('../hooks/file-tree/data/use-skill-asset-tree', () => ({ useExistingSkillNames: () => ({ data: mocks.existingNames, }), })) vi.mock('../hooks/file-tree/data/use-skill-tree-collaboration', () => ({ useSkillTreeUpdateEmitter: () => mocks.emitTreeUpdate, })) vi.mock('../utils/skill-upload-utils', () => ({ prepareSkillUploadFile: (...args: unknown[]) => mocks.prepareSkillUploadFile(...args), })) vi.mock('@/app/components/workflow/store', () => ({ useWorkflowStore: () => ({ getState: () => mocks.workflowState, }), })) vi.mock('@/app/components/base/ui/toast', () => ({ toast: { success: (...args: unknown[]) => mocks.toastSuccess(...args), error: (...args: unknown[]) => mocks.toastError(...args), }, })) describe('CreateBlankSkillModal', () => { beforeEach(() => { vi.clearAllMocks() mocks.existingNames = new Set() useAppStore.setState({ appDetail: { id: 'app-1' } as App & Partial, }) mocks.prepareSkillUploadFile.mockImplementation(async (file: File) => file) }) describe('Rendering', () => { it('should render modal title and disable create button when skill name is empty', () => { render() expect(screen.getByText('workflow.skill.startTab.createModal.title')).toBeInTheDocument() expect(screen.getByRole('button', { name: /common\.operation\.create/i })).toBeDisabled() }) it('should clear input and call onClose when cancel button is clicked', () => { const onClose = vi.fn() render() const input = screen.getByRole('textbox') fireEvent.change(input, { target: { value: 'to-be-cleared' } }) fireEvent.click(screen.getByRole('button', { name: /common\.operation\.cancel/i })) expect(onClose).toHaveBeenCalledTimes(1) expect(input).toHaveValue('') }) }) describe('Validation', () => { it('should show duplicate error and disable create when skill name already exists', () => { mocks.existingNames = new Set(['existing-skill']) render() fireEvent.change(screen.getByRole('textbox'), { target: { value: 'existing-skill' } }) expect(screen.getByText('workflow.skill.startTab.createModal.nameDuplicate')).toBeInTheDocument() expect(screen.getByRole('button', { name: /common\.operation\.create/i })).toBeDisabled() }) }) describe('Create Flow', () => { it('should upload skill template and notify success when creation succeeds', async () => { const onClose = vi.fn() mocks.mutateAsync.mockImplementationOnce(async ({ onProgress }: { onProgress?: (uploaded: number, total: number) => void }) => { onProgress?.(1, 1) return [{ children: [{ id: 'skill-md-id' }], }] }) render() fireEvent.change(screen.getByRole('textbox'), { target: { value: 'new-skill' } }) fireEvent.click(screen.getByRole('button', { name: /common\.operation\.create/i })) await waitFor(() => { expect(mocks.mutateAsync).toHaveBeenCalledTimes(1) }) expect(mocks.workflowState.setUploadStatus).toHaveBeenNthCalledWith(1, 'uploading') expect(mocks.workflowState.setUploadStatus).toHaveBeenNthCalledWith(2, 'success') expect(mocks.workflowState.setUploadProgress).toHaveBeenCalledWith({ uploaded: 0, total: 1, failed: 0 }) expect(mocks.workflowState.setUploadProgress).toHaveBeenCalledWith({ uploaded: 1, total: 1, failed: 0 }) expect(mocks.emitTreeUpdate).toHaveBeenCalledTimes(1) expect(mocks.workflowState.openTab).toHaveBeenCalledWith('skill-md-id', { pinned: true }) expect(mocks.toastSuccess).toHaveBeenCalledWith('workflow.skill.startTab.createSuccess:{"name":"new-skill"}') expect(mocks.toastError).not.toHaveBeenCalled() expect(onClose).toHaveBeenCalledTimes(1) expect(screen.getByRole('textbox')).toHaveValue('') }) it('should set partial error and show error toast when upload fails', async () => { const onClose = vi.fn() mocks.mutateAsync.mockRejectedValueOnce(new Error('upload failed')) render() fireEvent.change(screen.getByRole('textbox'), { target: { value: 'new-skill' } }) fireEvent.click(screen.getByRole('button', { name: /common\.operation\.create/i })) await waitFor(() => { expect(mocks.workflowState.setUploadStatus).toHaveBeenCalledWith('partial_error') }) expect(mocks.toastError).toHaveBeenCalledWith('workflow.skill.startTab.createError') expect(mocks.toastSuccess).not.toHaveBeenCalled() expect(onClose).not.toHaveBeenCalled() expect(screen.getByRole('textbox')).toHaveValue('') }) it('should not start upload when app id is missing', () => { useAppStore.setState({ appDetail: undefined }) render() fireEvent.change(screen.getByRole('textbox'), { target: { value: 'new-skill' } }) fireEvent.click(screen.getByRole('button', { name: /common\.operation\.create/i })) expect(mocks.mutateAsync).not.toHaveBeenCalled() }) it('should trigger create flow when Enter key is pressed and form is valid', async () => { mocks.mutateAsync.mockResolvedValueOnce([]) render() const input = screen.getByRole('textbox') fireEvent.change(input, { target: { value: 'new-skill' } }) fireEvent.keyDown(input, { key: 'Enter', code: 'Enter' }) await waitFor(() => { expect(mocks.mutateAsync).toHaveBeenCalledTimes(1) }) }) }) })