mirror of
https://github.com/chenasraf/nextcloud-pantry.git
synced 2026-05-17 17:28:01 +00:00
test: add frontend tests
This commit is contained in:
441
src/components/CategoryPicker/CategoryPicker.test.ts
Normal file
441
src/components/CategoryPicker/CategoryPicker.test.ts
Normal file
@@ -0,0 +1,441 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import { createIconMock, nextcloudL10nMock } from '@/test-utils'
|
||||
import type { Category } from '@/api/types'
|
||||
|
||||
// Mock @nextcloud/l10n
|
||||
vi.mock('@nextcloud/l10n', () => nextcloudL10nMock)
|
||||
|
||||
// Mock icon imports used by categoryIcons.ts
|
||||
vi.mock('@icons/Plus.vue', () => createIconMock('PlusIcon'))
|
||||
vi.mock('@icons/Tag.vue', () => createIconMock('TagIcon'))
|
||||
vi.mock('@icons/Food.vue', () => createIconMock('FoodIcon'))
|
||||
vi.mock('@icons/FoodApple.vue', () => createIconMock('FruitIcon'))
|
||||
vi.mock('@icons/Carrot.vue', () => createIconMock('VegetableIcon'))
|
||||
vi.mock('@icons/BreadSlice.vue', () => createIconMock('BakeryIcon'))
|
||||
vi.mock('@icons/Cheese.vue', () => createIconMock('DairyIcon'))
|
||||
vi.mock('@icons/FoodDrumstick.vue', () => createIconMock('MeatIcon'))
|
||||
vi.mock('@icons/Fish.vue', () => createIconMock('FishIcon'))
|
||||
vi.mock('@icons/FoodCroissant.vue', () => createIconMock('SnacksIcon'))
|
||||
vi.mock('@icons/Cookie.vue', () => createIconMock('CookieIcon'))
|
||||
vi.mock('@icons/BottleWine.vue', () => createIconMock('DrinksIcon'))
|
||||
vi.mock('@icons/Coffee.vue', () => createIconMock('CoffeeIcon'))
|
||||
vi.mock('@icons/Snowflake.vue', () => createIconMock('FrozenIcon'))
|
||||
vi.mock('@icons/Broom.vue', () => createIconMock('HouseholdIcon'))
|
||||
vi.mock('@icons/Dog.vue', () => createIconMock('PetsIcon'))
|
||||
vi.mock('@icons/Baby.vue', () => createIconMock('BabyIcon'))
|
||||
vi.mock('@icons/Home.vue', () => createIconMock('HomeIcon'))
|
||||
vi.mock('@icons/Leaf.vue', () => createIconMock('LeafIcon'))
|
||||
vi.mock('@icons/Pizza.vue', () => createIconMock('PizzaIcon'))
|
||||
|
||||
// Mock Nextcloud Vue components that pull in CSS
|
||||
vi.mock('@nextcloud/vue/components/NcSelect', () => ({
|
||||
default: {
|
||||
name: 'NcSelect',
|
||||
template: '<div class="nc-select" />',
|
||||
props: ['modelValue', 'options', 'clearable', 'placeholder', 'inputLabel', 'label'],
|
||||
emits: ['update:modelValue', 'option:selected'],
|
||||
},
|
||||
}))
|
||||
vi.mock('@nextcloud/vue/components/NcDialog', () => ({
|
||||
default: {
|
||||
name: 'NcDialog',
|
||||
template: '<div><slot /><slot name="actions" /></div>',
|
||||
props: ['name', 'open'],
|
||||
},
|
||||
}))
|
||||
vi.mock('@nextcloud/vue/components/NcTextField', () => ({
|
||||
default: {
|
||||
name: 'NcTextField',
|
||||
template: '<input />',
|
||||
props: ['modelValue', 'label', 'placeholder'],
|
||||
},
|
||||
}))
|
||||
vi.mock('@nextcloud/vue/components/NcButton', () => ({
|
||||
default: {
|
||||
name: 'NcButton',
|
||||
template: '<button><slot /><slot name="icon" /></button>',
|
||||
props: ['variant', 'disabled', 'type'],
|
||||
},
|
||||
}))
|
||||
|
||||
// Mock useCategories composable
|
||||
const mockItems = ref<Category[]>([])
|
||||
const mockLoad = vi.fn().mockResolvedValue(undefined)
|
||||
const mockCreate = vi.fn()
|
||||
|
||||
vi.mock('@/composables/useCategories', () => ({
|
||||
useCategories: () => ({
|
||||
items: mockItems,
|
||||
loading: ref(false),
|
||||
error: ref(null),
|
||||
loaded: ref(true),
|
||||
load: mockLoad,
|
||||
create: mockCreate,
|
||||
update: vi.fn(),
|
||||
remove: vi.fn(),
|
||||
findById: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
|
||||
import CategoryPicker from './CategoryPicker.vue'
|
||||
|
||||
function makeCategory(overrides: Partial<Category> = {}): Category {
|
||||
return {
|
||||
id: 1,
|
||||
houseId: 10,
|
||||
name: 'Dairy',
|
||||
icon: 'dairy',
|
||||
color: '#22c55e',
|
||||
sortOrder: 0,
|
||||
createdAt: 1000,
|
||||
updatedAt: 1000,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('CategoryPicker', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockItems.value = []
|
||||
})
|
||||
|
||||
describe('rendering', () => {
|
||||
it('renders with required props', () => {
|
||||
const wrapper = mount(CategoryPicker, {
|
||||
props: { houseId: 10, modelValue: null },
|
||||
global: {},
|
||||
})
|
||||
|
||||
expect(wrapper.exists()).toBe(true)
|
||||
expect(wrapper.find('.pantry-category-picker').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('calls load on mount', () => {
|
||||
mount(CategoryPicker, {
|
||||
props: { houseId: 10, modelValue: null },
|
||||
global: {},
|
||||
})
|
||||
|
||||
expect(mockLoad).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('shows the label when provided', () => {
|
||||
const wrapper = mount(CategoryPicker, {
|
||||
props: { houseId: 10, modelValue: null, label: 'Category' },
|
||||
global: {},
|
||||
})
|
||||
|
||||
const label = wrapper.find('.pantry-category-picker__label')
|
||||
expect(label.exists()).toBe(true)
|
||||
expect(label.text()).toBe('Category')
|
||||
})
|
||||
|
||||
it('does not show label when not provided', () => {
|
||||
const wrapper = mount(CategoryPicker, {
|
||||
props: { houseId: 10, modelValue: null },
|
||||
global: {},
|
||||
})
|
||||
|
||||
expect(wrapper.find('.pantry-category-picker__label').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('passes placeholder text to NcSelect', () => {
|
||||
const wrapper = mount(CategoryPicker, {
|
||||
props: { houseId: 10, modelValue: null, placeholder: 'Choose one' },
|
||||
global: {},
|
||||
})
|
||||
|
||||
const select = wrapper.findComponent({ name: 'NcSelect' })
|
||||
expect(select.props('placeholder')).toBe('Choose one')
|
||||
})
|
||||
|
||||
it('uses default placeholder when none provided', () => {
|
||||
const wrapper = mount(CategoryPicker, {
|
||||
props: { houseId: 10, modelValue: null },
|
||||
global: {},
|
||||
})
|
||||
|
||||
const select = wrapper.findComponent({ name: 'NcSelect' })
|
||||
expect(select.props('placeholder')).toBe('Pick a category')
|
||||
})
|
||||
})
|
||||
|
||||
describe('options', () => {
|
||||
it('renders category options from the composable', () => {
|
||||
const dairy = makeCategory({ id: 1, name: 'Dairy' })
|
||||
const produce = makeCategory({ id: 2, name: 'Produce', icon: 'fruit', color: '#ef4444' })
|
||||
mockItems.value = [dairy, produce]
|
||||
|
||||
const wrapper = mount(CategoryPicker, {
|
||||
props: { houseId: 10, modelValue: null },
|
||||
global: {},
|
||||
})
|
||||
|
||||
const select = wrapper.findComponent({ name: 'NcSelect' })
|
||||
const options = select.props('options') as Array<{
|
||||
label: string
|
||||
id?: number
|
||||
create?: boolean
|
||||
}>
|
||||
// Should have 2 category options + 1 create option
|
||||
expect(options).toHaveLength(3)
|
||||
expect(options[0]).toMatchObject({ label: 'Dairy', id: 1 })
|
||||
expect(options[1]).toMatchObject({ label: 'Produce', id: 2 })
|
||||
})
|
||||
|
||||
it('includes a "Create new category" option at the end', () => {
|
||||
mockItems.value = [makeCategory()]
|
||||
|
||||
const wrapper = mount(CategoryPicker, {
|
||||
props: { houseId: 10, modelValue: null },
|
||||
global: {},
|
||||
})
|
||||
|
||||
const select = wrapper.findComponent({ name: 'NcSelect' })
|
||||
const options = select.props('options') as Array<{ label: string; create?: boolean }>
|
||||
const lastOption = options[options.length - 1]
|
||||
expect(lastOption.create).toBe(true)
|
||||
expect(lastOption.label).toContain('Create new category')
|
||||
})
|
||||
|
||||
it('shows create option even when no categories exist', () => {
|
||||
mockItems.value = []
|
||||
|
||||
const wrapper = mount(CategoryPicker, {
|
||||
props: { houseId: 10, modelValue: null },
|
||||
global: {},
|
||||
})
|
||||
|
||||
const select = wrapper.findComponent({ name: 'NcSelect' })
|
||||
const options = select.props('options') as Array<{ label: string; create?: boolean }>
|
||||
expect(options).toHaveLength(1)
|
||||
expect(options[0].create).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('create dialog', () => {
|
||||
it('opens create dialog when the create option is selected', async () => {
|
||||
const wrapper = mount(CategoryPicker, {
|
||||
props: { houseId: 10, modelValue: null },
|
||||
global: {},
|
||||
})
|
||||
|
||||
// Dialog should not be visible initially
|
||||
expect(wrapper.findComponent({ name: 'NcDialog' }).exists()).toBe(false)
|
||||
|
||||
// Simulate selecting the create option
|
||||
const select = wrapper.findComponent({ name: 'NcSelect' })
|
||||
select.vm.$emit('option:selected', { label: 'Create new category …', create: true })
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
expect(wrapper.findComponent({ name: 'NcDialog' }).exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('create form has name field, icon grid, and color swatches', async () => {
|
||||
const wrapper = mount(CategoryPicker, {
|
||||
props: { houseId: 10, modelValue: null },
|
||||
global: {},
|
||||
})
|
||||
|
||||
// Open the create dialog
|
||||
const select = wrapper.findComponent({ name: 'NcSelect' })
|
||||
select.vm.$emit('option:selected', { label: 'Create new category …', create: true })
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
// Name text field
|
||||
expect(wrapper.findComponent({ name: 'NcTextField' }).exists()).toBe(true)
|
||||
|
||||
// Icon grid
|
||||
const iconGrid = wrapper.find('.pantry-create-cat__icon-grid')
|
||||
expect(iconGrid.exists()).toBe(true)
|
||||
const iconButtons = wrapper.findAll('.pantry-create-cat__icon-button')
|
||||
expect(iconButtons.length).toBe(19) // 19 icons in CATEGORY_ICONS
|
||||
|
||||
// Color swatches
|
||||
const colorGrid = wrapper.find('.pantry-create-cat__color-grid')
|
||||
expect(colorGrid.exists()).toBe(true)
|
||||
const colorSwatches = wrapper.findAll('.pantry-create-cat__color-swatch')
|
||||
expect(colorSwatches.length).toBe(10) // 10 colors in CATEGORY_COLORS
|
||||
})
|
||||
|
||||
it('shows create and cancel buttons in dialog actions', async () => {
|
||||
const wrapper = mount(CategoryPicker, {
|
||||
props: { houseId: 10, modelValue: null },
|
||||
global: {},
|
||||
})
|
||||
|
||||
const select = wrapper.findComponent({ name: 'NcSelect' })
|
||||
select.vm.$emit('option:selected', { label: 'Create new category …', create: true })
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
const buttons = wrapper.findAllComponents({ name: 'NcButton' })
|
||||
// Cancel button + Create button (inside dialog actions)
|
||||
expect(buttons.length).toBeGreaterThanOrEqual(2)
|
||||
const buttonTexts = buttons.map((b) => b.text())
|
||||
expect(buttonTexts).toContain('Cancel')
|
||||
expect(buttonTexts).toContain('Create')
|
||||
})
|
||||
|
||||
it('emits update:modelValue with created category id after creation', async () => {
|
||||
const createdCategory = makeCategory({ id: 42, name: 'New Cat' })
|
||||
mockCreate.mockResolvedValueOnce(createdCategory)
|
||||
|
||||
const wrapper = mount(CategoryPicker, {
|
||||
props: { houseId: 10, modelValue: null },
|
||||
global: {},
|
||||
})
|
||||
|
||||
// Open create dialog
|
||||
const select = wrapper.findComponent({ name: 'NcSelect' })
|
||||
select.vm.$emit('option:selected', { label: 'Create new category …', create: true })
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
// Set name via the component's internal state
|
||||
const vm = wrapper.vm as unknown as { newName: string }
|
||||
vm.newName = 'New Cat'
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
// Submit the form
|
||||
const form = wrapper.find('.pantry-create-cat')
|
||||
await form.trigger('submit')
|
||||
|
||||
// Wait for async create to resolve
|
||||
await vi.waitFor(() => {
|
||||
expect(wrapper.emitted('update:modelValue')).toBeTruthy()
|
||||
})
|
||||
|
||||
const emitted = wrapper.emitted('update:modelValue')!
|
||||
expect(emitted[emitted.length - 1]).toEqual([42])
|
||||
})
|
||||
|
||||
it('calls create with name, icon, and color', async () => {
|
||||
const createdCategory = makeCategory({ id: 50, name: 'Snacks' })
|
||||
mockCreate.mockResolvedValueOnce(createdCategory)
|
||||
|
||||
const wrapper = mount(CategoryPicker, {
|
||||
props: { houseId: 10, modelValue: null },
|
||||
global: {},
|
||||
})
|
||||
|
||||
// Open create dialog
|
||||
const select = wrapper.findComponent({ name: 'NcSelect' })
|
||||
select.vm.$emit('option:selected', { label: 'Create new category …', create: true })
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
// Set the name
|
||||
const vm = wrapper.vm as unknown as { newName: string; newIcon: string; newColor: string }
|
||||
vm.newName = 'Snacks'
|
||||
vm.newIcon = 'snacks'
|
||||
vm.newColor = '#ef4444'
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
// Submit
|
||||
const form = wrapper.find('.pantry-create-cat')
|
||||
await form.trigger('submit')
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(mockCreate).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
expect(mockCreate).toHaveBeenCalledWith({
|
||||
name: 'Snacks',
|
||||
icon: 'snacks',
|
||||
color: '#ef4444',
|
||||
})
|
||||
})
|
||||
|
||||
it('shows error when create fails', async () => {
|
||||
mockCreate.mockRejectedValueOnce(new Error('Server error'))
|
||||
|
||||
const wrapper = mount(CategoryPicker, {
|
||||
props: { houseId: 10, modelValue: null },
|
||||
global: {},
|
||||
})
|
||||
|
||||
// Open create dialog
|
||||
const select = wrapper.findComponent({ name: 'NcSelect' })
|
||||
select.vm.$emit('option:selected', { label: 'Create new category …', create: true })
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
const vm = wrapper.vm as unknown as { newName: string }
|
||||
vm.newName = 'Test'
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
const form = wrapper.find('.pantry-create-cat')
|
||||
await form.trigger('submit')
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(wrapper.find('.pantry-create-cat__error').exists()).toBe(true)
|
||||
})
|
||||
|
||||
expect(wrapper.find('.pantry-create-cat__error').text()).toBe('Server error')
|
||||
})
|
||||
})
|
||||
|
||||
describe('selection', () => {
|
||||
it('emits update:modelValue when a category is selected', async () => {
|
||||
const dairy = makeCategory({ id: 5, name: 'Dairy' })
|
||||
mockItems.value = [dairy]
|
||||
|
||||
const wrapper = mount(CategoryPicker, {
|
||||
props: { houseId: 10, modelValue: null },
|
||||
global: {},
|
||||
})
|
||||
|
||||
const select = wrapper.findComponent({ name: 'NcSelect' })
|
||||
select.vm.$emit('update:modelValue', { label: 'Dairy', id: 5, category: dairy })
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
const emitted = wrapper.emitted('update:modelValue')
|
||||
expect(emitted).toBeTruthy()
|
||||
expect(emitted![0]).toEqual([5])
|
||||
})
|
||||
|
||||
it('emits update:modelValue with null when cleared', async () => {
|
||||
const dairy = makeCategory({ id: 5, name: 'Dairy' })
|
||||
mockItems.value = [dairy]
|
||||
|
||||
const wrapper = mount(CategoryPicker, {
|
||||
props: { houseId: 10, modelValue: 5 },
|
||||
global: {},
|
||||
})
|
||||
|
||||
const select = wrapper.findComponent({ name: 'NcSelect' })
|
||||
select.vm.$emit('update:modelValue', null)
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
const emitted = wrapper.emitted('update:modelValue')
|
||||
expect(emitted).toBeTruthy()
|
||||
expect(emitted![0]).toEqual([null])
|
||||
})
|
||||
|
||||
it('resolves selected option from modelValue and items', () => {
|
||||
const dairy = makeCategory({ id: 5, name: 'Dairy' })
|
||||
mockItems.value = [dairy]
|
||||
|
||||
const wrapper = mount(CategoryPicker, {
|
||||
props: { houseId: 10, modelValue: 5 },
|
||||
global: {},
|
||||
})
|
||||
|
||||
const select = wrapper.findComponent({ name: 'NcSelect' })
|
||||
const modelValue = select.props('modelValue') as { label: string; id: number } | null
|
||||
expect(modelValue).toMatchObject({ label: 'Dairy', id: 5 })
|
||||
})
|
||||
|
||||
it('returns null selected when modelValue does not match any item', () => {
|
||||
mockItems.value = []
|
||||
|
||||
const wrapper = mount(CategoryPicker, {
|
||||
props: { houseId: 10, modelValue: 999 },
|
||||
global: {},
|
||||
})
|
||||
|
||||
const select = wrapper.findComponent({ name: 'NcSelect' })
|
||||
expect(select.props('modelValue')).toBeNull()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -116,7 +116,7 @@ import {
|
||||
CATEGORY_ICONS,
|
||||
DEFAULT_CATEGORY_ICON_KEY,
|
||||
categoryIconComponent,
|
||||
} from '@/components/categoryIcons'
|
||||
} from './categoryIcons'
|
||||
import type { Category } from '@/api/types'
|
||||
|
||||
const props = defineProps<{
|
||||
101
src/components/CategoryPicker/categoryIcons.test.ts
Normal file
101
src/components/CategoryPicker/categoryIcons.test.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('@icons/Tag.vue', () => ({ default: { name: 'TagIcon' } }))
|
||||
vi.mock('@icons/Food.vue', () => ({ default: { name: 'FoodIcon' } }))
|
||||
vi.mock('@icons/FoodApple.vue', () => ({ default: { name: 'FruitIcon' } }))
|
||||
vi.mock('@icons/Carrot.vue', () => ({ default: { name: 'VegetableIcon' } }))
|
||||
vi.mock('@icons/BreadSlice.vue', () => ({ default: { name: 'BakeryIcon' } }))
|
||||
vi.mock('@icons/Cheese.vue', () => ({ default: { name: 'DairyIcon' } }))
|
||||
vi.mock('@icons/FoodDrumstick.vue', () => ({ default: { name: 'MeatIcon' } }))
|
||||
vi.mock('@icons/Fish.vue', () => ({ default: { name: 'FishIcon' } }))
|
||||
vi.mock('@icons/FoodCroissant.vue', () => ({ default: { name: 'SnacksIcon' } }))
|
||||
vi.mock('@icons/Cookie.vue', () => ({ default: { name: 'CookieIcon' } }))
|
||||
vi.mock('@icons/BottleWine.vue', () => ({ default: { name: 'DrinksIcon' } }))
|
||||
vi.mock('@icons/Coffee.vue', () => ({ default: { name: 'CoffeeIcon' } }))
|
||||
vi.mock('@icons/Snowflake.vue', () => ({ default: { name: 'FrozenIcon' } }))
|
||||
vi.mock('@icons/Broom.vue', () => ({ default: { name: 'HouseholdIcon' } }))
|
||||
vi.mock('@icons/Dog.vue', () => ({ default: { name: 'PetsIcon' } }))
|
||||
vi.mock('@icons/Baby.vue', () => ({ default: { name: 'BabyIcon' } }))
|
||||
vi.mock('@icons/Home.vue', () => ({ default: { name: 'HomeIcon' } }))
|
||||
vi.mock('@icons/Leaf.vue', () => ({ default: { name: 'LeafIcon' } }))
|
||||
vi.mock('@icons/Pizza.vue', () => ({ default: { name: 'PizzaIcon' } }))
|
||||
|
||||
import {
|
||||
CATEGORY_COLORS,
|
||||
CATEGORY_ICONS,
|
||||
DEFAULT_CATEGORY_ICON_KEY,
|
||||
categoryIconComponent,
|
||||
} from './categoryIcons'
|
||||
|
||||
describe('CATEGORY_ICONS', () => {
|
||||
it('has 19 entries', () => {
|
||||
expect(CATEGORY_ICONS).toHaveLength(19)
|
||||
})
|
||||
|
||||
it('each entry has key, label, and component', () => {
|
||||
for (const icon of CATEGORY_ICONS) {
|
||||
expect(icon).toHaveProperty('key')
|
||||
expect(icon).toHaveProperty('label')
|
||||
expect(icon).toHaveProperty('component')
|
||||
expect(typeof icon.key).toBe('string')
|
||||
expect(typeof icon.label).toBe('string')
|
||||
expect(icon.component).toBeDefined()
|
||||
}
|
||||
})
|
||||
|
||||
it('has no duplicate keys', () => {
|
||||
const keys = CATEGORY_ICONS.map((i) => i.key)
|
||||
expect(new Set(keys).size).toBe(keys.length)
|
||||
})
|
||||
})
|
||||
|
||||
describe('DEFAULT_CATEGORY_ICON_KEY', () => {
|
||||
it('is "tag"', () => {
|
||||
expect(DEFAULT_CATEGORY_ICON_KEY).toBe('tag')
|
||||
})
|
||||
})
|
||||
|
||||
describe('categoryIconComponent', () => {
|
||||
it('returns the correct component for known keys', () => {
|
||||
const result = categoryIconComponent('food') as { name: string }
|
||||
expect(result.name).toBe('FoodIcon')
|
||||
})
|
||||
|
||||
it('returns the correct component for "dairy"', () => {
|
||||
const result = categoryIconComponent('dairy') as { name: string }
|
||||
expect(result.name).toBe('DairyIcon')
|
||||
})
|
||||
|
||||
it('returns the correct component for "tag"', () => {
|
||||
const result = categoryIconComponent('tag') as { name: string }
|
||||
expect(result.name).toBe('TagIcon')
|
||||
})
|
||||
|
||||
it('returns the fallback TagIcon for unknown keys', () => {
|
||||
const result = categoryIconComponent('nonexistent') as { name: string }
|
||||
expect(result.name).toBe('TagIcon')
|
||||
})
|
||||
|
||||
it('returns the fallback TagIcon for null', () => {
|
||||
const result = categoryIconComponent(null) as { name: string }
|
||||
expect(result.name).toBe('TagIcon')
|
||||
})
|
||||
|
||||
it('returns the fallback TagIcon for undefined', () => {
|
||||
const result = categoryIconComponent(undefined) as { name: string }
|
||||
expect(result.name).toBe('TagIcon')
|
||||
})
|
||||
})
|
||||
|
||||
describe('CATEGORY_COLORS', () => {
|
||||
it('has 10 entries', () => {
|
||||
expect(CATEGORY_COLORS).toHaveLength(10)
|
||||
})
|
||||
|
||||
it('all entries are valid hex color strings', () => {
|
||||
const hexColorRegex = /^#[0-9a-fA-F]{6}$/
|
||||
for (const color of CATEGORY_COLORS) {
|
||||
expect(color).toMatch(hexColorRegex)
|
||||
}
|
||||
})
|
||||
})
|
||||
2
src/components/CategoryPicker/index.ts
Normal file
2
src/components/CategoryPicker/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { default } from './CategoryPicker.vue'
|
||||
export * from './categoryIcons'
|
||||
344
src/components/HouseSettingsDialog/HouseSettingsDialog.test.ts
Normal file
344
src/components/HouseSettingsDialog/HouseSettingsDialog.test.ts
Normal file
@@ -0,0 +1,344 @@
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { computed, defineComponent, ref } from 'vue'
|
||||
|
||||
import { createIconMock, nextcloudL10nMock } from '@/test-utils'
|
||||
import { useCurrentHouse } from '@/composables/useCurrentHouse'
|
||||
|
||||
import HouseSettingsDialog from './HouseSettingsDialog.vue'
|
||||
|
||||
// Mock @nextcloud/l10n
|
||||
vi.mock('@nextcloud/l10n', () => nextcloudL10nMock)
|
||||
|
||||
// Mock icon components
|
||||
vi.mock('@icons/Plus.vue', () => createIconMock('PlusIcon'))
|
||||
vi.mock('@icons/Delete.vue', () => createIconMock('DeleteIcon'))
|
||||
|
||||
// Mock vue-router
|
||||
vi.mock('vue-router', () => ({
|
||||
useRouter: () => ({ push: vi.fn() }),
|
||||
useRoute: () => ({ params: { houseId: '1' } }),
|
||||
}))
|
||||
|
||||
// Mock composables
|
||||
vi.mock('@/composables/useCurrentHouse', () => ({
|
||||
useCurrentHouse: vi.fn(() => ({
|
||||
house: ref({
|
||||
id: 1,
|
||||
name: 'Test House',
|
||||
description: 'A test description',
|
||||
role: 'owner',
|
||||
ownerUid: 'me',
|
||||
createdAt: 0,
|
||||
updatedAt: 0,
|
||||
}),
|
||||
houseId: computed(() => 1),
|
||||
loading: ref(false),
|
||||
canEdit: computed(() => true),
|
||||
canAdmin: computed(() => true),
|
||||
isOwner: computed(() => true),
|
||||
refresh: vi.fn(),
|
||||
})),
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useHouses', () => ({
|
||||
useHouses: vi.fn(() => ({
|
||||
update: vi.fn(),
|
||||
remove: vi.fn(),
|
||||
})),
|
||||
}))
|
||||
|
||||
vi.mock('@/api/houses', () => ({
|
||||
listMembers: vi.fn(() => Promise.resolve([])),
|
||||
addMember: vi.fn(),
|
||||
updateMemberRole: vi.fn(),
|
||||
removeMember: vi.fn(),
|
||||
leaveHouse: vi.fn(),
|
||||
}))
|
||||
|
||||
// Mock Nextcloud Vue components that pull in CSS
|
||||
vi.mock('@nextcloud/vue/components/NcAppSettingsDialog', () => ({
|
||||
default: {
|
||||
name: 'NcAppSettingsDialog',
|
||||
template: '<div><slot /></div>',
|
||||
props: { open: Boolean, name: String, showNavigation: Boolean },
|
||||
},
|
||||
}))
|
||||
vi.mock('@nextcloud/vue/components/NcAppSettingsSection', () => ({
|
||||
default: {
|
||||
name: 'NcAppSettingsSection',
|
||||
template: '<div><slot /></div>',
|
||||
props: { id: String, name: String },
|
||||
},
|
||||
}))
|
||||
vi.mock('@nextcloud/vue/components/NcButton', () => ({
|
||||
default: {
|
||||
name: 'NcButton',
|
||||
template: '<button><slot /><slot name="icon" /></button>',
|
||||
props: { variant: String, disabled: Boolean, type: String, ariaLabel: String },
|
||||
},
|
||||
}))
|
||||
vi.mock('@nextcloud/vue/components/NcTextField', () => ({
|
||||
default: {
|
||||
name: 'NcTextField',
|
||||
template: '<input />',
|
||||
props: { modelValue: String, label: String, placeholder: String },
|
||||
},
|
||||
}))
|
||||
vi.mock('@nextcloud/vue/components/NcLoadingIcon', () => ({
|
||||
default: { name: 'NcLoadingIcon', template: '<span />', props: { size: Number } },
|
||||
}))
|
||||
vi.mock('@nextcloud/vue/components/NcDialog', () => ({
|
||||
default: {
|
||||
name: 'NcDialog',
|
||||
template: '<div><slot /><slot name="actions" /></div>',
|
||||
props: { name: String, open: Boolean },
|
||||
},
|
||||
}))
|
||||
vi.mock('@nextcloud/vue/components/NcSelect', () => ({
|
||||
default: {
|
||||
name: 'NcSelect',
|
||||
template: '<div />',
|
||||
props: { modelValue: [String, Object], options: Array, inputLabel: String },
|
||||
},
|
||||
}))
|
||||
vi.mock('@nextcloud/vue/components/NcDateTime', () => ({
|
||||
default: { name: 'NcDateTime', template: '<span />', props: { timestamp: Number } },
|
||||
}))
|
||||
|
||||
// Stub for Nextcloud Vue components
|
||||
function createStub(name: string, opts?: { slots?: boolean; props?: string[] }) {
|
||||
return defineComponent({
|
||||
name,
|
||||
props: opts?.props ?? {
|
||||
name: String,
|
||||
open: Boolean,
|
||||
showNavigation: Boolean,
|
||||
label: String,
|
||||
placeholder: String,
|
||||
disabled: Boolean,
|
||||
variant: String,
|
||||
type: String,
|
||||
ariaLabel: String,
|
||||
options: Array,
|
||||
inputLabel: String,
|
||||
timestamp: Number,
|
||||
modelValue: [String, Object, Number, Boolean],
|
||||
},
|
||||
emits: ['update:open', 'update:modelValue'],
|
||||
template:
|
||||
opts?.slots === false
|
||||
? `<div class="stub-${name}"><slot /></div>`
|
||||
: `<div class="stub-${name}"><slot /><slot name="icon" /><slot name="actions" /></div>`,
|
||||
})
|
||||
}
|
||||
|
||||
const stubs = {
|
||||
NcAppSettingsDialog: createStub('NcAppSettingsDialog'),
|
||||
NcAppSettingsSection: createStub('NcAppSettingsSection'),
|
||||
NcButton: createStub('NcButton'),
|
||||
NcTextField: createStub('NcTextField', { slots: false }),
|
||||
NcLoadingIcon: createStub('NcLoadingIcon', { slots: false }),
|
||||
NcDialog: createStub('NcDialog'),
|
||||
NcSelect: createStub('NcSelect', { slots: false }),
|
||||
NcDateTime: createStub('NcDateTime', { slots: false }),
|
||||
}
|
||||
|
||||
function mountDialog(props: { open: boolean } = { open: true }) {
|
||||
return mount(HouseSettingsDialog, {
|
||||
props,
|
||||
global: { stubs },
|
||||
})
|
||||
}
|
||||
|
||||
describe('HouseSettingsDialog', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
|
||||
// Reset to owner mock by default
|
||||
vi.mocked(useCurrentHouse).mockReturnValue({
|
||||
house: ref({
|
||||
id: 1,
|
||||
name: 'Test House',
|
||||
description: 'A test description',
|
||||
role: 'owner',
|
||||
ownerUid: 'me',
|
||||
createdAt: 0,
|
||||
updatedAt: 0,
|
||||
}),
|
||||
houseId: computed(() => 1),
|
||||
loading: ref(false),
|
||||
canEdit: computed(() => true),
|
||||
canAdmin: computed(() => true),
|
||||
isOwner: computed(() => true),
|
||||
refresh: vi.fn(),
|
||||
})
|
||||
})
|
||||
|
||||
describe('rendering', () => {
|
||||
it('renders when open is true with the house settings title', () => {
|
||||
const wrapper = mountDialog({ open: true })
|
||||
expect(wrapper.exists()).toBe(true)
|
||||
|
||||
const dialog = wrapper.findComponent({ name: 'NcAppSettingsDialog' })
|
||||
expect(dialog.exists()).toBe(true)
|
||||
expect(dialog.props('name')).toBe('House settings')
|
||||
expect(dialog.props('open')).toBe(true)
|
||||
})
|
||||
|
||||
it('passes open=false to the dialog when prop is false', () => {
|
||||
const wrapper = mountDialog({ open: false })
|
||||
const dialog = wrapper.findComponent({ name: 'NcAppSettingsDialog' })
|
||||
expect(dialog.props('open')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('general section', () => {
|
||||
it('shows name and description inputs', () => {
|
||||
const wrapper = mountDialog()
|
||||
const sections = wrapper.findAllComponents({ name: 'NcAppSettingsSection' })
|
||||
const generalSection = sections.find((s) => s.props('name') === 'General')
|
||||
|
||||
expect(generalSection).toBeDefined()
|
||||
|
||||
const textFields = wrapper.findAllComponents({ name: 'NcTextField' })
|
||||
const nameField = textFields.find((f) => f.props('label') === 'Name')
|
||||
const descField = textFields.find((f) => f.props('label') === 'Description')
|
||||
|
||||
expect(nameField).toBeDefined()
|
||||
expect(descField).toBeDefined()
|
||||
})
|
||||
|
||||
it('shows a save button', () => {
|
||||
const wrapper = mountDialog()
|
||||
expect(wrapper.text()).toContain('Save changes')
|
||||
})
|
||||
})
|
||||
|
||||
describe('members section', () => {
|
||||
it('renders member table headers', async () => {
|
||||
const { listMembers } = await import('@/api/houses')
|
||||
vi.mocked(listMembers).mockResolvedValueOnce([
|
||||
{ id: 1, houseId: 1, userId: 'alice', displayName: 'Alice', role: 'owner', joinedAt: 1000 },
|
||||
])
|
||||
const wrapper = mountDialog({ open: false })
|
||||
await wrapper.setProps({ open: true })
|
||||
await flushPromises()
|
||||
|
||||
const text = wrapper.text()
|
||||
expect(text).toContain('Account')
|
||||
expect(text).toContain('Role')
|
||||
expect(text).toContain('Joined')
|
||||
})
|
||||
|
||||
it('has add member button when user can admin', () => {
|
||||
const wrapper = mountDialog()
|
||||
expect(wrapper.text()).toContain('Add member')
|
||||
})
|
||||
})
|
||||
|
||||
describe('owner view', () => {
|
||||
it('shows danger zone section', () => {
|
||||
const wrapper = mountDialog()
|
||||
const sections = wrapper.findAllComponents({ name: 'NcAppSettingsSection' })
|
||||
const dangerSection = sections.find((s) => s.props('name') === 'Danger zone')
|
||||
|
||||
expect(dangerSection).toBeDefined()
|
||||
})
|
||||
|
||||
it('shows delete house button in danger zone', () => {
|
||||
const wrapper = mountDialog()
|
||||
expect(wrapper.text()).toContain('Delete house')
|
||||
})
|
||||
|
||||
it('does not show leave button for owner', () => {
|
||||
const wrapper = mountDialog()
|
||||
expect(wrapper.text()).not.toContain('Leave this house')
|
||||
})
|
||||
})
|
||||
|
||||
describe('non-owner view', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(useCurrentHouse).mockReturnValue({
|
||||
house: ref({
|
||||
id: 1,
|
||||
name: 'Test',
|
||||
description: null,
|
||||
role: 'member',
|
||||
ownerUid: 'other',
|
||||
createdAt: 0,
|
||||
updatedAt: 0,
|
||||
}),
|
||||
houseId: computed(() => 1),
|
||||
loading: ref(false),
|
||||
canEdit: computed(() => true),
|
||||
canAdmin: computed(() => false),
|
||||
isOwner: computed(() => false),
|
||||
refresh: vi.fn(),
|
||||
})
|
||||
})
|
||||
|
||||
it('hides danger zone when user is not owner', () => {
|
||||
const wrapper = mountDialog()
|
||||
const sections = wrapper.findAllComponents({ name: 'NcAppSettingsSection' })
|
||||
const dangerSection = sections.find((s) => s.props('name') === 'Danger zone')
|
||||
|
||||
expect(dangerSection).toBeUndefined()
|
||||
})
|
||||
|
||||
it('does not show delete house button', () => {
|
||||
const wrapper = mountDialog()
|
||||
expect(wrapper.text()).not.toContain('Delete house')
|
||||
})
|
||||
|
||||
it('shows leave this house button when not owner', () => {
|
||||
const wrapper = mountDialog()
|
||||
expect(wrapper.text()).toContain('Leave this house')
|
||||
})
|
||||
|
||||
it('does not show add member button when user cannot admin', () => {
|
||||
const wrapper = mountDialog()
|
||||
expect(wrapper.text()).not.toContain('Add member')
|
||||
})
|
||||
})
|
||||
|
||||
describe('non-owner admin view', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(useCurrentHouse).mockReturnValue({
|
||||
house: ref({
|
||||
id: 1,
|
||||
name: 'Test',
|
||||
description: null,
|
||||
role: 'admin',
|
||||
ownerUid: 'other',
|
||||
createdAt: 0,
|
||||
updatedAt: 0,
|
||||
}),
|
||||
houseId: computed(() => 1),
|
||||
loading: ref(false),
|
||||
canEdit: computed(() => true),
|
||||
canAdmin: computed(() => true),
|
||||
isOwner: computed(() => false),
|
||||
refresh: vi.fn(),
|
||||
})
|
||||
})
|
||||
|
||||
it('shows add member button for admin', () => {
|
||||
const wrapper = mountDialog()
|
||||
expect(wrapper.text()).toContain('Add member')
|
||||
})
|
||||
|
||||
it('shows leave button for admin who is not owner', () => {
|
||||
const wrapper = mountDialog()
|
||||
expect(wrapper.text()).toContain('Leave this house')
|
||||
})
|
||||
|
||||
it('hides danger zone for admin who is not owner', () => {
|
||||
const wrapper = mountDialog()
|
||||
const sections = wrapper.findAllComponents({ name: 'NcAppSettingsSection' })
|
||||
const dangerSection = sections.find((s) => s.props('name') === 'Danger zone')
|
||||
|
||||
expect(dangerSection).toBeUndefined()
|
||||
})
|
||||
})
|
||||
})
|
||||
1
src/components/HouseSettingsDialog/index.ts
Normal file
1
src/components/HouseSettingsDialog/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default } from './HouseSettingsDialog.vue'
|
||||
181
src/components/PantrySettingsDialog/PantrySettingsDialog.test.ts
Normal file
181
src/components/PantrySettingsDialog/PantrySettingsDialog.test.ts
Normal file
@@ -0,0 +1,181 @@
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { createIconMock, nextcloudL10nMock } from '@/test-utils'
|
||||
|
||||
vi.mock('@nextcloud/l10n', () => nextcloudL10nMock)
|
||||
vi.mock('@nextcloud/dialogs', () => ({ getFilePickerBuilder: vi.fn() }))
|
||||
vi.mock('@icons/Folder.vue', () => createIconMock('FolderIcon'))
|
||||
vi.mock('@/api/prefs', () => ({
|
||||
getImageFolder: vi.fn(),
|
||||
setImageFolder: vi.fn(),
|
||||
}))
|
||||
|
||||
// Mock Nextcloud Vue components that pull in CSS
|
||||
vi.mock('@nextcloud/vue/components/NcAppSettingsDialog', () => ({
|
||||
default: {
|
||||
name: 'NcAppSettingsDialog',
|
||||
template: '<div class="nc-app-settings-dialog"><slot /></div>',
|
||||
props: ['open', 'name', 'showNavigation'],
|
||||
},
|
||||
}))
|
||||
vi.mock('@nextcloud/vue/components/NcAppSettingsSection', () => ({
|
||||
default: {
|
||||
name: 'NcAppSettingsSection',
|
||||
template: '<div class="nc-app-settings-section"><slot /></div>',
|
||||
props: ['id', 'name'],
|
||||
},
|
||||
}))
|
||||
vi.mock('@nextcloud/vue/components/NcTextField', () => ({
|
||||
default: {
|
||||
name: 'NcTextField',
|
||||
template: '<input class="nc-text-field" />',
|
||||
props: ['modelValue', 'label', 'placeholder'],
|
||||
},
|
||||
}))
|
||||
vi.mock('@nextcloud/vue/components/NcButton', () => ({
|
||||
default: {
|
||||
name: 'NcButton',
|
||||
template: '<button class="nc-button"><slot /><slot name="icon" /></button>',
|
||||
props: ['variant', 'disabled', 'type'],
|
||||
},
|
||||
}))
|
||||
|
||||
import { getImageFolder, setImageFolder } from '@/api/prefs'
|
||||
import PantrySettingsDialog from './PantrySettingsDialog.vue'
|
||||
|
||||
const NcAppSettingsDialogStub = {
|
||||
template: '<div class="nc-app-settings-dialog"><slot /></div>',
|
||||
props: ['open', 'name', 'showNavigation'],
|
||||
}
|
||||
|
||||
const NcAppSettingsSectionStub = {
|
||||
template: '<div class="nc-app-settings-section"><slot /></div>',
|
||||
props: ['id', 'name'],
|
||||
}
|
||||
|
||||
const NcTextFieldStub = {
|
||||
template:
|
||||
'<input class="nc-text-field" :value="modelValue" @input="$emit(\'update:modelValue\', $event.target.value)" />',
|
||||
props: ['modelValue', 'label', 'placeholder'],
|
||||
emits: ['update:modelValue'],
|
||||
}
|
||||
|
||||
const NcButtonStub = {
|
||||
template:
|
||||
'<button class="nc-button" :type="type" :disabled="disabled"><slot /><slot name="icon" /></button>',
|
||||
props: ['type', 'variant', 'disabled'],
|
||||
}
|
||||
|
||||
function mountComponent(
|
||||
props: { open: boolean; houseId: number | null } = { open: true, houseId: 1 },
|
||||
) {
|
||||
return mount(PantrySettingsDialog, {
|
||||
props,
|
||||
global: {
|
||||
stubs: {
|
||||
NcAppSettingsDialog: NcAppSettingsDialogStub,
|
||||
NcAppSettingsSection: NcAppSettingsSectionStub,
|
||||
NcTextField: NcTextFieldStub,
|
||||
NcButton: NcButtonStub,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
describe('PantrySettingsDialog', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.mocked(getImageFolder).mockResolvedValue('/Pantry')
|
||||
vi.mocked(setImageFolder).mockResolvedValue('/Pantry')
|
||||
})
|
||||
|
||||
describe('rendering', () => {
|
||||
it('renders when open=true', async () => {
|
||||
const wrapper = mountComponent({ open: true, houseId: 1 })
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.find('.nc-app-settings-dialog').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('shows the "Account settings" title', async () => {
|
||||
const wrapper = mountComponent({ open: true, houseId: 1 })
|
||||
await flushPromises()
|
||||
|
||||
const dialog = wrapper.findComponent(NcAppSettingsDialogStub)
|
||||
expect(dialog.props('name')).toBe('Account settings')
|
||||
})
|
||||
|
||||
it('has an "Images" section', async () => {
|
||||
const wrapper = mountComponent({ open: true, houseId: 1 })
|
||||
await flushPromises()
|
||||
|
||||
const section = wrapper.findComponent(NcAppSettingsSectionStub)
|
||||
expect(section.props('name')).toBe('Images')
|
||||
})
|
||||
|
||||
it('shows the upload folder text input', async () => {
|
||||
const wrapper = mountComponent({ open: true, houseId: 1 })
|
||||
await flushPromises()
|
||||
|
||||
const textField = wrapper.findComponent(NcTextFieldStub)
|
||||
expect(textField.exists()).toBe(true)
|
||||
expect(textField.props('label')).toBe('Upload folder')
|
||||
})
|
||||
|
||||
it('shows a Browse button', async () => {
|
||||
const wrapper = mountComponent({ open: true, houseId: 1 })
|
||||
await flushPromises()
|
||||
|
||||
const buttons = wrapper.findAllComponents(NcButtonStub)
|
||||
const browseButton = buttons.find((b) => b.text().includes('Browse'))
|
||||
expect(browseButton).toBeDefined()
|
||||
})
|
||||
|
||||
it('shows a Save button', async () => {
|
||||
const wrapper = mountComponent({ open: true, houseId: 1 })
|
||||
await flushPromises()
|
||||
|
||||
const buttons = wrapper.findAllComponents(NcButtonStub)
|
||||
const saveButton = buttons.find((b) => b.text().includes('Save'))
|
||||
expect(saveButton).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('save button disabled state', () => {
|
||||
it('save button is disabled when folder is empty', async () => {
|
||||
vi.mocked(getImageFolder).mockResolvedValue('')
|
||||
const wrapper = mountComponent({ open: true, houseId: 1 })
|
||||
await flushPromises()
|
||||
|
||||
const buttons = wrapper.findAllComponents(NcButtonStub)
|
||||
const saveButton = buttons.find((b) => b.props('type') === 'submit')
|
||||
expect(saveButton!.props('disabled')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('API interactions', () => {
|
||||
it('loads the folder from API when opened', async () => {
|
||||
vi.mocked(getImageFolder).mockResolvedValue('/Photos')
|
||||
const wrapper = mountComponent({ open: true, houseId: 42 })
|
||||
await flushPromises()
|
||||
|
||||
expect(getImageFolder).toHaveBeenCalledWith(42)
|
||||
const textField = wrapper.findComponent(NcTextFieldStub)
|
||||
expect(textField.props('modelValue')).toBe('/Photos')
|
||||
})
|
||||
|
||||
it('calls setImageFolder API when saving', async () => {
|
||||
vi.mocked(getImageFolder).mockResolvedValue('/MyFolder')
|
||||
vi.mocked(setImageFolder).mockResolvedValue('/MyFolder')
|
||||
|
||||
const wrapper = mountComponent({ open: true, houseId: 5 })
|
||||
await flushPromises()
|
||||
|
||||
await wrapper.find('form').trigger('submit')
|
||||
await flushPromises()
|
||||
|
||||
expect(setImageFolder).toHaveBeenCalledWith(5, '/MyFolder')
|
||||
})
|
||||
})
|
||||
})
|
||||
1
src/components/PantrySettingsDialog/index.ts
Normal file
1
src/components/PantrySettingsDialog/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default } from './PantrySettingsDialog.vue'
|
||||
287
src/components/RecurrenceEditor/RecurrenceEditor.test.ts
Normal file
287
src/components/RecurrenceEditor/RecurrenceEditor.test.ts
Normal file
@@ -0,0 +1,287 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { createIconMock, nextcloudL10nMock } from '@/test-utils'
|
||||
import RecurrenceEditor from './RecurrenceEditor.vue'
|
||||
|
||||
// Mock @nextcloud/l10n
|
||||
vi.mock('@nextcloud/l10n', () => nextcloudL10nMock)
|
||||
|
||||
// Mock icon components
|
||||
vi.mock('@icons/Repeat.vue', () => createIconMock('RepeatIcon'))
|
||||
|
||||
// Stub Nextcloud Vue components
|
||||
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/NcButton', () => ({
|
||||
default: {
|
||||
name: 'NcButton',
|
||||
template:
|
||||
'<button class="nc-button" :data-variant="variant" @click="$emit(\'click\')"><slot /></button>',
|
||||
props: ['variant'],
|
||||
emits: ['click'],
|
||||
},
|
||||
}))
|
||||
vi.mock('@nextcloud/vue/components/NcSelect', () => ({
|
||||
default: {
|
||||
name: 'NcSelect',
|
||||
template: '<select class="nc-select" />',
|
||||
props: ['modelValue', 'options', 'clearable', 'inputLabel'],
|
||||
},
|
||||
}))
|
||||
vi.mock('@nextcloud/vue/components/NcCheckboxRadioSwitch', () => ({
|
||||
default: {
|
||||
name: 'NcCheckboxRadioSwitch',
|
||||
template:
|
||||
'<label class="nc-checkbox"><input type="checkbox" :checked="modelValue" @change="$emit(\'update:modelValue\', $event.target.checked)" /><slot /></label>',
|
||||
props: ['modelValue', 'type'],
|
||||
emits: ['update:modelValue'],
|
||||
},
|
||||
}))
|
||||
|
||||
// Mock rrule to avoid complex dependency issues
|
||||
vi.mock('rrule', () => {
|
||||
class MockWeekday {
|
||||
weekday: number
|
||||
constructor(weekday: number) {
|
||||
this.weekday = weekday
|
||||
}
|
||||
}
|
||||
|
||||
class MockRRule {
|
||||
options: Record<string, unknown>
|
||||
origOptions: Record<string, unknown>
|
||||
|
||||
static DAILY = 3
|
||||
static WEEKLY = 2
|
||||
static MONTHLY = 1
|
||||
static YEARLY = 0
|
||||
|
||||
constructor(options: Record<string, unknown>) {
|
||||
this.options = { ...options }
|
||||
this.origOptions = { ...options }
|
||||
}
|
||||
|
||||
toString() {
|
||||
return 'RRULE:FREQ=WEEKLY;INTERVAL=1'
|
||||
}
|
||||
|
||||
toText() {
|
||||
return 'every week'
|
||||
}
|
||||
|
||||
static fromString(_str: string) {
|
||||
return new MockRRule({ freq: MockRRule.WEEKLY, interval: 1 })
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
RRule: MockRRule,
|
||||
Weekday: MockWeekday,
|
||||
Frequency: { DAILY: 3, WEEKLY: 2, MONTHLY: 1, YEARLY: 0 },
|
||||
}
|
||||
})
|
||||
|
||||
function mountEditor(props: Record<string, unknown> = {}) {
|
||||
return mount(RecurrenceEditor, {
|
||||
props: {
|
||||
open: true,
|
||||
modelValue: null,
|
||||
...props,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
describe('RecurrenceEditor', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('rendering', () => {
|
||||
it('renders the dialog when open is true', () => {
|
||||
const wrapper = mountEditor({ open: true })
|
||||
expect(wrapper.find('.nc-dialog').exists()).toBe(true)
|
||||
expect(wrapper.find('.pantry-recurrence').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('does not render dialog content when open is false', () => {
|
||||
const wrapper = mountEditor({ open: false })
|
||||
expect(wrapper.find('.nc-dialog').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('renders preset buttons', () => {
|
||||
const wrapper = mountEditor()
|
||||
const presets = wrapper.find('.pantry-recurrence__presets')
|
||||
expect(presets.exists()).toBe(true)
|
||||
|
||||
const buttons = presets.findAll('.nc-button')
|
||||
expect(buttons.length).toBe(4)
|
||||
|
||||
const labels = buttons.map((b) => b.text())
|
||||
expect(labels).toContain('Daily')
|
||||
expect(labels).toContain('Weekly')
|
||||
expect(labels).toContain('Every 2 weeks')
|
||||
expect(labels).toContain('Monthly')
|
||||
})
|
||||
|
||||
it('renders interval input and frequency select', () => {
|
||||
const wrapper = mountEditor()
|
||||
const numberInput = wrapper.find('.pantry-recurrence__number')
|
||||
expect(numberInput.exists()).toBe(true)
|
||||
expect(numberInput.attributes('type')).toBe('number')
|
||||
expect(numberInput.attributes('min')).toBe('1')
|
||||
expect(numberInput.attributes('max')).toBe('999')
|
||||
|
||||
const select = wrapper.find('.nc-select')
|
||||
expect(select.exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('renders end condition radio buttons', () => {
|
||||
const wrapper = mountEditor()
|
||||
const radios = wrapper.findAll('.pantry-recurrence__radio')
|
||||
expect(radios.length).toBe(3)
|
||||
})
|
||||
|
||||
it('renders the summary section with repeat icon', () => {
|
||||
const wrapper = mountEditor()
|
||||
const summary = wrapper.find('.pantry-recurrence__summary')
|
||||
expect(summary.exists()).toBe(true)
|
||||
expect(summary.find('.mock-repeat-icon').exists()).toBe(true)
|
||||
expect(summary.find('strong').text()).toBe('Summary')
|
||||
})
|
||||
})
|
||||
|
||||
describe('action buttons', () => {
|
||||
it('has save and cancel buttons', () => {
|
||||
const wrapper = mountEditor()
|
||||
const buttons = wrapper.findAll('.nc-button')
|
||||
const texts = buttons.map((b) => b.text())
|
||||
expect(texts).toContain('Cancel')
|
||||
expect(texts).toContain('Save')
|
||||
})
|
||||
|
||||
it('shows clear button when modelValue is set', () => {
|
||||
const wrapper = mountEditor({ modelValue: 'FREQ=WEEKLY;INTERVAL=1' })
|
||||
const buttons = wrapper.findAll('.nc-button')
|
||||
const texts = buttons.map((b) => b.text())
|
||||
expect(texts).toContain('Remove recurrence')
|
||||
})
|
||||
|
||||
it('does not show clear button when modelValue is null', () => {
|
||||
const wrapper = mountEditor({ modelValue: null })
|
||||
const buttons = wrapper.findAll('.nc-button')
|
||||
const texts = buttons.map((b) => b.text())
|
||||
expect(texts).not.toContain('Remove recurrence')
|
||||
})
|
||||
})
|
||||
|
||||
describe('events', () => {
|
||||
it('emits update:open(false) when cancel is clicked', async () => {
|
||||
const wrapper = mountEditor()
|
||||
const buttons = wrapper.findAll('.nc-button')
|
||||
const cancelBtn = buttons.find((b) => b.text() === 'Cancel')!
|
||||
expect(cancelBtn).toBeTruthy()
|
||||
|
||||
await cancelBtn.trigger('click')
|
||||
|
||||
const emitted = wrapper.emitted('update:open')
|
||||
expect(emitted).toBeTruthy()
|
||||
expect(emitted!.some((args) => args[0] === false)).toBe(true)
|
||||
})
|
||||
|
||||
it('emits update:modelValue(null) and update:open(false) when clear is clicked', async () => {
|
||||
const wrapper = mountEditor({ modelValue: 'FREQ=WEEKLY;INTERVAL=1' })
|
||||
const buttons = wrapper.findAll('.nc-button')
|
||||
const clearBtn = buttons.find((b) => b.text() === 'Remove recurrence')!
|
||||
expect(clearBtn).toBeTruthy()
|
||||
|
||||
await clearBtn.trigger('click')
|
||||
|
||||
const modelEmitted = wrapper.emitted('update:modelValue')
|
||||
expect(modelEmitted).toBeTruthy()
|
||||
expect(modelEmitted!.some((args) => args[0] === null)).toBe(true)
|
||||
|
||||
const openEmitted = wrapper.emitted('update:open')
|
||||
expect(openEmitted).toBeTruthy()
|
||||
expect(openEmitted!.some((args) => args[0] === false)).toBe(true)
|
||||
})
|
||||
|
||||
it('emits update:fromCompletion(false) when clear is clicked', async () => {
|
||||
const wrapper = mountEditor({
|
||||
modelValue: 'FREQ=WEEKLY;INTERVAL=1',
|
||||
fromCompletion: true,
|
||||
})
|
||||
const buttons = wrapper.findAll('.nc-button')
|
||||
const clearBtn = buttons.find((b) => b.text() === 'Remove recurrence')!
|
||||
|
||||
await clearBtn.trigger('click')
|
||||
|
||||
const emitted = wrapper.emitted('update:fromCompletion')
|
||||
expect(emitted).toBeTruthy()
|
||||
expect(emitted!.some((args) => args[0] === false)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('from-completion checkbox', () => {
|
||||
it('renders the from-completion checkbox', () => {
|
||||
const wrapper = mountEditor()
|
||||
const checkbox = wrapper.find('.nc-checkbox')
|
||||
expect(checkbox.exists()).toBe(true)
|
||||
expect(checkbox.text()).toContain('Count interval from when the item is ticked off')
|
||||
})
|
||||
|
||||
it('reflects fromCompletion=false prop', () => {
|
||||
const wrapper = mountEditor({ fromCompletion: false })
|
||||
const input = wrapper.find('.nc-checkbox input[type="checkbox"]')
|
||||
expect((input.element as HTMLInputElement).checked).toBe(false)
|
||||
})
|
||||
|
||||
it('reflects fromCompletion=true prop', () => {
|
||||
const wrapper = mountEditor({ fromCompletion: true })
|
||||
const input = wrapper.find('.nc-checkbox input[type="checkbox"]')
|
||||
expect((input.element as HTMLInputElement).checked).toBe(true)
|
||||
})
|
||||
|
||||
it('shows fixed schedule hint when fromCompletion is false', () => {
|
||||
const wrapper = mountEditor({ fromCompletion: false })
|
||||
const hints = wrapper.findAll('.pantry-recurrence__hint')
|
||||
const hintTexts = hints.map((h) => h.text())
|
||||
expect(hintTexts.some((t) => t.includes('fixed'))).toBe(true)
|
||||
})
|
||||
|
||||
it('shows completion-based hint when fromCompletion is true', () => {
|
||||
const wrapper = mountEditor({ fromCompletion: true })
|
||||
const hints = wrapper.findAll('.pantry-recurrence__hint')
|
||||
const hintTexts = hints.map((h) => h.text())
|
||||
expect(hintTexts.some((t) => t.includes('tick'))).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('labels', () => {
|
||||
it('renders the presets label', () => {
|
||||
const wrapper = mountEditor()
|
||||
const labels = wrapper.findAll('.pantry-recurrence__label')
|
||||
const texts = labels.map((l) => l.text())
|
||||
expect(texts).toContain('Presets')
|
||||
})
|
||||
|
||||
it('renders the every label', () => {
|
||||
const wrapper = mountEditor()
|
||||
const labels = wrapper.findAll('.pantry-recurrence__label')
|
||||
const texts = labels.map((l) => l.text())
|
||||
expect(texts).toContain('Every')
|
||||
})
|
||||
|
||||
it('renders the ends label', () => {
|
||||
const wrapper = mountEditor()
|
||||
const labels = wrapper.findAll('.pantry-recurrence__label')
|
||||
const texts = labels.map((l) => l.text())
|
||||
expect(texts).toContain('Ends')
|
||||
})
|
||||
})
|
||||
})
|
||||
1
src/components/RecurrenceEditor/index.ts
Normal file
1
src/components/RecurrenceEditor/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default } from './RecurrenceEditor.vue'
|
||||
1
src/components/StatusBadge/index.ts
Normal file
1
src/components/StatusBadge/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default } from './StatusBadge.vue'
|
||||
@@ -1,2 +1,6 @@
|
||||
import StatusBadge from './StatusBadge.vue'
|
||||
export default StatusBadge
|
||||
export { default as CategoryPicker } from './CategoryPicker'
|
||||
export { default as HouseSettingsDialog } from './HouseSettingsDialog'
|
||||
export { default as PantrySettingsDialog } from './PantrySettingsDialog'
|
||||
export { default as RecurrenceEditor } from './RecurrenceEditor'
|
||||
export { default as StatusBadge } from './StatusBadge'
|
||||
export * from './CategoryPicker'
|
||||
|
||||
@@ -247,9 +247,9 @@ import PencilIcon from '@icons/Pencil.vue'
|
||||
import RepeatIcon from '@icons/Repeat.vue'
|
||||
import CartIcon from '@icons/Cart.vue'
|
||||
import UploadIcon from '@icons/Upload.vue'
|
||||
import RecurrenceEditor from '@/components/RecurrenceEditor.vue'
|
||||
import CategoryPicker from '@/components/CategoryPicker.vue'
|
||||
import { categoryIconComponent } from '@/components/categoryIcons'
|
||||
import RecurrenceEditor from '@/components/RecurrenceEditor'
|
||||
import CategoryPicker from '@/components/CategoryPicker'
|
||||
import { categoryIconComponent } from '@/components/CategoryPicker'
|
||||
import { useShoppingListItems } from '@/composables/useShoppingList'
|
||||
import { useCategories } from '@/composables/useCategories'
|
||||
import { getList } from '@/api/lists'
|
||||
|
||||
@@ -165,8 +165,8 @@ import ChevronDownIcon from '@icons/ChevronDown.vue'
|
||||
import CheckIcon from '@icons/Check.vue'
|
||||
import PlusIcon from '@icons/Plus.vue'
|
||||
import { useHouses } from '@/composables/useHouses'
|
||||
import HouseSettingsDialog from '@/components/HouseSettingsDialog.vue'
|
||||
import PantrySettingsDialog from '@/components/PantrySettingsDialog.vue'
|
||||
import HouseSettingsDialog from '@/components/HouseSettingsDialog'
|
||||
import PantrySettingsDialog from '@/components/PantrySettingsDialog'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
Reference in New Issue
Block a user