2026-05-11 19:38:59 +03:00
|
|
|
|
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: {
|
2026-05-12 20:23:51 +03:00
|
|
|
|
VDialog: {
|
|
|
|
|
|
template: '<div class="dialog-stub" v-if="modelValue"><slot /></div>',
|
|
|
|
|
|
props: ['modelValue'],
|
|
|
|
|
|
},
|
2026-05-11 19:38:59 +03:00
|
|
|
|
NewProjectDialog: true,
|
|
|
|
|
|
EditProjectDialog: true,
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
beforeEach(() => {
|
|
|
|
|
|
setActivePinia(createPinia());
|
|
|
|
|
|
vi.clearAllMocks();
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
describe('ProjectsView', () => {
|
|
|
|
|
|
it('shows empty state when no projects', async () => {
|
2026-05-12 20:23:51 +03:00
|
|
|
|
(axios.get as unknown as ReturnType<typeof vi.fn>).mockResolvedValue({
|
2026-05-11 19:38:59 +03:00
|
|
|
|
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 () => {
|
2026-05-12 20:23:51 +03:00
|
|
|
|
(axios.get as unknown as ReturnType<typeof vi.fn>).mockResolvedValue({
|
2026-05-11 19:38:59 +03:00
|
|
|
|
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.
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-05-14 15:02:33 +03:00
|
|
|
|
it('shows BulkActionsBar when at least 2 selected', async () => {
|
2026-05-12 20:23:51 +03:00
|
|
|
|
(axios.get as unknown as ReturnType<typeof vi.fn>).mockResolvedValue({
|
2026-05-11 19:38:59 +03:00
|
|
|
|
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',
|
|
|
|
|
|
},
|
2026-05-14 15:02:33 +03:00
|
|
|
|
{
|
|
|
|
|
|
id: 2,
|
|
|
|
|
|
name: 'B',
|
|
|
|
|
|
signal_type: 'site',
|
|
|
|
|
|
signal_identifier: 'b.ru',
|
|
|
|
|
|
daily_limit_target: 10,
|
|
|
|
|
|
delivered_today: 0,
|
|
|
|
|
|
is_active: true,
|
|
|
|
|
|
sync_status: 'ok',
|
|
|
|
|
|
},
|
2026-05-11 19:38:59 +03:00
|
|
|
|
],
|
2026-05-14 15:02:33 +03:00
|
|
|
|
meta: { total: 2, current_page: 1, per_page: 20 },
|
2026-05-11 19:38:59 +03:00
|
|
|
|
},
|
|
|
|
|
|
});
|
|
|
|
|
|
const wrapper = factory();
|
|
|
|
|
|
await flushPromises();
|
2026-05-14 15:02:33 +03:00
|
|
|
|
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);
|
2026-05-11 19:38:59 +03:00
|
|
|
|
await wrapper.vm.$nextTick();
|
|
|
|
|
|
expect(wrapper.findComponent({ name: 'BulkActionsBar' }).exists()).toBe(true);
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
2026-05-14 17:14:12 +03:00
|
|
|
|
|
|
|
|
|
|
// 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');
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
2026-05-21 11:17:04 +03:00
|
|
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|