Newer
Older
navi-1 / webclient / tests / unit / components / ui / skillProposal.test.js
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-doc-body').text()).toContain('Порядок')
    expect(wrapper.find('.skill-doc-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')
  })
})