import { describe, it, expect, beforeEach, vi } from 'vitest'; import { mount, config } from '@vue/test-utils'; import { createPinia, setActivePinia } from 'pinia'; import { createVuetify } from 'vuetify'; import axios from 'axios'; import ProjectDetailsDrawer from '../../resources/js/components/projects/ProjectDetailsDrawer.vue'; import type { Project } from '../../resources/js/stores/projectsStore'; import { useProjectsStore } from '../../resources/js/stores/projectsStore'; vi.mock('axios'); // VAutocomplete (added in regions field task) requires Vuetify defaults provide context. // Register Vuetify plugin globally for all mount() calls in this file. config.global.plugins = [createVuetify()]; const sampleProject: Project = { id: 42, name: 'Натяжные потолки', signal_type: 'call', signal_identifier: '79161112233', daily_limit_target: 30, delivered_today: 0, is_active: true, region_mask: 0, region_mode: 'include', regions: [], delivery_days_mask: 31, // Mon-Fri sync_status: 'pending', }; describe('ProjectDetailsDrawer', () => { beforeEach(() => setActivePinia(createPinia())); it('does not have .open class when project=null', () => { const wrapper = mount(ProjectDetailsDrawer, { props: { project: null } }); const aside = wrapper.find('aside.project-details-drawer'); expect(aside.exists()).toBe(true); expect(aside.classes()).not.toContain('open'); }); it('renders project name + daily_limit_target + delivery_days', () => { const wrapper = mount(ProjectDetailsDrawer, { props: { project: sampleProject } }); const aside = wrapper.find('aside.project-details-drawer'); expect(aside.classes()).toContain('open'); expect(wrapper.text()).toContain('Натяжные потолки'); const nameInput = wrapper.get('input[data-testid="pdd-name"]'); expect((nameInput.element as HTMLInputElement).value).toBe('Натяжные потолки'); const limitInput = wrapper.get('input[data-testid="pdd-limit"]'); expect((limitInput.element as HTMLInputElement).value).toBe('30'); // Days mask 31 = bits 0..4 = Mon..Fri (5 days active) const dayBtns = wrapper.findAll('button[data-testid^="pdd-day-"]'); expect(dayBtns.length).toBe(7); const activeBtns = dayBtns.filter((b) => b.classes().includes('active')); expect(activeBtns.length).toBe(5); }); it('emits close on X button click', async () => { const wrapper = mount(ProjectDetailsDrawer, { props: { project: sampleProject } }); await wrapper.get('[data-testid="pdd-close"]').trigger('click'); expect(wrapper.emitted('close')).toHaveLength(1); }); it('emits close on Cancel button click', async () => { const wrapper = mount(ProjectDetailsDrawer, { props: { project: sampleProject } }); await wrapper.get('[data-testid="pdd-cancel"]').trigger('click'); expect(wrapper.emitted('close')).toHaveLength(1); }); it('emits close on ESC keydown when project is set', async () => { const wrapper = mount(ProjectDetailsDrawer, { props: { project: sampleProject }, attachTo: document.body }); document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })); await wrapper.vm.$nextTick(); expect(wrapper.emitted('close')).toHaveLength(1); wrapper.unmount(); }); it('does NOT emit close on ESC when project=null', async () => { const wrapper = mount(ProjectDetailsDrawer, { props: { project: null }, attachTo: document.body }); document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })); await wrapper.vm.$nextTick(); expect(wrapper.emitted('close')).toBeUndefined(); wrapper.unmount(); }); it('reseeds form when project.id changes', async () => { const wrapper = mount(ProjectDetailsDrawer, { props: { project: sampleProject } }); const nameInputBefore = wrapper.get('input[data-testid="pdd-name"]').element as HTMLInputElement; expect(nameInputBefore.value).toBe('Натяжные потолки'); const other: Project = { ...sampleProject, id: 99, name: 'Окна СПб', daily_limit_target: 50 }; await wrapper.setProps({ project: other }); const nameInputAfter = wrapper.get('input[data-testid="pdd-name"]').element as HTMLInputElement; expect(nameInputAfter.value).toBe('Окна СПб'); expect((wrapper.get('input[data-testid="pdd-limit"]').element as HTMLInputElement).value).toBe('50'); }); it('Save: PATCH /api/projects/{id} + emits saved on 200', async () => { (axios.patch as unknown as ReturnType).mockResolvedValueOnce({ data: { data: sampleProject } }); const wrapper = mount(ProjectDetailsDrawer, { props: { project: sampleProject } }); await wrapper.get('[data-testid="pdd-name"]').setValue('Новое имя'); await wrapper.get('[data-testid="pdd-save"]').trigger('click'); await wrapper.vm.$nextTick(); await wrapper.vm.$nextTick(); expect(axios.patch).toHaveBeenCalledWith( '/api/projects/42', expect.objectContaining({ name: 'Новое имя', daily_limit_target: 30 }), ); expect(wrapper.emitted('saved')).toHaveLength(1); }); it('Save: 422 → errors reactive проставляется, no saved emit', async () => { (axios.patch as unknown as ReturnType).mockRejectedValueOnce({ response: { status: 422, data: { errors: { name: ['Имя обязательно'] } } }, }); const wrapper = mount(ProjectDetailsDrawer, { props: { project: sampleProject } }); await wrapper.get('[data-testid="pdd-save"]').trigger('click'); await wrapper.vm.$nextTick(); await wrapper.vm.$nextTick(); expect(wrapper.emitted('saved')).toBeUndefined(); expect(wrapper.text()).toContain('Имя обязательно'); }); it('Pause button calls store.toggleActive', async () => { (axios.patch as unknown as ReturnType).mockResolvedValueOnce({ data: { data: { ...sampleProject, is_active: false } }, }); (axios.get as unknown as ReturnType | undefined)?.mockResolvedValue?.({ data: { data: [], meta: { total: 0 } }, }); const wrapper = mount(ProjectDetailsDrawer, { props: { project: sampleProject } }); const store = useProjectsStore(); const spy = vi.spyOn(store, 'toggleActive').mockResolvedValueOnce(undefined); await wrapper.get('[data-testid="pdd-pause"]').trigger('click'); expect(spy).toHaveBeenCalledWith(sampleProject); }); it('Pause button label is "Возобновить" when is_active=false', () => { const paused: Project = { ...sampleProject, is_active: false }; const wrapper = mount(ProjectDetailsDrawer, { props: { project: paused } }); expect(wrapper.get('[data-testid="pdd-pause"]').text()).toContain('Возобновить'); }); it('Pause button label is "Приостановить" when is_active=true', () => { const wrapper = mount(ProjectDetailsDrawer, { props: { project: sampleProject } }); expect(wrapper.get('[data-testid="pdd-pause"]').text()).toContain('Приостановить'); }); it('Delete: confirm=true → del + close emit', async () => { const wrapper = mount(ProjectDetailsDrawer, { props: { project: sampleProject } }); const store = useProjectsStore(); const spy = vi.spyOn(store, 'del').mockResolvedValueOnce(undefined); vi.stubGlobal('confirm', () => true); await wrapper.get('[data-testid="pdd-delete"]').trigger('click'); await wrapper.vm.$nextTick(); expect(spy).toHaveBeenCalledWith(42); expect(wrapper.emitted('close')).toBeDefined(); vi.unstubAllGlobals(); }); it('Delete: confirm=false → no del, no close', async () => { const wrapper = mount(ProjectDetailsDrawer, { props: { project: sampleProject } }); const store = useProjectsStore(); const spy = vi.spyOn(store, 'del').mockResolvedValueOnce(undefined); vi.stubGlobal('confirm', () => false); await wrapper.get('[data-testid="pdd-delete"]').trigger('click'); expect(spy).not.toHaveBeenCalled(); expect(wrapper.emitted('close')).toBeUndefined(); vi.unstubAllGlobals(); }); it('Delete: 422 errors.project → drawer не закрывается, текст показан', async () => { const wrapper = mount(ProjectDetailsDrawer, { props: { project: sampleProject } }); const store = useProjectsStore(); const message = 'Мы уже начали сбор лидов по этому проекту на завтра. Пока поставьте на паузу — мы увидим это сегодня в 18:00 и завтра не будем запускать сбор лидов по этому проекту. Удалить можно будет послезавтра.'; vi.spyOn(store, 'del').mockRejectedValueOnce({ response: { status: 422, data: { errors: { project: [message] } } }, }); vi.stubGlobal('confirm', () => true); await wrapper.get('[data-testid="pdd-delete"]').trigger('click'); await wrapper.vm.$nextTick(); await wrapper.vm.$nextTick(); expect(wrapper.emitted('close')).toBeUndefined(); expect(wrapper.text()).toContain('Мы уже начали сбор лидов'); vi.unstubAllGlobals(); }); it('Save: 422 errors.project → текст показан', async () => { // Сброс предыдущих очередей mock'ов, чтобы наш reject точно был первым в queue. vi.mocked(axios.patch).mockReset(); (axios.patch as unknown as ReturnType).mockRejectedValueOnce({ response: { status: 422, data: { errors: { project: ['Мы уже начали сбор лидов по этому проекту на завтра. Изменить источник можно будет послезавтра.'] } }, }, }); const wrapper = mount(ProjectDetailsDrawer, { props: { project: sampleProject } }); await wrapper.get('[data-testid="pdd-save"]').trigger('click'); await wrapper.vm.$nextTick(); await wrapper.vm.$nextTick(); expect(wrapper.emitted('saved')).toBeUndefined(); expect(wrapper.text()).toContain('Изменить источник можно будет послезавтра'); }); it('renders region chips for project.regions = [1, 2]', async () => { const withRegions: Project = { ...sampleProject, regions: [1, 2] }; const wrapper = mount(ProjectDetailsDrawer, { props: { project: withRegions } }); await wrapper.vm.$nextTick(); const text = wrapper.text(); expect(text).toContain('Адыгея'); // code 1 expect(text).toContain('Алтай'); // code 2 (Республика Алтай) }); it('selecting regions adds to regions array (no bitmask conversion)', async () => { (axios.patch as unknown as ReturnType).mockResolvedValueOnce({ data: { data: sampleProject } }); const wrapper = mount(ProjectDetailsDrawer, { props: { project: sampleProject } }); const autocomplete = wrapper.getComponent({ name: 'VAutocomplete' }); await autocomplete.vm.$emit('update:model-value', [82, 83]); await wrapper.vm.$nextTick(); await wrapper.get('[data-testid="pdd-save"]').trigger('click'); await wrapper.vm.$nextTick(); await wrapper.vm.$nextTick(); expect(axios.patch).toHaveBeenCalledWith('/api/projects/42', expect.objectContaining({ regions: [82, 83] })); }); it('clearing all regions sets regions=[] on save', async () => { (axios.patch as unknown as ReturnType).mockResolvedValueOnce({ data: { data: sampleProject } }); const withRegions: Project = { ...sampleProject, regions: [82, 83] }; const wrapper = mount(ProjectDetailsDrawer, { props: { project: withRegions } }); const autocomplete = wrapper.getComponent({ name: 'VAutocomplete' }); await autocomplete.vm.$emit('update:model-value', []); await wrapper.vm.$nextTick(); await wrapper.get('[data-testid="pdd-save"]').trigger('click'); await wrapper.vm.$nextTick(); await wrapper.vm.$nextTick(); expect(axios.patch).toHaveBeenCalledWith('/api/projects/42', expect.objectContaining({ regions: [] })); }); it('region autocomplete has closable-chips so a single region can be removed', () => { const withRegions: Project = { ...sampleProject, regions: [1, 2] }; const wrapper = mount(ProjectDetailsDrawer, { props: { project: withRegions } }); const autocomplete = wrapper.getComponent({ name: 'VAutocomplete' }); expect(autocomplete.props('closableChips')).toBe(true); }); });