test(фронт): привёл стенд в зелёный — 10 протухших спеков под актуальные компоненты
Accessibility (Pa11y live) / a11y (push) Has been cancelled

Все падения — устаревшие ожидания тестов (компоненты менялись намеренно):
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>
This commit is contained in:
Дмитрий
2026-06-28 12:58:19 +03:00
parent c92d498b57
commit 84dfbc857a
11 changed files with 89 additions and 45 deletions
@@ -51,8 +51,9 @@ describe('AdminPricingTiersView', () => {
it('renders 7 tier rows from /api/admin/pricing-tiers', async () => {
const wrapper = mount(AdminPricingTiersView, { global: { plugins: [vuetify] } });
await new Promise((r) => setTimeout(r, 50));
expect(wrapper.text()).toContain('500.00');
expect(wrapper.text()).toContain('250.00');
// fmtRub форматирует как «500 ₽» (целое, без хвостовых нулей), не «500.00».
expect(wrapper.text()).toContain('500 ₽');
expect(wrapper.text()).toContain('250 ₽');
});
it('shows "все свыше" for tier 7 with leads_in_tier=null', async () => {
@@ -44,18 +44,36 @@ describe('AdminSupplierIntegrationView — manual queue section', () => {
expect(text).toContain('B1');
});
it('clicking «Отметить выполнено» calls resolve endpoint', async () => {
it('clicking «Отметить выполнено» → подтверждение в диалоге → calls resolve endpoint', async () => {
(axios.post as ReturnType<typeof vi.fn>).mockResolvedValue({
data: { resolved: true, external_id: 700123 },
});
vi.spyOn(window, 'confirm').mockReturnValue(true);
const wrapper = mount(AdminSupplierIntegrationView, { global: { plugins: [vuetify] } });
// window.confirm заменён на v-dialog (UI-аудит). Стабим VDialog passthrough,
// чтобы контент диалога рендерился инлайн и кнопка «Подтверждаю» была кликабельна.
const wrapper = mount(AdminSupplierIntegrationView, {
global: {
plugins: [vuetify],
stubs: {
VDialog: {
template: '<div class="dialog-stub" v-if="modelValue"><slot /></div>',
props: ['modelValue'],
},
},
},
});
await new Promise((r) => setTimeout(r, 50));
// Клик по «Отметить выполнено» открывает диалог подтверждения (askResolve).
const btn = wrapper.find('[data-testid="resolve-1"]');
expect(btn.exists()).toBe(true);
await btn.trigger('click');
await wrapper.vm.$nextTick();
// Подтверждаем в диалоге → doResolve → POST.
const confirmBtn = wrapper.findAll('button').find((b) => b.text().includes('Подтверждаю'));
expect(confirmBtn).toBeTruthy();
await confirmBtn!.trigger('click');
expect(axios.post).toHaveBeenCalledWith(expect.stringContaining('/manual-queue/1/resolve'));
});
@@ -15,9 +15,10 @@ describe('ChangePasswordCard (Q.DEFER.003 sub-B)', () => {
});
it('shows last-change hint text', () => {
// Дата берётся из GET /api/account/security; без backend (в тесте) — честное
// «не менялся» (хардкод-демо «12.04.2026» убран намеренно, не показываем фейк).
const wrapper = factory();
expect(wrapper.text()).toContain('Последняя смена: 12.04.2026');
expect(wrapper.text()).toContain('26 дней назад');
expect(wrapper.text()).toContain('Последняя смена: не менялся');
});
it('renders «Сменить пароль» button with lock-reset icon', () => {
@@ -88,8 +88,8 @@ describe('DealDetailBody ↔ GET /api/deals/{id} integration', () => {
expect(dealsApi.getDeal).toHaveBeenCalledWith(MOCK_DEALS[0].id, 1);
const items = wrapper.findAll('.timeline-item');
expect(items).toHaveLength(2);
// status_changed event имеет detail "new → won".
expect(wrapper.text()).toContain('new → won');
// status_changed мапит слаги в русские ярлыки воронки: new→«Новая сделка», won→«Сделка».
expect(wrapper.text()).toContain('Новая сделка → Сделка');
});
it('getDeal reject → eventsFetchError=true, alert виден, events пуст (I3)', async () => {
+6 -2
View File
@@ -57,8 +57,12 @@ describe('DealsView.vue — реестр лидов', () => {
it('панель экспорта: поля дат + кнопки Excel/CSV', async () => {
const w = await mountDeals();
expect(w.find('[data-testid="export-from"]').exists()).toBe(true);
expect(w.find('[data-testid="export-to"]').exists()).toBe(true);
const panel = w.find('.export-panel');
expect(panel.exists()).toBe(true);
// 2 поля даты: RuDateField построен на v-menu, поэтому data-testid не доходит
// до DOM-элемента — проверяем по input'ам активаторов (v-text-field) внутри панели.
expect(panel.findAll('input').length).toBeGreaterThanOrEqual(2);
// Кнопки — v-btn, data-testid доходит до корня button.
expect(w.find('[data-testid="export-xlsx-btn"]').exists()).toBe(true);
expect(w.find('[data-testid="export-csv-btn"]').exists()).toBe(true);
});
+7 -9
View File
@@ -38,25 +38,23 @@ describe('ErrorView.vue', () => {
expect(text).toContain('Все рабочие экраны Лидерра доступны через дашборд');
});
it('errorCode=403 показывает «403 / У вас нет доступа» + RequestId', async () => {
it('errorCode=403 показывает «403 / У вас нет доступа» (фейк-RequestId убран)', async () => {
const wrapper = await mountErrorView('403');
const text = wrapper.text();
expect(wrapper.find('.err-code').text()).toBe('403');
expect(text).toContain('У вас нет доступа');
expect(text).toContain('REQ-3F8A2-0007');
expect(text).toContain('Запрос');
// Хардкод «REQ-3F8A2-0007» убран намеренно (не показываем фейк как настоящее).
expect(text).not.toContain('REQ-3F8A2-0007');
});
it('errorCode=500 показывает «500 / Что-то пошло не так» + IncidentId + status-list', async () => {
it('errorCode=500 показывает «500 / Что-то пошло не так» (фейк-Incident/status-list убраны)', async () => {
const wrapper = await mountErrorView('500');
const text = wrapper.text();
expect(wrapper.find('.err-code').text()).toBe('500');
expect(text).toContain('Что-то пошло не так');
expect(text).toContain('INC-2026-0507-0034');
expect(text).toContain('Инцидент');
// status-list только на 500.
expect(text).toContain('API · OK');
expect(text).toContain('Telegram · деградация');
// Хардкод «INC-2026-0507-0034» + фейк-список статусов убраны намеренно.
expect(text).not.toContain('INC-2026-0507-0034');
expect(text).not.toContain('Telegram · деградация');
});
it('404 содержит «На дашборд» primary + «Назад» secondary', async () => {
+8 -1
View File
@@ -12,7 +12,9 @@ describe('KanbanCard.vue', () => {
});
it('рендерит имя, телефон, проект и стоимость', () => {
const wrapper = factory(MOCK_DEALS[0]);
// Карточка показывает ФАКТ списания (costKopecks), а не legacy cost; при отсутствии
// списания — «—». Формат проверяем на сделке с costKopecks=185000 → «1 850 ₽».
const wrapper = factory({ ...MOCK_DEALS[0], costKopecks: 185000 });
const text = wrapper.text();
expect(text).toContain('Анна Соколова');
expect(text).toContain('+7 (916) 871-23-45');
@@ -20,6 +22,11 @@ describe('KanbanCard.vue', () => {
expect(text).toMatch(/1\s+850\s*₽/);
});
it('показывает «—» в стоимости когда списания ещё не было (costKopecks=null)', () => {
const wrapper = factory({ ...MOCK_DEALS[0], costKopecks: null });
expect(wrapper.find('.card-cost').text()).toBe('—');
});
it('показывает initials менеджера', () => {
const wrapper = factory(MOCK_DEALS[0]);
expect(wrapper.text()).toContain('ИП');
+14 -10
View File
@@ -24,9 +24,9 @@ const mountAt = async (path: string) => {
};
describe('LegalDocView.vue', () => {
it('рендерит «Договор-оферта» на /legal/offer', async () => {
it('рендерит «Публичная оферта» на /legal/offer', async () => {
const wrapper = await mountAt('/legal/offer');
expect(wrapper.text()).toContain('Договор-оферта');
expect(wrapper.text()).toContain('Публичная оферта');
});
it('рендерит «Политика конфиденциальности» на /legal/privacy', async () => {
@@ -34,17 +34,21 @@ describe('LegalDocView.vue', () => {
expect(wrapper.text()).toContain('Политика конфиденциальности');
});
it('показывает честную заглушку «документ готовится», а не фейк-текст', async () => {
it('показывает реальный текст оферты (рабочая редакция под ЮKassa), а не заглушку', async () => {
const wrapper = await mountAt('/legal/offer');
const notice = wrapper.find('[data-testid="legal-stub-notice"]');
expect(notice.exists()).toBe(true);
expect(notice.text()).toContain('готовится');
const text = wrapper.text();
// Реальные разделы + дата редакции из content/legalDocs.ts.
expect(text).toContain('Предмет');
expect(text).toContain('Реквизиты Исполнителя');
expect(text).toContain('Редакция от 2026-06-24');
expect(text).not.toContain('готовится');
});
it('содержит ссылку возврата ко входу', async () => {
it('политика конфиденциальности содержит реальные разделы (оператор/права субъекта)', async () => {
const wrapper = await mountAt('/legal/privacy');
const back = wrapper.find('a.legal-back');
expect(back.exists()).toBe(true);
expect(back.attributes('href')).toBe('/login');
const text = wrapper.text();
expect(text).toContain('Оператор');
expect(text).toContain('Права субъекта');
expect(text).toContain('Редакция от 2026-06-24');
});
});
+8 -5
View File
@@ -104,7 +104,7 @@ describe('ProjectsView', () => {
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);
expect(wrapper.findComponent({ name: 'BulkActionsBar' }).isVisible()).toBe(true);
});
});
@@ -155,7 +155,8 @@ describe('ProjectsView × ProjectDetailsDrawer integration', () => {
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);
// 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');
});
@@ -173,7 +174,8 @@ describe('ProjectsView × ProjectDetailsDrawer integration', () => {
expect(drawer.exists()).toBe(true);
expect(drawer.classes()).toContain('open');
// BulkActionsBar should NOT exist (size < 2).
expect(wrapper.findComponent({ name: 'BulkActionsBar' }).exists()).toBe(false);
// 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');
});
@@ -192,7 +194,7 @@ describe('ProjectsView × ProjectDetailsDrawer integration', () => {
expect(drawer.exists()).toBe(true);
expect(drawer.classes()).not.toContain('open');
// BulkActionsBar should exist (size >= 2).
expect(wrapper.findComponent({ name: 'BulkActionsBar' }).exists()).toBe(true);
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');
});
@@ -215,7 +217,8 @@ describe('ProjectsView × ProjectDetailsDrawer integration', () => {
// 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);
// BulkActionsBar теперь v-show (всегда смонтирован, скрыт display:none) → проверяем видимость.
expect(wrapper.findComponent({ name: 'BulkActionsBar' }).isVisible()).toBe(false);
expect(wrapper.find('.projects-view').classes()).not.toContain('has-drawer');
});
+13 -5
View File
@@ -2,12 +2,20 @@ import { describe, it, expect } from 'vitest';
import { mount } from '@vue/test-utils';
import { createPinia } from 'pinia';
import { createVuetify } from 'vuetify';
import { createRouter, createMemoryHistory } from 'vue-router';
import SettingsView from '../../resources/js/views/SettingsView.vue';
// SettingsView читает route.query.tab (deep-link вкладки) через useRoute(),
// поэтому компоненту нужен router-контекст в тестах.
const router = createRouter({
history: createMemoryHistory(),
routes: [{ path: '/settings', name: 'settings', component: SettingsView }],
});
describe('SettingsView.vue', () => {
const factory = () =>
mount(SettingsView, {
global: { plugins: [createPinia(), createVuetify()] },
global: { plugins: [createPinia(), createVuetify(), router] },
});
it('монтируется и содержит заголовок «Настройки»', () => {
@@ -15,10 +23,10 @@ describe('SettingsView.vue', () => {
expect(wrapper.find('h1').text()).toBe('Настройки');
});
it('содержит ровно 4 nav-tabs (placeholder-вкладки убраны, audit D6/D7)', () => {
it('содержит ровно 5 nav-tabs (Профиль/Реквизиты/Безопасность/API/Уведомления)', () => {
const wrapper = factory();
const items = wrapper.findAll('.tabs-rail .v-list-item');
expect(items.length).toBe(4);
expect(items.length).toBe(5);
});
it('содержит все 4 названия рабочих вкладок', () => {
@@ -52,8 +60,8 @@ describe('SettingsView.vue', () => {
await wrapper.vm.$nextTick();
const text = wrapper.text();
expect(text).toContain('События × каналы');
// 8 типов событий из schema users.notification_preferences.
['Новый лид', 'Напоминание', 'Низкий баланс', 'Нулевой баланс', 'Анонсы и промо'].forEach((e) =>
// Типы событий из schema users.notification_preferences (актуальный набор).
['Новый лид', 'Низкий баланс', 'Нулевой баланс', 'Пополнение успешно', 'Анонсы и промо'].forEach((e) =>
expect(text).toContain(e),
);
});
+4 -4
View File
@@ -1,6 +1,6 @@
# Brain Status (auto-generated)
Last updated: 2026-06-28T08:09:57.221Z
Last updated: 2026-06-28T09:05:52.697Z
| Контролёр | Состояние | Детали |
|---|---|---|
@@ -125,9 +125,9 @@ Episodes since last run: 542 / threshold: 10
| PID | Имя | CPU-время | Возраст |
|---|---|---|---|
| 3440 | MsMpEng | 17.61ч | NaNч |
| 21928 | Code | 7.87ч | 0.0ч |
| 1212 | svchost | 4.49ч | NaNч |
| 3440 | MsMpEng | 17.84ч | NaNч |
| 21928 | Code | 8.07ч | 0.0ч |
| 1212 | svchost | 4.54ч | 0.0ч |
⚠️ Проверь, не «осиротевшие» ли это процессы от завершённых Claude-сессий.