Compare commits
3 commits
a532f4299f
...
51c102a895
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
51c102a895 | ||
|
|
f5c527a6d9 | ||
|
|
41b711a478 |
3 changed files with 260 additions and 6 deletions
|
|
@ -290,13 +290,11 @@ def has_open_children(conn: sqlite3.Connection, task_id: str, visited: set[str]
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
def _check_parent_completion(conn: sqlite3.Connection, task_id: str, visited: set[str] | None = None) -> None:
|
def _do_cascade(conn: sqlite3.Connection, task_id: str, visited: set[str]) -> None:
|
||||||
"""Cascade-check upward: if parent is 'revising' and all children closed → promote to 'done'."""
|
"""Recursive upward cascade without transaction management — no commits."""
|
||||||
if visited is None:
|
|
||||||
visited = set()
|
|
||||||
if task_id in visited:
|
if task_id in visited:
|
||||||
return
|
return
|
||||||
visited = visited | {task_id}
|
visited.add(task_id)
|
||||||
task = get_task(conn, task_id)
|
task = get_task(conn, task_id)
|
||||||
if not task:
|
if not task:
|
||||||
return
|
return
|
||||||
|
|
@ -314,8 +312,19 @@ def _check_parent_completion(conn: sqlite3.Connection, task_id: str, visited: se
|
||||||
"UPDATE tasks SET status = 'done', completed_at = ?, updated_at = ? WHERE id = ?",
|
"UPDATE tasks SET status = 'done', completed_at = ?, updated_at = ? WHERE id = ?",
|
||||||
(now, now, parent_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()
|
conn.commit()
|
||||||
_check_parent_completion(conn, parent_id, visited)
|
except Exception:
|
||||||
|
conn.rollback()
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
VALID_TASK_SORT_FIELDS = frozenset({
|
VALID_TASK_SORT_FIELDS = frozenset({
|
||||||
|
|
|
||||||
|
|
@ -366,6 +366,32 @@ describe('KIN-127: i18n ключи', () => {
|
||||||
// 7. Защита от циклических ссылок
|
// 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: защита от циклических ссылок', () => {
|
describe('KIN-127: защита от циклических ссылок', () => {
|
||||||
it('Проект с циклическими parent_task_id рендерится без зависания и не показывает toggle', async () => {
|
it('Проект с циклическими parent_task_id рендерится без зависания и не показывает toggle', async () => {
|
||||||
// Специально создаём циклическую ссылку: KIN-001 -> KIN-002 -> KIN-001
|
// Специально создаём циклическую ссылку: KIN-001 -> KIN-002 -> KIN-001
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,219 @@
|
||||||
|
/**
|
||||||
|
* 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<typeof import('../../api')>()
|
||||||
|
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<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 })
|
||||||
|
|
||||||
|
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('<Badge') && l.includes(':text='))
|
||||||
|
for (const line of badgeLines) {
|
||||||
|
expect(line).not.toContain(':text="t.status"')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// --------------------------------------------------------------------------
|
||||||
|
// SOURCE CHECK: taskStatusLabel используется в Badge биндингах
|
||||||
|
// --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
it('Badge биндинги используют taskStatusLabel() вместо прямого t.status', () => {
|
||||||
|
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('<Badge') && l.includes('taskStatusColor'))
|
||||||
|
expect(taskStatusBadgeLines.length).toBeGreaterThan(0)
|
||||||
|
for (const line of taskStatusBadgeLines) {
|
||||||
|
expect(line).toContain('taskStatusLabel')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
})
|
||||||
Loading…
Add table
Add a link
Reference in a new issue