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, archived_at: null, region_mask: 0, region_mode: 'include', 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 → archive + close emit', async () => { const wrapper = mount(ProjectDetailsDrawer, { props: { project: sampleProject } }); const store = useProjectsStore(); const spy = vi.spyOn(store, 'archive').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 archive, no close', async () => { const wrapper = mount(ProjectDetailsDrawer, { props: { project: sampleProject } }); const store = useProjectsStore(); const spy = vi.spyOn(store, 'archive').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('renders region chips when project has non-zero region_mask', async () => { const withRegions: Project = { ...sampleProject, region_mask: 6, region_mode: 'exclude' }; const wrapper = mount(ProjectDetailsDrawer, { props: { project: withRegions } }); await wrapper.vm.$nextTick(); const text = wrapper.text(); expect(text).toContain('Адыгея'); expect(text).toContain('Башкортостан'); }); it('selecting regions encodes mask + sets mode=exclude on save', 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', [3, 5]); 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({ region_mask: 40, region_mode: 'exclude' }), ); }); it('clearing all regions resets mask=0 + mode=include on save', async () => { (axios.patch as unknown as ReturnType).mockResolvedValueOnce({ data: { data: sampleProject } }); const withRegions: Project = { ...sampleProject, region_mask: 6, region_mode: 'exclude' }; 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({ region_mask: 0, region_mode: 'include' }), ); }); });