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:
@@ -352,12 +352,16 @@ export function registerIpc(): void {
|
||||
defaultPath,
|
||||
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 {
|
||||
writeFileSync(result.filePath, exportState(), 'utf-8')
|
||||
return { ok: true, path: result.filePath }
|
||||
return { ok: true, canceled: false, path: result.filePath }
|
||||
} 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'] }]
|
||||
})
|
||||
if (result.canceled || result.filePaths.length === 0) {
|
||||
return { ok: false }
|
||||
return { ok: false, canceled: true }
|
||||
}
|
||||
try {
|
||||
const raw = readFileSync(result.filePaths[0], 'utf-8')
|
||||
@@ -378,9 +382,9 @@ export function registerIpc(): void {
|
||||
broadcastState()
|
||||
broadcastHistoryChanged()
|
||||
}
|
||||
return { ok }
|
||||
return { ok, canceled: false }
|
||||
} catch (e) {
|
||||
return { ok: false, error: String(e) }
|
||||
return { ok: false, canceled: false, error: String(e) }
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -266,6 +266,65 @@ describe('validateSettingsPatch', () => {
|
||||
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', () => {
|
||||
const baseQh = {
|
||||
enabled: true,
|
||||
|
||||
@@ -122,10 +122,17 @@ const api = {
|
||||
ipcRenderer.invoke(IPC.clearHistory, beforeTs),
|
||||
|
||||
// Export / Import — открывают native save/open dialogs из main process.
|
||||
exportState: (): Promise<{ ok: boolean; path: string | null }> =>
|
||||
ipcRenderer.invoke(IPC.exportState),
|
||||
importState: (): Promise<{ ok: boolean; error?: string }> =>
|
||||
ipcRenderer.invoke(IPC.importState),
|
||||
exportState: (): Promise<{
|
||||
ok: boolean
|
||||
canceled: boolean
|
||||
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),
|
||||
onFire: (h: Handler<Exercise>): Unsub => on(IPC.evtFire, h),
|
||||
|
||||
@@ -261,7 +261,8 @@ function DataCard(): JSX.Element {
|
||||
const r = await window.api.exportState()
|
||||
if (r.ok && 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'))
|
||||
}
|
||||
} finally {
|
||||
@@ -275,7 +276,8 @@ function DataCard(): JSX.Element {
|
||||
try {
|
||||
const r = await window.api.importState()
|
||||
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'))
|
||||
}
|
||||
} finally {
|
||||
|
||||
Reference in New Issue
Block a user