From 51c102a8953a980e5e5bdc353a1d23b569c27b15 Mon Sep 17 00:00:00 2001 From: Gros Frumos Date: Wed, 18 Mar 2026 21:51:19 +0200 Subject: [PATCH] kin: auto-commit after pipeline --- web/frontend/src/__tests__/task-tree.test.ts | 26 +++ .../ProjectView.revising-badge.test.ts | 219 ++++++++++++++++++ 2 files changed, 245 insertions(+) create mode 100644 web/frontend/src/views/__tests__/ProjectView.revising-badge.test.ts diff --git a/web/frontend/src/__tests__/task-tree.test.ts b/web/frontend/src/__tests__/task-tree.test.ts index 5ba6192..f2767f8 100644 --- a/web/frontend/src/__tests__/task-tree.test.ts +++ b/web/frontend/src/__tests__/task-tree.test.ts @@ -366,6 +366,32 @@ 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 new file mode 100644 index 0000000..5049860 --- /dev/null +++ b/web/frontend/src/views/__tests__/ProjectView.revising-badge.test.ts @@ -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() + 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('