mirror of
https://github.com/chenasraf/nextcloud-forum.git
synced 2026-05-17 17:28:02 +00:00
test: add comprehensive component tests
This commit is contained in:
145
src/components/AdminTable/AdminTable.test.ts
Normal file
145
src/components/AdminTable/AdminTable.test.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import AdminTable from './AdminTable.vue'
|
||||
|
||||
describe('AdminTable', () => {
|
||||
const defaultColumns = [
|
||||
{ key: 'name', label: 'Name' },
|
||||
{ key: 'email', label: 'Email' },
|
||||
]
|
||||
|
||||
const defaultRows = [
|
||||
{ id: 1, name: 'John Doe', email: 'john@example.com' },
|
||||
{ id: 2, name: 'Jane Smith', email: 'jane@example.com' },
|
||||
]
|
||||
|
||||
describe('rendering', () => {
|
||||
it('should render column headers', () => {
|
||||
const wrapper = mount(AdminTable, {
|
||||
props: { columns: defaultColumns, rows: defaultRows },
|
||||
})
|
||||
expect(wrapper.find('.header-row').text()).toContain('Name')
|
||||
expect(wrapper.find('.header-row').text()).toContain('Email')
|
||||
})
|
||||
|
||||
it('should render data rows', () => {
|
||||
const wrapper = mount(AdminTable, {
|
||||
props: { columns: defaultColumns, rows: defaultRows },
|
||||
})
|
||||
const dataRows = wrapper.findAll('.data-row')
|
||||
expect(dataRows).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('should render cell values', () => {
|
||||
const wrapper = mount(AdminTable, {
|
||||
props: { columns: defaultColumns, rows: defaultRows },
|
||||
})
|
||||
expect(wrapper.text()).toContain('John Doe')
|
||||
expect(wrapper.text()).toContain('john@example.com')
|
||||
})
|
||||
})
|
||||
|
||||
describe('actions column', () => {
|
||||
it('should not show actions column by default', () => {
|
||||
const wrapper = mount(AdminTable, {
|
||||
props: { columns: defaultColumns, rows: defaultRows },
|
||||
})
|
||||
expect(wrapper.find('.col-actions').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('should show actions column when hasActions is true', () => {
|
||||
const wrapper = mount(AdminTable, {
|
||||
props: { columns: defaultColumns, rows: defaultRows, hasActions: true },
|
||||
})
|
||||
expect(wrapper.findAll('.col-actions').length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('should use custom actions label', () => {
|
||||
const wrapper = mount(AdminTable, {
|
||||
props: {
|
||||
columns: defaultColumns,
|
||||
rows: defaultRows,
|
||||
hasActions: true,
|
||||
actionsLabel: 'Operations',
|
||||
},
|
||||
})
|
||||
expect(wrapper.find('.header-row').text()).toContain('Operations')
|
||||
})
|
||||
})
|
||||
|
||||
describe('grid style', () => {
|
||||
it('should compute grid template columns', () => {
|
||||
const columns = [
|
||||
{ key: 'name', label: 'Name', width: '200px' },
|
||||
{ key: 'email', label: 'Email', minWidth: '150px' },
|
||||
]
|
||||
const wrapper = mount(AdminTable, {
|
||||
props: { columns, rows: defaultRows },
|
||||
})
|
||||
const grid = wrapper.find('.table-grid')
|
||||
expect(grid.attributes('style')).toContain('grid-template-columns')
|
||||
})
|
||||
})
|
||||
|
||||
describe('row key', () => {
|
||||
it('should use id as row key by default', () => {
|
||||
const wrapper = mount(AdminTable, {
|
||||
props: { columns: defaultColumns, rows: defaultRows },
|
||||
})
|
||||
expect(wrapper.findAll('.data-row')).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('should use custom row key', () => {
|
||||
const rows = [
|
||||
{ customId: 'a', name: 'Test' },
|
||||
{ customId: 'b', name: 'Test 2' },
|
||||
]
|
||||
const wrapper = mount(AdminTable, {
|
||||
props: { columns: [{ key: 'name', label: 'Name' }], rows, rowKey: 'customId' },
|
||||
})
|
||||
expect(wrapper.findAll('.data-row')).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('slots', () => {
|
||||
it('should render custom cell content via slot', () => {
|
||||
const wrapper = mount(AdminTable, {
|
||||
props: { columns: defaultColumns, rows: defaultRows },
|
||||
slots: {
|
||||
'cell-name': '<span class="custom-cell">Custom Name</span>',
|
||||
},
|
||||
})
|
||||
expect(wrapper.findAll('.custom-cell').length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('should render actions slot', () => {
|
||||
const wrapper = mount(AdminTable, {
|
||||
props: { columns: defaultColumns, rows: defaultRows, hasActions: true },
|
||||
slots: {
|
||||
actions: '<button class="action-btn">Edit</button>',
|
||||
},
|
||||
})
|
||||
expect(wrapper.findAll('.action-btn').length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('row class', () => {
|
||||
it('should apply string row class', () => {
|
||||
const wrapper = mount(AdminTable, {
|
||||
props: { columns: defaultColumns, rows: defaultRows, rowClass: 'custom-row' },
|
||||
})
|
||||
expect(wrapper.findAll('.data-row.custom-row')).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('should apply function row class', () => {
|
||||
const wrapper = mount(AdminTable, {
|
||||
props: {
|
||||
columns: defaultColumns,
|
||||
rows: defaultRows,
|
||||
rowClass: (row: { id: number }) => (row.id === 1 ? 'first-row' : ''),
|
||||
},
|
||||
})
|
||||
expect(wrapper.find('.data-row.first-row').exists()).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
61
src/components/AppToolbar/AppToolbar.test.ts
Normal file
61
src/components/AppToolbar/AppToolbar.test.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import AppToolbar from './AppToolbar.vue'
|
||||
|
||||
describe('AppToolbar', () => {
|
||||
describe('rendering', () => {
|
||||
it('should render toolbar container', () => {
|
||||
const wrapper = mount(AppToolbar)
|
||||
expect(wrapper.find('.app-toolbar').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('should render left and right sections', () => {
|
||||
const wrapper = mount(AppToolbar)
|
||||
expect(wrapper.find('.toolbar-left').exists()).toBe(true)
|
||||
expect(wrapper.find('.toolbar-right').exists()).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('slots', () => {
|
||||
it('should render left slot content', () => {
|
||||
const wrapper = mount(AppToolbar, {
|
||||
slots: {
|
||||
left: '<span class="left-content">Left Content</span>',
|
||||
},
|
||||
})
|
||||
expect(wrapper.find('.toolbar-left .left-content').exists()).toBe(true)
|
||||
expect(wrapper.find('.toolbar-left').text()).toBe('Left Content')
|
||||
})
|
||||
|
||||
it('should render right slot content', () => {
|
||||
const wrapper = mount(AppToolbar, {
|
||||
slots: {
|
||||
right: '<span class="right-content">Right Content</span>',
|
||||
},
|
||||
})
|
||||
expect(wrapper.find('.toolbar-right .right-content').exists()).toBe(true)
|
||||
expect(wrapper.find('.toolbar-right').text()).toBe('Right Content')
|
||||
})
|
||||
|
||||
it('should render both slots simultaneously', () => {
|
||||
const wrapper = mount(AppToolbar, {
|
||||
slots: {
|
||||
left: '<button>Action</button>',
|
||||
right: '<span>Status</span>',
|
||||
},
|
||||
})
|
||||
expect(wrapper.find('.toolbar-left button').exists()).toBe(true)
|
||||
expect(wrapper.find('.toolbar-right span').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('should render multiple elements in a slot', () => {
|
||||
const wrapper = mount(AppToolbar, {
|
||||
slots: {
|
||||
left: '<button>One</button><button>Two</button><button>Three</button>',
|
||||
},
|
||||
})
|
||||
const buttons = wrapper.findAll('.toolbar-left button')
|
||||
expect(buttons).toHaveLength(3)
|
||||
})
|
||||
})
|
||||
})
|
||||
419
src/components/BBCodeHelpDialog/BBCodeHelpDialog.test.ts
Normal file
419
src/components/BBCodeHelpDialog/BBCodeHelpDialog.test.ts
Normal file
@@ -0,0 +1,419 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { mount, flushPromises } from '@vue/test-utils'
|
||||
import type { BBCode } from '@/types'
|
||||
|
||||
// Mock axios - must use factory that doesn't reference external variables
|
||||
vi.mock('@/axios', () => ({
|
||||
ocs: {
|
||||
get: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
// Import after mock
|
||||
import { ocs } from '@/axios'
|
||||
import BBCodeHelpDialog from './BBCodeHelpDialog.vue'
|
||||
|
||||
const mockGet = vi.mocked(ocs.get)
|
||||
|
||||
describe('BBCodeHelpDialog', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockGet.mockResolvedValue({ data: [] } as never)
|
||||
})
|
||||
|
||||
const createWrapper = (props = {}) => {
|
||||
return mount(BBCodeHelpDialog, {
|
||||
props: {
|
||||
open: true,
|
||||
showCustom: true,
|
||||
...props,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
describe('rendering', () => {
|
||||
it('renders the dialog when open', () => {
|
||||
const wrapper = createWrapper()
|
||||
expect(wrapper.find('.nc-dialog').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('renders built-in BBCodes section', () => {
|
||||
const wrapper = createWrapper()
|
||||
expect(wrapper.find('.bbcode-section').exists()).toBe(true)
|
||||
expect(wrapper.text()).toContain('Built-in BBCodes')
|
||||
})
|
||||
|
||||
it('renders all built-in BBCode tags', () => {
|
||||
const wrapper = createWrapper()
|
||||
const tags = wrapper.findAll('.bbcode-tag')
|
||||
|
||||
// Check for some expected built-in tags
|
||||
const tagTexts = tags.map((t) => t.text())
|
||||
expect(tagTexts).toContain('[b]')
|
||||
expect(tagTexts).toContain('[i]')
|
||||
expect(tagTexts).toContain('[code]')
|
||||
expect(tagTexts).toContain('[url]')
|
||||
expect(tagTexts).toContain('[img]')
|
||||
expect(tagTexts).toContain('[quote]')
|
||||
})
|
||||
|
||||
it('renders BBCode examples', () => {
|
||||
const wrapper = createWrapper()
|
||||
const examples = wrapper.findAll('.example-code')
|
||||
expect(examples.length).toBeGreaterThan(0)
|
||||
// Check for a specific example
|
||||
expect(wrapper.text()).toContain('[b]Hello world![/b]')
|
||||
})
|
||||
|
||||
it('renders custom BBCodes section when showCustom is true', () => {
|
||||
const wrapper = createWrapper({ showCustom: true })
|
||||
expect(wrapper.text()).toContain('Custom BBCodes')
|
||||
})
|
||||
|
||||
it('does not render custom BBCodes section when showCustom is false', () => {
|
||||
const wrapper = createWrapper({ showCustom: false })
|
||||
expect(wrapper.text()).not.toContain('Custom BBCodes')
|
||||
})
|
||||
})
|
||||
|
||||
describe('fetching builtin DB codes', () => {
|
||||
it('fetches builtin codes when dialog opens', async () => {
|
||||
createWrapper({ open: true })
|
||||
await flushPromises()
|
||||
|
||||
expect(mockGet).toHaveBeenCalledWith('/bbcodes/builtin')
|
||||
})
|
||||
|
||||
it('displays builtin DB codes', async () => {
|
||||
const builtinCodes: BBCode[] = [
|
||||
{
|
||||
id: 1,
|
||||
tag: 'spoiler',
|
||||
replacement: '<span class="spoiler">{content}</span>',
|
||||
example: '[spoiler]Hidden text[/spoiler]',
|
||||
description: 'Spoiler text',
|
||||
enabled: true,
|
||||
parseInner: true,
|
||||
isBuiltin: true,
|
||||
specialHandler: null,
|
||||
createdAt: Date.now(),
|
||||
},
|
||||
]
|
||||
mockGet.mockImplementation((url: string) => {
|
||||
if (url === '/bbcodes/builtin') {
|
||||
return Promise.resolve({ data: builtinCodes }) as never
|
||||
}
|
||||
return Promise.resolve({ data: [] }) as never
|
||||
})
|
||||
|
||||
const wrapper = createWrapper({ open: true })
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('[spoiler]')
|
||||
expect(wrapper.text()).toContain('Spoiler text')
|
||||
})
|
||||
|
||||
it('silently fails when builtin codes fetch fails', async () => {
|
||||
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
mockGet.mockImplementation((url: string) => {
|
||||
if (url === '/bbcodes/builtin') {
|
||||
return Promise.reject(new Error('Network error'))
|
||||
}
|
||||
return Promise.resolve({ data: [] }) as never
|
||||
})
|
||||
|
||||
const wrapper = createWrapper({ open: true })
|
||||
await flushPromises()
|
||||
|
||||
// Should not show error state for builtin codes
|
||||
expect(wrapper.find('.error-state').exists()).toBe(false)
|
||||
expect(consoleError).toHaveBeenCalled()
|
||||
consoleError.mockRestore()
|
||||
})
|
||||
})
|
||||
|
||||
describe('fetching custom codes', () => {
|
||||
it('fetches custom codes when dialog opens with showCustom true', async () => {
|
||||
createWrapper({ open: true, showCustom: true })
|
||||
await flushPromises()
|
||||
|
||||
expect(mockGet).toHaveBeenCalledWith('/bbcodes')
|
||||
})
|
||||
|
||||
it('does not fetch custom codes when showCustom is false', async () => {
|
||||
createWrapper({ open: true, showCustom: false })
|
||||
await flushPromises()
|
||||
|
||||
expect(mockGet).not.toHaveBeenCalledWith('/bbcodes')
|
||||
})
|
||||
|
||||
it('displays loading state while fetching custom codes', async () => {
|
||||
let resolvePromise: (value: unknown) => void
|
||||
mockGet.mockImplementation((url: string) => {
|
||||
if (url === '/bbcodes') {
|
||||
return new Promise((resolve) => {
|
||||
resolvePromise = resolve
|
||||
}) as never
|
||||
}
|
||||
return Promise.resolve({ data: [] }) as never
|
||||
})
|
||||
|
||||
const wrapper = createWrapper({ open: true, showCustom: true })
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.find('.loading-state').exists()).toBe(true)
|
||||
expect(wrapper.text()).toContain('Loading custom BBCodes')
|
||||
|
||||
resolvePromise!({ data: [] })
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.find('.loading-state').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('displays custom codes after fetch', async () => {
|
||||
const customCodes: BBCode[] = [
|
||||
{
|
||||
id: 10,
|
||||
tag: 'highlight',
|
||||
replacement: '<mark>{content}</mark>',
|
||||
example: '[highlight]Important text[/highlight]',
|
||||
description: 'Highlight text',
|
||||
enabled: true,
|
||||
parseInner: true,
|
||||
isBuiltin: false,
|
||||
specialHandler: null,
|
||||
createdAt: Date.now(),
|
||||
},
|
||||
]
|
||||
mockGet.mockImplementation((url: string) => {
|
||||
if (url === '/bbcodes') {
|
||||
return Promise.resolve({ data: customCodes }) as never
|
||||
}
|
||||
return Promise.resolve({ data: [] }) as never
|
||||
})
|
||||
|
||||
const wrapper = createWrapper({ open: true, showCustom: true })
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('[highlight]')
|
||||
expect(wrapper.text()).toContain('Highlight text')
|
||||
})
|
||||
|
||||
it('filters out disabled custom codes', async () => {
|
||||
const customCodes: BBCode[] = [
|
||||
{
|
||||
id: 10,
|
||||
tag: 'enabled',
|
||||
replacement: '<span>{content}</span>',
|
||||
example: '[enabled]Text[/enabled]',
|
||||
description: 'Enabled code',
|
||||
enabled: true,
|
||||
parseInner: true,
|
||||
isBuiltin: false,
|
||||
specialHandler: null,
|
||||
createdAt: Date.now(),
|
||||
},
|
||||
{
|
||||
id: 11,
|
||||
tag: 'disabled',
|
||||
replacement: '<span>{content}</span>',
|
||||
example: '[disabled]Text[/disabled]',
|
||||
description: 'Disabled code',
|
||||
enabled: false,
|
||||
parseInner: true,
|
||||
isBuiltin: false,
|
||||
specialHandler: null,
|
||||
createdAt: Date.now(),
|
||||
},
|
||||
]
|
||||
mockGet.mockImplementation((url: string) => {
|
||||
if (url === '/bbcodes') {
|
||||
return Promise.resolve({ data: customCodes }) as never
|
||||
}
|
||||
return Promise.resolve({ data: [] }) as never
|
||||
})
|
||||
|
||||
const wrapper = createWrapper({ open: true, showCustom: true })
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('[enabled]')
|
||||
expect(wrapper.text()).not.toContain('[disabled]')
|
||||
})
|
||||
|
||||
it('displays empty state when no custom codes exist', async () => {
|
||||
mockGet.mockResolvedValue({ data: [] } as never)
|
||||
|
||||
const wrapper = createWrapper({ open: true, showCustom: true })
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.find('.empty-state').exists()).toBe(true)
|
||||
expect(wrapper.text()).toContain('No custom BBCodes configured')
|
||||
})
|
||||
|
||||
it('displays error state when fetch fails', async () => {
|
||||
mockGet.mockImplementation((url: string) => {
|
||||
if (url === '/bbcodes') {
|
||||
return Promise.reject(new Error('Network error'))
|
||||
}
|
||||
return Promise.resolve({ data: [] }) as never
|
||||
})
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
|
||||
const wrapper = createWrapper({ open: true, showCustom: true })
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.find('.error-state').exists()).toBe(true)
|
||||
expect(wrapper.text()).toContain('Failed to load custom BBCodes')
|
||||
})
|
||||
})
|
||||
|
||||
describe('caching', () => {
|
||||
it('does not refetch builtin codes if already loaded when reopening', async () => {
|
||||
// Mock returns data
|
||||
const builtinCodes: BBCode[] = [
|
||||
{
|
||||
id: 1,
|
||||
tag: 'test',
|
||||
replacement: '<span>{content}</span>',
|
||||
example: '[test]Hello[/test]',
|
||||
description: 'Test',
|
||||
enabled: true,
|
||||
parseInner: true,
|
||||
isBuiltin: true,
|
||||
specialHandler: null,
|
||||
createdAt: Date.now(),
|
||||
},
|
||||
]
|
||||
mockGet.mockImplementation((url: string) => {
|
||||
if (url === '/bbcodes/builtin') {
|
||||
return Promise.resolve({ data: builtinCodes }) as never
|
||||
}
|
||||
return Promise.resolve({ data: [] }) as never
|
||||
})
|
||||
|
||||
const wrapper = createWrapper({ open: true })
|
||||
await flushPromises()
|
||||
|
||||
const callCount = mockGet.mock.calls.filter((c) => c[0] === '/bbcodes/builtin').length
|
||||
expect(callCount).toBe(1)
|
||||
|
||||
// Close and reopen - since builtinDbCodes.length > 0, should not refetch
|
||||
await wrapper.setProps({ open: false })
|
||||
await wrapper.setProps({ open: true })
|
||||
await flushPromises()
|
||||
|
||||
const newCallCount = mockGet.mock.calls.filter((c) => c[0] === '/bbcodes/builtin').length
|
||||
expect(newCallCount).toBe(1) // Should still be 1
|
||||
})
|
||||
|
||||
it('does not refetch custom codes if already loaded when reopening', async () => {
|
||||
// Mock returns data for custom codes
|
||||
const customCodes: BBCode[] = [
|
||||
{
|
||||
id: 10,
|
||||
tag: 'custom',
|
||||
replacement: '<span>{content}</span>',
|
||||
example: '[custom]Hello[/custom]',
|
||||
description: 'Custom',
|
||||
enabled: true,
|
||||
parseInner: true,
|
||||
isBuiltin: false,
|
||||
specialHandler: null,
|
||||
createdAt: Date.now(),
|
||||
},
|
||||
]
|
||||
mockGet.mockImplementation((url: string) => {
|
||||
if (url === '/bbcodes') {
|
||||
return Promise.resolve({ data: customCodes }) as never
|
||||
}
|
||||
return Promise.resolve({ data: [] }) as never
|
||||
})
|
||||
|
||||
const wrapper = createWrapper({ open: true, showCustom: true })
|
||||
await flushPromises()
|
||||
|
||||
const callCount = mockGet.mock.calls.filter((c) => c[0] === '/bbcodes').length
|
||||
expect(callCount).toBe(1)
|
||||
|
||||
// Close and reopen - since customCodes.length > 0, should not refetch
|
||||
await wrapper.setProps({ open: false })
|
||||
await wrapper.setProps({ open: true })
|
||||
await flushPromises()
|
||||
|
||||
const newCallCount = mockGet.mock.calls.filter((c) => c[0] === '/bbcodes').length
|
||||
expect(newCallCount).toBe(1) // Should still be 1
|
||||
})
|
||||
})
|
||||
|
||||
describe('close event', () => {
|
||||
it('emits update:open event when dialog closes', async () => {
|
||||
const wrapper = createWrapper({ open: true })
|
||||
|
||||
;(wrapper.vm as unknown as { handleClose: (v: boolean) => void }).handleClose(false)
|
||||
|
||||
expect(wrapper.emitted('update:open')).toBeTruthy()
|
||||
expect(wrapper.emitted('update:open')![0]).toEqual([false])
|
||||
})
|
||||
})
|
||||
|
||||
describe('built-in codes content', () => {
|
||||
it('contains bold tag example', () => {
|
||||
const wrapper = createWrapper()
|
||||
expect(wrapper.text()).toContain('Font style bold')
|
||||
})
|
||||
|
||||
it('contains italic tag example', () => {
|
||||
const wrapper = createWrapper()
|
||||
expect(wrapper.text()).toContain('Font style italic')
|
||||
})
|
||||
|
||||
it('contains code tag example', () => {
|
||||
const wrapper = createWrapper()
|
||||
expect(wrapper.text()).toContain('[code]')
|
||||
expect(wrapper.text()).toContain('Code')
|
||||
})
|
||||
|
||||
it('contains email tag example', () => {
|
||||
const wrapper = createWrapper()
|
||||
expect(wrapper.text()).toContain('[email]')
|
||||
expect(wrapper.text()).toContain('Email (clickable)')
|
||||
})
|
||||
|
||||
it('contains url tag example', () => {
|
||||
const wrapper = createWrapper()
|
||||
expect(wrapper.text()).toContain('[url=http://example.com]')
|
||||
expect(wrapper.text()).toContain('URL (clickable)')
|
||||
})
|
||||
|
||||
it('contains image tag example', () => {
|
||||
const wrapper = createWrapper()
|
||||
expect(wrapper.text()).toContain('[img]')
|
||||
expect(wrapper.text()).toContain('Image (not clickable)')
|
||||
})
|
||||
|
||||
it('contains quote tag example', () => {
|
||||
const wrapper = createWrapper()
|
||||
expect(wrapper.text()).toContain('[quote]')
|
||||
expect(wrapper.text()).toContain('Quote')
|
||||
})
|
||||
|
||||
it('contains youtube tag example', () => {
|
||||
const wrapper = createWrapper()
|
||||
expect(wrapper.text()).toContain('[youtube]')
|
||||
expect(wrapper.text()).toContain('Embedded YouTube video')
|
||||
})
|
||||
|
||||
it('contains list tags examples', () => {
|
||||
const wrapper = createWrapper()
|
||||
expect(wrapper.text()).toContain('[list]')
|
||||
expect(wrapper.text()).toContain('List')
|
||||
})
|
||||
|
||||
it('contains alignment tag examples', () => {
|
||||
const wrapper = createWrapper()
|
||||
expect(wrapper.text()).toContain('[left]')
|
||||
expect(wrapper.text()).toContain('[center]')
|
||||
expect(wrapper.text()).toContain('[right]')
|
||||
})
|
||||
})
|
||||
})
|
||||
498
src/components/BBCodeToolbar/BBCodeToolbar.test.ts
Normal file
498
src/components/BBCodeToolbar/BBCodeToolbar.test.ts
Normal file
@@ -0,0 +1,498 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { mount, flushPromises } from '@vue/test-utils'
|
||||
import { createIconMock, createComponentMock } from '@/test-utils'
|
||||
|
||||
// Mock icons
|
||||
vi.mock('@icons/FormatBold.vue', () => createIconMock('FormatBoldIcon'))
|
||||
vi.mock('@icons/FormatItalic.vue', () => createIconMock('FormatItalicIcon'))
|
||||
vi.mock('@icons/FormatStrikethrough.vue', () => createIconMock('FormatStrikethroughIcon'))
|
||||
vi.mock('@icons/FormatUnderline.vue', () => createIconMock('FormatUnderlineIcon'))
|
||||
vi.mock('@icons/CodeTags.vue', () => createIconMock('CodeTagsIcon'))
|
||||
vi.mock('@icons/Email.vue', () => createIconMock('EmailIcon'))
|
||||
vi.mock('@icons/Link.vue', () => createIconMock('LinkIcon'))
|
||||
vi.mock('@icons/Image.vue', () => createIconMock('ImageIcon'))
|
||||
vi.mock('@icons/FormatQuoteClose.vue', () => createIconMock('FormatQuoteCloseIcon'))
|
||||
vi.mock('@icons/Youtube.vue', () => createIconMock('YoutubeIcon'))
|
||||
vi.mock('@icons/FormatFont.vue', () => createIconMock('FormatFontIcon'))
|
||||
vi.mock('@icons/FormatSize.vue', () => createIconMock('FormatSizeIcon'))
|
||||
vi.mock('@icons/FormatColorFill.vue', () => createIconMock('FormatColorFillIcon'))
|
||||
vi.mock('@icons/FormatAlignLeft.vue', () => createIconMock('FormatAlignLeftIcon'))
|
||||
vi.mock('@icons/FormatAlignCenter.vue', () => createIconMock('FormatAlignCenterIcon'))
|
||||
vi.mock('@icons/FormatAlignRight.vue', () => createIconMock('FormatAlignRightIcon'))
|
||||
vi.mock('@icons/EyeOff.vue', () => createIconMock('EyeOffIcon'))
|
||||
vi.mock('@icons/FormatListBulleted.vue', () => createIconMock('FormatListBulletedIcon'))
|
||||
vi.mock('@icons/Paperclip.vue', () => createIconMock('PaperclipIcon'))
|
||||
vi.mock('@icons/Upload.vue', () => createIconMock('UploadIcon'))
|
||||
vi.mock('@icons/Emoticon.vue', () => createIconMock('EmoticonIcon'))
|
||||
vi.mock('@icons/HelpCircle.vue', () => createIconMock('HelpCircleIcon'))
|
||||
|
||||
// Mock child components
|
||||
vi.mock('@/components/LazyEmojiPicker', () =>
|
||||
createComponentMock('LazyEmojiPicker', {
|
||||
template: '<div class="emoji-picker-mock"><slot /></div>',
|
||||
props: [],
|
||||
}),
|
||||
)
|
||||
|
||||
vi.mock('@/components/BBCodeHelpDialog', () =>
|
||||
createComponentMock('BBCodeHelpDialog', {
|
||||
template: '<div class="bbcode-help-dialog-mock" v-if="open" />',
|
||||
props: ['open'],
|
||||
}),
|
||||
)
|
||||
|
||||
// Mock Nextcloud dialogs
|
||||
vi.mock('@nextcloud/dialogs', () => ({
|
||||
getFilePickerBuilder: vi.fn(() => ({
|
||||
setMultiSelect: vi.fn().mockReturnThis(),
|
||||
setType: vi.fn().mockReturnThis(),
|
||||
build: vi.fn(() => ({
|
||||
pick: vi.fn(),
|
||||
})),
|
||||
})),
|
||||
FilePickerType: { TYPE_FILE: 1 },
|
||||
}))
|
||||
|
||||
// Mock Nextcloud auth
|
||||
vi.mock('@nextcloud/auth', () => ({
|
||||
getCurrentUser: vi.fn(() => ({ uid: 'testuser', displayName: 'Test User' })),
|
||||
}))
|
||||
|
||||
// Mock axios
|
||||
vi.mock('@/axios', () => ({
|
||||
ocs: {
|
||||
get: vi.fn(),
|
||||
},
|
||||
webDav: {
|
||||
put: vi.fn(),
|
||||
request: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
// Mock NcActions and NcActionButton since they're complex
|
||||
vi.mock('@nextcloud/vue/components/NcActions', () => ({
|
||||
default: {
|
||||
name: 'NcActions',
|
||||
template: '<div class="nc-actions-mock"><slot /><slot name="icon" /></div>',
|
||||
props: ['ariaLabel'],
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@nextcloud/vue/components/NcActionButton', () => ({
|
||||
default: {
|
||||
name: 'NcActionButton',
|
||||
template:
|
||||
'<button class="nc-action-button-mock" @click="$emit(\'click\')"><slot /><slot name="icon" /></button>',
|
||||
props: [],
|
||||
emits: ['click'],
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@nextcloud/vue/components/NcProgressBar', () => ({
|
||||
default: {
|
||||
name: 'NcProgressBar',
|
||||
template: '<div class="nc-progress-bar-mock" :data-value="value" />',
|
||||
props: ['value', 'size'],
|
||||
},
|
||||
}))
|
||||
|
||||
// Import after mocks
|
||||
import BBCodeToolbar from './BBCodeToolbar.vue'
|
||||
|
||||
describe('BBCodeToolbar', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.stubGlobal('prompt', vi.fn())
|
||||
})
|
||||
|
||||
const createWrapper = (props = {}) => {
|
||||
return mount(BBCodeToolbar, {
|
||||
props: {
|
||||
textareaRef: null,
|
||||
modelValue: '',
|
||||
...props,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
describe('rendering', () => {
|
||||
it('renders the toolbar', () => {
|
||||
const wrapper = createWrapper()
|
||||
expect(wrapper.find('.bbcode-toolbar').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('renders BBCode formatting buttons', () => {
|
||||
const wrapper = createWrapper()
|
||||
const buttons = wrapper.findAll('.bbcode-button')
|
||||
// Should have multiple BBCode buttons (bold, italic, etc.) + emoji + help
|
||||
expect(buttons.length).toBeGreaterThan(10)
|
||||
})
|
||||
|
||||
it('renders help button', () => {
|
||||
const wrapper = createWrapper()
|
||||
expect(wrapper.find('.bbcode-help-button').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('renders emoji picker trigger', () => {
|
||||
const wrapper = createWrapper()
|
||||
expect(wrapper.find('.emoji-picker-mock').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('renders attachment actions', () => {
|
||||
const wrapper = createWrapper()
|
||||
expect(wrapper.find('.nc-actions-mock').exists()).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('bbcodeButtons computed', () => {
|
||||
it('includes bold button', () => {
|
||||
const wrapper = createWrapper()
|
||||
const vm = wrapper.vm as unknown as { bbcodeButtons: Array<{ tag: string }> }
|
||||
expect(vm.bbcodeButtons.some((b) => b.tag === 'b')).toBe(true)
|
||||
})
|
||||
|
||||
it('includes italic button', () => {
|
||||
const wrapper = createWrapper()
|
||||
const vm = wrapper.vm as unknown as { bbcodeButtons: Array<{ tag: string }> }
|
||||
expect(vm.bbcodeButtons.some((b) => b.tag === 'i')).toBe(true)
|
||||
})
|
||||
|
||||
it('includes underline button', () => {
|
||||
const wrapper = createWrapper()
|
||||
const vm = wrapper.vm as unknown as { bbcodeButtons: Array<{ tag: string }> }
|
||||
expect(vm.bbcodeButtons.some((b) => b.tag === 'u')).toBe(true)
|
||||
})
|
||||
|
||||
it('includes strikethrough button', () => {
|
||||
const wrapper = createWrapper()
|
||||
const vm = wrapper.vm as unknown as { bbcodeButtons: Array<{ tag: string }> }
|
||||
expect(vm.bbcodeButtons.some((b) => b.tag === 's')).toBe(true)
|
||||
})
|
||||
|
||||
it('includes code button', () => {
|
||||
const wrapper = createWrapper()
|
||||
const vm = wrapper.vm as unknown as { bbcodeButtons: Array<{ tag: string }> }
|
||||
expect(vm.bbcodeButtons.some((b) => b.tag === 'code')).toBe(true)
|
||||
})
|
||||
|
||||
it('includes quote button', () => {
|
||||
const wrapper = createWrapper()
|
||||
const vm = wrapper.vm as unknown as { bbcodeButtons: Array<{ tag: string }> }
|
||||
expect(vm.bbcodeButtons.some((b) => b.tag === 'quote')).toBe(true)
|
||||
})
|
||||
|
||||
it('includes url button', () => {
|
||||
const wrapper = createWrapper()
|
||||
const vm = wrapper.vm as unknown as { bbcodeButtons: Array<{ tag: string }> }
|
||||
expect(vm.bbcodeButtons.some((b) => b.tag === 'url')).toBe(true)
|
||||
})
|
||||
|
||||
it('includes img button', () => {
|
||||
const wrapper = createWrapper()
|
||||
const vm = wrapper.vm as unknown as { bbcodeButtons: Array<{ tag: string }> }
|
||||
expect(vm.bbcodeButtons.some((b) => b.tag === 'img')).toBe(true)
|
||||
})
|
||||
|
||||
it('includes youtube button', () => {
|
||||
const wrapper = createWrapper()
|
||||
const vm = wrapper.vm as unknown as { bbcodeButtons: Array<{ tag: string }> }
|
||||
expect(vm.bbcodeButtons.some((b) => b.tag === 'youtube')).toBe(true)
|
||||
})
|
||||
|
||||
it('includes list button', () => {
|
||||
const wrapper = createWrapper()
|
||||
const vm = wrapper.vm as unknown as { bbcodeButtons: Array<{ tag: string }> }
|
||||
expect(vm.bbcodeButtons.some((b) => b.tag === 'list')).toBe(true)
|
||||
})
|
||||
|
||||
it('includes color button with hasValue', () => {
|
||||
const wrapper = createWrapper()
|
||||
const vm = wrapper.vm as unknown as {
|
||||
bbcodeButtons: Array<{ tag: string; hasValue?: boolean }>
|
||||
}
|
||||
const colorButton = vm.bbcodeButtons.find((b) => b.tag === 'color')
|
||||
expect(colorButton).toBeDefined()
|
||||
expect(colorButton!.hasValue).toBe(true)
|
||||
})
|
||||
|
||||
it('includes spoiler button', () => {
|
||||
const wrapper = createWrapper()
|
||||
const vm = wrapper.vm as unknown as { bbcodeButtons: Array<{ tag: string }> }
|
||||
expect(vm.bbcodeButtons.some((b) => b.tag === 'spoiler')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('help dialog', () => {
|
||||
it('opens help dialog when help button is clicked', async () => {
|
||||
const wrapper = createWrapper()
|
||||
|
||||
expect(wrapper.find('.bbcode-help-dialog-mock').exists()).toBe(false)
|
||||
|
||||
await wrapper.find('.bbcode-help-button').trigger('click')
|
||||
|
||||
expect(wrapper.find('.bbcode-help-dialog-mock').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('closes help dialog when showHelp is set to false', async () => {
|
||||
const wrapper = createWrapper()
|
||||
const vm = wrapper.vm as unknown as { showHelp: boolean }
|
||||
|
||||
vm.showHelp = true
|
||||
await flushPromises()
|
||||
expect(wrapper.find('.bbcode-help-dialog-mock').exists()).toBe(true)
|
||||
|
||||
vm.showHelp = false
|
||||
await flushPromises()
|
||||
expect(wrapper.find('.bbcode-help-dialog-mock').exists()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getEditorState', () => {
|
||||
it('returns null when textareaRef is null', () => {
|
||||
const wrapper = createWrapper({ textareaRef: null })
|
||||
const vm = wrapper.vm as unknown as { getEditorState: () => unknown }
|
||||
|
||||
expect(vm.getEditorState()).toBeNull()
|
||||
})
|
||||
|
||||
it('returns editor state for textarea element', () => {
|
||||
const textarea = document.createElement('textarea')
|
||||
textarea.value = 'Hello world'
|
||||
textarea.selectionStart = 0
|
||||
textarea.selectionEnd = 5
|
||||
|
||||
const wrapper = createWrapper({ textareaRef: textarea })
|
||||
const vm = wrapper.vm as unknown as {
|
||||
getEditorState: () => {
|
||||
value: string
|
||||
start: number
|
||||
end: number
|
||||
selectedText: string
|
||||
} | null
|
||||
}
|
||||
|
||||
const state = vm.getEditorState()
|
||||
expect(state).not.toBeNull()
|
||||
expect(state!.value).toBe('Hello world')
|
||||
expect(state!.start).toBe(0)
|
||||
expect(state!.end).toBe(5)
|
||||
expect(state!.selectedText).toBe('Hello')
|
||||
})
|
||||
})
|
||||
|
||||
describe('isTextarea', () => {
|
||||
it('returns true for textarea elements', () => {
|
||||
const wrapper = createWrapper()
|
||||
const vm = wrapper.vm as unknown as { isTextarea: (el: HTMLElement) => boolean }
|
||||
|
||||
const textarea = document.createElement('textarea')
|
||||
expect(vm.isTextarea(textarea)).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false for div elements', () => {
|
||||
const wrapper = createWrapper()
|
||||
const vm = wrapper.vm as unknown as { isTextarea: (el: HTMLElement) => boolean }
|
||||
|
||||
const div = document.createElement('div')
|
||||
expect(vm.isTextarea(div)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('insertBBCode', () => {
|
||||
it('does nothing when textareaRef is null', async () => {
|
||||
const wrapper = createWrapper({ textareaRef: null })
|
||||
const vm = wrapper.vm as unknown as {
|
||||
insertBBCode: (button: { tag: string; template: string }) => Promise<void>
|
||||
}
|
||||
|
||||
await vm.insertBBCode({ tag: 'b', template: '[b]{text}[/b]' })
|
||||
|
||||
expect(wrapper.emitted('insert')).toBeFalsy()
|
||||
})
|
||||
|
||||
it('emits insert event with new text for simple BBCode', async () => {
|
||||
const textarea = document.createElement('textarea')
|
||||
textarea.value = 'Hello world'
|
||||
textarea.selectionStart = 0
|
||||
textarea.selectionEnd = 5
|
||||
|
||||
const wrapper = createWrapper({ textareaRef: textarea })
|
||||
const vm = wrapper.vm as unknown as {
|
||||
insertBBCode: (button: { tag: string; template: string; label: string }) => Promise<void>
|
||||
}
|
||||
|
||||
await vm.insertBBCode({ tag: 'b', template: '[b]{text}[/b]', label: 'Bold' })
|
||||
|
||||
expect(wrapper.emitted('insert')).toBeTruthy()
|
||||
const emitted = wrapper.emitted('insert')![0]![0] as { text: string; cursorPos: number }
|
||||
expect(emitted.text).toBe('[b]Hello[/b] world')
|
||||
})
|
||||
|
||||
it('prompts for value when button has hasValue', async () => {
|
||||
const mockPrompt = vi.fn().mockReturnValue('red')
|
||||
vi.stubGlobal('prompt', mockPrompt)
|
||||
|
||||
const textarea = document.createElement('textarea')
|
||||
textarea.value = 'Hello'
|
||||
textarea.selectionStart = 0
|
||||
textarea.selectionEnd = 5
|
||||
|
||||
const wrapper = createWrapper({ textareaRef: textarea })
|
||||
const vm = wrapper.vm as unknown as {
|
||||
insertBBCode: (button: {
|
||||
tag: string
|
||||
template: string
|
||||
label: string
|
||||
hasValue: boolean
|
||||
placeholder: string
|
||||
}) => Promise<void>
|
||||
}
|
||||
|
||||
await vm.insertBBCode({
|
||||
tag: 'color',
|
||||
template: '[color={value}]{text}[/color]',
|
||||
label: 'Color',
|
||||
hasValue: true,
|
||||
placeholder: 'red',
|
||||
})
|
||||
|
||||
expect(mockPrompt).toHaveBeenCalled()
|
||||
expect(wrapper.emitted('insert')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('does nothing when prompt is cancelled for hasValue button', async () => {
|
||||
const mockPrompt = vi.fn().mockReturnValue(null)
|
||||
vi.stubGlobal('prompt', mockPrompt)
|
||||
|
||||
const textarea = document.createElement('textarea')
|
||||
textarea.value = 'Hello'
|
||||
textarea.selectionStart = 0
|
||||
textarea.selectionEnd = 5
|
||||
|
||||
const wrapper = createWrapper({ textareaRef: textarea })
|
||||
const vm = wrapper.vm as unknown as {
|
||||
insertBBCode: (button: {
|
||||
tag: string
|
||||
template: string
|
||||
label: string
|
||||
hasValue: boolean
|
||||
placeholder: string
|
||||
}) => Promise<void>
|
||||
}
|
||||
|
||||
await vm.insertBBCode({
|
||||
tag: 'color',
|
||||
template: '[color={value}]{text}[/color]',
|
||||
label: 'Color',
|
||||
hasValue: true,
|
||||
placeholder: 'red',
|
||||
})
|
||||
|
||||
expect(wrapper.emitted('insert')).toBeFalsy()
|
||||
})
|
||||
|
||||
it('prompts for content when no selection and promptForContent is true', async () => {
|
||||
const mockPrompt = vi.fn().mockReturnValue('http://example.com/image.png')
|
||||
vi.stubGlobal('prompt', mockPrompt)
|
||||
|
||||
const textarea = document.createElement('textarea')
|
||||
textarea.value = ''
|
||||
textarea.selectionStart = 0
|
||||
textarea.selectionEnd = 0
|
||||
|
||||
const wrapper = createWrapper({ textareaRef: textarea })
|
||||
const vm = wrapper.vm as unknown as {
|
||||
insertBBCode: (button: {
|
||||
tag: string
|
||||
template: string
|
||||
label: string
|
||||
promptForContent: boolean
|
||||
contentPlaceholder: string
|
||||
}) => Promise<void>
|
||||
}
|
||||
|
||||
await vm.insertBBCode({
|
||||
tag: 'img',
|
||||
template: '[img]{text}[/img]',
|
||||
label: 'Image',
|
||||
promptForContent: true,
|
||||
contentPlaceholder: 'http://example.com/image.png',
|
||||
})
|
||||
|
||||
expect(mockPrompt).toHaveBeenCalled()
|
||||
expect(wrapper.emitted('insert')).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
describe('handleEmojiSelect', () => {
|
||||
it('emits insert event with emoji', async () => {
|
||||
const textarea = document.createElement('textarea')
|
||||
textarea.value = 'Hello '
|
||||
textarea.selectionStart = 6
|
||||
textarea.selectionEnd = 6
|
||||
|
||||
const wrapper = createWrapper({ textareaRef: textarea })
|
||||
const vm = wrapper.vm as unknown as { handleEmojiSelect: (emoji: string) => void }
|
||||
|
||||
vm.handleEmojiSelect('😀')
|
||||
|
||||
expect(wrapper.emitted('insert')).toBeTruthy()
|
||||
const emitted = wrapper.emitted('insert')![0]![0] as { text: string; cursorPos: number }
|
||||
expect(emitted.text).toBe('Hello 😀')
|
||||
expect(emitted.cursorPos).toBe(8) // After emoji
|
||||
})
|
||||
|
||||
it('does nothing when textareaRef is null', () => {
|
||||
const wrapper = createWrapper({ textareaRef: null })
|
||||
const vm = wrapper.vm as unknown as { handleEmojiSelect: (emoji: string) => void }
|
||||
|
||||
vm.handleEmojiSelect('😀')
|
||||
|
||||
expect(wrapper.emitted('insert')).toBeFalsy()
|
||||
})
|
||||
})
|
||||
|
||||
describe('upload dialog', () => {
|
||||
it('initializes with upload dialog closed', () => {
|
||||
const wrapper = createWrapper()
|
||||
const vm = wrapper.vm as unknown as { uploadDialog: boolean }
|
||||
expect(vm.uploadDialog).toBe(false)
|
||||
})
|
||||
|
||||
it('closeUploadDialog resets upload state', () => {
|
||||
const wrapper = createWrapper()
|
||||
const vm = wrapper.vm as unknown as {
|
||||
uploadDialog: boolean
|
||||
uploadProgress: number
|
||||
uploadFileName: string
|
||||
uploadError: string | null
|
||||
closeUploadDialog: () => void
|
||||
}
|
||||
|
||||
vm.uploadDialog = true
|
||||
vm.uploadProgress = 50
|
||||
vm.uploadFileName = 'test.pdf'
|
||||
vm.uploadError = 'Some error'
|
||||
|
||||
vm.closeUploadDialog()
|
||||
|
||||
expect(vm.uploadDialog).toBe(false)
|
||||
expect(vm.uploadProgress).toBe(0)
|
||||
expect(vm.uploadFileName).toBe('')
|
||||
expect(vm.uploadError).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('strings', () => {
|
||||
it('has correct translation keys', () => {
|
||||
const wrapper = createWrapper()
|
||||
const vm = wrapper.vm as unknown as { strings: Record<string, string> }
|
||||
|
||||
expect(vm.strings.helpLabel).toBe('BBCode help')
|
||||
expect(vm.strings.emojiLabel).toBe('Insert emoji')
|
||||
expect(vm.strings.attachmentLabel).toBe('Attachment')
|
||||
expect(vm.strings.pickFileLabel).toBe('Pick file from Nextcloud')
|
||||
expect(vm.strings.uploadFileLabel).toBe('Upload file to Nextcloud')
|
||||
})
|
||||
})
|
||||
})
|
||||
97
src/components/CategoryCard/CategoryCard.test.ts
Normal file
97
src/components/CategoryCard/CategoryCard.test.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import CategoryCard from './CategoryCard.vue'
|
||||
import { createMockCategory } from '@/test-mocks'
|
||||
|
||||
// Uses global mock for @nextcloud/l10n from test-setup.ts
|
||||
|
||||
describe('CategoryCard', () => {
|
||||
describe('rendering', () => {
|
||||
it('should render category name', () => {
|
||||
const category = createMockCategory({ name: 'General Discussion' })
|
||||
const wrapper = mount(CategoryCard, {
|
||||
props: { category },
|
||||
})
|
||||
expect(wrapper.find('.category-name').text()).toBe('General Discussion')
|
||||
})
|
||||
|
||||
it('should render category description', () => {
|
||||
const category = createMockCategory({ description: 'Talk about anything' })
|
||||
const wrapper = mount(CategoryCard, {
|
||||
props: { category },
|
||||
})
|
||||
expect(wrapper.find('.category-description').text()).toBe('Talk about anything')
|
||||
})
|
||||
|
||||
it('should render placeholder when no description', () => {
|
||||
const category = createMockCategory({ description: null })
|
||||
const wrapper = mount(CategoryCard, {
|
||||
props: { category },
|
||||
})
|
||||
expect(wrapper.find('.category-description').text()).toBe('No description available')
|
||||
expect(wrapper.find('.category-description').classes()).toContain('muted')
|
||||
})
|
||||
})
|
||||
|
||||
describe('stats', () => {
|
||||
it('should display thread count', () => {
|
||||
const category = createMockCategory({ threadCount: 25 })
|
||||
const wrapper = mount(CategoryCard, {
|
||||
props: { category },
|
||||
})
|
||||
const stats = wrapper.findAll('.stat-value')
|
||||
expect(stats[0]!.text()).toBe('25')
|
||||
})
|
||||
|
||||
it('should display post count', () => {
|
||||
const category = createMockCategory({ postCount: 150 })
|
||||
const wrapper = mount(CategoryCard, {
|
||||
props: { category },
|
||||
})
|
||||
const stats = wrapper.findAll('.stat-value')
|
||||
expect(stats[1]!.text()).toBe('150')
|
||||
})
|
||||
|
||||
it('should handle zero counts', () => {
|
||||
const category = createMockCategory({ threadCount: 0, postCount: 0 })
|
||||
const wrapper = mount(CategoryCard, {
|
||||
props: { category },
|
||||
})
|
||||
const stats = wrapper.findAll('.stat-value')
|
||||
expect(stats[0]!.text()).toBe('0')
|
||||
expect(stats[1]!.text()).toBe('0')
|
||||
})
|
||||
|
||||
it('should handle undefined counts as zero', () => {
|
||||
const category = createMockCategory()
|
||||
// @ts-expect-error Testing undefined case
|
||||
category.threadCount = undefined
|
||||
// @ts-expect-error Testing undefined case
|
||||
category.postCount = undefined
|
||||
const wrapper = mount(CategoryCard, {
|
||||
props: { category },
|
||||
})
|
||||
const stats = wrapper.findAll('.stat-value')
|
||||
expect(stats[0]!.text()).toBe('0')
|
||||
expect(stats[1]!.text()).toBe('0')
|
||||
})
|
||||
})
|
||||
|
||||
describe('structure', () => {
|
||||
it('should have correct class', () => {
|
||||
const wrapper = mount(CategoryCard, {
|
||||
props: { category: createMockCategory() },
|
||||
})
|
||||
expect(wrapper.find('.category-card').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('should have header with name and stats', () => {
|
||||
const wrapper = mount(CategoryCard, {
|
||||
props: { category: createMockCategory() },
|
||||
})
|
||||
expect(wrapper.find('.category-header').exists()).toBe(true)
|
||||
expect(wrapper.find('.category-name').exists()).toBe(true)
|
||||
expect(wrapper.find('.category-stats').exists()).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
521
src/components/HeaderEditDialog/HeaderEditDialog.test.ts
Normal file
521
src/components/HeaderEditDialog/HeaderEditDialog.test.ts
Normal file
@@ -0,0 +1,521 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { mount, flushPromises } from '@vue/test-utils'
|
||||
import type { CatHeader } from '@/types'
|
||||
|
||||
// Mock axios
|
||||
vi.mock('@/axios', () => ({
|
||||
ocs: {
|
||||
post: vi.fn(),
|
||||
put: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
// Import after mocks
|
||||
import { ocs } from '@/axios'
|
||||
import HeaderEditDialog from './HeaderEditDialog.vue'
|
||||
|
||||
const mockPost = vi.mocked(ocs.post)
|
||||
const mockPut = vi.mocked(ocs.put)
|
||||
|
||||
describe('HeaderEditDialog', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
const createWrapper = (props = {}) => {
|
||||
return mount(HeaderEditDialog, {
|
||||
props: {
|
||||
open: true,
|
||||
...props,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
describe('rendering', () => {
|
||||
it('renders the dialog when open', () => {
|
||||
const wrapper = createWrapper()
|
||||
expect(wrapper.find('.nc-dialog').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('does not render the dialog when closed', () => {
|
||||
const wrapper = createWrapper({ open: false })
|
||||
expect(wrapper.find('.nc-dialog').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('shows create title when headerId is null', () => {
|
||||
const wrapper = createWrapper({ headerId: null })
|
||||
const vm = wrapper.vm as unknown as { isEditing: boolean }
|
||||
expect(vm.isEditing).toBe(false)
|
||||
})
|
||||
|
||||
it('shows edit title when headerId is provided', () => {
|
||||
const wrapper = createWrapper({ headerId: 1 })
|
||||
const vm = wrapper.vm as unknown as { isEditing: boolean }
|
||||
expect(vm.isEditing).toBe(true)
|
||||
})
|
||||
|
||||
it('renders name field', () => {
|
||||
const wrapper = createWrapper()
|
||||
expect(wrapper.find('.nc-text-field').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('renders description field', () => {
|
||||
const wrapper = createWrapper()
|
||||
expect(wrapper.find('.nc-text-area').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('renders sort order field', () => {
|
||||
const wrapper = createWrapper()
|
||||
const inputs = wrapper.findAll('.nc-text-field')
|
||||
// Name and sort order
|
||||
expect(inputs.length).toBe(2)
|
||||
})
|
||||
|
||||
it('renders cancel button', () => {
|
||||
const wrapper = createWrapper()
|
||||
const buttons = wrapper.findAll('button')
|
||||
expect(buttons.some((b) => b.text() === 'Cancel')).toBe(true)
|
||||
})
|
||||
|
||||
it('renders create button when creating', () => {
|
||||
const wrapper = createWrapper({ headerId: null })
|
||||
const buttons = wrapper.findAll('button')
|
||||
expect(buttons.some((b) => b.text() === 'Create')).toBe(true)
|
||||
})
|
||||
|
||||
it('renders update button when editing', () => {
|
||||
const wrapper = createWrapper({ headerId: 1 })
|
||||
const buttons = wrapper.findAll('button')
|
||||
expect(buttons.some((b) => b.text() === 'Update')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('initial values', () => {
|
||||
it('initializes with empty values when creating', () => {
|
||||
const wrapper = createWrapper({ headerId: null })
|
||||
const vm = wrapper.vm as unknown as {
|
||||
localName: string
|
||||
localDescription: string
|
||||
localSortOrder: number
|
||||
}
|
||||
|
||||
expect(vm.localName).toBe('')
|
||||
expect(vm.localDescription).toBe('')
|
||||
expect(vm.localSortOrder).toBe(0)
|
||||
})
|
||||
|
||||
it('initializes with provided values when editing', () => {
|
||||
const wrapper = createWrapper({
|
||||
headerId: 1,
|
||||
name: 'Test Header',
|
||||
description: 'Test Description',
|
||||
sortOrder: 5,
|
||||
})
|
||||
const vm = wrapper.vm as unknown as {
|
||||
localName: string
|
||||
localDescription: string
|
||||
localSortOrder: number
|
||||
}
|
||||
|
||||
expect(vm.localName).toBe('Test Header')
|
||||
expect(vm.localDescription).toBe('Test Description')
|
||||
expect(vm.localSortOrder).toBe(5)
|
||||
})
|
||||
|
||||
it('resets values when dialog reopens', async () => {
|
||||
const wrapper = createWrapper({
|
||||
headerId: 1,
|
||||
name: 'Original Name',
|
||||
description: 'Original Description',
|
||||
sortOrder: 3,
|
||||
})
|
||||
|
||||
const vm = wrapper.vm as unknown as {
|
||||
localName: string
|
||||
localDescription: string
|
||||
localSortOrder: number
|
||||
}
|
||||
|
||||
// Modify local values
|
||||
vm.localName = 'Modified Name'
|
||||
vm.localDescription = 'Modified Description'
|
||||
vm.localSortOrder = 10
|
||||
|
||||
// Close and reopen
|
||||
await wrapper.setProps({ open: false })
|
||||
await wrapper.setProps({ open: true })
|
||||
|
||||
expect(vm.localName).toBe('Original Name')
|
||||
expect(vm.localDescription).toBe('Original Description')
|
||||
expect(vm.localSortOrder).toBe(3)
|
||||
})
|
||||
})
|
||||
|
||||
describe('validation', () => {
|
||||
it('disables save button when name is empty', () => {
|
||||
const wrapper = createWrapper({ headerId: null, name: '' })
|
||||
const vm = wrapper.vm as unknown as { canSave: boolean }
|
||||
expect(vm.canSave).toBe(false)
|
||||
})
|
||||
|
||||
it('disables save button when name is only whitespace', () => {
|
||||
const wrapper = createWrapper({ headerId: null, name: ' ' })
|
||||
const vm = wrapper.vm as unknown as { canSave: boolean }
|
||||
expect(vm.canSave).toBe(false)
|
||||
})
|
||||
|
||||
it('enables save button when name has content', () => {
|
||||
const wrapper = createWrapper({ headerId: null, name: 'Valid Name' })
|
||||
const vm = wrapper.vm as unknown as { canSave: boolean }
|
||||
expect(vm.canSave).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('creating headers', () => {
|
||||
it('calls ocs.post when creating a new header', async () => {
|
||||
const mockHeader: CatHeader = {
|
||||
id: 1,
|
||||
name: 'New Header',
|
||||
description: 'New Description',
|
||||
sortOrder: 0,
|
||||
createdAt: Date.now(),
|
||||
}
|
||||
mockPost.mockResolvedValue({ data: mockHeader } as never)
|
||||
|
||||
const wrapper = createWrapper({ headerId: null, name: 'New Header' })
|
||||
|
||||
const vm = wrapper.vm as unknown as { handleSave: () => Promise<void> }
|
||||
await vm.handleSave()
|
||||
await flushPromises()
|
||||
|
||||
expect(mockPost).toHaveBeenCalledWith(
|
||||
'/headers',
|
||||
expect.objectContaining({
|
||||
name: 'New Header',
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('emits saved event with new header data', async () => {
|
||||
const mockHeader: CatHeader = {
|
||||
id: 1,
|
||||
name: 'New Header',
|
||||
description: null,
|
||||
sortOrder: 0,
|
||||
createdAt: Date.now(),
|
||||
}
|
||||
mockPost.mockResolvedValue({ data: mockHeader } as never)
|
||||
|
||||
const wrapper = createWrapper({ headerId: null, name: 'New Header' })
|
||||
|
||||
const vm = wrapper.vm as unknown as { handleSave: () => Promise<void> }
|
||||
await vm.handleSave()
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.emitted('saved')).toBeTruthy()
|
||||
expect(wrapper.emitted('saved')![0]).toEqual([mockHeader])
|
||||
})
|
||||
|
||||
it('closes dialog after successful create', async () => {
|
||||
const mockHeader: CatHeader = {
|
||||
id: 1,
|
||||
name: 'New Header',
|
||||
description: null,
|
||||
sortOrder: 0,
|
||||
createdAt: Date.now(),
|
||||
}
|
||||
mockPost.mockResolvedValue({ data: mockHeader } as never)
|
||||
|
||||
const wrapper = createWrapper({ headerId: null, name: 'New Header' })
|
||||
|
||||
const vm = wrapper.vm as unknown as { handleSave: () => Promise<void> }
|
||||
await vm.handleSave()
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.emitted('update:open')).toBeTruthy()
|
||||
expect(wrapper.emitted('update:open')![0]).toEqual([false])
|
||||
})
|
||||
})
|
||||
|
||||
describe('updating headers', () => {
|
||||
it('calls ocs.put when updating an existing header', async () => {
|
||||
const mockHeader: CatHeader = {
|
||||
id: 5,
|
||||
name: 'Updated Header',
|
||||
description: 'Updated Description',
|
||||
sortOrder: 2,
|
||||
createdAt: Date.now(),
|
||||
}
|
||||
mockPut.mockResolvedValue({ data: mockHeader } as never)
|
||||
|
||||
const wrapper = createWrapper({
|
||||
headerId: 5,
|
||||
name: 'Updated Header',
|
||||
description: 'Updated Description',
|
||||
sortOrder: 2,
|
||||
})
|
||||
|
||||
const vm = wrapper.vm as unknown as { handleSave: () => Promise<void> }
|
||||
await vm.handleSave()
|
||||
await flushPromises()
|
||||
|
||||
expect(mockPut).toHaveBeenCalledWith(
|
||||
'/headers/5',
|
||||
expect.objectContaining({
|
||||
name: 'Updated Header',
|
||||
description: 'Updated Description',
|
||||
sortOrder: 2,
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('emits saved event with updated header data', async () => {
|
||||
const mockHeader: CatHeader = {
|
||||
id: 5,
|
||||
name: 'Updated Header',
|
||||
description: 'Updated Description',
|
||||
sortOrder: 2,
|
||||
createdAt: Date.now(),
|
||||
}
|
||||
mockPut.mockResolvedValue({ data: mockHeader } as never)
|
||||
|
||||
const wrapper = createWrapper({
|
||||
headerId: 5,
|
||||
name: 'Updated Header',
|
||||
})
|
||||
|
||||
const vm = wrapper.vm as unknown as { handleSave: () => Promise<void> }
|
||||
await vm.handleSave()
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.emitted('saved')).toBeTruthy()
|
||||
expect(wrapper.emitted('saved')![0]).toEqual([mockHeader])
|
||||
})
|
||||
})
|
||||
|
||||
describe('error handling', () => {
|
||||
it('logs error when save fails', async () => {
|
||||
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
mockPost.mockRejectedValue(new Error('Network error'))
|
||||
|
||||
const wrapper = createWrapper({ headerId: null, name: 'New Header' })
|
||||
|
||||
const vm = wrapper.vm as unknown as { handleSave: () => Promise<void> }
|
||||
await vm.handleSave()
|
||||
await flushPromises()
|
||||
|
||||
expect(consoleError).toHaveBeenCalled()
|
||||
consoleError.mockRestore()
|
||||
})
|
||||
|
||||
it('does not close dialog when save fails', async () => {
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
mockPost.mockRejectedValue(new Error('Network error'))
|
||||
|
||||
const wrapper = createWrapper({ headerId: null, name: 'New Header' })
|
||||
|
||||
const vm = wrapper.vm as unknown as { handleSave: () => Promise<void> }
|
||||
await vm.handleSave()
|
||||
await flushPromises()
|
||||
|
||||
// Should not emit update:open on failure
|
||||
expect(wrapper.emitted('update:open')).toBeFalsy()
|
||||
})
|
||||
|
||||
it('resets submitting state after error', async () => {
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
mockPost.mockRejectedValue(new Error('Network error'))
|
||||
|
||||
const wrapper = createWrapper({ headerId: null, name: 'New Header' })
|
||||
|
||||
const vm = wrapper.vm as unknown as {
|
||||
handleSave: () => Promise<void>
|
||||
submitting: boolean
|
||||
}
|
||||
await vm.handleSave()
|
||||
await flushPromises()
|
||||
|
||||
expect(vm.submitting).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('close handling', () => {
|
||||
it('emits update:open when cancel button is clicked', async () => {
|
||||
const wrapper = createWrapper()
|
||||
|
||||
const cancelButton = wrapper.findAll('button').find((b) => b.text() === 'Cancel')
|
||||
await cancelButton!.trigger('click')
|
||||
|
||||
expect(wrapper.emitted('update:open')).toBeTruthy()
|
||||
expect(wrapper.emitted('update:open')![0]).toEqual([false])
|
||||
})
|
||||
|
||||
it('does not close when submitting', async () => {
|
||||
let resolvePromise: (value: unknown) => void
|
||||
mockPost.mockImplementation(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
resolvePromise = resolve
|
||||
}) as never,
|
||||
)
|
||||
|
||||
const wrapper = createWrapper({ headerId: null, name: 'New Header' })
|
||||
|
||||
// Start submitting
|
||||
const vm = wrapper.vm as unknown as {
|
||||
handleSave: () => Promise<void>
|
||||
handleClose: () => void
|
||||
}
|
||||
vm.handleSave() // Don't await
|
||||
|
||||
await flushPromises()
|
||||
|
||||
// Try to close while submitting
|
||||
vm.handleClose()
|
||||
|
||||
// Should not emit close event
|
||||
expect(wrapper.emitted('update:open')).toBeFalsy()
|
||||
|
||||
// Clean up
|
||||
resolvePromise!({ data: {} })
|
||||
await flushPromises()
|
||||
})
|
||||
})
|
||||
|
||||
describe('data transformation', () => {
|
||||
it('trims name before sending', async () => {
|
||||
const mockHeader: CatHeader = {
|
||||
id: 1,
|
||||
name: 'Trimmed Name',
|
||||
description: null,
|
||||
sortOrder: 0,
|
||||
createdAt: Date.now(),
|
||||
}
|
||||
mockPost.mockResolvedValue({ data: mockHeader } as never)
|
||||
|
||||
const wrapper = createWrapper({ headerId: null, name: ' Trimmed Name ' })
|
||||
|
||||
const vm = wrapper.vm as unknown as { handleSave: () => Promise<void> }
|
||||
await vm.handleSave()
|
||||
await flushPromises()
|
||||
|
||||
expect(mockPost).toHaveBeenCalledWith(
|
||||
'/headers',
|
||||
expect.objectContaining({
|
||||
name: 'Trimmed Name',
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('trims description before sending', async () => {
|
||||
const mockHeader: CatHeader = {
|
||||
id: 1,
|
||||
name: 'Header',
|
||||
description: 'Trimmed Description',
|
||||
sortOrder: 0,
|
||||
createdAt: Date.now(),
|
||||
}
|
||||
mockPost.mockResolvedValue({ data: mockHeader } as never)
|
||||
|
||||
const wrapper = createWrapper({
|
||||
headerId: null,
|
||||
name: 'Header',
|
||||
description: ' Trimmed Description ',
|
||||
})
|
||||
|
||||
const vm = wrapper.vm as unknown as { handleSave: () => Promise<void> }
|
||||
await vm.handleSave()
|
||||
await flushPromises()
|
||||
|
||||
expect(mockPost).toHaveBeenCalledWith(
|
||||
'/headers',
|
||||
expect.objectContaining({
|
||||
description: 'Trimmed Description',
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('sends null for empty description', async () => {
|
||||
const mockHeader: CatHeader = {
|
||||
id: 1,
|
||||
name: 'Header',
|
||||
description: null,
|
||||
sortOrder: 0,
|
||||
createdAt: Date.now(),
|
||||
}
|
||||
mockPost.mockResolvedValue({ data: mockHeader } as never)
|
||||
|
||||
const wrapper = createWrapper({
|
||||
headerId: null,
|
||||
name: 'Header',
|
||||
description: '',
|
||||
})
|
||||
|
||||
const vm = wrapper.vm as unknown as { handleSave: () => Promise<void> }
|
||||
await vm.handleSave()
|
||||
await flushPromises()
|
||||
|
||||
expect(mockPost).toHaveBeenCalledWith(
|
||||
'/headers',
|
||||
expect.objectContaining({
|
||||
description: null,
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('reset method', () => {
|
||||
it('resets all local values', () => {
|
||||
const wrapper = createWrapper({
|
||||
headerId: 1,
|
||||
name: 'Header',
|
||||
description: 'Description',
|
||||
sortOrder: 5,
|
||||
})
|
||||
|
||||
const vm = wrapper.vm as unknown as {
|
||||
localName: string
|
||||
localDescription: string
|
||||
localSortOrder: number
|
||||
submitting: boolean
|
||||
reset: () => void
|
||||
}
|
||||
|
||||
vm.reset()
|
||||
|
||||
expect(vm.localName).toBe('')
|
||||
expect(vm.localDescription).toBe('')
|
||||
expect(vm.localSortOrder).toBe(0)
|
||||
expect(vm.submitting).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('prop watchers', () => {
|
||||
it('updates localName when name prop changes', async () => {
|
||||
const wrapper = createWrapper({ name: 'Initial' })
|
||||
|
||||
await wrapper.setProps({ name: 'Updated' })
|
||||
|
||||
const vm = wrapper.vm as unknown as { localName: string }
|
||||
expect(vm.localName).toBe('Updated')
|
||||
})
|
||||
|
||||
it('updates localDescription when description prop changes', async () => {
|
||||
const wrapper = createWrapper({ description: 'Initial' })
|
||||
|
||||
await wrapper.setProps({ description: 'Updated' })
|
||||
|
||||
const vm = wrapper.vm as unknown as { localDescription: string }
|
||||
expect(vm.localDescription).toBe('Updated')
|
||||
})
|
||||
|
||||
it('updates localSortOrder when sortOrder prop changes', async () => {
|
||||
const wrapper = createWrapper({ sortOrder: 1 })
|
||||
|
||||
await wrapper.setProps({ sortOrder: 10 })
|
||||
|
||||
const vm = wrapper.vm as unknown as { localSortOrder: number }
|
||||
expect(vm.localSortOrder).toBe(10)
|
||||
})
|
||||
})
|
||||
})
|
||||
536
src/components/MoveCategoryDialog/MoveCategoryDialog.test.ts
Normal file
536
src/components/MoveCategoryDialog/MoveCategoryDialog.test.ts
Normal file
@@ -0,0 +1,536 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { mount, flushPromises } from '@vue/test-utils'
|
||||
import { ref } from 'vue'
|
||||
import type { CategoryHeader, Category } from '@/types'
|
||||
|
||||
// Mock useCategories composable
|
||||
const mockFetchCategories = vi.fn()
|
||||
const mockCategoryHeaders = ref<CategoryHeader[]>([])
|
||||
vi.mock('@/composables/useCategories', () => ({
|
||||
useCategories: () => ({
|
||||
categoryHeaders: mockCategoryHeaders,
|
||||
fetchCategories: mockFetchCategories,
|
||||
}),
|
||||
}))
|
||||
|
||||
// Import after mocks
|
||||
import MoveCategoryDialog from './MoveCategoryDialog.vue'
|
||||
|
||||
describe('MoveCategoryDialog', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockCategoryHeaders.value = []
|
||||
mockFetchCategories.mockResolvedValue([])
|
||||
})
|
||||
|
||||
const createMockCategory = (overrides: Partial<Category> = {}): Category => ({
|
||||
id: 1,
|
||||
headerId: 1,
|
||||
name: 'Test Category',
|
||||
description: null,
|
||||
slug: 'test-category',
|
||||
sortOrder: 0,
|
||||
threadCount: 0,
|
||||
postCount: 0,
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const createMockHeader = (overrides: Partial<CategoryHeader> = {}): CategoryHeader => ({
|
||||
id: 1,
|
||||
name: 'Test Header',
|
||||
description: null,
|
||||
sortOrder: 0,
|
||||
createdAt: Date.now(),
|
||||
categories: [],
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const createWrapper = (props = {}) => {
|
||||
return mount(MoveCategoryDialog, {
|
||||
props: {
|
||||
open: true,
|
||||
currentCategoryId: 1,
|
||||
...props,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
describe('rendering', () => {
|
||||
it('renders the dialog when open', () => {
|
||||
const wrapper = createWrapper()
|
||||
expect(wrapper.find('.nc-dialog').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('does not render the dialog when closed', () => {
|
||||
const wrapper = createWrapper({ open: false })
|
||||
expect(wrapper.find('.nc-dialog').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('displays the correct title', () => {
|
||||
const wrapper = createWrapper()
|
||||
const vm = wrapper.vm as unknown as { strings: { title: string } }
|
||||
expect(vm.strings.title).toBe('Move thread to category')
|
||||
})
|
||||
|
||||
it('displays description text', () => {
|
||||
const wrapper = createWrapper()
|
||||
expect(wrapper.text()).toContain('Select the category to move this thread to')
|
||||
})
|
||||
|
||||
it('renders cancel button', () => {
|
||||
const wrapper = createWrapper()
|
||||
const buttons = wrapper.findAll('button')
|
||||
expect(buttons.some((b) => b.text() === 'Cancel')).toBe(true)
|
||||
})
|
||||
|
||||
it('renders move button', () => {
|
||||
const wrapper = createWrapper()
|
||||
const buttons = wrapper.findAll('button')
|
||||
expect(buttons.some((b) => b.text() === 'Move')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('loading state', () => {
|
||||
it('shows loading state while fetching categories', async () => {
|
||||
let resolvePromise: (value: unknown) => void
|
||||
mockFetchCategories.mockImplementation(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
resolvePromise = resolve
|
||||
}),
|
||||
)
|
||||
|
||||
const wrapper = createWrapper({ open: true })
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.find('.loading-state').exists()).toBe(true)
|
||||
expect(wrapper.text()).toContain('Loading categories')
|
||||
|
||||
resolvePromise!(undefined)
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.find('.loading-state').exists()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('error state', () => {
|
||||
it('displays error state when fetch fails', async () => {
|
||||
mockFetchCategories.mockRejectedValue(new Error('Network error'))
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
|
||||
const wrapper = createWrapper({ open: true })
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.find('.error-state').exists()).toBe(true)
|
||||
expect(wrapper.text()).toContain('Failed to load categories')
|
||||
})
|
||||
})
|
||||
|
||||
describe('category options', () => {
|
||||
it('creates category options from headers', async () => {
|
||||
mockCategoryHeaders.value = [
|
||||
createMockHeader({
|
||||
id: 1,
|
||||
name: 'Header 1',
|
||||
categories: [
|
||||
createMockCategory({ id: 10, name: 'Category A' }),
|
||||
createMockCategory({ id: 11, name: 'Category B' }),
|
||||
],
|
||||
}),
|
||||
]
|
||||
|
||||
const wrapper = createWrapper({ open: true })
|
||||
await flushPromises()
|
||||
|
||||
const vm = wrapper.vm as unknown as {
|
||||
categoryOptions: Array<{ id: number; name: string; isHeader?: boolean }>
|
||||
}
|
||||
|
||||
// Should have 1 header + 2 categories
|
||||
expect(vm.categoryOptions.length).toBe(3)
|
||||
})
|
||||
|
||||
it('marks headers with negative IDs', async () => {
|
||||
mockCategoryHeaders.value = [
|
||||
createMockHeader({
|
||||
id: 5,
|
||||
name: 'Header',
|
||||
categories: [createMockCategory({ id: 10, name: 'Category' })],
|
||||
}),
|
||||
]
|
||||
|
||||
const wrapper = createWrapper({ open: true })
|
||||
await flushPromises()
|
||||
|
||||
const vm = wrapper.vm as unknown as {
|
||||
categoryOptions: Array<{ id: number; name: string; isHeader?: boolean }>
|
||||
}
|
||||
|
||||
const headerOption = vm.categoryOptions.find((o) => o.isHeader)
|
||||
expect(headerOption).toBeDefined()
|
||||
expect(headerOption!.id).toBe(-5) // Negative of header ID
|
||||
})
|
||||
|
||||
it('marks categories with isHeader false', async () => {
|
||||
mockCategoryHeaders.value = [
|
||||
createMockHeader({
|
||||
id: 1,
|
||||
name: 'Header',
|
||||
categories: [createMockCategory({ id: 10, name: 'Category' })],
|
||||
}),
|
||||
]
|
||||
|
||||
const wrapper = createWrapper({ open: true })
|
||||
await flushPromises()
|
||||
|
||||
const vm = wrapper.vm as unknown as {
|
||||
categoryOptions: Array<{ id: number; name: string; isHeader?: boolean }>
|
||||
}
|
||||
|
||||
const categoryOption = vm.categoryOptions.find((o) => !o.isHeader)
|
||||
expect(categoryOption).toBeDefined()
|
||||
expect(categoryOption!.isHeader).toBe(false)
|
||||
})
|
||||
|
||||
it('indents category names with spaces', async () => {
|
||||
mockCategoryHeaders.value = [
|
||||
createMockHeader({
|
||||
id: 1,
|
||||
name: 'Header',
|
||||
categories: [createMockCategory({ id: 10, name: 'Category' })],
|
||||
}),
|
||||
]
|
||||
|
||||
const wrapper = createWrapper({ open: true })
|
||||
await flushPromises()
|
||||
|
||||
const vm = wrapper.vm as unknown as {
|
||||
categoryOptions: Array<{ id: number; name: string; isHeader?: boolean }>
|
||||
}
|
||||
|
||||
const categoryOption = vm.categoryOptions.find((o) => !o.isHeader)
|
||||
expect(categoryOption!.name).toBe(' Category') // Two spaces prefix
|
||||
})
|
||||
|
||||
it('excludes headers with no categories', async () => {
|
||||
mockCategoryHeaders.value = [
|
||||
createMockHeader({ id: 1, name: 'Empty Header', categories: [] }),
|
||||
createMockHeader({
|
||||
id: 2,
|
||||
name: 'Header with Categories',
|
||||
categories: [createMockCategory({ id: 10, name: 'Category' })],
|
||||
}),
|
||||
]
|
||||
|
||||
const wrapper = createWrapper({ open: true })
|
||||
await flushPromises()
|
||||
|
||||
const vm = wrapper.vm as unknown as {
|
||||
categoryOptions: Array<{ id: number; name: string; isHeader?: boolean }>
|
||||
}
|
||||
|
||||
// Should only have header 2 and its category
|
||||
expect(vm.categoryOptions.length).toBe(2)
|
||||
expect(vm.categoryOptions.some((o) => o.name === 'Empty Header')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('validation warnings', () => {
|
||||
it('shows error when header is selected', async () => {
|
||||
mockCategoryHeaders.value = [
|
||||
createMockHeader({
|
||||
id: 1,
|
||||
name: 'Header',
|
||||
categories: [createMockCategory({ id: 10, name: 'Category' })],
|
||||
}),
|
||||
]
|
||||
|
||||
const wrapper = createWrapper({ open: true })
|
||||
await flushPromises()
|
||||
|
||||
// Select a header
|
||||
const vm = wrapper.vm as unknown as {
|
||||
selectedCategory: { id: number; name: string; isHeader?: boolean } | null
|
||||
}
|
||||
vm.selectedCategory = { id: -1, name: 'Header', isHeader: true }
|
||||
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('Cannot move to a category header')
|
||||
})
|
||||
|
||||
it('shows warning when same category is selected', async () => {
|
||||
mockCategoryHeaders.value = [
|
||||
createMockHeader({
|
||||
id: 1,
|
||||
name: 'Header',
|
||||
categories: [createMockCategory({ id: 10, name: 'Category' })],
|
||||
}),
|
||||
]
|
||||
|
||||
const wrapper = createWrapper({ open: true, currentCategoryId: 10 })
|
||||
await flushPromises()
|
||||
|
||||
// Select the same category
|
||||
const vm = wrapper.vm as unknown as {
|
||||
selectedCategory: { id: number; name: string; isHeader?: boolean } | null
|
||||
}
|
||||
vm.selectedCategory = { id: 10, name: 'Category', isHeader: false }
|
||||
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('This thread is already in this category')
|
||||
})
|
||||
})
|
||||
|
||||
describe('move button state', () => {
|
||||
it('disables move button when no category is selected', async () => {
|
||||
mockCategoryHeaders.value = [
|
||||
createMockHeader({
|
||||
id: 1,
|
||||
name: 'Header',
|
||||
categories: [createMockCategory({ id: 10, name: 'Category' })],
|
||||
}),
|
||||
]
|
||||
|
||||
const wrapper = createWrapper({ open: true })
|
||||
await flushPromises()
|
||||
|
||||
const moveButton = wrapper.findAll('button').find((b) => b.text() === 'Move')
|
||||
expect(moveButton!.attributes('disabled')).toBeDefined()
|
||||
})
|
||||
|
||||
it('disables move button when header is selected', async () => {
|
||||
mockCategoryHeaders.value = [
|
||||
createMockHeader({
|
||||
id: 1,
|
||||
name: 'Header',
|
||||
categories: [createMockCategory({ id: 10, name: 'Category' })],
|
||||
}),
|
||||
]
|
||||
|
||||
const wrapper = createWrapper({ open: true })
|
||||
await flushPromises()
|
||||
|
||||
const vm = wrapper.vm as unknown as {
|
||||
selectedCategory: { id: number; name: string; isHeader?: boolean } | null
|
||||
}
|
||||
vm.selectedCategory = { id: -1, name: 'Header', isHeader: true }
|
||||
|
||||
await flushPromises()
|
||||
|
||||
const moveButton = wrapper.findAll('button').find((b) => b.text() === 'Move')
|
||||
expect(moveButton!.attributes('disabled')).toBeDefined()
|
||||
})
|
||||
|
||||
it('disables move button when same category is selected', async () => {
|
||||
mockCategoryHeaders.value = [
|
||||
createMockHeader({
|
||||
id: 1,
|
||||
name: 'Header',
|
||||
categories: [createMockCategory({ id: 10, name: 'Category' })],
|
||||
}),
|
||||
]
|
||||
|
||||
const wrapper = createWrapper({ open: true, currentCategoryId: 10 })
|
||||
await flushPromises()
|
||||
|
||||
const vm = wrapper.vm as unknown as {
|
||||
selectedCategory: { id: number; name: string; isHeader?: boolean } | null
|
||||
}
|
||||
vm.selectedCategory = { id: 10, name: 'Category', isHeader: false }
|
||||
|
||||
await flushPromises()
|
||||
|
||||
const moveButton = wrapper.findAll('button').find((b) => b.text() === 'Move')
|
||||
expect(moveButton!.attributes('disabled')).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('move action', () => {
|
||||
it('emits move event with selected category ID', async () => {
|
||||
mockCategoryHeaders.value = [
|
||||
createMockHeader({
|
||||
id: 1,
|
||||
name: 'Header',
|
||||
categories: [createMockCategory({ id: 20, name: 'Target Category' })],
|
||||
}),
|
||||
]
|
||||
|
||||
const wrapper = createWrapper({ open: true, currentCategoryId: 10 })
|
||||
await flushPromises()
|
||||
|
||||
const vm = wrapper.vm as unknown as {
|
||||
selectedCategory: { id: number; name: string; isHeader?: boolean } | null
|
||||
handleMove: () => void
|
||||
}
|
||||
vm.selectedCategory = { id: 20, name: 'Target Category', isHeader: false }
|
||||
|
||||
vm.handleMove()
|
||||
|
||||
expect(wrapper.emitted('move')).toBeTruthy()
|
||||
expect(wrapper.emitted('move')![0]).toEqual([20])
|
||||
})
|
||||
|
||||
it('sets moving state when move is triggered', async () => {
|
||||
mockCategoryHeaders.value = [
|
||||
createMockHeader({
|
||||
id: 1,
|
||||
name: 'Header',
|
||||
categories: [createMockCategory({ id: 20, name: 'Category' })],
|
||||
}),
|
||||
]
|
||||
|
||||
const wrapper = createWrapper({ open: true, currentCategoryId: 10 })
|
||||
await flushPromises()
|
||||
|
||||
const vm = wrapper.vm as unknown as {
|
||||
selectedCategory: { id: number; name: string; isHeader?: boolean } | null
|
||||
handleMove: () => void
|
||||
moving: boolean
|
||||
}
|
||||
vm.selectedCategory = { id: 20, name: 'Category', isHeader: false }
|
||||
|
||||
vm.handleMove()
|
||||
|
||||
expect(vm.moving).toBe(true)
|
||||
})
|
||||
|
||||
it('does not emit move when header is selected', async () => {
|
||||
mockCategoryHeaders.value = [
|
||||
createMockHeader({
|
||||
id: 1,
|
||||
name: 'Header',
|
||||
categories: [createMockCategory({ id: 20, name: 'Category' })],
|
||||
}),
|
||||
]
|
||||
|
||||
const wrapper = createWrapper({ open: true })
|
||||
await flushPromises()
|
||||
|
||||
const vm = wrapper.vm as unknown as {
|
||||
selectedCategory: { id: number; name: string; isHeader?: boolean } | null
|
||||
handleMove: () => void
|
||||
}
|
||||
vm.selectedCategory = { id: -1, name: 'Header', isHeader: true }
|
||||
|
||||
vm.handleMove()
|
||||
|
||||
expect(wrapper.emitted('move')).toBeFalsy()
|
||||
})
|
||||
})
|
||||
|
||||
describe('close handling', () => {
|
||||
it('emits update:open when cancel button is clicked', async () => {
|
||||
const wrapper = createWrapper()
|
||||
await flushPromises()
|
||||
|
||||
const cancelButton = wrapper.findAll('button').find((b) => b.text() === 'Cancel')
|
||||
await cancelButton!.trigger('click')
|
||||
|
||||
expect(wrapper.emitted('update:open')).toBeTruthy()
|
||||
expect(wrapper.emitted('update:open')![0]).toEqual([false])
|
||||
})
|
||||
|
||||
it('does not close when moving', async () => {
|
||||
mockCategoryHeaders.value = [
|
||||
createMockHeader({
|
||||
id: 1,
|
||||
name: 'Header',
|
||||
categories: [createMockCategory({ id: 20, name: 'Category' })],
|
||||
}),
|
||||
]
|
||||
|
||||
const wrapper = createWrapper({ open: true, currentCategoryId: 10 })
|
||||
await flushPromises()
|
||||
|
||||
const vm = wrapper.vm as unknown as {
|
||||
selectedCategory: { id: number; name: string; isHeader?: boolean } | null
|
||||
handleMove: () => void
|
||||
handleClose: () => void
|
||||
}
|
||||
vm.selectedCategory = { id: 20, name: 'Category', isHeader: false }
|
||||
|
||||
// Start moving
|
||||
vm.handleMove()
|
||||
|
||||
// Try to close
|
||||
vm.handleClose()
|
||||
|
||||
// Should not emit close event
|
||||
expect(wrapper.emitted('update:open')).toBeFalsy()
|
||||
})
|
||||
})
|
||||
|
||||
describe('reset method', () => {
|
||||
it('resets moving and selectedCategory', async () => {
|
||||
mockCategoryHeaders.value = [
|
||||
createMockHeader({
|
||||
id: 1,
|
||||
name: 'Header',
|
||||
categories: [createMockCategory({ id: 20, name: 'Category' })],
|
||||
}),
|
||||
]
|
||||
|
||||
const wrapper = createWrapper({ open: true })
|
||||
await flushPromises()
|
||||
|
||||
const vm = wrapper.vm as unknown as {
|
||||
selectedCategory: { id: number; name: string; isHeader?: boolean } | null
|
||||
moving: boolean
|
||||
reset: () => void
|
||||
}
|
||||
|
||||
vm.selectedCategory = { id: 20, name: 'Category', isHeader: false }
|
||||
vm.moving = true
|
||||
|
||||
vm.reset()
|
||||
|
||||
expect(vm.selectedCategory).toBeNull()
|
||||
expect(vm.moving).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('dialog reopening', () => {
|
||||
it('resets selectedCategory when dialog reopens', async () => {
|
||||
mockCategoryHeaders.value = [
|
||||
createMockHeader({
|
||||
id: 1,
|
||||
name: 'Header',
|
||||
categories: [createMockCategory({ id: 20, name: 'Category' })],
|
||||
}),
|
||||
]
|
||||
|
||||
const wrapper = createWrapper({ open: true })
|
||||
await flushPromises()
|
||||
|
||||
const vm = wrapper.vm as unknown as {
|
||||
selectedCategory: { id: number; name: string; isHeader?: boolean } | null
|
||||
}
|
||||
vm.selectedCategory = { id: 20, name: 'Category', isHeader: false }
|
||||
|
||||
// Close and reopen
|
||||
await wrapper.setProps({ open: false })
|
||||
await wrapper.setProps({ open: true })
|
||||
await flushPromises()
|
||||
|
||||
expect(vm.selectedCategory).toBeNull()
|
||||
})
|
||||
|
||||
it('refetches categories when dialog reopens', async () => {
|
||||
const wrapper = createWrapper({ open: true })
|
||||
await flushPromises()
|
||||
|
||||
expect(mockFetchCategories).toHaveBeenCalledTimes(1)
|
||||
|
||||
// Close and reopen
|
||||
await wrapper.setProps({ open: false })
|
||||
await wrapper.setProps({ open: true })
|
||||
await flushPromises()
|
||||
|
||||
expect(mockFetchCategories).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
})
|
||||
126
src/components/NotFoundPage/NotFoundPage.test.ts
Normal file
126
src/components/NotFoundPage/NotFoundPage.test.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { createIconMock } from '@/test-utils'
|
||||
import NotFoundPage from './NotFoundPage.vue'
|
||||
|
||||
// Uses global mocks for @nextcloud/l10n, @nextcloud/router, NcButton, NcEmptyContent from test-setup.ts
|
||||
|
||||
vi.mock('@icons/ArrowLeft.vue', () => createIconMock('ArrowLeftIcon'))
|
||||
vi.mock('@icons/Home.vue', () => createIconMock('HomeIcon'))
|
||||
vi.mock('@icons/AlertCircle.vue', () => createIconMock('AlertCircleIcon'))
|
||||
|
||||
const mockBack = vi.fn()
|
||||
const mockPush = vi.fn()
|
||||
vi.mock('vue-router', () => ({
|
||||
useRouter: () => ({ back: mockBack, push: mockPush }),
|
||||
}))
|
||||
|
||||
describe('NotFoundPage', () => {
|
||||
beforeEach(() => {
|
||||
mockBack.mockClear()
|
||||
mockPush.mockClear()
|
||||
Object.defineProperty(window, 'history', {
|
||||
value: { length: 5 },
|
||||
writable: true,
|
||||
})
|
||||
})
|
||||
|
||||
describe('rendering', () => {
|
||||
it('should render with default props', () => {
|
||||
const wrapper = mount(NotFoundPage)
|
||||
expect(wrapper.find('.not-found-page').exists()).toBe(true)
|
||||
expect(wrapper.find('.nc-empty-content').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('should display default title', () => {
|
||||
const wrapper = mount(NotFoundPage)
|
||||
expect(wrapper.find('.title').text()).toBe('Page not found')
|
||||
})
|
||||
|
||||
it('should display default description', () => {
|
||||
const wrapper = mount(NotFoundPage)
|
||||
expect(wrapper.find('.description').text()).toBe(
|
||||
'The page you are looking for could not be found.',
|
||||
)
|
||||
})
|
||||
|
||||
it('should display custom title', () => {
|
||||
const wrapper = mount(NotFoundPage, {
|
||||
props: { title: 'Custom Title' },
|
||||
})
|
||||
expect(wrapper.find('.title').text()).toBe('Custom Title')
|
||||
})
|
||||
|
||||
it('should display custom description', () => {
|
||||
const wrapper = mount(NotFoundPage, {
|
||||
props: { description: 'Custom description text' },
|
||||
})
|
||||
expect(wrapper.find('.description').text()).toBe('Custom description text')
|
||||
})
|
||||
})
|
||||
|
||||
describe('buttons', () => {
|
||||
it('should show back button by default', () => {
|
||||
const wrapper = mount(NotFoundPage)
|
||||
const buttons = wrapper.findAll('button')
|
||||
expect(buttons.length).toBeGreaterThanOrEqual(1)
|
||||
expect(wrapper.find('.arrow-left-icon').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('should show home button by default', () => {
|
||||
const wrapper = mount(NotFoundPage)
|
||||
expect(wrapper.find('.home-icon').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('should hide back button when showBackButton is false', () => {
|
||||
const wrapper = mount(NotFoundPage, {
|
||||
props: { showBackButton: false },
|
||||
})
|
||||
expect(wrapper.find('.arrow-left-icon').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('should hide home button when showHomeButton is false', () => {
|
||||
const wrapper = mount(NotFoundPage, {
|
||||
props: { showHomeButton: false },
|
||||
})
|
||||
expect(wrapper.find('.home-icon').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('should have correct home URL', () => {
|
||||
const wrapper = mount(NotFoundPage)
|
||||
const homeButton = wrapper.findAll('button').find((b) => b.find('.home-icon').exists())
|
||||
expect(homeButton?.attributes('href')).toBe('/apps/forum')
|
||||
})
|
||||
})
|
||||
|
||||
describe('navigation', () => {
|
||||
it('should go back when back button is clicked and history exists', async () => {
|
||||
Object.defineProperty(window, 'history', {
|
||||
value: { length: 5 },
|
||||
writable: true,
|
||||
})
|
||||
const wrapper = mount(NotFoundPage)
|
||||
const backButton = wrapper.findAll('button').find((b) => b.find('.arrow-left-icon').exists())
|
||||
await backButton?.trigger('click')
|
||||
expect(mockBack).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should navigate to home when back button is clicked and no history', async () => {
|
||||
Object.defineProperty(window, 'history', {
|
||||
value: { length: 1 },
|
||||
writable: true,
|
||||
})
|
||||
const wrapper = mount(NotFoundPage)
|
||||
const backButton = wrapper.findAll('button').find((b) => b.find('.arrow-left-icon').exists())
|
||||
await backButton?.trigger('click')
|
||||
expect(mockPush).toHaveBeenCalledWith('/')
|
||||
})
|
||||
})
|
||||
|
||||
describe('icon', () => {
|
||||
it('should render default AlertCircle icon', () => {
|
||||
const wrapper = mount(NotFoundPage)
|
||||
expect(wrapper.find('.alert-circle-icon').exists()).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
76
src/components/PageHeader/PageHeader.test.ts
Normal file
76
src/components/PageHeader/PageHeader.test.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { createComponentMock } from '@/test-utils'
|
||||
import PageHeader from './PageHeader.vue'
|
||||
|
||||
vi.mock('@/components/Skeleton', () =>
|
||||
createComponentMock('Skeleton', {
|
||||
template: '<div class="skeleton-mock" :style="{ width, height }"></div>',
|
||||
props: ['width', 'height', 'radius'],
|
||||
}),
|
||||
)
|
||||
|
||||
describe('PageHeader', () => {
|
||||
describe('rendering', () => {
|
||||
it('should render title', () => {
|
||||
const wrapper = mount(PageHeader, {
|
||||
props: { title: 'Test Title' },
|
||||
})
|
||||
expect(wrapper.find('.page-title').text()).toBe('Test Title')
|
||||
})
|
||||
|
||||
it('should render subtitle when provided', () => {
|
||||
const wrapper = mount(PageHeader, {
|
||||
props: { title: 'Title', subtitle: 'Subtitle text' },
|
||||
})
|
||||
expect(wrapper.find('.page-subtitle').exists()).toBe(true)
|
||||
expect(wrapper.find('.page-subtitle').text()).toBe('Subtitle text')
|
||||
})
|
||||
|
||||
it('should not render subtitle when not provided', () => {
|
||||
const wrapper = mount(PageHeader, {
|
||||
props: { title: 'Title' },
|
||||
})
|
||||
expect(wrapper.find('.page-subtitle').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('should not render subtitle when empty string', () => {
|
||||
const wrapper = mount(PageHeader, {
|
||||
props: { title: 'Title', subtitle: '' },
|
||||
})
|
||||
expect(wrapper.find('.page-subtitle').exists()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('loading state', () => {
|
||||
it('should show skeleton loaders when loading', () => {
|
||||
const wrapper = mount(PageHeader, {
|
||||
props: { title: 'Title', loading: true },
|
||||
})
|
||||
expect(wrapper.findAll('.skeleton-mock').length).toBe(2)
|
||||
expect(wrapper.find('.page-title').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('should show content when not loading', () => {
|
||||
const wrapper = mount(PageHeader, {
|
||||
props: { title: 'Title', loading: false },
|
||||
})
|
||||
expect(wrapper.find('.skeleton-mock').exists()).toBe(false)
|
||||
expect(wrapper.find('.page-title').exists()).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('default props', () => {
|
||||
it('should have empty title by default', () => {
|
||||
const wrapper = mount(PageHeader)
|
||||
expect(wrapper.find('.page-title').text()).toBe('')
|
||||
})
|
||||
|
||||
it('should not be loading by default', () => {
|
||||
const wrapper = mount(PageHeader, {
|
||||
props: { title: 'Test' },
|
||||
})
|
||||
expect(wrapper.find('.page-title').exists()).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
68
src/components/PageWrapper/PageWrapper.test.ts
Normal file
68
src/components/PageWrapper/PageWrapper.test.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import PageWrapper from './PageWrapper.vue'
|
||||
|
||||
describe('PageWrapper', () => {
|
||||
describe('rendering', () => {
|
||||
it('should render default slot content', () => {
|
||||
const wrapper = mount(PageWrapper, {
|
||||
slots: {
|
||||
default: '<div class="test-content">Content</div>',
|
||||
},
|
||||
})
|
||||
expect(wrapper.find('.test-content').exists()).toBe(true)
|
||||
expect(wrapper.find('.test-content').text()).toBe('Content')
|
||||
})
|
||||
|
||||
it('should render toolbar slot when provided', () => {
|
||||
const wrapper = mount(PageWrapper, {
|
||||
slots: {
|
||||
toolbar: '<div class="test-toolbar">Toolbar</div>',
|
||||
default: '<div>Content</div>',
|
||||
},
|
||||
})
|
||||
expect(wrapper.find('.page-wrapper-toolbar').exists()).toBe(true)
|
||||
expect(wrapper.find('.test-toolbar').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('should not render toolbar wrapper when slot is empty', () => {
|
||||
const wrapper = mount(PageWrapper, {
|
||||
slots: {
|
||||
default: '<div>Content</div>',
|
||||
},
|
||||
})
|
||||
expect(wrapper.find('.page-wrapper-toolbar').exists()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('fullWidth prop', () => {
|
||||
it('should not have full-width class by default', () => {
|
||||
const wrapper = mount(PageWrapper, {
|
||||
slots: { default: '<div>Content</div>' },
|
||||
})
|
||||
expect(wrapper.find('.page-wrapper-content').classes()).not.toContain('full-width')
|
||||
})
|
||||
|
||||
it('should have full-width class when fullWidth is true', () => {
|
||||
const wrapper = mount(PageWrapper, {
|
||||
props: { fullWidth: true },
|
||||
slots: { default: '<div>Content</div>' },
|
||||
})
|
||||
expect(wrapper.find('.page-wrapper-content').classes()).toContain('full-width')
|
||||
})
|
||||
})
|
||||
|
||||
describe('structure', () => {
|
||||
it('should have correct container structure', () => {
|
||||
const wrapper = mount(PageWrapper, {
|
||||
slots: {
|
||||
toolbar: '<div>Toolbar</div>',
|
||||
default: '<div>Content</div>',
|
||||
},
|
||||
})
|
||||
expect(wrapper.find('.page-wrapper-container').exists()).toBe(true)
|
||||
expect(wrapper.find('.page-wrapper-toolbar').exists()).toBe(true)
|
||||
expect(wrapper.find('.page-wrapper-content').exists()).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,43 +1,12 @@
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { createIconMock } from '@/test-utils'
|
||||
import Pagination from './Pagination.vue'
|
||||
|
||||
// Mock @nextcloud/l10n
|
||||
vi.mock('@nextcloud/l10n', () => ({
|
||||
t: (app: string, text: string, vars?: Record<string, unknown>) => {
|
||||
if (vars) {
|
||||
return Object.entries(vars).reduce(
|
||||
(acc, [key, value]) => acc.replace(`{${key}}`, String(value)),
|
||||
text,
|
||||
)
|
||||
}
|
||||
return text
|
||||
},
|
||||
}))
|
||||
|
||||
// Mock @nextcloud/vue/components/NcButton
|
||||
vi.mock('@nextcloud/vue/components/NcButton', () => ({
|
||||
default: {
|
||||
name: 'NcButton',
|
||||
template:
|
||||
'<button :disabled="disabled" @click="$emit(\'click\')"><slot /><slot name="icon" /></button>',
|
||||
props: ['variant', 'disabled', 'ariaLabel', 'title'],
|
||||
},
|
||||
}))
|
||||
|
||||
// Mock icon components
|
||||
vi.mock('@icons/PageFirst.vue', () => ({
|
||||
default: { name: 'PageFirstIcon', template: '<span>«</span>', props: ['size'] },
|
||||
}))
|
||||
vi.mock('@icons/PageLast.vue', () => ({
|
||||
default: { name: 'PageLastIcon', template: '<span>»</span>', props: ['size'] },
|
||||
}))
|
||||
vi.mock('@icons/ChevronLeft.vue', () => ({
|
||||
default: { name: 'ChevronLeftIcon', template: '<span>‹</span>', props: ['size'] },
|
||||
}))
|
||||
vi.mock('@icons/ChevronRight.vue', () => ({
|
||||
default: { name: 'ChevronRightIcon', template: '<span>›</span>', props: ['size'] },
|
||||
}))
|
||||
vi.mock('@icons/PageFirst.vue', () => createIconMock('PageFirstIcon'))
|
||||
vi.mock('@icons/PageLast.vue', () => createIconMock('PageLastIcon'))
|
||||
vi.mock('@icons/ChevronLeft.vue', () => createIconMock('ChevronLeftIcon'))
|
||||
vi.mock('@icons/ChevronRight.vue', () => createIconMock('ChevronRightIcon'))
|
||||
|
||||
describe('Pagination', () => {
|
||||
describe('visibility', () => {
|
||||
@@ -61,7 +30,6 @@ describe('Pagination', () => {
|
||||
const wrapper = mount(Pagination, {
|
||||
props: { currentPage: 1, maxPages: 5 },
|
||||
})
|
||||
// Access the computed property via vm
|
||||
const pageItems = (wrapper.vm as unknown as { pageItems: (number | 'ellipsis')[] }).pageItems
|
||||
expect(pageItems).toEqual([1, 2, 3, 4, 5])
|
||||
})
|
||||
@@ -79,7 +47,6 @@ describe('Pagination', () => {
|
||||
props: { currentPage: 1, maxPages: 20 },
|
||||
})
|
||||
const pageItems = (wrapper.vm as unknown as { pageItems: (number | 'ellipsis')[] }).pageItems
|
||||
// Should show: 1, 2, 3 (first 3) + ellipsis + 18, 19, 20 (last 3)
|
||||
expect(pageItems).toEqual([1, 2, 3, 'ellipsis', 18, 19, 20])
|
||||
})
|
||||
|
||||
@@ -88,7 +55,6 @@ describe('Pagination', () => {
|
||||
props: { currentPage: 20, maxPages: 20 },
|
||||
})
|
||||
const pageItems = (wrapper.vm as unknown as { pageItems: (number | 'ellipsis')[] }).pageItems
|
||||
// Should show: 1, 2, 3 (first 3) + ellipsis + 18, 19, 20 (last 3)
|
||||
expect(pageItems).toEqual([1, 2, 3, 'ellipsis', 18, 19, 20])
|
||||
})
|
||||
|
||||
@@ -97,7 +63,6 @@ describe('Pagination', () => {
|
||||
props: { currentPage: 10, maxPages: 20 },
|
||||
})
|
||||
const pageItems = (wrapper.vm as unknown as { pageItems: (number | 'ellipsis')[] }).pageItems
|
||||
// Should show: 1, 2, 3 + ellipsis + 8, 9, 10, 11, 12 + ellipsis + 18, 19, 20
|
||||
expect(pageItems).toEqual([1, 2, 3, 'ellipsis', 8, 9, 10, 11, 12, 'ellipsis', 18, 19, 20])
|
||||
})
|
||||
|
||||
@@ -106,7 +71,6 @@ describe('Pagination', () => {
|
||||
props: { currentPage: 4, maxPages: 20 },
|
||||
})
|
||||
const pageItems = (wrapper.vm as unknown as { pageItems: (number | 'ellipsis')[] }).pageItems
|
||||
// Current=4, so around: 2,3,4,5,6, combined with first 3 (1,2,3): 1,2,3,4,5,6
|
||||
expect(pageItems).toContain(1)
|
||||
expect(pageItems).toContain(4)
|
||||
expect(pageItems).toContain(6)
|
||||
@@ -117,7 +81,6 @@ describe('Pagination', () => {
|
||||
props: { currentPage: 17, maxPages: 20 },
|
||||
})
|
||||
const pageItems = (wrapper.vm as unknown as { pageItems: (number | 'ellipsis')[] }).pageItems
|
||||
// Current=17, so around: 15,16,17,18,19, combined with last 3 (18,19,20): 15,16,17,18,19,20
|
||||
expect(pageItems).toContain(15)
|
||||
expect(pageItems).toContain(17)
|
||||
expect(pageItems).toContain(20)
|
||||
@@ -130,7 +93,6 @@ describe('Pagination', () => {
|
||||
props: { currentPage: 5, maxPages: 10 },
|
||||
})
|
||||
|
||||
// Find all buttons and click the one for page 3
|
||||
const buttons = wrapper.findAll('button')
|
||||
const page3Button = buttons.find((btn) => btn.text() === '3')
|
||||
expect(page3Button).toBeDefined()
|
||||
@@ -158,9 +120,8 @@ describe('Pagination', () => {
|
||||
})
|
||||
|
||||
const buttons = wrapper.findAll('button')
|
||||
// First two buttons are first page and previous page
|
||||
expect(buttons[0].attributes('disabled')).toBeDefined()
|
||||
expect(buttons[1].attributes('disabled')).toBeDefined()
|
||||
expect(buttons[0]!.attributes('disabled')).toBeDefined()
|
||||
expect(buttons[1]!.attributes('disabled')).toBeDefined()
|
||||
})
|
||||
|
||||
it('should disable next/last buttons on last page', () => {
|
||||
@@ -169,10 +130,9 @@ describe('Pagination', () => {
|
||||
})
|
||||
|
||||
const buttons = wrapper.findAll('button')
|
||||
// Last two buttons are next page and last page
|
||||
const lastIdx = buttons.length - 1
|
||||
expect(buttons[lastIdx].attributes('disabled')).toBeDefined()
|
||||
expect(buttons[lastIdx - 1].attributes('disabled')).toBeDefined()
|
||||
expect(buttons[lastIdx]!.attributes('disabled')).toBeDefined()
|
||||
expect(buttons[lastIdx - 1]!.attributes('disabled')).toBeDefined()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
375
src/components/PostCard/PostCard.test.ts
Normal file
375
src/components/PostCard/PostCard.test.ts
Normal file
@@ -0,0 +1,375 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { createIconMock, createComponentMock } from '@/test-utils'
|
||||
import { createMockPost, createMockUser, createMockRole } from '@/test-mocks'
|
||||
import PostCard from './PostCard.vue'
|
||||
|
||||
// Mock icons
|
||||
vi.mock('@icons/Reply.vue', () => createIconMock('ReplyIcon'))
|
||||
vi.mock('@icons/Pencil.vue', () => createIconMock('PencilIcon'))
|
||||
vi.mock('@icons/Delete.vue', () => createIconMock('DeleteIcon'))
|
||||
vi.mock('@icons/History.vue', () => createIconMock('HistoryIcon'))
|
||||
|
||||
// Mock components
|
||||
vi.mock('@/components/UserInfo', () =>
|
||||
createComponentMock('UserInfo', {
|
||||
template: '<div class="user-info-mock" :data-user-id="userId"><slot name="meta" /></div>',
|
||||
props: ['userId', 'displayName', 'isDeleted', 'avatarSize', 'roles'],
|
||||
}),
|
||||
)
|
||||
|
||||
vi.mock('@/components/PostReactions', () =>
|
||||
createComponentMock('PostReactions', {
|
||||
template: '<div class="post-reactions-mock" :data-post-id="postId" />',
|
||||
props: ['postId', 'reactions'],
|
||||
}),
|
||||
)
|
||||
|
||||
vi.mock('@/components/PostEditForm', () =>
|
||||
createComponentMock('PostEditForm', {
|
||||
template: '<div class="post-edit-form-mock" />',
|
||||
props: ['initialContent'],
|
||||
}),
|
||||
)
|
||||
|
||||
vi.mock('@/components/PostHistoryDialog', () =>
|
||||
createComponentMock('PostHistoryDialog', {
|
||||
template: '<div class="post-history-dialog-mock" v-if="open" />',
|
||||
props: ['open', 'postId'],
|
||||
}),
|
||||
)
|
||||
|
||||
// Mock NcActions and NcActionButton
|
||||
vi.mock('@nextcloud/vue/components/NcActions', () =>
|
||||
createComponentMock('NcActions', {
|
||||
template: '<div class="nc-actions-mock"><slot /></div>',
|
||||
props: [],
|
||||
}),
|
||||
)
|
||||
|
||||
vi.mock('@nextcloud/vue/components/NcActionButton', () =>
|
||||
createComponentMock('NcActionButton', {
|
||||
template:
|
||||
'<button class="nc-action-button" @click="$emit(\'click\')"><slot /><slot name="icon" /></button>',
|
||||
props: [],
|
||||
emits: ['click'],
|
||||
}),
|
||||
)
|
||||
|
||||
// Mock getCurrentUser
|
||||
const mockCurrentUser = vi.fn()
|
||||
vi.mock('@nextcloud/auth', () => ({
|
||||
getCurrentUser: () => mockCurrentUser(),
|
||||
}))
|
||||
|
||||
// Mock useUserRole
|
||||
const mockIsAdmin = vi.fn(() => false)
|
||||
const mockIsModerator = vi.fn(() => false)
|
||||
vi.mock('@/composables/useUserRole', () => ({
|
||||
useUserRole: () => ({
|
||||
isAdmin: mockIsAdmin(),
|
||||
isModerator: mockIsModerator(),
|
||||
}),
|
||||
}))
|
||||
|
||||
describe('PostCard', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockCurrentUser.mockReturnValue({ uid: 'testuser', displayName: 'Test User' })
|
||||
mockIsAdmin.mockReturnValue(false)
|
||||
mockIsModerator.mockReturnValue(false)
|
||||
})
|
||||
|
||||
describe('rendering', () => {
|
||||
it('should render post content', () => {
|
||||
const post = createMockPost({ content: '<p>Hello world</p>' })
|
||||
const wrapper = mount(PostCard, {
|
||||
props: { post },
|
||||
})
|
||||
expect(wrapper.find('.content-text').html()).toContain('Hello world')
|
||||
})
|
||||
|
||||
it('should render user info with author data', () => {
|
||||
const author = createMockUser({ userId: 'john', displayName: 'John Doe' })
|
||||
const post = createMockPost({ author })
|
||||
const wrapper = mount(PostCard, {
|
||||
props: { post },
|
||||
})
|
||||
expect(wrapper.find('.user-info-mock').attributes('data-user-id')).toBe('john')
|
||||
})
|
||||
|
||||
it('should render reactions component', () => {
|
||||
const post = createMockPost({ id: 42 })
|
||||
const wrapper = mount(PostCard, {
|
||||
props: { post },
|
||||
})
|
||||
expect(wrapper.find('.post-reactions-mock').attributes('data-post-id')).toBe('42')
|
||||
})
|
||||
|
||||
it('should render edited badge when post is edited', () => {
|
||||
const post = createMockPost({ isEdited: true, editedAt: Date.now() / 1000 })
|
||||
const wrapper = mount(PostCard, {
|
||||
props: { post },
|
||||
})
|
||||
expect(wrapper.find('.edited-badge').exists()).toBe(true)
|
||||
expect(wrapper.find('.edited-label').text()).toBe('Edited')
|
||||
})
|
||||
|
||||
it('should not render edited badge when post is not edited', () => {
|
||||
const post = createMockPost({ isEdited: false })
|
||||
const wrapper = mount(PostCard, {
|
||||
props: { post },
|
||||
})
|
||||
expect(wrapper.find('.edited-badge').exists()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('CSS classes', () => {
|
||||
it('should apply first-post class when isFirstPost is true', () => {
|
||||
const post = createMockPost()
|
||||
const wrapper = mount(PostCard, {
|
||||
props: { post, isFirstPost: true },
|
||||
})
|
||||
expect(wrapper.find('.post-card').classes()).toContain('first-post')
|
||||
})
|
||||
|
||||
it('should apply unread class when isUnread is true', () => {
|
||||
const post = createMockPost()
|
||||
const wrapper = mount(PostCard, {
|
||||
props: { post, isUnread: true },
|
||||
})
|
||||
expect(wrapper.find('.post-card').classes()).toContain('unread')
|
||||
})
|
||||
|
||||
it('should show unread indicator when isUnread is true', () => {
|
||||
const post = createMockPost()
|
||||
const wrapper = mount(PostCard, {
|
||||
props: { post, isUnread: true },
|
||||
})
|
||||
expect(wrapper.find('.unread-indicator').exists()).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('signature', () => {
|
||||
it('should render signature when author has one', () => {
|
||||
const author = createMockUser({ signature: '<p>My signature</p>' })
|
||||
const post = createMockPost({ author })
|
||||
const wrapper = mount(PostCard, {
|
||||
props: { post },
|
||||
})
|
||||
expect(wrapper.find('.post-signature').exists()).toBe(true)
|
||||
expect(wrapper.find('.signature-content').html()).toContain('My signature')
|
||||
})
|
||||
|
||||
it('should not render signature when author has none', () => {
|
||||
const author = createMockUser({ signature: null })
|
||||
const post = createMockPost({ author })
|
||||
const wrapper = mount(PostCard, {
|
||||
props: { post },
|
||||
})
|
||||
expect(wrapper.find('.post-signature').exists()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('action buttons', () => {
|
||||
it('should always show reply button', () => {
|
||||
const post = createMockPost()
|
||||
const wrapper = mount(PostCard, {
|
||||
props: { post },
|
||||
})
|
||||
const buttons = wrapper.findAll('.nc-action-button')
|
||||
expect(buttons.some((b) => b.text().includes('Quote reply'))).toBe(true)
|
||||
})
|
||||
|
||||
it('should show edit button when user is author', () => {
|
||||
mockCurrentUser.mockReturnValue({ uid: 'author123' })
|
||||
const post = createMockPost({ authorId: 'author123' })
|
||||
const wrapper = mount(PostCard, {
|
||||
props: { post },
|
||||
})
|
||||
const buttons = wrapper.findAll('.nc-action-button')
|
||||
expect(buttons.some((b) => b.text().includes('Edit'))).toBe(true)
|
||||
})
|
||||
|
||||
it('should show edit button when user is admin', () => {
|
||||
mockCurrentUser.mockReturnValue({ uid: 'admin' })
|
||||
mockIsAdmin.mockReturnValue(true)
|
||||
const post = createMockPost({ authorId: 'someone_else' })
|
||||
const wrapper = mount(PostCard, {
|
||||
props: { post },
|
||||
})
|
||||
const buttons = wrapper.findAll('.nc-action-button')
|
||||
expect(buttons.some((b) => b.text().includes('Edit'))).toBe(true)
|
||||
})
|
||||
|
||||
it('should show edit button when user is moderator', () => {
|
||||
mockCurrentUser.mockReturnValue({ uid: 'mod' })
|
||||
mockIsModerator.mockReturnValue(true)
|
||||
const post = createMockPost({ authorId: 'someone_else' })
|
||||
const wrapper = mount(PostCard, {
|
||||
props: { post },
|
||||
})
|
||||
const buttons = wrapper.findAll('.nc-action-button')
|
||||
expect(buttons.some((b) => b.text().includes('Edit'))).toBe(true)
|
||||
})
|
||||
|
||||
it('should show edit button when user can moderate category', () => {
|
||||
mockCurrentUser.mockReturnValue({ uid: 'catmod' })
|
||||
const post = createMockPost({ authorId: 'someone_else' })
|
||||
const wrapper = mount(PostCard, {
|
||||
props: { post, canModerateCategory: true },
|
||||
})
|
||||
const buttons = wrapper.findAll('.nc-action-button')
|
||||
expect(buttons.some((b) => b.text().includes('Edit'))).toBe(true)
|
||||
})
|
||||
|
||||
it('should not show edit button when user has no permissions', () => {
|
||||
mockCurrentUser.mockReturnValue({ uid: 'random_user' })
|
||||
const post = createMockPost({ authorId: 'someone_else' })
|
||||
const wrapper = mount(PostCard, {
|
||||
props: { post },
|
||||
})
|
||||
const buttons = wrapper.findAll('.nc-action-button')
|
||||
expect(buttons.some((b) => b.text().includes('Edit'))).toBe(false)
|
||||
})
|
||||
|
||||
it('should show view history button when post is edited', () => {
|
||||
const post = createMockPost({ isEdited: true })
|
||||
const wrapper = mount(PostCard, {
|
||||
props: { post },
|
||||
})
|
||||
const buttons = wrapper.findAll('.nc-action-button')
|
||||
expect(buttons.some((b) => b.text().includes('View edit history'))).toBe(true)
|
||||
})
|
||||
|
||||
it('should not show view history button when post is not edited', () => {
|
||||
const post = createMockPost({ isEdited: false })
|
||||
const wrapper = mount(PostCard, {
|
||||
props: { post },
|
||||
})
|
||||
const buttons = wrapper.findAll('.nc-action-button')
|
||||
expect(buttons.some((b) => b.text().includes('View edit history'))).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('events', () => {
|
||||
it('should emit reply event when reply button is clicked', async () => {
|
||||
const post = createMockPost()
|
||||
const wrapper = mount(PostCard, {
|
||||
props: { post },
|
||||
})
|
||||
const replyButton = wrapper
|
||||
.findAll('.nc-action-button')
|
||||
.find((b) => b.text().includes('Quote reply'))
|
||||
await replyButton?.trigger('click')
|
||||
|
||||
expect(wrapper.emitted('reply')).toBeTruthy()
|
||||
expect(wrapper.emitted('reply')![0]).toEqual([post])
|
||||
})
|
||||
|
||||
it('should emit delete event when delete is confirmed', async () => {
|
||||
const confirmMock = vi.fn(() => true)
|
||||
vi.stubGlobal('confirm', confirmMock)
|
||||
|
||||
mockCurrentUser.mockReturnValue({ uid: 'author' })
|
||||
const post = createMockPost({ authorId: 'author' })
|
||||
const wrapper = mount(PostCard, {
|
||||
props: { post },
|
||||
})
|
||||
const deleteButton = wrapper
|
||||
.findAll('.nc-action-button')
|
||||
.find((b) => b.text().includes('Delete'))
|
||||
await deleteButton?.trigger('click')
|
||||
|
||||
expect(confirmMock).toHaveBeenCalled()
|
||||
expect(wrapper.emitted('delete')).toBeTruthy()
|
||||
expect(wrapper.emitted('delete')![0]).toEqual([post])
|
||||
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('should not emit delete event when delete is cancelled', async () => {
|
||||
const confirmMock = vi.fn(() => false)
|
||||
vi.stubGlobal('confirm', confirmMock)
|
||||
|
||||
mockCurrentUser.mockReturnValue({ uid: 'author' })
|
||||
const post = createMockPost({ authorId: 'author' })
|
||||
const wrapper = mount(PostCard, {
|
||||
props: { post },
|
||||
})
|
||||
const deleteButton = wrapper
|
||||
.findAll('.nc-action-button')
|
||||
.find((b) => b.text().includes('Delete'))
|
||||
await deleteButton?.trigger('click')
|
||||
|
||||
expect(confirmMock).toHaveBeenCalled()
|
||||
expect(wrapper.emitted('delete')).toBeFalsy()
|
||||
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
})
|
||||
|
||||
describe('edit mode', () => {
|
||||
it('should show edit form when edit button is clicked', async () => {
|
||||
mockCurrentUser.mockReturnValue({ uid: 'author' })
|
||||
const post = createMockPost({ authorId: 'author', contentRaw: 'Raw content' })
|
||||
const wrapper = mount(PostCard, {
|
||||
props: { post },
|
||||
})
|
||||
|
||||
expect(wrapper.find('.post-edit-form-mock').exists()).toBe(false)
|
||||
|
||||
const editButton = wrapper.findAll('.nc-action-button').find((b) => b.text().includes('Edit'))
|
||||
await editButton?.trigger('click')
|
||||
|
||||
expect(wrapper.find('.post-edit-form-mock').exists()).toBe(true)
|
||||
expect(wrapper.find('.content-text').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('should hide reactions when in edit mode', async () => {
|
||||
mockCurrentUser.mockReturnValue({ uid: 'author' })
|
||||
const post = createMockPost({ authorId: 'author' })
|
||||
const wrapper = mount(PostCard, {
|
||||
props: { post },
|
||||
})
|
||||
|
||||
expect(wrapper.find('.post-reactions-mock').exists()).toBe(true)
|
||||
|
||||
const editButton = wrapper.findAll('.nc-action-button').find((b) => b.text().includes('Edit'))
|
||||
await editButton?.trigger('click')
|
||||
|
||||
expect(wrapper.find('.post-reactions-mock').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('should exit edit mode when cancel is triggered', async () => {
|
||||
mockCurrentUser.mockReturnValue({ uid: 'author' })
|
||||
const post = createMockPost({ authorId: 'author' })
|
||||
const wrapper = mount(PostCard, {
|
||||
props: { post },
|
||||
})
|
||||
|
||||
const editButton = wrapper.findAll('.nc-action-button').find((b) => b.text().includes('Edit'))
|
||||
await editButton?.trigger('click')
|
||||
|
||||
const vm = wrapper.vm as InstanceType<typeof PostCard>
|
||||
vm.cancelEdit()
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
expect(wrapper.find('.post-edit-form-mock').exists()).toBe(false)
|
||||
expect(wrapper.find('.content-text').exists()).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('unauthenticated user', () => {
|
||||
it('should not show edit or delete buttons when not logged in', () => {
|
||||
mockCurrentUser.mockReturnValue(null)
|
||||
const post = createMockPost()
|
||||
const wrapper = mount(PostCard, {
|
||||
props: { post },
|
||||
})
|
||||
const buttons = wrapper.findAll('.nc-action-button')
|
||||
expect(buttons.some((b) => b.text().includes('Edit'))).toBe(false)
|
||||
expect(buttons.some((b) => b.text().includes('Delete'))).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
261
src/components/PostEditForm/PostEditForm.test.ts
Normal file
261
src/components/PostEditForm/PostEditForm.test.ts
Normal file
@@ -0,0 +1,261 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { createComponentMock } from '@/test-utils'
|
||||
import PostEditForm from './PostEditForm.vue'
|
||||
|
||||
// Mock BBCodeEditor
|
||||
vi.mock('@/components/BBCodeEditor', () =>
|
||||
createComponentMock('BBCodeEditor', {
|
||||
template: `<div class="bbcode-editor-mock">
|
||||
<textarea
|
||||
:value="modelValue"
|
||||
:placeholder="placeholder"
|
||||
:disabled="disabled"
|
||||
@input="$emit('update:modelValue', $event.target.value)"
|
||||
@keydown="$emit('keydown', $event)"
|
||||
/>
|
||||
</div>`,
|
||||
props: ['modelValue', 'placeholder', 'rows', 'disabled', 'minHeight'],
|
||||
emits: ['update:modelValue', 'keydown'],
|
||||
}),
|
||||
)
|
||||
|
||||
// Mock NcLoadingIcon
|
||||
vi.mock('@nextcloud/vue/components/NcLoadingIcon', () =>
|
||||
createComponentMock('NcLoadingIcon', {
|
||||
template: '<span class="loading-icon-mock" />',
|
||||
props: ['size'],
|
||||
}),
|
||||
)
|
||||
|
||||
describe('PostEditForm', () => {
|
||||
const initialContent = 'Original post content'
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('rendering', () => {
|
||||
it('should render with initial content', () => {
|
||||
const wrapper = mount(PostEditForm, {
|
||||
props: { initialContent },
|
||||
})
|
||||
const textarea = wrapper.find('textarea')
|
||||
expect(textarea.element.value).toBe(initialContent)
|
||||
})
|
||||
|
||||
it('should render cancel and save buttons', () => {
|
||||
const wrapper = mount(PostEditForm, {
|
||||
props: { initialContent },
|
||||
})
|
||||
const buttons = wrapper.findAll('button')
|
||||
expect(buttons).toHaveLength(2)
|
||||
expect(buttons[0]!.text()).toBe('Cancel')
|
||||
expect(buttons[1]!.text()).toBe('Save')
|
||||
})
|
||||
})
|
||||
|
||||
describe('submit button state', () => {
|
||||
it('should disable save button when content is unchanged', () => {
|
||||
const wrapper = mount(PostEditForm, {
|
||||
props: { initialContent },
|
||||
})
|
||||
const saveButton = wrapper.findAll('button')[1]!
|
||||
expect(saveButton.attributes('disabled')).toBeDefined()
|
||||
})
|
||||
|
||||
it('should disable save button when content is empty', async () => {
|
||||
const wrapper = mount(PostEditForm, {
|
||||
props: { initialContent },
|
||||
})
|
||||
const textarea = wrapper.find('textarea')
|
||||
await textarea.setValue('')
|
||||
|
||||
const saveButton = wrapper.findAll('button')[1]!
|
||||
expect(saveButton.attributes('disabled')).toBeDefined()
|
||||
})
|
||||
|
||||
it('should disable save button when content is only whitespace', async () => {
|
||||
const wrapper = mount(PostEditForm, {
|
||||
props: { initialContent },
|
||||
})
|
||||
const textarea = wrapper.find('textarea')
|
||||
await textarea.setValue(' ')
|
||||
|
||||
const saveButton = wrapper.findAll('button')[1]!
|
||||
expect(saveButton.attributes('disabled')).toBeDefined()
|
||||
})
|
||||
|
||||
it('should enable save button when content is changed and not empty', async () => {
|
||||
const wrapper = mount(PostEditForm, {
|
||||
props: { initialContent },
|
||||
})
|
||||
const textarea = wrapper.find('textarea')
|
||||
await textarea.setValue('Updated content')
|
||||
|
||||
const saveButton = wrapper.findAll('button')[1]!
|
||||
expect(saveButton.attributes('disabled')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('submit', () => {
|
||||
it('should emit submit with trimmed content when save is clicked', async () => {
|
||||
const wrapper = mount(PostEditForm, {
|
||||
props: { initialContent },
|
||||
})
|
||||
const textarea = wrapper.find('textarea')
|
||||
await textarea.setValue(' New content with spaces ')
|
||||
|
||||
const saveButton = wrapper.findAll('button')[1]!
|
||||
await saveButton.trigger('click')
|
||||
|
||||
expect(wrapper.emitted('submit')).toBeTruthy()
|
||||
expect(wrapper.emitted('submit')![0]).toEqual(['New content with spaces'])
|
||||
})
|
||||
|
||||
it('should not emit submit when content is unchanged', async () => {
|
||||
const wrapper = mount(PostEditForm, {
|
||||
props: { initialContent },
|
||||
})
|
||||
const saveButton = wrapper.findAll('button')[1]!
|
||||
await saveButton.trigger('click')
|
||||
|
||||
expect(wrapper.emitted('submit')).toBeFalsy()
|
||||
})
|
||||
})
|
||||
|
||||
describe('cancel', () => {
|
||||
it('should emit cancel when cancel button is clicked with no changes', async () => {
|
||||
const wrapper = mount(PostEditForm, {
|
||||
props: { initialContent },
|
||||
})
|
||||
const cancelButton = wrapper.findAll('button')[0]!
|
||||
await cancelButton.trigger('click')
|
||||
|
||||
expect(wrapper.emitted('cancel')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('should show confirmation when canceling with changes', async () => {
|
||||
const confirmMock = vi.fn(() => false)
|
||||
vi.stubGlobal('confirm', confirmMock)
|
||||
|
||||
const wrapper = mount(PostEditForm, {
|
||||
props: { initialContent },
|
||||
})
|
||||
const textarea = wrapper.find('textarea')
|
||||
await textarea.setValue('Changed content')
|
||||
|
||||
const cancelButton = wrapper.findAll('button')[0]!
|
||||
await cancelButton.trigger('click')
|
||||
|
||||
expect(confirmMock).toHaveBeenCalled()
|
||||
expect(wrapper.emitted('cancel')).toBeFalsy()
|
||||
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('should emit cancel when confirmation is accepted', async () => {
|
||||
const confirmMock = vi.fn(() => true)
|
||||
vi.stubGlobal('confirm', confirmMock)
|
||||
|
||||
const wrapper = mount(PostEditForm, {
|
||||
props: { initialContent },
|
||||
})
|
||||
const textarea = wrapper.find('textarea')
|
||||
await textarea.setValue('Changed content')
|
||||
|
||||
const cancelButton = wrapper.findAll('button')[0]!
|
||||
await cancelButton.trigger('click')
|
||||
|
||||
expect(confirmMock).toHaveBeenCalled()
|
||||
expect(wrapper.emitted('cancel')).toBeTruthy()
|
||||
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
})
|
||||
|
||||
describe('submitting state', () => {
|
||||
it('should disable buttons when submitting', async () => {
|
||||
const wrapper = mount(PostEditForm, {
|
||||
props: { initialContent },
|
||||
})
|
||||
const textarea = wrapper.find('textarea')
|
||||
await textarea.setValue('New content')
|
||||
|
||||
// Trigger submit
|
||||
const saveButton = wrapper.findAll('button')[1]!
|
||||
await saveButton.trigger('click')
|
||||
|
||||
// Both buttons should be disabled
|
||||
const buttons = wrapper.findAll('button')
|
||||
expect(buttons[0]!.attributes('disabled')).toBeDefined()
|
||||
expect(buttons[1]!.attributes('disabled')).toBeDefined()
|
||||
})
|
||||
|
||||
it('should disable editor when submitting', async () => {
|
||||
const wrapper = mount(PostEditForm, {
|
||||
props: { initialContent },
|
||||
})
|
||||
const textarea = wrapper.find('textarea')
|
||||
await textarea.setValue('New content')
|
||||
|
||||
const saveButton = wrapper.findAll('button')[1]!
|
||||
await saveButton.trigger('click')
|
||||
|
||||
expect(wrapper.find('textarea').attributes('disabled')).toBeDefined()
|
||||
})
|
||||
|
||||
it('should expose setSubmitting method', async () => {
|
||||
const wrapper = mount(PostEditForm, {
|
||||
props: { initialContent },
|
||||
})
|
||||
|
||||
const vm = wrapper.vm as InstanceType<typeof PostEditForm>
|
||||
vm.setSubmitting(true)
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
expect(wrapper.find('textarea').attributes('disabled')).toBeDefined()
|
||||
|
||||
vm.setSubmitting(false)
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
expect(wrapper.find('textarea').attributes('disabled')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('computed properties', () => {
|
||||
it('should correctly compute hasChanges', async () => {
|
||||
const wrapper = mount(PostEditForm, {
|
||||
props: { initialContent },
|
||||
})
|
||||
const vm = wrapper.vm as InstanceType<typeof PostEditForm>
|
||||
|
||||
expect(vm.hasChanges).toBe(false)
|
||||
|
||||
const textarea = wrapper.find('textarea')
|
||||
await textarea.setValue('Different content')
|
||||
|
||||
expect(vm.hasChanges).toBe(true)
|
||||
})
|
||||
|
||||
it('should correctly compute canSubmit', async () => {
|
||||
const wrapper = mount(PostEditForm, {
|
||||
props: { initialContent },
|
||||
})
|
||||
const vm = wrapper.vm as InstanceType<typeof PostEditForm>
|
||||
|
||||
// Same content - cannot submit
|
||||
expect(vm.canSubmit).toBe(false)
|
||||
|
||||
const textarea = wrapper.find('textarea')
|
||||
|
||||
// Empty content - cannot submit
|
||||
await textarea.setValue('')
|
||||
expect(vm.canSubmit).toBe(false)
|
||||
|
||||
// Different non-empty content - can submit
|
||||
await textarea.setValue('New content')
|
||||
expect(vm.canSubmit).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
409
src/components/PostHistoryDialog/PostHistoryDialog.test.ts
Normal file
409
src/components/PostHistoryDialog/PostHistoryDialog.test.ts
Normal file
@@ -0,0 +1,409 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { mount, flushPromises } from '@vue/test-utils'
|
||||
import { createIconMock, createComponentMock } from '@/test-utils'
|
||||
import type { PostHistoryResponse, Post, PostHistoryEntry, User } from '@/types'
|
||||
|
||||
// Mock axios - must use factory that doesn't reference external variables
|
||||
vi.mock('@/axios', () => ({
|
||||
ocs: {
|
||||
get: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
// Mock icons
|
||||
vi.mock('@icons/History.vue', () => createIconMock('HistoryIcon'))
|
||||
|
||||
// Mock UserInfo component
|
||||
vi.mock('@/components/UserInfo', () =>
|
||||
createComponentMock('UserInfo', {
|
||||
template: '<span class="user-info-mock">{{ displayName }}</span>',
|
||||
props: ['userId', 'displayName', 'isDeleted', 'avatarSize', 'inline'],
|
||||
}),
|
||||
)
|
||||
|
||||
// Import after mocks
|
||||
import { ocs } from '@/axios'
|
||||
import PostHistoryDialog from './PostHistoryDialog.vue'
|
||||
|
||||
const mockGet = vi.mocked(ocs.get)
|
||||
|
||||
describe('PostHistoryDialog', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockGet.mockResolvedValue({ data: null } as never)
|
||||
})
|
||||
|
||||
const createMockUser = (overrides: Partial<User> = {}): User => ({
|
||||
userId: 'testuser',
|
||||
displayName: 'Test User',
|
||||
isDeleted: false,
|
||||
roles: [],
|
||||
signature: null,
|
||||
signatureRaw: null,
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const createMockPost = (overrides: Partial<Post> = {}): Post => ({
|
||||
id: 1,
|
||||
threadId: 1,
|
||||
authorId: 'testuser',
|
||||
content: '<p>Current content</p>',
|
||||
contentRaw: 'Current content',
|
||||
isEdited: true,
|
||||
isFirstPost: false,
|
||||
editedAt: 1700000000,
|
||||
createdAt: 1699000000,
|
||||
updatedAt: 1700000000,
|
||||
author: createMockUser(),
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const createMockHistoryEntry = (overrides: Partial<PostHistoryEntry> = {}): PostHistoryEntry => ({
|
||||
id: 1,
|
||||
postId: 1,
|
||||
content: '<p>Old content</p>',
|
||||
editedBy: 'editor1',
|
||||
editedAt: 1699500000,
|
||||
editor: createMockUser({ userId: 'editor1', displayName: 'Editor One' }),
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const createMockHistoryResponse = (
|
||||
overrides: Partial<PostHistoryResponse> = {},
|
||||
): PostHistoryResponse => ({
|
||||
current: createMockPost(),
|
||||
history: [createMockHistoryEntry()],
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const createWrapper = (props = {}) => {
|
||||
return mount(PostHistoryDialog, {
|
||||
props: {
|
||||
open: true,
|
||||
postId: 1,
|
||||
...props,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
describe('rendering', () => {
|
||||
it('renders the dialog when open', () => {
|
||||
const wrapper = createWrapper()
|
||||
expect(wrapper.find('.nc-dialog').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('does not render the dialog when closed', () => {
|
||||
const wrapper = createWrapper({ open: false })
|
||||
expect(wrapper.find('.nc-dialog').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('passes the correct title to dialog', async () => {
|
||||
mockGet.mockResolvedValue({ data: createMockHistoryResponse() } as never)
|
||||
const wrapper = createWrapper()
|
||||
await flushPromises()
|
||||
|
||||
// The title is passed as a prop to NcDialog, not rendered as text
|
||||
const vm = wrapper.vm as unknown as { strings: { title: string } }
|
||||
expect(vm.strings.title).toBe('Edit history')
|
||||
})
|
||||
})
|
||||
|
||||
describe('loading state', () => {
|
||||
it('shows loading state while fetching history', async () => {
|
||||
let resolvePromise: (value: unknown) => void
|
||||
mockGet.mockImplementation(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
resolvePromise = resolve
|
||||
}) as never,
|
||||
)
|
||||
|
||||
const wrapper = createWrapper({ open: true })
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.find('.loading-state').exists()).toBe(true)
|
||||
expect(wrapper.text()).toContain('Loading history')
|
||||
|
||||
resolvePromise!({ data: createMockHistoryResponse() })
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.find('.loading-state').exists()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('error state', () => {
|
||||
it('displays error state when fetch fails', async () => {
|
||||
mockGet.mockRejectedValue(new Error('Network error'))
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
|
||||
const wrapper = createWrapper({ open: true })
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.find('.error-state').exists()).toBe(true)
|
||||
expect(wrapper.text()).toContain('Failed to load edit history')
|
||||
})
|
||||
})
|
||||
|
||||
describe('empty state', () => {
|
||||
it('displays empty state when no history exists', async () => {
|
||||
mockGet.mockResolvedValue({
|
||||
data: createMockHistoryResponse({ history: [] }),
|
||||
} as never)
|
||||
|
||||
const wrapper = createWrapper({ open: true })
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.find('.empty-state').exists()).toBe(true)
|
||||
expect(wrapper.text()).toContain('This post has no edit history')
|
||||
})
|
||||
|
||||
it('displays history icon in empty state', async () => {
|
||||
mockGet.mockResolvedValue({
|
||||
data: createMockHistoryResponse({ history: [] }),
|
||||
} as never)
|
||||
|
||||
const wrapper = createWrapper({ open: true })
|
||||
await flushPromises()
|
||||
|
||||
// The icon mock uses kebab-case class name: HistoryIcon -> .history-icon
|
||||
expect(wrapper.find('.history-icon').exists()).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('history content', () => {
|
||||
it('displays current version', async () => {
|
||||
mockGet.mockResolvedValue({ data: createMockHistoryResponse() } as never)
|
||||
|
||||
const wrapper = createWrapper({ open: true })
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.find('.current-version').exists()).toBe(true)
|
||||
expect(wrapper.text()).toContain('Current version')
|
||||
})
|
||||
|
||||
it('displays current version content', async () => {
|
||||
const response = createMockHistoryResponse({
|
||||
current: createMockPost({ content: '<p>This is current content</p>' }),
|
||||
})
|
||||
mockGet.mockResolvedValue({ data: response } as never)
|
||||
|
||||
const wrapper = createWrapper({ open: true })
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.find('.entry-content').html()).toContain('This is current content')
|
||||
})
|
||||
|
||||
it('displays historical versions', async () => {
|
||||
const response = createMockHistoryResponse({
|
||||
history: [
|
||||
createMockHistoryEntry({ id: 1, content: '<p>Version 1 content</p>' }),
|
||||
createMockHistoryEntry({ id: 2, content: '<p>Version 2 content</p>' }),
|
||||
],
|
||||
})
|
||||
mockGet.mockResolvedValue({ data: response } as never)
|
||||
|
||||
const wrapper = createWrapper({ open: true })
|
||||
await flushPromises()
|
||||
|
||||
const entries = wrapper.findAll('.history-entry')
|
||||
// 1 current + 2 historical
|
||||
expect(entries.length).toBe(3)
|
||||
})
|
||||
|
||||
it('displays version labels correctly', async () => {
|
||||
const response = createMockHistoryResponse({
|
||||
history: [createMockHistoryEntry({ id: 1 }), createMockHistoryEntry({ id: 2 })],
|
||||
})
|
||||
mockGet.mockResolvedValue({ data: response } as never)
|
||||
|
||||
const wrapper = createWrapper({ open: true })
|
||||
await flushPromises()
|
||||
|
||||
// Translation mock replaces {index} with actual values
|
||||
// Version labels should be "Version 2", "Version 1" (in reverse order)
|
||||
expect(wrapper.text()).toContain('Version 2')
|
||||
expect(wrapper.text()).toContain('Version 1')
|
||||
})
|
||||
|
||||
it('displays editor info for historical versions', async () => {
|
||||
const response = createMockHistoryResponse({
|
||||
history: [
|
||||
createMockHistoryEntry({
|
||||
editor: createMockUser({ userId: 'editor1', displayName: 'Editor One' }),
|
||||
}),
|
||||
],
|
||||
})
|
||||
mockGet.mockResolvedValue({ data: response } as never)
|
||||
|
||||
const wrapper = createWrapper({ open: true })
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('Edited by')
|
||||
expect(wrapper.find('.user-info-mock').exists()).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('API calls', () => {
|
||||
it('fetches history when dialog opens', async () => {
|
||||
const wrapper = createWrapper({ open: true, postId: 42 })
|
||||
await flushPromises()
|
||||
|
||||
expect(mockGet).toHaveBeenCalledWith('/posts/42/history')
|
||||
})
|
||||
|
||||
it('does not fetch when dialog is closed', async () => {
|
||||
createWrapper({ open: false, postId: 42 })
|
||||
await flushPromises()
|
||||
|
||||
expect(mockGet).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('refetches when dialog reopens', async () => {
|
||||
const wrapper = createWrapper({ open: true, postId: 42 })
|
||||
await flushPromises()
|
||||
|
||||
expect(mockGet).toHaveBeenCalledTimes(1)
|
||||
|
||||
// Close
|
||||
await wrapper.setProps({ open: false })
|
||||
await flushPromises()
|
||||
|
||||
// Reopen
|
||||
await wrapper.setProps({ open: true })
|
||||
await flushPromises()
|
||||
|
||||
expect(mockGet).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('clears data when dialog closes', async () => {
|
||||
mockGet.mockResolvedValue({ data: createMockHistoryResponse() } as never)
|
||||
|
||||
const wrapper = createWrapper({ open: true })
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.find('.history-content').exists()).toBe(true)
|
||||
|
||||
await wrapper.setProps({ open: false })
|
||||
await flushPromises()
|
||||
|
||||
// Reopen - should show loading again
|
||||
mockGet.mockImplementation(
|
||||
() =>
|
||||
new Promise(() => {
|
||||
/* never resolves */
|
||||
}) as never,
|
||||
)
|
||||
await wrapper.setProps({ open: true })
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.find('.loading-state').exists()).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('close event', () => {
|
||||
it('emits update:open event when close button is clicked', async () => {
|
||||
mockGet.mockResolvedValue({ data: createMockHistoryResponse() } as never)
|
||||
|
||||
const wrapper = createWrapper({ open: true })
|
||||
await flushPromises()
|
||||
|
||||
const closeButton = wrapper.find('button')
|
||||
await closeButton.trigger('click')
|
||||
|
||||
expect(wrapper.emitted('update:open')).toBeTruthy()
|
||||
expect(wrapper.emitted('update:open')![0]).toEqual([false])
|
||||
})
|
||||
|
||||
it('emits update:open event when handleClose is called', async () => {
|
||||
const wrapper = createWrapper({ open: true })
|
||||
|
||||
;(wrapper.vm as unknown as { handleClose: () => void }).handleClose()
|
||||
|
||||
expect(wrapper.emitted('update:open')).toBeTruthy()
|
||||
expect(wrapper.emitted('update:open')![0]).toEqual([false])
|
||||
})
|
||||
})
|
||||
|
||||
describe('timestamps', () => {
|
||||
it('displays editedAt timestamp for current version when edited', async () => {
|
||||
const response = createMockHistoryResponse({
|
||||
current: createMockPost({ editedAt: 1700000000, createdAt: 1699000000 }),
|
||||
})
|
||||
mockGet.mockResolvedValue({ data: response } as never)
|
||||
|
||||
const wrapper = createWrapper({ open: true })
|
||||
await flushPromises()
|
||||
|
||||
const dateTime = wrapper.find('.current-version .nc-datetime')
|
||||
expect(dateTime.exists()).toBe(true)
|
||||
// editedAt * 1000 = 1700000000000
|
||||
expect(dateTime.attributes('data-timestamp')).toBe('1700000000000')
|
||||
})
|
||||
|
||||
it('displays createdAt timestamp for current version when not edited', async () => {
|
||||
const response = createMockHistoryResponse({
|
||||
current: createMockPost({ editedAt: null, createdAt: 1699000000 }),
|
||||
})
|
||||
mockGet.mockResolvedValue({ data: response } as never)
|
||||
|
||||
const wrapper = createWrapper({ open: true })
|
||||
await flushPromises()
|
||||
|
||||
const dateTime = wrapper.find('.current-version .nc-datetime')
|
||||
expect(dateTime.exists()).toBe(true)
|
||||
// createdAt * 1000 = 1699000000000
|
||||
expect(dateTime.attributes('data-timestamp')).toBe('1699000000000')
|
||||
})
|
||||
|
||||
it('displays timestamps for historical versions', async () => {
|
||||
const response = createMockHistoryResponse({
|
||||
history: [createMockHistoryEntry({ editedAt: 1699500000 })],
|
||||
})
|
||||
mockGet.mockResolvedValue({ data: response } as never)
|
||||
|
||||
const wrapper = createWrapper({ open: true })
|
||||
await flushPromises()
|
||||
|
||||
const entries = wrapper.findAll('.history-entry:not(.current-version)')
|
||||
expect(entries.length).toBeGreaterThan(0)
|
||||
|
||||
const dateTime = entries[0]!.find('.nc-datetime')
|
||||
expect(dateTime.exists()).toBe(true)
|
||||
expect(dateTime.attributes('data-timestamp')).toBe('1699500000000')
|
||||
})
|
||||
})
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('handles null historyData gracefully', async () => {
|
||||
mockGet.mockResolvedValue({ data: null } as never)
|
||||
|
||||
const wrapper = createWrapper({ open: true })
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.find('.empty-state').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('uses editedBy as fallback when editor is not available', async () => {
|
||||
const response = createMockHistoryResponse({
|
||||
history: [
|
||||
{
|
||||
id: 1,
|
||||
postId: 1,
|
||||
content: '<p>Old content</p>',
|
||||
editedBy: 'fallback_user',
|
||||
editedAt: 1699500000,
|
||||
editor: undefined,
|
||||
},
|
||||
],
|
||||
})
|
||||
mockGet.mockResolvedValue({ data: response } as never)
|
||||
|
||||
const wrapper = createWrapper({ open: true })
|
||||
await flushPromises()
|
||||
|
||||
// Should use editedBy as userId and displayName
|
||||
const userInfo = wrapper.find('.user-info-mock')
|
||||
expect(userInfo.text()).toBe('fallback_user')
|
||||
})
|
||||
})
|
||||
})
|
||||
247
src/components/PostReactions/PostReactions.test.ts
Normal file
247
src/components/PostReactions/PostReactions.test.ts
Normal file
@@ -0,0 +1,247 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { createComponentMock } from '@/test-utils'
|
||||
import PostReactions from './PostReactions.vue'
|
||||
import type { ReactionGroup } from '@/composables/useReactions'
|
||||
|
||||
// Mock LazyEmojiPicker
|
||||
vi.mock('@/components/LazyEmojiPicker', () =>
|
||||
createComponentMock('LazyEmojiPicker', {
|
||||
template: '<div class="emoji-picker-mock"><slot /></div>',
|
||||
props: [],
|
||||
}),
|
||||
)
|
||||
|
||||
// Mock useReactions composable
|
||||
const mockToggleReaction = vi.fn()
|
||||
vi.mock('@/composables/useReactions', () => ({
|
||||
useReactions: () => ({
|
||||
toggleReaction: mockToggleReaction,
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock getCurrentUser
|
||||
vi.mock('@nextcloud/auth', () => ({
|
||||
getCurrentUser: () => ({ uid: 'testuser', displayName: 'Test User' }),
|
||||
}))
|
||||
|
||||
describe('PostReactions', () => {
|
||||
beforeEach(() => {
|
||||
mockToggleReaction.mockReset()
|
||||
})
|
||||
|
||||
const defaultEmojis = ['👍', '❤️', '😄', '🎉', '👏']
|
||||
|
||||
describe('rendering', () => {
|
||||
it('should render default emojis', () => {
|
||||
const wrapper = mount(PostReactions, {
|
||||
props: { postId: 1, reactions: [] },
|
||||
})
|
||||
const buttons = wrapper.findAll('.reaction-button')
|
||||
expect(buttons.length).toBe(defaultEmojis.length)
|
||||
defaultEmojis.forEach((emoji) => {
|
||||
expect(wrapper.text()).toContain(emoji)
|
||||
})
|
||||
})
|
||||
|
||||
it('should render add reaction button', () => {
|
||||
const wrapper = mount(PostReactions, {
|
||||
props: { postId: 1, reactions: [] },
|
||||
})
|
||||
expect(wrapper.find('.add-reaction-button').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('should display reaction counts when present', () => {
|
||||
const reactions: ReactionGroup[] = [
|
||||
{ emoji: '👍', count: 5, hasReacted: false, userIds: ['user1', 'user2'] },
|
||||
]
|
||||
const wrapper = mount(PostReactions, {
|
||||
props: { postId: 1, reactions },
|
||||
})
|
||||
expect(wrapper.find('.count').text()).toBe('5')
|
||||
})
|
||||
|
||||
it('should not display count when zero', () => {
|
||||
const wrapper = mount(PostReactions, {
|
||||
props: { postId: 1, reactions: [] },
|
||||
})
|
||||
const thumbsUpButton = wrapper.findAll('.reaction-button')[0]!
|
||||
expect(thumbsUpButton.find('.count').exists()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('CSS classes', () => {
|
||||
it('should apply reacted class when user has reacted', () => {
|
||||
const reactions: ReactionGroup[] = [
|
||||
{ emoji: '👍', count: 1, hasReacted: true, userIds: ['testuser'] },
|
||||
]
|
||||
const wrapper = mount(PostReactions, {
|
||||
props: { postId: 1, reactions },
|
||||
})
|
||||
const thumbsUpButton = wrapper.findAll('.reaction-button')[0]!
|
||||
expect(thumbsUpButton.classes()).toContain('reacted')
|
||||
})
|
||||
|
||||
it('should not apply reacted class when user has not reacted', () => {
|
||||
const reactions: ReactionGroup[] = [
|
||||
{ emoji: '👍', count: 1, hasReacted: false, userIds: ['otheruser'] },
|
||||
]
|
||||
const wrapper = mount(PostReactions, {
|
||||
props: { postId: 1, reactions },
|
||||
})
|
||||
const thumbsUpButton = wrapper.findAll('.reaction-button')[0]!
|
||||
expect(thumbsUpButton.classes()).not.toContain('reacted')
|
||||
})
|
||||
|
||||
it('should apply has-count class when count is greater than zero', () => {
|
||||
const reactions: ReactionGroup[] = [
|
||||
{ emoji: '👍', count: 3, hasReacted: false, userIds: ['user1'] },
|
||||
]
|
||||
const wrapper = mount(PostReactions, {
|
||||
props: { postId: 1, reactions },
|
||||
})
|
||||
const thumbsUpButton = wrapper.findAll('.reaction-button')[0]!
|
||||
expect(thumbsUpButton.classes()).toContain('has-count')
|
||||
})
|
||||
})
|
||||
|
||||
describe('sorting', () => {
|
||||
it('should sort emojis by count (highest first)', () => {
|
||||
const reactions: ReactionGroup[] = [
|
||||
{ emoji: '👍', count: 2, hasReacted: false, userIds: [] },
|
||||
{ emoji: '❤️', count: 10, hasReacted: false, userIds: [] },
|
||||
{ emoji: '😄', count: 5, hasReacted: false, userIds: [] },
|
||||
]
|
||||
const wrapper = mount(PostReactions, {
|
||||
props: { postId: 1, reactions },
|
||||
})
|
||||
const buttons = wrapper.findAll('.reaction-button')
|
||||
const emojis = buttons.map((b) => b.find('.emoji').text())
|
||||
// ❤️ (10) should be first, then 😄 (5), then 👍 (2)
|
||||
expect(emojis[0]).toBe('❤️')
|
||||
expect(emojis[1]).toBe('😄')
|
||||
expect(emojis[2]).toBe('👍')
|
||||
})
|
||||
|
||||
it('should preserve default order for equal counts', () => {
|
||||
const wrapper = mount(PostReactions, {
|
||||
props: { postId: 1, reactions: [] },
|
||||
})
|
||||
const buttons = wrapper.findAll('.reaction-button')
|
||||
const emojis = buttons.map((b) => b.find('.emoji').text())
|
||||
expect(emojis).toEqual(defaultEmojis)
|
||||
})
|
||||
|
||||
it('should show custom emojis with reactions', () => {
|
||||
const reactions: ReactionGroup[] = [{ emoji: '🚀', count: 3, hasReacted: false, userIds: [] }]
|
||||
const wrapper = mount(PostReactions, {
|
||||
props: { postId: 1, reactions },
|
||||
})
|
||||
expect(wrapper.text()).toContain('🚀')
|
||||
})
|
||||
})
|
||||
|
||||
describe('tooltips', () => {
|
||||
it('should show "React with" tooltip for zero reactions', () => {
|
||||
const wrapper = mount(PostReactions, {
|
||||
props: { postId: 1, reactions: [] },
|
||||
})
|
||||
const thumbsUpButton = wrapper.findAll('.reaction-button')[0]!
|
||||
expect(thumbsUpButton.attributes('title')).toBe('React with 👍')
|
||||
})
|
||||
|
||||
it('should show "You reacted" tooltip when user is sole reactor', () => {
|
||||
const reactions: ReactionGroup[] = [
|
||||
{ emoji: '👍', count: 1, hasReacted: true, userIds: ['testuser'] },
|
||||
]
|
||||
const wrapper = mount(PostReactions, {
|
||||
props: { postId: 1, reactions },
|
||||
})
|
||||
const thumbsUpButton = wrapper.findAll('.reaction-button')[0]!
|
||||
expect(thumbsUpButton.attributes('title')).toBe('You reacted with 👍')
|
||||
})
|
||||
|
||||
it('should show count tooltip when user has not reacted', () => {
|
||||
const reactions: ReactionGroup[] = [
|
||||
{ emoji: '👍', count: 3, hasReacted: false, userIds: ['a', 'b', 'c'] },
|
||||
]
|
||||
const wrapper = mount(PostReactions, {
|
||||
props: { postId: 1, reactions },
|
||||
})
|
||||
const thumbsUpButton = wrapper.findAll('.reaction-button')[0]!
|
||||
expect(thumbsUpButton.attributes('title')).toBe('%n people reacted with 👍')
|
||||
})
|
||||
})
|
||||
|
||||
describe('toggle reaction', () => {
|
||||
it('should call toggleReaction when clicking a reaction button', async () => {
|
||||
mockToggleReaction.mockResolvedValue({ action: 'added' })
|
||||
const wrapper = mount(PostReactions, {
|
||||
props: { postId: 42, reactions: [] },
|
||||
})
|
||||
const thumbsUpButton = wrapper.findAll('.reaction-button')[0]!
|
||||
await thumbsUpButton.trigger('click')
|
||||
expect(mockToggleReaction).toHaveBeenCalledWith(42, '👍')
|
||||
})
|
||||
|
||||
it('should emit update event after toggling reaction', async () => {
|
||||
mockToggleReaction.mockResolvedValue({ action: 'added' })
|
||||
const wrapper = mount(PostReactions, {
|
||||
props: { postId: 1, reactions: [] },
|
||||
})
|
||||
const thumbsUpButton = wrapper.findAll('.reaction-button')[0]!
|
||||
await thumbsUpButton.trigger('click')
|
||||
expect(wrapper.emitted('update')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('should update local state when adding reaction', async () => {
|
||||
mockToggleReaction.mockResolvedValue({ action: 'added' })
|
||||
const wrapper = mount(PostReactions, {
|
||||
props: { postId: 1, reactions: [] },
|
||||
})
|
||||
const thumbsUpButton = wrapper.findAll('.reaction-button')[0]!
|
||||
await thumbsUpButton.trigger('click')
|
||||
|
||||
// Wait for async update
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
// Check that the button now shows as reacted
|
||||
expect(thumbsUpButton.classes()).toContain('reacted')
|
||||
expect(thumbsUpButton.find('.count').text()).toBe('1')
|
||||
})
|
||||
|
||||
it('should update local state when removing reaction', async () => {
|
||||
mockToggleReaction.mockResolvedValue({ action: 'removed' })
|
||||
const reactions: ReactionGroup[] = [
|
||||
{ emoji: '👍', count: 1, hasReacted: true, userIds: ['testuser'] },
|
||||
]
|
||||
const wrapper = mount(PostReactions, {
|
||||
props: { postId: 1, reactions },
|
||||
})
|
||||
const thumbsUpButton = wrapper.findAll('.reaction-button')[0]!
|
||||
await thumbsUpButton.trigger('click')
|
||||
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
expect(thumbsUpButton.classes()).not.toContain('reacted')
|
||||
})
|
||||
})
|
||||
|
||||
describe('props reactivity', () => {
|
||||
it('should update when reactions prop changes', async () => {
|
||||
const wrapper = mount(PostReactions, {
|
||||
props: { postId: 1, reactions: [] },
|
||||
})
|
||||
|
||||
// Initially no count
|
||||
expect(wrapper.find('.count').exists()).toBe(false)
|
||||
|
||||
// Update reactions
|
||||
await wrapper.setProps({
|
||||
reactions: [{ emoji: '👍', count: 5, hasReacted: false, userIds: [] }],
|
||||
})
|
||||
|
||||
expect(wrapper.find('.count').text()).toBe('5')
|
||||
})
|
||||
})
|
||||
})
|
||||
239
src/components/PostReplyForm/PostReplyForm.test.ts
Normal file
239
src/components/PostReplyForm/PostReplyForm.test.ts
Normal file
@@ -0,0 +1,239 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { createIconMock, createComponentMock } from '@/test-utils'
|
||||
import PostReplyForm from './PostReplyForm.vue'
|
||||
|
||||
// Mock BBCodeEditor
|
||||
vi.mock('@/components/BBCodeEditor', () =>
|
||||
createComponentMock('BBCodeEditor', {
|
||||
template: `<div class="bbcode-editor-mock">
|
||||
<textarea
|
||||
:value="modelValue"
|
||||
:placeholder="placeholder"
|
||||
:disabled="disabled"
|
||||
@input="$emit('update:modelValue', $event.target.value)"
|
||||
@keydown="$emit('keydown', $event)"
|
||||
/>
|
||||
</div>`,
|
||||
props: ['modelValue', 'placeholder', 'rows', 'disabled', 'minHeight'],
|
||||
emits: ['update:modelValue', 'keydown'],
|
||||
}),
|
||||
)
|
||||
|
||||
// Mock UserInfo
|
||||
vi.mock('@/components/UserInfo', () =>
|
||||
createComponentMock('UserInfo', {
|
||||
template: '<div class="user-info-mock" :data-user-id="userId">{{ displayName }}</div>',
|
||||
props: ['userId', 'displayName', 'avatarSize', 'clickable'],
|
||||
}),
|
||||
)
|
||||
|
||||
// Mock icons
|
||||
vi.mock('@icons/Send.vue', () => createIconMock('SendIcon'))
|
||||
|
||||
// Mock NcLoadingIcon
|
||||
vi.mock('@nextcloud/vue/components/NcLoadingIcon', () =>
|
||||
createComponentMock('NcLoadingIcon', {
|
||||
template: '<span class="loading-icon-mock" />',
|
||||
props: ['size'],
|
||||
}),
|
||||
)
|
||||
|
||||
// Mock useCurrentUser composable
|
||||
vi.mock('@/composables/useCurrentUser', () => ({
|
||||
useCurrentUser: () => ({
|
||||
userId: 'testuser',
|
||||
displayName: 'Test User',
|
||||
}),
|
||||
}))
|
||||
|
||||
describe('PostReplyForm', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('rendering', () => {
|
||||
it('should render user info header', () => {
|
||||
const wrapper = mount(PostReplyForm)
|
||||
const userInfo = wrapper.find('.user-info-mock')
|
||||
expect(userInfo.exists()).toBe(true)
|
||||
expect(userInfo.attributes('data-user-id')).toBe('testuser')
|
||||
expect(userInfo.text()).toBe('Test User')
|
||||
})
|
||||
|
||||
it('should render editor', () => {
|
||||
const wrapper = mount(PostReplyForm)
|
||||
expect(wrapper.find('.bbcode-editor-mock').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('should render cancel and submit buttons', () => {
|
||||
const wrapper = mount(PostReplyForm)
|
||||
const buttons = wrapper.findAll('button')
|
||||
expect(buttons).toHaveLength(2)
|
||||
expect(buttons[0]!.text()).toBe('Cancel')
|
||||
expect(buttons[1]!.text()).toContain('Submit reply')
|
||||
})
|
||||
|
||||
it('should render send icon in submit button', () => {
|
||||
const wrapper = mount(PostReplyForm)
|
||||
expect(wrapper.find('.send-icon').exists()).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('button states', () => {
|
||||
it('should disable submit button when content is empty', () => {
|
||||
const wrapper = mount(PostReplyForm)
|
||||
const submitButton = wrapper.findAll('button')[1]!
|
||||
expect(submitButton.attributes('disabled')).toBeDefined()
|
||||
})
|
||||
|
||||
it('should disable cancel button when content is empty', () => {
|
||||
const wrapper = mount(PostReplyForm)
|
||||
const cancelButton = wrapper.findAll('button')[0]!
|
||||
expect(cancelButton.attributes('disabled')).toBeDefined()
|
||||
})
|
||||
|
||||
it('should enable submit button when content is not empty', async () => {
|
||||
const wrapper = mount(PostReplyForm)
|
||||
const textarea = wrapper.find('textarea')
|
||||
await textarea.setValue('Some reply content')
|
||||
|
||||
const submitButton = wrapper.findAll('button')[1]!
|
||||
expect(submitButton.attributes('disabled')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should enable cancel button when content is not empty', async () => {
|
||||
const wrapper = mount(PostReplyForm)
|
||||
const textarea = wrapper.find('textarea')
|
||||
await textarea.setValue('Some reply content')
|
||||
|
||||
const cancelButton = wrapper.findAll('button')[0]!
|
||||
expect(cancelButton.attributes('disabled')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('submit', () => {
|
||||
it('should emit submit with trimmed content', async () => {
|
||||
const wrapper = mount(PostReplyForm)
|
||||
const textarea = wrapper.find('textarea')
|
||||
await textarea.setValue(' Reply content with spaces ')
|
||||
|
||||
const submitButton = wrapper.findAll('button')[1]!
|
||||
await submitButton.trigger('click')
|
||||
|
||||
expect(wrapper.emitted('submit')).toBeTruthy()
|
||||
expect(wrapper.emitted('submit')![0]).toEqual(['Reply content with spaces'])
|
||||
})
|
||||
|
||||
it('should not emit submit when content is empty', async () => {
|
||||
const wrapper = mount(PostReplyForm)
|
||||
const submitButton = wrapper.findAll('button')[1]!
|
||||
await submitButton.trigger('click')
|
||||
|
||||
expect(wrapper.emitted('submit')).toBeFalsy()
|
||||
})
|
||||
})
|
||||
|
||||
describe('cancel', () => {
|
||||
it('should show confirmation when canceling with content', async () => {
|
||||
const confirmMock = vi.fn(() => false)
|
||||
vi.stubGlobal('confirm', confirmMock)
|
||||
|
||||
const wrapper = mount(PostReplyForm)
|
||||
const textarea = wrapper.find('textarea')
|
||||
await textarea.setValue('Some content')
|
||||
|
||||
const cancelButton = wrapper.findAll('button')[0]!
|
||||
await cancelButton.trigger('click')
|
||||
|
||||
expect(confirmMock).toHaveBeenCalled()
|
||||
expect(wrapper.emitted('cancel')).toBeFalsy()
|
||||
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('should emit cancel and clear content when confirmation is accepted', async () => {
|
||||
const confirmMock = vi.fn(() => true)
|
||||
vi.stubGlobal('confirm', confirmMock)
|
||||
|
||||
const wrapper = mount(PostReplyForm)
|
||||
const textarea = wrapper.find('textarea')
|
||||
await textarea.setValue('Some content')
|
||||
|
||||
const cancelButton = wrapper.findAll('button')[0]!
|
||||
await cancelButton.trigger('click')
|
||||
|
||||
expect(wrapper.emitted('cancel')).toBeTruthy()
|
||||
expect(textarea.element.value).toBe('')
|
||||
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
})
|
||||
|
||||
describe('exposed methods', () => {
|
||||
it('should clear content with clear()', async () => {
|
||||
const wrapper = mount(PostReplyForm)
|
||||
const textarea = wrapper.find('textarea')
|
||||
await textarea.setValue('Some content')
|
||||
|
||||
const vm = wrapper.vm as InstanceType<typeof PostReplyForm>
|
||||
vm.clear()
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
expect(textarea.element.value).toBe('')
|
||||
})
|
||||
|
||||
it('should set submitting state with setSubmitting()', async () => {
|
||||
const wrapper = mount(PostReplyForm)
|
||||
const textarea = wrapper.find('textarea')
|
||||
await textarea.setValue('Some content')
|
||||
|
||||
const vm = wrapper.vm as InstanceType<typeof PostReplyForm>
|
||||
vm.setSubmitting(true)
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
expect(wrapper.find('textarea').attributes('disabled')).toBeDefined()
|
||||
expect(wrapper.find('.loading-icon-mock').exists()).toBe(true)
|
||||
|
||||
vm.setSubmitting(false)
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
expect(wrapper.find('textarea').attributes('disabled')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should set quoted content with setQuotedContent()', async () => {
|
||||
const wrapper = mount(PostReplyForm)
|
||||
|
||||
const vm = wrapper.vm as InstanceType<typeof PostReplyForm>
|
||||
vm.setQuotedContent('Original message')
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
const textarea = wrapper.find('textarea')
|
||||
expect(textarea.element.value).toBe('[quote]Original message[/quote]\n')
|
||||
})
|
||||
})
|
||||
|
||||
describe('submitting state', () => {
|
||||
it('should disable editor when submitting', async () => {
|
||||
const wrapper = mount(PostReplyForm)
|
||||
const textarea = wrapper.find('textarea')
|
||||
await textarea.setValue('Reply content')
|
||||
|
||||
const submitButton = wrapper.findAll('button')[1]!
|
||||
await submitButton.trigger('click')
|
||||
|
||||
expect(wrapper.find('textarea').attributes('disabled')).toBeDefined()
|
||||
})
|
||||
|
||||
it('should show loading icon when submitting', async () => {
|
||||
const wrapper = mount(PostReplyForm)
|
||||
const textarea = wrapper.find('textarea')
|
||||
await textarea.setValue('Reply content')
|
||||
|
||||
const submitButton = wrapper.findAll('button')[1]!
|
||||
await submitButton.trigger('click')
|
||||
|
||||
expect(wrapper.find('.loading-icon-mock').exists()).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,30 +1,9 @@
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import RoleBadge from './RoleBadge.vue'
|
||||
import type { Role } from '@/types/models'
|
||||
import { createMockRole } from '@/test-mocks'
|
||||
|
||||
// Mock isDarkTheme - default to light theme
|
||||
vi.mock('@nextcloud/vue/functions/isDarkTheme', () => ({
|
||||
isDarkTheme: false,
|
||||
}))
|
||||
|
||||
// Helper to create a mock role
|
||||
function createMockRole(overrides: Partial<Role> = {}): Role {
|
||||
return {
|
||||
id: 100,
|
||||
name: 'Test Role',
|
||||
description: null,
|
||||
colorLight: null,
|
||||
colorDark: null,
|
||||
canAccessAdminTools: false,
|
||||
canEditRoles: false,
|
||||
canEditCategories: false,
|
||||
isSystemRole: false,
|
||||
roleType: 'custom',
|
||||
createdAt: Date.now(),
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
// Uses global mock for @nextcloud/vue/functions/isDarkTheme from test-setup.ts
|
||||
|
||||
describe('RoleBadge', () => {
|
||||
describe('rendering', () => {
|
||||
@@ -69,7 +48,6 @@ describe('RoleBadge', () => {
|
||||
props: { role },
|
||||
})
|
||||
const style = wrapper.find('.role-badge').attributes('style')
|
||||
// Fallback light color for Admin is #dc2626
|
||||
expect(style).toContain('background-color: #dc2626')
|
||||
})
|
||||
|
||||
@@ -79,7 +57,6 @@ describe('RoleBadge', () => {
|
||||
props: { role },
|
||||
})
|
||||
const style = wrapper.find('.role-badge').attributes('style')
|
||||
// Fallback light color for Moderator is #2563eb
|
||||
expect(style).toContain('background-color: #2563eb')
|
||||
})
|
||||
|
||||
@@ -89,7 +66,6 @@ describe('RoleBadge', () => {
|
||||
props: { role },
|
||||
})
|
||||
const style = wrapper.find('.role-badge').attributes('style')
|
||||
// Fallback light color for User is #059669
|
||||
expect(style).toContain('background-color: #059669')
|
||||
})
|
||||
|
||||
@@ -99,14 +75,12 @@ describe('RoleBadge', () => {
|
||||
props: { role },
|
||||
})
|
||||
const style = wrapper.find('.role-badge').attributes('style')
|
||||
// Default light fallback is #000000
|
||||
expect(style).toContain('background-color: #000000')
|
||||
})
|
||||
})
|
||||
|
||||
describe('text color calculation (contrast)', () => {
|
||||
it('should use dark text on light backgrounds', () => {
|
||||
// White background should have black text
|
||||
const role = createMockRole({ colorLight: '#ffffff' })
|
||||
const wrapper = mount(RoleBadge, {
|
||||
props: { role },
|
||||
@@ -116,7 +90,6 @@ describe('RoleBadge', () => {
|
||||
})
|
||||
|
||||
it('should use light text on dark backgrounds', () => {
|
||||
// Black background should have white text
|
||||
const role = createMockRole({ colorLight: '#000000' })
|
||||
const wrapper = mount(RoleBadge, {
|
||||
props: { role },
|
||||
@@ -126,7 +99,6 @@ describe('RoleBadge', () => {
|
||||
})
|
||||
|
||||
it('should use light text on moderately dark backgrounds', () => {
|
||||
// Dark blue should have white text
|
||||
const role = createMockRole({ colorLight: '#1e3a5f' })
|
||||
const wrapper = mount(RoleBadge, {
|
||||
props: { role },
|
||||
@@ -136,7 +108,6 @@ describe('RoleBadge', () => {
|
||||
})
|
||||
|
||||
it('should use dark text on moderately light backgrounds', () => {
|
||||
// Light yellow should have black text
|
||||
const role = createMockRole({ colorLight: '#ffeb3b' })
|
||||
const wrapper = mount(RoleBadge, {
|
||||
props: { role },
|
||||
@@ -152,7 +123,6 @@ describe('RoleBadge', () => {
|
||||
const wrapper = mount(RoleBadge, {
|
||||
props: { role },
|
||||
})
|
||||
// Access the method via vm
|
||||
const vm = wrapper.vm as unknown as {
|
||||
hexToRgb: (hex: string) => { r: number; g: number; b: number } | null
|
||||
}
|
||||
@@ -168,7 +138,6 @@ describe('RoleBadge', () => {
|
||||
const vm = wrapper.vm as unknown as {
|
||||
hexToRgb: (hex: string) => { r: number; g: number; b: number } | null
|
||||
}
|
||||
// #f00 expands to #ff0000
|
||||
const result = vm.hexToRgb('#f00')
|
||||
expect(result).toEqual({ r: 255, g: 0, b: 0 })
|
||||
})
|
||||
|
||||
176
src/components/SearchPostResult/SearchPostResult.test.ts
Normal file
176
src/components/SearchPostResult/SearchPostResult.test.ts
Normal file
@@ -0,0 +1,176 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { createIconMock } from '@/test-utils'
|
||||
import { createMockPost, createMockUser } from '@/test-mocks'
|
||||
import SearchPostResult from './SearchPostResult.vue'
|
||||
|
||||
// Uses global mocks for @nextcloud/l10n, isDarkTheme, NcDateTime from test-setup.ts
|
||||
|
||||
vi.mock('@icons/Account.vue', () => createIconMock('AccountIcon'))
|
||||
vi.mock('@icons/Clock.vue', () => createIconMock('ClockIcon'))
|
||||
vi.mock('@icons/Pencil.vue', () => createIconMock('PencilIcon'))
|
||||
|
||||
const mockPush = vi.fn()
|
||||
vi.mock('vue-router', () => ({
|
||||
useRouter: () => ({ push: mockPush }),
|
||||
}))
|
||||
|
||||
describe('SearchPostResult', () => {
|
||||
beforeEach(() => {
|
||||
mockPush.mockClear()
|
||||
})
|
||||
|
||||
describe('rendering', () => {
|
||||
it('should render thread title link', () => {
|
||||
const post = createMockPost({ threadTitle: 'Discussion Thread' })
|
||||
const wrapper = mount(SearchPostResult, {
|
||||
props: { post, query: 'test' },
|
||||
global: {
|
||||
stubs: {
|
||||
'router-link': {
|
||||
template: '<a class="thread-link"><slot /></a>',
|
||||
props: ['to'],
|
||||
},
|
||||
},
|
||||
mocks: { $router: { push: mockPush } },
|
||||
},
|
||||
})
|
||||
expect(wrapper.find('.thread-link').text()).toBe('Discussion Thread')
|
||||
})
|
||||
|
||||
it('should show thread unavailable when no slug', () => {
|
||||
const post = createMockPost({ threadSlug: undefined })
|
||||
const wrapper = mount(SearchPostResult, {
|
||||
props: { post, query: 'test' },
|
||||
global: {
|
||||
stubs: { 'router-link': true },
|
||||
mocks: { $router: { push: mockPush } },
|
||||
},
|
||||
})
|
||||
expect(wrapper.find('.thread-missing').text()).toBe('Thread unavailable')
|
||||
})
|
||||
|
||||
it('should render author name', () => {
|
||||
const post = createMockPost({
|
||||
author: createMockUser({ userId: 'john', displayName: 'John Doe' }),
|
||||
})
|
||||
const wrapper = mount(SearchPostResult, {
|
||||
props: { post, query: 'test' },
|
||||
global: {
|
||||
stubs: { 'router-link': true },
|
||||
mocks: { $router: { push: mockPush } },
|
||||
},
|
||||
})
|
||||
expect(wrapper.find('.author').text()).toContain('John Doe')
|
||||
})
|
||||
|
||||
it('should show "Deleted user" when author is missing', () => {
|
||||
const post = createMockPost()
|
||||
post.author = undefined
|
||||
const wrapper = mount(SearchPostResult, {
|
||||
props: { post, query: 'test' },
|
||||
global: {
|
||||
stubs: { 'router-link': true },
|
||||
mocks: { $router: { push: mockPush } },
|
||||
},
|
||||
})
|
||||
expect(wrapper.find('.author').text()).toContain('Deleted user')
|
||||
})
|
||||
|
||||
it('should show edited indicator when post is edited', () => {
|
||||
const post = createMockPost({ isEdited: true })
|
||||
const wrapper = mount(SearchPostResult, {
|
||||
props: { post, query: 'test' },
|
||||
global: {
|
||||
stubs: { 'router-link': true },
|
||||
mocks: { $router: { push: mockPush } },
|
||||
},
|
||||
})
|
||||
expect(wrapper.find('.edited').exists()).toBe(true)
|
||||
expect(wrapper.find('.pencil-icon').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('should not show edited indicator when post is not edited', () => {
|
||||
const post = createMockPost({ isEdited: false })
|
||||
const wrapper = mount(SearchPostResult, {
|
||||
props: { post, query: 'test' },
|
||||
global: {
|
||||
stubs: { 'router-link': true },
|
||||
mocks: { $router: { push: mockPush } },
|
||||
},
|
||||
})
|
||||
expect(wrapper.find('.edited').exists()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('content processing', () => {
|
||||
it('should strip HTML from content', () => {
|
||||
const post = createMockPost({ content: '<p>Hello <strong>World</strong></p>' })
|
||||
const wrapper = mount(SearchPostResult, {
|
||||
props: { post, query: 'xyz' },
|
||||
global: {
|
||||
stubs: { 'router-link': true },
|
||||
mocks: { $router: { push: mockPush } },
|
||||
},
|
||||
})
|
||||
const content = wrapper.find('.post-content').text()
|
||||
expect(content).not.toContain('<p>')
|
||||
expect(content).not.toContain('<strong>')
|
||||
})
|
||||
|
||||
it('should truncate long content', () => {
|
||||
const longContent = '<p>' + 'A'.repeat(500) + '</p>'
|
||||
const post = createMockPost({ content: longContent })
|
||||
const wrapper = mount(SearchPostResult, {
|
||||
props: { post, query: 'xyz' },
|
||||
global: {
|
||||
stubs: { 'router-link': true },
|
||||
mocks: { $router: { push: mockPush } },
|
||||
},
|
||||
})
|
||||
const content = wrapper.find('.post-content').text()
|
||||
expect(content.length).toBeLessThan(300)
|
||||
expect(content).toContain('...')
|
||||
})
|
||||
|
||||
it('should highlight search terms', () => {
|
||||
const post = createMockPost({ content: '<p>This is a test post</p>' })
|
||||
const wrapper = mount(SearchPostResult, {
|
||||
props: { post, query: 'test' },
|
||||
global: {
|
||||
stubs: { 'router-link': true },
|
||||
mocks: { $router: { push: mockPush } },
|
||||
},
|
||||
})
|
||||
expect(wrapper.find('.post-content').html()).toContain('<mark>test</mark>')
|
||||
})
|
||||
})
|
||||
|
||||
describe('navigation', () => {
|
||||
it('should navigate to post when clicked', async () => {
|
||||
const post = createMockPost({ threadSlug: 'my-thread', id: 42 })
|
||||
const wrapper = mount(SearchPostResult, {
|
||||
props: { post, query: 'test' },
|
||||
global: {
|
||||
stubs: { 'router-link': true },
|
||||
mocks: { $router: { push: mockPush } },
|
||||
},
|
||||
})
|
||||
await wrapper.find('.search-post-result').trigger('click')
|
||||
expect(mockPush).toHaveBeenCalledWith('/t/my-thread#post-42')
|
||||
})
|
||||
|
||||
it('should not navigate when thread slug is missing', async () => {
|
||||
const post = createMockPost({ threadSlug: undefined })
|
||||
const wrapper = mount(SearchPostResult, {
|
||||
props: { post, query: 'test' },
|
||||
global: {
|
||||
stubs: { 'router-link': true },
|
||||
mocks: { $router: { push: mockPush } },
|
||||
},
|
||||
})
|
||||
await wrapper.find('.search-post-result').trigger('click')
|
||||
expect(mockPush).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
112
src/components/SearchThreadResult/SearchThreadResult.test.ts
Normal file
112
src/components/SearchThreadResult/SearchThreadResult.test.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { createIconMock } from '@/test-utils'
|
||||
import { createMockThread, createMockUser } from '@/test-mocks'
|
||||
import SearchThreadResult from './SearchThreadResult.vue'
|
||||
|
||||
// Uses global mocks for @nextcloud/l10n, isDarkTheme, NcDateTime from test-setup.ts
|
||||
|
||||
vi.mock('@icons/Folder.vue', () => createIconMock('FolderIcon'))
|
||||
vi.mock('@icons/Account.vue', () => createIconMock('AccountIcon'))
|
||||
vi.mock('@icons/Message.vue', () => createIconMock('MessageIcon'))
|
||||
vi.mock('@icons/Eye.vue', () => createIconMock('EyeIcon'))
|
||||
vi.mock('@icons/Clock.vue', () => createIconMock('ClockIcon'))
|
||||
vi.mock('@icons/Pin.vue', () => createIconMock('PinIcon'))
|
||||
vi.mock('@icons/Lock.vue', () => createIconMock('LockIcon'))
|
||||
|
||||
describe('SearchThreadResult', () => {
|
||||
describe('rendering', () => {
|
||||
it('should render thread title', () => {
|
||||
const thread = createMockThread({ title: 'How to configure settings' })
|
||||
const wrapper = mount(SearchThreadResult, {
|
||||
props: { thread, query: 'test' },
|
||||
})
|
||||
expect(wrapper.find('.thread-title').text()).toContain('How to configure settings')
|
||||
})
|
||||
|
||||
it('should render category name', () => {
|
||||
const thread = createMockThread({ categoryName: 'Technical Support' })
|
||||
const wrapper = mount(SearchThreadResult, {
|
||||
props: { thread, query: 'test' },
|
||||
})
|
||||
expect(wrapper.find('.category').text()).toContain('Technical Support')
|
||||
})
|
||||
|
||||
it('should render author name', () => {
|
||||
const thread = createMockThread({
|
||||
author: createMockUser({ userId: 'john', displayName: 'John Doe' }),
|
||||
})
|
||||
const wrapper = mount(SearchThreadResult, {
|
||||
props: { thread, query: 'test' },
|
||||
})
|
||||
expect(wrapper.find('.author').text()).toContain('John Doe')
|
||||
})
|
||||
|
||||
it('should show "Deleted user" when author is missing', () => {
|
||||
const thread = createMockThread()
|
||||
thread.author = undefined
|
||||
const wrapper = mount(SearchThreadResult, {
|
||||
props: { thread, query: 'test' },
|
||||
})
|
||||
expect(wrapper.find('.author').text()).toContain('Deleted user')
|
||||
})
|
||||
})
|
||||
|
||||
describe('badges', () => {
|
||||
it('should show pin icon when thread is pinned', () => {
|
||||
const thread = createMockThread({ isPinned: true })
|
||||
const wrapper = mount(SearchThreadResult, {
|
||||
props: { thread, query: 'test' },
|
||||
})
|
||||
expect(wrapper.find('.pin-icon').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('should show lock icon when thread is locked', () => {
|
||||
const thread = createMockThread({ isLocked: true })
|
||||
const wrapper = mount(SearchThreadResult, {
|
||||
props: { thread, query: 'test' },
|
||||
})
|
||||
expect(wrapper.find('.lock-icon').exists()).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('highlighting', () => {
|
||||
it('should highlight search terms in title', () => {
|
||||
const thread = createMockThread({ title: 'How to test your code' })
|
||||
const wrapper = mount(SearchThreadResult, {
|
||||
props: { thread, query: 'test' },
|
||||
})
|
||||
expect(wrapper.find('.thread-title').html()).toContain('<mark>test</mark>')
|
||||
})
|
||||
|
||||
it('should handle quoted phrases', () => {
|
||||
const thread = createMockThread({ title: 'Testing the exact phrase match' })
|
||||
const wrapper = mount(SearchThreadResult, {
|
||||
props: { thread, query: '"exact phrase"' },
|
||||
})
|
||||
expect(wrapper.find('.thread-title').html()).toContain('<mark>exact phrase</mark>')
|
||||
})
|
||||
|
||||
it('should exclude AND/OR operators from highlighting', () => {
|
||||
const thread = createMockThread({ title: 'Test AND production environments' })
|
||||
const wrapper = mount(SearchThreadResult, {
|
||||
props: { thread, query: 'test AND production' },
|
||||
})
|
||||
const html = wrapper.find('.thread-title').html().toLowerCase()
|
||||
expect(html).toContain('<mark>test</mark>')
|
||||
expect(html).toContain('<mark>production</mark>')
|
||||
expect(html).not.toContain('<mark>and</mark>')
|
||||
})
|
||||
})
|
||||
|
||||
describe('events', () => {
|
||||
it('should emit click event when clicked', async () => {
|
||||
const thread = createMockThread()
|
||||
const wrapper = mount(SearchThreadResult, {
|
||||
props: { thread, query: 'test' },
|
||||
})
|
||||
await wrapper.find('.search-thread-result').trigger('click')
|
||||
expect(wrapper.emitted('click')).toBeTruthy()
|
||||
})
|
||||
})
|
||||
})
|
||||
72
src/components/Skeleton/Skeleton.test.ts
Normal file
72
src/components/Skeleton/Skeleton.test.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import Skeleton from './Skeleton.vue'
|
||||
|
||||
describe('Skeleton', () => {
|
||||
describe('rendering', () => {
|
||||
it('should render with default props', () => {
|
||||
const wrapper = mount(Skeleton)
|
||||
expect(wrapper.find('.skeleton').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('should apply default width and height', () => {
|
||||
const wrapper = mount(Skeleton)
|
||||
const style = wrapper.find('.skeleton').attributes('style')
|
||||
expect(style).toContain('width: 100%')
|
||||
expect(style).toContain('height: 20px')
|
||||
})
|
||||
|
||||
it('should apply custom width and height', () => {
|
||||
const wrapper = mount(Skeleton, {
|
||||
props: { width: '200px', height: '40px' },
|
||||
})
|
||||
const style = wrapper.find('.skeleton').attributes('style')
|
||||
expect(style).toContain('width: 200px')
|
||||
expect(style).toContain('height: 40px')
|
||||
})
|
||||
})
|
||||
|
||||
describe('shapes', () => {
|
||||
it('should apply rounded-rect border radius by default', () => {
|
||||
const wrapper = mount(Skeleton)
|
||||
const style = wrapper.find('.skeleton').attributes('style')
|
||||
expect(style).toContain('border-radius: 4px')
|
||||
})
|
||||
|
||||
it('should apply circle border radius', () => {
|
||||
const wrapper = mount(Skeleton, {
|
||||
props: { shape: 'circle' },
|
||||
})
|
||||
const style = wrapper.find('.skeleton').attributes('style')
|
||||
expect(style).toContain('border-radius: 50%')
|
||||
})
|
||||
|
||||
it('should apply square border radius (0)', () => {
|
||||
const wrapper = mount(Skeleton, {
|
||||
props: { shape: 'square' },
|
||||
})
|
||||
const style = wrapper.find('.skeleton').attributes('style')
|
||||
expect(style).toContain('border-radius: 0')
|
||||
})
|
||||
|
||||
it('should apply custom radius for rounded-rect', () => {
|
||||
const wrapper = mount(Skeleton, {
|
||||
props: { shape: 'rounded-rect', radius: '8px' },
|
||||
})
|
||||
const style = wrapper.find('.skeleton').attributes('style')
|
||||
expect(style).toContain('border-radius: 8px')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getBorderRadius method', () => {
|
||||
it('should return correct radius for each shape', () => {
|
||||
const wrapper = mount(Skeleton, {
|
||||
props: { radius: '10px' },
|
||||
})
|
||||
const vm = wrapper.vm as unknown as { getBorderRadius: () => string; shape: string }
|
||||
|
||||
// Test via computed style since method is internal
|
||||
expect(wrapper.find('.skeleton').attributes('style')).toContain('border-radius: 10px')
|
||||
})
|
||||
})
|
||||
})
|
||||
136
src/components/ThreadCard/ThreadCard.test.ts
Normal file
136
src/components/ThreadCard/ThreadCard.test.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { createIconMock, createComponentMock } from '@/test-utils'
|
||||
import { createMockThread } from '@/test-mocks'
|
||||
import ThreadCard from './ThreadCard.vue'
|
||||
|
||||
// Uses global mocks for @nextcloud/l10n, NcDateTime from test-setup.ts
|
||||
|
||||
vi.mock('@/components/UserInfo', () =>
|
||||
createComponentMock('UserInfo', {
|
||||
template: '<div class="user-info-mock"><slot name="meta" /></div>',
|
||||
props: ['userId', 'displayName', 'isDeleted', 'avatarSize', 'roles', 'showRoles', 'layout'],
|
||||
}),
|
||||
)
|
||||
vi.mock('@icons/Pin.vue', () => createIconMock('PinIcon'))
|
||||
vi.mock('@icons/Lock.vue', () => createIconMock('LockIcon'))
|
||||
vi.mock('@icons/Comment.vue', () => createIconMock('CommentIcon'))
|
||||
vi.mock('@icons/Eye.vue', () => createIconMock('EyeIcon'))
|
||||
|
||||
describe('ThreadCard', () => {
|
||||
describe('rendering', () => {
|
||||
it('should render thread title', () => {
|
||||
const thread = createMockThread({ title: 'My First Thread' })
|
||||
const wrapper = mount(ThreadCard, {
|
||||
props: { thread },
|
||||
})
|
||||
expect(wrapper.find('.thread-title').text()).toContain('My First Thread')
|
||||
})
|
||||
|
||||
it('should render post count', () => {
|
||||
const thread = createMockThread({ postCount: 25 })
|
||||
const wrapper = mount(ThreadCard, {
|
||||
props: { thread },
|
||||
})
|
||||
expect(wrapper.find('.thread-stats').text()).toContain('25')
|
||||
})
|
||||
|
||||
it('should render view count', () => {
|
||||
const thread = createMockThread({ viewCount: 500 })
|
||||
const wrapper = mount(ThreadCard, {
|
||||
props: { thread },
|
||||
})
|
||||
expect(wrapper.find('.thread-stats').text()).toContain('500')
|
||||
})
|
||||
})
|
||||
|
||||
describe('badges', () => {
|
||||
it('should show pin icon when thread is pinned', () => {
|
||||
const thread = createMockThread({ isPinned: true })
|
||||
const wrapper = mount(ThreadCard, {
|
||||
props: { thread },
|
||||
})
|
||||
expect(wrapper.find('.pin-icon').exists()).toBe(true)
|
||||
expect(wrapper.find('.badge-pinned').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('should not show pin icon when thread is not pinned', () => {
|
||||
const thread = createMockThread({ isPinned: false })
|
||||
const wrapper = mount(ThreadCard, {
|
||||
props: { thread },
|
||||
})
|
||||
expect(wrapper.find('.pin-icon').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('should show lock icon when thread is locked', () => {
|
||||
const thread = createMockThread({ isLocked: true })
|
||||
const wrapper = mount(ThreadCard, {
|
||||
props: { thread },
|
||||
})
|
||||
expect(wrapper.find('.lock-icon').exists()).toBe(true)
|
||||
expect(wrapper.find('.badge-locked').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('should not show lock icon when thread is not locked', () => {
|
||||
const thread = createMockThread({ isLocked: false })
|
||||
const wrapper = mount(ThreadCard, {
|
||||
props: { thread },
|
||||
})
|
||||
expect(wrapper.find('.lock-icon').exists()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('CSS classes', () => {
|
||||
it('should have pinned class when thread is pinned', () => {
|
||||
const thread = createMockThread({ isPinned: true })
|
||||
const wrapper = mount(ThreadCard, {
|
||||
props: { thread },
|
||||
})
|
||||
expect(wrapper.find('.thread-card').classes()).toContain('pinned')
|
||||
})
|
||||
|
||||
it('should have locked class when thread is locked', () => {
|
||||
const thread = createMockThread({ isLocked: true })
|
||||
const wrapper = mount(ThreadCard, {
|
||||
props: { thread },
|
||||
})
|
||||
expect(wrapper.find('.thread-card').classes()).toContain('locked')
|
||||
})
|
||||
|
||||
it('should have unread class when isUnread prop is true', () => {
|
||||
const thread = createMockThread()
|
||||
const wrapper = mount(ThreadCard, {
|
||||
props: { thread, isUnread: true },
|
||||
})
|
||||
expect(wrapper.find('.thread-card').classes()).toContain('unread')
|
||||
})
|
||||
|
||||
it('should show unread indicator when isUnread is true', () => {
|
||||
const thread = createMockThread()
|
||||
const wrapper = mount(ThreadCard, {
|
||||
props: { thread, isUnread: true },
|
||||
})
|
||||
expect(wrapper.find('.unread-indicator').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('should not show unread indicator by default', () => {
|
||||
const thread = createMockThread()
|
||||
const wrapper = mount(ThreadCard, {
|
||||
props: { thread },
|
||||
})
|
||||
expect(wrapper.find('.unread-indicator').exists()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('stats handling', () => {
|
||||
it('should handle zero counts', () => {
|
||||
const thread = createMockThread({ postCount: 0, viewCount: 0 })
|
||||
const wrapper = mount(ThreadCard, {
|
||||
props: { thread },
|
||||
})
|
||||
const statValues = wrapper.findAll('.stat-value')
|
||||
expect(statValues[0]!.text()).toBe('0')
|
||||
expect(statValues[1]!.text()).toBe('0')
|
||||
})
|
||||
})
|
||||
})
|
||||
512
src/components/ThreadCreateForm/ThreadCreateForm.test.ts
Normal file
512
src/components/ThreadCreateForm/ThreadCreateForm.test.ts
Normal file
@@ -0,0 +1,512 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { mount, flushPromises } from '@vue/test-utils'
|
||||
import { ref, computed } from 'vue'
|
||||
import { createIconMock, createComponentMock } from '@/test-utils'
|
||||
|
||||
// Mock useCurrentUser composable
|
||||
vi.mock('@/composables/useCurrentUser', () => ({
|
||||
useCurrentUser: () => ({
|
||||
userId: computed(() => 'testuser'),
|
||||
displayName: computed(() => 'Test User'),
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock icons
|
||||
vi.mock('@icons/Check.vue', () => createIconMock('CheckIcon'))
|
||||
vi.mock('@icons/ContentSave.vue', () => createIconMock('ContentSaveIcon'))
|
||||
vi.mock('@icons/ContentSaveCheck.vue', () => createIconMock('ContentSaveCheckIcon'))
|
||||
vi.mock('@icons/ContentSaveAlert.vue', () => createIconMock('ContentSaveAlertIcon'))
|
||||
|
||||
// Mock UserInfo component
|
||||
vi.mock('@/components/UserInfo', () =>
|
||||
createComponentMock('UserInfo', {
|
||||
template: '<div class="user-info-mock">{{ displayName }}</div>',
|
||||
props: ['userId', 'displayName', 'avatarSize', 'clickable'],
|
||||
}),
|
||||
)
|
||||
|
||||
// Mock BBCodeEditor component
|
||||
vi.mock('@/components/BBCodeEditor', () => ({
|
||||
default: {
|
||||
name: 'BBCodeEditor',
|
||||
template:
|
||||
'<textarea class="bbcode-editor-mock" :value="modelValue" @input="$emit(\'update:modelValue\', $event.target.value)" :disabled="disabled" />',
|
||||
props: ['modelValue', 'placeholder', 'rows', 'disabled', 'minHeight'],
|
||||
emits: ['update:modelValue'],
|
||||
methods: {
|
||||
focus() {},
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
// Import after mocks
|
||||
import ThreadCreateForm from './ThreadCreateForm.vue'
|
||||
|
||||
describe('ThreadCreateForm', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.stubGlobal(
|
||||
'confirm',
|
||||
vi.fn(() => true),
|
||||
)
|
||||
})
|
||||
|
||||
const createWrapper = (props = {}) => {
|
||||
return mount(ThreadCreateForm, {
|
||||
props: {
|
||||
...props,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
describe('rendering', () => {
|
||||
it('renders the form', () => {
|
||||
const wrapper = createWrapper()
|
||||
expect(wrapper.find('.thread-create-form').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('renders user info header', () => {
|
||||
const wrapper = createWrapper()
|
||||
expect(wrapper.find('.user-info-mock').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('renders title input', () => {
|
||||
const wrapper = createWrapper()
|
||||
expect(wrapper.find('.nc-text-field').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('renders BBCode editor', () => {
|
||||
const wrapper = createWrapper()
|
||||
expect(wrapper.find('.bbcode-editor-mock').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('renders cancel button', () => {
|
||||
const wrapper = createWrapper()
|
||||
const buttons = wrapper.findAll('button')
|
||||
expect(buttons.some((b) => b.text() === 'Cancel')).toBe(true)
|
||||
})
|
||||
|
||||
it('renders submit button', () => {
|
||||
const wrapper = createWrapper()
|
||||
const buttons = wrapper.findAll('button')
|
||||
expect(buttons.some((b) => b.text() === 'Create thread')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('draft status', () => {
|
||||
it('shows saving status', () => {
|
||||
const wrapper = createWrapper({ draftStatus: 'saving' })
|
||||
expect(wrapper.text()).toContain('Saving draft')
|
||||
})
|
||||
|
||||
it('shows saved status', () => {
|
||||
const wrapper = createWrapper({ draftStatus: 'saved' })
|
||||
expect(wrapper.text()).toContain('Draft saved')
|
||||
})
|
||||
|
||||
it('shows dirty status', () => {
|
||||
const wrapper = createWrapper({ draftStatus: 'dirty' })
|
||||
expect(wrapper.text()).toContain('Unsaved changes')
|
||||
})
|
||||
|
||||
it('hides status when null', () => {
|
||||
const wrapper = createWrapper({ draftStatus: null })
|
||||
expect(wrapper.text()).not.toContain('Saving draft')
|
||||
expect(wrapper.text()).not.toContain('Draft saved')
|
||||
expect(wrapper.text()).not.toContain('Unsaved changes')
|
||||
})
|
||||
|
||||
it('displays saving icon for saving status', () => {
|
||||
const wrapper = createWrapper({ draftStatus: 'saving' })
|
||||
expect(wrapper.find('.content-save-icon').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('displays saved icon for saved status', () => {
|
||||
const wrapper = createWrapper({ draftStatus: 'saved' })
|
||||
expect(wrapper.find('.content-save-check-icon').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('displays dirty icon for dirty status', () => {
|
||||
const wrapper = createWrapper({ draftStatus: 'dirty' })
|
||||
expect(wrapper.find('.content-save-alert-icon').exists()).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('validation', () => {
|
||||
it('disables submit when title is empty', () => {
|
||||
const wrapper = createWrapper()
|
||||
const vm = wrapper.vm as unknown as { canSubmit: boolean }
|
||||
expect(vm.canSubmit).toBe(false)
|
||||
})
|
||||
|
||||
it('disables submit when content is empty', async () => {
|
||||
const wrapper = createWrapper()
|
||||
|
||||
const vm = wrapper.vm as unknown as { title: string; canSubmit: boolean }
|
||||
vm.title = 'Test Title'
|
||||
|
||||
await flushPromises()
|
||||
|
||||
expect(vm.canSubmit).toBe(false)
|
||||
})
|
||||
|
||||
it('enables submit when both title and content have values', async () => {
|
||||
const wrapper = createWrapper()
|
||||
|
||||
const vm = wrapper.vm as unknown as { title: string; content: string; canSubmit: boolean }
|
||||
vm.title = 'Test Title'
|
||||
vm.content = 'Test Content'
|
||||
|
||||
await flushPromises()
|
||||
|
||||
expect(vm.canSubmit).toBe(true)
|
||||
})
|
||||
|
||||
it('disables submit when title is only whitespace', async () => {
|
||||
const wrapper = createWrapper()
|
||||
|
||||
const vm = wrapper.vm as unknown as { title: string; content: string; canSubmit: boolean }
|
||||
vm.title = ' '
|
||||
vm.content = 'Test Content'
|
||||
|
||||
await flushPromises()
|
||||
|
||||
expect(vm.canSubmit).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('submitting', () => {
|
||||
it('emits submit event with trimmed data', async () => {
|
||||
const wrapper = createWrapper()
|
||||
|
||||
const vm = wrapper.vm as unknown as {
|
||||
title: string
|
||||
content: string
|
||||
submitThread: () => Promise<void>
|
||||
}
|
||||
vm.title = ' Test Title '
|
||||
vm.content = ' Test Content '
|
||||
|
||||
await vm.submitThread()
|
||||
|
||||
expect(wrapper.emitted('submit')).toBeTruthy()
|
||||
expect(wrapper.emitted('submit')![0]).toEqual([
|
||||
{ title: 'Test Title', content: 'Test Content' },
|
||||
])
|
||||
})
|
||||
|
||||
it('sets submitting state to true', async () => {
|
||||
const wrapper = createWrapper()
|
||||
|
||||
const vm = wrapper.vm as unknown as {
|
||||
title: string
|
||||
content: string
|
||||
submitting: boolean
|
||||
submitThread: () => Promise<void>
|
||||
}
|
||||
vm.title = 'Test Title'
|
||||
vm.content = 'Test Content'
|
||||
|
||||
await vm.submitThread()
|
||||
|
||||
expect(vm.submitting).toBe(true)
|
||||
})
|
||||
|
||||
it('does not submit when already submitting', async () => {
|
||||
const wrapper = createWrapper()
|
||||
|
||||
const vm = wrapper.vm as unknown as {
|
||||
title: string
|
||||
content: string
|
||||
submitting: boolean
|
||||
submitThread: () => Promise<void>
|
||||
}
|
||||
vm.title = 'Test Title'
|
||||
vm.content = 'Test Content'
|
||||
vm.submitting = true
|
||||
|
||||
await vm.submitThread()
|
||||
|
||||
expect(wrapper.emitted('submit')).toBeFalsy()
|
||||
})
|
||||
|
||||
it('does not submit when validation fails', async () => {
|
||||
const wrapper = createWrapper()
|
||||
|
||||
const vm = wrapper.vm as unknown as {
|
||||
title: string
|
||||
content: string
|
||||
submitThread: () => Promise<void>
|
||||
}
|
||||
vm.title = ''
|
||||
vm.content = ''
|
||||
|
||||
await vm.submitThread()
|
||||
|
||||
expect(wrapper.emitted('submit')).toBeFalsy()
|
||||
})
|
||||
})
|
||||
|
||||
describe('canceling', () => {
|
||||
it('emits cancel event when cancel is clicked without content', async () => {
|
||||
const wrapper = createWrapper()
|
||||
|
||||
const vm = wrapper.vm as unknown as { cancel: () => void }
|
||||
vm.cancel()
|
||||
|
||||
expect(wrapper.emitted('cancel')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('shows confirmation when content exists', async () => {
|
||||
const wrapper = createWrapper()
|
||||
|
||||
const vm = wrapper.vm as unknown as { title: string; content: string; cancel: () => void }
|
||||
vm.title = 'Test Title'
|
||||
vm.content = 'Test Content'
|
||||
|
||||
vm.cancel()
|
||||
|
||||
expect(window.confirm).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('emits cancel event when confirmed', async () => {
|
||||
vi.stubGlobal(
|
||||
'confirm',
|
||||
vi.fn(() => true),
|
||||
)
|
||||
|
||||
const wrapper = createWrapper()
|
||||
|
||||
const vm = wrapper.vm as unknown as { title: string; content: string; cancel: () => void }
|
||||
vm.title = 'Test Title'
|
||||
vm.content = 'Test Content'
|
||||
|
||||
vm.cancel()
|
||||
|
||||
expect(wrapper.emitted('cancel')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('does not emit cancel event when not confirmed', async () => {
|
||||
vi.stubGlobal(
|
||||
'confirm',
|
||||
vi.fn(() => false),
|
||||
)
|
||||
|
||||
const wrapper = createWrapper()
|
||||
|
||||
const vm = wrapper.vm as unknown as { title: string; content: string; cancel: () => void }
|
||||
vm.title = 'Test Title'
|
||||
vm.content = 'Test Content'
|
||||
|
||||
vm.cancel()
|
||||
|
||||
expect(wrapper.emitted('cancel')).toBeFalsy()
|
||||
})
|
||||
|
||||
it('clears title and content on cancel', async () => {
|
||||
vi.stubGlobal(
|
||||
'confirm',
|
||||
vi.fn(() => true),
|
||||
)
|
||||
|
||||
const wrapper = createWrapper()
|
||||
|
||||
const vm = wrapper.vm as unknown as {
|
||||
title: string
|
||||
content: string
|
||||
cancel: () => void
|
||||
}
|
||||
vm.title = 'Test Title'
|
||||
vm.content = 'Test Content'
|
||||
|
||||
vm.cancel()
|
||||
|
||||
expect(vm.title).toBe('')
|
||||
expect(vm.content).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('update events', () => {
|
||||
it('emits update:title when title changes', async () => {
|
||||
const wrapper = createWrapper()
|
||||
|
||||
const vm = wrapper.vm as unknown as { title: string }
|
||||
vm.title = 'New Title'
|
||||
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.emitted('update:title')).toBeTruthy()
|
||||
expect(wrapper.emitted('update:title')![0]).toEqual(['New Title'])
|
||||
})
|
||||
|
||||
it('emits update:content when content changes', async () => {
|
||||
const wrapper = createWrapper()
|
||||
|
||||
const vm = wrapper.vm as unknown as { content: string }
|
||||
vm.content = 'New Content'
|
||||
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.emitted('update:content')).toBeTruthy()
|
||||
expect(wrapper.emitted('update:content')![0]).toEqual(['New Content'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('clear method', () => {
|
||||
it('resets title, content, and submitting state', () => {
|
||||
const wrapper = createWrapper()
|
||||
|
||||
const vm = wrapper.vm as unknown as {
|
||||
title: string
|
||||
content: string
|
||||
submitting: boolean
|
||||
clear: () => void
|
||||
}
|
||||
vm.title = 'Test Title'
|
||||
vm.content = 'Test Content'
|
||||
vm.submitting = true
|
||||
|
||||
vm.clear()
|
||||
|
||||
expect(vm.title).toBe('')
|
||||
expect(vm.content).toBe('')
|
||||
expect(vm.submitting).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('setSubmitting method', () => {
|
||||
it('sets submitting state', () => {
|
||||
const wrapper = createWrapper()
|
||||
|
||||
const vm = wrapper.vm as unknown as {
|
||||
submitting: boolean
|
||||
setSubmitting: (value: boolean) => void
|
||||
}
|
||||
|
||||
vm.setSubmitting(true)
|
||||
expect(vm.submitting).toBe(true)
|
||||
|
||||
vm.setSubmitting(false)
|
||||
expect(vm.submitting).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('setTitle method', () => {
|
||||
it('sets title value', () => {
|
||||
const wrapper = createWrapper()
|
||||
|
||||
const vm = wrapper.vm as unknown as {
|
||||
title: string
|
||||
setTitle: (value: string) => void
|
||||
}
|
||||
|
||||
vm.setTitle('New Title')
|
||||
expect(vm.title).toBe('New Title')
|
||||
})
|
||||
})
|
||||
|
||||
describe('setContent method', () => {
|
||||
it('sets content value', () => {
|
||||
const wrapper = createWrapper()
|
||||
|
||||
const vm = wrapper.vm as unknown as {
|
||||
content: string
|
||||
setContent: (value: string) => void
|
||||
}
|
||||
|
||||
vm.setContent('New Content')
|
||||
expect(vm.content).toBe('New Content')
|
||||
})
|
||||
})
|
||||
|
||||
describe('hasContent computed', () => {
|
||||
it('returns false when both title and content are empty', () => {
|
||||
const wrapper = createWrapper()
|
||||
|
||||
const vm = wrapper.vm as unknown as { hasContent: boolean }
|
||||
expect(vm.hasContent).toBe(false)
|
||||
})
|
||||
|
||||
it('returns true when title has content', async () => {
|
||||
const wrapper = createWrapper()
|
||||
|
||||
const vm = wrapper.vm as unknown as { title: string; hasContent: boolean }
|
||||
vm.title = 'Test'
|
||||
|
||||
await flushPromises()
|
||||
|
||||
expect(vm.hasContent).toBe(true)
|
||||
})
|
||||
|
||||
it('returns true when content has content', async () => {
|
||||
const wrapper = createWrapper()
|
||||
|
||||
const vm = wrapper.vm as unknown as { content: string; hasContent: boolean }
|
||||
vm.content = 'Test'
|
||||
|
||||
await flushPromises()
|
||||
|
||||
expect(vm.hasContent).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false when title and content are only whitespace', () => {
|
||||
const wrapper = createWrapper()
|
||||
|
||||
const vm = wrapper.vm as unknown as { title: string; content: string; hasContent: boolean }
|
||||
vm.title = ' '
|
||||
vm.content = ' '
|
||||
|
||||
expect(vm.hasContent).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('disabled state', () => {
|
||||
it('disables inputs when submitting', async () => {
|
||||
const wrapper = createWrapper()
|
||||
|
||||
const vm = wrapper.vm as unknown as {
|
||||
title: string
|
||||
content: string
|
||||
submitting: boolean
|
||||
}
|
||||
vm.title = 'Test'
|
||||
vm.content = 'Test'
|
||||
vm.submitting = true
|
||||
|
||||
await flushPromises()
|
||||
|
||||
const titleInput = wrapper.find('.nc-text-field')
|
||||
expect(titleInput.attributes('disabled')).toBeDefined()
|
||||
})
|
||||
|
||||
it('disables cancel button when submitting', async () => {
|
||||
const wrapper = createWrapper()
|
||||
|
||||
const vm = wrapper.vm as unknown as { submitting: boolean }
|
||||
vm.submitting = true
|
||||
|
||||
await flushPromises()
|
||||
|
||||
const cancelButton = wrapper.findAll('button').find((b) => b.text() === 'Cancel')
|
||||
expect(cancelButton!.attributes('disabled')).toBeDefined()
|
||||
})
|
||||
|
||||
it('disables submit button when submitting', async () => {
|
||||
const wrapper = createWrapper()
|
||||
|
||||
const vm = wrapper.vm as unknown as {
|
||||
title: string
|
||||
content: string
|
||||
submitting: boolean
|
||||
}
|
||||
vm.title = 'Test'
|
||||
vm.content = 'Test'
|
||||
vm.submitting = true
|
||||
|
||||
await flushPromises()
|
||||
|
||||
const submitButton = wrapper.findAll('button').find((b) => b.text() === 'Create thread')
|
||||
expect(submitButton!.attributes('disabled')).toBeDefined()
|
||||
})
|
||||
})
|
||||
})
|
||||
119
src/components/UserAvatar/UserAvatar.test.ts
Normal file
119
src/components/UserAvatar/UserAvatar.test.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import UserAvatar from './UserAvatar.vue'
|
||||
|
||||
// Uses global mock for @nextcloud/vue/components/NcAvatar from test-setup.ts
|
||||
|
||||
const mockPush = vi.fn()
|
||||
vi.mock('vue-router', () => ({
|
||||
useRouter: () => ({ push: mockPush }),
|
||||
}))
|
||||
|
||||
describe('UserAvatar', () => {
|
||||
beforeEach(() => {
|
||||
mockPush.mockClear()
|
||||
})
|
||||
|
||||
describe('rendering', () => {
|
||||
it('should render avatar for active user', () => {
|
||||
const wrapper = mount(UserAvatar, {
|
||||
props: { userId: 'testuser' },
|
||||
global: { mocks: { $router: { push: mockPush } } },
|
||||
})
|
||||
expect(wrapper.find('.user-avatar').exists()).toBe(true)
|
||||
expect(wrapper.find('.nc-avatar-mock').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('should pass userId to NcAvatar', () => {
|
||||
const wrapper = mount(UserAvatar, {
|
||||
props: { userId: 'john_doe' },
|
||||
global: { mocks: { $router: { push: mockPush } } },
|
||||
})
|
||||
expect(wrapper.find('.nc-avatar-mock').attributes('data-user')).toBe('john_doe')
|
||||
})
|
||||
|
||||
it('should pass size to NcAvatar', () => {
|
||||
const wrapper = mount(UserAvatar, {
|
||||
props: { userId: 'testuser', size: 48 },
|
||||
global: { mocks: { $router: { push: mockPush } } },
|
||||
})
|
||||
expect(wrapper.find('.nc-avatar-mock').attributes('data-size')).toBe('48')
|
||||
})
|
||||
|
||||
it('should apply height style based on size', () => {
|
||||
const wrapper = mount(UserAvatar, {
|
||||
props: { userId: 'testuser', size: 64 },
|
||||
global: { mocks: { $router: { push: mockPush } } },
|
||||
})
|
||||
expect(wrapper.find('.user-avatar').attributes('style')).toContain('height: 64px')
|
||||
})
|
||||
})
|
||||
|
||||
describe('deleted user', () => {
|
||||
it('should render differently for deleted user', () => {
|
||||
const wrapper = mount(UserAvatar, {
|
||||
props: { userId: 'deleteduser', isDeleted: true, displayName: 'Deleted User' },
|
||||
global: { mocks: { $router: { push: mockPush } } },
|
||||
})
|
||||
expect(wrapper.find('.user-avatar').exists()).toBe(true)
|
||||
expect(wrapper.find('.user-avatar').classes()).not.toContain('clickable')
|
||||
})
|
||||
})
|
||||
|
||||
describe('clickable behavior', () => {
|
||||
it('should have clickable class by default', () => {
|
||||
const wrapper = mount(UserAvatar, {
|
||||
props: { userId: 'testuser' },
|
||||
global: { mocks: { $router: { push: mockPush } } },
|
||||
})
|
||||
expect(wrapper.find('.user-avatar').classes()).toContain('clickable')
|
||||
})
|
||||
|
||||
it('should not have clickable class when clickable is false', () => {
|
||||
const wrapper = mount(UserAvatar, {
|
||||
props: { userId: 'testuser', clickable: false },
|
||||
global: { mocks: { $router: { push: mockPush } } },
|
||||
})
|
||||
expect(wrapper.find('.user-avatar').classes()).not.toContain('clickable')
|
||||
})
|
||||
|
||||
it('should not be clickable when user is deleted', () => {
|
||||
const wrapper = mount(UserAvatar, {
|
||||
props: { userId: 'testuser', isDeleted: true, clickable: true },
|
||||
global: { mocks: { $router: { push: mockPush } } },
|
||||
})
|
||||
expect(wrapper.find('.user-avatar').classes()).not.toContain('clickable')
|
||||
})
|
||||
|
||||
it('should emit click and navigate when clicked', async () => {
|
||||
const wrapper = mount(UserAvatar, {
|
||||
props: { userId: 'testuser' },
|
||||
global: { mocks: { $router: { push: mockPush } } },
|
||||
})
|
||||
await wrapper.find('.user-avatar').trigger('click')
|
||||
expect(wrapper.emitted('click')).toBeTruthy()
|
||||
expect(wrapper.emitted('click')![0]).toEqual(['testuser'])
|
||||
expect(mockPush).toHaveBeenCalledWith('/u/testuser')
|
||||
})
|
||||
|
||||
it('should not navigate when not clickable', async () => {
|
||||
const wrapper = mount(UserAvatar, {
|
||||
props: { userId: 'testuser', clickable: false },
|
||||
global: { mocks: { $router: { push: mockPush } } },
|
||||
})
|
||||
await wrapper.find('.user-avatar').trigger('click')
|
||||
expect(wrapper.emitted('click')).toBeFalsy()
|
||||
expect(mockPush).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('default props', () => {
|
||||
it('should use default size of 32', () => {
|
||||
const wrapper = mount(UserAvatar, {
|
||||
props: { userId: 'testuser' },
|
||||
global: { mocks: { $router: { push: mockPush } } },
|
||||
})
|
||||
expect(wrapper.find('.nc-avatar-mock').attributes('data-size')).toBe('32')
|
||||
})
|
||||
})
|
||||
})
|
||||
137
src/components/UserInfo/UserInfo.test.ts
Normal file
137
src/components/UserInfo/UserInfo.test.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { createComponentMock } from '@/test-utils'
|
||||
import { createMockRole } from '@/test-mocks'
|
||||
import UserInfo from './UserInfo.vue'
|
||||
|
||||
vi.mock('@/components/UserAvatar', () =>
|
||||
createComponentMock('UserAvatar', {
|
||||
template: '<div class="user-avatar-mock" :data-user-id="userId"></div>',
|
||||
props: ['userId', 'displayName', 'size', 'isDeleted', 'clickable'],
|
||||
}),
|
||||
)
|
||||
|
||||
vi.mock('@/components/RoleBadge', () =>
|
||||
createComponentMock('RoleBadge', {
|
||||
template: '<span class="role-badge-mock">{{ role.name }}</span>',
|
||||
props: ['role', 'density'],
|
||||
}),
|
||||
)
|
||||
|
||||
const mockPush = vi.fn()
|
||||
|
||||
describe('UserInfo', () => {
|
||||
describe('rendering', () => {
|
||||
it('should render user avatar', () => {
|
||||
const wrapper = mount(UserInfo, {
|
||||
props: { userId: 'testuser', displayName: 'Test User' },
|
||||
global: { mocks: { $router: { push: mockPush } } },
|
||||
})
|
||||
expect(wrapper.find('.user-avatar-mock').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('should render user name', () => {
|
||||
const wrapper = mount(UserInfo, {
|
||||
props: { userId: 'testuser', displayName: 'Test User' },
|
||||
global: { mocks: { $router: { push: mockPush } } },
|
||||
})
|
||||
expect(wrapper.find('.user-name').text()).toBe('Test User')
|
||||
})
|
||||
|
||||
it('should fallback to userId when displayName is empty', () => {
|
||||
const wrapper = mount(UserInfo, {
|
||||
props: { userId: 'testuser', displayName: '' },
|
||||
global: { mocks: { $router: { push: mockPush } } },
|
||||
})
|
||||
expect(wrapper.find('.user-name').text()).toBe('testuser')
|
||||
})
|
||||
})
|
||||
|
||||
describe('layout', () => {
|
||||
it('should apply column layout by default', () => {
|
||||
const wrapper = mount(UserInfo, {
|
||||
props: { userId: 'testuser' },
|
||||
global: { mocks: { $router: { push: mockPush } } },
|
||||
})
|
||||
expect(wrapper.find('.user-info-component').classes()).not.toContain('layout-inline')
|
||||
})
|
||||
|
||||
it('should apply inline layout when specified', () => {
|
||||
const wrapper = mount(UserInfo, {
|
||||
props: { userId: 'testuser', layout: 'inline' },
|
||||
global: { mocks: { $router: { push: mockPush } } },
|
||||
})
|
||||
expect(wrapper.find('.user-info-component').classes()).toContain('layout-inline')
|
||||
})
|
||||
})
|
||||
|
||||
describe('deleted user', () => {
|
||||
it('should apply deleted-user class for deleted users', () => {
|
||||
const wrapper = mount(UserInfo, {
|
||||
props: { userId: 'testuser', isDeleted: true },
|
||||
global: { mocks: { $router: { push: mockPush } } },
|
||||
})
|
||||
expect(wrapper.find('.user-name').classes()).toContain('deleted-user')
|
||||
})
|
||||
|
||||
it('should not be clickable when user is deleted', () => {
|
||||
const wrapper = mount(UserInfo, {
|
||||
props: { userId: 'testuser', isDeleted: true, clickable: true },
|
||||
global: { mocks: { $router: { push: mockPush } } },
|
||||
})
|
||||
expect(wrapper.find('.user-name').classes()).not.toContain('clickable')
|
||||
})
|
||||
})
|
||||
|
||||
describe('roles', () => {
|
||||
it('should display admin role', () => {
|
||||
const adminRole = createMockRole({ id: 1, name: 'Admin', roleType: 'admin' })
|
||||
const wrapper = mount(UserInfo, {
|
||||
props: { userId: 'testuser', roles: [adminRole] },
|
||||
global: { mocks: { $router: { push: mockPush } } },
|
||||
})
|
||||
expect(wrapper.find('.role-badge-mock').exists()).toBe(true)
|
||||
expect(wrapper.find('.role-badge-mock').text()).toBe('Admin')
|
||||
})
|
||||
|
||||
it('should display custom roles', () => {
|
||||
const customRole = createMockRole({ id: 10, name: 'VIP', roleType: 'custom' })
|
||||
const wrapper = mount(UserInfo, {
|
||||
props: { userId: 'testuser', roles: [customRole] },
|
||||
global: { mocks: { $router: { push: mockPush } } },
|
||||
})
|
||||
expect(wrapper.find('.role-badge-mock').text()).toBe('VIP')
|
||||
})
|
||||
|
||||
it('should hide roles when showRoles is false', () => {
|
||||
const adminRole = createMockRole({ id: 1, name: 'Admin', roleType: 'admin' })
|
||||
const wrapper = mount(UserInfo, {
|
||||
props: { userId: 'testuser', roles: [adminRole], showRoles: false },
|
||||
global: { mocks: { $router: { push: mockPush } } },
|
||||
})
|
||||
expect(wrapper.find('.role-badge-mock').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('should not display default role', () => {
|
||||
const defaultRole = createMockRole({ id: 3, name: 'User', roleType: 'default' })
|
||||
const wrapper = mount(UserInfo, {
|
||||
props: { userId: 'testuser', roles: [defaultRole] },
|
||||
global: { mocks: { $router: { push: mockPush } } },
|
||||
})
|
||||
expect(wrapper.find('.role-badge-mock').exists()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('slots', () => {
|
||||
it('should render meta slot content', () => {
|
||||
const wrapper = mount(UserInfo, {
|
||||
props: { userId: 'testuser' },
|
||||
slots: {
|
||||
meta: '<span class="test-meta">Meta content</span>',
|
||||
},
|
||||
global: { mocks: { $router: { push: mockPush } } },
|
||||
})
|
||||
expect(wrapper.find('.test-meta').exists()).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
129
src/test-mocks.ts
Normal file
129
src/test-mocks.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
import type { Category, Post, Role, Thread, User } from '@/types'
|
||||
|
||||
// ============================================================================
|
||||
// MODEL FACTORIES - Safe to use in tests (not in vi.mock factories)
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Create a mock Role object with sensible defaults.
|
||||
*
|
||||
* @param overrides - Partial Role object to override defaults
|
||||
*
|
||||
* @example
|
||||
* const role = createMockRole({ name: 'Admin', roleType: 'admin' })
|
||||
*/
|
||||
export function createMockRole(overrides: Partial<Role> = {}): Role {
|
||||
return {
|
||||
id: 100,
|
||||
name: 'Test Role',
|
||||
description: null,
|
||||
colorLight: null,
|
||||
colorDark: null,
|
||||
canAccessAdminTools: false,
|
||||
canEditRoles: false,
|
||||
canEditCategories: false,
|
||||
isSystemRole: false,
|
||||
roleType: 'custom',
|
||||
createdAt: Date.now(),
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a mock User object with sensible defaults.
|
||||
*
|
||||
* @param overrides - Partial User object to override defaults
|
||||
*
|
||||
* @example
|
||||
* const user = createMockUser({ userId: 'john', displayName: 'John Doe' })
|
||||
*/
|
||||
export function createMockUser(overrides: Partial<User> = {}): User {
|
||||
return {
|
||||
userId: 'testuser',
|
||||
displayName: 'Test User',
|
||||
isDeleted: false,
|
||||
roles: [],
|
||||
signature: null,
|
||||
signatureRaw: null,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a mock Thread object with sensible defaults.
|
||||
*
|
||||
* @param overrides - Partial Thread object to override defaults
|
||||
*
|
||||
* @example
|
||||
* const thread = createMockThread({ title: 'My Thread', isPinned: true })
|
||||
*/
|
||||
export function createMockThread(overrides: Partial<Thread> = {}): Thread {
|
||||
return {
|
||||
id: 1,
|
||||
categoryId: 1,
|
||||
authorId: 'testuser',
|
||||
title: 'Test Thread',
|
||||
slug: 'test-thread',
|
||||
viewCount: 100,
|
||||
postCount: 10,
|
||||
lastPostId: null,
|
||||
isLocked: false,
|
||||
isPinned: false,
|
||||
isHidden: false,
|
||||
createdAt: Date.now() / 1000,
|
||||
updatedAt: Date.now() / 1000,
|
||||
author: createMockUser(),
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a mock Post object with sensible defaults.
|
||||
*
|
||||
* @param overrides - Partial Post object to override defaults
|
||||
*
|
||||
* @example
|
||||
* const post = createMockPost({ content: '<p>Hello world</p>' })
|
||||
*/
|
||||
export function createMockPost(overrides: Partial<Post> = {}): Post {
|
||||
return {
|
||||
id: 1,
|
||||
threadId: 1,
|
||||
authorId: 'testuser',
|
||||
content: '<p>This is a test post content.</p>',
|
||||
contentRaw: 'This is a test post content.',
|
||||
isEdited: false,
|
||||
isFirstPost: false,
|
||||
editedAt: null,
|
||||
createdAt: Date.now() / 1000,
|
||||
updatedAt: Date.now() / 1000,
|
||||
threadTitle: 'Test Thread',
|
||||
threadSlug: 'test-thread',
|
||||
author: createMockUser(),
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a mock Category object with sensible defaults.
|
||||
*
|
||||
* @param overrides - Partial Category object to override defaults
|
||||
*
|
||||
* @example
|
||||
* const category = createMockCategory({ name: 'General Discussion' })
|
||||
*/
|
||||
export function createMockCategory(overrides: Partial<Category> = {}): Category {
|
||||
return {
|
||||
id: 1,
|
||||
headerId: 1,
|
||||
name: 'Test Category',
|
||||
description: 'Test description',
|
||||
slug: 'test-category',
|
||||
sortOrder: 0,
|
||||
threadCount: 10,
|
||||
postCount: 50,
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
135
src/test-setup.ts
Normal file
135
src/test-setup.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
/**
|
||||
* Vitest global setup file.
|
||||
*
|
||||
* This file sets up global mocks that are commonly used across tests.
|
||||
* These mocks are applied automatically to all test files.
|
||||
*/
|
||||
import { vi } from 'vitest'
|
||||
|
||||
// Mock @nextcloud/l10n globally
|
||||
vi.mock('@nextcloud/l10n', () => ({
|
||||
t: (_app: string, text: string, vars?: Record<string, unknown>) => {
|
||||
if (vars) {
|
||||
return Object.entries(vars).reduce(
|
||||
(acc, [key, value]) => acc.replace(`{${key}}`, String(value)),
|
||||
text,
|
||||
)
|
||||
}
|
||||
return text
|
||||
},
|
||||
n: (
|
||||
_app: string,
|
||||
singular: string,
|
||||
plural: string,
|
||||
count: number,
|
||||
vars?: Record<string, unknown>,
|
||||
) => {
|
||||
let result = count === 1 ? singular : plural
|
||||
if (vars) {
|
||||
result = Object.entries(vars).reduce(
|
||||
(acc, [key, value]) => acc.replace(`{${key}}`, String(value)),
|
||||
result,
|
||||
)
|
||||
}
|
||||
return result
|
||||
},
|
||||
}))
|
||||
|
||||
// Mock @nextcloud/router globally
|
||||
vi.mock('@nextcloud/router', () => ({
|
||||
generateUrl: (path: string) => path,
|
||||
}))
|
||||
|
||||
// Mock @nextcloud/vue/functions/isDarkTheme globally (defaults to light theme)
|
||||
vi.mock('@nextcloud/vue/functions/isDarkTheme', () => ({
|
||||
isDarkTheme: false,
|
||||
}))
|
||||
|
||||
// Mock @nextcloud/vue components globally
|
||||
vi.mock('@nextcloud/vue/components/NcDateTime', () => ({
|
||||
default: {
|
||||
name: 'NcDateTime',
|
||||
template: '<span class="nc-datetime" :data-timestamp="timestamp" />',
|
||||
props: ['timestamp'],
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@nextcloud/vue/components/NcButton', () => ({
|
||||
default: {
|
||||
name: 'NcButton',
|
||||
template:
|
||||
'<button :disabled="disabled" :href="href" @click="$emit(\'click\')"><slot /><slot name="icon" /></button>',
|
||||
props: ['variant', 'disabled', 'ariaLabel', 'title', 'href'],
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@nextcloud/vue/components/NcAvatar', () => ({
|
||||
default: {
|
||||
name: 'NcAvatar',
|
||||
template: '<div class="nc-avatar-mock" :data-user="user" :data-size="size"></div>',
|
||||
props: ['user', 'displayName', 'size'],
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@nextcloud/vue/components/NcEmptyContent', () => ({
|
||||
default: {
|
||||
name: 'NcEmptyContent',
|
||||
template:
|
||||
'<div class="nc-empty-content"><slot name="icon" /><span class="title">{{ title }}</span><span class="description">{{ description }}</span><slot name="action" /></div>',
|
||||
props: ['title', 'description'],
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@nextcloud/vue/components/NcDialog', () => ({
|
||||
default: {
|
||||
name: 'NcDialog',
|
||||
template: '<div class="nc-dialog" v-if="open"><slot /><slot name="actions" /></div>',
|
||||
props: ['name', 'open', 'size'],
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@nextcloud/vue/components/NcLoadingIcon', () => ({
|
||||
default: {
|
||||
name: 'NcLoadingIcon',
|
||||
template: '<span class="nc-loading-icon" />',
|
||||
props: ['size'],
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@nextcloud/vue/components/NcTextField', () => ({
|
||||
default: {
|
||||
name: 'NcTextField',
|
||||
template:
|
||||
'<input class="nc-text-field" :value="modelValue" @input="$emit(\'update:modelValue\', $event.target.value)" :disabled="disabled" :placeholder="placeholder" />',
|
||||
props: ['modelValue', 'label', 'placeholder', 'disabled', 'required', 'type'],
|
||||
emits: ['update:modelValue'],
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@nextcloud/vue/components/NcTextArea', () => ({
|
||||
default: {
|
||||
name: 'NcTextArea',
|
||||
template:
|
||||
'<textarea class="nc-text-area" :value="modelValue" @input="$emit(\'update:modelValue\', $event.target.value)" :disabled="disabled" :placeholder="placeholder" />',
|
||||
props: ['modelValue', 'label', 'placeholder', 'disabled', 'rows'],
|
||||
emits: ['update:modelValue'],
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@nextcloud/vue/components/NcSelect', () => ({
|
||||
default: {
|
||||
name: 'NcSelect',
|
||||
template:
|
||||
'<select class="nc-select" :value="modelValue" @change="$emit(\'update:modelValue\', $event.target.value)"><slot /></select>',
|
||||
props: ['modelValue', 'options', 'placeholder', 'label', 'trackBy', 'clearable'],
|
||||
emits: ['update:modelValue'],
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@nextcloud/vue/components/NcNoteCard', () => ({
|
||||
default: {
|
||||
name: 'NcNoteCard',
|
||||
template: '<div class="nc-note-card" :data-type="type"><slot /></div>',
|
||||
props: ['type'],
|
||||
},
|
||||
}))
|
||||
60
src/test-utils.ts
Normal file
60
src/test-utils.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* Test utilities and mock factories for Nextcloud dependencies.
|
||||
*
|
||||
* Usage in test files:
|
||||
*
|
||||
* import { vi } from 'vitest'
|
||||
* import { createIconMock, createComponentMock } from '@/test-utils'
|
||||
*
|
||||
* vi.mock('@icons/Check.vue', () => createIconMock('CheckIcon'))
|
||||
* vi.mock('@/components/UserInfo', () => createComponentMock('UserInfo', { ... }))
|
||||
*/
|
||||
|
||||
/**
|
||||
* Create a mock for an icon component from vue-material-design-icons.
|
||||
*
|
||||
* @param name - Component name (e.g., 'CheckIcon')
|
||||
* @param className - Optional CSS class (defaults to kebab-case of name)
|
||||
* @returns Mock factory object for vi.mock()
|
||||
*
|
||||
* @example
|
||||
* vi.mock('@icons/Check.vue', () => createIconMock('CheckIcon'))
|
||||
* // Creates: <span class="check-icon" data-icon="CheckIcon" />
|
||||
*/
|
||||
export function createIconMock(name: string, className?: string) {
|
||||
const cssClass = className ?? name.replace(/([a-z])([A-Z])/g, '$1-$2').toLowerCase()
|
||||
return {
|
||||
default: {
|
||||
name,
|
||||
template: `<span class="${cssClass}" data-icon="${name}" />`,
|
||||
props: ['size'],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a mock for a Vue component.
|
||||
*
|
||||
* @param name - Component name
|
||||
* @param options - Optional template and props configuration
|
||||
* @returns Mock factory object for vi.mock()
|
||||
*
|
||||
* @example
|
||||
* vi.mock('@/components/UserInfo', () => createComponentMock('UserInfo', {
|
||||
* template: '<div class="user-info-mock"><slot name="meta" /></div>',
|
||||
* props: ['userId', 'displayName'],
|
||||
* }))
|
||||
*/
|
||||
export function createComponentMock(
|
||||
name: string,
|
||||
options: { template?: string; props?: string[] } = {},
|
||||
) {
|
||||
const className = name.replace(/([a-z])([A-Z])/g, '$1-$2').toLowerCase()
|
||||
return {
|
||||
default: {
|
||||
name,
|
||||
template: options.template ?? `<div class="${className}-mock" />`,
|
||||
props: options.props ?? [],
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -14,5 +14,6 @@ export default defineConfig({
|
||||
environment: 'happy-dom',
|
||||
include: ['src/**/*.{test,spec}.{js,ts}'],
|
||||
globals: true,
|
||||
setupFiles: ['src/test-setup.ts'],
|
||||
},
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user