e31ea5354a
Заменяет static-снапшот LEAD_STATUSES в коде на live-данные из БД. Custom slug'и (добавленные после deployment'а) теперь видны UI без rebuild'а. Backend: - LeadStatus model (PK=slug string, incrementing=false, timestamps=null). - LeadStatusController::index — GET /api/lead-statuses, ORDER BY sort_order, slug. Таблица глобальная (не tenant-aware), auth не требуется на MVP. Pest +5 (LeadStatusesIndexTest): - 200 + не пустой / все 14 системных slug'ов из seed / все нужные поля / sort_order ASC / кастомный slug после INSERT появляется в endpoint'е. Frontend: - api/leadStatuses.ts::listLeadStatuses — GET helper. - stores/leadStatuses.ts::useLeadStatusesStore — Pinia setup-store: statuses default = LEAD_STATUSES snapshot (UI работает без fetch'а), load(force=false) идемпотентен, bySlug computed Map, findBySlug helper. На fail — snapshot остаётся, fetchError=true. - DealsView/KanbanView/DealDetailDrawer переехали со static-импорта LEAD_STATUSES на store. KanbanView использует safe-access dealsByStatus[slug] || [] (защита от custom slug'а из API без seeded column). load() в onMounted у обоих view'ов. Vitest +7 (leadStatusesStore.spec.ts): - initial snapshot / findBySlug existing & null / load success replace + loaded / load reject — fetchError + snapshot fallback / load идемпотентен / load(force=true) refetch. - 2 spec'а DealDetailDrawer получили setActivePinia(createPinia()) в beforeEach (без этого Pinia store-injection в jsdom падает). PHPStan baseline регенерирован. Регресс: - Lint+type-check+format passed. - Vitest 280/280 за 19.44 сек (+7 от 273). - Vite build 1.17 сек. - Pint + PHPStan passed. - Pest 210/210 за 24.59 сек (+5 от 205, 840 assertions). Реестр v1.63→v1.64 / CLAUDE.md v1.54→v1.55. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
97 lines
4.3 KiB
TypeScript
97 lines
4.3 KiB
TypeScript
import { describe, it, expect, beforeEach } from 'vitest';
|
||
import { mount } from '@vue/test-utils';
|
||
import { createVuetify } from 'vuetify';
|
||
import { createPinia, setActivePinia } from 'pinia';
|
||
import DealDetailDrawer from '../../resources/js/components/deals/DealDetailDrawer.vue';
|
||
import { MOCK_DEALS } from '../../resources/js/composables/mockDeals';
|
||
import { MOCK_EVENTS } from '../../resources/js/composables/mockDealEvents';
|
||
|
||
beforeEach(() => {
|
||
setActivePinia(createPinia());
|
||
});
|
||
|
||
// DealDetailDrawer использует v-navigation-drawer, который требует layout-
|
||
// контекст от v-app/v-layout. В Vitest auto-import недоступен — stub'им
|
||
// v-navigation-drawer как passthrough div чтобы slot-content рендерился
|
||
// и был доступен для assertion.
|
||
|
||
describe('DealDetailDrawer.vue', () => {
|
||
const factory = (props: { open: boolean; deal: (typeof MOCK_DEALS)[number] | null }) =>
|
||
mount(DealDetailDrawer, {
|
||
props,
|
||
global: {
|
||
plugins: [createVuetify()],
|
||
stubs: {
|
||
VNavigationDrawer: {
|
||
template: '<div class="drawer-stub" v-if="modelValue"><slot /></div>',
|
||
props: ['modelValue'],
|
||
},
|
||
},
|
||
},
|
||
});
|
||
|
||
const sampleDeal = MOCK_DEALS[0]; // Анна Соколова
|
||
|
||
it('не рендерит контент когда open=false', () => {
|
||
const wrapper = factory({ open: false, deal: sampleDeal });
|
||
expect(wrapper.find('.drawer-stub').exists()).toBe(false);
|
||
});
|
||
|
||
it('не рендерит контент когда deal=null (даже при open=true)', () => {
|
||
const wrapper = factory({ open: true, deal: null });
|
||
// Drawer открыт, но deal нет — content внутри v-if не рендерится.
|
||
const stub = wrapper.find('.drawer-stub');
|
||
if (stub.exists()) {
|
||
// Нет hero/section элементов внутри.
|
||
expect(wrapper.find('.hero').exists()).toBe(false);
|
||
}
|
||
});
|
||
|
||
it('рендерит hero с именем сделки и id', () => {
|
||
const wrapper = factory({ open: true, deal: sampleDeal });
|
||
const text = wrapper.text();
|
||
expect(text).toContain(sampleDeal.name);
|
||
expect(text).toContain(`#${sampleDeal.id}`);
|
||
});
|
||
|
||
it('рендерит phone как кликабельную ссылку tel:', () => {
|
||
const wrapper = factory({ open: true, deal: sampleDeal });
|
||
const phoneLink = wrapper.find('.phone-link');
|
||
expect(phoneLink.exists()).toBe(true);
|
||
expect(phoneLink.attributes('href')).toMatch(/^tel:\+/);
|
||
expect(phoneLink.text()).toBe(sampleDeal.phone);
|
||
});
|
||
|
||
it('рендерит status-chip с nameRu статуса сделки', () => {
|
||
const wrapper = factory({ open: true, deal: sampleDeal });
|
||
// sampleDeal.statusSlug='new' → 'Новые'.
|
||
expect(wrapper.text()).toContain('Новые');
|
||
});
|
||
|
||
it('рендерит секцию параметров с проектом, стоимостью, менеджером', () => {
|
||
const wrapper = factory({ open: true, deal: sampleDeal });
|
||
const text = wrapper.text();
|
||
expect(text).toContain('Параметры');
|
||
expect(text).toContain(sampleDeal.project);
|
||
expect(text).toContain(sampleDeal.manager.name);
|
||
expect(text).toMatch(/1\s+850\s*₽/); // sampleDeal.cost = 1850
|
||
});
|
||
|
||
it('рендерит timeline с MOCK_EVENTS (6 событий)', () => {
|
||
const wrapper = factory({ open: true, deal: sampleDeal });
|
||
const items = wrapper.findAll('.timeline-item');
|
||
expect(items).toHaveLength(MOCK_EVENTS.length);
|
||
});
|
||
|
||
it('emit-ит update:open=false при close-кнопке', async () => {
|
||
const wrapper = factory({ open: true, deal: sampleDeal });
|
||
// Vuetify v-btn рендерит как button. close-btn — единственный с aria-label.
|
||
const closeBtn = wrapper.find('button[aria-label="Закрыть панель"]');
|
||
if (closeBtn.exists()) {
|
||
await closeBtn.trigger('click');
|
||
expect(wrapper.emitted('update:open')).toBeTruthy();
|
||
expect(wrapper.emitted('update:open')?.[0]).toEqual([false]);
|
||
}
|
||
});
|
||
});
|