실사례: `/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>
483 lines
25 KiB
TypeScript
483 lines
25 KiB
TypeScript
/**
|
||
* /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);
|
||
}
|
||
});
|
||
});
|