From 0c813c3ac8254404ed1bf52813b2a0d0153ebb48 Mon Sep 17 00:00:00 2001 From: AnRil Date: Fri, 22 May 2026 23:22:34 +0700 Subject: [PATCH] =?UTF-8?q?fix+test:=20=D0=B0=D0=B2=D1=82=D0=BE=D0=BD?= =?UTF-8?q?=D0=BE=D0=BC=D0=BD=D1=8B=D0=B5=20=D0=BF=D1=80=D0=B0=D0=B2=D0=BA?= =?UTF-8?q?=D0=B8=20=D0=BF=D0=BE=D1=81=D0=BB=D0=B5=20=D1=80=D0=B5=D0=B2?= =?UTF-8?q?=D1=8C=D1=8E=20v0.5.7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bug — Heatmap/streak/achievements не обновлялись после markDone/ markChallengeDone. Регресс из Sprint C (история выделена из state-broadcast). Корень: store мутирует Exercise.lastDoneAt in-place → state.exercises ref не меняется → useEffect([exercises]) не fires → Dashboard не перетягивает history. Фикс: новый event IPC.evtHistoryChanged + broadcastHistoryChanged(). Триггерится после markDone/snooze/skip/markChallengeDone/ clearHistory/import. Dashboard.useEffect подписывается через onHistoryChanged. Settings → AboutCard теперь показывает текущую версию приложения (раньше была только кнопка «Что нового»). Версия через IPC.getAppVersion. Tests: +6 для repsDoneTodayForExercise — match-challenges, snapshot, deleted-exercise fallback, ignore skip/snooze. +2 для dailyReps с новыми snapshot-полями (match-challenges и deleted exercises). +6 для unseenVersions + RELEASE_NOTES контракт. +7 для adjustNextFireAt (адаптивный шедулер): малая история, плохой/хороший час, MAX_SHIFT_HOURS, фильтр по упражнению, 30-day window. Итого 135 → 159 (+24). Грепнул src/ на стейл-references к removed setPaused/isPaused/ `let paused` — чисто. Sprint C-D refactor завершён без residue. --- src/main/adaptive.test.ts | 126 +++++++++++++++++++++++++++ src/main/ipc.ts | 19 ++-- src/main/state-actions.ts | 7 ++ src/preload/index.ts | 4 +- src/renderer/src/i18n/dict.ts | 4 + src/renderer/src/lib/history.test.ts | 105 +++++++++++++++++++++- src/renderer/src/pages/Dashboard.tsx | 16 +++- src/renderer/src/pages/Settings.tsx | 17 ++++ src/shared/ipc.ts | 9 ++ src/shared/release-notes.test.ts | 75 ++++++++++++++++ 10 files changed, 371 insertions(+), 11 deletions(-) create mode 100644 src/main/adaptive.test.ts create mode 100644 src/shared/release-notes.test.ts diff --git a/src/main/adaptive.test.ts b/src/main/adaptive.test.ts new file mode 100644 index 0000000..3a10bee --- /dev/null +++ b/src/main/adaptive.test.ts @@ -0,0 +1,126 @@ +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() + ) + }) +}) diff --git a/src/main/ipc.ts b/src/main/ipc.ts index 0f9e0c1..df8c1f2 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -30,7 +30,7 @@ import { updateExercise, updateSettings } from './store' -import { broadcastState } from './state-actions' +import { broadcastHistoryChanged, broadcastState } from './state-actions' import { setAutostart, isAutostartEnabled } from './autostart' import { forceCheck } from './scheduler' import { hideReminderWindow, getMainWindow } from './windows' @@ -122,6 +122,7 @@ export function registerIpc(): void { if (!id) return null const ex = markDone(id, validateActualReps(repsRaw)) broadcastState() + broadcastHistoryChanged() return ex }) @@ -131,6 +132,7 @@ export function registerIpc(): void { if (!id || minutes === null) return null const ex = snooze(id, minutes) broadcastState() + broadcastHistoryChanged() return ex }) @@ -139,6 +141,7 @@ export function registerIpc(): void { if (!id) return null const ex = skip(id) broadcastState() + broadcastHistoryChanged() return ex }) @@ -299,6 +302,7 @@ export function registerIpc(): void { if (!id || reps === undefined || reps <= 0) return false markChallengeDone(id, reps) broadcastState() + broadcastHistoryChanged() return true } ) @@ -328,9 +332,11 @@ export function registerIpc(): void { // History ipcMain.handle(IPC.getHistory, (_e, sinceMs?: number) => getHistory(sinceMs)) - ipcMain.handle(IPC.clearHistory, (_e, beforeTs?: number) => - clearHistory(beforeTs) - ) + ipcMain.handle(IPC.clearHistory, (_e, beforeTs?: number) => { + const removed = clearHistory(beforeTs) + if (removed > 0) broadcastHistoryChanged() + return removed + }) // Export / Import. Используем native save/open dialogs Electron'а — // renderer не получает прямого доступа к ФС. @@ -368,7 +374,10 @@ export function registerIpc(): void { try { const raw = readFileSync(result.filePaths[0], 'utf-8') const ok = importState(raw) - if (ok) broadcastState() + if (ok) { + broadcastState() + broadcastHistoryChanged() + } return { ok } } catch (e) { return { ok: false, error: String(e) } diff --git a/src/main/state-actions.ts b/src/main/state-actions.ts index d32671f..bc2b46c 100644 --- a/src/main/state-actions.ts +++ b/src/main/state-actions.ts @@ -12,6 +12,13 @@ export function broadcastState(): void { } } +/** Сигнализирует renderer'у что историю надо перетянуть. */ +export function broadcastHistoryChanged(): void { + for (const win of BrowserWindow.getAllWindows()) { + if (!win.isDestroyed()) win.webContents.send(IPC.evtHistoryChanged) + } +} + export function snoozeAll(minutes: number): void { const now = Date.now() for (const ex of getExercises()) { diff --git a/src/preload/index.ts b/src/preload/index.ts index c714c77..587e3da 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -141,7 +141,9 @@ const api = { onMaximizeChanged: (h: Handler): Unsub => on(IPC.evtMaximizeChanged, h), onMeetingChanged: (h: Handler): Unsub => - on(IPC.evtMeetingChanged, h) + on(IPC.evtMeetingChanged, h), + onHistoryChanged: (h: Handler): Unsub => + on(IPC.evtHistoryChanged, h) } contextBridge.exposeInMainWorld('api', api) diff --git a/src/renderer/src/i18n/dict.ts b/src/renderer/src/i18n/dict.ts index d4895c7..d0c3f61 100644 --- a/src/renderer/src/i18n/dict.ts +++ b/src/renderer/src/i18n/dict.ts @@ -174,6 +174,8 @@ export const ru: Dict = { 'settings.data.import.ok': 'Восстановлено', 'settings.data.import.err': 'Файл не подошёл — это не наша резервная копия?', 'settings.section.about': 'О приложении', + 'settings.version.label': 'Версия', + 'settings.version.hint': 'Текущая установленная версия приложения.', 'settings.whatsnew.label': 'Что нового', 'settings.whatsnew.hint': 'Посмотреть заметки последних релизов.', 'settings.whatsnew.btn': 'Открыть', @@ -503,6 +505,8 @@ export const en: Dict = { 'settings.data.import.ok': 'Restored', 'settings.data.import.err': "Couldn't read the file — not our backup?", 'settings.section.about': 'About', + 'settings.version.label': 'Version', + 'settings.version.hint': 'Currently installed app version.', 'settings.whatsnew.label': "What's new", 'settings.whatsnew.hint': 'See the latest release notes.', 'settings.whatsnew.btn': 'Open', diff --git a/src/renderer/src/lib/history.test.ts b/src/renderer/src/lib/history.test.ts index 96e77aa..99b07e2 100644 --- a/src/renderer/src/lib/history.test.ts +++ b/src/renderer/src/lib/history.test.ts @@ -5,7 +5,8 @@ import { dailyReps, dayKey, dailyRepsRange, - plannedRepsToday + plannedRepsToday, + repsDoneTodayForExercise } from './history' const MS_DAY = 24 * 60 * 60 * 1000 @@ -197,3 +198,105 @@ describe('currentStreak edge cases', () => { expect(currentStreak(hist)).toBe(1) }) }) + +describe('repsDoneTodayForExercise', () => { + const today = Date.now() + const exercise = ex('a', 10) + const other = ex('b', 5) + + it('returns 0 if no entries', () => { + expect(repsDoneTodayForExercise([], exercise)).toBe(0) + }) + + it('counts only entries for this exercise today', () => { + const hist = [ + entry('a', today), + entry('a', today), + entry('b', today), // other exercise — игнорируем + entry('a', today - 2 * 24 * 60 * 60 * 1000) // позавчера — игнорируем + ] + expect(repsDoneTodayForExercise(hist, exercise)).toBe(20) + expect(repsDoneTodayForExercise(hist, other)).toBe(5) + }) + + it('uses actualReps when set', () => { + const hist = [entry('a', today, 'done', 7), entry('a', today)] + expect(repsDoneTodayForExercise(hist, exercise)).toBe(7 + 10) + }) + + it('ignores skip / snooze entries', () => { + const hist = [ + entry('a', today, 'skip'), + entry('a', today, 'snooze'), + entry('a', today) + ] + expect(repsDoneTodayForExercise(hist, exercise)).toBe(10) + }) + + it('prefers entry.reps snapshot over exercise.reps (historical accuracy)', () => { + // Контракт: entry.reps это «сколько было запланировано на момент + // записи». Если пользователь раньше делал 15 раз приседаний, потом + // изменил планку на 10 — history должна показывать 15 для старых + // entries, не 10. Это правильнее для аналитики «что я тогда делал». + const histWithSnapshot: HistoryEntry[] = [ + { exerciseId: 'a', ts: today, action: 'done', reps: 15 } + ] + expect(repsDoneTodayForExercise(histWithSnapshot, exercise)).toBe(15) + }) + + it('falls back to exercise.reps when entry has no snapshot', () => { + // Старые entries (до Sprint #1 / v0.5.7) не имеют entry.reps. + // Должны fall'back'нуться на текущий exercise.reps. + const histOldEntry: HistoryEntry[] = [ + { exerciseId: 'a', ts: today, action: 'done' } + ] + expect(repsDoneTodayForExercise(histOldEntry, exercise)).toBe(10) + }) + + it('survives match challenges (exerciseId=challenge:)', () => { + // Match-челлендж не привязан к exercise — repsDoneTodayForExercise + // его игнорирует (это не reps для этого упражнения). + const hist: HistoryEntry[] = [ + { + exerciseId: 'challenge:abc', + ts: today, + action: 'done', + actualReps: 30, + reps: 30, + source: 'match' + } + ] + expect(repsDoneTodayForExercise(hist, exercise)).toBe(0) + }) +}) + +describe('dailyReps with new entry.reps snapshot', () => { + const today = Date.now() + const exs = [ex('a', 10)] + + it('counts match-challenge entries via entry.reps snapshot', () => { + // У match-челленджа exerciseId='challenge:', byId.get вернёт + // undefined. entry.reps snapshot — единственный источник. + const hist: HistoryEntry[] = [ + { + exerciseId: 'challenge:abc', + ts: today, + action: 'done', + actualReps: 30, + reps: 30, + source: 'match' + }, + entry('a', today) // обычная entry — 10 reps через byId + ] + expect(dailyReps(hist, exs, dayKey(today))).toBe(40) + }) + + it('survives deleted exercise via entry.reps snapshot', () => { + // Упражнение 'gone' удалено, но entry.reps=8 был записан до удаления. + const hist: HistoryEntry[] = [ + { exerciseId: 'gone', ts: today, action: 'done', reps: 8 } + ] + // byId.get('gone') = undefined → fallback на entry.reps=8. + expect(dailyReps(hist, exs, dayKey(today))).toBe(8) + }) +}) diff --git a/src/renderer/src/pages/Dashboard.tsx b/src/renderer/src/pages/Dashboard.tsx index b4b2f9e..b98e553 100644 --- a/src/renderer/src/pages/Dashboard.tsx +++ b/src/renderer/src/pages/Dashboard.tsx @@ -60,12 +60,20 @@ export default function Dashboard(): JSX.Element { (g) => g.enabled && (!g.integrationActive || g.launchOptionStatus !== 'applied') ) - // Local history mirror; reloaded only when exercises change (not on every - // tick or settings tweak — those don't affect history). When ticks/settings - // change we don't re-fetch. + // Local history mirror. Перетягиваем (а) на mount, (б) при изменении + // exercises (add/delete/edit — могут поменять name/icon в snapshot'ах + // для будущих entries), (в) при evtHistoryChanged — это event который + // main отправляет ПОСЛЕ любого markDone/markChallengeDone/clearHistory/ + // import. Без (в) heatmap и стрик стояли на месте после markDone — + // store мутирует exercise in place, ref не меняется, useEffect не + // fire'ил. const [history, setHistory] = useState([]) useEffect(() => { - void window.api.getHistory().then(setHistory) + const refetch = (): void => { + void window.api.getHistory().then(setHistory) + } + refetch() + return window.api.onHistoryChanged(refetch) }, [exercises]) // Meeting auto-pause indicator: подписываемся на evtMeetingChanged + diff --git a/src/renderer/src/pages/Settings.tsx b/src/renderer/src/pages/Settings.tsx index 856ff93..26b77ed 100644 --- a/src/renderer/src/pages/Settings.tsx +++ b/src/renderer/src/pages/Settings.tsx @@ -191,6 +191,10 @@ export default function SettingsPage(): JSX.Element { function AboutCard(): JSX.Element { const { t } = useT() const [open, setOpen] = useState(false) + const [version, setVersion] = useState('') + useEffect(() => { + void window.api.getAppVersion().then(setVersion) + }, []) // Все версии для которых у нас есть заметки, отсортированы desc. const allVersions = Object.keys(RELEASE_NOTES).sort((a, b) => { const pa = a.split('.').map(Number) @@ -200,6 +204,19 @@ function AboutCard(): JSX.Element { }) return ( + +
+
+ {t('settings.version.label')} +
+
+ {t('settings.version.hint')} +
+
+
+ {version ? `v${version}` : '—'} +
+
diff --git a/src/shared/ipc.ts b/src/shared/ipc.ts index 9f19e40..109e0cb 100644 --- a/src/shared/ipc.ts +++ b/src/shared/ipc.ts @@ -68,6 +68,15 @@ export const IPC = { evtUpdaterStatus: 'evt:updaterStatus', evtMaximizeChanged: 'evt:maximizeChanged', evtMeetingChanged: 'evt:meetingChanged', + /** + * Шлётся когда история мутирует (markDone / snooze / skip / + * markChallengeDone / clearHistory / import). Renderer'у достаточно + * перезапросить getHistory. Раньше Dashboard переключал history по + * `exercises` ref'у — но markDone мутирует Exercise in place, ref не + * меняется, и heatmap стояла. Этот event — единый сигнал что надо + * перетянуть. + */ + evtHistoryChanged: 'evt:historyChanged', getMeetingActive: 'system:meetingActive' } as const diff --git a/src/shared/release-notes.test.ts b/src/shared/release-notes.test.ts new file mode 100644 index 0000000..eb3396a --- /dev/null +++ b/src/shared/release-notes.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from 'vitest' +import { unseenVersions, RELEASE_NOTES } from './release-notes' + +describe('unseenVersions', () => { + // Завязываемся на реальный RELEASE_NOTES — это OK, тест защищает контракт. + // Если из RELEASE_NOTES удалят ключ, упадёт expect. + + it('returns only current version when lastSeen is undefined (new user proxy)', () => { + // Логика: при отсутствии lastSeen возвращаем только current — мы НЕ + // знаем, новичок это или нет; UI решает (через exercises.lastDoneAt). + // Эта функция только показывает «что было бы показано». + const result = unseenVersions('0.5.6', undefined) + expect(result).toEqual(['0.5.6']) + }) + + it('returns nothing when lastSeen equals current', () => { + expect(unseenVersions('0.5.6', '0.5.6')).toEqual([]) + }) + + it('returns versions strictly between lastSeen and current (desc)', () => { + // Юзер видел 0.5.4, обновился на 0.5.7 → видит 0.5.5, 0.5.6, 0.5.7. + const result = unseenVersions('0.5.7', '0.5.4') + // Порядок desc (новейшее сверху). + expect(result[0]).toBe('0.5.7') + expect(result).toContain('0.5.5') + expect(result).toContain('0.5.6') + expect(result).not.toContain('0.5.4') + expect(result).not.toContain('0.5.3') + }) + + it('skips versions beyond current (no notes for not-yet-installed releases)', () => { + // Юзер видел 0.5.4, current=0.5.5 → видит только 0.5.5, не 0.5.6+. + const result = unseenVersions('0.5.5', '0.5.4') + expect(result).toEqual(['0.5.5']) + }) + + it('handles versions with patch increments correctly', () => { + const result = unseenVersions('0.5.7', '0.5.6') + expect(result).toEqual(['0.5.7']) + }) + + it('lastSeen ahead of current returns empty (downgrade case)', () => { + const result = unseenVersions('0.5.3', '0.5.5') + expect(result).toEqual([]) + }) +}) + +describe('RELEASE_NOTES contract', () => { + it('has both ru and en for every version', () => { + for (const [v, notes] of Object.entries(RELEASE_NOTES)) { + expect(notes.ru, `v${v} missing ru notes`).toBeTruthy() + expect(notes.en, `v${v} missing en notes`).toBeTruthy() + expect(notes.ru.length, `v${v} empty ru`).toBeGreaterThan(0) + expect(notes.en.length, `v${v} empty en`).toBeGreaterThan(0) + } + }) + + it('all version keys match semver-light /^\\d+\\.\\d+\\.\\d+$/', () => { + for (const v of Object.keys(RELEASE_NOTES)) { + expect(v).toMatch(/^\d+\.\d+\.\d+$/) + } + }) + + it('every note item has title; tag is in allowed set if present', () => { + const allowedTags = new Set(['new', 'fix', 'security', 'perf']) + for (const notes of Object.values(RELEASE_NOTES)) { + for (const lang of ['ru', 'en'] as const) { + for (const it of notes[lang]) { + expect(it.title.length).toBeGreaterThan(0) + if (it.tag) expect(allowedTags.has(it.tag)).toBe(true) + } + } + } + }) +})