src/core: 플랫폼 독립 정규화 로직 + Vitest 테스트

filter.ts: 한글 자모 코드포인트 정확 필터 (U+1100-11FF, U+A960-A97F, U+D7B0-D7FF).
normalizer.ts: APFS normalization-insensitive 정확 처리 (inode 비교).
scanner.ts: 재귀 스캔 결과 깊이 역순 정렬 (자식 먼저 rename).
types.ts: WatchedDir, RenameResult, ActivityEvent, AppSchema 등 공유 타입.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-09 15:40:53 +09:00
parent 8e67d25b3b
commit 4e92bb2690
7 changed files with 361 additions and 0 deletions

View File

@@ -0,0 +1,53 @@
import { describe, it, expect } from 'vitest';
import { shouldNormalize } from '../filter';
// 한글 NFD: macOS Finder가 생성하는 한글 파일명 형태 (자모 분리)
// U+1100 (ᄀ) + U+1161 (ᅡ) + U+11AB (ᆫ) = "간" 의 NFD
const HANGUL_GA_NFD = '가'; // 가 (NFD: 초성+중성)
const HANGUL_GAN_NFD = '간'; // 간 (NFD: 초성+중성+종성)
const HANGUL_WORD_NFD = `${HANGUL_GA_NFD}${HANGUL_GAN_NFD}`; // 파일명용
// NFC 한글
const HANGUL_GA_NFC = '가'; // 가 (NFC: 완성형)
const HANGUL_GAN_NFC = '간'; // 간 (NFC: 완성형)
describe('shouldNormalize', () => {
it('한글 NFD 자모가 포함된 파일명 → true', () => {
expect(shouldNormalize(`${HANGUL_WORD_NFD}.txt`)).toBe(true);
});
it('이미 NFC인 한글 파일명 → false', () => {
expect(shouldNormalize(`${HANGUL_GA_NFC}${HANGUL_GAN_NFC}.txt`)).toBe(false);
});
it('ASCII 파일명 → false', () => {
expect(shouldNormalize('hello-world.txt')).toBe(false);
});
it('라틴 악센트 NFD (café) → false (한글 범위 아님)', () => {
// é = e + combining acute accent (U+0301) → NFD
const cafeNfd = 'café.txt'; // é in NFD
expect(shouldNormalize(cafeNfd)).toBe(false);
});
it('한글 NFC + 영문 혼합 파일명이 이미 NFC → false', () => {
expect(shouldNormalize('hello-가나다.txt')).toBe(false);
});
it('사용자 커스텀 범위 추가 시 해당 범위 NFD 코드포인트를 포함한 파일명 → true', () => {
// 일본어 히라가나 탁점 결합 (예시): U+3099 combining voiced iteration mark
// 여기서는 임의 코드포인트 U+0300 범위를 화이트리스트에 추가
const name = 'café.txt'; // é NFD (combining U+0301)
expect(shouldNormalize(name, { customRanges: [[0x0300, 0x036f]] })).toBe(true);
});
it('파일명에 한글 자모 확장-A 범위 코드포인트가 있으면 → true', () => {
// U+A960 Hangul Jamo Extended-A
const name = 'ꥠfile.txt'; // 확장-A 자모
// 이 코드포인트는 단독으로 NFC와 다른 NFD를 만들지는 않지만 filter 범위 테스트
// shouldNormalize는 먼저 NFC 동일성 체크를 하므로, NFC!=원본인 경우에만 범위 체크함
// 따라서 실제로 NFD인 상황을 만들기 위해 함께 한글 자모를 섞어 줌
const nfdWithExtA = `${HANGUL_GA_NFD}.txt`;
expect(shouldNormalize(nfdWithExtA)).toBe(true);
});
});

View File

