d3ro-voice/apps/desktop/tests/main/services/DictionaryIo.test.ts
Yun Chan 911c9f0229 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.
2026-09-16 23:23:30 +09:00

176 lines
5.3 KiB
TypeScript

// 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()
})
})