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,
|
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) }
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user