Files
connectai/tests/naverTrend.test.ts
koriwebandClaude Opus 5 b73f1f48b3 v2.2.324: /naver-trend "<분야> <기간>" 질문이 원문을 통째로 키워드로 조회하던 버그 수정
실사례: `/naver-trend 경제 8/11~8/12` → 키워드 칸에 "경제 8/11~8/12" 가 그대로
박힌 ratio 0.0 짜리 1행 표. 기대는 그 기간에 경제 분야에서 많이 검색된 키워드 목록.

원인 1 — 질문 원문 폴백: parseSearchTrendIntent 가 LLM 후보(keywords)가 비면
질문 원문을 키워드로 채웠다. 기간 표현까지 붙은 문자열이라 검색량이 잡힐 리 없다.
후보가 비면 빈 채로 두고, handlers 가 topic 기반 후보 생성으로 복구하게 변경.
isPlausibleKeyword 에 기간 표현 차단(8/11~8/12, 8월 11일, 최근 일주일, 지난 30일)
추가 + 질문 원문·기간 제거 잔여 텍스트와 일치하는 후보 제거.

원인 2 — 분류 누락: 프롬프트가 "<분야> <기간>" 형태를 어느 모드로도 안내하지
않아 LLM 이 contains(포함단어 없음)로 찍고 discover 로 강등되며 topic 까지 버려졌다.
"분야명 + 기간만 있는 질문 = discover" 를 프롬프트에 명시하고, 강등 시에도 topic 을
살려 후보 생성에 쓴다.

- runContainsMode → runTopicMode 일반화: 포함단어를 선택 인자로 바꿔 "<분야> <기간>"
  도 같은 2단 구조(LLM 후보 생성 → 실제 API 검증·앵커 보정 랭킹)를 타게 했다.
  분야명 자체는 후보에서 제외(상위 개념이라 늘 1위를 먹고 정보 가치 0).
- generateContainingKeywords → generateKeywordCandidates(ai, topic, word?, count)
  통합, 포함단어 없을 때 전용 프롬프트 사용.
- 비교는 대상 2개 이상일 때만: 키워드 1개짜리 compare 는 표가 늘 100.0 1행이라
  무의미 — 그 검색어를 분야로 보고 인기 검색어를 뽑는다.
- graceful degradation: AI 가 죽어도 분야명 자체 추이는 보여주고 실패를 명시.
  결과가 전부 0 이고 조회 구간에 오늘이 포함되면 "데이터랩 당일 집계 미완" 안내.

검증: tsc 무오류 + jest 1055 통과(신규 12 — 슬래시 라우팅부터 끝까지 태우는
e2e 회귀 3건 포함) + esbuild/vsce 패키징 정상.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 22:04:06 +09:00

