feat(backend): 메일 검색을 자체 색인으로 바꾸고 조건 검색 · 강조를 더한다

14단계 2차다. 색인이 준비된 직원은 서버의 자체 색인으로, 채우는 중인 직원은 지금처럼
엔진으로 찾는다(방식은 첫 쪽에서 정하고 커서가 잇는다).

- 조건 from · to(참조 포함) · subject · body · after · before · hasAttachment — q 와 합친 낱말 8개까지
- 부분 일치(대소문자 · 전각 · NFC), 구절은 줄바꿈 · NBSP 로 갈린 글에도, 한 글자 낱말뿐이면
  제목 · 사람 · 첨부 이름에서만(2026-11-05 사용자 확정)
- 찾기: GIN(lower(user_email), grams) 후보 → 기본 키로 확인, 한 요청 확인 3,000줄 상한 +
  이어 찾기 커서, total 은 첫 쪽에서 1,000 까지(못 세면 null) — 1만 통에서 가장 무거운 검색 0.8초
- 목록 항목 searchHighlight(제목 · 맞은 말 둘레 미리 보기), 읽기 화면은 정화한 HTML 의 글
  조각에 <mark> · 제목 · 첨부 이름 · 글 본문 위치
- 마이그레이션 SearchIndexUserGrams(btree_gin), 색인 판 2(공백 접기) — 판이 바뀌면 그 직원의
  색인을 지우고 밤에 다시 채운다

