feat(v030): SyncSection + ConflictModal — Configure UI + 충돌 해결 UI
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -107,6 +107,20 @@ export function registerSettingsApi(deps?: SettingsIpcDeps): void {
|
||||
return { ok: true as const };
|
||||
});
|
||||
|
||||
ipcMain.handle('settings:set-sync-auto-enabled', async (_e, value: boolean) => {
|
||||
await deps.settings.setAutoSyncEnabled(value);
|
||||
return { ok: true as const };
|
||||
});
|
||||
|
||||
ipcMain.handle('settings:set-sync-interval-min', async (_e, value: number) => {
|
||||
try {
|
||||
await deps.settings.setSyncIntervalMin(value);
|
||||
return { ok: true as const };
|
||||
} catch (e) {
|
||||
return { ok: false as const, reason: (e as Error).message };
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('settings:run-backup', async () => {
|
||||
try {
|
||||
const r = await backup.runDaily();
|
||||
|
||||
@@ -95,6 +95,8 @@ const api: InklingApi = {
|
||||
resolveConflict: (noteId: string, choice: 'local' | 'remote') =>
|
||||
ipcRenderer.invoke('sync:resolve-conflict', noteId, choice),
|
||||
getSyncStatus: () => ipcRenderer.invoke('sync:get-status'),
|
||||
setSyncAutoEnabled: (value: boolean) => ipcRenderer.invoke('settings:set-sync-auto-enabled', value),
|
||||
setSyncIntervalMin: (value: number) => ipcRenderer.invoke('settings:set-sync-interval-min', value),
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
112
src/renderer/inbox/components/ConflictModal.tsx
Normal file
112
src/renderer/inbox/components/ConflictModal.tsx
Normal file
@@ -0,0 +1,112 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import type { SyncConflict } from '@shared/types';
|
||||
import { inboxApi } from '../api.js';
|
||||
|
||||
interface Props {
|
||||
onClose: () => void;
|
||||
onResolved: () => void;
|
||||
}
|
||||
|
||||
const overlayStyle: React.CSSProperties = {
|
||||
position: 'fixed', top: 0, left: 0, width: '100vw', height: '100vh',
|
||||
background: 'rgba(0,0,0,0.4)', display: 'flex', alignItems: 'center',
|
||||
justifyContent: 'center', zIndex: 100
|
||||
};
|
||||
|
||||
const modalStyle: React.CSSProperties = {
|
||||
background: '#fff', borderRadius: 8, padding: 20, width: 600,
|
||||
maxHeight: '70vh', overflow: 'auto', boxShadow: '0 4px 16px rgba(0,0,0,0.2)'
|
||||
};
|
||||
|
||||
const rowStyle: React.CSSProperties = {
|
||||
border: '1px solid #eee', borderRadius: 6, padding: 10, marginTop: 8
|
||||
};
|
||||
|
||||
export function ConflictModal({ onClose, onResolved }: Props): React.ReactElement {
|
||||
const [conflicts, setConflicts] = useState<SyncConflict[]>([]);
|
||||
const [busy, setBusy] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
const c = await inboxApi.listConflicts();
|
||||
if (!cancelled) setConflicts(c);
|
||||
})();
|
||||
return () => { cancelled = true; };
|
||||
}, []);
|
||||
|
||||
async function onChoose(noteId: string, choice: 'local' | 'remote') {
|
||||
setBusy(noteId);
|
||||
setError(null);
|
||||
const r = await inboxApi.resolveConflict(noteId, choice);
|
||||
setBusy(null);
|
||||
if (!r.ok) {
|
||||
setError(`해결 실패: ${r.reason}`);
|
||||
return;
|
||||
}
|
||||
const next = conflicts.filter((c) => c.noteId !== noteId);
|
||||
setConflicts(next);
|
||||
if (next.length === 0) {
|
||||
onResolved();
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={overlayStyle} onClick={onClose}>
|
||||
<div style={modalStyle} onClick={(e) => e.stopPropagation()}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<h3 style={{ margin: 0, fontSize: 16 }}>충돌 ({conflicts.length}건)</h3>
|
||||
<button onClick={onClose} aria-label="닫기" style={{ background: 'none', border: 'none', fontSize: 18, cursor: 'pointer', color: '#888' }}>×</button>
|
||||
</div>
|
||||
{error !== null && <div style={{ marginTop: 10, fontSize: 12, color: '#c93030' }}>{error}</div>}
|
||||
{conflicts.map((c) => (
|
||||
<div key={c.noteId} style={rowStyle}>
|
||||
<div style={{ fontSize: 12, color: '#888', marginBottom: 6 }}>note: {c.noteId}</div>
|
||||
<div style={{ display: 'flex', gap: 12 }}>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ fontSize: 11, color: '#666', marginBottom: 4 }}>내 기기</div>
|
||||
<pre style={preStyle()}>{c.localText || '(미리보기 없음)'}</pre>
|
||||
</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ fontSize: 11, color: '#666', marginBottom: 4 }}>다른 기기</div>
|
||||
<pre style={preStyle()}>{c.remoteText || '(미리보기 없음)'}</pre>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ marginTop: 8, display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
|
||||
<button
|
||||
onClick={() => { void onChoose(c.noteId, 'local'); }}
|
||||
disabled={busy === c.noteId}
|
||||
style={chooseBtnStyle('#0a4b80')}
|
||||
>
|
||||
{busy === c.noteId ? '처리 중…' : '내 것 사용'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { void onChoose(c.noteId, 'remote'); }}
|
||||
disabled={busy === c.noteId}
|
||||
style={chooseBtnStyle('#236b1a')}
|
||||
>
|
||||
{busy === c.noteId ? '처리 중…' : '원격 사용'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function preStyle(): React.CSSProperties {
|
||||
return {
|
||||
margin: 0, whiteSpace: 'pre-wrap', fontSize: 11, color: '#444',
|
||||
background: '#fafafa', padding: 6, borderRadius: 3, maxHeight: 120, overflow: 'auto'
|
||||
};
|
||||
}
|
||||
|
||||
function chooseBtnStyle(color: string): React.CSSProperties {
|
||||
return {
|
||||
background: 'none', border: `1px solid ${color}`, color, cursor: 'pointer',
|
||||
fontSize: 12, padding: '4px 10px', borderRadius: 4
|
||||
};
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { AiProviderSection } from './settings/AiProviderSection.js';
|
||||
import { AutostartSection } from './settings/AutostartSection.js';
|
||||
import { BackupSection } from './settings/BackupSection.js';
|
||||
import { InfoSection } from './settings/InfoSection.js';
|
||||
import { SyncSection } from './settings/SyncSection.js';
|
||||
|
||||
export function SettingsPage(): React.ReactElement {
|
||||
const setShowSettings = useInbox((s) => s.setShowSettings);
|
||||
@@ -40,6 +41,10 @@ export function SettingsPage(): React.ReactElement {
|
||||
<h2 style={{ fontSize: 14, marginBottom: 8 }}>정보</h2>
|
||||
<InfoSection />
|
||||
</section>
|
||||
<section style={{ marginBottom: 24 }}>
|
||||
<h2 style={{ fontSize: 14, marginBottom: 8 }}>동기화</h2>
|
||||
<SyncSection />
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
150
src/renderer/inbox/components/settings/SyncSection.tsx
Normal file
150
src/renderer/inbox/components/settings/SyncSection.tsx
Normal file
@@ -0,0 +1,150 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { inboxApi } from '../../api.js';
|
||||
import type { SyncStatusSnapshot } from '@shared/types';
|
||||
import { ConflictModal } from '../ConflictModal.js';
|
||||
|
||||
export function SyncSection(): React.ReactElement {
|
||||
const [url, setUrl] = useState('');
|
||||
const [draftUrl, setDraftUrl] = useState('');
|
||||
const [autoEnabled, setAutoEnabled] = useState(true);
|
||||
const [intervalMin, setIntervalMin] = useState(30);
|
||||
const [status, setStatus] = useState<SyncStatusSnapshot | null>(null);
|
||||
const [busy, setBusy] = useState<'save' | 'test' | 'sync' | null>(null);
|
||||
const [feedback, setFeedback] = useState<string | null>(null);
|
||||
const [showConflict, setShowConflict] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
void (async () => {
|
||||
const s = await inboxApi.getSettings();
|
||||
const u = s.sync_repo_url ?? '';
|
||||
setUrl(u);
|
||||
setDraftUrl(u);
|
||||
setAutoEnabled(s.sync_auto_enabled ?? true);
|
||||
setIntervalMin(s.sync_interval_min ?? 30);
|
||||
setStatus(await inboxApi.getSyncStatus());
|
||||
})();
|
||||
}, []);
|
||||
|
||||
async function onSaveUrl() {
|
||||
setBusy('save');
|
||||
setFeedback(null);
|
||||
const r = await inboxApi.configureSync(draftUrl.trim() === '' ? null : draftUrl.trim());
|
||||
setBusy(null);
|
||||
if (r.ok) {
|
||||
setUrl(draftUrl.trim());
|
||||
setFeedback('저장되었습니다');
|
||||
} else {
|
||||
setFeedback(`저장 실패: ${r.reason}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function onTestConnection() {
|
||||
setBusy('test');
|
||||
setFeedback(null);
|
||||
const r = await inboxApi.testSyncConnection();
|
||||
setBusy(null);
|
||||
setFeedback(r.ok ? '연결 성공' : `연결 실패: ${r.reason}`);
|
||||
}
|
||||
|
||||
async function onToggleAuto(next: boolean) {
|
||||
await inboxApi.setSyncAutoEnabled(next);
|
||||
setAutoEnabled(next);
|
||||
}
|
||||
|
||||
async function onChangeInterval(value: number) {
|
||||
if (!Number.isInteger(value) || value < 5) return;
|
||||
const r = await inboxApi.setSyncIntervalMin(value);
|
||||
if (r.ok) setIntervalMin(value);
|
||||
}
|
||||
|
||||
const conflictCount = status?.lastResult?.conflicts?.length ?? 0;
|
||||
|
||||
return (
|
||||
<section style={{ marginTop: 24 }}>
|
||||
<h3 style={{ fontSize: 14, marginBottom: 8 }}>동기화 저장소</h3>
|
||||
|
||||
<div style={{ display: 'flex', gap: 6, marginBottom: 8 }}>
|
||||
<input
|
||||
type="text"
|
||||
aria-label="저장소 URL"
|
||||
placeholder="git@host:user/inkling-notes.git"
|
||||
value={draftUrl}
|
||||
onChange={(e) => setDraftUrl(e.target.value)}
|
||||
style={{ flex: 1, fontSize: 12, padding: '4px 8px', border: '1px solid #ccc', borderRadius: 4 }}
|
||||
/>
|
||||
<button onClick={() => { void onSaveUrl(); }} disabled={busy !== null} style={btnStyle()}>
|
||||
{busy === 'save' ? '저장 중…' : '저장'}
|
||||
</button>
|
||||
<button onClick={() => { void onTestConnection(); }} disabled={busy !== null || url.trim() === ''} style={btnStyle()}>
|
||||
{busy === 'test' ? '확인 중…' : '연결 테스트'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{feedback !== null && (
|
||||
<div style={{ fontSize: 12, color: '#444', marginBottom: 8 }}>{feedback}</div>
|
||||
)}
|
||||
|
||||
{url.trim() !== '' && (
|
||||
<>
|
||||
<div style={{ fontSize: 12, color: '#666', marginBottom: 8 }}>
|
||||
마지막 sync: {status?.lastAt ?? '없음'} {status?.lastResult?.ok === false && status?.lastResult?.reason !== 'conflict' && (
|
||||
<span style={{ color: '#a55' }}> ({status.lastResult.reason})</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 12, marginBottom: 6 }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={autoEnabled}
|
||||
onChange={(e) => { void onToggleAuto(e.target.checked); }}
|
||||
/>
|
||||
자동 sync 사용
|
||||
</label>
|
||||
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 12, marginBottom: 8 }}>
|
||||
interval:
|
||||
<input
|
||||
type="number"
|
||||
aria-label="sync interval (분)"
|
||||
min={5}
|
||||
value={intervalMin}
|
||||
onChange={(e) => { void onChangeInterval(Number.parseInt(e.target.value, 10)); }}
|
||||
disabled={!autoEnabled}
|
||||
style={{ width: 60, fontSize: 12, padding: '2px 4px' }}
|
||||
/>
|
||||
분
|
||||
</label>
|
||||
|
||||
{conflictCount > 0 && (
|
||||
<div style={{ marginTop: 8 }}>
|
||||
<button onClick={() => setShowConflict(true)} style={btnStyle()}>
|
||||
충돌 해결… ({conflictCount}건)
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showConflict && (
|
||||
<ConflictModal
|
||||
onClose={() => setShowConflict(false)}
|
||||
onResolved={async () => {
|
||||
setStatus(await inboxApi.getSyncStatus());
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function btnStyle(): React.CSSProperties {
|
||||
return {
|
||||
background: '#0a4b80',
|
||||
color: '#fff',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
fontSize: 12,
|
||||
padding: '4px 10px',
|
||||
borderRadius: 4
|
||||
};
|
||||
}
|
||||
@@ -192,6 +192,9 @@ export interface InboxApi {
|
||||
ollama?: { endpoint: string; model: string };
|
||||
ai_enabled?: boolean;
|
||||
onboarding_completed?: boolean;
|
||||
sync_repo_url?: string | null;
|
||||
sync_auto_enabled?: boolean;
|
||||
sync_interval_min?: number;
|
||||
}>;
|
||||
setAiEnabled(enabled: boolean): Promise<{ ok: true }>;
|
||||
setOnboardingCompleted(completed: boolean): Promise<{ ok: true }>;
|
||||
@@ -211,6 +214,8 @@ export interface InboxApi {
|
||||
listConflicts(): Promise<SyncConflict[]>;
|
||||
resolveConflict(noteId: string, choice: 'local' | 'remote'): Promise<{ ok: true } | { ok: false; reason: string }>;
|
||||
getSyncStatus(): Promise<SyncStatusSnapshot>;
|
||||
setSyncAutoEnabled(enabled: boolean): Promise<{ ok: true }>;
|
||||
setSyncIntervalMin(value: number): Promise<{ ok: true } | { ok: false; reason: string }>;
|
||||
}
|
||||
|
||||
export interface InklingApi {
|
||||
|
||||
@@ -56,7 +56,13 @@ vi.mock('../../src/renderer/inbox/api.js', () => ({
|
||||
setOnboardingCompleted: vi.fn(async () => ({ ok: true as const })),
|
||||
// v0.2.9 Cut B Task 16 — AiProviderSection 가 SettingsPage 렌더 시 호출.
|
||||
getDisabledCount: vi.fn(async () => 0),
|
||||
enqueueDisabled: vi.fn(async () => ({ count: 0 }))
|
||||
enqueueDisabled: vi.fn(async () => ({ count: 0 })),
|
||||
// v0.3.0 Cut E — SyncSection 이 SettingsPage 에 마운트되어 호출.
|
||||
getSyncStatus: vi.fn(async () => ({ lastAt: null, lastResult: null, nextAt: null })),
|
||||
setSyncAutoEnabled: vi.fn(async () => ({ ok: true as const })),
|
||||
setSyncIntervalMin: vi.fn(async () => ({ ok: true as const })),
|
||||
configureSync: vi.fn(async () => ({ ok: true as const })),
|
||||
testSyncConnection: vi.fn(async () => ({ ok: true as const }))
|
||||
}
|
||||
}));
|
||||
|
||||
|
||||
59
tests/unit/ConflictModal.test.tsx
Normal file
59
tests/unit/ConflictModal.test.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
// @vitest-environment jsdom
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import '@testing-library/jest-dom/vitest';
|
||||
import { render, screen, fireEvent, cleanup, waitFor } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
|
||||
const { mockListConflicts, mockResolveConflict } = vi.hoisted(() => ({
|
||||
mockListConflicts: vi.fn(),
|
||||
mockResolveConflict: vi.fn()
|
||||
}));
|
||||
|
||||
vi.mock('../../src/renderer/inbox/api.js', () => ({
|
||||
inboxApi: { listConflicts: mockListConflicts, resolveConflict: mockResolveConflict }
|
||||
}));
|
||||
|
||||
import { ConflictModal } from '../../src/renderer/inbox/components/ConflictModal';
|
||||
|
||||
describe('ConflictModal', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
cleanup();
|
||||
mockListConflicts.mockResolvedValue([
|
||||
{ noteId: 'n1', localText: 'local A', remoteText: 'remote A' },
|
||||
{ noteId: 'n2', localText: 'local B', remoteText: 'remote B' }
|
||||
]);
|
||||
mockResolveConflict.mockResolvedValue({ ok: true });
|
||||
});
|
||||
|
||||
it('open 시 listConflicts 호출 + 양 conflict 표시', async () => {
|
||||
render(<ConflictModal onClose={() => {}} onResolved={() => {}} />);
|
||||
await waitFor(() => screen.getByText(/local A/));
|
||||
expect(screen.getByText(/local A/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/remote A/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/local B/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('내 것 사용 클릭 → resolveConflict(noteId, "local") 호출', async () => {
|
||||
render(<ConflictModal onClose={() => {}} onResolved={() => {}} />);
|
||||
await waitFor(() => screen.getByText(/local A/));
|
||||
const buttons = screen.getAllByRole('button', { name: /내 것 사용/ });
|
||||
fireEvent.click(buttons[0]!);
|
||||
await waitFor(() => {
|
||||
expect(mockResolveConflict).toHaveBeenCalledWith('n1', 'local');
|
||||
});
|
||||
});
|
||||
|
||||
it('마지막 conflict 해결 → onResolved + onClose 호출', async () => {
|
||||
mockListConflicts.mockResolvedValueOnce([{ noteId: 'n1', localText: 'a', remoteText: 'b' }]);
|
||||
const onResolved = vi.fn();
|
||||
const onClose = vi.fn();
|
||||
render(<ConflictModal onClose={onClose} onResolved={onResolved} />);
|
||||
await waitFor(() => screen.getByRole('button', { name: /원격 사용/ }));
|
||||
fireEvent.click(screen.getByRole('button', { name: /원격 사용/ }));
|
||||
await waitFor(() => {
|
||||
expect(onResolved).toHaveBeenCalled();
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -46,7 +46,13 @@ vi.mock('../../src/renderer/inbox/api.js', () => ({
|
||||
setAiEnabled: vi.fn(async () => ({ ok: true as const })),
|
||||
setOnboardingCompleted: vi.fn(async () => ({ ok: true as const })),
|
||||
getDisabledCount: vi.fn(async () => 0),
|
||||
enqueueDisabled: vi.fn(async () => ({ count: 0 }))
|
||||
enqueueDisabled: vi.fn(async () => ({ count: 0 })),
|
||||
// v0.3.0 Cut E — SyncSection 이 SettingsPage 에 마운트되어 호출.
|
||||
getSyncStatus: vi.fn(async () => ({ lastAt: null, lastResult: null, nextAt: null })),
|
||||
setSyncAutoEnabled: vi.fn(async () => ({ ok: true as const })),
|
||||
setSyncIntervalMin: vi.fn(async () => ({ ok: true as const })),
|
||||
configureSync: vi.fn(async () => ({ ok: true as const })),
|
||||
testSyncConnection: vi.fn(async () => ({ ok: true as const }))
|
||||
}
|
||||
}));
|
||||
|
||||
@@ -64,12 +70,13 @@ describe('SettingsPage', () => {
|
||||
expect(screen.getByRole('button', { name: /돌아가기/ })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders 4 section headings', () => {
|
||||
it('renders 5 section headings', () => {
|
||||
render(<SettingsPage />);
|
||||
expect(screen.getByText('AI 제공자')).toBeInTheDocument();
|
||||
expect(screen.getByText('자동 실행')).toBeInTheDocument();
|
||||
expect(screen.getByText('백업 / 복원')).toBeInTheDocument();
|
||||
expect(screen.getByText('정보')).toBeInTheDocument();
|
||||
expect(screen.getByText('동기화')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('clicking "← 돌아가기" sets showSettings to false', () => {
|
||||
|
||||
75
tests/unit/SyncSection.test.tsx
Normal file
75
tests/unit/SyncSection.test.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
// @vitest-environment jsdom
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import '@testing-library/jest-dom/vitest';
|
||||
import { render, screen, fireEvent, cleanup, waitFor } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
|
||||
const { mockGetSettings, mockConfigureSync, mockTestSyncConnection, mockGetSyncStatus, mockSetAuto, mockSetInterval } = vi.hoisted(() => ({
|
||||
mockGetSettings: vi.fn(async () => ({ sync_repo_url: '', sync_auto_enabled: true, sync_interval_min: 30 })),
|
||||
mockConfigureSync: vi.fn(async () => ({ ok: true as const })),
|
||||
mockTestSyncConnection: vi.fn(async () => ({ ok: true as const })),
|
||||
mockGetSyncStatus: vi.fn(async () => ({ lastAt: null, lastResult: null, nextAt: null })),
|
||||
mockSetAuto: vi.fn(async () => ({ ok: true as const })),
|
||||
mockSetInterval: vi.fn(async () => ({ ok: true as const }))
|
||||
}));
|
||||
|
||||
vi.mock('../../src/renderer/inbox/api.js', () => ({
|
||||
inboxApi: {
|
||||
getSettings: mockGetSettings,
|
||||
configureSync: mockConfigureSync,
|
||||
testSyncConnection: mockTestSyncConnection,
|
||||
getSyncStatus: mockGetSyncStatus,
|
||||
setSyncAutoEnabled: mockSetAuto,
|
||||
setSyncIntervalMin: mockSetInterval
|
||||
}
|
||||
}));
|
||||
|
||||
// ConflictModal is imported by SyncSection — mock it to avoid needing listConflicts
|
||||
vi.mock('../../src/renderer/inbox/components/ConflictModal.js', () => ({
|
||||
ConflictModal: () => null
|
||||
}));
|
||||
|
||||
import { SyncSection } from '../../src/renderer/inbox/components/settings/SyncSection';
|
||||
|
||||
describe('SyncSection', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
cleanup();
|
||||
mockGetSettings.mockResolvedValue({ sync_repo_url: '', sync_auto_enabled: true, sync_interval_min: 30 });
|
||||
mockGetSyncStatus.mockResolvedValue({ lastAt: null, lastResult: null, nextAt: null });
|
||||
});
|
||||
|
||||
it('빈 URL — 저장/연결 테스트 버튼 + 자동 sync 옵션 hide', async () => {
|
||||
render(<SyncSection />);
|
||||
await waitFor(() => screen.getByRole('button', { name: /저장/ }));
|
||||
expect(screen.queryByText(/자동 sync/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('URL 입력 + 저장 → configureSync 호출 + 자동 sync 옵션 표시', async () => {
|
||||
mockGetSettings.mockResolvedValueOnce({ sync_repo_url: 'git@host:u/r.git', sync_auto_enabled: true, sync_interval_min: 30 });
|
||||
render(<SyncSection />);
|
||||
await waitFor(() => screen.getByText(/자동 sync/));
|
||||
expect(screen.getByText(/자동 sync/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('연결 테스트 클릭 → testSyncConnection 호출 + 결과 표시', async () => {
|
||||
mockGetSettings.mockResolvedValueOnce({ sync_repo_url: 'git@host:u/r.git', sync_auto_enabled: true, sync_interval_min: 30 });
|
||||
render(<SyncSection />);
|
||||
await waitFor(() => screen.getByRole('button', { name: /연결 테스트/ }));
|
||||
fireEvent.click(screen.getByRole('button', { name: /연결 테스트/ }));
|
||||
await waitFor(() => {
|
||||
expect(mockTestSyncConnection).toHaveBeenCalled();
|
||||
expect(screen.getByText(/연결 성공/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('자동 sync 토글 → setSyncAutoEnabled 호출', async () => {
|
||||
mockGetSettings.mockResolvedValueOnce({ sync_repo_url: 'git@host:u/r.git', sync_auto_enabled: true, sync_interval_min: 30 });
|
||||
render(<SyncSection />);
|
||||
await waitFor(() => screen.getByLabelText(/자동 sync/));
|
||||
fireEvent.click(screen.getByLabelText(/자동 sync/));
|
||||
await waitFor(() => {
|
||||
expect(mockSetAuto).toHaveBeenCalledWith(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user