Add TimeSelector and ScheduleModal components with tests
All checks were successful
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 2m45s
All checks were successful
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 2m45s
- Implemented TimeSelector component for selecting time with AM/PM toggle and minute/hour increment/decrement functionality. - Created ScheduleModal component for scheduling chores with options for specific days or intervals. - Added utility functions for scheduling logic in scheduleUtils.ts. - Developed comprehensive tests for TimeSelector and scheduleUtils functions to ensure correct behavior.
This commit is contained in:
283
frontend/vue-app/src/__tests__/ScheduleModal.spec.ts
Normal file
283
frontend/vue-app/src/__tests__/ScheduleModal.spec.ts
Normal file
@@ -0,0 +1,283 @@
|
||||
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()
|
||||
|
||||
vi.mock('@/common/api', () => ({
|
||||
setChoreSchedule: (...args: unknown[]) => mockSetChoreSchedule(...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 TimeSelectorStub = {
|
||||
template: '<div class="time-selector-stub" />',
|
||||
props: ['modelValue'],
|
||||
emits: ['update:modelValue'],
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
const TASK: ChildTask = { id: 'task-1', name: 'Clean Room', is_good: true, 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, TimeSelector: TimeSelectorStub },
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mockSetChoreSchedule.mockReset()
|
||||
mockSetChoreSchedule.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 — check/uncheck days
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('ScheduleModal Specific Days form', () => {
|
||||
it('renders 7 day rows', () => {
|
||||
const w = mountModal()
|
||||
expect(w.findAll('.day-row').length).toBe(7)
|
||||
})
|
||||
|
||||
it('no days are checked by default (no existing schedule)', () => {
|
||||
const w = mountModal()
|
||||
const checkboxes = w.findAll<HTMLInputElement>('input[type="checkbox"]')
|
||||
expect(checkboxes.every((cb) => !(cb.element as HTMLInputElement).checked)).toBe(true)
|
||||
})
|
||||
|
||||
it('checking a day reveals a TimeSelector for that day', async () => {
|
||||
const w = mountModal()
|
||||
expect(w.findAll('.time-selector-stub').length).toBe(0)
|
||||
const checkboxes = w.findAll('input[type="checkbox"]')
|
||||
await checkboxes[1].trigger('change') // Monday (idx 1)
|
||||
await nextTick()
|
||||
expect(w.findAll('.time-selector-stub').length).toBe(1)
|
||||
})
|
||||
|
||||
it('unchecking a day removes its TimeSelector', async () => {
|
||||
const w = mountModal()
|
||||
const checkboxes = w.findAll('input[type="checkbox"]')
|
||||
await checkboxes[0].trigger('change') // check Sunday
|
||||
await nextTick()
|
||||
expect(w.findAll('.time-selector-stub').length).toBe(1)
|
||||
await checkboxes[0].trigger('change') // uncheck Sunday
|
||||
await nextTick()
|
||||
expect(w.findAll('.time-selector-stub').length).toBe(0)
|
||||
})
|
||||
|
||||
it('Save is disabled when no days checked (days mode)', () => {
|
||||
const w = mountModal()
|
||||
const saveBtn = w.find('.btn-primary')
|
||||
expect((saveBtn.element as HTMLButtonElement).disabled).toBe(true)
|
||||
})
|
||||
|
||||
it('Save is enabled after checking at least one day', async () => {
|
||||
const w = mountModal()
|
||||
const checkboxes = w.findAll('input[type="checkbox"]')
|
||||
await checkboxes[2].trigger('change') // Tuesday
|
||||
await nextTick()
|
||||
const saveBtn = w.find('.btn-primary')
|
||||
expect((saveBtn.element as HTMLButtonElement).disabled).toBe(false)
|
||||
})
|
||||
|
||||
it('pre-populates checked days from an existing schedule', () => {
|
||||
const existing: ChoreSchedule = {
|
||||
child_id: CHILD_ID,
|
||||
task_id: TASK.id,
|
||||
mode: 'days',
|
||||
day_configs: [
|
||||
{ day: 1, hour: 8, minute: 0 },
|
||||
{ day: 4, hour: 9, minute: 30 },
|
||||
],
|
||||
interval_days: 2,
|
||||
anchor_weekday: 0,
|
||||
interval_hour: 0,
|
||||
interval_minute: 0,
|
||||
}
|
||||
const w = mountModal(existing)
|
||||
// Two TimeSelectorStubs should already be visible
|
||||
expect(w.findAll('.time-selector-stub').length).toBe(2)
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Save emits correct ChoreSchedule shape — days mode
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('ScheduleModal save — days mode', () => {
|
||||
it('calls setChoreSchedule with correct days payload', async () => {
|
||||
const w = mountModal()
|
||||
// Check Monday (idx 1)
|
||||
const checkboxes = w.findAll('input[type="checkbox"]')
|
||||
await checkboxes[1].trigger('change')
|
||||
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: 8, minute: 0 }],
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('emits "saved" after successful save', async () => {
|
||||
const w = mountModal()
|
||||
const checkboxes = w.findAll('input[type="checkbox"]')
|
||||
await checkboxes[0].trigger('change') // check Sunday
|
||||
await nextTick()
|
||||
|
||||
await w.find('.btn-primary').trigger('click')
|
||||
await nextTick()
|
||||
await nextTick() // await the async save()
|
||||
|
||||
expect(w.emitted('saved')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('does not emit "saved" on API error', async () => {
|
||||
mockSetChoreSchedule.mockResolvedValue({ ok: false })
|
||||
const w = mountModal()
|
||||
const checkboxes = w.findAll('input[type="checkbox"]')
|
||||
await checkboxes[0].trigger('change')
|
||||
await nextTick()
|
||||
|
||||
await w.find('.btn-primary').trigger('click')
|
||||
await nextTick()
|
||||
await nextTick()
|
||||
|
||||
expect(w.emitted('saved')).toBeFalsy()
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Save emits correct ChoreSchedule shape — interval mode
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('ScheduleModal save — interval mode', () => {
|
||||
it('calls setChoreSchedule with correct interval payload', async () => {
|
||||
const w = mountModal()
|
||||
// Switch to interval mode
|
||||
await w.findAll('.mode-btn')[1].trigger('click')
|
||||
|
||||
// Set interval_days input to 3
|
||||
const intervalInput = w.find<HTMLInputElement>('.interval-input')
|
||||
await intervalInput.setValue(3)
|
||||
|
||||
// Set anchor_weekday to 2 (Tuesday)
|
||||
const anchorSelect = w.find<HTMLSelectElement>('.anchor-select')
|
||||
await anchorSelect.setValue(2)
|
||||
|
||||
await w.find('.btn-primary').trigger('click')
|
||||
await nextTick()
|
||||
|
||||
expect(mockSetChoreSchedule).toHaveBeenCalledWith(
|
||||
CHILD_ID,
|
||||
TASK.id,
|
||||
expect.objectContaining({
|
||||
mode: 'interval',
|
||||
interval_days: 3,
|
||||
anchor_weekday: 2,
|
||||
interval_hour: expect.any(Number),
|
||||
interval_minute: expect.any(Number),
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('Save enabled by default in interval mode (interval_days defaults to 2)', 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 interval fields from existing interval schedule', () => {
|
||||
const existing: ChoreSchedule = {
|
||||
child_id: CHILD_ID,
|
||||
task_id: TASK.id,
|
||||
mode: 'interval',
|
||||
day_configs: [],
|
||||
interval_days: 4,
|
||||
anchor_weekday: 3,
|
||||
interval_hour: 14,
|
||||
interval_minute: 30,
|
||||
}
|
||||
const w = mountModal(existing)
|
||||
// Should default to interval mode
|
||||
expect(w.find('.interval-form').exists()).toBe(true)
|
||||
const input = w.find<HTMLInputElement>('.interval-input')
|
||||
expect(Number((input.element as HTMLInputElement).value)).toBe(4)
|
||||
const select = w.find<HTMLSelectElement>('.anchor-select')
|
||||
expect(Number((select.element as HTMLSelectElement).value)).toBe(3)
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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()
|
||||
})
|
||||
})
|
||||
154
frontend/vue-app/src/__tests__/TimeSelector.spec.ts
Normal file
154
frontend/vue-app/src/__tests__/TimeSelector.spec.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import TimeSelector from '../components/shared/TimeSelector.vue'
|
||||
|
||||
function mk(hour: number, minute: number) {
|
||||
return mount(TimeSelector, { props: { modelValue: { hour, minute } } })
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Display
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('TimeSelector display', () => {
|
||||
it('shows 12 for midnight (hour=0)', () => {
|
||||
const w = mk(0, 0)
|
||||
expect(w.findAll('.time-value')[0].text()).toBe('12')
|
||||
expect(w.find('.ampm-btn').text()).toBe('AM')
|
||||
})
|
||||
|
||||
it('shows 12 for noon (hour=12)', () => {
|
||||
const w = mk(12, 0)
|
||||
expect(w.findAll('.time-value')[0].text()).toBe('12')
|
||||
expect(w.find('.ampm-btn').text()).toBe('PM')
|
||||
})
|
||||
|
||||
it('shows 3:30 PM for hour=15 minute=30', () => {
|
||||
const w = mk(15, 30)
|
||||
const cols = w.findAll('.time-value')
|
||||
expect(cols[0].text()).toBe('3')
|
||||
expect(cols[1].text()).toBe('30')
|
||||
expect(w.find('.ampm-btn').text()).toBe('PM')
|
||||
})
|
||||
|
||||
it('pads single-digit minutes', () => {
|
||||
const w = mk(8, 0)
|
||||
expect(w.findAll('.time-value')[1].text()).toBe('00')
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Increment / Decrement hour
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('TimeSelector hour increment/decrement', () => {
|
||||
it('emits hour+1 when increment hour clicked', async () => {
|
||||
const w = mk(8, 15)
|
||||
await w.findAll('.arrow-btn')[0].trigger('click')
|
||||
const emitted = w.emitted('update:modelValue') as any[]
|
||||
expect(emitted[0][0]).toEqual({ hour: 9, minute: 15 })
|
||||
})
|
||||
|
||||
it('emits hour-1 when decrement hour clicked', async () => {
|
||||
const w = mk(8, 15)
|
||||
await w.findAll('.arrow-btn')[1].trigger('click')
|
||||
const emitted = w.emitted('update:modelValue') as any[]
|
||||
expect(emitted[0][0]).toEqual({ hour: 7, minute: 15 })
|
||||
})
|
||||
|
||||
it('wraps hour 23 → 0 on increment', async () => {
|
||||
const w = mk(23, 0)
|
||||
await w.findAll('.arrow-btn')[0].trigger('click')
|
||||
expect(w.emitted('update:modelValue')![0][0]).toEqual({ hour: 0, minute: 0 })
|
||||
})
|
||||
|
||||
it('wraps hour 0 → 23 on decrement', async () => {
|
||||
const w = mk(0, 0)
|
||||
await w.findAll('.arrow-btn')[1].trigger('click')
|
||||
expect(w.emitted('update:modelValue')![0][0]).toEqual({ hour: 23, minute: 0 })
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Increment / Decrement minute (15-min steps)
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('TimeSelector minute increment/decrement', () => {
|
||||
it('emits minute+15 on increment', async () => {
|
||||
const w = mk(8, 0)
|
||||
await w.findAll('.arrow-btn')[2].trigger('click')
|
||||
expect(w.emitted('update:modelValue')![0][0]).toEqual({ hour: 8, minute: 15 })
|
||||
})
|
||||
|
||||
it('emits minute-15 on decrement', async () => {
|
||||
const w = mk(8, 30)
|
||||
await w.findAll('.arrow-btn')[3].trigger('click')
|
||||
expect(w.emitted('update:modelValue')![0][0]).toEqual({ hour: 8, minute: 15 })
|
||||
})
|
||||
|
||||
it('minute 45 → 0 and carry hour on increment', async () => {
|
||||
const w = mk(8, 45)
|
||||
await w.findAll('.arrow-btn')[2].trigger('click')
|
||||
expect(w.emitted('update:modelValue')![0][0]).toEqual({ hour: 9, minute: 0 })
|
||||
})
|
||||
|
||||
it('minute 0 → 45 and borrow hour on decrement', async () => {
|
||||
const w = mk(8, 0)
|
||||
await w.findAll('.arrow-btn')[3].trigger('click')
|
||||
expect(w.emitted('update:modelValue')![0][0]).toEqual({ hour: 7, minute: 45 })
|
||||
})
|
||||
|
||||
// The key boundary case from the spec: 11:45 AM → 12:00 PM
|
||||
it('11:45 AM (hour=11, minute=45) → increment minute → 12:00 PM (hour=12)', async () => {
|
||||
const w = mk(11, 45)
|
||||
await w.findAll('.arrow-btn')[2].trigger('click')
|
||||
expect(w.emitted('update:modelValue')![0][0]).toEqual({ hour: 12, minute: 0 })
|
||||
})
|
||||
|
||||
// Inverse: 12:00 PM (hour=12) → decrement minute → 11:45 AM (hour=11, minute=45)
|
||||
it('12:00 PM (hour=12, minute=0) → decrement minute → 11:45 (hour=11)', async () => {
|
||||
const w = mk(12, 0)
|
||||
await w.findAll('.arrow-btn')[3].trigger('click')
|
||||
expect(w.emitted('update:modelValue')![0][0]).toEqual({ hour: 11, minute: 45 })
|
||||
})
|
||||
|
||||
// Midnight boundary: 0:00 → decrement → 23:45
|
||||
it('0:00 AM (hour=0, minute=0) → decrement minute → 23:45', async () => {
|
||||
const w = mk(0, 0)
|
||||
await w.findAll('.arrow-btn')[3].trigger('click')
|
||||
expect(w.emitted('update:modelValue')![0][0]).toEqual({ hour: 23, minute: 45 })
|
||||
})
|
||||
|
||||
// 23:45 → increment → 0:00
|
||||
it('23:45 → increment minute → 0:00', async () => {
|
||||
const w = mk(23, 45)
|
||||
await w.findAll('.arrow-btn')[2].trigger('click')
|
||||
expect(w.emitted('update:modelValue')![0][0]).toEqual({ hour: 0, minute: 0 })
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// AM/PM toggle
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('TimeSelector AM/PM toggle', () => {
|
||||
it('toggles AM → PM (hour < 12 adds 12)', async () => {
|
||||
const w = mk(8, 0)
|
||||
await w.find('.ampm-btn').trigger('click')
|
||||
expect(w.emitted('update:modelValue')![0][0]).toEqual({ hour: 20, minute: 0 })
|
||||
})
|
||||
|
||||
it('toggles PM → AM (hour >= 12 subtracts 12)', async () => {
|
||||
const w = mk(14, 30)
|
||||
await w.find('.ampm-btn').trigger('click')
|
||||
expect(w.emitted('update:modelValue')![0][0]).toEqual({ hour: 2, minute: 30 })
|
||||
})
|
||||
|
||||
it('toggles midnight (0) → 12 (noon)', async () => {
|
||||
const w = mk(0, 0)
|
||||
await w.find('.ampm-btn').trigger('click')
|
||||
expect(w.emitted('update:modelValue')![0][0]).toEqual({ hour: 12, minute: 0 })
|
||||
})
|
||||
|
||||
it('toggles noon (12) → 0 (midnight)', async () => {
|
||||
const w = mk(12, 0)
|
||||
await w.find('.ampm-btn').trigger('click')
|
||||
expect(w.emitted('update:modelValue')![0][0]).toEqual({ hour: 0, minute: 0 })
|
||||
})
|
||||
})
|
||||
254
frontend/vue-app/src/__tests__/scheduleUtils.spec.ts
Normal file
254
frontend/vue-app/src/__tests__/scheduleUtils.spec.ts
Normal file
@@ -0,0 +1,254 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import {
|
||||
intervalHitsToday,
|
||||
isScheduledToday,
|
||||
getDueTimeToday,
|
||||
isPastTime,
|
||||
isExtendedToday,
|
||||
formatDueTimeLabel,
|
||||
msUntilExpiry,
|
||||
toLocalISODate,
|
||||
} from '../common/scheduleUtils'
|
||||
import type { ChoreSchedule } from '../common/models'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// toLocalISODate
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('toLocalISODate', () => {
|
||||
it('formats a date as YYYY-MM-DD', () => {
|
||||
const d = new Date(2025, 0, 5) // Jan 5, 2025 local
|
||||
expect(toLocalISODate(d)).toBe('2025-01-05')
|
||||
})
|
||||
|
||||
it('pads month and day', () => {
|
||||
const d = new Date(2025, 2, 9) // Mar 9
|
||||
expect(toLocalISODate(d)).toBe('2025-03-09')
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// intervalHitsToday
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('intervalHitsToday', () => {
|
||||
it('anchor day hits on itself', () => {
|
||||
// Wednesday = weekday 3
|
||||
const wednesday = new Date(2025, 0, 8) // Jan 8, 2025 is a Wednesday
|
||||
expect(intervalHitsToday(3, 2, wednesday)).toBe(true)
|
||||
})
|
||||
|
||||
it('anchor=Wednesday, interval=2, Thursday does NOT hit', () => {
|
||||
const thursday = new Date(2025, 0, 9)
|
||||
expect(intervalHitsToday(3, 2, thursday)).toBe(false)
|
||||
})
|
||||
|
||||
it('anchor=Wednesday, interval=2, Friday hits (2 days after anchor)', () => {
|
||||
const friday = new Date(2025, 0, 10)
|
||||
expect(intervalHitsToday(3, 2, friday)).toBe(true)
|
||||
})
|
||||
|
||||
it('interval=1 hits every day', () => {
|
||||
// interval of 1 means every day; diffDays % 1 === 0 always
|
||||
// Note: interval_days=1 is disallowed in UI (min 2) but logic should still work
|
||||
const monday = new Date(2025, 0, 6)
|
||||
expect(intervalHitsToday(1, 1, monday)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// isScheduledToday
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('isScheduledToday', () => {
|
||||
const daysSchedule: ChoreSchedule = {
|
||||
child_id: 'c1',
|
||||
task_id: 't1',
|
||||
mode: 'days',
|
||||
day_configs: [
|
||||
{ day: 1, hour: 8, minute: 0 }, // Monday
|
||||
{ day: 3, hour: 9, minute: 30 }, // Wednesday
|
||||
],
|
||||
interval_days: 2,
|
||||
anchor_weekday: 0,
|
||||
interval_hour: 0,
|
||||
interval_minute: 0,
|
||||
}
|
||||
|
||||
it('returns true on a scheduled weekday', () => {
|
||||
const monday = new Date(2025, 0, 6) // Jan 6, 2025 = Monday
|
||||
expect(isScheduledToday(daysSchedule, monday)).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false on an unscheduled weekday', () => {
|
||||
const tuesday = new Date(2025, 0, 7) // Tuesday
|
||||
expect(isScheduledToday(daysSchedule, tuesday)).toBe(false)
|
||||
})
|
||||
|
||||
it('interval mode delegates to intervalHitsToday', () => {
|
||||
const intervalSchedule: ChoreSchedule = {
|
||||
child_id: 'c1',
|
||||
task_id: 't1',
|
||||
mode: 'interval',
|
||||
day_configs: [],
|
||||
interval_days: 2,
|
||||
anchor_weekday: 3, // Wednesday
|
||||
interval_hour: 8,
|
||||
interval_minute: 0,
|
||||
}
|
||||
const wednesday = new Date(2025, 0, 8)
|
||||
expect(isScheduledToday(intervalSchedule, wednesday)).toBe(true)
|
||||
const thursday = new Date(2025, 0, 9)
|
||||
expect(isScheduledToday(intervalSchedule, thursday)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// getDueTimeToday
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('getDueTimeToday', () => {
|
||||
const daysSchedule: ChoreSchedule = {
|
||||
child_id: 'c1',
|
||||
task_id: 't1',
|
||||
mode: 'days',
|
||||
day_configs: [{ day: 1, hour: 8, minute: 30 }],
|
||||
interval_days: 2,
|
||||
anchor_weekday: 0,
|
||||
interval_hour: 0,
|
||||
interval_minute: 0,
|
||||
}
|
||||
|
||||
it('returns correct time for a scheduled day', () => {
|
||||
const monday = new Date(2025, 0, 6)
|
||||
expect(getDueTimeToday(daysSchedule, monday)).toEqual({ hour: 8, minute: 30 })
|
||||
})
|
||||
|
||||
it('returns null for an unscheduled day', () => {
|
||||
const tuesday = new Date(2025, 0, 7)
|
||||
expect(getDueTimeToday(daysSchedule, tuesday)).toBeNull()
|
||||
})
|
||||
|
||||
it('interval mode returns interval time on hit days', () => {
|
||||
const intervalSchedule: ChoreSchedule = {
|
||||
child_id: 'c1',
|
||||
task_id: 't1',
|
||||
mode: 'interval',
|
||||
day_configs: [],
|
||||
interval_days: 2,
|
||||
anchor_weekday: 3,
|
||||
interval_hour: 14,
|
||||
interval_minute: 45,
|
||||
}
|
||||
const wednesday = new Date(2025, 0, 8)
|
||||
expect(getDueTimeToday(intervalSchedule, wednesday)).toEqual({ hour: 14, minute: 45 })
|
||||
})
|
||||
|
||||
it('interval mode returns null on non-hit days', () => {
|
||||
const intervalSchedule: ChoreSchedule = {
|
||||
child_id: 'c1',
|
||||
task_id: 't1',
|
||||
mode: 'interval',
|
||||
day_configs: [],
|
||||
interval_days: 2,
|
||||
anchor_weekday: 3,
|
||||
interval_hour: 14,
|
||||
interval_minute: 45,
|
||||
}
|
||||
const thursday = new Date(2025, 0, 9)
|
||||
expect(getDueTimeToday(intervalSchedule, thursday)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// isPastTime
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('isPastTime', () => {
|
||||
it('returns true when current time equals due time', () => {
|
||||
const now = new Date(2025, 0, 6, 8, 30, 0)
|
||||
expect(isPastTime(8, 30, now)).toBe(true)
|
||||
})
|
||||
|
||||
it('returns true when current time is past due time', () => {
|
||||
const now = new Date(2025, 0, 6, 9, 0, 0)
|
||||
expect(isPastTime(8, 30, now)).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false when current time is before due time', () => {
|
||||
const now = new Date(2025, 0, 6, 8, 0, 0)
|
||||
expect(isPastTime(8, 30, now)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// isExtendedToday
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('isExtendedToday', () => {
|
||||
it('returns true when extension date matches today', () => {
|
||||
const today = new Date(2025, 0, 15)
|
||||
expect(isExtendedToday('2025-01-15', today)).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false when extension date does not match today', () => {
|
||||
const today = new Date(2025, 0, 15)
|
||||
expect(isExtendedToday('2025-01-14', today)).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false for null extension date', () => {
|
||||
const today = new Date(2025, 0, 15)
|
||||
expect(isExtendedToday(null, today)).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false for undefined extension date', () => {
|
||||
const today = new Date(2025, 0, 15)
|
||||
expect(isExtendedToday(undefined, today)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// formatDueTimeLabel
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('formatDueTimeLabel', () => {
|
||||
it('formats midnight as 12:00 AM', () => {
|
||||
expect(formatDueTimeLabel(0, 0)).toBe('12:00 AM')
|
||||
})
|
||||
|
||||
it('formats noon as 12:00 PM', () => {
|
||||
expect(formatDueTimeLabel(12, 0)).toBe('12:00 PM')
|
||||
})
|
||||
|
||||
it('formats 8:30 AM', () => {
|
||||
expect(formatDueTimeLabel(8, 30)).toBe('8:30 AM')
|
||||
})
|
||||
|
||||
it('formats 14:45 as 2:45 PM', () => {
|
||||
expect(formatDueTimeLabel(14, 45)).toBe('2:45 PM')
|
||||
})
|
||||
|
||||
it('pads single-digit minutes', () => {
|
||||
expect(formatDueTimeLabel(9, 5)).toBe('9:05 AM')
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// msUntilExpiry
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('msUntilExpiry', () => {
|
||||
it('returns positive ms when due time is in the future', () => {
|
||||
const now = new Date(2025, 0, 6, 8, 0, 0, 0)
|
||||
const ms = msUntilExpiry(8, 30, now)
|
||||
expect(ms).toBe(30 * 60 * 1000) // 30 minutes
|
||||
})
|
||||
|
||||
it('returns 0 when due time is in the past', () => {
|
||||
const now = new Date(2025, 0, 6, 9, 0, 0, 0)
|
||||
expect(msUntilExpiry(8, 30, now)).toBe(0)
|
||||
})
|
||||
|
||||
it('returns 0 when due time equals current time', () => {
|
||||
const now = new Date(2025, 0, 6, 8, 30, 0, 0)
|
||||
expect(msUntilExpiry(8, 30, now)).toBe(0)
|
||||
})
|
||||
|
||||
it('correctly handles seconds and milliseconds offset', () => {
|
||||
// 8:29:30.500 → due at 8:30:00.000 = 29500ms remaining
|
||||
const now = new Date(2025, 0, 6, 8, 29, 30, 500)
|
||||
expect(msUntilExpiry(8, 30, now)).toBe(29500)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user