fix(sync): keep desktop-only language and preset command across settings pulls

This commit is contained in:
Yun Chan 2026-09-28 02:16:15 +09:00
parent ddc78546f0
commit 75e053d72f
3 changed files with 258 additions and 64 deletions

View file

@ -0,0 +1,136 @@
// 설정 동기화 회귀 — 데스크톱 전용 언어·프리셋 명령이 다음 pull에서 서버 값으로 되돌아가지 않는다.
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createTestDb } from '../../helpers/createTestDb'
import { FakeSyncRemote } from '../../helpers/fakeSyncRemote'
import { bindTestDatabase, unbindTestDatabase } from '../../../src/main/db'
import { configGet, configSet, initInMemoryConfig, resetInMemoryConfig } from '../../../src/main/services/ConfigService'
import {
getCustomInstructionService,
resetCustomInstructionServiceForTests,
} from '../../../src/main/services/CustomInstructionService'
import { resetDictationTemplateServiceForTests } from '../../../src/main/services/DictationTemplateService'
import { resetMeetingDocTemplateServiceForTests } from '../../../src/main/services/MeetingDocTemplateService'
import { resetRAGServiceForTests } from '../../../src/main/services/RAGService'
import { SyncEngine } from '../../../src/main/services/sync/SyncEngine'
import { enqueueChange } from '../../../src/main/services/sync/sync-outbox'
import {
remoteToLocalPatch,
resolveActiveInstructionPush,
toRemoteSettings,
type LocalSettings,
} from '../../../src/main/services/sync/settings-sync-policy'
const USER = '11111111-1111-4111-8111-111111111111'
const U = '22222222-2222-4222-8222-222222222222'
const U2 = '33333333-3333-4333-8333-333333333333'
function local(overrides: Partial<LocalSettings> = {}): LocalSettings {
return { language: 'ko', theme: 'auto', defaultLLMAction: 'refine', activeInstructionId: '', ...overrides }
}
describe('settings-sync-policy: 언어', () => {
it('데스크톱 전용 언어(ja)는 서버의 ko/en이 덮지 않는다', () => {
const row = { locale: 'ko', theme_mode: 'system', auto_polish_enabled: true, active_instruction_id: null }
expect(remoteToLocalPatch(row, local({ language: 'ja' }), new Set())).toEqual({})
expect(remoteToLocalPatch({ ...row, locale: 'en' }, local({ language: 'zh-TW' }), new Set())).toEqual({})
})
it('로컬이 ko/en이면 서버 언어를 받는다', () => {
const row = { locale: 'en', theme_mode: 'system', auto_polish_enabled: true, active_instruction_id: null }
expect(remoteToLocalPatch(row, local({ language: 'ko' }), new Set())).toEqual({ language: 'en' })
})
it('서버가 모르는 locale 값은 무시한다', () => {
const row = { locale: 'fr', theme_mode: 'system', auto_polish_enabled: true, active_instruction_id: null }
expect(remoteToLocalPatch(row, local({ language: 'ko' }), new Set())).toEqual({})
})
it('push는 ko/en만 locale로 보낸다', () => {
expect(toRemoteSettings(local({ language: 'en' }))).toMatchObject({ locale: 'en' })
expect(toRemoteSettings(local({ language: 'ja' }))).not.toHaveProperty('locale')
})
})
describe('settings-sync-policy: 활성 명령', () => {
const ids = new Set([U, U2])
it('pull: 서버의 사용자 명령은 로컬 사용자 명령·없음을 바꾼다', () => {
expect(remoteToLocalPatch({ active_instruction_id: U }, local({ activeInstructionId: '' }), ids)).toEqual({
activeInstructionId: U,
})
expect(remoteToLocalPatch({ active_instruction_id: null }, local({ activeInstructionId: U }), ids)).toEqual({
activeInstructionId: '',
})
expect(remoteToLocalPatch({ active_instruction_id: 'not-local' }, local({ activeInstructionId: U }), ids)).toEqual({})
})
it('push: 없음·사용자 명령은 서버와 다를 때만 올린다', () => {
expect(resolveActiveInstructionPush('', U, ids)).toEqual({ instructionId: null })
expect(resolveActiveInstructionPush('', null, ids)).toBeNull()
expect(resolveActiveInstructionPush(U, U2, ids)).toEqual({ instructionId: U })
expect(resolveActiveInstructionPush(U, U, ids)).toBeNull()
})
it('push: 데스크톱 프리셋은 서버의 알려진 사용자 명령을 해제하고, 모르는 값은 건드리지 않는다', () => {
expect(resolveActiveInstructionPush('builtin-summarize', U, ids)).toEqual({ instructionId: null })
expect(resolveActiveInstructionPush('builtin-summarize', null, ids)).toBeNull()
expect(resolveActiveInstructionPush('builtin-summarize', '44444444-4444-4444-8444-444444444444', ids)).toBeNull()
})
})
describe('설정 동기화 왕복 (엔진)', () => {
let testDb: ReturnType<typeof createTestDb>
let remote: FakeSyncRemote
let engine: SyncEngine
beforeEach(() => {
testDb = createTestDb()
bindTestDatabase(testDb.db, USER)
initInMemoryConfig()
resetCustomInstructionServiceForTests()
resetDictationTemplateServiceForTests()
resetMeetingDocTemplateServiceForTests()
resetRAGServiceForTests()
getCustomInstructionService().initialize()
vi.spyOn(globalThis, 'fetch').mockRejectedValue(new TypeError('fetch failed'))
remote = new FakeSyncRemote(USER)
engine = new SyncEngine({ remote, userId: USER })
})
afterEach(() => {
vi.restoreAllMocks()
engine.dispose()
unbindTestDatabase()
resetInMemoryConfig()
testDb.close()
})
it('데스크톱에서 ja를 고르면 이후 pull이 ko로 되돌리지 않는다', async () => {
remote.mobileInsert('user_settings', { locale: 'ko', theme_mode: 'system', auto_polish_enabled: true, revision: 1, active_instruction_id: null })
await engine.runFullSync()
configSet('language', 'ja')
enqueueChange('user_settings', 'self', 'upsert')
await engine.flush()
await engine.pull()
expect(configGet('language')).toBe('ja')
expect(remote.rows('user_settings')[0].locale).toBe('ko')
})
it('프리셋을 고르면 서버 활성 명령이 해제되어 다음 pull이 사용자 명령으로 되돌리지 않는다', async () => {
const command = getCustomInstructionService().create({ name: 'Calm', description: '', prompt: 'Make it calm' })
configSet('activeInstructionId', command.id)
await engine.runFullSync()
enqueueChange('user_settings', 'self', 'upsert')
await engine.flush()
expect(remote.rows('user_settings')[0].active_instruction_id).toBe(command.id)
configSet('activeInstructionId', 'builtin-summarize')
enqueueChange('user_settings', 'self', 'upsert')
await engine.flush()
expect(remote.rows('user_settings')[0].active_instruction_id).toBeNull()
await engine.pull()
expect(configGet('activeInstructionId')).toBe('builtin-summarize')
})
})