import { describe, it, expect, vi, beforeEach } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
import SkillProposal from '@/components/ui/SkillProposal.vue'
const PROPOSAL = {
id: 'pr1',
name: 'Выключение инфраструктуры',
description: 'Останавливает сервисы по порядку.',
area: 'infrastructure',
tags: ['power'],
status: 'pending',
replaces_id: null,
preconditions: ['Все предупреждены'],
steps: ['Остановить приложения', 'Остановить базы'],
post_checks: ['Юнитов не осталось'],
on_deviation: 'Остановиться и доложить.',
on_blocked: 'Прервать и доложить.',
on_already_done: 'Проверить и пропустить.',
body: '## Порядок\n\nПорядок зависимостей.',
}
vi.mock('@/api/index.js', () => ({
getSkillProposal: vi.fn(),
}))
// The card reaches the socket through the bridge, not an inject() — the bridge
// is what the app registers when a session view mounts.
vi.mock('@/composables/wsBridge.js', () => ({
wsSend: vi.fn(),
}))
import * as api from '@/api/index.js'
import { wsSend } from '@/composables/wsBridge.js'
function mountWith(data) {
return mount(SkillProposal, { props: { data } })
}
describe('SkillProposal', () => {
beforeEach(() => {
vi.clearAllMocks()
api.getSkillProposal.mockResolvedValue({ ...PROPOSAL })
wsSend.mockReturnValue(true)
})
it('renders the stored document', async () => {
const wrapper = mountWith({ proposal_id: 'pr1' })
await flushPromises()
expect(api.getSkillProposal).toHaveBeenCalledWith('pr1')
expect(wrapper.text()).toContain('Выключение инфраструктуры')
expect(wrapper.text()).toContain('Остановить приложения')
expect(wrapper.text()).toContain('Юнитов не осталось')
expect(wrapper.text()).toContain('Остановиться и доложить.')
expect(wrapper.text()).toContain('Порядок зависимостей.')
expect(wrapper.text()).toContain('New protocol')
// The body goes through the markdown renderer rather than showing as raw
// source; the heading markup itself differs between test DOM and browser.
expect(wrapper.find('.skill-proposal-body').text()).toContain('Порядок')
expect(wrapper.find('.skill-proposal-body').text()).not.toContain('##')
})
it('shows when the protocol applies', async () => {
api.getSkillProposal.mockResolvedValue({ ...PROPOSAL, triggers: ['выключить сервер'] })
const wrapper = mountWith({ proposal_id: 'pr1' })
await flushPromises()
expect(wrapper.text()).toContain('When to use it')
expect(wrapper.text()).toContain('выключить сервер')
})
// The parser stringifies a step the model wrote as a mapping; the card shows
// the step, not the Python repr of it.
it('unwraps a step written as a mapping', async () => {
api.getSkillProposal.mockResolvedValue({
...PROPOSAL,
steps: [
"{'id': 'marker', 'title': 'Создать маркер', 'action': 'Записать файл', 'check': 'Файл есть'}",
],
})
const wrapper = mountWith({ proposal_id: 'pr1' })
await flushPromises()
const text = wrapper.text()
expect(text).toContain('Создать маркер')
expect(text).toContain('Записать файл')
expect(text).toContain('Файл есть')
expect(text).not.toContain("'id'")
})
it('asks the server to accept, and does not write anything itself', async () => {
const wrapper = mountWith({ proposal_id: 'pr1' })
await flushPromises()
await wrapper.find('.skill-proposal-btn.is-accept').trigger('click')
expect(wsSend).toHaveBeenCalledWith({
type: 'skill_proposal',
proposal_id: 'pr1',
action: 'accept',
})
})
it('sends reject and leaves the document readable', async () => {
const wrapper = mountWith({ proposal_id: 'pr1' })
await flushPromises()
await wrapper.find('.skill-proposal-btn.is-reject').trigger('click')
expect(wsSend).toHaveBeenCalledWith({
type: 'skill_proposal',
proposal_id: 'pr1',
action: 'reject',
})
expect(wrapper.text()).toContain('Остановить приложения')
})
it('turns into a decided card when the server answers', async () => {
const wrapper = mountWith({ proposal_id: 'pr1' })
await flushPromises()
await wrapper.setProps({ data: { proposal_id: 'pr1', status: 'accepted' } })
expect(wrapper.find('.skill-proposal-btn').exists()).toBe(false)
expect(wrapper.text()).toContain('Added to the catalog')
})
it('shows the stored decision after a reload', async () => {
api.getSkillProposal.mockResolvedValue({ ...PROPOSAL, status: 'rejected' })
const wrapper = mountWith({ proposal_id: 'pr1' })
await flushPromises()
expect(wrapper.text()).toContain('Rejected')
expect(wrapper.find('.skill-proposal-btn').exists()).toBe(false)
})
it('keeps the buttons and shows why a refused accept failed', async () => {
const wrapper = mountWith({ proposal_id: 'pr1' })
await flushPromises()
await wrapper.setProps({
data: { proposal_id: 'pr1', status: 'pending', error: 'already exists' },
})
expect(wrapper.text()).toContain('already exists')
expect(wrapper.find('.skill-proposal-btn.is-accept').exists()).toBe(true)
})
it('says the decision cannot be sent when no socket is mounted', async () => {
wsSend.mockReturnValue(false)
const wrapper = mountWith({ proposal_id: 'pr1' })
await flushPromises()
await wrapper.find('.skill-proposal-btn.is-accept').trigger('click')
await flushPromises()
expect(wrapper.text()).toContain('No connection')
expect(wrapper.find('.skill-proposal-btn.is-accept').exists()).toBe(true)
})
it('reports a proposal that cannot be loaded', async () => {
api.getSkillProposal.mockRejectedValue(new Error('404'))
const wrapper = mountWith({ proposal_id: 'gone' })
await flushPromises()
expect(wrapper.text()).toContain('could not be loaded')
})
})