// 설정 동기화 회귀 — 데스크톱 전용 언어·프리셋 명령이 다음 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 { 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 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') }) })