Files
homeclaw/tests/manual/scad-select-check.ts
T
kimandClaude Sonnet 5 86d541d928 fix: .scad 선택 시 "새 경로 + 옛 부품" 어긋난 상태로 렌더가 실패하던 문제
- 원인: 다른 .scad를 고르면 경로만 먼저 바꾸고 부품은 나중에(또는 취소하면 영영) 안 바뀌어, 그 사이 저장 타이머/자동 렌더가
  새 파일에 없는 모듈(옛 파일의 motor())로 렌더 → "부품 motor: .scad에 module motor이(가) 없습니다"가 화면에 남음
- 확인창을 먼저 띄우고, 자동 초안이 성공한 뒤에만 경로+부품을 한 번에 교체. 취소/초안 실패 시 아무것도 바꾸지 않고 선택 상자만 원복
  (경로만 바꾸려면 "경로 직접 입력")
- 편집하면 이전 렌더 오류 문구를 지움. 서버 오류 문구에 파일 이름과 해결 방법(자동 초안으로 부품 재생성/모듈 이름 수정) 안내
- 회귀 하네스: 취소/초안 실패/도중 어긋난 상태 저장 감시 (옛 코드에서 4개 항목 실패 확인)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-25 10:31:53 +09:00

88 lines
7.9 KiB
TypeScript

