kin: KIN-UI-027-frontend_dev

This commit is contained in:
Gros Frumos 2026-03-21 09:21:41 +02:00
parent 2e6755eee3
commit a05bdcb4ec
3 changed files with 138 additions and 74 deletions

View file

@ -176,13 +176,14 @@ describe('KIN-FIX-002: execution_mode унификация на "auto_complete"'
}) })
await flushPromises() await flushPromises()
// Найти и кликнуть кнопку тоггла режима // Открываем ⚙ Mode меню и кликаем по кнопке авто/ревью
const toggleBtn = wrapper.findAll('button').find(b => const trigger = wrapper.find('[data-testid="mode-menu-trigger"]')
b.text().includes('Auto') || b.text().includes('Review') if (trigger.exists()) {
) await trigger.trigger('click')
await flushPromises()
if (toggleBtn) { const autoBtn = wrapper.find('[data-testid="mode-toggle-auto"]')
await toggleBtn.trigger('click') await autoBtn.trigger('click')
await flushPromises() await flushPromises()
// Проверяем, что localStorage содержит 'auto_complete', не 'auto' // Проверяем, что localStorage содержит 'auto_complete', не 'auto'
@ -547,11 +548,10 @@ describe('KIN-097: runTask синхронизирует execution_mode с тог
}) })
await flushPromises() await flushPromises()
// Тоггл должен показывать Auto // Триггер ⚙ Mode должен иметь data-mode="auto" при execution_mode=auto_complete
const toggleBtn = wrapper.findAll('button').find(b => const trigger = wrapper.find('[data-testid="mode-menu-trigger"]')
b.text().includes('Auto') || b.text().includes('Review') expect(trigger.exists()).toBe(true)
) expect(trigger.attributes('data-mode')).toBe('auto')
expect(toggleBtn!.text()).toContain('Auto')
// DB переключается на review (например, другой клиент изменил режим) // DB переключается на review (например, другой клиент изменил режим)
vi.mocked(api.project).mockResolvedValue( vi.mocked(api.project).mockResolvedValue(
@ -561,17 +561,14 @@ describe('KIN-097: runTask синхронизирует execution_mode с тог
// После load() тоггл должен обновиться на Review // После load() тоггл должен обновиться на Review
// Имитируем внешний load (например, после создания задачи) // Имитируем внешний load (например, после создания задачи)
vi.mocked(api.patchProject).mockResolvedValue({ execution_mode: 'review' } as any) vi.mocked(api.patchProject).mockResolvedValue({ execution_mode: 'review' } as any)
// Триггерим reload через toggleAutocommit (который вызывает patchProject, но не load) // Вместо этого напрямую проверим что при новом mount с review — data-mode="review"
// Вместо этого напрямую проверим что при новом mount с review — кнопка Review
const wrapper2 = mount(ProjectView, { const wrapper2 = mount(ProjectView, {
props: { id: 'KIN' }, props: { id: 'KIN' },
global: { plugins: [router] }, global: { plugins: [router] },
}) })
await flushPromises() await flushPromises()
const toggleBtn2 = wrapper2.findAll('button').find(b => const trigger2 = wrapper2.find('[data-testid="mode-menu-trigger"]')
b.text().includes('Auto') || b.text().includes('Review') expect(trigger2.attributes('data-mode')).toBe('review')
)
expect(toggleBtn2!.text()).toContain('Review')
}) })
}) })

View file

