/** * KIN-UI-008: ChatView — логирование ошибок в polling-цикле * * Проверяет: * 1. console.warn вызывается при каждой ошибке polling * 2. После 3 последовательных ошибок — error.value устанавливается * 3. После 3 ошибок — polling останавливается * 4. Счётчик сбрасывается при успешном ответе (error.value тоже сбрасывается) * 5. Менее 3 ошибок — error.value не устанавливается * 6. load() сбрасывает consecutiveErrors до начала загрузки */ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import { mount, flushPromises } from '@vue/test-utils' import { createRouter, createMemoryHistory } from 'vue-router' import ChatView from '../views/ChatView.vue' vi.mock('../api', async (importOriginal) => { const actual = await importOriginal() return { ...actual, api: { chatHistory: vi.fn(), project: vi.fn(), sendChatMessage: vi.fn(), }, } }) import { api } from '../api' const Stub = { template: '
' } function makeRouter() { return createRouter({ history: createMemoryHistory(), routes: [ { path: '/', component: Stub }, { path: '/project/:id', component: Stub }, { path: '/chat/:projectId', component: ChatView, props: true }, ], }) } const MOCK_MESSAGES_IDLE = [ { id: 1, project_id: 'KIN', role: 'user', content: 'Привет', message_type: 'text', task_stub: null, created_at: '2024-01-01T00:00:00', }, ] const MOCK_MESSAGES_WITH_RUNNING_TASK = [ { id: 1, project_id: 'KIN', role: 'assistant', content: 'Работаю...', message_type: 'task_created', task_stub: { id: 'KIN-001', status: 'in_progress' }, created_at: '2024-01-01T00:00:00', }, ] const MOCK_PROJECT = { id: 'KIN', name: 'Kin', path: '/projects/kin', status: 'active', } beforeEach(() => { vi.useFakeTimers() vi.clearAllMocks() vi.mocked(api.project).mockResolvedValue(MOCK_PROJECT as any) }) afterEach(() => { vi.useRealTimers() vi.restoreAllMocks() }) async function mountChatView(projectId = 'KIN') { const router = makeRouter() await router.push(`/chat/${projectId}`) const wrapper = mount(ChatView, { props: { projectId }, global: { plugins: [router] }, }) return wrapper } describe('KIN-UI-008: ChatView — polling error handling', () => { describe('console.warn при ошибках polling', () => { it('console.warn вызывается при первой ошибке polling', async () => { // Первый вызов (load) — успех, второй (polling) — ошибка vi.mocked(api.chatHistory) .mockResolvedValueOnce(MOCK_MESSAGES_WITH_RUNNING_TASK as any) .mockRejectedValue(new Error('Network error')) const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) const wrapper = await mountChatView() await flushPromises() // Триггерим первый тик polling await vi.advanceTimersByTimeAsync(3000) await flushPromises() // Фильтруем только polling-предупреждения (игнорируем Vue Router warn) const pollingCalls = warnSpy.mock.calls.filter( args => typeof args[0] === 'string' && args[0].includes('[polling]'), ) expect(pollingCalls).toHaveLength(1) expect(pollingCalls[0][0]).toContain('[polling] ошибка #1:') expect(pollingCalls[0][1]).toBeInstanceOf(Error) wrapper.unmount() }) it('console.warn содержит нарастающий номер ошибки при нескольких сбоях', async () => { vi.mocked(api.chatHistory) .mockResolvedValueOnce(MOCK_MESSAGES_WITH_RUNNING_TASK as any) .mockRejectedValue(new Error('Server down')) const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) const wrapper = await mountChatView() await flushPromises() await vi.advanceTimersByTimeAsync(3000) await flushPromises() await vi.advanceTimersByTimeAsync(3000) await flushPromises() // Фильтруем только polling-предупреждения const pollingCalls = warnSpy.mock.calls.filter( args => typeof args[0] === 'string' && args[0].includes('[polling]'), ) expect(pollingCalls).toHaveLength(2) expect(pollingCalls[0][0]).toContain('#1:') expect(pollingCalls[1][0]).toContain('#2:') wrapper.unmount() }) }) describe('error.value после 3 последовательных ошибок', () => { it('error.value устанавливается после 3 ошибок подряд', async () => { vi.mocked(api.chatHistory) .mockResolvedValueOnce(MOCK_MESSAGES_WITH_RUNNING_TASK as any) .mockRejectedValue(new Error('Server down')) vi.spyOn(console, 'warn').mockImplementation(() => {}) const wrapper = await mountChatView() await flushPromises() // Ошибки 1, 2, 3 await vi.advanceTimersByTimeAsync(3000) await flushPromises() await vi.advanceTimersByTimeAsync(3000) await flushPromises() await vi.advanceTimersByTimeAsync(3000) await flushPromises() expect(wrapper.text()).toContain('Сервер недоступен') wrapper.unmount() }) it('error.value НЕ устанавливается после 1 ошибки', async () => { vi.mocked(api.chatHistory) .mockResolvedValueOnce(MOCK_MESSAGES_WITH_RUNNING_TASK as any) .mockRejectedValueOnce(new Error('Transient error')) .mockResolvedValue(MOCK_MESSAGES_IDLE as any) vi.spyOn(console, 'warn').mockImplementation(() => {}) const wrapper = await mountChatView() await flushPromises() // Только 1 ошибка, потом успех await vi.advanceTimersByTimeAsync(3000) await flushPromises() expect(wrapper.text()).not.toContain('Сервер недоступен') wrapper.unmount() }) it('error.value НЕ устанавливается после 2 ошибок подряд', async () => { vi.mocked(api.chatHistory) .mockResolvedValueOnce(MOCK_MESSAGES_WITH_RUNNING_TASK as any) .mockRejectedValueOnce(new Error('err 1')) .mockRejectedValueOnce(new Error('err 2')) .mockResolvedValue(MOCK_MESSAGES_IDLE as any) vi.spyOn(console, 'warn').mockImplementation(() => {}) const wrapper = await mountChatView() await flushPromises() await vi.advanceTimersByTimeAsync(3000) await flushPromises() await vi.advanceTimersByTimeAsync(3000) await flushPromises() expect(wrapper.text()).not.toContain('Сервер недоступен') wrapper.unmount() }) }) describe('сброс счётчика при успешном ответе', () => { it('после успешного ответа (reload через смену projectId) error.value очищается', async () => { // Первый проект: 3 ошибки → error.value устанавливается vi.mocked(api.chatHistory) .mockResolvedValueOnce(MOCK_MESSAGES_WITH_RUNNING_TASK as any) .mockRejectedValueOnce(new Error('err 1')) .mockRejectedValueOnce(new Error('err 2')) .mockRejectedValueOnce(new Error('err 3')) vi.spyOn(console, 'warn').mockImplementation(() => {}) const wrapper = await mountChatView('KIN') await flushPromises() await vi.advanceTimersByTimeAsync(3000) await flushPromises() await vi.advanceTimersByTimeAsync(3000) await flushPromises() await vi.advanceTimersByTimeAsync(3000) await flushPromises() expect(wrapper.text()).toContain('Сервер недоступен') // Переключаемся на другой проект — вызывает load(), который сбрасывает error.value vi.mocked(api.chatHistory).mockResolvedValue(MOCK_MESSAGES_IDLE as any) await wrapper.setProps({ projectId: 'KIN2' }) await flushPromises() expect(wrapper.text()).not.toContain('Сервер недоступен') wrapper.unmount() }) it('после 2 ошибок и 1 успеха — следующие 3 ошибки снова вызывают error.value', async () => { vi.mocked(api.chatHistory) // load() .mockResolvedValueOnce(MOCK_MESSAGES_WITH_RUNNING_TASK as any) // 2 ошибки .mockRejectedValueOnce(new Error('err')) .mockRejectedValueOnce(new Error('err')) // успех — сброс счётчика .mockResolvedValueOnce(MOCK_MESSAGES_WITH_RUNNING_TASK as any) // ещё 3 ошибки — снова должна появиться ошибка .mockRejectedValueOnce(new Error('err')) .mockRejectedValueOnce(new Error('err')) .mockRejectedValue(new Error('err')) vi.spyOn(console, 'warn').mockImplementation(() => {}) const wrapper = await mountChatView() await flushPromises() // 2 ошибки (счётчик = 2, ошибки нет) await vi.advanceTimersByTimeAsync(3000) await flushPromises() await vi.advanceTimersByTimeAsync(3000) await flushPromises() expect(wrapper.text()).not.toContain('Сервер недоступен') // Успех (счётчик = 0) await vi.advanceTimersByTimeAsync(3000) await flushPromises() // Ещё 3 ошибки — счётчик начинается с 0, достигает 3 await vi.advanceTimersByTimeAsync(3000) await flushPromises() await vi.advanceTimersByTimeAsync(3000) await flushPromises() await vi.advanceTimersByTimeAsync(3000) await flushPromises() expect(wrapper.text()).toContain('Сервер недоступен') wrapper.unmount() }) }) describe('остановка polling после 3 ошибок', () => { it('polling останавливается после 3 ошибок — дополнительные тики не вызывают api', async () => { vi.mocked(api.chatHistory) .mockResolvedValueOnce(MOCK_MESSAGES_WITH_RUNNING_TASK as any) .mockRejectedValue(new Error('Server down')) vi.spyOn(console, 'warn').mockImplementation(() => {}) const wrapper = await mountChatView() await flushPromises() // 3 тика — достигаем лимита await vi.advanceTimersByTimeAsync(3000) await flushPromises() await vi.advanceTimersByTimeAsync(3000) await flushPromises() await vi.advanceTimersByTimeAsync(3000) await flushPromises() const callCountAfterStop = vi.mocked(api.chatHistory).mock.calls.length // Ещё 3 тика — polling должен быть остановлен, новых вызовов нет await vi.advanceTimersByTimeAsync(9000) await flushPromises() expect(vi.mocked(api.chatHistory).mock.calls.length).toBe(callCountAfterStop) wrapper.unmount() }) }) })