kin: auto-commit after pipeline

This commit is contained in:
Gros Frumos 2026-03-18 17:43:54 +02:00
parent 1487e84eb6
commit 8623323161
2 changed files with 391 additions and 0 deletions

View file

@ -0,0 +1,108 @@
"""Regression tests for KIN-126 — фильтр по дате выполнения задач.
Проверяет backend-поведение:
1. Колонка completed_at добавляется в таблицу tasks при инициализации БД
2. Новая задача имеет completed_at=None по умолчанию
3. update_task(status='done') устанавливает completed_at как ISO-строку
4. update_task(status='done') completed_at является валидной ISO-строкой
5. update_task(status='in_progress') не устанавливает completed_at
6. update_task(status='cancelled') не устанавливает completed_at
7. Обновление полей без смены статуса на done не трогает completed_at
8. update_task(status='blocked') не устанавливает completed_at
"""
import pytest
from datetime import datetime
from core.db import init_db
from core import models
@pytest.fixture
def conn():
"""Fresh in-memory DB for each test."""
c = init_db(db_path=":memory:")
yield c
c.close()
# ---------------------------------------------------------------------------
# Schema
# ---------------------------------------------------------------------------
def test_schema_tasks_has_completed_at_column(conn):
"""KIN-126: таблица tasks содержит колонку completed_at после инициализации БД."""
cols = {r[1] for r in conn.execute("PRAGMA table_info(tasks)").fetchall()}
assert "completed_at" in cols, "KIN-126: колонка completed_at должна быть в таблице tasks"
# ---------------------------------------------------------------------------
# Default value
# ---------------------------------------------------------------------------
def test_new_task_completed_at_is_null(conn):
"""KIN-126: новая задача имеет completed_at=None по умолчанию."""
models.create_project(conn, "p1", "P1", "/p1")
t = models.create_task(conn, "P1-001", "p1", "Task")
assert t["completed_at"] is None
# ---------------------------------------------------------------------------
# Setting completed_at on status='done'
# ---------------------------------------------------------------------------
def test_update_task_to_done_sets_completed_at(conn):
"""KIN-126: update_task(status='done') устанавливает completed_at."""
models.create_project(conn, "p1", "P1", "/p1")
models.create_task(conn, "P1-001", "p1", "Task")
updated = models.update_task(conn, "P1-001", status="done")
assert updated["completed_at"] is not None
def test_update_task_to_done_completed_at_is_valid_iso(conn):
"""KIN-126: completed_at при status='done' — валидная ISO-строка datetime."""
models.create_project(conn, "p1", "P1", "/p1")
models.create_task(conn, "P1-001", "p1", "Task")
updated = models.update_task(conn, "P1-001", status="done")
# Must parse without exception
parsed = datetime.fromisoformat(updated["completed_at"])
assert parsed is not None
# ---------------------------------------------------------------------------
# Non-done statuses do NOT set completed_at
# ---------------------------------------------------------------------------
def test_update_task_to_in_progress_does_not_set_completed_at(conn):
"""KIN-126: update_task(status='in_progress') не устанавливает completed_at."""
models.create_project(conn, "p1", "P1", "/p1")
models.create_task(conn, "P1-001", "p1", "Task")
updated = models.update_task(conn, "P1-001", status="in_progress")
assert updated["completed_at"] is None
def test_update_task_to_cancelled_does_not_set_completed_at(conn):
"""KIN-126: update_task(status='cancelled') не устанавливает completed_at."""
models.create_project(conn, "p1", "P1", "/p1")
models.create_task(conn, "P1-001", "p1", "Task")
updated = models.update_task(conn, "P1-001", status="cancelled")
assert updated["completed_at"] is None
def test_update_task_to_blocked_does_not_set_completed_at(conn):
"""KIN-126: update_task(status='blocked') не устанавливает completed_at."""
models.create_project(conn, "p1", "P1", "/p1")
models.create_task(conn, "P1-001", "p1", "Task")
updated = models.update_task(conn, "P1-001", status="blocked")
assert updated["completed_at"] is None
# ---------------------------------------------------------------------------
# Non-status updates do NOT touch completed_at
# ---------------------------------------------------------------------------
def test_update_task_priority_only_does_not_set_completed_at(conn):
"""KIN-126: update_task с изменением только priority не трогает completed_at."""
models.create_project(conn, "p1", "P1", "/p1")
models.create_task(conn, "P1-001", "p1", "Task")
updated = models.update_task(conn, "P1-001", priority=1)
assert updated["completed_at"] is None

