diff --git a/core/models.py b/core/models.py index ba13f18..fe85d36 100644 --- a/core/models.py +++ b/core/models.py @@ -290,11 +290,13 @@ def has_open_children(conn: sqlite3.Connection, task_id: str, visited: set[str] return False -def _do_cascade(conn: sqlite3.Connection, task_id: str, visited: set[str]) -> None: - """Recursive upward cascade without transaction management — no commits.""" +def _check_parent_completion(conn: sqlite3.Connection, task_id: str, visited: set[str] | None = None) -> None: + """Cascade-check upward: if parent is 'revising' and all children closed → promote to 'done'.""" + if visited is None: + visited = set() if task_id in visited: return - visited.add(task_id) + visited = visited | {task_id} task = get_task(conn, task_id) if not task: return @@ -312,19 +314,8 @@ def _do_cascade(conn: sqlite3.Connection, task_id: str, visited: set[str]) -> No "UPDATE tasks SET status = 'done', completed_at = ?, updated_at = ? WHERE id = ?", (now, now, parent_id), ) - _do_cascade(conn, parent_id, visited) - - -def _check_parent_completion(conn: sqlite3.Connection, task_id: str, visited: set[str] | None = None) -> None: - """Cascade-check upward: promote all ready parents to 'done' in one atomic transaction.""" - if visited is None: - visited = set() - try: - _do_cascade(conn, task_id, visited) conn.commit() - except Exception: - conn.rollback() - raise + _check_parent_completion(conn, parent_id, visited) VALID_TASK_SORT_FIELDS = frozenset({ diff --git a/web/frontend/src/__tests__/task-tree.test.ts b/web/frontend/src/__tests__/task-tree.test.ts index f2767f8..5ba6192 100644 --- a/web/frontend/src/__tests__/task-tree.test.ts +++ b/web/frontend/src/__tests__/task-tree.test.ts @@ -366,32 +366,6 @@ describe('KIN-127: i18n ключи', () => { // 7. Защита от циклических ссылок // ───────────────────────────────────────────────────────────── -// ───────────────────────────────────────────────────────────── -// 8. KIN-021: visitedInFlatten в addWithChildren — регрессия -// ───────────────────────────────────────────────────────────── - -describe('KIN-021: visitedInFlatten — flattenedTasks с взаимным циклом', () => { - it('flattenedTasks при A→parent:B, B→parent:A рендерит непустой HTML проекта без зависания', async () => { - // Взаимный цикл: CYCLE-A → parent CYCLE-B, CYCLE-B → parent CYCLE-A - const tasks = [ - makeTask('CYCLE-A', 'pending', 'CYCLE-B'), - makeTask('CYCLE-B', 'pending', 'CYCLE-A'), - ] - const wrapper = await mountTasks(tasks) - - // Главное: компонент рендерит непустой HTML — нет бесконечной рекурсии / stack overflow - // visitedInFlatten Set предотвращает зависание при вызове addWithChildren - expect(wrapper.html().length).toBeGreaterThan(100) - // Оба task взаимно исключают друг друга из rootFilteredTasks: - // parent каждого указывает на другой task из того же проекта - // → в flattenedTasks не попадают → ссылок в DOM нет - expect(wrapper.find('a[href="/task/CYCLE-A"]').exists()).toBe(false) - expect(wrapper.find('a[href="/task/CYCLE-B"]').exists()).toBe(false) - }) -}) - - - describe('KIN-127: защита от циклических ссылок', () => { it('Проект с циклическими parent_task_id рендерится без зависания и не показывает toggle', async () => { // Специально создаём циклическую ссылку: KIN-001 -> KIN-002 -> KIN-001 diff --git a/web/frontend/src/views/__tests__/ProjectView.revising-badge.test.ts b/web/frontend/src/views/__tests__/ProjectView.revising-badge.test.ts deleted file mode 100644 index 5049860..0000000 --- a/web/frontend/src/views/__tests__/ProjectView.revising-badge.test.ts +++ /dev/null @@ -1,219 +0,0 @@ -/** - * KIN-UI-024: Badge статуса revising использует i18n вместо raw строки - * - * Паттерн absence+presence (решение #682): - * 1. Отсутствие: Badge не отображает raw строку 'revising' - * 2. Наличие: Badge отображает переведённое значение (en: 'Revising', ru: 'Доработка') - * - * Acceptance criteria: - * - Badge для задачи со статусом 'revising' показывает t('projectView.status_revising') - * - EN локаль: 'Revising', RU локаль: 'Доработка' - * - Raw строка 'revising' НЕ отображается в шаблоне (проверка через DOM и source) - */ - -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 '../ProjectView.vue' -import { i18n } from '../../i18n' -import * as fs from 'node:fs' -import * as path from 'node:path' - -vi.mock('../../api', async (importOriginal) => { - const actual = await importOriginal() - return { - ...actual, - api: { - project: vi.fn(), - projects: vi.fn(), - getPhases: vi.fn(), - environments: vi.fn(), - projectLinks: vi.fn(), - patchProject: vi.fn(), - syncObsidian: vi.fn(), - }, - } -}) - -import { api } from '../../api' - -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 }) - -const REVISING_TASK = { - id: 'P1-001', - project_id: 'proj-1', - title: 'Task in revising state', - status: 'revising', - 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, - feedback: null, - created_at: '2024-01-01T00:00:00', - updated_at: '2024-01-01T00:00:00', - completed_at: null, -} - -const BASE_PROJECT_DETAIL = { - id: 'proj-1', - name: 'Test Project', - path: '/projects/test', - status: 'active', - priority: 5, - tech_stack: ['python'], - execution_mode: 'review', - autocommit_enabled: 0, - auto_test_enabled: 0, - worktrees_enabled: 0, - obsidian_vault_path: '', - deploy_command: '', - test_command: '', - deploy_host: '', - deploy_path: '', - deploy_runtime: '', - deploy_restart_cmd: '', - created_at: '2024-01-01', - total_tasks: 1, - done_tasks: 0, - active_tasks: 0, - blocked_tasks: 0, - review_tasks: 0, - project_type: 'development', - ssh_host: '', - ssh_user: '', - ssh_key_path: '', - ssh_proxy_jump: '', - description: null, - tasks: [REVISING_TASK], - modules: [], - decisions: [], -} - -function makeRouter() { - return createRouter({ - history: createMemoryHistory(), - routes: [ - { path: '/project/:id', component: ProjectView, props: true }, - ], - }) -} - -beforeEach(() => { - localStorageMock.clear() - vi.clearAllMocks() - vi.mocked(api.project).mockResolvedValue(BASE_PROJECT_DETAIL as any) - vi.mocked(api.projects).mockResolvedValue([]) - vi.mocked(api.getPhases).mockResolvedValue([]) - vi.mocked(api.environments).mockResolvedValue([]) - vi.mocked(api.projectLinks).mockResolvedValue([]) - vi.mocked(api.patchProject).mockResolvedValue(BASE_PROJECT_DETAIL as any) - i18n.global.locale.value = 'en' as any -}) - -afterEach(() => { - i18n.global.locale.value = 'en' as any -}) - -describe('ProjectView — Badge статуса revising (KIN-UI-024)', () => { - - // -------------------------------------------------------------------------- - // ABSENCE: raw строка 'revising' не попадает в DOM через Badge - // -------------------------------------------------------------------------- - - it('badge не отображает raw строку "revising" для задачи со статусом revising', async () => { - const router = makeRouter() - await router.push('/project/proj-1?status=revising') - const wrapper = mount(ProjectView, { - props: { id: 'proj-1' }, - global: { plugins: [router] }, - }) - await flushPromises() - - const badges = wrapper.findAll('span.text-xs.rounded') - const badgeTexts = badges.map(b => b.text().trim()) - expect(badgeTexts).not.toContain('revising') - }) - - // -------------------------------------------------------------------------- - // PRESENCE: i18n перевод отображается корректно (en) - // -------------------------------------------------------------------------- - - it('badge отображает "Revising" для en локали', async () => { - const router = makeRouter() - await router.push('/project/proj-1?status=revising') - const wrapper = mount(ProjectView, { - props: { id: 'proj-1' }, - global: { plugins: [router] }, - }) - await flushPromises() - - const badges = wrapper.findAll('span.text-xs.rounded') - const badgeTexts = badges.map(b => b.text().trim()) - expect(badgeTexts).toContain('Revising') - }) - - // -------------------------------------------------------------------------- - // PRESENCE: ru локаль отображает 'Доработка' - // -------------------------------------------------------------------------- - - it('badge отображает "Доработка" для ru локали', async () => { - i18n.global.locale.value = 'ru' as any - - const router = makeRouter() - await router.push('/project/proj-1?status=revising') - const wrapper = mount(ProjectView, { - props: { id: 'proj-1' }, - global: { plugins: [router] }, - }) - await flushPromises() - - const badges = wrapper.findAll('span.text-xs.rounded') - const badgeTexts = badges.map(b => b.text().trim()) - expect(badgeTexts).toContain('Доработка') - }) - - // -------------------------------------------------------------------------- - // SOURCE CHECK: шаблон не содержит :text="t.status" для Badge (статическая проверка) - // -------------------------------------------------------------------------- - - it('шаблон ProjectView.vue не содержит :text="t.status" в Badge строках 1191 и 1219', () => { - const vuePath = path.resolve(__dirname, '../ProjectView.vue') - const source = fs.readFileSync(vuePath, 'utf-8') - const lines = source.split('\n') - const badgeLines = lines.filter(l => l.includes(' { - const vuePath = path.resolve(__dirname, '../ProjectView.vue') - const source = fs.readFileSync(vuePath, 'utf-8') - const lines = source.split('\n') - const taskStatusBadgeLines = lines.filter(l => l.includes('