feat(meals): вкладка «Питание» — напоминания о еде по времени суток
Новая модель Meal — напоминание по настенным часам (time HH:MM + дни недели), в отличие от interval-based Exercise. Отдельная вкладка «Питание» с пресетами быстрого добавления (Завтрак/Обед/Ужин/Перекус). - shared: тип Meal, meals в AppState, nextMealOccurrence (DST-safe), SAMPLE_MEALS, MEAL_PRESETS; IPC-каналы meal:* + evtFireMeal - main: валидация (строгая HH:MM-проверка диапазона), store-мутаторы с пересчётом nextFireAt, scheduler.checkDueMeals (гейт только globalEnabled, grace-окно 120с, игнор тихих часов/ВКС), notifications.fireMealReminder, IPC-хендлеры - renderer: вкладка Meals + MealEditor (время/дни/иконка), MealReminder в окне напоминания (Поел/Отложить, TTS), пункт в Sidebar, маршрут, i18n RU/EN, иконки UtensilsCrossed/Soup - persistence: meals additive (без bump схемы — старые state'ы получают []) - +24 теста (203 -> 227): nextMealOccurrence, валидаторы приёмов пищи, scheduler meal-gating (вкл/выкл, grace, игнор тихих часов) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -2,6 +2,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import type {
|
||||
Exercise,
|
||||
HistoryEntry,
|
||||
Meal,
|
||||
QuietHours,
|
||||
Settings
|
||||
} from '@shared/types'
|
||||
@@ -16,10 +17,13 @@ import { DEFAULT_SETTINGS } from '@shared/types'
|
||||
const h = vi.hoisted(() => ({
|
||||
settings: null as Settings | null,
|
||||
exercises: [] as Exercise[],
|
||||
meals: [] as Meal[],
|
||||
history: [] as HistoryEntry[],
|
||||
meetingActive: false,
|
||||
fireReminder: vi.fn(),
|
||||
fireMealReminder: vi.fn(),
|
||||
updateExercise: vi.fn(),
|
||||
updateMeal: vi.fn(),
|
||||
broadcastState: vi.fn(),
|
||||
refreshMeetingState: vi.fn(),
|
||||
adjustNextFireAt: vi.fn((_ex: Exercise, candidate: number) => candidate)
|
||||
@@ -32,14 +36,23 @@ vi.mock('electron', () => ({
|
||||
vi.mock('./store', () => ({
|
||||
getSettings: () => h.settings,
|
||||
getExercises: () => h.exercises,
|
||||
getMeals: () => h.meals,
|
||||
getHistory: () => h.history,
|
||||
updateExercise: (id: string, patch: Partial<Exercise>) => {
|
||||
h.updateExercise(id, patch)
|
||||
const ex = h.exercises.find((e) => e.id === id)
|
||||
return ex ? { ...ex, ...patch } : undefined
|
||||
},
|
||||
updateMeal: (id: string, patch: Partial<Meal>) => {
|
||||
h.updateMeal(id, patch)
|
||||
const m = h.meals.find((e) => e.id === id)
|
||||
return m ? { ...m, ...patch } : undefined
|
||||
}
|
||||
}))
|
||||
vi.mock('./notifications', () => ({ fireReminder: h.fireReminder }))
|
||||
vi.mock('./notifications', () => ({
|
||||
fireReminder: h.fireReminder,
|
||||
fireMealReminder: h.fireMealReminder
|
||||
}))
|
||||
vi.mock('./state-actions', () => ({ broadcastState: h.broadcastState }))
|
||||
vi.mock('./meeting-detect', () => ({
|
||||
isMeetingActiveSync: () => h.meetingActive,
|
||||
@@ -78,14 +91,30 @@ async function loadScheduler(): Promise<typeof import('./scheduler')> {
|
||||
return import('./scheduler')
|
||||
}
|
||||
|
||||
function makeMeal(over: Partial<Meal> = {}): Meal {
|
||||
return {
|
||||
id: 'm1',
|
||||
name: 'Обед',
|
||||
time: '13:00',
|
||||
icon: 'Soup',
|
||||
enabled: true,
|
||||
days: [],
|
||||
nextFireAt: Date.now() - 1000, // due, в пределах grace
|
||||
...over
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetModules()
|
||||
h.settings = { ...DEFAULT_SETTINGS }
|
||||
h.exercises = []
|
||||
h.meals = []
|
||||
h.history = []
|
||||
h.meetingActive = false
|
||||
h.fireReminder.mockClear()
|
||||
h.fireMealReminder.mockClear()
|
||||
h.updateExercise.mockClear()
|
||||
h.updateMeal.mockClear()
|
||||
h.broadcastState.mockClear()
|
||||
h.refreshMeetingState.mockClear()
|
||||
h.adjustNextFireAt.mockClear()
|
||||
@@ -165,3 +194,49 @@ describe('checkDueExercises gating', () => {
|
||||
expect(h.fireReminder).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('checkDueMeals', () => {
|
||||
it('fire-ит приём пищи, чьё время наступило (в пределах grace)', async () => {
|
||||
h.meals = [makeMeal()]
|
||||
const { forceCheck } = await loadScheduler()
|
||||
forceCheck()
|
||||
expect(h.fireMealReminder).toHaveBeenCalledTimes(1)
|
||||
// Переносит nextFireAt вперёд.
|
||||
expect(h.updateMeal).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('пропускает выключенный приём пищи', async () => {
|
||||
h.meals = [makeMeal({ enabled: false })]
|
||||
const { forceCheck } = await loadScheduler()
|
||||
forceCheck()
|
||||
expect(h.fireMealReminder).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('не fire-ит при globalEnabled=false', async () => {
|
||||
h.settings = { ...DEFAULT_SETTINGS, globalEnabled: false }
|
||||
h.meals = [makeMeal()]
|
||||
const { forceCheck } = await loadScheduler()
|
||||
forceCheck()
|
||||
expect(h.fireMealReminder).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('пропущенный давно (> grace) переносится без срабатывания', async () => {
|
||||
h.meals = [makeMeal({ nextFireAt: Date.now() - 10 * 60_000 })]
|
||||
const { forceCheck } = await loadScheduler()
|
||||
forceCheck()
|
||||
expect(h.fireMealReminder).not.toHaveBeenCalled()
|
||||
expect(h.updateMeal).toHaveBeenCalled() // всё равно переносим вперёд
|
||||
})
|
||||
|
||||
it('приёмы пищи ИГНОРИРУЮТ тихие часы (в отличие от упражнений)', async () => {
|
||||
h.settings = { ...DEFAULT_SETTINGS, quietHours: quietWindowAroundNow() }
|
||||
h.exercises = [makeExercise()]
|
||||
h.meals = [makeMeal()]
|
||||
const { forceCheck } = await loadScheduler()
|
||||
forceCheck()
|
||||
// Упражнение подавлено тихими часами...
|
||||
expect(h.fireReminder).not.toHaveBeenCalled()
|
||||
// ...а приём пищи всё равно срабатывает.
|
||||
expect(h.fireMealReminder).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user