@ -517,7 +517,14 @@ describe('KIN-047: TaskDetail — Approve/Reject в статусе review', () =
// ───────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────
describe('KIN-065: ProjectView — Autocommit toggle', () => { describe('KIN-065: ProjectView — Autocommit toggle', () => {
it('Кнопка Autocommit присутствует в DOM', async () => { async function openModeMenu(wrapper: ReturnType<typeof mount>) {
const trigger = wrapper.find('[data-testid="mode-menu-trigger"]')
await trigger.trigger('click')
await flushPromises()
return wrapper.find('[data-testid="mode-toggle-autocommit"]')
}
it('Кнопка Autocommit присутствует в меню ⚙ Mode', async () => {
const router = makeRouter() const router = makeRouter()
await router.push('/project/KIN') await router.push('/project/KIN')
@ -527,8 +534,8 @@ describe('KIN-065: ProjectView — Autocommit toggle', () => {
}) })
await flushPromises() await flushPromises()
const btn = wrapper.findAll('button').find(b => b.text().includes('Autocommit')) const btn = await openModeMenu(wrapper)
expect(btn?.exists()).toBe(true) expect(btn.exists()).toBe(true)
}) })
it('Кнопка имеет title "Autocommit: off" когда autocommit_enabled=0', async () => { it('Кнопка имеет title "Autocommit: off" когда autocommit_enabled=0', async () => {
@ -542,8 +549,8 @@ describe('KIN-065: ProjectView — Autocommit toggle', () => {
}) })
await flushPromises() await flushPromises()
const btn = wrapper.findAll('button').find(b => b.text().includes('Autocommit')) const btn = await openModeMenu(wrapper)
expect(btn?.attributes('title')).toBe('Autocommit: off') expect(btn.attributes('title')).toBe('Autocommit: off')
}) })
it('Кнопка имеет title "Autocommit: on..." когда autocommit_enabled=1', async () => { it('Кнопка имеет title "Autocommit: on..." когда autocommit_enabled=1', async () => {
@ -557,8 +564,8 @@ describe('KIN-065: ProjectView — Autocommit toggle', () => {
}) })
await flushPromises() await flushPromises()
const btn = wrapper.findAll('button').find(b => b.text().includes('Autocommit')) const btn = await openModeMenu(wrapper)
expect(btn?.attributes('title')).toContain('Autocommit: on') expect(btn.attributes('title')).toContain('Autocommit: on')
}) })
it('Клик по кнопке вызывает patchProject с autocommit_enabled=true (включение)', async () => { it('Клик по кнопке вызывает patchProject с autocommit_enabled=true (включение)', async () => {
@ -574,8 +581,8 @@ describe('KIN-065: ProjectView — Autocommit toggle', () => {
}) })
await flushPromises() await flushPromises()
const btn = wrapper.findAll('button').find(b => b.text().includes('Autocommit')) const btn = await openModeMenu(wrapper)
await btn!.trigger('click') await btn.trigger('click')
await flushPromises() await flushPromises()
expect(api.patchProject).toHaveBeenCalledWith('KIN', { autocommit_enabled: true }) expect(api.patchProject).toHaveBeenCalledWith('KIN', { autocommit_enabled: true })
@ -594,8 +601,8 @@ describe('KIN-065: ProjectView — Autocommit toggle', () => {
}) })
await flushPromises() await flushPromises()
const btn = wrapper.findAll('button').find(b => b.text().includes('Autocommit')) const btn = await openModeMenu(wrapper)
await btn!.trigger('click') await btn.trigger('click')
await flushPromises() await flushPromises()
expect(api.patchProject).toHaveBeenCalledWith('KIN', { autocommit_enabled: false }) expect(api.patchProject).toHaveBeenCalledWith('KIN', { autocommit_enabled: false })
@ -616,8 +623,8 @@ describe('KIN-065: ProjectView — Autocommit toggle', () => {
}) })
await flushPromises() await flushPromises()
const btn = wrapper.findAll('button').find(b => b.text().includes('Autocommit')) const btn = await openModeMenu(wrapper)
await btn!.trigger('click') await btn.trigger('click')
await flushPromises() await flushPromises()
// Catch-блок установил error.value → компонент показывает сообщение об ошибке // Catch-блок установил error.value → компонент показывает сообщение об ошибке

View file

