Files
NFD2NFC/src/main/__tests__/watcher.test.ts
jung-geun 7209486a71 fix(watcher): 한글 NFD 파일 압축 해제 시 동일 파일 반복 변환 race condition 수정
- inFlight Set으로 handlePath 진입 즉시 경로를 잠가 burst add 이벤트가
  몰려올 때 첫 번째 처리가 끝나기 전 두 번째 호출을 즉시 차단
- recentlyRenamed 키를 NFC 정규화로 통일하고 oldPath/newPath 양쪽 등록
  (chokidar가 NFD·NFC 변형 중 어느 쪽으로 이벤트를 보내도 TTL 가드 적용)
- chokidar awaitWriteFinish(stabilityThreshold 300ms) 추가로 압축 해제
  중 부분 쓰기 단계의 add 폭주를 상류에서 차단
- race condition 회귀 방지를 위한 watcher 단위 테스트 3개 추가

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-20 22:06:46 +09:00

104 lines
3.9 KiB
TypeScript

import { EventEmitter } from 'events';
import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest';
vi.mock('chokidar');
vi.mock('electron', () => ({ BrowserWindow: { getAllWindows: () => [] } }));
const mockAppendUndoEntries = vi.fn().mockResolvedValue(undefined);
vi.mock('../store', () => ({ appendUndoEntries: mockAppendUndoEntries }));
const mockQueueRenamedNotification = vi.fn().mockResolvedValue(undefined);
vi.mock('../notifier', () => ({
queueRenamedNotification: mockQueueRenamedNotification,
notifyManualQueue: vi.fn().mockResolvedValue(undefined),
}));
vi.mock('../nanoid', () => ({ nanoid: () => 'test-id' }));
const mockNormalizeEntry = vi.fn();
vi.mock('../../core/normalizer', () => ({ normalizeEntry: mockNormalizeEntry }));
import chokidar from 'chokidar';
import type { WatchedDir } from '../../core/types';
// 테스트마다 고유 경로를 사용해 모듈 레벨 recentlyRenamed 상태 충돌 방지
function makePaths(slug: string) {
const nfc = `/test/watch/${slug}.txt`;
return { nfc, nfd: nfc.normalize('NFD') };
}
function makeDir(id: string): WatchedDir {
return { id, path: '/test/watch', recursive: false, mode: 'auto', enabled: true, customRanges: [] };
}
describe('watcher — in-flight race condition', () => {
let mockWatcher: EventEmitter & { close: ReturnType<typeof vi.fn> };
beforeEach(() => {
vi.clearAllMocks();
mockWatcher = Object.assign(new EventEmitter(), { close: vi.fn().mockResolvedValue(undefined) });
vi.mocked(chokidar.watch).mockReturnValue(mockWatcher as unknown as ReturnType<typeof chokidar.watch>);
});
afterEach(async () => {
const { stopDir } = await import('../watcher');
// 각 테스트에서 사용한 id 정리
for (const id of ['dir-burst', 'dir-nfc-reentry', 'dir-nfd-reentry']) {
await stopDir(id);
}
});
it('동일 NFD 경로에 burst add 이벤트가 와도 normalizeEntry가 1번만 호출된다', async () => {
const { nfc, nfd } = makePaths('강의자료-burst');
mockNormalizeEntry.mockResolvedValue({ type: 'file', status: 'renamed', oldPath: nfd, newPath: nfc });
const { startDir } = await import('../watcher');
await startDir(makeDir('dir-burst'));
// 같은 경로를 동기적으로 두 번 emit — race 시나리오 재현
mockWatcher.emit('add', nfd);
mockWatcher.emit('add', nfd);
await new Promise((r) => setTimeout(r, 0));
expect(mockNormalizeEntry).toHaveBeenCalledTimes(1);
expect(mockAppendUndoEntries).toHaveBeenCalledTimes(1);
});
it('rename 완료 후 NFC 경로로 다시 add 이벤트가 와도 recentlyRenamed 가드에 막힌다', async () => {
const { nfc, nfd } = makePaths('강의자료-nfc-reentry');
mockNormalizeEntry.mockResolvedValue({ type: 'file', status: 'renamed', oldPath: nfd, newPath: nfc });
const { startDir } = await import('../watcher');
await startDir(makeDir('dir-nfc-reentry'));
mockWatcher.emit('add', nfd);
await new Promise((r) => setTimeout(r, 0));
expect(mockNormalizeEntry).toHaveBeenCalledTimes(1);
vi.clearAllMocks();
// RENAME_TTL(2000ms) 이내에 NFC 경로로 재진입 — 가드에 막혀야 함
mockWatcher.emit('add', nfc);
await new Promise((r) => setTimeout(r, 0));
expect(mockNormalizeEntry).not.toHaveBeenCalled();
});
it('rename 완료 후 NFD 경로로 재진입해도 recentlyRenamed 가드에 막힌다', async () => {
const { nfc, nfd } = makePaths('강의자료-nfd-reentry');
mockNormalizeEntry.mockResolvedValue({ type: 'file', status: 'renamed', oldPath: nfd, newPath: nfc });
const { startDir } = await import('../watcher');
await startDir(makeDir('dir-nfd-reentry'));
mockWatcher.emit('add', nfd);
await new Promise((r) => setTimeout(r, 0));
expect(mockNormalizeEntry).toHaveBeenCalledTimes(1);
vi.clearAllMocks();
mockWatcher.emit('add', nfd);
await new Promise((r) => setTimeout(r, 0));
expect(mockNormalizeEntry).not.toHaveBeenCalled();
});
});