Files
homeclaw/tests/model-search.test.ts
kimandClaude Code 5a571f592e feat: 3D 모델 저장소 연동 — model_search/model_download (Printables·MakerWorld·Thingiverse)
- model_search: 3사이트 통합 검색(Thingiverse는 토큰 필요, Printables/MakerWorld는 익명 검색 실측 확인)
- model_download: 슬라이싱 가능한 STL/3MF 중 최대 파일 1개 워크스페이스 저장(Printables getDownloadLink 익명 동작, Thingiverse /v2/files download, MakerWorld는 Bambu 토큰 필요)
- print3d_model에 stl_path 추가 — model_download로 받아둔 파일 바로 슬라이싱
- 토큰은 vault secret(print3d.*_token/cookie), Thingiverse App Token 등록 완료
- 게이트: ws_ 세션 항상 + 메인 채팅은 3D 키워드

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-23 11:43:03 +09:00

117 lines
5.3 KiB
TypeScript

// 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);
});