// model-search.ts 순수함수 단위 테스트 (2026-09-23) // 네트워크는 건드리지 않는다 — 사이트 클라이언트의 응답 매핑/파싱만 검증. // 픽스처는 2026-09-23 실측 curl 응답(축약)을 그대로 쓴다. import test from 'node:test'; import assert from 'node:assert/strict'; import { parseModelRef, resolveSitePlan, mapThingiverseResults, mapPrintablesResults, mapMakerWorldResults, formatModelResults, downloadErrorDetail, pickSlicableFile, } from '../src/tools/model-search.js'; test('parseModelRef: 숫자 id 그대로 통과', () => { assert.deepEqual(parseModelRef('printables', '1138497'), { id: '1138497' }); assert.deepEqual(parseModelRef('thingiverse', '2473'), { id: '2473' }); }); test('parseModelRef: 모델 페이지 URL 해석 (3 사이트)', () => { assert.deepEqual(parseModelRef('printables', 'https://www.printables.com/model/1138497-mecanum-wheel'), { id: '1138497' }); assert.deepEqual(parseModelRef('printables', 'printables.com/print/952992'), { id: '952992' }); assert.deepEqual(parseModelRef('makerworld', 'https://makerworld.com/en/models/716411'), { id: '716411' }); assert.deepEqual(parseModelRef('thingiverse', 'https://www.thingiverse.com/thing:2473'), { id: '2473' }); assert.deepEqual(parseModelRef('thingiverse', 'thingiverse.com/things/2473'), { id: '2473' }); }); test('parseModelRef: 가비지는 에러', () => { assert.ok('error' in parseModelRef('printables', '')); assert.ok('error' in parseModelRef('printables', 'https://example.com/thing/123')); assert.ok('error' in parseModelRef('makerworld', 'not-a-url')); }); test('resolveSitePlan: Thingiverse는 토큰 없으면 스킵, 나머지는 익명 가능', () => { const plan = resolveSitePlan({}, {}); assert.deepEqual(plan.attempt, ['printables', 'makerworld']); assert.equal(plan.skipped.length, 1); assert.equal(plan.skipped[0].site, 'thingiverse'); assert.ok(plan.skipped[0].reason.includes('thingiverse_token')); }); test('resolveSitePlan: 토큰 있으면 3개 전부 시도', () => { const plan = resolveSitePlan({}, { thingiverse: 'tok' }); assert.deepEqual(plan.attempt, ['thingiverse', 'printables', 'makerworld']); assert.equal(plan.skipped.length, 0); }); test('resolveSitePlan: site 지정이 그 사이트만', () => { const plan = resolveSitePlan({ site: 'printables' }, {}); assert.deepEqual(plan.attempt, ['printables']); // 지정 사이트에 토큰이 필요한데 없으면 전부 스킵 const plan2 = resolveSitePlan({ site: 'thingiverse' }, {}); assert.deepEqual(plan2.attempt, []); assert.equal(plan2.skipped[0].site, 'thingiverse'); }); test('mapThingiverseResults: 실측 응답(축약) 매핑', () => { const mapped = mapThingiverseResults({ total: 221, hits: [{ id: 2473, name: 'Mecanum Wheel MK2', like_count: 1323, public_url: 'https://www.thingiverse.com/thing:2473', creator: { name: 'Zaggo' }, }], }); assert.equal(mapped.length, 1); assert.equal(mapped[0].site, 'thingiverse'); assert.equal(mapped[0].id, '2473'); assert.equal(mapped[0].likes, 1323); assert.equal(mapped[0].author, 'Zaggo'); assert.equal(mapped[0].url, 'https://www.thingiverse.com/thing:2473'); }); test('mapPrintablesResults: 실측 응답 매핑 + 빈 필드 허용', () => { const mapped = mapPrintablesResults({ items: [ { id: '1138497', name: 'Mecanum Wheel', slug: 'mecanum-wheel', likesCount: 104, downloadCount: 366, filesCount: 6, user: { publicUsername: 'Evolv3D' } }, { id: '952992', name: 'x', user: {} }, ], }); assert.equal(mapped.length, 2); assert.equal(mapped[0].fileCount, 6); assert.equal(mapped[1].likes, 0); assert.equal(mapped[1].author, ''); }); test('mapMakerWorldResults: 실측 응답 매핑', () => { const mapped = mapMakerWorldResults({ hits: [{ id: 716411, title: 'Omnidirectional Ball Caster Wheel', likeCount: 1591, downloadCount: 2521, designCreator: { name: 'Claymuh' } }], }); assert.equal(mapped[0].id, '716411'); assert.equal(mapped[0].site, 'makerworld'); assert.equal(mapped[0].likes, 1591); }); test('formatModelResults: 스킵 진단 라인 포함', () => { const out = formatModelResults([], [{ site: 'thingiverse', reason: '토큰 미설정' }], 'mecanum wheel'); assert.ok(out.includes('스킵됨: thingiverse')); assert.ok(out.includes('영문')); }); test('downloadErrorDetail: 사이트별 토큰 만료 안내', () => { assert.ok(downloadErrorDetail('thingiverse', 401, '').includes('thingiverse_token')); assert.ok(downloadErrorDetail('makerworld', 401, '').includes('90일')); assert.ok(downloadErrorDetail('makerworld', 418, '').includes('캡차')); assert.ok(downloadErrorDetail('printables', 403, '').includes('printables_cookie')); assert.ok(downloadErrorDetail('printables', 500, 'oops').includes('HTTP 500')); }); test('pickSlicableFile: stl/3mf만, 큰 것 우선, .stp 배제', () => { // 09-23 실측: Printables stls 리스트에 .stp 파일이 섞여 온다 const pick = pickSlicableFile([ { name: 'mecanum-wheel-Part1.stp', size: 189000 }, { name: 'mecanum-wheel-Part2.stp', size: 10699 }, { name: 'small.stl', size: 5000 }, { name: 'big.3mf', size: 9000 }, { name: 'biggest.stl', size: 12000 }, ]); assert.equal(pick?.name, 'biggest.stl'); assert.equal(pickSlicableFile([{ name: 'a.stp', size: 100 }, { name: 'b.jpg', size: 50 }]), null); });