import { describe, it, expect, vi, beforeEach } from 'vitest'
import { mount } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import { useAuthStore } from '@/stores/auth.js'
import SkillsCatalogPanel from '@/components/settings/SkillsCatalogPanel.vue'
vi.mock('@/api/index.js', () => ({
getSkills: vi.fn(),
getSkill: vi.fn(),
createSkill: vi.fn(),
updateSkill: vi.fn(),
deleteSkill: vi.fn(),
getSkillVersions: vi.fn(),
}))
vi.mock('@/composables/useConfirm.js', () => ({
useConfirm: () => () => Promise.resolve(true),
}))
// No GnToastProvider in tests — swallow toast calls.
vi.mock('gnexus-ui-kit/vue', async (importOriginal) => {
const mod = await importOriginal()
const toast = {
show: () => {}, info: () => {}, success: () => {},
warning: () => {}, danger: () => {}, error: () => {}, close: () => {},
}
return { ...mod, useToast: () => toast }
})
import * as api from '@/api/index.js'
const SKILL = {
id: 'sk-1', kind: 'skill', name: 'Разбор логов',
description: 'Быстрый поиск причины в логах.',
area: 'workflows', tags: ['logs'], visibility: 'personal',
version: 2, owner_id: 'u1', can_edit: true,
updated_at: '2026-11-01T10:00:00+00:00',
content: '---\nkind: skill\nname: Разбор логов\n---\n\nГрепай ERROR.\n',
document: {
kind: 'skill', name: 'Разбор логов', description: 'Быстрый поиск причины в логах.',
area: 'workflows', tags: ['logs'], triggers: ['нужно найти ошибку'],
steps: [], preconditions: [], post_checks: [],
on_deviation: '', on_blocked: '', on_already_done: '',
body: 'Грепай ERROR, потом сужай по времени.',
},
}
const PROTOCOL = {
id: 'pr-1', kind: 'protocol', name: 'Выключение',
description: 'Порядок выключения.',
area: 'infrastructure', tags: ['power'], visibility: 'shared',
version: 1, owner_id: '', can_edit: false,
updated_at: '2026-11-02T10:00:00+00:00',
content: '---\nkind: protocol\nname: Выключение\n---\n',
document: {
kind: 'protocol', name: 'Выключение', description: 'Порядок выключения.',
area: 'infrastructure', tags: ['power'], triggers: ['выключить сервер'],
steps: ['Остановить сервисы', 'Отмонтировать диски'],
preconditions: ['Все предупреждены'],
post_checks: ['Юнитов не осталось'],
on_deviation: 'Остановиться и доложить.',
on_blocked: 'Прервать и доложить.',
on_already_done: 'Проверить и пропустить.',
body: 'Порядок зависимостей.',
},
}
function settle() {
return new Promise(r => setTimeout(r))
}
function mountPanel(kind = 'skill') {
return mount(SkillsCatalogPanel, { props: { kind } })
}
function button(wrapper, text) {
return wrapper.findAll('button').find(b => b.text().trim() === text)
}
describe('SkillsCatalogPanel', () => {
beforeEach(() => {
setActivePinia(createPinia())
vi.clearAllMocks()
useAuthStore().user = { id: 'u1', role: 'user', permissions: [] }
api.getSkills.mockImplementation(async ({ kind }) => ({
items: kind === 'protocol'
? [structuredClone(PROTOCOL)]
: [structuredClone(SKILL)],
}))
api.getSkill.mockImplementation(async (id) => (
id === 'pr-1' ? structuredClone(PROTOCOL) : structuredClone(SKILL)
))
api.updateSkill.mockImplementation(async (id, payload) => ({
...structuredClone(SKILL), content: payload.content,
}))
api.createSkill.mockImplementation(async (payload) => ({
...structuredClone(SKILL), content: payload.content, version: 1,
}))
api.getSkillVersions.mockResolvedValue({ items: [] })
})
it('asks the API for its own kind and lists only that kind', async () => {
const wrapper = mountPanel('skill')
await settle()
expect(api.getSkills).toHaveBeenCalledWith(
expect.objectContaining({ kind: 'skill' }),
)
expect(wrapper.findAll('.catalog-row')).toHaveLength(1)
expect(wrapper.text()).toContain('Разбор логов')
expect(wrapper.text()).not.toContain('Выключение')
})
it('the protocols section asks for protocols', async () => {
const wrapper = mountPanel('protocol')
await settle()
expect(api.getSkills).toHaveBeenCalledWith(
expect.objectContaining({ kind: 'protocol' }),
)
expect(wrapper.text()).toContain('Выключение')
})
it('shows the entry as a document, not as its source', async () => {
const wrapper = mountPanel('protocol')
await settle()
await wrapper.find('.catalog-row').trigger('click')
await settle()
expect(api.getSkill).toHaveBeenCalledWith('pr-1')
expect(wrapper.find('textarea').exists()).toBe(false)
expect(wrapper.text()).toContain('When to use it')
expect(wrapper.text()).toContain('выключить сервер')
expect(wrapper.text()).toContain('Остановить сервисы')
expect(wrapper.text()).toContain('Остановиться и доложить.')
expect(wrapper.text()).toContain('Порядок зависимостей.')
})
it('opens the source only when asked to edit it', async () => {
const wrapper = mountPanel('skill')
await settle()
await wrapper.find('.catalog-row').trigger('click')
await settle()
expect(wrapper.find('textarea').exists()).toBe(false)
await wrapper.find('.catalog-edit-btn').trigger('click')
await settle()
expect(wrapper.find('textarea').element.value).toContain('kind: skill')
expect(button(wrapper, 'Save')).toBeTruthy()
})
it('saves the edited source back to the server', async () => {
const wrapper = mountPanel('skill')
await settle()
await wrapper.find('.catalog-row').trigger('click')
await settle()
await wrapper.find('.catalog-edit-btn').trigger('click')
await settle()
await wrapper.find('textarea').setValue('---\nkind: skill\nname: Разбор логов\n---\n\nНовое тело.\n')
await button(wrapper, 'Save').trigger('click')
await settle()
expect(api.updateSkill).toHaveBeenCalledWith('sk-1', {
content: expect.stringContaining('Новое тело.'),
})
// Back to the document once the server accepted it.
expect(wrapper.find('textarea').exists()).toBe(false)
})
it('a shared entry is read-only for a regular user', async () => {
const wrapper = mountPanel('protocol')
await settle()
await wrapper.find('.catalog-row').trigger('click')
await settle()
expect(wrapper.find('.catalog-readonly').exists()).toBe(true)
expect(wrapper.find('.catalog-edit-btn').exists()).toBe(false)
expect(wrapper.find('.catalog-delete-btn').exists()).toBe(false)
})
it('an admin can choose the visibility while editing', async () => {
useAuthStore().user = { id: 'u1', role: 'admin', permissions: [] }
const wrapper = mountPanel('skill')
await settle()
await wrapper.find('.catalog-row').trigger('click')
await settle()
await wrapper.find('.catalog-edit-btn').trigger('click')
await settle()
expect(wrapper.find('.catalog-visibility').exists()).toBe(true)
})
it('a new entry starts from a template with the escape hatches', async () => {
const wrapper = mountPanel('protocol')
await settle()
await button(wrapper, 'New protocol').trigger('click')
await settle()
const text = wrapper.find('textarea').element.value
expect(text).toContain('kind: protocol')
expect(text).toContain('on_deviation:')
expect(text).toContain('post_checks:')
})
it('shows the server validation message when a protocol is refused', async () => {
api.createSkill.mockRejectedValue(
new Error('POST /skills → 400: {"detail":"Protocol needs: post_checks, on_blocked"}'),
)
const wrapper = mountPanel('protocol')
await settle()
await button(wrapper, 'New protocol').trigger('click')
await settle()
await button(wrapper, 'Save').trigger('click')
await settle()
expect(api.createSkill).toHaveBeenCalled()
expect(wrapper.find('.catalog-save-error').text()).toContain(
'Protocol needs: post_checks, on_blocked',
)
})
it('falls back to the source when the stored document does not parse', async () => {
api.getSkill.mockResolvedValue({
...structuredClone(SKILL), document: null, content: '---\nname: битый\n---\n',
})
const wrapper = mountPanel('skill')
await settle()
await wrapper.find('.catalog-row').trigger('click')
await settle()
expect(wrapper.find('.catalog-parse-error').exists()).toBe(true)
expect(wrapper.find('.catalog-raw').text()).toContain('name: битый')
})
it('loads the edit history on demand', async () => {
api.getSkillVersions.mockResolvedValue({
items: [{ version: 1, edited_by: 'u1', created_at: '2026-11-01T10:00:00+00:00', content: 'v1' }],
})
const wrapper = mountPanel('skill')
await settle()
await wrapper.find('.catalog-row').trigger('click')
await settle()
await wrapper.find('.catalog-history-toggle').trigger('click')
await settle()
expect(api.getSkillVersions).toHaveBeenCalledWith('sk-1')
expect(wrapper.text()).toContain('v1')
})
it('the back control returns to the list', async () => {
const wrapper = mountPanel('skill')
await settle()
await wrapper.find('.catalog-row').trigger('click')
await settle()
expect(wrapper.find('.catalog-back').exists()).toBe(true)
await wrapper.find('.catalog-back').trigger('click')
await settle()
expect(wrapper.find('h3').exists()).toBe(false)
})
it('the area filter is sent to the API', async () => {
const wrapper = mountPanel('skill')
await settle()
api.getSkills.mockClear()
await wrapper.find('select').setValue('home')
await new Promise(r => setTimeout(r, 300))
expect(api.getSkills).toHaveBeenCalledWith(
expect.objectContaining({ area: 'home' }),
)
})
})