// .scad 선택 상자 회귀 검증(수동): npx tsx tests/manual/scad-select-check.ts
// 2026-09-25 신고 "scad 파일 선택이 안 된다": <input list=datalist>가 후보 도착 후 다시 안 그려져 항상 비어 있었고,
// 값이 있으면 그 값과 일치하는 항목만 보였다 → <select>로 교체. 목록 표시/현재값 선택/다른 파일 선택(교체·취소)/직접 입력 동기화를 확인.
import express from 'express';
import fs from 'fs'; import path from 'path'; import os from 'os'; import http from 'http';
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'scadsel-')); process.env.SMALLCLAW_DATA_DIR = tmp;
const OUT = process.env.SHOT_DIR || '/tmp';
(async () => {
const { registerWorkshopRoutes } = await import('../../src/gateway/routes/routes-workshop');
const { chromium } = require('playwright-core');
const app = express(); app.use(express.json());
app.get('/api/auth/status', (_q, r) => r.json({ authenticated: true }));
app.get('/api/chat/sessions/:id', (_q, r) => r.json({ history: [] }));
app.get('/api/settings/model', (_q, r) => r.json({ primary: 'm' }));
registerWorkshopRoutes(app, () => ({ username: 'tester' }), (b: string, t: string) => path.resolve(t).startsWith(path.resolve(b) + path.sep));
app.use(express.static('/srv/homeclaw/web-ui'));
const server = http.createServer(app); await new Promise<void>(r => server.listen(0, r));
const base = `http://127.0.0.1:${(server.address() as any).port}`;
const ws = path.join(tmp, '.smallclaw/users/tester/workspace');
fs.mkdirSync(path.join(ws, 'workshop/pa/CAD'), { recursive: true });
const src = '/srv/homeclaw/.smallclaw/users/papa/workspace/workshop/';
fs.copyFileSync(src + 'proj_scan_dental/CAD/scanner-rig.scad', path.join(ws, 'workshop/pa/CAD/scanner-rig.scad'));
fs.copyFileSync(src + 'proj_scan_dental_diy/CAD/scanner-diy-rig.scad', path.join(ws, 'workshop/pa/CAD/scanner-diy-rig.scad'));
fs.copyFileSync(src + 'proj_h70rk5i5/CAD/robot_arm_demo.scad', path.join(ws, 'workshop/pa/CAD/robot_arm_demo.scad'));
fs.writeFileSync(path.join(ws, 'workshop/pa/CAD/no_modules.scad'), 'cube(5);\n'); // 조립 호출이 없어 자동 초안이 실패하는 파일
// 사용자가 이미 scanner-rig.scad를 연결해 둔 상태(부품 1개)
await fetch(`${base}/api/workshop/project/pa?force=1`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id: 'pa', name: '선택 시험', parts: [], phases: [], notes: '',
assembly: { scad: 'workshop/pa/CAD/scanner-rig.scad', parts: [{ id: 'a1', name: '베이스', call: 'base_plate()', offset: [0, 0, 0] }], steps: [] } }) });
const browser = await chromium.launch({ executablePath: '/usr/bin/google-chrome', args: ['--no-sandbox'] });
const page = await browser.newPage({ viewport: { width: 1400, height: 900 } });
page.on('dialog', (d: any) => d.dismiss());
await page.addInitScript(() => { try { sessionStorage.setItem('smallclaw_token', 't'); } catch {} });
await page.goto(`${base}/html/workshop-app.html`); await page.waitForSelector('#project-select');
await page.selectOption('#project-select', 'pa');
await page.click('.rb-tab-btn[data-tab=assembly]');
await page.waitForSelector('#asm-scad');
const opts = async () => page.locator('#asm-scad-select option').allInnerTexts();
const res: string[] = [];
const ck = (n: string, ok: boolean, x = '') => res.push(`${ok ? 'PASS' : 'FAIL'} ${n}${x ? ' — ' + x : ''}`);
await page.waitForTimeout(2000);
let o = await opts();
ck('선택 상자에 프로젝트의 .scad 4개가 표시됨(예전엔 0개)', o.length === 4, o.join(' | '));
ck('현재 연결된 파일이 선택되어 있음', (await page.inputValue('#asm-scad-select')) === 'workshop/pa/CAD/scanner-rig.scad');
ck('개수 안내 표시', (await page.locator('#asm-scad-count').innerText()).includes('4개'));
await page.screenshot({ path: `${OUT}/scad-after.png` });
// B) 이미 부품이 있을 때 다른 파일 선택 → 확인창 취소: 아무것도 바뀌지 않고 선택이 원래 파일로 되돌아간다
page.removeAllListeners('dialog'); let dlgMsg = '';
page.on('dialog', (d: any) => { dlgMsg = d.message(); d.dismiss(); });
await page.selectOption('#asm-scad-select', 'workshop/pa/CAD/robot_arm_demo.scad');
await page.waitForTimeout(700);
ck('확인창이 뜸(기존 부품 교체 여부)', dlgMsg.includes('자동 초안으로 바꿀까요'));
ck('취소 시 경로·부품 그대로(어긋난 상태를 만들지 않음)', (await page.inputValue('#asm-scad')) === 'workshop/pa/CAD/scanner-rig.scad' && (await page.locator('.asm-part').count()) === 1);
ck('취소 후 선택 상자가 원래 파일로 복귀', (await page.inputValue('#asm-scad-select')) === 'workshop/pa/CAD/scanner-rig.scad');
// D) 수락했지만 초안이 실패하는 파일(.scad에 모듈이 없음): 경로·부품이 그대로이고 오류 안내만
page.removeAllListeners('dialog'); const dlgs: string[] = [];
page.on('dialog', (d: any) => { dlgs.push(d.message()); d.accept(); });
await page.selectOption('#asm-scad-select', 'workshop/pa/CAD/no_modules.scad');
await page.waitForTimeout(2500);
const afterFail = (await (await fetch(`${base}/api/workshop/assembly/pa`)).json()).project.assembly;
ck('초안 실패 시 저장된 경로·부품 그대로', afterFail.scad === 'workshop/pa/CAD/scanner-rig.scad' && afterFail.parts.length === 1, `${afterFail.scad} / 부품 ${afterFail.parts.length}`);
ck('초안 실패 안내 표시 + 선택 상자 원복', (await page.locator('#asm-status').innerText()).includes('자동 초안 실패') && (await page.inputValue('#asm-scad-select')) === 'workshop/pa/CAD/scanner-rig.scad', dlgs.slice(-1)[0]?.slice(0, 40));
// A) 확인창 수락: 새 파일의 자동 초안으로 교체 + 자동 렌더
page.removeAllListeners('dialog'); page.on('dialog', (d: any) => d.accept());
// 선택~교체 사이에 서버에 "새 경로 + 옛 부품(1개)" 같은 어긋난 상태가 저장되는 순간이 있었는지 감시(09-25 신고의 원인)
const mismatched: string[] = []; let watching = true;
const watcher = (async () => { while (watching) { try { const a = (await (await fetch(`${base}/api/workshop/assembly/pa`)).json()).project.assembly; if (a && a.scad.endsWith('scanner-diy-rig.scad') && a.parts.length === 1) mismatched.push(`${a.scad}/${a.parts.length}`); } catch { /* noop */ } await new Promise(r => setTimeout(r, 80)); } })();
await page.selectOption('#asm-scad-select', 'workshop/pa/CAD/scanner-diy-rig.scad');
await page.waitForFunction(() => document.querySelectorAll('.asm-part').length === 6, null, { timeout: 30000 });
ck('수락 시 새 .scad의 자동 초안으로 교체(부품 6개)', true);
await page.waitForSelector('.asm-imgs figure', { timeout: 90000 });
ck('새 파일 기준으로 그림 자동 렌더', (await page.locator('.asm-imgs figure').count()) >= 3);
const saved = await (await fetch(`${base}/api/workshop/assembly/pa`)).json();
watching = false; await watcher;
ck('선택 도중 어긋난 상태(새 경로+옛 부품)가 서버에 저장된 적 없음', mismatched.length === 0, mismatched.slice(0, 2).join(','));
ck('서버에 저장된 조립 모델 경로가 바뀜', saved.project.assembly.scad === 'workshop/pa/CAD/scanner-diy-rig.scad' && saved.project.assembly.parts.length === 6);
// C) 직접 입력하면 선택 상자도 따라감 / 목록 밖 경로는 "(목록 밖)"으로 표시
await page.fill('#asm-scad', 'workshop/pa/CAD/scanner-rig.scad');
ck('직접 입력 → 선택 상자 동기화', (await page.inputValue('#asm-scad-select')) === 'workshop/pa/CAD/scanner-rig.scad');
await page.fill('#asm-scad', 'workshop/pa/CAD/없는파일.scad');
ck('목록 밖 경로 표시', (await opts()).some(t => t.includes('(목록 밖)')));
console.log(res.join('\n'));
await browser.close(); server.close(); fs.rmSync(tmp, { recursive: true, force: true });
})().catch(e => { console.error('ERR', e.message); process.exit(1); });