Refactored frontend directory
Some checks failed
Chore App Build, Test, and Push Docker Images / build-and-push (push) Failing after 2m46s
Some checks failed
Chore App Build, Test, and Push Docker Images / build-and-push (push) Failing after 2m46s
This commit is contained in:
490
frontend/src/__tests__/ScheduleModal.spec.ts
Normal file
490
frontend/src/__tests__/ScheduleModal.spec.ts
Normal file
@@ -0,0 +1,490 @@
|
||||
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 } from '../common/models'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mocks
|
||||
// ---------------------------------------------------------------------------
|
||||
const mockSetChoreSchedule = vi.fn()
|
||||
const mockDeleteChoreSchedule = vi.fn()
|
||||
|
||||
vi.mock('@/common/api', () => ({
|
||||
setChoreSchedule: (...args: unknown[]) => mockSetChoreSchedule(...args),
|
||||
deleteChoreSchedule: (...args: unknown[]) => mockDeleteChoreSchedule(...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 | null = null) {
|
||||
return mount(ScheduleModal, {
|
||||
props: { task: TASK, childId: CHILD_ID, schedule },
|
||||
global: {
|
||||
stubs: {
|
||||
ModalDialog: ModalDialogStub,
|
||||
TimePickerPopover: TimePickerPopoverStub,
|
||||
DateInputField: DateInputFieldStub,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mockSetChoreSchedule.mockReset()
|
||||
mockDeleteChoreSchedule.mockReset()
|
||||
mockSetChoreSchedule.mockResolvedValue({ ok: true })
|
||||
mockDeleteChoreSchedule.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()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user