diff --git a/src/main/validate.test.ts b/src/main/validate.test.ts new file mode 100644 index 0000000..41f7ade --- /dev/null +++ b/src/main/validate.test.ts @@ -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() + }) +}) diff --git a/src/renderer/src/i18n/i18n.test.ts b/src/renderer/src/i18n/i18n.test.ts index 1046ef7..2b9cde3 100644 --- a/src/renderer/src/i18n/i18n.test.ts +++ b/src/renderer/src/i18n/i18n.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest' import { translate, translateN } from './index' +import { ru, en } from './dict' describe('translate', () => { it('returns the matching string by key', () => { @@ -30,6 +31,50 @@ describe('translate', () => { // @ts-expect-error testing fallback 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)', () => { diff --git a/src/renderer/src/lib/format.test.ts b/src/renderer/src/lib/format.test.ts index 7cef0c9..da0e483 100644 --- a/src/renderer/src/lib/format.test.ts +++ b/src/renderer/src/lib/format.test.ts @@ -33,6 +33,29 @@ describe('formatCountdown', () => { expect(formatCountdown(999)).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', () => { @@ -53,4 +76,10 @@ describe('formatInterval', () => { expect(formatInterval(90)).toBe('1 ч 30 мин') 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') + }) }) diff --git a/src/renderer/src/lib/history.test.ts b/src/renderer/src/lib/history.test.ts index 56cafd2..96e77aa 100644 --- a/src/renderer/src/lib/history.test.ts +++ b/src/renderer/src/lib/history.test.ts @@ -1,6 +1,12 @@ import { describe, expect, it } from 'vitest' 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 @@ -117,4 +123,77 @@ describe('dailyRepsRange', () => { expect(range.at(-1)?.reps).toBe(10) // today 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) + }) }) diff --git a/src/renderer/src/lib/icon-choices.test.ts b/src/renderer/src/lib/icon-choices.test.ts new file mode 100644 index 0000000..824d8a9 --- /dev/null +++ b/src/renderer/src/lib/icon-choices.test.ts @@ -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(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) + }) +}) diff --git a/src/renderer/src/lib/icon-choices.ts b/src/renderer/src/lib/icon-choices.ts new file mode 100644 index 0000000..ab64d7b --- /dev/null +++ b/src/renderer/src/lib/icon-choices.ts @@ -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] diff --git a/src/renderer/src/lib/icon.tsx b/src/renderer/src/lib/icon.tsx index a4848da..1ad006d 100644 --- a/src/renderer/src/lib/icon.tsx +++ b/src/renderer/src/lib/icon.tsx @@ -1,28 +1,9 @@ import * as Lucide from 'lucide-react' import type { LucideProps } from 'lucide-react' +import { ICON_CHOICES, type IconName } from './icon-choices' -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] +// Re-export для обратной совместимости с импортёрами icon.tsx. +export { ICON_CHOICES, type IconName } const ICON_SET = new Set(ICON_CHOICES) diff --git a/src/shared/types.test.ts b/src/shared/types.test.ts index 3590095..357a28b 100644 --- a/src/shared/types.test.ts +++ b/src/shared/types.test.ts @@ -30,7 +30,11 @@ describe('SAMPLE_EXERCISES', () => { 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', () => { it('has a Russian label for every GameStat in every GAME_STATS bundle', () => {