diff --git a/tests/test_kin_126_regression.py b/tests/test_kin_126_regression.py new file mode 100644 index 0000000..5c1a56d --- /dev/null +++ b/tests/test_kin_126_regression.py @@ -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 diff --git a/web/frontend/src/__tests__/date-filter.test.ts b/web/frontend/src/__tests__/date-filter.test.ts new file mode 100644 index 0000000..869308a --- /dev/null +++ b/web/frontend/src/__tests__/date-filter.test.ts @@ -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: '
' } + +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 = {} + 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') + }) +})