Compare commits
4 Commits
v0.5.3
...
9378cabfe5
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9378cabfe5 | ||
|
|
c735659567 | ||
|
|
c5c05ee651 | ||
|
|
36085f225f |
24
CHANGELOG.md
24
CHANGELOG.md
@@ -6,6 +6,27 @@
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [0.5.4] — 2026-05-19
|
||||||
|
|
||||||
|
Обновление приложения теперь по-настоящему фоновое + почти моментальный
|
||||||
|
рестарт в новую версию.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **Скачивание апдейта — фоновое.** Раньше клик «Скачать» блокировал
|
||||||
|
кнопку (`busy=true`) до конца download'а (минуты на медленной сети).
|
||||||
|
Теперь IPC `updaterDownload` — fire-and-forget, прогресс приходит
|
||||||
|
через события. Пользователь сразу может уйти на Dashboard и
|
||||||
|
продолжать упражнения, апдейт качается в фоне.
|
||||||
|
- **«Рестарт» — почти моментальный.** `quitAndInstall(true, true)`:
|
||||||
|
isSilent=true — NSIS без UI установщика (~1-2 сек вместо ~5-10),
|
||||||
|
isForceRunAfter=true — гарантия что приложение откроется после.
|
||||||
|
Раньше показывался диалог установщика с прогрессом, теперь —
|
||||||
|
только мгновение между закрытием и появлением новой версии.
|
||||||
|
- Подсказка на экране скачивания: «можно закрыть это окно, продолжится
|
||||||
|
в фоне». На downloaded-экране: «нажми Рестарт — приложение
|
||||||
|
моментально откроется в новой версии».
|
||||||
|
|
||||||
## [0.5.3] — 2026-05-19
|
## [0.5.3] — 2026-05-19
|
||||||
|
|
||||||
Полировка кастомного тайтлбара и размера окна.
|
Полировка кастомного тайтлбара и размера окна.
|
||||||
@@ -201,7 +222,8 @@
|
|||||||
иконки), системный трей, автозапуск с Windows, native-уведомления,
|
иконки), системный трей, автозапуск с Windows, native-уведомления,
|
||||||
NSIS-инсталлятор, auto-update через electron-updater.
|
NSIS-инсталлятор, auto-update через electron-updater.
|
||||||
|
|
||||||
[Unreleased]: https://xn--90adajar8af4h.xn--p1ai/git/AnRil/laude/compare/v0.5.3...HEAD
|
[Unreleased]: https://xn--90adajar8af4h.xn--p1ai/git/AnRil/laude/compare/v0.5.4...HEAD
|
||||||
|
[0.5.4]: https://xn--90adajar8af4h.xn--p1ai/git/AnRil/laude/releases/tag/v0.5.4
|
||||||
[0.5.3]: https://xn--90adajar8af4h.xn--p1ai/git/AnRil/laude/releases/tag/v0.5.3
|
[0.5.3]: https://xn--90adajar8af4h.xn--p1ai/git/AnRil/laude/releases/tag/v0.5.3
|
||||||
[0.5.2]: https://xn--90adajar8af4h.xn--p1ai/git/AnRil/laude/releases/tag/v0.5.2
|
[0.5.2]: https://xn--90adajar8af4h.xn--p1ai/git/AnRil/laude/releases/tag/v0.5.2
|
||||||
[0.5.1]: https://xn--90adajar8af4h.xn--p1ai/git/AnRil/laude/releases/tag/v0.5.1
|
[0.5.1]: https://xn--90adajar8af4h.xn--p1ai/git/AnRil/laude/releases/tag/v0.5.1
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
## TL;DR
|
## TL;DR
|
||||||
|
|
||||||
**Laude / Exercise Reminder** — Windows desktop приложение на Electron 33, которое напоминает делать упражнения и опционально парсит статистику матчей Dota 2 (через GSI) в количество повторений. Текущая версия — **0.5.3**. Один разработчик (AnRil), один remote — self-hosted Gitea.
|
**Laude / Exercise Reminder** — Windows desktop приложение на Electron 33, которое напоминает делать упражнения и опционально парсит статистику матчей Dota 2 (через GSI) в количество повторений. Текущая версия — **0.5.4**. Один разработчик (AnRil), один remote — self-hosted Gitea.
|
||||||
|
|
||||||
## Стек
|
## Стек
|
||||||
|
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
|
|
||||||
Windows desktop приложение, которое напоминает делать упражнения во время работы за компьютером. Опционально подключается к Dota 2 и после каждого матча превращает статистику (смерти, убийства, ассисты) в количество повторений.
|
Windows desktop приложение, которое напоминает делать упражнения во время работы за компьютером. Опционально подключается к Dota 2 и после каждого матча превращает статистику (смерти, убийства, ассисты) в количество повторений.
|
||||||
|
|
||||||
[](https://xn--90adajar8af4h.xn--p1ai/git/AnRil/laude/releases/latest)
|
[](https://xn--90adajar8af4h.xn--p1ai/git/AnRil/laude/releases/latest)
|
||||||
[]()
|
[]()
|
||||||
[]()
|
[]()
|
||||||
|
|
||||||
## Что внутри
|
## Что внутри
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "laude",
|
"name": "laude",
|
||||||
"version": "0.5.3",
|
"version": "0.5.4",
|
||||||
"description": "Exercise reminder — Windows desktop app",
|
"description": "Exercise reminder — Windows desktop app",
|
||||||
"main": "out/main/index.js",
|
"main": "out/main/index.js",
|
||||||
"author": "AnRil",
|
"author": "AnRil",
|
||||||
|
|||||||
@@ -285,8 +285,13 @@ export function registerIpc(): void {
|
|||||||
// Auto-updater
|
// Auto-updater
|
||||||
ipcMain.handle(IPC.updaterStatus, () => getUpdaterStatus())
|
ipcMain.handle(IPC.updaterStatus, () => getUpdaterStatus())
|
||||||
ipcMain.handle(IPC.updaterCheck, () => checkForUpdates())
|
ipcMain.handle(IPC.updaterCheck, () => checkForUpdates())
|
||||||
ipcMain.handle(IPC.updaterDownload, () => downloadUpdate())
|
// download/install — fire-and-forget. Прогресс и завершение приходят в
|
||||||
ipcMain.handle(IPC.updaterInstall, () => quitAndInstall())
|
// renderer через evtUpdaterStatus, ждать promise бессмысленно — renderer
|
||||||
|
// только зря держал бы `busy=true` весь download (минуты на медленной сети).
|
||||||
|
ipcMain.on(IPC.updaterDownload, () => {
|
||||||
|
void downloadUpdate()
|
||||||
|
})
|
||||||
|
ipcMain.on(IPC.updaterInstall, () => quitAndInstall())
|
||||||
|
|
||||||
// History
|
// History
|
||||||
ipcMain.handle(IPC.getHistory, (_e, sinceMs?: number) => getHistory(sinceMs))
|
ipcMain.handle(IPC.getHistory, (_e, sinceMs?: number) => getHistory(sinceMs))
|
||||||
|
|||||||
@@ -172,5 +172,12 @@ export async function downloadUpdate(): Promise<void> {
|
|||||||
|
|
||||||
export function quitAndInstall(): void {
|
export function quitAndInstall(): void {
|
||||||
if (!app.isPackaged) return
|
if (!app.isPackaged) return
|
||||||
autoUpdater.quitAndInstall()
|
// (isSilent=true, isForceRunAfter=true):
|
||||||
|
// - isSilent: NSIS работает без UI-диалогов установки → restart занимает
|
||||||
|
// ~1-2 сек вместо ~5-10 (без чёрного окна установщика на половину экрана).
|
||||||
|
// - isForceRunAfter: гарантируем что после установки приложение запустится
|
||||||
|
// автоматически, даже если в NSIS-конфиге runAfterFinish был выключен
|
||||||
|
// для этого сценария. Без этого пользователь нажал «Рестарт» — и остался
|
||||||
|
// без открытого приложения.
|
||||||
|
autoUpdater.quitAndInstall(true, true)
|
||||||
}
|
}
|
||||||
|
|||||||
408
src/main/validate.test.ts
Normal file
408
src/main/validate.test.ts
Normal file
@@ -0,0 +1,408 @@
|
|||||||
|
/**
|
||||||
|
* Тесты для IPC validation layer.
|
||||||
|
*
|
||||||
|
* Этот слой — security-boundary между renderer и main. Если он сломается,
|
||||||
|
* compromised renderer сможет писать в стор NaN, отрицательные, Infinity,
|
||||||
|
* сверхдлинные строки или undefined-enum'ы. Поэтому покрытие важно для:
|
||||||
|
*
|
||||||
|
* 1. Тип-проверок (строка/число/булево/массив)
|
||||||
|
* 2. Range-checks (reps ∈ [1,9999], minutes ∈ [1,1440] и т.д.)
|
||||||
|
* 3. Enum allowlist (theme/lang/notify-mode/stat)
|
||||||
|
* 4. Edge cases: NaN, Infinity, MAX_SAFE_INTEGER, 0, отрицательные, длина строк
|
||||||
|
* 5. Partial-patch semantics (отсутствие поля ≠ невалидное значение)
|
||||||
|
* 6. Сложный nested case: quietHours с HH:MM regex и dedup days
|
||||||
|
*/
|
||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import {
|
||||||
|
validateExerciseInput,
|
||||||
|
validateExercisePatch,
|
||||||
|
validateChallengeInput,
|
||||||
|
validateChallengePatch,
|
||||||
|
validateSettingsPatch,
|
||||||
|
validateId,
|
||||||
|
validateActualReps,
|
||||||
|
validateSnoozeMinutes
|
||||||
|
} from './validate'
|
||||||
|
|
||||||
|
const validExercise = {
|
||||||
|
name: 'Push-ups',
|
||||||
|
reps: 10,
|
||||||
|
intervalMinutes: 30,
|
||||||
|
icon: 'Dumbbell',
|
||||||
|
enabled: true
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('validateExerciseInput', () => {
|
||||||
|
it('accepts a fully-formed valid input', () => {
|
||||||
|
expect(validateExerciseInput(validExercise)).toEqual(validExercise)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects non-objects', () => {
|
||||||
|
expect(validateExerciseInput(null)).toBeNull()
|
||||||
|
expect(validateExerciseInput(undefined)).toBeNull()
|
||||||
|
expect(validateExerciseInput('string')).toBeNull()
|
||||||
|
expect(validateExerciseInput(42)).toBeNull()
|
||||||
|
expect(validateExerciseInput([])).toBeNull() // arrays not allowed
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects missing required fields', () => {
|
||||||
|
expect(validateExerciseInput({ ...validExercise, name: undefined })).toBeNull()
|
||||||
|
expect(validateExerciseInput({ ...validExercise, reps: undefined })).toBeNull()
|
||||||
|
expect(
|
||||||
|
validateExerciseInput({ ...validExercise, intervalMinutes: undefined })
|
||||||
|
).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects out-of-range reps', () => {
|
||||||
|
expect(validateExerciseInput({ ...validExercise, reps: 0 })).toBeNull()
|
||||||
|
expect(validateExerciseInput({ ...validExercise, reps: -1 })).toBeNull()
|
||||||
|
expect(validateExerciseInput({ ...validExercise, reps: 10_000 })).toBeNull()
|
||||||
|
expect(validateExerciseInput({ ...validExercise, reps: NaN })).toBeNull()
|
||||||
|
expect(
|
||||||
|
validateExerciseInput({ ...validExercise, reps: Infinity })
|
||||||
|
).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('truncates reps with Math.trunc (5.7 → 5)', () => {
|
||||||
|
const r = validateExerciseInput({ ...validExercise, reps: 5.7 })
|
||||||
|
expect(r?.reps).toBe(5)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects out-of-range intervalMinutes (> 24h)', () => {
|
||||||
|
expect(
|
||||||
|
validateExerciseInput({ ...validExercise, intervalMinutes: 0 })
|
||||||
|
).toBeNull()
|
||||||
|
expect(
|
||||||
|
validateExerciseInput({ ...validExercise, intervalMinutes: 1441 })
|
||||||
|
).toBeNull()
|
||||||
|
expect(
|
||||||
|
validateExerciseInput({ ...validExercise, intervalMinutes: -1 })
|
||||||
|
).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects empty name', () => {
|
||||||
|
expect(validateExerciseInput({ ...validExercise, name: '' })).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects name longer than MAX_STR_LEN (200)', () => {
|
||||||
|
expect(
|
||||||
|
validateExerciseInput({ ...validExercise, name: 'x'.repeat(201) })
|
||||||
|
).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('accepts name exactly at MAX_STR_LEN', () => {
|
||||||
|
const r = validateExerciseInput({ ...validExercise, name: 'x'.repeat(200) })
|
||||||
|
expect(r?.name).toHaveLength(200)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('defaults icon to Activity if missing', () => {
|
||||||
|
const { icon: _ignored, ...rest } = validExercise
|
||||||
|
void _ignored
|
||||||
|
expect(validateExerciseInput(rest)?.icon).toBe('Activity')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('defaults enabled to true if missing', () => {
|
||||||
|
const { enabled: _ignored, ...rest } = validExercise
|
||||||
|
void _ignored
|
||||||
|
expect(validateExerciseInput(rest)?.enabled).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Дизайн validateExerciseInput: required-поля (name/reps/intervalMinutes)
|
||||||
|
// строгие — невалидное значение reject'ит весь input. Optional-поля
|
||||||
|
// (icon/enabled) lenient — невалидное молча подменяется дефолтом. Это
|
||||||
|
// фиксирует контракт: malicious renderer не сможет создать запись с
|
||||||
|
// reps=-1, но если он пришлёт `enabled: 'yes'`, получит просто enabled=true.
|
||||||
|
it('coerces invalid enabled to true (lenient default for optional fields)', () => {
|
||||||
|
expect(
|
||||||
|
validateExerciseInput({ ...validExercise, enabled: 'yes' })?.enabled
|
||||||
|
).toBe(true)
|
||||||
|
expect(
|
||||||
|
validateExerciseInput({ ...validExercise, enabled: 1 })?.enabled
|
||||||
|
).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
// А вот в patch optional-поля строгие — нет defaults, есть `if (v ===
|
||||||
|
// undefined) return null`. Это правильнее: если renderer пришёл с патчем,
|
||||||
|
// в котором есть поле, оно должно быть валидным.
|
||||||
|
it('strict patch: rejects invalid enabled in patch (unlike input)', () => {
|
||||||
|
expect(validateExercisePatch({ enabled: 'yes' })).toBeNull()
|
||||||
|
expect(validateExercisePatch({ enabled: 1 })).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects non-string name', () => {
|
||||||
|
expect(validateExerciseInput({ ...validExercise, name: 42 })).toBeNull()
|
||||||
|
expect(validateExerciseInput({ ...validExercise, name: null })).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('validateExercisePatch', () => {
|
||||||
|
it('accepts an empty patch (no-op update)', () => {
|
||||||
|
expect(validateExercisePatch({})).toEqual({})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('accepts partial patches', () => {
|
||||||
|
expect(validateExercisePatch({ reps: 12 })).toEqual({ reps: 12 })
|
||||||
|
expect(validateExercisePatch({ name: 'New' })).toEqual({ name: 'New' })
|
||||||
|
expect(validateExercisePatch({ enabled: false })).toEqual({ enabled: false })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects patch with a single invalid field', () => {
|
||||||
|
// Patch is all-or-nothing: one bad field rejects the whole patch.
|
||||||
|
expect(validateExercisePatch({ name: 'OK', reps: -1 })).toBeNull()
|
||||||
|
expect(validateExercisePatch({ name: '', reps: 10 })).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects non-object', () => {
|
||||||
|
expect(validateExercisePatch(null)).toBeNull()
|
||||||
|
expect(validateExercisePatch([])).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('accepts nextFireAt and lastDoneAt with valid ranges', () => {
|
||||||
|
expect(validateExercisePatch({ nextFireAt: 0 })).toEqual({ nextFireAt: 0 })
|
||||||
|
expect(validateExercisePatch({ lastDoneAt: 1_000_000_000_000 })).toEqual({
|
||||||
|
lastDoneAt: 1_000_000_000_000
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects negative timestamps', () => {
|
||||||
|
expect(validateExercisePatch({ nextFireAt: -1 })).toBeNull()
|
||||||
|
expect(validateExercisePatch({ lastDoneAt: -1 })).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects NaN/Infinity timestamps', () => {
|
||||||
|
expect(validateExercisePatch({ nextFireAt: NaN })).toBeNull()
|
||||||
|
expect(validateExercisePatch({ nextFireAt: Infinity })).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('validateChallengeInput', () => {
|
||||||
|
const valid = {
|
||||||
|
name: 'Deaths → squats',
|
||||||
|
gameId: 'dota2',
|
||||||
|
stat: 'deaths' as const,
|
||||||
|
multiplier: 3,
|
||||||
|
exerciseName: 'Приседания',
|
||||||
|
icon: 'Activity',
|
||||||
|
enabled: true
|
||||||
|
}
|
||||||
|
|
||||||
|
it('accepts valid input', () => {
|
||||||
|
expect(validateChallengeInput(valid)).toEqual(valid)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects unknown stat', () => {
|
||||||
|
expect(validateChallengeInput({ ...valid, stat: 'pizza' })).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('accepts all valid stats', () => {
|
||||||
|
const stats = ['deaths', 'kills', 'assists', 'last_hits', 'denies', 'duration_min']
|
||||||
|
for (const stat of stats) {
|
||||||
|
expect(validateChallengeInput({ ...valid, stat })).not.toBeNull()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects negative multiplier', () => {
|
||||||
|
expect(validateChallengeInput({ ...valid, multiplier: -1 })).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects multiplier > 1000', () => {
|
||||||
|
expect(validateChallengeInput({ ...valid, multiplier: 1001 })).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('accepts zero multiplier (legitimate "disable" semantics)', () => {
|
||||||
|
expect(validateChallengeInput({ ...valid, multiplier: 0 })?.multiplier).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('accepts fractional multiplier (e.g. 0.5×)', () => {
|
||||||
|
expect(validateChallengeInput({ ...valid, multiplier: 0.5 })?.multiplier).toBe(0.5)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('validateChallengePatch', () => {
|
||||||
|
it('accepts empty patch', () => {
|
||||||
|
expect(validateChallengePatch({})).toEqual({})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects unknown stat in patch', () => {
|
||||||
|
expect(validateChallengePatch({ stat: 'mana' })).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('validateSettingsPatch', () => {
|
||||||
|
it('accepts empty patch', () => {
|
||||||
|
expect(validateSettingsPatch({})).toEqual({})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('accepts each boolean toggle independently', () => {
|
||||||
|
expect(validateSettingsPatch({ globalEnabled: false })).toEqual({
|
||||||
|
globalEnabled: false
|
||||||
|
})
|
||||||
|
expect(validateSettingsPatch({ soundEnabled: true })).toEqual({
|
||||||
|
soundEnabled: true
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects unknown theme', () => {
|
||||||
|
expect(validateSettingsPatch({ theme: 'sepia' })).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('accepts all valid themes', () => {
|
||||||
|
expect(validateSettingsPatch({ theme: 'light' })?.theme).toBe('light')
|
||||||
|
expect(validateSettingsPatch({ theme: 'dark' })?.theme).toBe('dark')
|
||||||
|
expect(validateSettingsPatch({ theme: 'system' })?.theme).toBe('system')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects unknown language', () => {
|
||||||
|
expect(validateSettingsPatch({ language: 'fr' })).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects unknown notification mode', () => {
|
||||||
|
expect(validateSettingsPatch({ notificationMode: 'sms' })).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects out-of-range snoozeMinutes', () => {
|
||||||
|
expect(validateSettingsPatch({ snoozeMinutes: 0 })).toBeNull()
|
||||||
|
expect(validateSettingsPatch({ snoozeMinutes: 1441 })).toBeNull()
|
||||||
|
expect(validateSettingsPatch({ snoozeMinutes: -5 })).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('quietHours subobject', () => {
|
||||||
|
const baseQh = {
|
||||||
|
enabled: true,
|
||||||
|
from: '22:00',
|
||||||
|
to: '08:00',
|
||||||
|
days: [0, 1, 2, 3, 4, 5, 6]
|
||||||
|
}
|
||||||
|
|
||||||
|
it('accepts a valid quietHours', () => {
|
||||||
|
expect(validateSettingsPatch({ quietHours: baseQh })?.quietHours).toEqual(
|
||||||
|
baseQh
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects non-object quietHours', () => {
|
||||||
|
expect(validateSettingsPatch({ quietHours: 'always' })).toBeNull()
|
||||||
|
expect(validateSettingsPatch({ quietHours: null })).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects malformed HH:MM', () => {
|
||||||
|
expect(
|
||||||
|
validateSettingsPatch({ quietHours: { ...baseQh, from: '2500' } })
|
||||||
|
).toBeNull()
|
||||||
|
expect(
|
||||||
|
validateSettingsPatch({ quietHours: { ...baseQh, to: 'bedtime' } })
|
||||||
|
).toBeNull()
|
||||||
|
expect(
|
||||||
|
validateSettingsPatch({ quietHours: { ...baseQh, from: '8' } })
|
||||||
|
).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('accepts HH:MM with 1-digit hour (9:30)', () => {
|
||||||
|
// Regex is /^\d{1,2}:\d{2}$/ — допускаем «9:30», парсер сам разберётся.
|
||||||
|
const r = validateSettingsPatch({
|
||||||
|
quietHours: { ...baseQh, from: '9:30' }
|
||||||
|
})
|
||||||
|
expect(r?.quietHours?.from).toBe('9:30')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('dedupes days array', () => {
|
||||||
|
const r = validateSettingsPatch({
|
||||||
|
quietHours: { ...baseQh, days: [1, 2, 2, 3, 1] }
|
||||||
|
})
|
||||||
|
expect(r?.quietHours?.days).toEqual([1, 2, 3])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects out-of-range day (7)', () => {
|
||||||
|
expect(
|
||||||
|
validateSettingsPatch({ quietHours: { ...baseQh, days: [0, 7] } })
|
||||||
|
).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects negative day', () => {
|
||||||
|
expect(
|
||||||
|
validateSettingsPatch({ quietHours: { ...baseQh, days: [-1] } })
|
||||||
|
).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects non-array days', () => {
|
||||||
|
expect(
|
||||||
|
validateSettingsPatch({ quietHours: { ...baseQh, days: 'all' } })
|
||||||
|
).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('accepts empty days array (window effectively disabled)', () => {
|
||||||
|
const r = validateSettingsPatch({
|
||||||
|
quietHours: { ...baseQh, days: [] }
|
||||||
|
})
|
||||||
|
expect(r?.quietHours?.days).toEqual([])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('validateId', () => {
|
||||||
|
it('accepts reasonable id strings', () => {
|
||||||
|
expect(validateId('abc')).toBe('abc')
|
||||||
|
expect(validateId('uuid-v4-style-thing-123')).toBe('uuid-v4-style-thing-123')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects non-strings', () => {
|
||||||
|
expect(validateId(42)).toBeNull()
|
||||||
|
expect(validateId(null)).toBeNull()
|
||||||
|
expect(validateId(undefined)).toBeNull()
|
||||||
|
expect(validateId({})).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects empty string', () => {
|
||||||
|
expect(validateId('')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects strings longer than 64 chars', () => {
|
||||||
|
expect(validateId('x'.repeat(65))).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('validateActualReps', () => {
|
||||||
|
it('returns undefined for undefined/null (means: use planned reps)', () => {
|
||||||
|
expect(validateActualReps(undefined)).toBeUndefined()
|
||||||
|
expect(validateActualReps(null)).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('accepts zero (partial completion = "did 0 of 10")', () => {
|
||||||
|
expect(validateActualReps(0)).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('accepts large values up to cap', () => {
|
||||||
|
expect(validateActualReps(100_000)).toBe(100_000)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects negative', () => {
|
||||||
|
expect(validateActualReps(-1)).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects values above cap', () => {
|
||||||
|
expect(validateActualReps(100_001)).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects NaN/Infinity', () => {
|
||||||
|
expect(validateActualReps(NaN)).toBeUndefined()
|
||||||
|
expect(validateActualReps(Infinity)).toBeUndefined()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('validateSnoozeMinutes', () => {
|
||||||
|
it('accepts valid minutes', () => {
|
||||||
|
expect(validateSnoozeMinutes(15)).toBe(15)
|
||||||
|
expect(validateSnoozeMinutes(1)).toBe(1)
|
||||||
|
expect(validateSnoozeMinutes(1440)).toBe(1440)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects 0 and above 24h', () => {
|
||||||
|
expect(validateSnoozeMinutes(0)).toBeNull()
|
||||||
|
expect(validateSnoozeMinutes(1441)).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects non-numbers', () => {
|
||||||
|
expect(validateSnoozeMinutes('15')).toBeNull()
|
||||||
|
expect(validateSnoozeMinutes(null)).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -105,8 +105,10 @@ const api = {
|
|||||||
ipcRenderer.invoke(IPC.updaterStatus),
|
ipcRenderer.invoke(IPC.updaterStatus),
|
||||||
updaterCheck: (): Promise<UpdaterStatus> =>
|
updaterCheck: (): Promise<UpdaterStatus> =>
|
||||||
ipcRenderer.invoke(IPC.updaterCheck),
|
ipcRenderer.invoke(IPC.updaterCheck),
|
||||||
updaterDownload: (): Promise<void> => ipcRenderer.invoke(IPC.updaterDownload),
|
// Fire-and-forget. Прогресс и завершение прилетают через onUpdaterStatus —
|
||||||
updaterInstall: (): Promise<void> => ipcRenderer.invoke(IPC.updaterInstall),
|
// renderer не должен `await`'ить, иначе busy-state висит весь download.
|
||||||
|
updaterDownload: (): void => ipcRenderer.send(IPC.updaterDownload),
|
||||||
|
updaterInstall: (): void => ipcRenderer.send(IPC.updaterInstall),
|
||||||
|
|
||||||
// History
|
// History
|
||||||
getHistory: (sinceMs?: number): Promise<HistoryEntry[]> =>
|
getHistory: (sinceMs?: number): Promise<HistoryEntry[]> =>
|
||||||
|
|||||||
@@ -24,6 +24,9 @@ function formatChecked(ts: number, t: TFn): string {
|
|||||||
|
|
||||||
export function UpdaterCard(): JSX.Element {
|
export function UpdaterCard(): JSX.Element {
|
||||||
const [status, setStatus] = useState<UpdaterStatus>({ kind: 'idle' })
|
const [status, setStatus] = useState<UpdaterStatus>({ kind: 'idle' })
|
||||||
|
// busy используется только для синхронного `check()` — для асинхронного
|
||||||
|
// download/install статус сам переключится через события (downloading →
|
||||||
|
// downloaded), отдельный busy-флаг будет только дублировать визуально.
|
||||||
const [busy, setBusy] = useState(false)
|
const [busy, setBusy] = useState(false)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -39,16 +42,15 @@ export function UpdaterCard(): JSX.Element {
|
|||||||
setBusy(false)
|
setBusy(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
async function download(): Promise<void> {
|
function download(): void {
|
||||||
setBusy(true)
|
// Fire-and-forget — UI моментально перейдёт в kind:'downloading' через
|
||||||
try {
|
// первое же event'ное обновление статуса. Никакого `await` — пользователь
|
||||||
await window.api.updaterDownload()
|
// должен иметь возможность уйти на Dashboard, продолжать упражнения,
|
||||||
} finally {
|
// пока обновление качается в фоне.
|
||||||
setBusy(false)
|
window.api.updaterDownload()
|
||||||
}
|
|
||||||
}
|
}
|
||||||
function install(): void {
|
function install(): void {
|
||||||
void window.api.updaterInstall()
|
window.api.updaterInstall()
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -180,6 +182,10 @@ function Body({
|
|||||||
transition={{ duration: 0.3, ease: 'linear' }}
|
transition={{ duration: 0.3, ease: 'linear' }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
{/* Подсказка: download идёт в фоне, не нужно сидеть на этом экране. */}
|
||||||
|
<div className="text-[12px] text-text/55 mt-3 font-medium">
|
||||||
|
{t('updater.downloading.hint')}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -194,8 +194,9 @@ export const ru: Dict = {
|
|||||||
'updater.available.title': 'Доступна v{v}',
|
'updater.available.title': 'Доступна v{v}',
|
||||||
'updater.downloading.title': 'Загружаем обновление',
|
'updater.downloading.title': 'Загружаем обновление',
|
||||||
'updater.downloading.subtitle': '{got} / {total} МБ · {speed} МБ/с',
|
'updater.downloading.subtitle': '{got} / {total} МБ · {speed} МБ/с',
|
||||||
|
'updater.downloading.hint': 'Можно закрыть это окно — скачивание продолжится в фоне.',
|
||||||
'updater.downloaded.title': 'Готово · v{v}',
|
'updater.downloaded.title': 'Готово · v{v}',
|
||||||
'updater.downloaded.subtitle': 'Перезапусти для применения',
|
'updater.downloaded.subtitle': 'Нажми «Рестарт» — приложение моментально откроется в новой версии.',
|
||||||
'updater.error.title': 'Ошибка проверки',
|
'updater.error.title': 'Ошибка проверки',
|
||||||
'updater.idle.title': 'Проверить обновления',
|
'updater.idle.title': 'Проверить обновления',
|
||||||
'updater.idle.subtitle': 'Авто-проверка раз в час',
|
'updater.idle.subtitle': 'Авто-проверка раз в час',
|
||||||
@@ -440,8 +441,9 @@ export const en: Dict = {
|
|||||||
'updater.available.title': 'v{v} available',
|
'updater.available.title': 'v{v} available',
|
||||||
'updater.downloading.title': 'Downloading update',
|
'updater.downloading.title': 'Downloading update',
|
||||||
'updater.downloading.subtitle': '{got} / {total} MB · {speed} MB/s',
|
'updater.downloading.subtitle': '{got} / {total} MB · {speed} MB/s',
|
||||||
|
'updater.downloading.hint': 'You can close this window — download continues in the background.',
|
||||||
'updater.downloaded.title': 'Ready · v{v}',
|
'updater.downloaded.title': 'Ready · v{v}',
|
||||||
'updater.downloaded.subtitle': 'Restart to apply',
|
'updater.downloaded.subtitle': 'Click Restart — the app will reopen instantly in the new version.',
|
||||||
'updater.error.title': 'Check failed',
|
'updater.error.title': 'Check failed',
|
||||||
'updater.idle.title': 'Check for updates',
|
'updater.idle.title': 'Check for updates',
|
||||||
'updater.idle.subtitle': 'Auto-check every hour',
|
'updater.idle.subtitle': 'Auto-check every hour',
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { describe, expect, it } from 'vitest'
|
import { describe, expect, it } from 'vitest'
|
||||||
import { translate, translateN } from './index'
|
import { translate, translateN } from './index'
|
||||||
|
import { ru, en } from './dict'
|
||||||
|
|
||||||
describe('translate', () => {
|
describe('translate', () => {
|
||||||
it('returns the matching string by key', () => {
|
it('returns the matching string by key', () => {
|
||||||
@@ -30,6 +31,50 @@ describe('translate', () => {
|
|||||||
// @ts-expect-error testing fallback
|
// @ts-expect-error testing fallback
|
||||||
expect(translate('fr', 'btn.save')).toBe('Сохранить')
|
expect(translate('fr', 'btn.save')).toBe('Сохранить')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Регрессия: до v0.5.2 интерполяция шла через regex, и если
|
||||||
|
// var-значение содержало regex-метасимволы ($1, .*, и т.д.), они
|
||||||
|
// интерпретировались как backreferences. Сейчас split/join.
|
||||||
|
it('substitutes regex metacharacters literally (no regex injection)', () => {
|
||||||
|
expect(
|
||||||
|
translate('ru', 'btn.snooze_min', { n: '$1.*' as unknown as number })
|
||||||
|
).toBe('Отложить $1.* мин')
|
||||||
|
expect(
|
||||||
|
translate('en', 'btn.snooze_min', {
|
||||||
|
n: '$$$&\\1' as unknown as number
|
||||||
|
})
|
||||||
|
).toBe('Snooze $$$&\\1m')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('leaves unsubstituted placeholders intact', () => {
|
||||||
|
// {n} остаётся как есть, если var не передан — это сигнал «забыл vars».
|
||||||
|
expect(translate('ru', 'btn.snooze_min')).toContain('{n}')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('dictionary parity', () => {
|
||||||
|
// EN не имеет CLDR-категории `few` — только `one`/`many`. Поэтому RU-ключи
|
||||||
|
// вида `*_few` легитимно отсутствуют в EN, исключаем их из парити-чека.
|
||||||
|
const isRuFewOnly = (k: string): boolean => k.endsWith('_few')
|
||||||
|
|
||||||
|
it('every key in ru (except *_few) exists in en', () => {
|
||||||
|
const missing = Object.keys(ru).filter(
|
||||||
|
(k) => !isRuFewOnly(k) && !(k in en)
|
||||||
|
)
|
||||||
|
expect(missing, `missing in en: ${missing.join(', ')}`).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('every key in en exists in ru', () => {
|
||||||
|
const missing = Object.keys(en).filter((k) => !(k in ru))
|
||||||
|
expect(missing, `missing in ru: ${missing.join(', ')}`).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('weekday.short.0..6 exist in both languages', () => {
|
||||||
|
for (const i of [0, 1, 2, 3, 4, 5, 6]) {
|
||||||
|
expect(ru[`weekday.short.${i}`]).toBeTruthy()
|
||||||
|
expect(en[`weekday.short.${i}`]).toBeTruthy()
|
||||||
|
}
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('translateN (plural)', () => {
|
describe('translateN (plural)', () => {
|
||||||
|
|||||||
@@ -33,6 +33,29 @@ describe('formatCountdown', () => {
|
|||||||
expect(formatCountdown(999)).toBe('0с')
|
expect(formatCountdown(999)).toBe('0с')
|
||||||
expect(formatCountdown(500)).toBe('0с')
|
expect(formatCountdown(500)).toBe('0с')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Guard added in v0.5.2 — electron-updater и scheduler могут передать
|
||||||
|
// NaN/Infinity на ранних событиях. Должны вернуть «сейчас», не «NaNс».
|
||||||
|
it('returns "сейчас" for NaN and Infinity (defensive guard)', () => {
|
||||||
|
expect(formatCountdown(NaN)).toBe('сейчас')
|
||||||
|
expect(formatCountdown(Infinity)).toBe('сейчас')
|
||||||
|
expect(formatCountdown(-Infinity)).toBe('сейчас')
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('english locale', () => {
|
||||||
|
it('renders sub-minute with "s"', () => {
|
||||||
|
expect(formatCountdown(45_000, 'en')).toBe('45s')
|
||||||
|
})
|
||||||
|
it('renders minutes+seconds with "m"/"s"', () => {
|
||||||
|
expect(formatCountdown(65_000, 'en')).toBe('1m 05s')
|
||||||
|
})
|
||||||
|
it('renders hours+minutes with "h"/"m"', () => {
|
||||||
|
expect(formatCountdown(3_660_000, 'en')).toBe('1h 01m')
|
||||||
|
})
|
||||||
|
it('returns "now" for zero', () => {
|
||||||
|
expect(formatCountdown(0, 'en')).toBe('now')
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('formatInterval', () => {
|
describe('formatInterval', () => {
|
||||||
@@ -53,4 +76,10 @@ describe('formatInterval', () => {
|
|||||||
expect(formatInterval(90)).toBe('1 ч 30 мин')
|
expect(formatInterval(90)).toBe('1 ч 30 мин')
|
||||||
expect(formatInterval(125)).toBe('2 ч 5 мин')
|
expect(formatInterval(125)).toBe('2 ч 5 мин')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('english locale', () => {
|
||||||
|
expect(formatInterval(30, 'en')).toBe('30 min')
|
||||||
|
expect(formatInterval(60, 'en')).toBe('1 h')
|
||||||
|
expect(formatInterval(90, 'en')).toBe('1 h 30 min')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
import { describe, expect, it } from 'vitest'
|
import { describe, expect, it } from 'vitest'
|
||||||
import type { Exercise, HistoryEntry } from '@shared/types'
|
import type { Exercise, HistoryEntry } from '@shared/types'
|
||||||
import { currentStreak, dailyReps, dayKey, dailyRepsRange } from './history'
|
import {
|
||||||
|
currentStreak,
|
||||||
|
dailyReps,
|
||||||
|
dayKey,
|
||||||
|
dailyRepsRange,
|
||||||
|
plannedRepsToday
|
||||||
|
} from './history'
|
||||||
|
|
||||||
const MS_DAY = 24 * 60 * 60 * 1000
|
const MS_DAY = 24 * 60 * 60 * 1000
|
||||||
|
|
||||||
@@ -117,4 +123,77 @@ describe('dailyRepsRange', () => {
|
|||||||
expect(range.at(-1)?.reps).toBe(10) // today
|
expect(range.at(-1)?.reps).toBe(10) // today
|
||||||
expect(range.at(-2)?.reps).toBe(3) // yesterday, partial
|
expect(range.at(-2)?.reps).toBe(3) // yesterday, partial
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// DST regression: до v0.5.2 dailyRepsRange использовал `ts - i*MS_DAY`.
|
||||||
|
// На границе DST (например в EU last Sunday October — 25 час) арифметика
|
||||||
|
// ms-vs-календарь расходилась, и dayKey() выдавал дубликат/пропуск дня.
|
||||||
|
// Сейчас shiftDays() через setDate(). Простой инвариант: количество
|
||||||
|
// уникальных day-keys всегда == days, и все keys строго возрастают.
|
||||||
|
it('produces unique day keys without gaps (DST-safe)', () => {
|
||||||
|
const range = dailyRepsRange([], [], 90)
|
||||||
|
const keys = range.map((r) => r.key)
|
||||||
|
expect(new Set(keys).size).toBe(90)
|
||||||
|
for (let i = 1; i < keys.length; i++) {
|
||||||
|
expect(keys[i] > keys[i - 1]).toBe(true)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('last entry is today', () => {
|
||||||
|
const range = dailyRepsRange([], [], 7)
|
||||||
|
expect(range.at(-1)?.key).toBe(dayKey(Date.now()))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('plannedRepsToday', () => {
|
||||||
|
it('returns 0 when no exercises enabled', () => {
|
||||||
|
const exs = [{ ...ex('a', 10), enabled: false }]
|
||||||
|
expect(plannedRepsToday(exs)).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns 0 for empty list', () => {
|
||||||
|
expect(plannedRepsToday([])).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('multiplies reps by approximate fires per day', () => {
|
||||||
|
// 60-min interval × 24 = 24 fires/day × 10 reps = 240
|
||||||
|
const exs = [{ ...ex('a', 10), intervalMinutes: 60 }]
|
||||||
|
expect(plannedRepsToday(exs)).toBe(240)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sums across multiple enabled exercises', () => {
|
||||||
|
const exs = [
|
||||||
|
{ ...ex('a', 10), intervalMinutes: 60 }, // 24 × 10 = 240
|
||||||
|
{ ...ex('b', 5), intervalMinutes: 30 } // 48 × 5 = 240
|
||||||
|
]
|
||||||
|
expect(plannedRepsToday(exs)).toBe(480)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('floor of (1440/interval), minimum 1 fire/day for huge intervals', () => {
|
||||||
|
// 1440-min interval = 1 fire/day; 2000-min interval should still be ≥ 1.
|
||||||
|
const exs = [{ ...ex('a', 7), intervalMinutes: 2000 }]
|
||||||
|
expect(plannedRepsToday(exs)).toBe(7)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('currentStreak edge cases', () => {
|
||||||
|
const today = Date.now()
|
||||||
|
|
||||||
|
it('ignores future-dated entries (clock skew, partial restore)', () => {
|
||||||
|
const tomorrow = today + 24 * 60 * 60 * 1000
|
||||||
|
// future entry shouldn't anchor the streak.
|
||||||
|
expect(currentStreak([entry('a', tomorrow)])).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('handles entries spread across the same day with mixed actions', () => {
|
||||||
|
const e = (
|
||||||
|
action: 'done' | 'skip' | 'snooze',
|
||||||
|
ts: number
|
||||||
|
): HistoryEntry => entry('a', ts, action)
|
||||||
|
const hist = [
|
||||||
|
e('skip', today),
|
||||||
|
e('done', today), // done is enough — streak counts the day
|
||||||
|
e('snooze', today)
|
||||||
|
]
|
||||||
|
expect(currentStreak(hist)).toBe(1)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
26
src/renderer/src/lib/icon-choices.test.ts
Normal file
26
src/renderer/src/lib/icon-choices.test.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { ICON_CHOICES } from './icon-choices'
|
||||||
|
import { SAMPLE_EXERCISES } from '@shared/types'
|
||||||
|
|
||||||
|
describe('ICON_CHOICES', () => {
|
||||||
|
// Если иконка SAMPLE_EXERCISES не входит в whitelist, при первом запуске
|
||||||
|
// приложения иконка молча заменится на fallback-Activity. Лучше ловить
|
||||||
|
// расхождение в CI.
|
||||||
|
it('contains every icon used by SAMPLE_EXERCISES', () => {
|
||||||
|
const allowed = new Set<string>(ICON_CHOICES)
|
||||||
|
for (const ex of SAMPLE_EXERCISES) {
|
||||||
|
expect(
|
||||||
|
allowed.has(ex.icon),
|
||||||
|
`icon "${ex.icon}" for sample "${ex.name}" is not in ICON_CHOICES`
|
||||||
|
).toBe(true)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('has no duplicates', () => {
|
||||||
|
expect(new Set(ICON_CHOICES).size).toBe(ICON_CHOICES.length)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('is non-empty', () => {
|
||||||
|
expect(ICON_CHOICES.length).toBeGreaterThan(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
27
src/renderer/src/lib/icon-choices.ts
Normal file
27
src/renderer/src/lib/icon-choices.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
/**
|
||||||
|
* Whitelist of allowed Lucide-icon names. Wrapped in a separate .ts file
|
||||||
|
* (без JSX), чтобы его можно было импортировать из node-tests и из shared/
|
||||||
|
* без подтягивания JSX-зависимости icon.tsx.
|
||||||
|
*/
|
||||||
|
export const ICON_CHOICES = [
|
||||||
|
'Activity',
|
||||||
|
'Dumbbell',
|
||||||
|
'StretchHorizontal',
|
||||||
|
'PersonStanding',
|
||||||
|
'Heart',
|
||||||
|
'Footprints',
|
||||||
|
'Hand',
|
||||||
|
'Eye',
|
||||||
|
'Brain',
|
||||||
|
'Bike',
|
||||||
|
'Waves',
|
||||||
|
'Wind',
|
||||||
|
'Sun',
|
||||||
|
'Coffee',
|
||||||
|
'Apple',
|
||||||
|
'GlassWater',
|
||||||
|
'BookOpen',
|
||||||
|
'Sparkles'
|
||||||
|
] as const
|
||||||
|
|
||||||
|
export type IconName = (typeof ICON_CHOICES)[number]
|
||||||
@@ -1,28 +1,9 @@
|
|||||||
import * as Lucide from 'lucide-react'
|
import * as Lucide from 'lucide-react'
|
||||||
import type { LucideProps } from 'lucide-react'
|
import type { LucideProps } from 'lucide-react'
|
||||||
|
import { ICON_CHOICES, type IconName } from './icon-choices'
|
||||||
|
|
||||||
export const ICON_CHOICES = [
|
// Re-export для обратной совместимости с импортёрами icon.tsx.
|
||||||
'Activity',
|
export { ICON_CHOICES, type IconName }
|
||||||
'Dumbbell',
|
|
||||||
'StretchHorizontal',
|
|
||||||
'PersonStanding',
|
|
||||||
'Heart',
|
|
||||||
'Footprints',
|
|
||||||
'Hand',
|
|
||||||
'Eye',
|
|
||||||
'Brain',
|
|
||||||
'Bike',
|
|
||||||
'Waves',
|
|
||||||
'Wind',
|
|
||||||
'Sun',
|
|
||||||
'Coffee',
|
|
||||||
'Apple',
|
|
||||||
'GlassWater',
|
|
||||||
'BookOpen',
|
|
||||||
'Sparkles'
|
|
||||||
] as const
|
|
||||||
|
|
||||||
export type IconName = (typeof ICON_CHOICES)[number]
|
|
||||||
|
|
||||||
const ICON_SET = new Set<string>(ICON_CHOICES)
|
const ICON_SET = new Set<string>(ICON_CHOICES)
|
||||||
|
|
||||||
|
|||||||
@@ -30,7 +30,11 @@ describe('SAMPLE_EXERCISES', () => {
|
|||||||
expect(ex.icon.length, `icon set for ${ex.name}`).toBeGreaterThan(0)
|
expect(ex.icon.length, `icon set for ${ex.name}`).toBeGreaterThan(0)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
})
|
})
|
||||||
|
// NB: тест «sample icons ⊆ ICON_CHOICES» лежит в
|
||||||
|
// src/renderer/src/lib/icon-choices.test.ts — он тянет renderer-сторону
|
||||||
|
// (ICON_CHOICES), а node-tsconfig сюда не пускает renderer-импорты.
|
||||||
|
|
||||||
describe('STAT_LABELS', () => {
|
describe('STAT_LABELS', () => {
|
||||||
it('has a Russian label for every GameStat in every GAME_STATS bundle', () => {
|
it('has a Russian label for every GameStat in every GAME_STATS bundle', () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user