From 911c9f0229c403cdd33dcefe2f1580f0139bcf3d Mon Sep 17 00:00:00 2001 From: Yun Chan Date: Wed, 16 Sep 2026 23:23:30 +0900 Subject: [PATCH] feat(desktop): move dictionary entries in and out as files Users could only rebuild their spoken-word dictionary entry by entry. Import and export now round-trip the whole list, reporting duplicate and invalid entries per row instead of failing the batch, so a dictionary survives a reinstall or a move to another machine. --- .../src/main/ipc/dictionary-handlers.ts | 45 ++- .../src/main/services/DictionaryService.ts | 292 +++++++++++++++++- apps/desktop/src/preload/index.ts | 14 +- .../src/renderer/pages/DictionaryPage.tsx | 113 ++++++- .../tests/main/services/DictionaryIo.test.ts | 176 +++++++++++ 5 files changed, 625 insertions(+), 15 deletions(-) create mode 100644 apps/desktop/tests/main/services/DictionaryIo.test.ts diff --git a/apps/desktop/src/main/ipc/dictionary-handlers.ts b/apps/desktop/src/main/ipc/dictionary-handlers.ts index 113f106..534ee34 100644 --- a/apps/desktop/src/main/ipc/dictionary-handlers.ts +++ b/apps/desktop/src/main/ipc/dictionary-handlers.ts @@ -1,6 +1,6 @@ // src/main/ipc/dictionary-handlers.ts -import { ipcMain } from 'electron' +import { ipcMain, dialog } from 'electron' import { IPC_CHANNELS } from '@d3ro/core/ipc-channels' import { ipcSuccess, ipcError, ErrorCode, D3ROError } from '@d3ro/core/errors' import { getDictionaryService } from '../services/DictionaryService' @@ -9,7 +9,9 @@ import type { DictionaryAddParams, DictionaryUpdateParams, DictionaryDeleteParams, - DictionarySearchParams + DictionarySearchParams, + DictionaryImportParams, + DictionaryExportParams } from '@d3ro/core/types' export function registerDictionaryHandlers(): void { @@ -74,4 +76,43 @@ export function registerDictionaryHandlers(): void { return ipcError(ErrorCode.DBQueryFailed, `Failed to search dictionary: ${message}`) } }) + + ipcMain.handle(IPC_CHANNELS.DICTIONARY.IMPORT, async (_event, params: DictionaryImportParams) => { + try { + let filePath = params.filePath + if (!filePath) { + const picked = await dialog.showOpenDialog({ + properties: ['openFile'], + filters: [ + params.format === 'csv' + ? { name: 'CSV', extensions: ['csv'] } + : { name: 'JSON', extensions: ['json'] } + ] + }) + if (picked.canceled || picked.filePaths.length === 0) { + return ipcSuccess({ imported: 0, skipped: 0, errors: 0 }) + } + filePath = picked.filePaths[0] + } + return ipcSuccess(getDictionaryService().importDictionary({ ...params, filePath })) + } catch (error) { + if (error instanceof D3ROError) { + return ipcError(error.code, error.message) + } + const message = error instanceof Error ? error.message : String(error) + return ipcError(ErrorCode.DictionaryImportFailed, `Failed to import dictionary: ${message}`) + } + }) + + ipcMain.handle(IPC_CHANNELS.DICTIONARY.EXPORT, async (_event, params: DictionaryExportParams) => { + try { + return ipcSuccess(await getDictionaryService().exportDictionary(params)) + } catch (error) { + if (error instanceof D3ROError) { + return ipcError(error.code, error.message) + } + const message = error instanceof Error ? error.message : String(error) + return ipcError(ErrorCode.DictionaryExportFailed, `Failed to export dictionary: ${message}`) + } + }) } diff --git a/apps/desktop/src/main/services/DictionaryService.ts b/apps/desktop/src/main/services/DictionaryService.ts index 56b74e2..e8528b3 100644 --- a/apps/desktop/src/main/services/DictionaryService.ts +++ b/apps/desktop/src/main/services/DictionaryService.ts @@ -1,12 +1,16 @@ // src/main/services/DictionaryService.ts // 사용자 커스텀 단어 사전. 설계서 01/03 IDictionaryService 구현. -import { eq, like, desc, count, sql } from 'drizzle-orm' +import { eq, and, like, desc, count, sql } from 'drizzle-orm' +import path from 'path' +import fs from 'fs' +import { app, dialog } from 'electron' import { getDatabase } from '../db' import { dictionary } from '../db/schema' import type { Dictionary, NewDictionary } from '../db/schema' import { getLogger } from './LoggerService' import { getCloudSyncService } from './CloudSyncService' +import { getMainWindow } from '../windows/WindowManager' import { D3ROError, ErrorCode } from '@d3ro/core/errors' import type { DictionaryEntry, @@ -14,11 +18,100 @@ import type { DictionaryPage, DictionaryAddParams, DictionaryUpdateParams, - DictionarySearchParams + DictionarySearchParams, + DictionaryImportParams, + DictionaryImportResult, + DictionaryExportParams } from '@d3ro/core/types' const logger = getLogger('DictionaryService') +let isShowingDictionarySaveDialog = false + +const DICTIONARY_CSV_HEADER = [ + 'word', + 'pronunciation', + 'category', + 'usageCount', + 'createdAt', + 'updatedAt' +] as const + +function csvCell(value: unknown): string { + const text = value === null || value === undefined ? '' : String(value) + const escaped = text.replace(/"/g, '""') + const needsQuotes = /[",\r\n]/.test(escaped) || /^[=+\-@]/.test(escaped) + return needsQuotes ? `"${escaped}"` : escaped +} + +function parseCsvRows(input: string): string[][] { + const rows: string[][] = [] + let row: string[] = [] + let field = '' + let inQuotes = false + for (let i = 0; i < input.length; i += 1) { + const char = input[i] + if (inQuotes) { + if (char === '"') { + if (input[i + 1] === '"') { + field += '"' + i += 1 + } else { + inQuotes = false + } + } else { + field += char + } + continue + } + if (char === '"') { + inQuotes = true + } else if (char === ',') { + row.push(field) + field = '' + } else if (char === '\n') { + row.push(field) + rows.push(row) + row = [] + field = '' + } else if (char === '\r') { + // skip CR; LF terminates the row + } else { + field += char + } + } + if (field.length > 0 || row.length > 0) { + row.push(field) + rows.push(row) + } + return rows.filter((candidate) => candidate.some((cell) => cell.trim().length > 0)) +} + +function pickString(source: Record, keys: string[]): string | null { + for (const key of keys) { + const value = source[key] + if (typeof value === 'string' && value.trim().length > 0) return value.trim() + } + return null +} + +function pickNumber(source: Record, keys: string[]): number | null { + for (const key of keys) { + const value = source[key] + if (typeof value === 'number' && Number.isFinite(value)) return value + if (typeof value === 'string' && value.trim() !== '') { + const parsed = Number(value) + if (Number.isFinite(parsed)) return parsed + } + } + return null +} + +function normalizeCategory(value: string | null): DictionaryEntry['category'] { + if (value === 'auto' || value === 'technical' || value === 'user') return value + return 'user' +} + class DictionaryService { add(params: DictionaryAddParams): DictionaryEntry { const word = params.word.trim() @@ -160,6 +253,201 @@ class DictionaryService { return words.map((w) => w.word).join(', ') } + async exportDictionary(params: DictionaryExportParams): Promise { + const db = getDatabase() + const entries = db + .select() + .from(dictionary) + .orderBy(desc(dictionary.createdAt)) + .all() + .map((row) => this._toEntry(row)) + + const content = + params.format === 'csv' ? this._serializeCsv(entries) : this._serializeJson(entries) + const defaultName = `d3ro-dictionary-${new Date().toISOString().slice(0, 10)}.${params.format}` + + if (isShowingDictionarySaveDialog) { + throw new D3ROError(ErrorCode.DictionaryExportFailed, 'Export dialog already active') + } + isShowingDictionarySaveDialog = true + let result: Electron.SaveDialogReturnValue + try { + const mainWindow = getMainWindow() + const dialogOptions = { + defaultPath: path.join(app.getPath('documents'), defaultName), + filters: [ + params.format === 'csv' + ? { name: 'CSV', extensions: ['csv'] } + : { name: 'JSON', extensions: ['json'] } + ] + } + result = mainWindow + ? await dialog.showSaveDialog(mainWindow, dialogOptions) + : await dialog.showSaveDialog(dialogOptions) + } finally { + isShowingDictionarySaveDialog = false + } + + if (result.canceled || !result.filePath) { + throw new D3ROError(ErrorCode.DictionaryExportFailed, 'Export cancelled') + } + + try { + fs.writeFileSync(result.filePath, content, 'utf-8') + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + throw new D3ROError( + ErrorCode.DictionaryExportFailed, + `Failed to write dictionary export: ${message}` + ) + } + logger.info(`Dictionary exported (${entries.length} entries, ${params.format})`) + return result.filePath + } + + importDictionary(params: DictionaryImportParams): DictionaryImportResult { + if (params.format !== 'json' && params.format !== 'csv') { + throw new D3ROError( + ErrorCode.DictionaryImportInvalidFormat, + `Unsupported dictionary format: ${String(params.format)}` + ) + } + + let raw: string + try { + raw = fs.readFileSync(params.filePath, 'utf-8') + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + throw new D3ROError( + ErrorCode.DictionaryImportFailed, + `Failed to read dictionary file: ${message}` + ) + } + + let parsed: Array> + try { + parsed = params.format === 'json' ? this._parseJson(raw) : this._parseCsv(raw) + } catch (err) { + if (err instanceof D3ROError) throw err + const message = err instanceof Error ? err.message : String(err) + throw new D3ROError( + ErrorCode.DictionaryImportInvalidFormat, + `Invalid dictionary file: ${message}` + ) + } + + const db = getDatabase() + const now = Date.now() + const outcome: DictionaryImportResult = { imported: 0, skipped: 0, errors: 0 } + + db.transaction((tx) => { + for (const row of parsed) { + const word = pickString(row, ['word']) + if (!word) { + outcome.errors += 1 + continue + } + const category = normalizeCategory(pickString(row, ['category'])) + const existing = tx + .select({ id: dictionary.id }) + .from(dictionary) + .where(and(eq(dictionary.word, word), eq(dictionary.category, category))) + .get() + if (existing) { + outcome.skipped += 1 + continue + } + + const createdAt = pickNumber(row, ['createdAt', 'created_at']) + const updatedAt = pickNumber(row, ['updatedAt', 'updated_at']) + const usageCount = pickNumber(row, ['usageCount', 'usage_count']) + const lastUsedAt = pickNumber(row, ['lastUsedAt', 'last_used_at']) + const id = crypto.randomUUID() + try { + tx.insert(dictionary) + .values({ + id, + word, + pronunciation: pickString(row, ['pronunciation']), + category, + usageCount: usageCount !== null && usageCount >= 0 ? Math.floor(usageCount) : 0, + lastUsedAt, + createdAt: createdAt ?? now, + updatedAt: updatedAt ?? createdAt ?? now + }) + .run() + outcome.imported += 1 + void getCloudSyncService().pushOne('dictionary', id) + } catch { + outcome.skipped += 1 + } + } + }) + + logger.info( + `Dictionary import: ${outcome.imported} imported, ${outcome.skipped} skipped, ${outcome.errors} errors` + ) + return outcome + } + + private _serializeJson(entries: DictionaryEntry[]): string { + return JSON.stringify({ entries }, null, 2) + } + + private _serializeCsv(entries: DictionaryEntry[]): string { + const lines = [DICTIONARY_CSV_HEADER.join(',')] + for (const entry of entries) { + lines.push( + [ + csvCell(entry.word), + csvCell(entry.pronunciation ?? ''), + csvCell(entry.category), + csvCell(entry.usageCount), + csvCell(entry.createdAt), + csvCell(entry.updatedAt) + ].join(',') + ) + } + return `\uFEFF${lines.join('\r\n')}\r\n` + } + + private _parseJson(raw: string): Array> { + const data: unknown = JSON.parse(raw) + let list: unknown + if (Array.isArray(data)) { + list = data + } else if ( + data && + typeof data === 'object' && + Array.isArray((data as { entries?: unknown }).entries) + ) { + list = (data as { entries: unknown[] }).entries + } else { + throw new Error('expected an array or an object with an "entries" array') + } + return (list as unknown[]).filter( + (item): item is Record => !!item && typeof item === 'object' + ) + } + + private _parseCsv(raw: string): Array> { + const rows = parseCsvRows(raw.replace(/^\uFEFF/, '')) + if (rows.length === 0) { + throw new Error('empty CSV') + } + const header = rows[0].map((cell) => cell.trim()) + if (!header.includes('word')) { + throw new Error('missing "word" column') + } + return rows.slice(1).map((cells) => { + const record: Record = {} + header.forEach((key, index) => { + record[key] = cells[index] ?? '' + }) + return record + }) + } + dispose(): void { logger.info('DictionaryService disposed') } diff --git a/apps/desktop/src/preload/index.ts b/apps/desktop/src/preload/index.ts index 418ba00..b1ec420 100644 --- a/apps/desktop/src/preload/index.ts +++ b/apps/desktop/src/preload/index.ts @@ -69,6 +69,9 @@ import type { DictionaryUpdateParams, DictionaryDeleteParams, DictionarySearchParams, + DictionaryImportParams, + DictionaryImportResult, + DictionaryExportParams, StatsSummary, PermissionStatus, // Phase 10 @@ -156,7 +159,6 @@ import type { MeetingDeleteSessionParams, MeetingExportParams, MeetingProcessingProgress, - CaptionSegment, MeetingDocument, MeetingDocTemplate, MeetingGenerateDocParams, @@ -165,7 +167,7 @@ import type { MeetingGetDocsParams, MeetingUpdateTranscriptParams, MeetingExportDocParams, - MeetingExportFormat, + MeetingExportTranscriptParams, MeetingDocGeneratingProgress, CreateMeetingDocTemplateParams, UpdateMeetingDocTemplateParams, @@ -394,7 +396,11 @@ const electronAPI = { delete: (params: DictionaryDeleteParams) => invoke(IPC_CHANNELS.DICTIONARY.DELETE, params), search: (params: DictionarySearchParams) => - invoke(IPC_CHANNELS.DICTIONARY.SEARCH, params) + invoke(IPC_CHANNELS.DICTIONARY.SEARCH, params), + import: (params: DictionaryImportParams) => + invoke(IPC_CHANNELS.DICTIONARY.IMPORT, params), + export: (params: DictionaryExportParams) => + invoke(IPC_CHANNELS.DICTIONARY.EXPORT, params) }, // ── Stats ────────────────────────────────────────────── @@ -714,7 +720,7 @@ const electronAPI = { invoke(IPC_CHANNELS.MEETING_MODE.DELETE_DOCUMENT, params), exportDocument: (params: MeetingExportDocParams) => invoke(IPC_CHANNELS.MEETING_MODE.EXPORT_DOCUMENT, params), - exportTranscript: (params: { sessionId: string; format: MeetingExportFormat }) => + exportTranscript: (params: MeetingExportTranscriptParams) => invoke(IPC_CHANNELS.MEETING_MODE.EXPORT_TRANSCRIPT, params), editSegment: (params: { sessionId: string; segmentId: string; text: string }) => invoke(IPC_CHANNELS.MEETING_MODE.EDIT_SEGMENT, params), diff --git a/apps/desktop/src/renderer/pages/DictionaryPage.tsx b/apps/desktop/src/renderer/pages/DictionaryPage.tsx index 6a4bc5f..0805165 100644 --- a/apps/desktop/src/renderer/pages/DictionaryPage.tsx +++ b/apps/desktop/src/renderer/pages/DictionaryPage.tsx @@ -3,7 +3,7 @@ import React, { useState, useEffect, useCallback, useRef } from 'react' import { Box, TextField, Dialog, DialogTitle, DialogContent, DialogActions, IconButton, Tooltip } from '@mui/material' -import { Plus, Trash2, Pencil, BookA } from 'lucide-react' +import { Plus, Trash2, Pencil, BookA, Download, Upload } from 'lucide-react' import { MetalCard, PhosphorText, PhysicalButton, TactileBadge } from '@d3ro/ui/components/ds' import { PageHeader, SearchInput, EmptyStateCard } from '../components/shared' import { d3roPalette, d3roFontSans, d3roTypo, d3roRadius, d3roShadow } from '@d3ro/ui/theme' @@ -21,10 +21,15 @@ export function DictionaryPage(): React.ReactElement { const [formPronunciation, setFormPronunciation] = useState('') const [saving, setSaving] = useState(false) const isSavingRef = useRef(false) + const [ioBusy, setIoBusy] = useState(false) + const [ioMessage, setIoMessage] = useState(null) const loadData = useCallback(async () => { setLoading(true) - const result = await window.electronAPI.dictionary.getAll({ search: search || undefined }) + const trimmed = search.trim() + const result = trimmed + ? await window.electronAPI.dictionary.search({ query: trimmed, page: 0, pageSize: 50 }) + : await window.electronAPI.dictionary.getAll({ page: 0, pageSize: 50 }) if (result.success) setData(result.data) setLoading(false) }, [search]) @@ -74,18 +79,112 @@ export function DictionaryPage(): React.ReactElement { } } + const handleExport = async (format: 'json' | 'csv'): Promise => { + if (ioBusy) return + setIoBusy(true) + setIoMessage(null) + try { + const result = await window.electronAPI.dictionary.export({ format }) + setIoMessage(result.success ? t('dictionary.exported') : t('dictionary.exportFailed')) + } finally { + setIoBusy(false) + } + } + + const handleImport = async (format: 'json' | 'csv'): Promise => { + if (ioBusy) return + setIoBusy(true) + setIoMessage(null) + try { + const result = await window.electronAPI.dictionary.import({ filePath: '', format }) + if (!result.success) { + setIoMessage(t('dictionary.importFailed')) + return + } + const { imported, skipped, errors } = result.data + setIoMessage( + t('dictionary.importedCount', { + imported: String(imported), + skipped: String(skipped), + errors: String(errors), + }) + ) + await loadData() + } finally { + setIoBusy(false) + } + } + + const ioButtonSx = { + p: 0.7, + color: d3roPalette.text.inactive, + bgcolor: d3roPalette.glass.raised, + border: `1px solid ${d3roPalette.glass.hairline}`, + '&:hover': { color: d3roPalette.accent.light, bgcolor: d3roPalette.glass.hairlineStrong }, + } as const + return ( }> - {t('dictionary.add')} - + + + void handleExport('json')} + sx={ioButtonSx} + > + + + + + void handleExport('csv')} + sx={ioButtonSx} + > + + + + + void handleImport('json')} + sx={ioButtonSx} + > + + + + + void handleImport('csv')} + sx={ioButtonSx} + > + + + + }> + {t('dictionary.add')} + + } /> + {ioMessage && ( + + + {ioMessage} + + + )} + { - window.electronAPI.dictionary.delete({ id: entry.id }) + onClick={async () => { + await window.electronAPI.dictionary.delete({ id: entry.id }) loadData() }} sx={{ diff --git a/apps/desktop/tests/main/services/DictionaryIo.test.ts b/apps/desktop/tests/main/services/DictionaryIo.test.ts new file mode 100644 index 0000000..25e1fdb --- /dev/null +++ b/apps/desktop/tests/main/services/DictionaryIo.test.ts @@ -0,0 +1,176 @@ +// tests/main/services/DictionaryIo.test.ts +// DictionaryService import/export — file + dialog mocking + +import { describe, it, expect, beforeEach, vi } from 'vitest' + +const writeFileSync = vi.fn() +const readFileSync = vi.fn() +const showSaveDialog = vi.fn() + +vi.mock('fs', () => ({ + default: { writeFileSync, readFileSync }, + writeFileSync, + readFileSync +})) + +vi.mock('electron', () => ({ + app: { getPath: () => '/tmp/d3ro' }, + dialog: { showSaveDialog } +})) + +vi.mock('../../../src/main/windows/WindowManager', () => ({ + getMainWindow: () => null +})) + +vi.mock('../../../src/main/services/LoggerService', () => ({ + getLogger: () => ({ info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }) +})) + +vi.mock('../../../src/main/services/CloudSyncService', () => ({ + getCloudSyncService: () => ({ pushOne: vi.fn(async () => undefined) }) +})) + +interface TxStub { + select: ReturnType + insert: ReturnType +} + +let existingRow: unknown = null +let txStub: TxStub + +const mockDb = { + select: vi.fn(() => ({ + from: vi.fn(() => ({ + orderBy: vi.fn(() => ({ + all: vi.fn(() => [ + { + id: 'row-1', + word: 'AI', + pronunciation: '에이아이', + category: 'technical', + usageCount: 3, + lastUsedAt: 111, + createdAt: 100, + updatedAt: 200 + } + ]) + })) + })) + })), + transaction: vi.fn((fn: (tx: TxStub) => void) => fn(txStub)) +} + +vi.mock('../../../src/main/db', () => ({ getDatabase: () => mockDb })) + +type ServiceModule = typeof import('../../../src/main/services/DictionaryService') +let mod: ServiceModule + +beforeEach(async () => { + vi.clearAllMocks() + existingRow = null + txStub = { + select: vi.fn(() => ({ + from: vi.fn(() => ({ where: vi.fn(() => ({ get: vi.fn(() => existingRow) })) })) + })), + insert: vi.fn(() => ({ values: vi.fn(() => ({ run: vi.fn(() => ({ changes: 1 })) })) })) + } + vi.resetModules() + mod = await import('../../../src/main/services/DictionaryService') +}) + +describe('DictionaryService.importDictionary', () => { + it('imports JSON entries with camelCase fields', () => { + readFileSync.mockReturnValue( + JSON.stringify({ + entries: [ + { word: '쿠버네티스', pronunciation: '쿠버네티스', category: 'technical', usageCount: 2 } + ] + }) + ) + + const result = mod.getDictionaryService().importDictionary({ + filePath: '/in/dict.json', + format: 'json' + }) + + expect(result).toEqual({ imported: 1, skipped: 0, errors: 0 }) + expect(txStub.insert).toHaveBeenCalled() + }) + + it('skips entries that already exist', () => { + existingRow = { id: 'existing' } + readFileSync.mockReturnValue(JSON.stringify({ entries: [{ word: 'AI' }] })) + + const result = mod.getDictionaryService().importDictionary({ + filePath: '/in/dict.json', + format: 'json' + }) + + expect(result).toEqual({ imported: 0, skipped: 1, errors: 0 }) + }) + + it('parses CSV exports', () => { + readFileSync.mockReturnValue( + 'word,pronunciation,category,usageCount,createdAt,updatedAt\r\n"테스트",,"user",0,100,200\r\n' + ) + + const result = mod.getDictionaryService().importDictionary({ + filePath: '/in/dict.csv', + format: 'csv' + }) + + expect(result).toEqual({ imported: 1, skipped: 0, errors: 0 }) + }) + + it('rejects a file without a word column', () => { + readFileSync.mockReturnValue('term,category\r\nfoo,user\r\n') + + expect(() => + mod.getDictionaryService().importDictionary({ filePath: '/in/dict.csv', format: 'csv' }) + ).toThrowError(/Invalid dictionary file/) + }) + + it('counts invalid JSON entries as errors', () => { + readFileSync.mockReturnValue(JSON.stringify({ entries: [{ pronunciation: 'x' }, { word: 'ok' }] })) + + const result = mod.getDictionaryService().importDictionary({ + filePath: '/in/dict.json', + format: 'json' + }) + + expect(result).toEqual({ imported: 1, skipped: 0, errors: 1 }) + }) +}) + +describe('DictionaryService.exportDictionary', () => { + it('writes a JSON export and returns the chosen path', async () => { + showSaveDialog.mockResolvedValue({ canceled: false, filePath: '/out/d3ro.json' }) + + const path = await mod.getDictionaryService().exportDictionary({ format: 'json' }) + + expect(path).toBe('/out/d3ro.json') + expect(writeFileSync).toHaveBeenCalledTimes(1) + const [writtenPath, content] = writeFileSync.mock.calls[0] as [string, string] + expect(writtenPath).toBe('/out/d3ro.json') + const parsed = JSON.parse(content) as { entries: Array<{ word: string }> } + expect(parsed.entries[0].word).toBe('AI') + }) + + it('writes CSV with a header row', async () => { + showSaveDialog.mockResolvedValue({ canceled: false, filePath: '/out/d3ro.csv' }) + + await mod.getDictionaryService().exportDictionary({ format: 'csv' }) + + const [, content] = writeFileSync.mock.calls[0] as [string, string] + expect(content).toContain('word,pronunciation,category,usageCount,createdAt,updatedAt') + }) + + it('rejects a cancelled save dialog', async () => { + showSaveDialog.mockResolvedValue({ canceled: true, filePath: undefined }) + + await expect( + mod.getDictionaryService().exportDictionary({ format: 'json' }) + ).rejects.toThrowError(/Export cancelled/) + expect(writeFileSync).not.toHaveBeenCalled() + }) +})