test: add frontend tests

This commit is contained in:
2026-04-06 11:06:48 +03:00
parent 2aecd4cc6e
commit ec2d2e75a5
20 changed files with 1372 additions and 8 deletions

View 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()
})
})
})

View File

@@ -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<{

View 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)
}
})
})

View File

@@ -0,0 +1,2 @@
export { default } from './CategoryPicker.vue'
export * from './categoryIcons'

View 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()
})
})
})

View File

@@ -0,0 +1 @@
export { default } from './HouseSettingsDialog.vue'

View 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')
})
})
})

View File

@@ -0,0 +1 @@
export { default } from './PantrySettingsDialog.vue'

View 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')
})
})
})

View File

@@ -0,0 +1 @@
export { default } from './RecurrenceEditor.vue'

View File

@@ -0,0 +1 @@
export { default } from './StatusBadge.vue'

View File

@@ -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'

View File

@@ -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'

View File

@@ -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()