fix: export/import — отмена пользователем не показывает error toast

Bug: при отмене save-dialog или open-dialog DataCard показывал тост
«Не удалось сохранить» / «Файл не подошёл». Но cancel — это не ошибка.

Расширил IPC возврат: { ok, canceled, path?, error? }. UI теперь
различает: ok → success toast, !ok && canceled → молча, !ok && !canceled
→ error toast.

+9 тестов на validateSettingsPatch для voicePromptsEnabled,
meetingAutoPause, lastSeenVersion (semver-regex / null-сброс /
malformed). Итого 159 → 168 тестов.

Settings → About теперь показывает текущую версию приложения
(раньше была только кнопка «Что нового»). Загружается через
IPC.getAppVersion при mount.
This commit is contained in:
AnRil
2026-05-22 23:26:11 +07:00
parent 0c813c3ac8
commit 2b7eb412c7
4 changed files with 84 additions and 12 deletions

View File

@@ -352,12 +352,16 @@ export function registerIpc(): void {
defaultPath, defaultPath,
filters: [{ name: 'JSON', extensions: ['json'] }] filters: [{ name: 'JSON', extensions: ['json'] }]
}) })
if (result.canceled || !result.filePath) return { ok: false, path: null } // Cancel — это не ошибка. Возвращаем canceled=true чтобы UI мог
// ничего не показывать (без error toast).
if (result.canceled || !result.filePath) {
return { ok: false, canceled: true, path: null }
}
try { try {
writeFileSync(result.filePath, exportState(), 'utf-8') writeFileSync(result.filePath, exportState(), 'utf-8')
return { ok: true, path: result.filePath } return { ok: true, canceled: false, path: result.filePath }
} catch (e) { } catch (e) {
return { ok: false, path: null, error: String(e) } return { ok: false, canceled: false, path: null, error: String(e) }
} }
}) })
@@ -369,7 +373,7 @@ export function registerIpc(): void {
filters: [{ name: 'JSON', extensions: ['json'] }] filters: [{ name: 'JSON', extensions: ['json'] }]
}) })
if (result.canceled || result.filePaths.length === 0) { if (result.canceled || result.filePaths.length === 0) {
return { ok: false } return { ok: false, canceled: true }
} }
try { try {
const raw = readFileSync(result.filePaths[0], 'utf-8') const raw = readFileSync(result.filePaths[0], 'utf-8')
@@ -378,9 +382,9 @@ export function registerIpc(): void {
broadcastState() broadcastState()
broadcastHistoryChanged() broadcastHistoryChanged()
} }
return { ok } return { ok, canceled: false }
} catch (e) { } catch (e) {
return { ok: false, error: String(e) } return { ok: false, canceled: false, error: String(e) }
} }
}) })
} }

View File

@@ -266,6 +266,65 @@ describe('validateSettingsPatch', () => {
expect(validateSettingsPatch({ snoozeMinutes: -5 })).toBeNull() expect(validateSettingsPatch({ snoozeMinutes: -5 })).toBeNull()
}) })
it('accepts voicePromptsEnabled boolean', () => {
expect(validateSettingsPatch({ voicePromptsEnabled: true })).toEqual({
voicePromptsEnabled: true
})
expect(validateSettingsPatch({ voicePromptsEnabled: false })).toEqual({
voicePromptsEnabled: false
})
})
it('rejects non-boolean voicePromptsEnabled in patch', () => {
expect(validateSettingsPatch({ voicePromptsEnabled: 'yes' })).toBeNull()
expect(validateSettingsPatch({ voicePromptsEnabled: 1 })).toBeNull()
})
it('accepts meetingAutoPause boolean', () => {
expect(validateSettingsPatch({ meetingAutoPause: true })).toEqual({
meetingAutoPause: true
})
})
it('rejects non-boolean meetingAutoPause', () => {
expect(validateSettingsPatch({ meetingAutoPause: 'yes' })).toBeNull()
})
describe('lastSeenVersion', () => {
it('accepts valid semver', () => {
const r = validateSettingsPatch({ lastSeenVersion: '0.5.7' })
expect(r?.lastSeenVersion).toBe('0.5.7')
expect(validateSettingsPatch({ lastSeenVersion: '10.20.30' })).toEqual({
lastSeenVersion: '10.20.30'
})
})
it('accepts pre-release suffix', () => {
const r = validateSettingsPatch({ lastSeenVersion: '0.5.7-beta.1' })
expect(r?.lastSeenVersion).toBe('0.5.7-beta.1')
})
it('treats null/undefined as reset to undefined', () => {
const r1 = validateSettingsPatch({ lastSeenVersion: null })
expect(r1).toEqual({ lastSeenVersion: undefined })
const r2 = validateSettingsPatch({ lastSeenVersion: undefined })
// 'lastSeenVersion' is `in raw` even if undefined — both treated reset.
expect(r2).toEqual({ lastSeenVersion: undefined })
})
it('rejects malformed strings', () => {
expect(validateSettingsPatch({ lastSeenVersion: '0.5' })).toBeNull()
expect(validateSettingsPatch({ lastSeenVersion: 'v0.5.7' })).toBeNull()
expect(validateSettingsPatch({ lastSeenVersion: 'beta' })).toBeNull()
expect(validateSettingsPatch({ lastSeenVersion: '' })).toBeNull()
})
it('rejects non-strings', () => {
expect(validateSettingsPatch({ lastSeenVersion: 42 })).toBeNull()
expect(validateSettingsPatch({ lastSeenVersion: ['1', '0', '0'] })).toBeNull()
})
})
describe('quietHours subobject', () => { describe('quietHours subobject', () => {
const baseQh = { const baseQh = {
enabled: true, enabled: true,

View File

@@ -122,10 +122,17 @@ const api = {
ipcRenderer.invoke(IPC.clearHistory, beforeTs), ipcRenderer.invoke(IPC.clearHistory, beforeTs),
// Export / Import — открывают native save/open dialogs из main process. // Export / Import — открывают native save/open dialogs из main process.
exportState: (): Promise<{ ok: boolean; path: string | null }> => exportState: (): Promise<{
ipcRenderer.invoke(IPC.exportState), ok: boolean
importState: (): Promise<{ ok: boolean; error?: string }> => canceled: boolean
ipcRenderer.invoke(IPC.importState), path: string | null
error?: string
}> => ipcRenderer.invoke(IPC.exportState),
importState: (): Promise<{
ok: boolean
canceled: boolean
error?: string
}> => ipcRenderer.invoke(IPC.importState),
onTick: (h: Handler<Tick[]>): Unsub => on(IPC.evtTick, h), onTick: (h: Handler<Tick[]>): Unsub => on(IPC.evtTick, h),
onFire: (h: Handler<Exercise>): Unsub => on(IPC.evtFire, h), onFire: (h: Handler<Exercise>): Unsub => on(IPC.evtFire, h),

View File

@@ -261,7 +261,8 @@ function DataCard(): JSX.Element {
const r = await window.api.exportState() const r = await window.api.exportState()
if (r.ok && r.path) { if (r.ok && r.path) {
setToast(t('settings.data.export.ok', { path: r.path })) setToast(t('settings.data.export.ok', { path: r.path }))
} else if (!r.ok) { } else if (!r.ok && !r.canceled) {
// canceled — пользователь сам передумал, тост не нужен.
setToast(t('settings.data.export.err')) setToast(t('settings.data.export.err'))
} }
} finally { } finally {
@@ -275,7 +276,8 @@ function DataCard(): JSX.Element {
try { try {
const r = await window.api.importState() const r = await window.api.importState()
if (r.ok) setToast(t('settings.data.import.ok')) if (r.ok) setToast(t('settings.data.import.ok'))
else if ('error' in r && r.error !== undefined) { else if (!r.canceled) {
// canceled — пользователь не выбрал файл, не показываем error.
setToast(t('settings.data.import.err')) setToast(t('settings.data.import.err'))
} }
} finally { } finally {