View file

@ -0,0 +1,283 @@
/**
* KIN-126: Тесты фильтра по дате выполнения задач (done tasks date range filter)
*
* AC: фильтр сделан и работает
*
* Проверяет:
* 1. Date filter inputs отображаются только когда выбран статус 'done'
* 2. Date filter inputs скрыты на других статусах
* 3. Date filter inputs скрыты без фильтра статуса
* 4. Фильтрация по dateFrom задачи до этой даты исключаются
* 5. Фильтрация по dateTo задачи после этой даты исключаются
* 6. Пустой результат при несовпадении дат
* 7. Сброс фильтра (кнопка ) показывает все done-задачи снова
* 8. Fallback на updated_at когда completed_at=null
*/
import { describe, it, expect, vi, beforeEach } 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(),
},
}))
import { api } from '../api'
const Stub = { template: '<div />' }
function makeTask(
id: string,
status: string,
completedAt: string | null,
updatedAt = '2024-01-10T00:00:00',
) {
return {
id,
project_id: 'KIN',
title: `Task ${id}`,
status,
priority: 5,
assigned_role: null,
parent_task_id: null,
brief: null,
spec: null,
created_at: '2024-01-01T00:00:00',
updated_at: updatedAt,
completed_at: completedAt,
}
}
const MOCK_PROJECT_DATE_FILTER = {
id: 'KIN',
name: 'Kin',
path: '/projects/kin',
status: 'active',
priority: 5,
tech_stack: ['python', 'vue'],
created_at: '2024-01-01',
total_tasks: 4,
done_tasks: 3,
active_tasks: 1,
blocked_tasks: 0,
review_tasks: 0,
tasks: [
makeTask('KIN-001', 'done', '2024-01-05T12:00:00'), // before range
makeTask('KIN-002', 'done', '2024-01-15T12:00:00'), // in middle
makeTask('KIN-003', 'done', '2024-01-25T12:00:00'), // after range
makeTask('KIN-004', 'pending', null),
],
decisions: [],
modules: [],
}
function makeRouter() {
return createRouter({
history: createMemoryHistory(),
routes: [
{ path: '/', component: Stub },
{ path: '/project/:id', component: ProjectView, props: true },
],
})
}
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 })
beforeEach(() => {
localStorageMock.clear()
vi.mocked(api.project).mockResolvedValue(MOCK_PROJECT_DATE_FILTER as any)
})
describe('KIN-126: фильтр по дате выполнения задач', () => {
it('1. Date filter inputs отображаются когда выбран статус done', async () => {
const router = makeRouter()
await router.push('/project/KIN?status=done')
const wrapper = mount(ProjectView, {
props: { id: 'KIN' },
global: { plugins: [router] },
})
await flushPromises()
expect(
wrapper.find('[data-testid="date-from"]').exists(),
'date-from должен быть виден при status=done',
).toBe(true)
expect(
wrapper.find('[data-testid="date-to"]').exists(),
'date-to должен быть виден при status=done',
).toBe(true)
})
it('2. Date filter inputs скрыты когда done не выбран', async () => {
const router = makeRouter()
await router.push('/project/KIN?status=pending')
const wrapper = mount(ProjectView, {
props: { id: 'KIN' },
global: { plugins: [router] },
})
await flushPromises()
expect(
wrapper.find('[data-testid="date-from"]').exists(),
'date-from не должен быть виден при status=pending',
).toBe(false)
expect(
wrapper.find('[data-testid="date-to"]').exists(),
'date-to не должен быть виден при status=pending',
).toBe(false)
})
it('3. Date filter inputs скрыты без фильтра статуса', async () => {
const router = makeRouter()
await router.push('/project/KIN')
const wrapper = mount(ProjectView, {
props: { id: 'KIN' },
global: { plugins: [router] },
})
await flushPromises()
expect(
wrapper.find('[data-testid="date-from"]').exists(),
'date-from не должен быть виден без фильтра статуса',
).toBe(false)
})
it('4. Фильтрация по dateFrom исключает задачи выполненные до этой даты', async () => {
const router = makeRouter()
await router.push('/project/KIN?status=done')
const wrapper = mount(ProjectView, {
props: { id: 'KIN' },
global: { plugins: [router] },
})
await flushPromises()
await wrapper.find('[data-testid="date-from"]').setValue('2024-01-10')
await flushPromises()
const links = wrapper.findAll('a[href^="/task/"]')
const ids = links.map(l => l.attributes('href')?.replace('/task/', '').split('?')[0])
expect(ids, 'KIN-001 (2024-01-05) должен быть исключён').not.toContain('KIN-001')
expect(ids, 'KIN-002 (2024-01-15) должен быть виден').toContain('KIN-002')
expect(ids, 'KIN-003 (2024-01-25) должен быть виден').toContain('KIN-003')
})
it('5. Фильтрация по dateTo исключает задачи выполненные после этой даты', async () => {
const router = makeRouter()
await router.push('/project/KIN?status=done')
const wrapper = mount(ProjectView, {
props: { id: 'KIN' },
global: { plugins: [router] },
})
await flushPromises()
await wrapper.find('[data-testid="date-to"]').setValue('2024-01-20')
await flushPromises()
const links = wrapper.findAll('a[href^="/task/"]')
const ids = links.map(l => l.attributes('href')?.replace('/task/', '').split('?')[0])
expect(ids, 'KIN-003 (2024-01-25) должен быть исключён').not.toContain('KIN-003')
expect(ids, 'KIN-001 (2024-01-05) должен быть виден').toContain('KIN-001')
expect(ids, 'KIN-002 (2024-01-15) должен быть виден').toContain('KIN-002')
})
it('6. Пустой результат когда ни одна задача не попадает в диапазон дат', async () => {
const router = makeRouter()
await router.push('/project/KIN?status=done')
const wrapper = mount(ProjectView, {
props: { id: 'KIN' },
global: { plugins: [router] },
})
await flushPromises()
await wrapper.find('[data-testid="date-from"]').setValue('2024-02-01')
await wrapper.find('[data-testid="date-to"]').setValue('2024-02-28')
await flushPromises()
const links = wrapper.findAll('a[href^="/task/"]')
expect(links, 'Ни одна done-задача не должна отображаться при несовпадении дат').toHaveLength(0)
})
it('7. Клик по кнопке сброса очищает фильтр и показывает все done-задачи', async () => {
const router = makeRouter()
await router.push('/project/KIN?status=done')
const wrapper = mount(ProjectView, {
props: { id: 'KIN' },
global: { plugins: [router] },
})
await flushPromises()
// Устанавливаем dateFrom — остаётся только одна done-задача
await wrapper.find('[data-testid="date-from"]').setValue('2024-01-20')
await flushPromises()
expect(wrapper.findAll('a[href^="/task/"]')).toHaveLength(1)
// Кнопка сброса — ✕ без data-action (не clear-status)
const resetBtn = wrapper.findAll('button').find(
b => b.text() === '✕' && !b.attributes('data-action'),
)
expect(resetBtn?.exists(), 'Кнопка сброса дат должна появиться').toBe(true)
await resetBtn!.trigger('click')
await flushPromises()
// После сброса — все 3 done-задачи видны
expect(wrapper.findAll('a[href^="/task/"]')).toHaveLength(3)
})
it('8. Fallback на updated_at когда completed_at=null', async () => {
const projectWithNullCompletedAt = {
...MOCK_PROJECT_DATE_FILTER,
tasks: [
makeTask('KIN-010', 'done', null, '2024-01-05T00:00:00'), // updated_at early
makeTask('KIN-011', 'done', null, '2024-01-25T00:00:00'), // updated_at late
],
}
vi.mocked(api.project).mockResolvedValue(projectWithNullCompletedAt as any)
const router = makeRouter()
await router.push('/project/KIN?status=done')
const wrapper = mount(ProjectView, {
props: { id: 'KIN' },
global: { plugins: [router] },
})
await flushPromises()
// dateFrom='2024-01-20' → KIN-010 (updated_at 2024-01-05) должен быть исключён
await wrapper.find('[data-testid="date-from"]').setValue('2024-01-20')
await flushPromises()
const links = wrapper.findAll('a[href^="/task/"]')
const ids = links.map(l => l.attributes('href')?.replace('/task/', '').split('?')[0])
expect(ids, 'KIN-010 должен быть исключён (updated_at до dateFrom)').not.toContain('KIN-010')
expect(ids, 'KIN-011 должен быть виден (updated_at после dateFrom)').toContain('KIN-011')
})
})