kin: KIN-UI-008 Логировать ошибки в polling-цикле ChatView

This commit is contained in:
Gros Frumos 2026-03-16 19:44:10 +02:00
parent bd9fbfbbcb
commit 300b44a3a4

View file

@ -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<typeof import('../api')>()
return {
...actual,
api: {
chatHistory: vi.fn(),
project: vi.fn(),
sendChatMessage: vi.fn(),
},
}
})
import { api } from '../api'
const Stub = { template: '<div />' }
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()
})
})
})