483 lines
25 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* /naver-shopping, /naver-trend 순수 로직 테스트.
* 실제 네이버 API 호출은 통합 영역이라 제외 — 카테고리 매칭 + intent 파서(tolerant
* JSON 파싱, AI 호출 mock)만 고정.
*/
jest.mock('../src/features/naverTrend/naverApi', () => ({
fetchSearchTrend: jest.fn(),
fetchShoppingCategoryTrend: jest.fn(),
}));
/** handlers.ts 가 `new AIService()` 로 직접 만들어 쓰므로 모듈째 교체 — 큐에서 순서대로 꺼내 응답. */
const mockAiResponses: string[] = [];
jest.mock('../src/core/services', () => ({
AIService: class {
async call() { return mockAiResponses.shift() ?? ''; }
async chat() {
const content = mockAiResponses.shift() ?? '';
return { content, engine: 'mock', model: 'mock', empty: !content };
}
},
}));
import { findCategoryCode, ROOT_CATEGORY_CODES } from '../src/features/naverTrend/categoryCodes';
import {
parseShoppingIntent, parseSearchTrendIntent, generateKeywordCandidates,
parseContainsShorthand, keywordContains,
} from '../src/features/naverTrend/intentParser';
import { parseKoreanDateRange, stripDateExpression, ymdLocal } from '../src/features/naverTrend/dateRange';
import { isPlausibleKeyword } from '../src/features/naverTrend/handlers';
import { listSlashCommands } from '../src/features/datacollect/slashRouter';
import { rankKeywordsAnchored } from '../src/features/naverTrend/rankEngine';
import { fetchSearchTrend } from '../src/features/naverTrend/naverApi';
import type { IAIService, AIChatResult } from '../src/core/services';
import * as esbuild from 'esbuild';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
function mockAi(content: string, shouldThrow = false): IAIService {
return {
call: async () => content,
chat: async (): Promise<AIChatResult> => {
if (shouldThrow) throw new Error('AI 호출 실패(mock)');
return { content, engine: 'lmstudio', model: 'mock', empty: false };
},
};
}
describe('findCategoryCode — 카테고리 이름/코드 매칭', () => {
test('검증된 대분류 루트 코드 정확 매치', () => {
const m = findCategoryCode('패션의류');
expect(m[0]).toMatchObject({ code: ROOT_CATEGORY_CODES['패션의류'], source: 'root' });
});
test('부분 일치도 루트 코드로 매치', () => {
const m = findCategoryCode('화장품');
expect(m[0]).toMatchObject({ code: '50000002', source: 'root' });
});
test('숫자만 입력하면 raw cat_id 로 그대로 통과', () => {
const m = findCategoryCode('50012345');
expect(m).toEqual([{ name: '50012345', path: '50012345', code: '50012345', source: 'raw' }]);
});
test('트리에 있는 세부 카테고리명은 tree 매치로 코드 반환', () => {
const m = findCategoryCode('원피스');
expect(m.length).toBeGreaterThan(0);
expect(m[0].source).toBe('tree');
expect(m[0].code).toMatch(/^\d+$/);
});
test('빈 문자열은 빈 배열', () => {
expect(findCategoryCode('')).toEqual([]);
});
test('매칭 안 되는 이름은 빈 배열', () => {
expect(findCategoryCode('존재하지않는분야이름절대매칭안됨xyz')).toEqual([]);
});
});
describe('parseShoppingIntent — tolerant JSON 파싱', () => {
test('정상 JSON', async () => {
const ai = mockAi('{"mode":"compare","categories":["패션의류","화장품/미용"],"startDate":"","endDate":"","timeUnit":"","device":"","gender":"","ages":[]}');
const r = await parseShoppingIntent(ai, '패션의류랑 화장품 비교해줘', '2026-08-04');
expect(r.parsed).toBe(true);
expect(r.mode).toBe('compare');
expect(r.categories).toEqual(['패션의류', '화장품/미용']);
});
test('코드펜스로 감싼 JSON', async () => {
const ai = mockAi('설명입니다\n```json\n{"mode":"discover","categories":["뷰티","디지털/가전"],"startDate":"","endDate":"","timeUnit":"","device":"","gender":"","ages":[]}\n```');
const r = await parseShoppingIntent(ai, '요즘 뜨는 카테고리', '2026-08-04');
expect(r.parsed).toBe(true);
expect(r.mode).toBe('discover');
expect(r.categories).toEqual(['뷰티', '디지털/가전']);
});
test('완전히 깨진 응답 → compare 모드로 원문 폴백', async () => {
const ai = mockAi('이건 JSON이 아닙니다 그냥 텍스트');
const r = await parseShoppingIntent(ai, '패션의류 트렌드', '2026-08-04');
expect(r.parsed).toBe(false);
expect(r.mode).toBe('compare');
expect(r.categories).toEqual(['패션의류 트렌드']);
});
test('AI 호출 자체가 실패해도 안전한 기본값으로 폴백(예외 전파 안 함)', async () => {
const ai = mockAi('', true);
const r = await parseShoppingIntent(ai, '패션의류 트렌드', '2026-08-04');
expect(r.parsed).toBe(false);
expect(r.mode).toBe('compare');
expect(r.categories).toEqual(['패션의류 트렌드']);
});
test('scope="keywords" — 분야 자체가 아니라 분야 "안"의 인기 검색어를 묻는 질문', async () => {
const ai = mockAi('{"scope":"keywords","mode":"discover","categories":["디지털/가전"],"keywords":["무선이어폰","보조배터리","블루투스스피커"],"startDate":"","endDate":"","timeUnit":"","device":"","gender":"","ages":[]}');
const r = await parseShoppingIntent(ai, '디지털/가전 인기 검색어', '2026-08-04');
expect(r.parsed).toBe(true);
expect(r.scope).toBe('keywords');
expect(r.categories).toEqual(['디지털/가전']);
expect(r.keywords).toEqual(['무선이어폰', '보조배터리', '블루투스스피커']);
});
test('scope 미명시 응답은 기존 동작과 동일하게 category 로 기본값 처리', async () => {
const ai = mockAi('{"mode":"compare","categories":["패션의류"],"startDate":"","endDate":"","timeUnit":"","device":"","gender":"","ages":[]}');
const r = await parseShoppingIntent(ai, '패션의류 트렌드', '2026-08-04');
expect(r.scope).toBe('category');
expect(r.keywords).toEqual([]);
});
});
describe('parseSearchTrendIntent — tolerant JSON 파싱', () => {
test('정상 JSON', async () => {
const ai = mockAi('{"mode":"compare","keywords":["원피스","블라우스"],"startDate":"","endDate":"","timeUnit":"","device":"","gender":"","ages":[]}');
const r = await parseSearchTrendIntent(ai, '원피스랑 블라우스 비교', '2026-08-04');
expect(r.parsed).toBe(true);
expect(r.keywords).toEqual(['원피스', '블라우스']);
});
test('balanced-brace 추출 — 앞뒤에 잡텍스트가 섞여도 파싱', async () => {
const ai = mockAi('물론이죠! {"mode":"discover","keywords":["여름 원피스","린넨 원피스"],"startDate":"","endDate":"","timeUnit":"","device":"","gender":"","ages":[]} 이상입니다.');
const r = await parseSearchTrendIntent(ai, '20대 여성 원피스 인기 키워드', '2026-08-04');
expect(r.parsed).toBe(true);
expect(r.mode).toBe('discover');
expect(r.keywords).toEqual(['여름 원피스', '린넨 원피스']);
});
test('빈 응답 → compare 모드로 원문 폴백', async () => {
const ai = mockAi('');
const r = await parseSearchTrendIntent(ai, '원피스 키워드', '2026-08-04');
expect(r.parsed).toBe(false);
expect(r.mode).toBe('compare');
expect(r.keywords).toEqual(['원피스 키워드']);
});
});
describe('parseKoreanDateRange — 기간 표현 결정적 파싱', () => {
const TODAY = new Date(2026, 7, 11); // 2026-08-11
test('"8월1일부터 오늘까지"', () => {
const r = parseKoreanDateRange('경제 방법 8월1일부터 오늘까지', TODAY);
expect(r).toMatchObject({ startDate: '2026-08-01', endDate: '2026-08-11' });
});
test('matchedText 를 걷어내면 분야/포함단어만 남는다', () => {
const text = '경제 방법 8월1일부터 오늘까지';
const r = parseKoreanDateRange(text, TODAY)!;
expect(text.replace(r.matchedText, ' ').trim().split(/\s+/)).toEqual(['경제', '방법']);
});
test('"8/1~8/10" 슬래시 표기', () => {
const r = parseKoreanDateRange('8/1~8/10', TODAY);
expect(r).toMatchObject({ startDate: '2026-08-01', endDate: '2026-08-10' });
});
test('연도를 밝힌 표현', () => {
const r = parseKoreanDateRange('2026년 8월 1일부터 8월 10일까지', TODAY);
expect(r).toMatchObject({ startDate: '2026-08-01', endDate: '2026-08-10' });
});
test('연도 없는 미래 날짜는 작년으로 해석 — 8월 기준 "12월 1일"은 지난 12월', () => {
const r = parseKoreanDateRange('12월 1일부터', TODAY);
expect(r).toMatchObject({ startDate: '2025-12-01', endDate: '2026-08-11' });
});
test('상대 기간 — "최근 일주일"은 오늘 포함 7일', () => {
expect(parseKoreanDateRange('최근 일주일', TODAY)).toMatchObject({ startDate: '2026-08-05', endDate: '2026-08-11' });
});
test('상대 기간 — "지난 30일"', () => {
expect(parseKoreanDateRange('지난 30일', TODAY)).toMatchObject({ startDate: '2026-07-13', endDate: '2026-08-11' });
});
test('상대 기간 — 한글 수사 "최근 한 달"', () => {
expect(parseKoreanDateRange('최근 한 달', TODAY)).toMatchObject({ startDate: '2026-07-12', endDate: '2026-08-11' });
});
test('기간 표현이 없으면 null', () => {
expect(parseKoreanDateRange('경제 방법 알려줘', TODAY)).toBeNull();
});
test('두 날짜 사이에 범위 연결어가 없으면 기간으로 오독하지 않는다', () => {
expect(parseKoreanDateRange('8월 1일 방법 8월 10일 후기', TODAY)).toBeNull();
});
test('버전 문자열의 점 표기를 날짜로 오독하지 않는다', () => {
expect(parseKoreanDateRange('2.2.322 릴리즈 노트', TODAY)).toBeNull();
});
test('ymdLocal 은 UTC 로 밀리지 않는다 — KST 새벽 0시 30분', () => {
expect(ymdLocal(new Date(2026, 7, 11, 0, 30))).toBe('2026-08-11');
});
});
describe('contains 모드 — 분야 + 포함단어', () => {
test('keywordContains — 공백 무시, 단어 자체만인 후보는 제외', () => {
expect(keywordContains('재테크 방법', '방법')).toBe(true);
expect(keywordContains('재테크방법', '방법')).toBe(true);
expect(keywordContains('주식 투자', '방법')).toBe(false);
expect(keywordContains('방법', '방법')).toBe(false);
});
test('parseContainsShorthand — 두 덩어리일 때만 성립', () => {
expect(parseContainsShorthand(' 경제 방법 ')).toEqual({ topic: '경제', containsWord: '방법' });
expect(parseContainsShorthand('경제')).toBeNull();
expect(parseContainsShorthand('경제 방법 추천')).toBeNull();
});
test('parseSearchTrendIntent — mode="contains" 파싱', async () => {
const ai = mockAi('{"mode":"contains","topic":"경제","containsWord":"방법","limit":10,"keywords":["재테크 방법","돈 모으는 방법"],"startDate":"2026-08-01","endDate":"2026-08-11","timeUnit":"","device":"","gender":"","ages":[]}');
const r = await parseSearchTrendIntent(ai, '경제 방법 8월1일부터 오늘까지', '2026-08-11');
expect(r.mode).toBe('contains');
expect(r.topic).toBe('경제');
expect(r.containsWord).toBe('방법');
expect(r.limit).toBe(10);
expect(r.keywords).toEqual(['재테크 방법', '돈 모으는 방법']);
});
test('contains 인데 포함 단어가 비면 discover 로 강등 — 필터 없이 조회되는 사고 방지', async () => {
const ai = mockAi('{"mode":"contains","topic":"경제","containsWord":"","keywords":["재테크"],"startDate":"","endDate":"","timeUnit":"","device":"","gender":"","ages":[]}');
const r = await parseSearchTrendIntent(ai, '경제 트렌드', '2026-08-11');
expect(r.mode).toBe('discover');
expect(r.containsWord).toBeUndefined();
});
test('generateKeywordCandidates — 포함 단어가 빠진 후보는 코드에서 걸러낸다', async () => {
const ai = mockAi('{"keywords":["재테크 방법","주식 투자","절세 방법"]}');
const out = await generateKeywordCandidates(ai, '경제', '방법', 30);
expect(out).toEqual(['재테크 방법', '절세 방법']);
});
test('generateKeywordCandidates — 포함 단어가 없으면(분야만) 필터 없이 전부 후보', async () => {
const ai = mockAi('{"keywords":["금리 인상","환율 전망","코스피 전망"]}');
const out = await generateKeywordCandidates(ai, '경제', undefined, 30);
expect(out).toEqual(['금리 인상', '환율 전망', '코스피 전망']);
});
test('generateKeywordCandidates — AI 실패해도 예외 전파 없이 빈 배열', async () => {
const out = await generateKeywordCandidates(mockAi('', true), '경제', '방법', 30);
expect(out).toEqual([]);
});
});
// v2.2.324 회귀: `/naver-trend 경제 8/11~8/12` 가 "경제 8/11~8/12" 를 통째로 키워드로
// 조회해 ratio 0.0 짜리 1행 표를 내던 문제. 원인은 두 갈래 — (1) LLM 이 후보를 못 주면
// 질문 원문을 키워드로 쓰던 폴백, (2) "<분야> <기간>" 질문이 분야 탐색으로 분류되지
// 않던 것.
describe('“<분야> <기간>” 질문 — 질문 원문이 키워드로 새지 않는다', () => {
test('후보가 비어도 질문 원문으로 메우지 않는다(handlers 가 topic 으로 복구)', async () => {
const ai = mockAi('{"mode":"discover","topic":"경제","containsWord":"","limit":0,"keywords":[],"startDate":"","endDate":"","timeUnit":"","device":"","gender":"","ages":[]}');
const r = await parseSearchTrendIntent(ai, '경제 8/11~8/12', '2026-08-12');
expect(r.mode).toBe('discover');
expect(r.keywords).toEqual([]);
expect(r.topic).toBe('경제');
});
test('contains 강등(포함 단어 없음) 시에도 topic 은 살아남는다', async () => {
const ai = mockAi('{"mode":"contains","topic":"경제","containsWord":"","limit":0,"keywords":[],"startDate":"","endDate":"","timeUnit":"","device":"","gender":"","ages":[]}');
const r = await parseSearchTrendIntent(ai, '경제 8/11~8/12', '2026-08-12');
expect(r.mode).toBe('discover');
expect(r.containsWord).toBeUndefined();
expect(r.topic).toBe('경제');
});
test('stripDateExpression — 기간 표현만 걷어내고 분야만 남긴다', () => {
const today = new Date(2026, 7, 12);
const text = '경제 8/11~8/12';
expect(stripDateExpression(text, parseKoreanDateRange(text, today))).toBe('경제');
});
test('stripDateExpression — 전각 물결표(~)로 써도 걷어낸다', () => {
const today = new Date(2026, 7, 12);
const text = '경제 8/11~8/12';
expect(stripDateExpression(text, parseKoreanDateRange(text, today))).toBe('경제');
});
test('stripDateExpression — 기간 표현이 없으면 원문 그대로(공백만 정리)', () => {
expect(stripDateExpression(' 경제 트렌드 ', null)).toBe('경제 트렌드');
});
test('isPlausibleKeyword — 기간 표현이 붙은 조각은 검색어 후보가 아니다', () => {
expect(isPlausibleKeyword('경제 8/11~8/12')).toBe(false);
expect(isPlausibleKeyword('경제 8월 11일')).toBe(false);
expect(isPlausibleKeyword('경제 최근 일주일')).toBe(false);
expect(isPlausibleKeyword('경제 지난 30일')).toBe(false);
});
test('isPlausibleKeyword — 숫자가 든 실제 검색어는 살린다', () => {
expect(isPlausibleKeyword('금리 인상')).toBe(true);
expect(isPlausibleKeyword('아이폰 17 프로')).toBe(true);
expect(isPlausibleKeyword('맥북 에어 M3')).toBe(true);
expect(isPlausibleKeyword('갤럭시 Z 플립7')).toBe(true);
});
});
// 라우팅까지 포함한 end-to-end — 실제 리포트된 입력(`/naver-trend 경제 8/11~8/12`)이
// 표에 질문 원문 대신 진짜 검색어를 채우는지 고정한다. 유닛 테스트만으로는 handlers
// 의 모드 분기(이번 버그의 실제 발생 지점)를 못 잡는다.
describe('/naver-trend 라우팅 — "경제 8/11~8/12"', () => {
const mockFetch = fetchSearchTrend as unknown as jest.Mock;
const runNaverTrend = async (arg: string): Promise<string> => {
const def = listSlashCommands().find(c => c.name === '/naver-trend');
expect(def).toBeDefined();
const out: string[] = [];
const view = { postMessage: (m: any) => { out.push(String(m?.value ?? '')); } };
await def!.handler(arg, view as any, {} as any);
return out.join('');
};
beforeEach(() => {
mockAiResponses.length = 0;
mockFetch.mockReset();
mockFetch.mockImplementation(async (_ctx: unknown, body: any) => ({
ok: true,
data: {
startDate: body.startDate, endDate: body.endDate, timeUnit: body.timeUnit,
results: body.keywordGroups.map((g: any, i: number) => ({
title: g.groupName,
keywords: g.keywords,
data: [{ period: body.startDate, ratio: 100 - i * 10 }],
})),
},
}));
});
test('분야 + 기간 → 그 분야 인기 검색어 표(질문 원문이 키워드로 들어가지 않는다)', async () => {
// 1) intent 판정: 분야만 잡히고 후보는 비어서 돌아온 실제 실패 형태.
mockAiResponses.push('{"mode":"discover","topic":"경제","containsWord":"","limit":0,"keywords":[],"startDate":"","endDate":"","timeUnit":"","device":"","gender":"","ages":[]}');
// 2) 후보 보충.
mockAiResponses.push('{"keywords":["금리 인상","환율 전망","코스피 전망","적금 금리","연말정산 환급"]}');
const out = await runNaverTrend('경제 8/11~8/12');
expect(out).toContain('금리 인상');
expect(out).toContain('"경제" 분야 인기 검색어 TOP');
// 회귀 지점: 질문 원문이 키워드 칸에 들어가면 안 된다.
expect(out).not.toContain('| 경제 8/11~8/12 |');
const queried = mockFetch.mock.calls.flatMap((c: any[]) => c[1].keywordGroups.map((g: any) => g.groupName));
expect(queried).not.toContain('경제 8/11~8/12');
expect(queried).toContain('환율 전망');
// 기간은 규칙 파서가 확정한 값 그대로.
expect(mockFetch.mock.calls[0][1]).toMatchObject({ startDate: '2026-08-11', endDate: '2026-08-12' });
});
test('AI 가 죽어도 질문 원문을 조회하지 않는다 — 분야명만 조회하고 실패를 밝힌다', async () => {
// 응답 큐가 비어 있음 = 두 번의 AI 호출이 모두 빈 응답.
const out = await runNaverTrend('경제 8/11~8/12');
const queried = mockFetch.mock.calls.flatMap((c: any[]) => c[1].keywordGroups.map((g: any) => g.groupName));
expect(queried).toEqual(['경제']);
expect(out).toContain('검색어 후보 생성에 실패');
});
test('키워드 2개 이상 비교는 그대로 비교 표', async () => {
mockAiResponses.push('{"mode":"compare","topic":"","containsWord":"","limit":0,"keywords":["원피스","블라우스"],"startDate":"","endDate":"","timeUnit":"","device":"","gender":"","ages":[]}');
const out = await runNaverTrend('원피스랑 블라우스 비교 8/11~8/12');
expect(out).toContain('키워드 비교');
const queried = mockFetch.mock.calls.flatMap((c: any[]) => c[1].keywordGroups.map((g: any) => g.groupName));
expect(queried).toEqual(['원피스', '블라우스']);
});
});
// 검색어트렌드 ratio 는 "요청 하나" 안에서 최댓값을 100 으로 두는 상대 지수라,
// 5개씩 끊어 호출하면 배치마다 기준이 달라진다. 후보 30개를 검색량순으로 줄
// 세우려면 배치마다 공통 앵커를 끼워 되돌려야 한다는 걸 고정한다.
describe('rankKeywordsAnchored — 배치 경계를 넘는 순위 보정', () => {
// 실제 검색량(테스트 기준값). 네이버가 하듯 "요청 안에서 최댓값=100" 으로 정규화해 응답한다.
const VOLUME: Record<string, number> = {
k1: 100, k2: 50, k3: 30, k4: 20, k5: 10,
k6: 400, k7: 300, k8: 5, k9: 1,
};
const mockFetch = fetchSearchTrend as unknown as jest.Mock;
beforeEach(() => {
mockFetch.mockReset();
mockFetch.mockImplementation(async (_ctx: unknown, body: any) => {
const names: string[] = body.keywordGroups.map((g: any) => g.groupName);
const max = Math.max(...names.map(n => VOLUME[n]));
return {
ok: true,
data: {
startDate: body.startDate, endDate: body.endDate, timeUnit: body.timeUnit,
results: names.map(n => ({
title: n,
keywords: [n],
data: [
{ period: '2026-08-01', ratio: (VOLUME[n] / max) * 100 },
{ period: '2026-08-02', ratio: (VOLUME[n] / max) * 100 },
],
})),
},
};
});
});
const RANGE = { startDate: '2026-08-01', endDate: '2026-08-11', timeUnit: 'date' as const };
test('2배치에 걸친 9개 후보가 실제 검색량 순서로 정렬된다', async () => {
const r = await rankKeywordsAnchored({} as any, Object.keys(VOLUME), RANGE, {});
expect(r.items.map(i => i.name)).toEqual(['k6', 'k7', 'k1', 'k2', 'k3', 'k4', 'k5', 'k8', 'k9']);
expect(r.errors).toEqual([]);
});
test('1위를 100 으로 재정규화하고, 비율이 실제 검색량 비와 일치한다', async () => {
const r = await rankKeywordsAnchored({} as any, Object.keys(VOLUME), RANGE, {});
expect(r.items[0].avgRatio).toBeCloseTo(100, 5);
// k7(300) / k6(400) = 0.75
expect(r.items[1].avgRatio).toBeCloseTo(75, 5);
// k1(100) / k6(400) = 0.25 — 앵커 보정이 없으면 k1 이 100 으로 남아 1위가 된다.
expect(r.items[2].avgRatio).toBeCloseTo(25, 5);
});
test('두 번째 배치부터는 앵커 키워드가 함께 조회된다', async () => {
await rankKeywordsAnchored({} as any, Object.keys(VOLUME), RANGE, {});
const secondCall = mockFetch.mock.calls[1][1];
expect(secondCall.keywordGroups.map((g: any) => g.groupName)).toEqual(['k6', 'k7', 'k8', 'k9', 'k1']);
});
test('첫 배치가 실패하면 보정 없이라도 결과를 내고 경고를 남긴다', async () => {
mockFetch.mockImplementationOnce(async () => ({ ok: false, error: 'Naver API 500' }));
const r = await rankKeywordsAnchored({} as any, Object.keys(VOLUME), RANGE, {});
expect(r.items.length).toBeGreaterThan(0);
expect(r.errors.some(e => e.includes('배치 간 보정 없이'))).toBe(true);
});
test('후보가 없으면 빈 결과 — API 호출도 하지 않는다', async () => {
const r = await rankKeywordsAnchored({} as any, [], RANGE, {});
expect(r.items).toEqual([]);
expect(mockFetch).not.toHaveBeenCalled();
});
});
// v2.2.319 회귀: `import * as x from './data.json'`(namespace import) 를 esbuild 로
// 번들하면 배열 최상위 JSON 이 `{ default: [...] }` 로 감싸져 x 자체가 배열이 아니게
// 된다. ts-jest 는 require() 를 그대로 써서 이 버그가 안 보여 통과했지만 실제 배포
// 번들(esbuild)에서 "CATEGORY_TREE is not iterable" 로 터졌다. default import 로
// 고친 뒤에도 재발 안 하도록 실제 esbuild 번들 결과를 실행해 검증한다.
describe('categoryCodes.ts — esbuild 번들 회귀 방지', () => {
test('esbuild 로 번들링한 결과에서도 카테고리 트리가 정상 로드됨', () => {
const entry = path.join(__dirname, '../src/features/naverTrend/categoryCodes.ts');
const result = esbuild.buildSync({
entryPoints: [entry],
bundle: true,
platform: 'node',
format: 'cjs',
write: false,
});
const tmpFile = path.join(os.tmpdir(), `categoryCodes.bundle.${process.pid}.${Date.now()}.js`);
fs.writeFileSync(tmpFile, result.outputFiles[0].text);
try {
// eslint-disable-next-line @typescript-eslint/no-var-requires
const mod = require(tmpFile);
const names = mod.listTopLevelCategoryNames();
expect(Array.isArray(names)).toBe(true);
expect(names.length).toBeGreaterThan(5);
expect(mod.findCategoryCode('패션의류')[0]).toMatchObject({ code: '50000000', source: 'root' });
} finally {
fs.unlinkSync(tmpFile);
}
});
});