From 300b44a3a417e9e2c50b44ba7c863c42a82259d0 Mon Sep 17 00:00:00 2001 From: Gros Frumos Date: Mon, 16 Mar 2026 19:44:10 +0200 Subject: [PATCH] =?UTF-8?q?kin:=20KIN-UI-008=20=D0=9B=D0=BE=D0=B3=D0=B8?= =?UTF-8?q?=D1=80=D0=BE=D0=B2=D0=B0=D1=82=D1=8C=20=D0=BE=D1=88=D0=B8=D0=B1?= =?UTF-8?q?=D0=BA=D0=B8=20=D0=B2=20polling-=D1=86=D0=B8=D0=BA=D0=BB=D0=B5?= =?UTF-8?q?=20ChatView?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../chat-view-polling-errors.test.ts | 310 ++++++++++++++++++ 1 file changed, 310 insertions(+) create mode 100644 web/frontend/src/__tests__/chat-view-polling-errors.test.ts diff --git a/web/frontend/src/__tests__/chat-view-polling-errors.test.ts b/web/frontend/src/__tests__/chat-view-polling-errors.test.ts new file mode 100644 index 0000000..e98b942 --- /dev/null +++ b/web/frontend/src/__tests__/chat-view-polling-errors.test.ts @@ -0,0 +1,310 @@ +/** + * 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() + }) + }) +})