test: expand coverage 53 → 135 (+82 tests)
Аудит тестов выявил критические пробелы в покрытии. Расширили существующие файлы и добавили два новых: Новые файлы: - src/main/validate.test.ts (59) — security-boundary IPC layer вообще не имел тестов. Покрывает NaN/Infinity, range edge cases, тип- сабверсии, partial-patch semantics, quietHours regex+dedup. Фиксирует контракт «strict для required, lenient для optional defaults» (input принимает enabled:'yes' → true, patch строгий). - src/renderer/src/lib/icon-choices.test.ts (3) — SAMPLE_EXERCISES.icon ⊆ ICON_CHOICES (иначе fallback-Activity на первом запуске). Расширения: - format.test.ts: NaN/Infinity guard, EN-локаль. - history.test.ts: DST-safe инвариант (unique keys, monotonic), plannedRepsToday, future-dated entries, mixed actions. - i18n.test.ts: dict parity RU↔EN (с правильным skip для RU-only *_few CLDR-категории), regex-injection в var-значениях, weekday.short.* parity. Рефакторинг: - ICON_CHOICES вынесен в src/renderer/src/lib/icon-choices.ts (без JSX) — теперь whitelist импортируется из любого слоя без React-зависимости. icon.tsx реэкспортирует для обратной совместимости.
This commit is contained in:
@@ -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)', () => {
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
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 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<string>(ICON_CHOICES)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user