kin/web/frontend/src/__tests__/upload-warning.test.ts
2026-03-17 16:06:39 +02:00

306 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* KIN-UI-009: Предупреждение при частичной загрузке вложений
*
* Проверяет:
* 1. Если все файлы загружены успешно — баннер uploadWarning не показывается
* 2. Если один файл упал — показывается предупреждение с его именем
* 3. Если несколько файлов упали — все имена перечислены в предупреждении
* 4. Если часть файлов прошла успешно — в предупреждении только упавшие
* 5. Кнопка ✕ скрывает баннер
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
import { createRouter, createMemoryHistory } from 'vue-router'
import ProjectView from '../views/ProjectView.vue'
vi.mock('../api', () => ({
api: {
project: vi.fn(),
taskFull: vi.fn(),
runTask: vi.fn(),
auditProject: vi.fn(),
createTask: vi.fn(),
patchTask: vi.fn(),
patchProject: vi.fn(),
deployProject: vi.fn(),
getPhases: vi.fn(),
uploadAttachment: vi.fn(),
environments: vi.fn(),
},
}))
import { api } from '../api'
const Stub = { template: '<div />' }
function makeTask(id: string, status: string = 'pending') {
return {
id,
project_id: 'KIN',
title: `Task ${id}`,
status,
priority: 5,
assigned_role: null,
parent_task_id: null,
brief: null,
spec: null,
execution_mode: null,
blocked_reason: null,
dangerously_skipped: null,
category: null,
acceptance_criteria: null,
created_at: '2024-01-01',
updated_at: '2024-01-01',
}
}
const MOCK_PROJECT = {
id: 'KIN',
name: 'Kin',
path: '/projects/kin',
status: 'active',
priority: 5,
tech_stack: ['python', 'vue'],
execution_mode: 'review',
autocommit_enabled: 0,
obsidian_vault_path: null,
deploy_command: null,
created_at: '2024-01-01',
total_tasks: 0,
done_tasks: 0,
active_tasks: 0,
blocked_tasks: 0,
review_tasks: 0,
project_type: 'development',
ssh_host: null,
ssh_user: null,
ssh_key_path: null,
ssh_proxy_jump: null,
description: null,
tasks: [],
decisions: [],
modules: [],
}
const localStorageMock = (() => {
let store: Record<string, string> = {}
return {
getItem: (k: string) => store[k] ?? null,
setItem: (k: string, v: string) => { store[k] = v },
removeItem: (k: string) => { delete store[k] },
clear: () => { store = {} },
}
})()
Object.defineProperty(globalThis, 'localStorage', { value: localStorageMock, configurable: true })
function makeRouter() {
return createRouter({
history: createMemoryHistory(),
routes: [
{ path: '/', component: Stub },
{ path: '/project/:id', component: ProjectView, props: true },
],
})
}
async function mountAndOpenAddTaskModal() {
const router = makeRouter()
await router.push('/project/KIN')
const wrapper = mount(ProjectView, {
props: { id: 'KIN' },
global: { plugins: [router] },
})
await flushPromises()
// Открываем модал добавления задачи (на Tasks-вкладке кнопка называется "+ Task")
const tasBtn = wrapper.findAll('button').find(b => b.text() === '+ Task')!
await tasBtn.trigger('click')
await flushPromises()
return wrapper
}
/** Добавляет файлы в pendingFiles через внутреннее состояние <script setup>.
* В Vue 3 setupState автоматически разворачивает refs, поэтому доступ без .value */
function pushPendingFiles(wrapper: ReturnType<typeof mount>, files: File[]) {
const setupState = (wrapper.vm as any).$.setupState
// setupState unwraps refs — pendingFiles это уже массив, не ref
setupState.pendingFiles.push(...files)
}
beforeEach(() => {
localStorageMock.clear()
vi.clearAllMocks()
vi.mocked(api.project).mockResolvedValue(JSON.parse(JSON.stringify(MOCK_PROJECT)) as any)
vi.mocked(api.getPhases).mockResolvedValue([])
vi.mocked(api.environments).mockResolvedValue([])
})
afterEach(() => {
vi.restoreAllMocks()
})
// ─────────────────────────────────────────────────────────────
// Критерий 1: все файлы успешно — предупреждение не показывается
// ─────────────────────────────────────────────────────────────
describe('KIN-UI-009: все файлы загружены успешно — warning не показывается', () => {
it('При успешной загрузке всех вложений баннер uploadWarning отсутствует в DOM', async () => {
vi.mocked(api.createTask).mockResolvedValue(makeTask('KIN-NEW-1') as any)
vi.mocked(api.uploadAttachment).mockResolvedValue(undefined as any)
const wrapper = await mountAndOpenAddTaskModal()
await wrapper.find('input[placeholder="Task title"]').setValue('Test task')
pushPendingFiles(wrapper, [
new File(['a'], 'success1.pdf', { type: 'application/pdf' }),
new File(['b'], 'success2.png', { type: 'image/png' }),
])
await wrapper.find('form').trigger('submit')
await flushPromises()
expect(
wrapper.text(),
'Предупреждение не должно содержать "Не удалось загрузить" при успешной загрузке',
).not.toContain('Не удалось загрузить')
})
it('При отсутствии вложений uploadWarning остаётся пустым', async () => {
vi.mocked(api.createTask).mockResolvedValue(makeTask('KIN-NEW-2') as any)
const wrapper = await mountAndOpenAddTaskModal()
await wrapper.find('input[placeholder="Task title"]').setValue('Task without files')
// pendingFiles пуст — загрузки не происходит
await wrapper.find('form').trigger('submit')
await flushPromises()
expect(wrapper.text()).not.toContain('Не удалось загрузить')
expect(vi.mocked(api.uploadAttachment)).not.toHaveBeenCalled()
})
})
// ─────────────────────────────────────────────────────────────
// Критерий 2: один файл упал — показывается предупреждение
// ─────────────────────────────────────────────────────────────
describe('KIN-UI-009: один файл упал — показывается предупреждение с его именем', () => {
it('Баннер появляется если uploadAttachment выбросил ошибку', async () => {
vi.mocked(api.createTask).mockResolvedValue(makeTask('KIN-NEW-1') as any)
vi.mocked(api.uploadAttachment).mockRejectedValue(new Error('network error'))
const wrapper = await mountAndOpenAddTaskModal()
await wrapper.find('input[placeholder="Task title"]').setValue('Task with failing file')
pushPendingFiles(wrapper, [new File(['x'], 'broken-report.pdf', { type: 'application/pdf' })])
await wrapper.find('form').trigger('submit')
await flushPromises()
expect(
wrapper.text(),
'Баннер должен содержать текст предупреждения',
).toContain('Не удалось загрузить')
})
it('Имя упавшего файла присутствует в тексте предупреждения', async () => {
vi.mocked(api.createTask).mockResolvedValue(makeTask('KIN-NEW-1') as any)
vi.mocked(api.uploadAttachment).mockRejectedValue(new Error('upload failed'))
const wrapper = await mountAndOpenAddTaskModal()
await wrapper.find('input[placeholder="Task title"]').setValue('Task')
pushPendingFiles(wrapper, [new File(['x'], 'important-doc.xlsx', { type: 'application/vnd.ms-excel' })])
await wrapper.find('form').trigger('submit')
await flushPromises()
expect(
wrapper.text(),
'Имя файла должно быть упомянуто в предупреждении',
).toContain('important-doc.xlsx')
})
})
// ─────────────────────────────────────────────────────────────
// Критерий 3: несколько файлов упали — все имена перечислены
// ─────────────────────────────────────────────────────────────
describe('KIN-UI-009: несколько файлов упали — все имена в предупреждении', () => {
it('Все имена упавших файлов перечислены в тексте предупреждения', async () => {
vi.mocked(api.createTask).mockResolvedValue(makeTask('KIN-NEW-1') as any)
vi.mocked(api.uploadAttachment).mockRejectedValue(new Error('upload failed'))
const wrapper = await mountAndOpenAddTaskModal()
await wrapper.find('input[placeholder="Task title"]').setValue('Task')
pushPendingFiles(wrapper, [
new File(['a'], 'alpha.pdf', { type: 'application/pdf' }),
new File(['b'], 'beta.csv', { type: 'text/csv' }),
new File(['c'], 'gamma.txt', { type: 'text/plain' }),
])
await wrapper.find('form').trigger('submit')
await flushPromises()
const text = wrapper.text()
expect(text, 'Первый файл должен быть упомянут').toContain('alpha.pdf')
expect(text, 'Второй файл должен быть упомянут').toContain('beta.csv')
expect(text, 'Третий файл должен быть упомянут').toContain('gamma.txt')
})
it('Если часть файлов прошла успешно — в предупреждении только упавшие', async () => {
vi.mocked(api.createTask).mockResolvedValue(makeTask('KIN-NEW-1') as any)
// Первый успешен, второй упал
vi.mocked(api.uploadAttachment)
.mockResolvedValueOnce(undefined as any)
.mockRejectedValueOnce(new Error('upload failed'))
const wrapper = await mountAndOpenAddTaskModal()
await wrapper.find('input[placeholder="Task title"]').setValue('Task')
pushPendingFiles(wrapper, [
new File(['ok'], 'good-file.pdf', { type: 'application/pdf' }),
new File(['bad'], 'bad-file.png', { type: 'image/png' }),
])
await wrapper.find('form').trigger('submit')
await flushPromises()
const text = wrapper.text()
expect(text, 'Упавший файл должен быть в предупреждении').toContain('bad-file.png')
expect(text, 'Успешный файл не должен быть в предупреждении').not.toContain('good-file.pdf')
})
})
// ─────────────────────────────────────────────────────────────
// Взаимодействие с баннером
// ─────────────────────────────────────────────────────────────
describe('KIN-UI-009: кнопка ✕ скрывает баннер предупреждения', () => {
it('После нажатия кнопки закрытия баннер исчезает из DOM', async () => {
vi.mocked(api.createTask).mockResolvedValue(makeTask('KIN-NEW-1') as any)
vi.mocked(api.uploadAttachment).mockRejectedValue(new Error('upload failed'))
const wrapper = await mountAndOpenAddTaskModal()
await wrapper.find('input[placeholder="Task title"]').setValue('Task')
pushPendingFiles(wrapper, [new File(['x'], 'fail.pdf', { type: 'application/pdf' })])
await wrapper.find('form').trigger('submit')
await flushPromises()
// Убеждаемся что баннер виден
expect(wrapper.text(), 'Баннер должен быть виден перед закрытием').toContain('Не удалось загрузить')
// Находим баннер по цвету (желтый) и кнопку ✕ внутри него
const yellowBanner = wrapper.find('.border-yellow-700')
expect(yellowBanner.exists(), 'Жёлтый баннер должен существовать').toBe(true)
const closeBtn = yellowBanner.find('button')
await closeBtn.trigger('click')
await flushPromises()
expect(
wrapper.text(),
'После нажатия ✕ предупреждение должно исчезнуть',
).not.toContain('Не удалось загрузить')
})
})