kin: auto-commit after pipeline
This commit is contained in:
parent
feb7ea71b1
commit
993a8447d2
1 changed files with 297 additions and 0 deletions
297
web/frontend/src/__tests__/kin-ui-025-fixes.test.ts
Normal file
297
web/frontend/src/__tests__/kin-ui-025-fixes.test.ts
Normal file
|
|
@ -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<string, unknown>).search_placeholder).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('значение dashboard.search_placeholder в en.json корректно', () => {
|
||||||
|
expect((enJson.dashboard as Record<string, unknown>).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<string, unknown>).search_placeholder).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('значение dashboard.search_placeholder в ru.json корректно', () => {
|
||||||
|
expect((ruJson.dashboard as Record<string, unknown>).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<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 })
|
||||||
|
|
||||||
|
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')
|
||||||
|
})
|
||||||
|
})
|
||||||
Loading…
Add table
Add a link
Reference in a new issue