리뷰 반영: 옛 판 줄 지우기, 꾸민 커서 · NUL 은 VAL_001 · U+FFFD, 한 글자 길 한 트랜잭션,
다른 직원 줄 격리 e2e.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-06 00:18:41 +09:00
parent d57e6bfe6c
commit f9fbf8a20f
53 changed files with 6073 additions and 269 deletions
+567
View File
@@ -128,7 +128,9 @@ import {
type MailIndexSource,
} from '@/modules/mail-search/mail-index-source';
import { MailSearchBuildService } from '@/modules/mail-search/mail-search-build.service';
import type { SearchScope } from '@/modules/mail-search/mail-search.types';
import { MailSearchIndexRepository } from '@/modules/mail-search/mail-search-index.repository';
import { INDEX_FORMAT } from '@/modules/mail-search/mail-search.constants';
import { toIndexRow } from '@/modules/mail-search/index-document';
import {
SystemSettingsService,
@@ -602,6 +604,37 @@ function indexDocument(overrides: Partial<IndexDocument>): IndexDocument {
};
}
/** 대역 엔진이 색인 검색에서 빼는 메일함 (휴지통 · 스팸메일함 역할 — 14단계 2차) */
const SEARCH_EXCLUDED_MAILBOX = 'idxtrash';
/** 색인 문서의 주소 → 요약의 주소 (주소가 없는 것은 뺀다) */
function mailAddressesOf(
addresses: readonly { name: string | null; email: string | null }[],
): { name: string | null; email: string }[] {
return addresses.flatMap(({ name, email }) =>
email === null ? [] : [{ name, email }],
);
}
/** 대역 엔진의 요약 — 색인 문서와 같은 메일 (14단계 2차 — 색인으로 찾은 쪽의 요약은 엔진에서 읽는다) */
function summaryOfDocument(document: IndexDocument): MessageSummary {
return {
id: document.id,
threadId: document.threadId ?? document.id,
mailboxIds: [...document.mailboxIds],
from: mailAddressesOf(document.from)[0] ?? null,
to: mailAddressesOf(document.to),
subject: document.subject,
preview: document.preview,
receivedAt: new Date(document.receivedAt).toISOString(),
isRead: document.keywords.includes('$seen'),
isFlagged: document.keywords.includes('$flagged'),
hasAttachment: document.hasAttachment,
size: document.size,
pendingSend: null,
};
}
/** 목록 응답의 data — 계약의 { items, pageInfo } */
interface Page<T> {
items: T[];
@@ -713,6 +746,8 @@ describe('응답 계약 (e2e)', () => {
/** 대역 엔진이 받은 메일 목록 요청 (검색 범위·검색어 확인용) */
const listQueries: { user: string; query: MessageListQuery }[] = [];
/** 14단계 2차 — 대역 엔진이 받은 요약 요청 (색인으로 찾은 쪽) */
const summaryRequests: { user: string; ids: readonly string[] }[] = [];
/** 대역 엔진이 받은 여러 통 바꾸기 · 휴지통에서 영구 삭제 */
const bulkUpdates: {
user: string;
@@ -1235,6 +1270,46 @@ describe('응답 계약 (e2e)', () => {
total: 0,
});
},
// 14단계 2차 — 색인 검색의 범위 · 찾은 쪽의 요약 (엔진 쪽 판단은 jmap-mail.provider.spec.ts 가 본다)
searchScope: (
_user: string,
mailboxId?: string,
): Promise<SearchScope> => {
if (mailboxId === MISSING_MAILBOX) {
return Promise.reject(
new BusinessException('RES_001', {
context: { reason: 'e2e-mailbox-not-found' },
}),
);
}
return Promise.resolve(
mailboxId === undefined
? {
mailboxId: null,
excludedMailboxIds: [SEARCH_EXCLUDED_MAILBOX],
excludedKeywords: ['$ara-sending'],
}
: {
mailboxId,
excludedMailboxIds: [],
excludedKeywords: ['$ara-sending'],
},
);
},
getSummaries: (
user: string,
ids: readonly string[],
): Promise<MessageSummary[]> => {
summaryRequests.push({ user, ids });
const emails = fakeIndexSource.mailboxes.get(user)?.emails ?? [];
const byId = new Map(emails.map((email) => [email.id, email]));
return Promise.resolve(
ids.flatMap((id) => {
const email = byId.get(id);
return email ? [summaryOfDocument(email)] : [];
}),
);
},
updateMessages: (
user: string,
ids: readonly string[],
@@ -7840,6 +7915,498 @@ describe('응답 계약 (e2e)', () => {
});
});
describe('14단계 2차 — 색인으로 찾기 · 조건 · 강조 (GET /api/messages · GET /api/messages/{id})', () => {
const ME = 'search.query@e2e.arasystem.test';
const SEOJUN = { name: '박서준', email: 'seojun.park@e2e.arasystem.test' };
const DOCUMENTS = [
indexDocument({
id: 'm-meeting',
subject: '회의 일정',
bodyText: '내일 회의는 3층 회의실에서 합니다',
preview: '내일 회의는 3층 회의실에서 합니다',
from: [SEOJUN],
to: [{ name: '김아라', email: ME }],
receivedAt: '2026-11-05T01:00:00Z',
}),
indexDocument({
id: 'm-contract',
subject: '[공지] 11월 계약서',
bodyText: '계약서를 보내 드립니다. 갱신 일정은 다음 주입니다.',
preview: '계약서를 보내 드립니다.',
attachmentNames: ['계약서.PDF'],
hasAttachment: true,
cc: [{ name: 'Partner', email: 'partner@example.com' }],
receivedAt: '2026-11-04T01:00:00Z',
}),
indexDocument({
id: 'm-renewal',
subject: '회의록',
bodyText: '계약 갱신 회의 내용을 정리했습니다',
preview: '계약 갱신 회의 내용을 정리했습니다',
receivedAt: '2026-11-03T01:00:00Z',
}),
indexDocument({
id: 'm-floor',
subject: '7층 공사 안내',
bodyText: '공사 기간 동안 엘리베이터를 쓸 수 없습니다',
receivedAt: '2026-11-02T01:00:00Z',
}),
// 휴지통에만 든 메일 — 메일함을 정하지 않은 검색에서 빠진다
indexDocument({
id: 'm-trash',
subject: '지난 계약 해지',
bodyText: '계약 해지 통보',
mailboxIds: [SEARCH_EXCLUDED_MAILBOX],
receivedAt: '2026-11-01T01:00:00Z',
}),
];
interface ListItem {
id: string;
subject: string;
searchHighlight: {
subject: [number, number][];
preview: { text: string; ranges: [number, number][] };
} | null;
}
const list = (params: Record<string, string | number>) =>
request(server)
.get('/api/messages')
.query(params)
.set('Cookie', cookieOf(ME));
const idsOf = async (params: Record<string, string | number>) =>
dataOf<Page<ListItem>>(await list(params).expect(200)).items.map(
(item) => item.id,
);
const setStatus = (status: 'ready' | 'building') =>
dataSource.query(
'UPDATE mail_search_states SET status = $2 WHERE user_email = $1',
[ME, status],
);
beforeAll(async () => {
// 상태 줄은 준비 상태를 물을 때 생긴다 — 채우기로 ready (색인 판은 지금 판)
await request(server)
.get('/api/search-index')
.set('Cookie', cookieOf(ME))
.expect(200);
fakeIndexSource.mailboxes.set(ME, { state: 'q1', emails: DOCUMENTS });
const build = app.get(MailSearchBuildService);
expect(await build.build(ME, Date.now() + 120_000)).toBe('ready');
});
afterAll(async () => {
fakeIndexSource.mailboxes.delete(ME);
for (const table of ['mail_search_messages', 'mail_search_states']) {
await dataSource.query(`DELETE FROM "${table}" WHERE user_email = $1`, [
ME,
]);
}
});
it('색인이 준비되면 부분 일치로 찾는다 — 휴지통 · 스팸메일함에만 든 메일은 빼고 받은 시각 최신부터, 요약은 엔진에서 · 첫 쪽의 total', async () => {
const queriesBefore = listQueries.length;
const res = await list({ q: '계약' }).expect(200);
const page = dataOf<Page<ListItem>>(res);
expect(page.items.map((item) => item.id)).toEqual([
'm-contract',
'm-renewal',
]);
expect(page.pageInfo).toEqual({
hasNext: false,
nextCursor: null,
total: 2,
});
// 엔진의 목록 검색은 묻지 않았다 — 찾은 쪽의 요약만
expect(listQueries.length).toBe(queriesBefore);
expect(summaryRequests.at(-1)).toEqual({
user: ME,
ids: ['m-contract', 'm-renewal'],
});
});
it('강조 — 제목의 맞은 곳 · 본문에서 맞은 곳 둘레의 미리 보기와 그 안의 맞은 곳 (UTF-16 [시작, 끝))', async () => {
const [contract] = dataOf<Page<ListItem>>(
await list({ q: '계약 갱신' }).expect(200),
).items;
expect(contract.id).toBe('m-contract');
expect(contract.searchHighlight).toEqual({
subject: [[9, 11]],
preview: {
text: '계약서를 보내 드립니다. 갱신 일정은 다음 주입니다.',
ranges: [
[0, 2],
[14, 16],
],
},
});
});
it('조건 — 보낸 사람 · 받는 사람(참조 포함) · 제목 · 내용 · 기간 · 첨부, 검색어 없이도 · 검색어와 함께면 모두 맞아야', async () => {
expect(await idsOf({ from: '박서준' })).toEqual(['m-meeting']);
expect(await idsOf({ from: 'seojun' })).toEqual(['m-meeting']);
expect(await idsOf({ to: 'partner' })).toEqual(['m-contract']);
expect(await idsOf({ subject: '회의' })).toEqual([
'm-meeting',
'm-renewal',
]);
expect(await idsOf({ body: '회의실' })).toEqual(['m-meeting']);
expect(await idsOf({ hasAttachment: 'true' })).toEqual(['m-contract']);
expect(
await idsOf({
after: '2026-11-03T00:00:00.000Z',
before: '2026-11-04T01:00:00.000Z',
}),
).toEqual(['m-renewal']);
expect(await idsOf({ q: '회의', from: '이도윤' })).toEqual(['m-renewal']);
expect(await idsOf({ q: '"갱신 일정"' })).toEqual(['m-contract']);
// 메일함 범위
expect(await idsOf({ mailboxId: 'inbox', q: '계약' })).toEqual([
'm-contract',
'm-renewal',
]);
expect(
await idsOf({ mailboxId: SEARCH_EXCLUDED_MAILBOX, q: '계약' }),
).toEqual(['m-trash']);
});
it('한 글자 낱말뿐이면 제목 · 사람 · 첨부 이름에서만 — 본문에만 있는 메일은 빠진다', async () => {
// '층' 은 m-floor 의 제목 · m-meeting 의 본문에 있다
expect(await idsOf({ q: '층' })).toEqual(['m-floor']);
// 두 글자 넘는 낱말과 함께면 본문에서도 확인한다
expect(await idsOf({ q: '층 회의실' })).toEqual(['m-meeting']);
});
it('쪽 넘기기 — 다음 쪽은 커서의 자리부터, 다음 쪽의 total 은 null · 다른 검색의 커서는 400 VAL_001', async () => {
const first = dataOf<Page<ListItem>>(
await list({ q: '계약', limit: 1 }).expect(200),
);
expect(first.items.map((item) => item.id)).toEqual(['m-contract']);
expect(first.pageInfo).toMatchObject({ hasNext: true, total: 2 });
const cursor = first.pageInfo.nextCursor as string;
const second = dataOf<Page<ListItem>>(
await list({ q: '계약', limit: 1, cursor }).expect(200),
);
expect(second.items.map((item) => item.id)).toEqual(['m-renewal']);
expect(second.pageInfo).toEqual({
hasNext: false,
nextCursor: null,
total: null,
});
const others: Record<string, string>[] = [
{ q: '견적', cursor },
{ q: '계약', from: '이도윤', cursor },
{ q: '계약', mailboxId: 'inbox', cursor },
];
for (const params of others) {
const res = await list(params).expect(400);
expect(body(res).error?.code).toBe('VAL_001');
}
});
it('쪽을 넘기는 중에는 처음 방식대로 — 색인 커서는 다시 채우기에 들어가도 색인으로, 준비 중의 새 검색은 엔진 방식(조건도 넘긴다 · 강조 없음)', async () => {
const cursor = dataOf<Page<ListItem>>(
await list({ q: '계약', limit: 1 }).expect(200),
).pageInfo.nextCursor as string;
await setStatus('building');
try {
const queriesBefore = listQueries.length;
const next = dataOf<Page<ListItem>>(
await list({ q: '계약', limit: 1, cursor }).expect(200),
);
expect(next.items.map((item) => item.id)).toEqual(['m-renewal']);
expect(listQueries.length).toBe(queriesBefore);
const engine = dataOf<Page<ListItem>>(
await list({
q: ' 계약 ',
from: '이도윤',
hasAttachment: 'true',
}).expect(200),
);
expect(engine.items).toEqual([]);
expect(listQueries.slice(queriesBefore)).toEqual([
{
user: ME,
query: {
mailboxId: undefined,
limit: 20,
cursor: undefined,
text: '계약',
conditions: { from: '이도윤', hasAttachment: true },
},
},
]);
} finally {
await setStatus('ready');
}
});
it('다른 직원의 줄은 섞이지 않는다 — 같은 낱말 · 같은 메일함 id 여도 (두 글자 · 확인 · 한 글자 · 조건만 · 메일함 범위 — 리뷰 L6)', async () => {
const OTHER = 'search.neighbor@e2e.arasystem.test';
await request(server)
.get('/api/search-index')
.set('Cookie', cookieOf(OTHER))
.expect(200);
// 엔진의 메일함 id 는 계정마다 같은 꼴이다(inbox) — 글도 ME 의 것과 같게
fakeIndexSource.mailboxes.set(OTHER, {
state: 'n1',
emails: DOCUMENTS.map((document) => ({
...document,
id: `x-${document.id}`,
})),
});
try {
const build = app.get(MailSearchBuildService);
expect(await build.build(OTHER, Date.now() + 120_000)).toBe('ready');
const searches: Record<string, string>[] = [
{ q: '계약' },
{ q: '계약서' },
{ q: '층' },
{ subject: '회의' },
{ hasAttachment: 'true' },
{ after: '2026-11-01T00:00:00.000Z' },
{ mailboxId: 'inbox', q: '회의' },
{ mailboxId: SEARCH_EXCLUDED_MAILBOX, q: '계약' },
];
for (const params of searches) {
const before = summaryRequests.length;
const ids = await idsOf(params);
expect(ids.length).toBeGreaterThan(0);
// 색인이 찾은 id 그대로(엔진에 요약을 묻기 전) — 다른 직원의 줄이 없다
const asked = summaryRequests
.slice(before)
.flatMap((request) => [...request.ids]);
expect(asked.length).toBeGreaterThan(0);
expect(asked.filter((id) => id.startsWith('x-'))).toEqual([]);
}
} finally {
fakeIndexSource.mailboxes.delete(OTHER);
for (const table of ['mail_search_messages', 'mail_search_states']) {
await dataSource.query(
`DELETE FROM "${table}" WHERE user_email = $1`,
[OTHER],
);
}
}
});
it('검색 글의 NUL 은 U+FFFD 로 바꿔 찾는다 — DB 오류(500)가 아니다, 엔진 방식도 (리뷰 L2)', async () => {
const NUL = String.fromCharCode(0);
expect(await idsOf({ q: `계약${NUL}` })).toEqual([
'm-contract',
'm-renewal',
]);
expect(await idsOf({ q: `계${NUL}약`, subject: `회${NUL}의` })).toEqual(
[],
);
await setStatus('building');
try {
const res = await list({ q: `계${NUL}약` }).expect(200);
expect(dataOf<Page<ListItem>>(res).items).toEqual([]);
} finally {
await setStatus('ready');
}
});
it('검색어와 조건 칸 넷의 낱말은 합쳐 8개까지 — 넘으면 400 VAL_001 (읽기 화면도 같다 — 리뷰 M2)', async () => {
const words = (prefix: string, count: number) =>
Array.from({ length: count }, (_, index) => `${prefix}${index}`).join(
' ',
);
await list({ q: words('q', 4), to: words('t', 4) }).expect(200);
const overLimit: Record<string, string>[] = [
{ q: words('q', 4), to: words('t', 5) },
{ from: words('f', 3), subject: words('s', 3), body: words('b', 3) },
];
for (const params of overLimit) {
const res = await list(params).expect(400);
expect(body(res).error?.code).toBe('VAL_001');
}
const detail = await request(server)
.get(`/api/messages/${IMAGE_MESSAGE}`)
.query({ q: words('q', 5), body: words('b', 4) })
.set('Cookie', cookieOf(ME))
.expect(400);
expect(body(detail).error?.code).toBe('VAL_001');
});
it('판을 되돌리면(서버가 뜰 때) building · 엔진 방식이 되고, 다시 채울 때 첫 묶음이 옛 판 줄을 지운다 — 실제 PostgreSQL (리뷰 M1)', async () => {
const RESET = 'search.reset@e2e.arasystem.test';
await request(server)
.get('/api/search-index')
.set('Cookie', cookieOf(RESET))
.expect(200);
fakeIndexSource.mailboxes.set(RESET, {
state: 'r1',
emails: DOCUMENTS.map((document) => ({
...document,
id: `r-${document.id}`,
})),
});
try {
const build = app.get(MailSearchBuildService);
expect(await build.build(RESET, Date.now() + 120_000)).toBe('ready');
// 옛 판으로 만든 줄처럼 — 판을 내리고 엔진에 없는 줄도 하나 남긴다
await dataSource.query(
`UPDATE mail_search_states SET index_format = $2 WHERE user_email = $1`,
[RESET, INDEX_FORMAT - 1],
);
await dataSource.query(
`UPDATE mail_search_messages SET fold_body = 'old-format' WHERE user_email = $1`,
[RESET],
);
// 판이 다르면 되돌리기 전에도 상태는 building (검색은 엔진 방식)
expect(
dataOf(
await request(server)
.get('/api/search-index')
.set('Cookie', cookieOf(RESET))
.expect(200),
),
).toEqual({ status: 'building', done: 0, total: null });
const repository = app.get(MailSearchIndexRepository);
expect(
await repository.resetOutdatedFormats(
`@${TEST_MAIL_SETTINGS.domain}`,
INDEX_FORMAT,
new Date(),
),
).toBeGreaterThanOrEqual(1);
const [state]: {
status: string;
index_format: number;
build_started_at: Date | null;
}[] = await dataSource.query(
'SELECT status, index_format, build_started_at FROM mail_search_states WHERE user_email = $1',
[RESET],
);
expect(state).toEqual({
status: 'building',
index_format: INDEX_FORMAT,
build_started_at: null,
});
expect(await build.build(RESET, Date.now() + 120_000)).toBe('ready');
const rows: { email_id: string; fold_body: string }[] =
await dataSource.query(
'SELECT email_id, fold_body FROM mail_search_messages WHERE user_email = $1 ORDER BY email_id',
[RESET],
);
expect(rows.map((row) => row.email_id)).toEqual(
DOCUMENTS.map((document) => `r-${document.id}`).sort(),
);
expect(rows.filter((row) => row.fold_body === 'old-format')).toEqual(
[],
);
} finally {
fakeIndexSource.mailboxes.delete(RESET);
for (const table of ['mail_search_messages', 'mail_search_states']) {
await dataSource.query(
`DELETE FROM "${table}" WHERE user_email = $1`,
[RESET],
);
}
}
});
it('검색 칸의 검사 — 길이 · 낱말 수 · 기간 형식 · 첨부 값이 틀리면 400 VAL_001 (엔진 · 색인에 묻지 않는다)', async () => {
const before = summaryRequests.length;
const rejected: Record<string, string>[] = [
{ subject: '가'.repeat(MAX_SEARCH_LENGTH + 1) },
{
body: Array.from(
{ length: MAX_SEARCH_WORDS + 1 },
(_, index) => `w${index}`,
).join(' '),
},
{ after: '2026-11-01' },
{ before: '2026-11-01T00:00:00+09:00' },
{ hasAttachment: 'false' },
];
for (const params of rejected) {
const res = await list(params).expect(400);
expect(body(res).error?.code).toBe('VAL_001');
}
expect(summaryRequests.length).toBe(before);
});
describe('읽기 화면의 강조 (GET /api/messages/{id} 의 q · subject · body)', () => {
interface ReadDetail {
subject: string;
htmlBody: string | null;
textBody: string | null;
imagesShown: boolean;
searchHighlight: {
subject: [number, number][];
attachments: Record<string, [number, number][]>;
textBody: [number, number][];
} | null;
}
const read = (query: Record<string, string>) =>
request(server)
.get(`/api/messages/${IMAGE_MESSAGE}`)
.query(query)
.set('Cookie', cookieOf(ME));
it('검색 쿼리가 없으면 searchHighlight 는 null', async () => {
const detail = dataOf<ReadDetail>(await read({}).expect(200));
expect(detail.searchHighlight).toBeNull();
expect(detail.htmlBody).not.toContain('<mark>');
});
it('검색어 — 본문의 글에 <mark>(정화한 뒤 — 태그 · 속성은 그대로) · textBody 의 위치, 보낸 사람 조건은 강조하지 않는다', async () => {
const detail = dataOf<ReadDetail>(
await read({ q: '안녕', from: '바깥' }).expect(200),
);
expect(detail.searchHighlight).toEqual({
subject: [],
attachments: {},
textBody: [[0, 2]],
});
expect(detail.htmlBody).toContain('<p><mark>안녕</mark>하세요</p>');
expect(detail.htmlBody).not.toContain('<mark>로고');
});
it('images=show 와 함께 — 중계 주소로 바꾼 본문에 강조, 제목 조건은 제목에', async () => {
const detail = dataOf<ReadDetail>(
await read({ images: 'show', subject: '그림', q: '안녕' }).expect(
200,
),
);
expect(detail.imagesShown).toBe(true);
expect(detail.htmlBody).toContain('/api/mail-images/');
expect(detail.htmlBody).toContain('<mark>안녕</mark>');
expect(detail.searchHighlight?.subject).toEqual([[0, 2]]);
});
it('검색 칸은 목록과 같은 검사 · 목록에만 있는 조건(after · hasAttachment)은 받지 않는다 — 400 VAL_001', async () => {
const rejected: Record<string, string>[] = [
{ q: '가'.repeat(MAX_SEARCH_LENGTH + 1) },
{ after: '2026-11-01T00:00:00.000Z' },
{ before: '2026-11-30T15:00:00.000Z' },
{ hasAttachment: 'true' },
{ cursor: 'abc' },
{ limit: '20' },
{ mailboxId: 'inbox' },
];
for (const query of rejected) {
const res = await read(query).expect(400);
expect(body(res).error?.code).toBe('VAL_001');
}
});
});
});
describe('13단계 — 제한된 세션 · 2단계 인증 필수 · 문의 연락처', () => {
let systemSettings: SystemSettingsService;
/** 시작 전 설정 — 끝나면 되돌린다(컨테이너 안에서 돌리면 개발 DB 다). 준비가 실패했으면 없다 */
+7 -2
View File
@@ -18,8 +18,10 @@ export const FOLD_SAMPLES: readonly (readonly [
['ABC123!', 'abc123!'],
['RE:회의', 're:회의'],
['[공지]', '[공지]'],
// 전각 공백(U+3000)은 바꾸지 않는다 — 공백은 위치를 지키려고 그대로 둔다
['회의\u3000일정', '회의\u3000일정'],
// 공백류(전각 공백 · 줄바꿈 · 탭 · NBSP · CR)는 띄어쓰기 하나로 1:1 (색인 판 2) — 이어진 공백을 줄이지는 않는다(위치를 지킨다)
['회의\u3000일정', '회의 일정'],
['계약\n갱신\t안내\u00a0끝', '계약 갱신 안내 끝'],
['a\r\n\nb', 'a b'],
// 소문자가 코드 포인트 하나 · 같은 길이일 때만 — 'İ'(U+0130)는 'i̇' 두 글자가 되므로 그대로
['İstanbul', 'İstanbul'],
['ÀÉÎÕÜ', 'àéîõü'],
@@ -84,6 +86,9 @@ export const MATCH_SAMPLES: readonly (readonly [
[3, 5],
],
],
// 구절은 줄바꿈 · NBSP 로 갈린 글에도 맞는다 (공백류 → 띄어쓰기 1:1) — 위치는 원문 그대로
['계약\n갱신 안내', ['계약 갱신'], [[0, 5]]],
['계약\u00a0갱신', ['계약 갱신'], [[0, 5]]],
// 구절은 띄어쓰기까지 그대로
['회의 일정 안내', ['회의 일정'], [[0, 5]]],
['회의 일정', ['회의 일정'], []],