From 993a8447d2c021b8cc532904c8b2f0cf6dff7a4a Mon Sep 17 00:00:00 2001 From: Gros Frumos Date: Sat, 21 Mar 2026 09:16:48 +0200 Subject: [PATCH] kin: auto-commit after pipeline --- .../src/__tests__/kin-ui-025-fixes.test.ts | 297 ++++++++++++++++++ 1 file changed, 297 insertions(+) create mode 100644 web/frontend/src/__tests__/kin-ui-025-fixes.test.ts diff --git a/web/frontend/src/__tests__/kin-ui-025-fixes.test.ts b/web/frontend/src/__tests__/kin-ui-025-fixes.test.ts new file mode 100644 index 0000000..348e7ac --- /dev/null +++ b/web/frontend/src/__tests__/kin-ui-025-fixes.test.ts @@ -0,0 +1,297 @@ +/** + * KIN-UI-025: Тесты трёх исправлений ревьюера + * + * Fix 1: Click-outside overlay — dropdown '+ New ▾' закрывается при клике вне меню + * Fix 2: i18n — ключ dashboard.search_placeholder присутствует в обоих локалях без second arg + * Fix 3: Stats-bar — статусы revising/cancelled/decomposed отображаются в счётчиках + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { mount, flushPromises } from '@vue/test-utils' +import { createRouter, createMemoryHistory } from 'vue-router' +import * as fs from 'node:fs' +import * as path from 'node:path' +import enJson from '../locales/en.json' +import ruJson from '../locales/ru.json' +import ProjectView from '../views/ProjectView.vue' +import { i18n } from '../i18n' + +// ============================================================================ +// Fix 2: i18n — dashboard.search_placeholder +// ============================================================================ + +describe('KIN-UI-025 Fix 2: dashboard.search_placeholder в en.json', () => { + it('ключ dashboard.search_placeholder присутствует в en.json', () => { + expect((enJson.dashboard as Record).search_placeholder).toBeDefined() + }) + + it('значение dashboard.search_placeholder в en.json корректно', () => { + expect((enJson.dashboard as Record).search_placeholder).toBe('Search projects...') + }) +}) + +describe('KIN-UI-025 Fix 2: dashboard.search_placeholder в ru.json', () => { + it('ключ dashboard.search_placeholder присутствует в ru.json', () => { + expect((ruJson.dashboard as Record).search_placeholder).toBeDefined() + }) + + it('значение dashboard.search_placeholder в ru.json корректно', () => { + expect((ruJson.dashboard as Record).search_placeholder).toBe('Поиск проектов...') + }) +}) + +describe('KIN-UI-025 Fix 2: Dashboard.vue использует t() без второго аргумента', () => { + it('вызов t("dashboard.search_placeholder") без второго аргумента (нет plural-хака)', () => { + const vuePath = path.resolve(__dirname, '../views/Dashboard.vue') + const source = fs.readFileSync(vuePath, 'utf-8') + // Должен быть вызов без второго аргумента + expect(source).toContain("t('dashboard.search_placeholder')") + // Не должно быть второго аргумента типа строки (старый plural-хак) + expect(source).not.toContain("t('dashboard.search_placeholder', '") + expect(source).not.toContain('t("dashboard.search_placeholder", "') + }) +}) + +// ============================================================================ +// Fix 1: Click-outside overlay в Dashboard.vue +// ============================================================================ + +describe('KIN-UI-025 Fix 1: Click-outside overlay в Dashboard.vue', () => { + it('шаблон содержит overlay div с fixed inset-0 и z-[5]', () => { + const vuePath = path.resolve(__dirname, '../views/Dashboard.vue') + const source = fs.readFileSync(vuePath, 'utf-8') + expect(source).toContain('fixed inset-0 z-[5]') + }) + + it('overlay показывается только когда showNewMenu === true (v-if)', () => { + const vuePath = path.resolve(__dirname, '../views/Dashboard.vue') + const source = fs.readFileSync(vuePath, 'utf-8') + // Overlay div должен быть внутри v-if="showNewMenu" + const overlayLineMatch = source.match(/v-if="showNewMenu"[^>]*fixed inset-0/) + expect(overlayLineMatch).not.toBeNull() + }) + + it('overlay закрывает меню при клике: @click="showNewMenu = false"', () => { + const vuePath = path.resolve(__dirname, '../views/Dashboard.vue') + const source = fs.readFileSync(vuePath, 'utf-8') + // Overlay должен иметь обработчик закрытия + const lines = source.split('\n') + const overlayLine = lines.find(l => l.includes('fixed inset-0 z-[5]')) + expect(overlayLine).toBeDefined() + expect(overlayLine).toContain('showNewMenu = false') + }) + + it('dropdown имеет z-10 — выше overlay z-[5]', () => { + const vuePath = path.resolve(__dirname, '../views/Dashboard.vue') + const source = fs.readFileSync(vuePath, 'utf-8') + // Меню должно иметь z-10 + expect(source).toContain('z-10') + // Overlay должен иметь z-[5] (ниже меню) + expect(source).toContain('z-[5]') + }) +}) + +// ============================================================================ +// Fix 3: Stats-bar — revising/cancelled/decomposed в ProjectView +// ============================================================================ + +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 }) + +function makeTask(id: string, status: string) { + return { + id, + project_id: 'proj-stats', + title: `Task ${id}`, + status, + 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 STATS_PROJECT = { + id: 'proj-stats', + name: 'Stats Test Project', + path: '/projects/stats', + 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: 6, + done_tasks: 1, + active_tasks: 1, + blocked_tasks: 0, + review_tasks: 0, + project_type: 'development', + ssh_host: '', + ssh_user: '', + ssh_key_path: '', + ssh_proxy_jump: '', + description: null, + tasks: [ + makeTask('T1', 'done'), + makeTask('T2', 'in_progress'), + makeTask('T3', 'revising'), + makeTask('T4', 'cancelled'), + makeTask('T5', 'decomposed'), + makeTask('T6', 'pending'), + ], + 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(STATS_PROJECT 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(STATS_PROJECT as any) + i18n.global.locale.value = 'en' as any +}) + +describe('KIN-UI-025 Fix 3: Stats-бар — revising/cancelled/decomposed', () => { + it('taskStats computed содержит поле revising', async () => { + const vuePath = path.resolve(__dirname, '../views/ProjectView.vue') + const source = fs.readFileSync(vuePath, 'utf-8') + // Проверяем что в taskStats есть фильтрация по revising + expect(source).toContain("t.status === 'revising'") + }) + + it('taskStats computed содержит поле cancelled', async () => { + const vuePath = path.resolve(__dirname, '../views/ProjectView.vue') + const source = fs.readFileSync(vuePath, 'utf-8') + expect(source).toContain("t.status === 'cancelled'") + }) + + it('taskStats computed содержит поле decomposed', async () => { + const vuePath = path.resolve(__dirname, '../views/ProjectView.vue') + const source = fs.readFileSync(vuePath, 'utf-8') + expect(source).toContain("t.status === 'decomposed'") + }) + + it('шаблон отображает span для revising со стилем text-orange-400', () => { + const vuePath = path.resolve(__dirname, '../views/ProjectView.vue') + const source = fs.readFileSync(vuePath, 'utf-8') + const lines = source.split('\n') + const revisingSpan = lines.find(l => l.includes('taskStats.revising') && l.includes('text-orange-400')) + expect(revisingSpan).toBeDefined() + }) + + it('шаблон отображает span для cancelled', () => { + const vuePath = path.resolve(__dirname, '../views/ProjectView.vue') + const source = fs.readFileSync(vuePath, 'utf-8') + expect(source).toContain('taskStats.cancelled') + }) + + it('шаблон отображает span для decomposed', () => { + const vuePath = path.resolve(__dirname, '../views/ProjectView.vue') + const source = fs.readFileSync(vuePath, 'utf-8') + expect(source).toContain('taskStats.decomposed') + }) + + it('stats-бар рендерит "revising" для задачи со статусом revising', async () => { + const router = makeRouter() + await router.push('/project/proj-stats') + const wrapper = mount(ProjectView, { + props: { id: 'proj-stats' }, + global: { plugins: [router] }, + }) + await flushPromises() + + const statsText = wrapper.html() + expect(statsText).toContain('revising') + }) + + it('stats-бар рендерит "cancelled" для задачи со статусом cancelled', async () => { + const router = makeRouter() + await router.push('/project/proj-stats') + const wrapper = mount(ProjectView, { + props: { id: 'proj-stats' }, + global: { plugins: [router] }, + }) + await flushPromises() + + const statsText = wrapper.html() + expect(statsText).toContain('cancelled') + }) + + it('stats-бар рендерит "decomposed" для задачи со статусом decomposed', async () => { + const router = makeRouter() + await router.push('/project/proj-stats') + const wrapper = mount(ProjectView, { + props: { id: 'proj-stats' }, + global: { plugins: [router] }, + }) + await flushPromises() + + const statsText = wrapper.html() + expect(statsText).toContain('decomposed') + }) + + it('сумма всех статусов равна total (6 задач)', () => { + // Статическая проверка: taskStats возвращает revising+cancelled+decomposed в sum + const vuePath = path.resolve(__dirname, '../views/ProjectView.vue') + const source = fs.readFileSync(vuePath, 'utf-8') + // return объект должен включать все 8 статусов + total + pct + expect(source).toContain('revising, cancelled, decomposed') + }) +})