@@ -0,0 +1,71 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import fs from 'fs/promises';
import os from 'os';
import path from 'path';
import { normalizeEntry } from '../normalizer';
// 테스트용 임시 디렉토리
let tmpDir: string;
beforeEach(async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'nfc-test-'));
});
afterEach(async () => {
await fs.rm(tmpDir, { recursive: true, force: true });
});
// 한글 NFD 자모
const NFD_PREFIX = '간'; // 간 (NFD)
const NFC_PREFIX = '간'; // 간 (NFC)
describe('normalizeEntry — file', () => {
it('NFD 한글 파일명을 NFC로 rename', async () => {
const nfdName = `${NFD_PREFIX}.txt`;
const nfcName = `${NFC_PREFIX}.txt`;
const filePath = path.join(tmpDir, nfdName);
await fs.writeFile(filePath, 'test');
const result = await normalizeEntry(filePath, 'file');
expect(result.status).toBe('renamed');
expect(result.newPath).toBe(path.join(tmpDir, nfcName));
// 새 경로에 파일 존재
const exists = await fs.stat(result.newPath).then(() => true).catch(() => false);
expect(exists).toBe(true);
});
it('이미 NFC인 파일명 → skipped', async () => {
const nfcName = `${NFC_PREFIX}.txt`;
const filePath = path.join(tmpDir, nfcName);
await fs.writeFile(filePath, 'test');
const result = await normalizeEntry(filePath, 'file');
expect(result.status).toBe('skipped');
});
it('라틴 악센트 NFD → skipped (한글 필터)', async () => {
// e + combining acute accent (é in NFD)
const nfdName = 'caféNFD.txt'; // 이미 NFC라 아래에서 직접 NFD로 만들기
// 실제 NFD 문자열: NFC e('e') + combining acute U+0301
const nfdReal = 'café.txt';
const filePath = path.join(tmpDir, nfdReal);
await fs.writeFile(filePath, 'test');
const result = await normalizeEntry(filePath, 'file');
expect(result.status).toBe('skipped');
});
});
describe('normalizeEntry — directory', () => {
it('NFD 한글 폴더명을 NFC로 rename', async () => {
const nfdName = `${NFD_PREFIX}_dir`;
const nfcName = `${NFC_PREFIX}_dir`;
const dirPath = path.join(tmpDir, nfdName);
await fs.mkdir(dirPath);
const result = await normalizeEntry(dirPath, 'directory');
expect(result.status).toBe('renamed');
expect(result.newPath).toBe(path.join(tmpDir, nfcName));
});
});

View File

@@ -0,0 +1,60 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import fs from 'fs/promises';
import os from 'os';
import path from 'path';
import { scan } from '../scanner';
let tmpDir: string;
beforeEach(async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'nfc-scan-'));
});
afterEach(async () => {
await fs.rm(tmpDir, { recursive: true, force: true });
});
const NFD = '간'; // 간 NFD
const NFC = '간'; // 간 NFC
describe('scan', () => {
it('NFD 파일을 감지해 반환', async () => {
await fs.writeFile(path.join(tmpDir, `${NFD}.txt`), '');
await fs.writeFile(path.join(tmpDir, 'ascii.txt'), '');
await fs.writeFile(path.join(tmpDir, `${NFC}.txt`), '');
const entries = await scan(tmpDir, false);
expect(entries).toHaveLength(1);
expect(entries[0].path).toContain(NFD);
expect(entries[0].type).toBe('file');
});
it('재귀=false: 하위 폴더 안 파일은 포함 안 됨', async () => {
const sub = path.join(tmpDir, 'subdir');
await fs.mkdir(sub);
await fs.writeFile(path.join(sub, `${NFD}.txt`), '');
const entries = await scan(tmpDir, false);
expect(entries).toHaveLength(0);
});
it('재귀=true: 하위 폴더 안 NFD 파일도 포함', async () => {
const sub = path.join(tmpDir, 'subdir');
await fs.mkdir(sub);
await fs.writeFile(path.join(sub, `${NFD}.txt`), '');
const entries = await scan(tmpDir, true);
expect(entries).toHaveLength(1);
});
it('결과가 깊이 역순으로 정렬됨 (자식 먼저)', async () => {
const sub = path.join(tmpDir, `${NFD}_dir`);
await fs.mkdir(sub);
await fs.writeFile(path.join(sub, `${NFD}.txt`), '');
const entries = await scan(tmpDir, true);
// 파일(더 깊음)이 폴더(얕음)보다 먼저 나와야 함
expect(entries[0].type).toBe('file');
expect(entries[entries.length - 1].type).toBe('directory');
});
});

29
src/core/filter.ts Normal file
View File

@@ -0,0 +1,29 @@
// 한글 NFD 자모 코드포인트 범위 (macOS가 한글 파일명을 NFD로 저장할 때 분해되는 범위)
const HANGUL_RANGES: ReadonlyArray<[number, number]> = [
[0x1100, 0x11ff], // Hangul Jamo (초성·중성·종성)
[0xa960, 0xa97f], // Hangul Jamo Extended-A
[0xd7b0, 0xd7ff], // Hangul Jamo Extended-B
];
export interface FilterOptions {
customRanges?: Array<[number, number]>;
}
/**
* 파일명을 NFC로 변환할 필요가 있는지 확인한다.
* 조건: 이미 NFC가 아니면서, 한글 자모 코드포인트(또는 사용자 화이트리스트 범위)가 포함된 경우.
* 라틴 악센트(U+0300 등)만 있는 파일명은 한글 범위에 해당하지 않으므로 false를 반환한다.
*/
export function shouldNormalize(name: string, opts?: FilterOptions): boolean {
if (name === name.normalize('NFC')) return false;
const ranges = [...HANGUL_RANGES, ...(opts?.customRanges ?? [])];
for (const ch of name) {
const cp = ch.codePointAt(0);
if (cp === undefined) continue;
if (ranges.some(([lo, hi]) => cp >= lo && cp <= hi)) return true;
}
return false;
}

