Files
portal/app/tests/Frontend/ProjectsView.spec.ts
T
Дмитрий a85cf3d32b feat(projects): пагинация страницы «Проекты» — переключатель страниц снизу
Бэкенд ProjectController уже отдаёт постранично (page+per_page),
projectsStore уже знает фильтры/total, на view не было UI-контрола.
Клиент с 125 проектами видел только первые 20 без возможности листать.

+ pageCount = ceil(store.total / store.filters.per_page)
+ <v-pagination v-if="pageCount > 1" v-model="store.filters.page" ...>
  с @update:model-value="store.fetch()" — refetch при смене страницы.
+ 3 spec'а: renders when total>per_page / hidden when total<=per_page /
  page change triggers refetch.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 12:27:16 +03:00

333 lines
14 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);
});
});
// 2026-05-22: при total > per_page фронт должен показывать переключатель
// страниц (бэкенд уже отдаёт постранично — без UI пользователь видел только
// первые 20 проектов из 125).
describe('ProjectsView pagination', () => {
function makeCard(id: number) {
return {
id,
name: `P${id}`,
signal_type: 'site' as const,
signal_identifier: `${id}.ru`,
daily_limit_target: 10,
delivered_today: 0,
is_active: true,
sync_status: 'ok' as const,
};
}
it('renders pagination control when total > per_page', async () => {
const items = Array.from({ length: 20 }, (_, i) => makeCard(i + 1));
(axios.get as unknown as ReturnType<typeof vi.fn>).mockResolvedValue({
data: { data: items, meta: { total: 125, current_page: 1, per_page: 20 } },
});
const wrapper = factory();
await flushPromises();
expect(wrapper.find('[data-testid="projects-pagination"]').exists()).toBe(true);
});
it('does NOT render pagination when total <= per_page', async () => {
const items = [makeCard(1), makeCard(2)];
(axios.get as unknown as ReturnType<typeof vi.fn>).mockResolvedValue({
data: { data: items, meta: { total: 2, current_page: 1, per_page: 20 } },
});
const wrapper = factory();
await flushPromises();
expect(wrapper.find('[data-testid="projects-pagination"]').exists()).toBe(false);
});
it('changing page triggers refetch with new page param', async () => {
const items = Array.from({ length: 20 }, (_, i) => makeCard(i + 1));
const mockGet = axios.get as unknown as ReturnType<typeof vi.fn>;
mockGet.mockResolvedValue({
data: { data: items, meta: { total: 125, current_page: 1, per_page: 20 } },
});
const wrapper = factory();
await flushPromises();
mockGet.mockClear();
// Emulate user picking page 2 via VPagination's model-value.
const pagination = wrapper.findComponent({ name: 'VPagination' });
expect(pagination.exists()).toBe(true);
pagination.vm.$emit('update:modelValue', 2);
await flushPromises();
expect(mockGet).toHaveBeenCalledWith(
'/api/projects',
expect.objectContaining({ params: expect.objectContaining({ page: 2 }) }),
);
});
});