@ -15,6 +15,8 @@ const project = ref<ProjectDetail | null>(null)
const loading = ref(true) const loading = ref(true)
const error = ref('') const error = ref('')
const activeTab = ref<'tasks' | 'phases' | 'decisions' | 'modules' | 'kanban' | 'environments' | 'links' | 'settings'>('tasks') const activeTab = ref<'tasks' | 'phases' | 'decisions' | 'modules' | 'kanban' | 'environments' | 'links' | 'settings'>('tasks')
const showModeMenu = ref(false)
const showMoreMenu = ref(false)
// Phases // Phases
const phases = ref<Phase[]>([]) const phases = ref<Phase[]>([])
@ -172,6 +174,34 @@ function phaseStatusColor(s: string) {
return m[s] || 'gray' return m[s] || 'gray'
} }
// Tab groups
const PRIMARY_TABS = ['tasks', 'kanban', 'phases', 'decisions'] as const
const MORE_TABS = ['modules', 'environments', 'links', 'settings'] as const
function tabLabel(tab: string): string {
const labels: Record<string, string> = {
tasks: t('projectView.tasks_tab'),
phases: t('projectView.phases_tab'),
decisions: t('projectView.decisions_tab'),
modules: t('projectView.modules_tab'),
kanban: t('projectView.kanban_tab'),
environments: t('projectView.environments'),
links: t('projectView.links_tab'),
settings: t('projectView.settings_tab'),
}
return labels[tab] ?? tab
}
function tabCount(tab: string): string | number {
if (tab === 'tasks' || tab === 'kanban') return project.value!.tasks.length
if (tab === 'phases') return phases.value.length
if (tab === 'decisions') return project.value!.decisions.length
if (tab === 'modules') return project.value!.modules.length
if (tab === 'environments') return environments.value.length
if (tab === 'links') return links.value.length
return ''
}
// Filters // Filters
const ALL_TASK_STATUSES = ['pending', 'in_progress', 'review', 'blocked', 'decomposed', 'done', 'revising', 'cancelled'] const ALL_TASK_STATUSES = ['pending', 'in_progress', 'review', 'blocked', 'decomposed', 'done', 'revising', 'cancelled']
@ -278,6 +308,8 @@ async function toggleWorktrees() {
} }
} }
const anyModeActive = computed(() => autoMode.value || autocommit.value || autoTest.value || worktrees.value)
// Settings form // Settings form
const settingsForm = ref({ const settingsForm = ref({
execution_mode: 'review', execution_mode: 'review',
@ -1072,24 +1104,38 @@ async function addDecision() {
<!-- Tabs --> <!-- Tabs -->
<div class="flex gap-1 mb-4 border-b border-gray-800 flex-wrap"> <div class="flex gap-1 mb-4 border-b border-gray-800 flex-wrap">
<button v-for="tab in (['tasks', 'phases', 'decisions', 'modules', 'kanban', 'environments', 'links', 'settings'] as const)" :key="tab" <button v-for="tab in PRIMARY_TABS" :key="tab"
@click="activeTab = tab" @click="activeTab = tab"
class="px-4 py-2 text-sm border-b-2 transition-colors" class="px-4 py-2 text-sm border-b-2 transition-colors"
:class="activeTab === tab :class="activeTab === tab
? 'text-gray-200 border-blue-500' ? 'text-gray-200 border-blue-500'
: 'text-gray-500 border-transparent hover:text-gray-300'"> : 'text-gray-500 border-transparent hover:text-gray-300'">
{{ tab === 'tasks' ? t('projectView.tasks_tab') : tab === 'phases' ? t('projectView.phases_tab') : tab === 'decisions' ? t('projectView.decisions_tab') : tab === 'modules' ? t('projectView.modules_tab') : tab === 'kanban' ? t('projectView.kanban_tab') : tab === 'environments' ? t('projectView.environments') : tab === 'links' ? t('projectView.links_tab') : t('projectView.settings_tab') }} {{ tabLabel(tab) }}
<span class="text-xs text-gray-600 ml-1"> <span class="text-xs text-gray-600 ml-1">{{ tabCount(tab) }}</span>
{{ tab === 'tasks' ? project.tasks.length
: tab === 'phases' ? phases.length
: tab === 'decisions' ? project.decisions.length
: tab === 'modules' ? project.modules.length
: tab === 'environments' ? environments.length
: tab === 'links' ? links.length
: tab === 'kanban' ? project.tasks.length
: '' }}
</span>
</button> </button>
<!-- More dropdown -->
<div class="relative">
<div v-if="showMoreMenu" class="fixed inset-0 z-10" @click="showMoreMenu = false"></div>
<button
@click="showMoreMenu = !showMoreMenu"
class="px-4 py-2 text-sm border-b-2 transition-colors relative z-20"
:class="(MORE_TABS as readonly string[]).includes(activeTab)
? 'text-gray-200 border-blue-500'
: 'text-gray-500 border-transparent hover:text-gray-300'">
More
</button>
<div v-if="showMoreMenu" class="absolute left-0 top-full z-20 bg-gray-900 border border-gray-700 rounded shadow-lg py-1 min-w-[10rem]">
<button v-for="tab in MORE_TABS" :key="tab"
@click="activeTab = tab; showMoreMenu = false"
class="w-full text-left px-3 py-1.5 text-sm transition-colors flex items-center justify-between"
:class="activeTab === tab
? 'text-gray-200 bg-gray-800'
: 'text-gray-500 hover:text-gray-300 hover:bg-gray-800'">
{{ tabLabel(tab) }}
<span class="text-xs text-gray-600 ml-2">{{ tabCount(tab) }}</span>
</button>
</div>
</div>
</div> </div>
<!-- Tasks Tab --> <!-- Tasks Tab -->
@ -1130,38 +1176,52 @@ async function addDecision() {
class="px-1.5 py-0.5 text-xs text-gray-600 hover:text-red-400 rounded"></button> class="px-1.5 py-0.5 text-xs text-gray-600 hover:text-red-400 rounded"></button>
</div> </div>
<div class="flex gap-2"> <div class="flex gap-2">
<button @click="toggleMode" <!-- Mode dropdown -->
class="px-2 py-1 text-xs border rounded transition-colors" <div class="relative">
:class="autoMode <div v-if="showModeMenu" class="fixed inset-0 z-10" @click="showModeMenu = false"></div>
? 'bg-yellow-900/30 text-yellow-400 border-yellow-800 hover:bg-yellow-900/50' <button
: 'bg-gray-800/50 text-gray-400 border-gray-700 hover:bg-gray-800'" data-testid="mode-menu-trigger"
:data-mode="autoMode ? 'auto' : 'review'"
@click="showModeMenu = !showModeMenu"
class="px-2 py-1 text-xs border rounded relative z-20"
:class="anyModeActive ? 'text-yellow-400 border-yellow-800 bg-yellow-900/20' : 'text-gray-400 border-gray-700 bg-gray-800/50'">
Mode
</button>
<div v-if="showModeMenu" class="absolute right-0 top-full mt-1 z-20 w-52 bg-gray-900 border border-gray-700 rounded shadow-lg py-1">
<button
data-testid="mode-toggle-auto"
@click="toggleMode"
class="w-full text-left px-3 py-1.5 text-xs flex items-center justify-between hover:bg-gray-800"
:title="autoMode ? 'Auto mode: agents can write files' : 'Review mode: agents read-only'"> :title="autoMode ? 'Auto mode: agents can write files' : 'Review mode: agents read-only'">
{{ autoMode ? '&#x1F513; Auto' : '&#x1F512; Review' }} <span>{{ autoMode ? '🔓 Auto' : '🔒 Review' }}</span>
<span :class="autoMode ? 'text-yellow-400' : 'text-gray-600'" class="text-[10px]">{{ autoMode ? 'on' : 'off' }}</span>
</button> </button>
<button @click="toggleAutocommit" <button
class="px-2 py-1 text-xs border rounded transition-colors" data-testid="mode-toggle-autocommit"
:class="autocommit @click="toggleAutocommit"
? 'bg-green-900/30 text-green-400 border-green-800 hover:bg-green-900/50' class="w-full text-left px-3 py-1.5 text-xs flex items-center justify-between hover:bg-gray-800"
: 'bg-gray-800/50 text-gray-400 border-gray-700 hover:bg-gray-800'"
:title="autocommit ? 'Autocommit: on — git commit after pipeline' : 'Autocommit: off'"> :title="autocommit ? 'Autocommit: on — git commit after pipeline' : 'Autocommit: off'">
{{ autocommit ? '&#x2713; Autocommit' : 'Autocommit' }} <span>Autocommit</span>
<span :class="autocommit ? 'text-green-400' : 'text-gray-600'" class="text-[10px]">{{ autocommit ? 'on' : 'off' }}</span>
</button> </button>
<button @click="toggleAutoTest" <button
class="px-2 py-1 text-xs border rounded transition-colors" data-testid="mode-toggle-autotest"
:class="autoTest @click="toggleAutoTest"
? 'bg-blue-900/30 text-blue-400 border-blue-800 hover:bg-blue-900/50' class="w-full text-left px-3 py-1.5 text-xs flex items-center justify-between hover:bg-gray-800"
: 'bg-gray-800/50 text-gray-400 border-gray-700 hover:bg-gray-800'"
:title="autoTest ? 'Auto-test: on — запускать тесты после pipeline' : 'Auto-test: off'"> :title="autoTest ? 'Auto-test: on — запускать тесты после pipeline' : 'Auto-test: off'">
{{ autoTest ? '✓ ' + t('projectView.auto_test_label') : t('projectView.auto_test_label') }} <span>{{ t('projectView.auto_test_label') }}</span>
<span :class="autoTest ? 'text-blue-400' : 'text-gray-600'" class="text-[10px]">{{ autoTest ? 'on' : 'off' }}</span>
</button> </button>
<button @click="toggleWorktrees" <button
class="px-2 py-1 text-xs border rounded transition-colors" data-testid="mode-toggle-worktrees"
:class="worktrees @click="toggleWorktrees"
? 'bg-teal-900/30 text-teal-400 border-teal-800 hover:bg-teal-900/50' class="w-full text-left px-3 py-1.5 text-xs flex items-center justify-between hover:bg-gray-800"
: 'bg-gray-800/50 text-gray-400 border-gray-700 hover:bg-gray-800'"
:title="worktrees ? 'Worktrees: on' : 'Worktrees: off'"> :title="worktrees ? 'Worktrees: on' : 'Worktrees: off'">
{{ worktrees ? t('projectView.worktrees_on') : t('projectView.worktrees_off') }} <span>{{ worktrees ? t('projectView.worktrees_on') : t('projectView.worktrees_off') }}</span>
<span :class="worktrees ? 'text-teal-400' : 'text-gray-600'" class="text-[10px]">{{ worktrees ? 'on' : 'off' }}</span>
</button> </button>
</div>
</div>
<button @click="runAudit" :disabled="auditLoading" <button @click="runAudit" :disabled="auditLoading"
class="px-2 py-1 text-xs bg-purple-900/30 text-purple-400 border border-purple-800 rounded hover:bg-purple-900/50 disabled:opacity-50" class="px-2 py-1 text-xs bg-purple-900/30 text-purple-400 border border-purple-800 rounded hover:bg-purple-900/50 disabled:opacity-50"
title="Check which pending tasks are already done"> title="Check which pending tasks are already done">