Files
portal/app/tests/Frontend/ProjectDetailsDrawer.spec.ts
T
Дмитрий f9820460fa feat(pdd): regions multi-select autocomplete + bitmask binding
Реализует Out-of-plan «Region multi-select autocomplete» из parent PDD spec.
Spec: 4f60add. Plan: 159ed3e.

Component (ProjectDetailsDrawer.vue):
- import REGIONS из constants/regions
- selectedRegions: Ref<number[]> + selectableRegions (filter code !== 0
  для исключения «Вся РФ» sentinel — fixes latent NewProjectDialog bug)
- maskToCodes(mask): reverse-decompose bits 1..31
- reseedFromProject: +selectedRegions.value = maskToCodes(form.region_mask)
- watch(selectedRegions): forward-encode mask + mode (include при empty, exclude иначе)
- Template: v-autocomplete multi+chips+clearable между Лимитом и Днями

Tests (ProjectDetailsDrawer.spec.ts): 17 passed (14 prior + 3 new):
- renders region chips when project has non-zero region_mask
- selecting regions encodes mask + sets mode=exclude on save
- clearing all regions resets mask=0 + mode=include on save

NB: config.global.plugins = [createVuetify()] добавлен в spec.ts — v-autocomplete
требует Vuetify defaults provide context. Все 17 PDD tests + 8/1sk ProjectsView
integration green (0 regressions).

Backend без изменений (region_mask + region_mode payload уже в Task 5 onSave).
2026-05-14 17:51:56 +03:00

217 lines
10 KiB
TypeScript

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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).mockResolvedValueOnce({ data: { data: { ...sampleProject, is_active: false } } });
(axios.get as unknown as ReturnType<typeof vi.fn> | 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<typeof vi.fn>).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<typeof vi.fn>).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' }),
);
});
});