Files
portal/app/tests/Frontend/ProjectsView.spec.ts
T
Дмитрий 68f42ad385 feat(projects): информационный баннер о сроке изменений до 18:00 МСК
Закрывается крестиком, закрытие запоминается в localStorage. Чисто фронтенд (информация, без блокировок, без бэкенда). +3 Vitest.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 11:21:42 +03:00

275 lines
12 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' }).exists()).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.
expect(wrapper.findComponent({ name: 'BulkActionsBar' }).exists()).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).
expect(wrapper.findComponent({ name: 'BulkActionsBar' }).exists()).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' }).exists()).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');
expect(wrapper.findComponent({ name: 'BulkActionsBar' }).exists()).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', () => {
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('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);
});
});