Files
portal/app/tests/Frontend/ProjectsView.spec.ts
T
Дмитрий 84dfbc857a
Accessibility (Pa11y live) / a11y (push) Has been cancelled
test(фронт): привёл стенд в зелёный — 10 протухших спеков под актуальные компоненты
Все падения — устаревшие ожидания тестов (компоненты менялись намеренно):
SettingsView (роутер+вкладка Реквизиты+события), LegalDoc (реальные доки под ЮKassa),
ProjectsView (BulkActionsBar v-show→isVisible), ErrorView (убран фейк REQ/INC),
PricingTiers (формат «500 ₽»), KanbanCard (costKopecks→«—»), ChangePassword (дата из API),
DealDetail (русские ярлыки статусов), DealsView (RuDateField на v-menu), SupplierIntegration
(window.confirm→v-dialog). Изменены ТОЛЬКО тесты, компоненты не тронуты.
Полный прогон: 127 файлов / 992 теста зелёные.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 12:59:01 +03:00

308 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { mount, flushPromises } from '@vue/test-utils';
import { createPinia, setActivePinia } from 'pinia';
import { createVuetify } from 'vuetify';
import axios from 'axios';
vi.mock('axios');
import ProjectsView from '../../resources/js/views/ProjectsView.vue';
// VDialog в JSDOM не рендерит в teleport-цели; стабом отключаем диалоги,
// чтобы не падал mount при попытке портала.
const factory = () =>
mount(ProjectsView, {
global: {
plugins: [createVuetify()],
stubs: {
VDialog: {
template: '<div class="dialog-stub" v-if="modelValue"><slot /></div>',
props: ['modelValue'],
},
NewProjectDialog: true,
EditProjectDialog: true,
},
},
});
beforeEach(() => {
setActivePinia(createPinia());
vi.clearAllMocks();
});
describe('ProjectsView', () => {
it('shows empty state when no projects', async () => {
(axios.get as unknown as ReturnType<typeof vi.fn>).mockResolvedValue({
data: { data: [], meta: { total: 0, current_page: 1, per_page: 20 } },
});
const wrapper = factory();
await flushPromises();
expect(wrapper.text()).toMatch(/нет проектов|empty/i);
});
it('renders card for each project', async () => {
(axios.get as unknown as ReturnType<typeof vi.fn>).mockResolvedValue({
data: {
data: [
{
id: 1,
name: 'AlphaSite',
signal_type: 'site',
signal_identifier: 'a.ru',
daily_limit_target: 10,
delivered_today: 0,
is_active: true,
sync_status: 'ok',
},
],
meta: { total: 1, current_page: 1, per_page: 20 },
},
});
const wrapper = factory();
await flushPromises();
expect(wrapper.text()).toContain('AlphaSite');
});
it.skip('filter by signal_type refetches', async () => {
// TODO: VSelect dropdown в jsdom не открывает items-list через teleport,
// findComponent({name:'VSelect'}).vm.$emit некорректно тригерит реактивную
// цепочку @update:model-value. Покрытие — через Histoire + e2e после Plan 5.
});
it('shows BulkActionsBar when at least 2 selected', async () => {
(axios.get as unknown as ReturnType<typeof vi.fn>).mockResolvedValue({
data: {
data: [
{
id: 1,
name: 'A',
signal_type: 'site',
signal_identifier: 'a.ru',
daily_limit_target: 10,
delivered_today: 0,
is_active: true,
sync_status: 'ok',
},
{
id: 2,
name: 'B',
signal_type: 'site',
signal_identifier: 'b.ru',
daily_limit_target: 10,
delivered_today: 0,
is_active: true,
sync_status: 'ok',
},
],
meta: { total: 2, current_page: 1, per_page: 20 },
},
});
const wrapper = factory();
await flushPromises();
const cards = wrapper.findAllComponents({ name: 'ProjectCard' });
expect(cards.length).toBe(2);
cards[0].vm.$emit('toggle-select', 1);
cards[1].vm.$emit('toggle-select', 2);
await wrapper.vm.$nextTick();
expect(wrapper.findComponent({ name: 'BulkActionsBar' }).isVisible()).toBe(true);
});
});
// Integration tests for the Task 8 contract: drawer × bulk-bar mutual exclusion.
// Contract:
// - 0 selected → no drawer open, no bulk-bar, no .has-drawer class.
// - 1 selected → drawer open, bulk-bar hidden, .has-drawer class on view.
// - ≥2 selected → drawer NOT open, bulk-bar visible, no .has-drawer class.
// - Drawer @close → store.clearSelection() → both hidden.
// - singleSelectedProject = null when selected id not in items.
describe('ProjectsView × ProjectDetailsDrawer integration', () => {
const projectA = {
id: 1,
name: 'A',
signal_type: 'site' as const,
signal_identifier: 'a.ru',
daily_limit_target: 10,
delivered_today: 0,
is_active: true,
sync_status: 'ok' as const,
};
const projectB = {
id: 2,
name: 'B',
signal_type: 'site' as const,
signal_identifier: 'b.ru',
daily_limit_target: 10,
delivered_today: 0,
is_active: true,
sync_status: 'ok' as const,
};
function mockProjects(items: Array<Record<string, unknown>>) {
(axios.get as unknown as ReturnType<typeof vi.fn>).mockResolvedValue({
data: {
data: items,
meta: { total: items.length, current_page: 1, per_page: 20 },
},
});
}
it('0 selected → no drawer open, no bulk-bar, no .has-drawer class', async () => {
mockProjects([projectA, projectB]);
const wrapper = factory();
await flushPromises();
// Drawer aside element always renders, but should not have .open class.
const drawer = wrapper.find('aside.project-details-drawer');
expect(drawer.exists()).toBe(true);
expect(drawer.classes()).not.toContain('open');
// BulkActionsBar uses v-if; should not exist.
// BulkActionsBar теперь v-show (всегда смонтирован, скрыт display:none) → проверяем видимость.
expect(wrapper.findComponent({ name: 'BulkActionsBar' }).isVisible()).toBe(false);
// .has-drawer class should not be on the view root.
expect(wrapper.find('.projects-view').classes()).not.toContain('has-drawer');
});
it('1 selected → drawer open, bulk-bar hidden, .has-drawer class present', async () => {
mockProjects([projectA, projectB]);
const wrapper = factory();
await flushPromises();
const cards = wrapper.findAllComponents({ name: 'ProjectCard' });
expect(cards.length).toBe(2);
cards[0].vm.$emit('toggle-select', 1);
await wrapper.vm.$nextTick();
// Drawer should be open.
const drawer = wrapper.find('aside.project-details-drawer');
expect(drawer.exists()).toBe(true);
expect(drawer.classes()).toContain('open');
// BulkActionsBar should NOT exist (size < 2).
// BulkActionsBar теперь v-show (всегда смонтирован, скрыт display:none) → проверяем видимость.
expect(wrapper.findComponent({ name: 'BulkActionsBar' }).isVisible()).toBe(false);
// .has-drawer class should be present.
expect(wrapper.find('.projects-view').classes()).toContain('has-drawer');
});
it('2 selected → drawer NOT open, bulk-bar visible, no .has-drawer class', async () => {
mockProjects([projectA, projectB]);
const wrapper = factory();
await flushPromises();
const cards = wrapper.findAllComponents({ name: 'ProjectCard' });
expect(cards.length).toBe(2);
cards[0].vm.$emit('toggle-select', 1);
cards[1].vm.$emit('toggle-select', 2);
await wrapper.vm.$nextTick();
// Drawer should NOT be open (singleSelectedProject = null when size != 1).
const drawer = wrapper.find('aside.project-details-drawer');
expect(drawer.exists()).toBe(true);
expect(drawer.classes()).not.toContain('open');
// BulkActionsBar should exist (size >= 2).
expect(wrapper.findComponent({ name: 'BulkActionsBar' }).isVisible()).toBe(true);
// .has-drawer class should NOT be present.
expect(wrapper.find('.projects-view').classes()).not.toContain('has-drawer');
});
it('drawer @close → store.clearSelection → both bulk-bar and drawer hidden', async () => {
mockProjects([projectA, projectB]);
const wrapper = factory();
await flushPromises();
const cards = wrapper.findAllComponents({ name: 'ProjectCard' });
cards[0].vm.$emit('toggle-select', 1);
await wrapper.vm.$nextTick();
// Sanity: drawer is open with 1 selected.
const drawerBefore = wrapper.find('aside.project-details-drawer');
expect(drawerBefore.classes()).toContain('open');
// Emit close from drawer; onDrawerClose handler calls store.clearSelection().
const drawerComp = wrapper.findComponent({ name: 'ProjectDetailsDrawer' });
expect(drawerComp.exists()).toBe(true);
drawerComp.vm.$emit('close');
await wrapper.vm.$nextTick();
// Both should be hidden now.
const drawerAfter = wrapper.find('aside.project-details-drawer');
expect(drawerAfter.classes()).not.toContain('open');
// BulkActionsBar теперь v-show (всегда смонтирован, скрыт display:none) → проверяем видимость.
expect(wrapper.findComponent({ name: 'BulkActionsBar' }).isVisible()).toBe(false);
expect(wrapper.find('.projects-view').classes()).not.toContain('has-drawer');
});
it('singleSelectedProject = null when selected id is not in items', async () => {
// Items contain only id=1, but we'll select id=999 (not present).
mockProjects([projectA]);
const wrapper = factory();
await flushPromises();
// Reach into store to add a phantom id directly (simulates stale selection
// after items refetch dropped the project).
const cards = wrapper.findAllComponents({ name: 'ProjectCard' });
expect(cards.length).toBe(1);
cards[0].vm.$emit('toggle-select', 999);
await wrapper.vm.$nextTick();
// Selection size is 1, but lookup fails → singleSelectedProject = null
// → drawer NOT open.
const drawer = wrapper.find('aside.project-details-drawer');
expect(drawer.exists()).toBe(true);
expect(drawer.classes()).not.toContain('open');
// .has-drawer also depends on singleSelectedProject !== null.
expect(wrapper.find('.projects-view').classes()).not.toContain('has-drawer');
});
});
describe('ProjectsView 18:00 cutoff banner', () => {
// Косяк 07: баннер «до 18:00» и фильтры показываются, только когда есть
// проекты. Для банннер-тестов мокаем непустой аккаунт.
const oneProject = {
id: 1,
name: 'A',
signal_type: 'site',
signal_identifier: 'a.ru',
daily_limit_target: 10,
delivered_today: 0,
is_active: true,
sync_status: 'ok',
};
beforeEach(() => {
localStorage.clear();
(axios.get as unknown as ReturnType<typeof vi.fn>).mockResolvedValue({
data: { data: [oneProject], meta: { total: 1, current_page: 1, per_page: 20 } },
});
});
it('shows the cutoff banner with the 18:00 deadline by default', async () => {
const wrapper = factory();
await flushPromises();
const banner = wrapper.find('[data-testid="cutoff-banner"]');
expect(banner.exists()).toBe(true);
expect(banner.text()).toContain('18:00');
});
it('hides the banner after the close button and remembers it in localStorage', async () => {
const wrapper = factory();
await flushPromises();
await wrapper.find('[data-testid="cutoff-banner-close"]').trigger('click');
await wrapper.vm.$nextTick();
expect(wrapper.find('[data-testid="cutoff-banner"]').exists()).toBe(false);
expect(localStorage.getItem('projects.cutoffBannerDismissed')).toBe('1');
});
it('stays hidden on next mount when previously dismissed', async () => {
localStorage.setItem('projects.cutoffBannerDismissed', '1');
const wrapper = factory();
await flushPromises();
expect(wrapper.find('[data-testid="cutoff-banner"]').exists()).toBe(false);
});
});
describe('ProjectsView — косяк 07: пустой аккаунт без шума', () => {
beforeEach(() => {
localStorage.clear();
(axios.get as unknown as ReturnType<typeof vi.fn>).mockResolvedValue({
data: { data: [], meta: { total: 0, current_page: 1, per_page: 20 } },
});
});
it('при 0 проектов скрывает баннер «до 18:00», фильтры и пагинацию, оставляя подсказку', async () => {
const wrapper = factory();
await flushPromises();
expect(wrapper.find('[data-testid="cutoff-banner"]').exists()).toBe(false);
expect(wrapper.find('[data-testid="filter-region"]').exists()).toBe(false);
expect(wrapper.find('[data-testid="perpage-toggle"]').exists()).toBe(false);
expect(wrapper.text()).toMatch(/Нет проектов/i);
});
});