Files
chore/frontend/src/__tests__/ScheduleModal.spec.ts
Ryan Kegel eb775ba7d8
All checks were successful
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 3m0s
feat: Implement routines feature with CRUD operations and child assignment
- 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.
2026-05-05 09:08:19 -04:00

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