import { describe, expect, it } from 'vitest' import type { Exercise, HistoryEntry } from '@shared/types' import { adjustNextFireAt } from './adaptive' const ex: Exercise = { id: 'e1', name: 'Pushups', reps: 10, icon: 'Dumbbell', intervalMinutes: 30, enabled: true, nextFireAt: 0, adaptive: true } function entryAt(year: number, month: number, day: number, hour: number, action: 'done' | 'skip' | 'snooze'): HistoryEntry { return { exerciseId: 'e1', ts: new Date(year, month - 1, day, hour).getTime(), action } } /** Помощник: построить N entries в указанный hour-of-day за последние 30 дней. */ function buildAtHour(hour: number, doneCount: number, skipCount: number): HistoryEntry[] { const now = new Date() const out: HistoryEntry[] = [] for (let i = 0; i < doneCount; i++) { const d = new Date(now) d.setDate(d.getDate() - i - 1) d.setHours(hour, 0, 0, 0) out.push({ exerciseId: 'e1', ts: d.getTime(), action: 'done' }) } for (let i = 0; i < skipCount; i++) { const d = new Date(now) d.setDate(d.getDate() - i - 1) d.setHours(hour, 0, 0, 0) out.push({ exerciseId: 'e1', ts: d.getTime(), action: 'skip' }) } return out } describe('adjustNextFireAt', () => { it('returns candidate unchanged when history is too small (<10 events)', () => { const candidate = new Date(2026, 4, 22, 14, 30).getTime() const result = adjustNextFireAt(ex, candidate, [ entryAt(2026, 4, 21, 14, 'done'), entryAt(2026, 4, 20, 14, 'done') ]) expect(result).toBe(candidate) }) it('returns candidate unchanged when candidate hour is not bad', () => { // Час 14 — хороший (10 done, 0 skip = 100% success). Не сдвигаем. const history = buildAtHour(14, 10, 0) const candidate = new Date() candidate.setHours(14, 30, 0, 0) expect(adjustNextFireAt(ex, candidate.getTime(), history)).toBe( candidate.getTime() ) }) it('shifts candidate from a bad hour to the nearest non-bad hour', () => { // Час 9 — плохой (1 done, 9 skip = 10% success). // Час 10 — нейтральный (no data) = good по нашему определению. // Спецификация: шедулер выбирает первый non-bad час, neutral OK // (пользователь ещё не показал, что этот час плохой). const history = [ ...buildAtHour(9, 1, 9), // 10 событий, success 10% ...buildAtHour(11, 10, 0) // 10 событий, success 100% ] const candidate = new Date() candidate.setHours(9, 30, 0, 0) const result = adjustNextFireAt(ex, candidate.getTime(), history) const shifted = new Date(result) // Час 10 ближайший non-bad (neutral). expect(shifted.getHours()).toBe(10) expect(shifted.getMinutes()).toBe(0) }) it('does not shift beyond MAX_SHIFT_HOURS (4 hours)', () => { // Час 9 — плохой. Все часы 10..23 без данных (neutral, не «good» // по нашему определению isHourGood которое требует tota=0 OR rate>=0.5). // Wait — isHourGood вернёт true если total===0 (neutral). Значит // сдвиг произойдёт на 10:00. Это OK поведение — neutral час лучше // плохого. const history = buildAtHour(9, 1, 9) const candidate = new Date() candidate.setHours(9, 30, 0, 0) const result = adjustNextFireAt(ex, candidate.getTime(), history) // Сдвиг на 10:00 (первый neutral час). expect(new Date(result).getHours()).toBe(10) }) it('only counts entries for this exercise', () => { // Истории много, но всё по другому упражнению — не trust'able. const otherEx: HistoryEntry[] = [] for (let i = 0; i < 20; i++) { const d = new Date() d.setDate(d.getDate() - i - 1) d.setHours(9, 0, 0, 0) otherEx.push({ exerciseId: 'other', ts: d.getTime(), action: 'skip' }) } const candidate = new Date() candidate.setHours(9, 30, 0, 0) expect(adjustNextFireAt(ex, candidate.getTime(), otherEx)).toBe( candidate.getTime() ) }) it('ignores entries older than 30 days', () => { // 20 событий 60 дней назад → не учитываются (только 30-day window). const oldHistory: HistoryEntry[] = [] for (let i = 0; i < 20; i++) { const d = new Date() d.setDate(d.getDate() - 60 - i) d.setHours(9, 0, 0, 0) oldHistory.push({ exerciseId: 'e1', ts: d.getTime(), action: 'skip' }) } const candidate = new Date() candidate.setHours(9, 30, 0, 0) expect(adjustNextFireAt(ex, candidate.getTime(), oldHistory)).toBe( candidate.getTime() ) }) })