All checks were successful
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 3m0s
- Add backend routines management with add, get, update, delete, and list functionalities. - Create models for Routine, RoutineItem, RoutineSchedule, and RoutineExtension. - Develop event types for routine confirmation and modification. - Implement frontend components for routine assignment, confirmation dialog, and routine management views. - Add unit tests for routine API and integration tests for routine CRUD flow. - Create end-to-end test plan for routines feature covering parent and child interactions.
535 lines
18 KiB
TypeScript
535 lines
18 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
import { mount } from '@vue/test-utils'
|
|
import { nextTick } from 'vue'
|
|
import ScheduleModal from '../components/shared/ScheduleModal.vue'
|
|
import type { ChildTask, ChoreSchedule, RoutineSchedule } from '../common/models'
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Mocks
|
|
// ---------------------------------------------------------------------------
|
|
const mockSetChoreSchedule = vi.fn()
|
|
const mockDeleteChoreSchedule = vi.fn()
|
|
const mockSetRoutineSchedule = vi.fn()
|
|
const mockDeleteRoutineSchedule = vi.fn()
|
|
|
|
vi.mock('@/common/api', () => ({
|
|
setChoreSchedule: (...args: unknown[]) => mockSetChoreSchedule(...args),
|
|
deleteChoreSchedule: (...args: unknown[]) => mockDeleteChoreSchedule(...args),
|
|
setRoutineSchedule: (...args: unknown[]) => mockSetRoutineSchedule(...args),
|
|
deleteRoutineSchedule: (...args: unknown[]) => mockDeleteRoutineSchedule(...args),
|
|
parseErrorResponse: vi.fn().mockResolvedValue({ msg: 'error', code: 'ERR' }),
|
|
}))
|
|
|
|
// Stubs that render their default slot so form content is accessible
|
|
const ModalDialogStub = {
|
|
template: '<div><slot /></div>',
|
|
props: ['imageUrl', 'title', 'subtitle'],
|
|
}
|
|
const TimePickerPopoverStub = {
|
|
template: '<div class="time-picker-popover-stub" />',
|
|
props: ['modelValue'],
|
|
emits: ['update:modelValue'],
|
|
}
|
|
const DateInputFieldStub = {
|
|
template:
|
|
'<input class="date-input-field-stub" type="date" :value="modelValue" :min="min" @change="$emit(\'update:modelValue\', $event.target.value)" />',
|
|
props: ['modelValue', 'min'],
|
|
emits: ['update:modelValue'],
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Helpers
|
|
// ---------------------------------------------------------------------------
|
|
const TASK: ChildTask = { id: 'task-1', name: 'Clean Room', type: 'chore', points: 5, image_id: '' }
|
|
const CHILD_ID = 'child-1'
|
|
|
|
function mountModal(schedule: ChoreSchedule | RoutineSchedule | null = null) {
|
|
return mount(ScheduleModal, {
|
|
props: { entity: TASK, entityType: 'task', childId: CHILD_ID, schedule },
|
|
global: {
|
|
stubs: {
|
|
ModalDialog: ModalDialogStub,
|
|
TimePickerPopover: TimePickerPopoverStub,
|
|
DateInputField: DateInputFieldStub,
|
|
},
|
|
},
|
|
})
|
|
}
|
|
|
|
beforeEach(() => {
|
|
mockSetChoreSchedule.mockReset()
|
|
mockDeleteChoreSchedule.mockReset()
|
|
mockSetRoutineSchedule.mockReset()
|
|
mockDeleteRoutineSchedule.mockReset()
|
|
mockSetChoreSchedule.mockResolvedValue({ ok: true })
|
|
mockDeleteChoreSchedule.mockResolvedValue({ ok: true })
|
|
mockSetRoutineSchedule.mockResolvedValue({ ok: true })
|
|
mockDeleteRoutineSchedule.mockResolvedValue({ ok: true })
|
|
})
|
|
// ---------------------------------------------------------------------------
|
|
// Mode toggle
|
|
// ---------------------------------------------------------------------------
|
|
describe('ScheduleModal mode toggle', () => {
|
|
it('defaults to Specific Days mode', () => {
|
|
const w = mountModal()
|
|
expect(w.find('.days-form').exists()).toBe(true)
|
|
expect(w.find('.interval-form').exists()).toBe(false)
|
|
})
|
|
|
|
it('switches to Every X Days on button click', async () => {
|
|
const w = mountModal()
|
|
const modeBtns = w.findAll('.mode-btn')
|
|
await modeBtns[1].trigger('click') // "Every X Days"
|
|
expect(w.find('.interval-form').exists()).toBe(true)
|
|
expect(w.find('.days-form').exists()).toBe(false)
|
|
})
|
|
|
|
it('switches back to Specific Days', async () => {
|
|
const w = mountModal()
|
|
const modeBtns = w.findAll('.mode-btn')
|
|
await modeBtns[1].trigger('click') // switch to interval
|
|
await modeBtns[0].trigger('click') // switch back
|
|
expect(w.find('.days-form').exists()).toBe(true)
|
|
expect(w.find('.interval-form').exists()).toBe(false)
|
|
})
|
|
|
|
it('marks "Specific Days" button active by default', () => {
|
|
const w = mountModal()
|
|
const modeBtns = w.findAll('.mode-btn')
|
|
expect(modeBtns[0].classes()).toContain('active')
|
|
expect(modeBtns[1].classes()).not.toContain('active')
|
|
})
|
|
|
|
it('marks "Every X Days" button active after switching', async () => {
|
|
const w = mountModal()
|
|
const modeBtns = w.findAll('.mode-btn')
|
|
await modeBtns[1].trigger('click')
|
|
expect(modeBtns[1].classes()).toContain('active')
|
|
expect(modeBtns[0].classes()).not.toContain('active')
|
|
})
|
|
})
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Specific Days form — chip toggles
|
|
// ---------------------------------------------------------------------------
|
|
describe('ScheduleModal Specific Days form', () => {
|
|
it('renders 7 day chips', () => {
|
|
const w = mountModal()
|
|
expect(w.findAll('.chip').length).toBe(7)
|
|
})
|
|
|
|
it('no chips are active by default (no existing schedule)', () => {
|
|
const w = mountModal()
|
|
const chips = w.findAll('.chip')
|
|
expect(chips.every((c) => !c.classes().includes('active'))).toBe(true)
|
|
})
|
|
|
|
it('clicking a chip makes it active', async () => {
|
|
const w = mountModal()
|
|
const chips = w.findAll('.chip')
|
|
await chips[1].trigger('click') // Monday
|
|
expect(chips[1].classes()).toContain('active')
|
|
})
|
|
|
|
it('clicking an active chip deactivates it', async () => {
|
|
const w = mountModal()
|
|
const chips = w.findAll('.chip')
|
|
await chips[1].trigger('click') // activate
|
|
await chips[1].trigger('click') // deactivate
|
|
expect(chips[1].classes()).not.toContain('active')
|
|
})
|
|
|
|
it('selecting a chip shows the default-deadline-row', async () => {
|
|
const w = mountModal()
|
|
expect(w.find('.default-deadline-row').exists()).toBe(false)
|
|
await w.findAll('.chip')[0].trigger('click')
|
|
expect(w.find('.default-deadline-row').exists()).toBe(true)
|
|
})
|
|
|
|
it('Save is disabled when no days selected (isDirty is false)', () => {
|
|
const w = mountModal()
|
|
const saveBtn = w.find('.btn-primary')
|
|
expect((saveBtn.element as HTMLButtonElement).disabled).toBe(true)
|
|
})
|
|
|
|
it('Save is enabled after selecting at least one day', async () => {
|
|
const w = mountModal()
|
|
await w.findAll('.chip')[2].trigger('click') // Tuesday
|
|
await nextTick()
|
|
const saveBtn = w.find('.btn-primary')
|
|
expect((saveBtn.element as HTMLButtonElement).disabled).toBe(false)
|
|
})
|
|
|
|
it('pre-populates active chips from an existing schedule', () => {
|
|
const existing: ChoreSchedule = {
|
|
id: 's1',
|
|
child_id: CHILD_ID,
|
|
task_id: TASK.id,
|
|
mode: 'days',
|
|
day_configs: [
|
|
{ day: 1, hour: 8, minute: 0 },
|
|
{ day: 4, hour: 9, minute: 30 },
|
|
],
|
|
default_hour: 8,
|
|
default_minute: 0,
|
|
interval_days: 1,
|
|
anchor_date: '',
|
|
interval_has_deadline: true,
|
|
interval_hour: 0,
|
|
interval_minute: 0,
|
|
created_at: 0,
|
|
updated_at: 0,
|
|
}
|
|
const w = mountModal(existing)
|
|
const chips = w.findAll('.chip')
|
|
expect(chips[1].classes()).toContain('active') // Monday idx 1
|
|
expect(chips[4].classes()).toContain('active') // Thursday idx 4
|
|
})
|
|
})
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Save emits correct ChoreSchedule shape — days mode
|
|
// ---------------------------------------------------------------------------
|
|
describe('ScheduleModal save — days mode', () => {
|
|
it('calls setChoreSchedule with correct days payload', async () => {
|
|
const w = mountModal()
|
|
// Select Monday (chip idx 1)
|
|
await w.findAll('.chip')[1].trigger('click')
|
|
await nextTick()
|
|
|
|
await w.find('.btn-primary').trigger('click')
|
|
await nextTick()
|
|
|
|
expect(mockSetChoreSchedule).toHaveBeenCalledWith(
|
|
CHILD_ID,
|
|
TASK.id,
|
|
expect.objectContaining({
|
|
mode: 'days',
|
|
day_configs: [{ day: 1, hour: expect.any(Number), minute: expect.any(Number) }],
|
|
}),
|
|
)
|
|
})
|
|
|
|
it('calls deleteChoreSchedule when 0 days selected on an existing schedule', async () => {
|
|
const existing: ChoreSchedule = {
|
|
id: 's1',
|
|
child_id: CHILD_ID,
|
|
task_id: TASK.id,
|
|
mode: 'days',
|
|
day_configs: [{ day: 2, hour: 8, minute: 0 }],
|
|
default_hour: 8,
|
|
default_minute: 0,
|
|
interval_days: 1,
|
|
anchor_date: '',
|
|
interval_has_deadline: true,
|
|
interval_hour: 0,
|
|
interval_minute: 0,
|
|
created_at: 0,
|
|
updated_at: 0,
|
|
}
|
|
const w = mountModal(existing)
|
|
await w.findAll('.chip')[2].trigger('click') // deselect Wednesday
|
|
await nextTick()
|
|
await w.find('.btn-primary').trigger('click')
|
|
await nextTick()
|
|
await nextTick()
|
|
expect(mockDeleteChoreSchedule).toHaveBeenCalledWith(CHILD_ID, TASK.id)
|
|
})
|
|
|
|
it('emits "saved" after successful save', async () => {
|
|
const w = mountModal()
|
|
await w.findAll('.chip')[0].trigger('click') // Sunday
|
|
await nextTick()
|
|
|
|
await w.find('.btn-primary').trigger('click')
|
|
await nextTick()
|
|
await nextTick()
|
|
|
|
expect(w.emitted('saved')).toBeTruthy()
|
|
})
|
|
|
|
it('does not emit "saved" on API error', async () => {
|
|
mockSetChoreSchedule.mockResolvedValue({ ok: false })
|
|
const w = mountModal()
|
|
await w.findAll('.chip')[0].trigger('click')
|
|
await nextTick()
|
|
|
|
await w.find('.btn-primary').trigger('click')
|
|
await nextTick()
|
|
await nextTick()
|
|
|
|
expect(w.emitted('saved')).toBeFalsy()
|
|
})
|
|
})
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Interval form — stepper
|
|
// ---------------------------------------------------------------------------
|
|
describe('ScheduleModal interval form — stepper', () => {
|
|
async function openInterval(schedule: ChoreSchedule | null = null) {
|
|
const w = mountModal(schedule)
|
|
await w.findAll('.mode-btn')[1].trigger('click')
|
|
return w
|
|
}
|
|
|
|
it('default stepper value is 1', async () => {
|
|
const w = await openInterval()
|
|
expect(w.find('.stepper-value').text()).toBe('1')
|
|
})
|
|
|
|
it('increment button increases the value', async () => {
|
|
const w = await openInterval()
|
|
await w.findAll('.stepper-btn')[1].trigger('click')
|
|
expect(w.find('.stepper-value').text()).toBe('2')
|
|
})
|
|
|
|
it('decrement button is disabled when value is 1', async () => {
|
|
const w = await openInterval()
|
|
const decrementBtn = w.findAll('.stepper-btn')[0]
|
|
expect((decrementBtn.element as HTMLButtonElement).disabled).toBe(true)
|
|
})
|
|
|
|
it('increment button is disabled when value is 7', async () => {
|
|
const w = await openInterval()
|
|
const plusBtn = w.findAll('.stepper-btn')[1]
|
|
for (let i = 0; i < 6; i++) await plusBtn.trigger('click')
|
|
expect((plusBtn.element as HTMLButtonElement).disabled).toBe(true)
|
|
})
|
|
|
|
it('value does not go above 7', async () => {
|
|
const w = await openInterval()
|
|
const plusBtn = w.findAll('.stepper-btn')[1]
|
|
for (let i = 0; i < 10; i++) await plusBtn.trigger('click')
|
|
expect(w.find('.stepper-value').text()).toBe('7')
|
|
})
|
|
|
|
it('pre-populates interval_days from existing schedule', async () => {
|
|
const existing: ChoreSchedule = {
|
|
id: 's2',
|
|
child_id: CHILD_ID,
|
|
task_id: TASK.id,
|
|
mode: 'interval',
|
|
day_configs: [],
|
|
interval_days: 4,
|
|
anchor_date: '2026-03-10',
|
|
interval_has_deadline: true,
|
|
interval_hour: 0,
|
|
interval_minute: 0,
|
|
created_at: 0,
|
|
updated_at: 0,
|
|
}
|
|
const w = await openInterval(existing)
|
|
expect(w.find('.stepper-value').text()).toBe('4')
|
|
})
|
|
})
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Interval form — Anytime toggle
|
|
// ---------------------------------------------------------------------------
|
|
describe('ScheduleModal interval form — Anytime toggle', () => {
|
|
async function openInterval() {
|
|
const w = mountModal()
|
|
await w.findAll('.mode-btn')[1].trigger('click')
|
|
return w
|
|
}
|
|
|
|
it('shows TimePickerPopover by default (hasDeadline=true)', async () => {
|
|
const w = await openInterval()
|
|
expect(w.find('.time-picker-popover-stub').exists()).toBe(true)
|
|
expect(w.find('.anytime-label').exists()).toBe(false)
|
|
})
|
|
|
|
it('shows "Clear (Anytime)" link by default', async () => {
|
|
const w = await openInterval()
|
|
const link = w.find('.interval-time-row .link-btn')
|
|
expect(link.text()).toContain('Clear')
|
|
})
|
|
|
|
it('clicking "Clear (Anytime)" hides TimePickerPopover and shows anytime label', async () => {
|
|
const w = await openInterval()
|
|
await w.find('.interval-time-row .link-btn').trigger('click')
|
|
await nextTick()
|
|
expect(w.find('.time-picker-popover-stub').exists()).toBe(false)
|
|
expect(w.find('.anytime-label').exists()).toBe(true)
|
|
})
|
|
|
|
it('clicking "Set deadline" after clearing restores TimePickerPopover', async () => {
|
|
const w = await openInterval()
|
|
await w.find('.interval-time-row .link-btn').trigger('click')
|
|
await nextTick()
|
|
await w.find('.interval-time-row .link-btn').trigger('click')
|
|
await nextTick()
|
|
expect(w.find('.time-picker-popover-stub').exists()).toBe(true)
|
|
expect(w.find('.anytime-label').exists()).toBe(false)
|
|
})
|
|
|
|
it('pre-populates interval_has_deadline=false from existing schedule', () => {
|
|
const existing: ChoreSchedule = {
|
|
id: 's3',
|
|
child_id: CHILD_ID,
|
|
task_id: TASK.id,
|
|
mode: 'interval',
|
|
day_configs: [],
|
|
interval_days: 2,
|
|
anchor_date: '',
|
|
interval_has_deadline: false,
|
|
interval_hour: 0,
|
|
interval_minute: 0,
|
|
created_at: 0,
|
|
updated_at: 0,
|
|
}
|
|
const w = mountModal(existing)
|
|
expect(w.find('.anytime-label').exists()).toBe(true)
|
|
expect(w.find('.time-picker-popover-stub').exists()).toBe(false)
|
|
})
|
|
})
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Save emits correct ChoreSchedule shape — interval mode
|
|
// ---------------------------------------------------------------------------
|
|
describe('ScheduleModal save — interval mode', () => {
|
|
it('calls setChoreSchedule with anchor_date and interval_has_deadline in payload', async () => {
|
|
const w = mountModal()
|
|
await w.findAll('.mode-btn')[1].trigger('click')
|
|
const plusBtn = w.findAll('.stepper-btn')[1]
|
|
await plusBtn.trigger('click') // interval_days = 2
|
|
await plusBtn.trigger('click') // interval_days = 3
|
|
|
|
await w.find('.btn-primary').trigger('click')
|
|
await nextTick()
|
|
|
|
expect(mockSetChoreSchedule).toHaveBeenCalledWith(
|
|
CHILD_ID,
|
|
TASK.id,
|
|
expect.objectContaining({
|
|
mode: 'interval',
|
|
interval_days: 3,
|
|
anchor_date: expect.any(String),
|
|
interval_has_deadline: true,
|
|
interval_hour: expect.any(Number),
|
|
interval_minute: expect.any(Number),
|
|
}),
|
|
)
|
|
})
|
|
|
|
it('payload has interval_has_deadline=false when Anytime toggle is active', async () => {
|
|
const w = mountModal()
|
|
await w.findAll('.mode-btn')[1].trigger('click')
|
|
await w.find('.interval-time-row .link-btn').trigger('click') // Clear
|
|
await nextTick()
|
|
await w.find('.btn-primary').trigger('click')
|
|
await nextTick()
|
|
expect(mockSetChoreSchedule).toHaveBeenCalledWith(
|
|
CHILD_ID,
|
|
TASK.id,
|
|
expect.objectContaining({ interval_has_deadline: false }),
|
|
)
|
|
})
|
|
|
|
it('Save enabled by default in interval mode', async () => {
|
|
const w = mountModal()
|
|
await w.findAll('.mode-btn')[1].trigger('click')
|
|
const saveBtn = w.find('.btn-primary')
|
|
expect((saveBtn.element as HTMLButtonElement).disabled).toBe(false)
|
|
})
|
|
|
|
it('pre-populates anchor_date in the DateInputField stub', () => {
|
|
const existing: ChoreSchedule = {
|
|
id: 's4',
|
|
child_id: CHILD_ID,
|
|
task_id: TASK.id,
|
|
mode: 'interval',
|
|
day_configs: [],
|
|
interval_days: 2,
|
|
anchor_date: '2026-04-01',
|
|
interval_has_deadline: true,
|
|
interval_hour: 8,
|
|
interval_minute: 0,
|
|
created_at: 0,
|
|
updated_at: 0,
|
|
}
|
|
const w = mountModal(existing)
|
|
const dateInput = w.find('.date-input-field-stub')
|
|
expect((dateInput.element as HTMLInputElement).value).toBe('2026-04-01')
|
|
})
|
|
})
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Interval form — next occurrence label
|
|
// ---------------------------------------------------------------------------
|
|
describe('ScheduleModal interval form — next occurrence', () => {
|
|
it('shows next occurrence label in interval mode', async () => {
|
|
const w = mountModal()
|
|
await w.findAll('.mode-btn')[1].trigger('click')
|
|
await nextTick()
|
|
expect(w.find('.next-occurrence-label').exists()).toBe(true)
|
|
})
|
|
|
|
it('next occurrence label contains "Next occurrence:"', async () => {
|
|
const w = mountModal()
|
|
await w.findAll('.mode-btn')[1].trigger('click')
|
|
await nextTick()
|
|
expect(w.find('.next-occurrence-label').text()).toContain('Next occurrence:')
|
|
})
|
|
})
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Cancel
|
|
// ---------------------------------------------------------------------------
|
|
describe('ScheduleModal cancel', () => {
|
|
it('emits "cancelled" when Cancel is clicked', async () => {
|
|
const w = mountModal()
|
|
await w.find('.btn-secondary').trigger('click')
|
|
expect(w.emitted('cancelled')).toBeTruthy()
|
|
})
|
|
})
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Backdrop click
|
|
// ---------------------------------------------------------------------------
|
|
describe('ScheduleModal backdrop', () => {
|
|
it('does not emit cancelled when ModalDialog fires backdrop-click', async () => {
|
|
const w = mountModal()
|
|
const dialog = w.findComponent(ModalDialogStub)
|
|
dialog.vm.$emit('backdrop-click')
|
|
await nextTick()
|
|
expect(w.emitted('cancelled')).toBeFalsy()
|
|
})
|
|
})
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Entity type routing
|
|
// ---------------------------------------------------------------------------
|
|
describe('ScheduleModal entityType routing', () => {
|
|
it('uses routine schedule API when entityType is routine', async () => {
|
|
const routineEntity = { ...TASK, id: 'routine-1', items: [] }
|
|
const w = mount(ScheduleModal, {
|
|
props: {
|
|
entity: routineEntity,
|
|
entityType: 'routine',
|
|
childId: CHILD_ID,
|
|
schedule: null,
|
|
},
|
|
global: {
|
|
stubs: {
|
|
ModalDialog: ModalDialogStub,
|
|
TimePickerPopover: TimePickerPopoverStub,
|
|
DateInputField: DateInputFieldStub,
|
|
},
|
|
},
|
|
})
|
|
|
|
await w.findAll('.chip')[1].trigger('click')
|
|
await nextTick()
|
|
await w.find('.btn-primary').trigger('click')
|
|
await nextTick()
|
|
|
|
expect(mockSetRoutineSchedule).toHaveBeenCalledWith(
|
|
CHILD_ID,
|
|
'routine-1',
|
|
expect.objectContaining({ mode: 'days' }),
|
|
)
|
|
expect(mockSetChoreSchedule).not.toHaveBeenCalled()
|
|
})
|
|
})
|