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.
This commit is contained in:
parent
7953706142
commit
911c9f0229
5 changed files with 625 additions and 15 deletions
176
apps/desktop/tests/main/services/DictionaryIo.test.ts
Normal file
176
apps/desktop/tests/main/services/DictionaryIo.test.ts
Normal file
|
|
@ -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<typeof vi.fn>
|
||||
insert: ReturnType<typeof vi.fn>
|
||||
}
|
||||
|
||||
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()
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue