import { describe, it, expect, vi, beforeEach } from 'vitest'
import { mount } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import { useSynapseReactionsStore } from '@/stores/synapseReactions.js'
import SynapseReactionsPanel from '@/components/settings/SynapseReactionsPanel.vue'
vi.mock('@/api/index.js', () => ({
getSynapseReactions: vi.fn(),
updateSynapseReactions: vi.fn(),
getSynapseReactionVersions: vi.fn(),
}))
// No GnToastProvider in tests — swallow toast calls.
const { toastCalls } = await vi.hoisted(() => ({ toastCalls: [] }))
vi.mock('gnexus-ui-kit/vue', async (importOriginal) => {
const mod = await importOriginal()
const toast = {
show: (a) => toastCalls.push(['show', a]),
info: (a) => toastCalls.push(['info', a]),
success: (a) => toastCalls.push(['success', a]),
warning: (a) => toastCalls.push(['warning', a]),
danger: (a) => toastCalls.push(['danger', a]),
error: (a) => toastCalls.push(['error', a]),
close: (a) => toastCalls.push(['close', a]),
}
return { ...mod, useToast: () => toast }
})
import * as api from '@/api/index.js'
function makeSettings(overrides = {}) {
return {
reactions_enabled: false,
push_target: 'app',
completion_notify: 'important',
instructions: '',
dispatcher_instructions: '',
reaction_session_ttl_minutes: 1440,
source_ready: true,
...overrides,
}
}
describe('SynapseReactionsPanel', () => {
beforeEach(() => {
setActivePinia(createPinia())
vi.clearAllMocks()
api.getSynapseReactions.mockResolvedValue(makeSettings())
api.getSynapseReactionVersions.mockResolvedValue({ items: [] })
api.updateSynapseReactions.mockImplementation(async (payload) => ({
...makeSettings(),
...payload,
}))
})
it('renders the toggle and radio groups from fetched settings', async () => {
const wrapper = mount(SynapseReactionsPanel, { global: { plugins: [] } })
// let onMounted fetches settle
await new Promise(r => setTimeout(r))
expect(wrapper.text()).toContain('Synapse reactions')
expect(wrapper.text()).toContain('Enable reactions')
const radios = wrapper.findAll('input[type="radio"]')
// 3 completion options + 3 push-target options
expect(radios.length).toBe(6)
})
it('each radio group shares one name so selection is exclusive', async () => {
const wrapper = mount(SynapseReactionsPanel, { global: { plugins: [] } })
await new Promise(r => setTimeout(r))
const completion = wrapper.findAll('input[name="completion-notify"]')
const pushTarget = wrapper.findAll('input[name="push-target"]')
expect(completion.length + pushTarget.length).toBe(6)
// the two groups must not share a name (native radio exclusion is per name)
expect(new Set([completion[0].attributes('name'), pushTarget[0].attributes('name')]).size).toBe(2)
})
it('shows the not-configured alert and disables Synapse targets', async () => {
api.getSynapseReactions.mockResolvedValue(
makeSettings({ source_ready: false, push_target: 'app' })
)
const wrapper = mount(SynapseReactionsPanel, { global: { plugins: [] } })
await new Promise(r => setTimeout(r))
expect(wrapper.text()).toContain('not configured')
const radios = wrapper.findAll('input[type="radio"]')
const disabled = radios.filter(r => r.attributes('disabled') !== undefined)
// the two Synapse push-target radios
expect(disabled.length).toBe(2)
})
it('saving a dirty instructions patch PUTs full settings and reloads versions', async () => {
api.getSynapseReactions.mockResolvedValue(
makeSettings({ instructions: 'first version' })
)
const wrapper = mount(SynapseReactionsPanel, { global: { plugins: [] } })
await new Promise(r => setTimeout(r))
// Two instruction documents render two textareas — address the reaction one.
const textarea = wrapper.find('[data-doc="reaction"] textarea')
textarea.element.value = 'first version 2'
await textarea.trigger('input')
await new Promise(r => setTimeout(r))
const saveBtn = wrapper.find('[data-doc="reaction"] button')
expect(saveBtn.attributes('disabled')).toBeUndefined()
await saveBtn.trigger('click')
await new Promise(r => setTimeout(r))
expect(api.updateSynapseReactions).toHaveBeenCalledWith(
expect.objectContaining({ instructions: 'first version 2' })
)
// one history fetch per document on mount, one more for the saved one
expect(api.getSynapseReactionVersions).toHaveBeenCalledTimes(3)
expect(api.getSynapseReactionVersions).toHaveBeenCalledWith(20, 'reaction')
})
it('keeps a history per instruction document', async () => {
api.getSynapseReactionVersions.mockImplementation(async (limit, doc) => ({
items: [{ content: `${doc} v1`, edited_by: 'navi', reason: null, created_at: '2026-10-07T00:00:00Z' }],
}))
const wrapper = mount(SynapseReactionsPanel, { global: { plugins: [] } })
await new Promise(r => setTimeout(r))
expect(api.getSynapseReactionVersions).toHaveBeenCalledWith(20, 'reaction')
expect(api.getSynapseReactionVersions).toHaveBeenCalledWith(20, 'dispatcher')
expect(wrapper.find('[data-doc="reaction"] .reactions-history-text').text()).toBe('reaction v1')
expect(wrapper.find('[data-doc="dispatcher"] .reactions-history-text').text()).toBe('dispatcher v1')
})
it('saves the dispatcher document on its own button', async () => {
api.getSynapseReactions.mockResolvedValue(
makeSettings({ dispatcher_instructions: 'tg.* → secretary' })
)
const wrapper = mount(SynapseReactionsPanel, { global: { plugins: [] } })
await new Promise(r => setTimeout(r))
const textarea = wrapper.find('[data-doc="dispatcher"] textarea')
textarea.element.value = 'tg.* → developer'
await textarea.trigger('input')
await new Promise(r => setTimeout(r))
// the reaction document is untouched, so its own button stays disabled
expect(wrapper.find('[data-doc="reaction"] button').attributes('disabled')).toBeDefined()
await wrapper.find('[data-doc="dispatcher"] button').trigger('click')
await new Promise(r => setTimeout(r))
expect(api.updateSynapseReactions).toHaveBeenCalledWith(
expect.objectContaining({
dispatcher_instructions: 'tg.* → developer',
instructions: '',
})
)
})
it('commits the continuation window once, on change', async () => {
const wrapper = mount(SynapseReactionsPanel, { global: { plugins: [] } })
await new Promise(r => setTimeout(r))
const ttl = wrapper.find('.reactions-ttl-input')
expect(ttl.element.value).toBe('1440')
ttl.element.value = '60'
await ttl.trigger('input')
await new Promise(r => setTimeout(r))
// typing alone must not PUT — only the committed change does
expect(api.updateSynapseReactions).not.toHaveBeenCalled()
await ttl.trigger('change')
await new Promise(r => setTimeout(r))
expect(api.updateSynapseReactions).toHaveBeenCalledWith(
expect.objectContaining({ reaction_session_ttl_minutes: 60 })
)
})
})