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: '
', props: ['imageUrl', 'title', 'subtitle'], } const TimePickerPopoverStub = { template: '
', props: ['modelValue'], emits: ['update:modelValue'], } const DateInputFieldStub = { template: '', 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() }) })