kin: KIN-ARCH-020 Передавать blocked_reason от dept head в DB при блокировке sub-pipeline
This commit is contained in:
parent
90d7abfa80
commit
136916793e
2 changed files with 308 additions and 11 deletions
|
|
@ -140,17 +140,10 @@ class TestBlockedReasonPropagation:
|
||||||
task = models.get_task(conn, "PROJ-001")
|
task = models.get_task(conn, "PROJ-001")
|
||||||
assert task["status"] == "blocked"
|
assert task["status"] == "blocked"
|
||||||
|
|
||||||
# BUG DOCUMENTED: The specific worker error is NOT in task.blocked_reason.
|
# FIXED (KIN-ARCH-014): specific worker error IS now in task.blocked_reason
|
||||||
# Current behavior: task.blocked_reason == 'Department backend_head sub-pipeline failed'
|
assert specific_worker_error in (task["blocked_reason"] or ""), (
|
||||||
# Desired behavior: specific_worker_error should appear in task.blocked_reason
|
f"Expected specific worker error in task.blocked_reason, "
|
||||||
assert specific_worker_error not in (task["blocked_reason"] or ""), (
|
f"got: {task['blocked_reason']!r}"
|
||||||
f"Unexpected: specific worker error was propagated to task.blocked_reason. "
|
|
||||||
f"This means Issue 1 has been fixed — update this test!"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Verify: generic message IS what gets stored (documents current behavior)
|
|
||||||
assert "backend_head" in (task["blocked_reason"] or ""), (
|
|
||||||
"Expected task.blocked_reason to contain 'backend_head' (the generic message)"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
@patch("agents.runner._run_autocommit")
|
@patch("agents.runner._run_autocommit")
|
||||||
|
|
|
||||||
304
web/frontend/src/__tests__/upload-warning.test.ts
Normal file
304
web/frontend/src/__tests__/upload-warning.test.ts
Normal file
|
|
@ -0,0 +1,304 @@
|
||||||
|
/**
|
||||||
|
* 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()
|
||||||
|
|
||||||
|
// Открываем модал добавления задачи
|
||||||
|
const tasBtn = wrapper.findAll('button').find(b => b.text() === '+ Тас')!
|
||||||
|
await tasBtn.trigger('click')
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
return wrapper
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Добавляет файлы в pendingFiles через внутреннее состояние <script setup> */
|
||||||
|
function pushPendingFiles(wrapper: ReturnType<typeof mount>, files: File[]) {
|
||||||
|
const setupState = (wrapper.vm as any).$.setupState
|
||||||
|
setupState.pendingFiles.value.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('Не удалось загрузить')
|
||||||
|
})
|
||||||
|
})
|
||||||
Loading…
Add table
Add a link
Reference in a new issue