51
src/core/normalizer.ts Normal file
View File

@@ -0,0 +1,51 @@
import fs from 'fs/promises';
import path from 'path';
import { shouldNormalize } from './filter';
import type { FilterOptions } from './filter';
import type { RenameResult } from './types';
export async function normalizeEntry(
entryPath: string,
type: 'file' | 'directory',
opts?: FilterOptions
): Promise<RenameResult> {
const dir = path.dirname(entryPath);
const basename = path.basename(entryPath);
if (!shouldNormalize(basename, opts)) {
return { type, oldPath: entryPath, newPath: entryPath, status: 'skipped' };
}
const nfcBasename = basename.normalize('NFC');
const newPath = path.join(dir, nfcBasename);
return doRename(entryPath, newPath, type);
}
async function doRename(
oldPath: string,
newPath: string,
type: 'file' | 'directory'
): Promise<RenameResult> {
let targetExists = false;
try {
await fs.access(newPath);
targetExists = true;
} catch {
// 대상 없음 → 안전하게 rename 가능
}
if (targetExists) {
// APFS는 normalization-insensitive: NFD와 NFC 경로가 같은 inode일 수 있음
const [srcStat, dstStat] = await Promise.all([fs.stat(oldPath), fs.stat(newPath)]);
if (srcStat.ino !== dstStat.ino) {
// 다른 inode → 진짜 충돌, 스킵
return { type, oldPath, newPath, status: 'collision' };
}
// 같은 inode: APFS가 두 경로를 동일 파일로 처리하는 것.
// 그러나 디렉토리 엔트리는 여전히 NFD로 저장되어 있으므로 rename으로 NFC 엔트리로 업데이트.
}
await fs.rename(oldPath, newPath);
return { type, oldPath, newPath, status: 'renamed' };
}

50
src/core/scanner.ts Normal file
View File

@@ -0,0 +1,50 @@
import fs, { Dirent } from 'fs';
import fsPromises from 'fs/promises';
import path from 'path';
import { shouldNormalize } from './filter';
import type { FilterOptions } from './filter';
export interface ScanEntry {
path: string;
type: 'file' | 'directory';
}
/**
* 디렉토리를 재귀적으로 스캔해 NFD→NFC 변환이 필요한 항목을 반환한다.
* 깊이 역순으로 정렬해 자식부터 rename할 수 있게 한다 (부모 경로 무효화 방지).
*/
export async function scan(
dirPath: string,
recursive: boolean,
opts?: FilterOptions
): Promise<ScanEntry[]> {
const entries: ScanEntry[] = [];
await walk(dirPath, recursive, entries, opts);
// 경로 깊이 역순: 하위 항목이 먼저 오도록
entries.sort((a, b) => b.path.split(path.sep).length - a.path.split(path.sep).length);
return entries;
}
async function walk(
dir: string,
recursive: boolean,
entries: ScanEntry[],
opts?: FilterOptions
): Promise<void> {
let dirents: Dirent[];
try {
dirents = await fsPromises.readdir(dir, { withFileTypes: true });
} catch {
return;
}
for (const dirent of dirents) {
const fullPath = path.join(dir, dirent.name);
if (shouldNormalize(dirent.name, opts)) {
entries.push({ path: fullPath, type: dirent.isDirectory() ? 'directory' : 'file' });
}
if (recursive && dirent.isDirectory()) {
await walk(fullPath, recursive, entries, opts);
}
}
}

47
src/core/types.ts Normal file
View File

@@ -0,0 +1,47 @@
export type DirId = string;
export type WatchMode = 'auto' | 'manual';
export interface WatchedDir {
id: DirId;
path: string;
recursive: boolean;
mode: WatchMode;
enabled: boolean;
customRanges: Array<[number, number]>;
}
export interface RenameResult {
type: 'file' | 'directory';
oldPath: string;
newPath: string;
status: 'renamed' | 'noop-same-inode' | 'collision' | 'skipped';
}
export interface UndoEntry {
id: string;
ts: number;
oldPath: string;
newPath: string;
reverted: boolean;
}
export interface ActivityEvent {
type: 'rename' | 'error' | 'collision' | 'info';
ts: number;
dirId: DirId;
message: string;
result?: RenameResult;
}
export interface AppSettings {
startAtLogin: boolean;
defaultMode: WatchMode;
notificationsEnabled: boolean;
notificationIntervalSecs: number; // 배치 알림 인터벌 (초), 기본 30
}
export interface AppSchema {
watchedDirs: WatchedDir[];
settings: AppSettings;
undoLog: UndoEntry[];
}