fix: 조립 탭 ".scad 파일 선택이 안 된다" — datalist를 선택 상자로 교체
- 원인: <input list=datalist>의 후보(서버 조회)가 화면을 그린 뒤 도착하는데 목록을 다시 안 그려 항상 비어 있었고(옵션 0개), 입력칸에 값이 있으면 브라우저가 그 값과 일치하는 항목만 보여줘 다른 파일을 고를 수 없었다 - <select>로 교체(최근 수정 순, 현재 파일 선택 표시, 후보 도착 시 제자리 갱신), 직접 입력칸은 아래에 유지(목록 밖 경로는 "(목록 밖)") - 다른 .scad를 고르면: 부품이 비어 있으면 자동 초안+렌더, 이미 있으면 교체할지 확인(취소하면 경로만 변경) - 회귀 하네스 tests/manual/scad-select-check.ts (11개 항목) Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,72 @@
|
||||
// .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'));
|
||||
// 사용자가 이미 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 3개가 표시됨(예전엔 0개)', o.length === 3, o.join(' | '));
|
||||
ck('현재 연결된 파일이 선택되어 있음', (await page.inputValue('#asm-scad-select')) === 'workshop/pa/CAD/scanner-rig.scad');
|
||||
ck('개수 안내 표시', (await page.locator('#asm-scad-count').innerText()).includes('3개'));
|
||||
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(600);
|
||||
ck('확인창이 뜸(기존 부품 교체 여부)', dlgMsg.includes('자동 초안으로 바꿀까요'));
|
||||
ck('취소 시 경로만 변경 + 부품 유지', (await page.inputValue('#asm-scad')) === 'workshop/pa/CAD/robot_arm_demo.scad' && (await page.locator('.asm-part').count()) === 1);
|
||||
ck('취소 후 선택 상자가 새 파일을 가리킴', (await page.inputValue('#asm-scad-select')) === 'workshop/pa/CAD/robot_arm_demo.scad');
|
||||
|
||||
// A) 확인창 수락: 새 파일의 자동 초안으로 교체 + 자동 렌더
|
||||
page.removeAllListeners('dialog'); page.on('dialog', (d: any) => d.accept());
|
||||
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();
|
||||
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); });
|
||||
@@ -38,6 +38,7 @@ async function loadAssemblyInfo(){
|
||||
const fr=await fetch('/api/workshop/assembly-scad-files?projectId='+encodeURIComponent(proj.id),{headers:authH()});
|
||||
if(fr.ok)asmScadFiles=(await fr.json()).files||[];
|
||||
if(seq!==asmLoadSeq||!asmPaneActive())return;
|
||||
refreshScadSelect(); // 후보가 화면을 그린 뒤 도착하므로 선택 상자를 제자리에서 갱신
|
||||
renderAssemblyImages();
|
||||
await asmAutoLink();
|
||||
}catch{}
|
||||
@@ -115,15 +116,18 @@ function renderAssembly(){
|
||||
<div class="asm-sec-hdr">📐 조립 모델(.scad) <span style="font-weight:400;color:var(--muted)">— 프로젝트의 OpenSCAD 조립 파일. 원본은 수정하지 않고 부품만 골라 그립니다.</span></div>
|
||||
<div class="asm-sec-body">
|
||||
<div class="asm-row">
|
||||
<input type="text" id="asm-scad" list="asm-scad-list" style="flex:1;min-width:220px" placeholder="workshop/<프로젝트id>/CAD/xxx.scad" value="${escAttr(a?a.scad:'')}" oninput="asmSet('scad',this.value)">
|
||||
<datalist id="asm-scad-list">${asmScadFiles.map(f=>`<option value="${escAttr(f)}">`).join('')}</datalist>
|
||||
<select id="asm-scad-select" style="flex:1;min-width:220px" onchange="asmPickScad(this.value)" title="이 프로젝트에 있는 .scad 파일 중에서 고릅니다">${asmScadSelectHtml()}</select>
|
||||
<button class="rb-mini-btn" onclick="asmScan()">🔍 모듈 스캔</button>
|
||||
<button class="rb-mini-btn" onclick="asmDraft(false)" title=".scad의 조립 블록에서 부품·분해 위치·조립 순서를 자동으로 뽑습니다">✨ 자동 초안</button>
|
||||
</div>
|
||||
<div class="asm-row">
|
||||
<span style="font-size:10px;color:var(--muted)">경로 직접 입력</span>
|
||||
<input type="text" id="asm-scad" style="flex:1;min-width:220px;font-size:11px" placeholder="목록에 없는 파일이면 워크스페이스 기준 경로를 직접 입력 (예: workshop/<프로젝트id>/CAD/xxx.scad)" value="${escAttr(a?a.scad:'')}" oninput="asmSet('scad',this.value)">
|
||||
</div>
|
||||
<div class="asm-row">
|
||||
<label style="font-size:11px;color:var(--muted);display:flex;align-items:center;gap:5px;cursor:pointer" title="채팅에서 .scad를 수정하거나, 이 탭을 열었을 때 그림이 오래됐으면 자동으로 다시 렌더링합니다">
|
||||
<input type="checkbox" ${!a||a.autoRender!==false?'checked':''} onchange="asmSet('autoRender',this.checked)">.scad가 바뀌면 그림 자동 갱신</label>
|
||||
<span style="font-size:10px;color:var(--muted)">${asmScadFiles.length>1?'· 이 프로젝트의 .scad '+asmScadFiles.length+'개 (입력칸에서 선택)':''}</span>
|
||||
<span id="asm-scad-count" style="font-size:10px;color:var(--muted)">${asmScadCountText()}</span>
|
||||
</div>
|
||||
<div id="asm-draft-note">${asmDraftNoteHtml()}</div>
|
||||
<div class="asm-row">
|
||||
@@ -187,10 +191,47 @@ function renderAssemblyImages(){
|
||||
+(asmInfo.unassigned&&asmInfo.unassigned.length?'<div class="asm-warn">⚠ 어느 단계에도 없는 부품: '+asmInfo.unassigned.map(escHtml).join(', ')+'</div>':'');
|
||||
}
|
||||
|
||||
// ── .scad 선택 상자 ───────────────────────────────────────────────────────────
|
||||
// 예전엔 <input list=datalist>였는데 ① 후보(서버 조회)가 화면을 그린 뒤 도착해도 목록을 다시 안 그려 항상 비어 있었고
|
||||
// ② 입력칸에 값이 있으면 브라우저가 그 값과 일치하는 항목만 보여줘 다른 파일을 고를 수 없었다 → 진짜 <select>로 바꾸고
|
||||
// 후보가 도착하면 제자리에서 갱신한다.
|
||||
const scadLabel=f=>String(f).replace(/^workshop\/[^/]+\//,'');
|
||||
function asmScadSelectHtml(){
|
||||
const cur=assembly?assembly.scad:'';
|
||||
const files=[...asmScadFiles];
|
||||
if(cur&&!files.includes(cur))files.unshift(cur); // 목록 밖(직접 입력한) 경로도 현재 값으로 보여준다
|
||||
if(!files.length)return '<option value="">(이 프로젝트에 .scad 파일이 없습니다 — 파일 탭에 올리거나 채팅으로 만드세요)</option>';
|
||||
return (cur?'':'<option value="">(.scad 파일 선택)</option>')
|
||||
+files.map(f=>`<option value="${escAttr(f)}" ${f===cur?'selected':''}>${escAttr(scadLabel(f))}${asmScadFiles.includes(f)?'':' (목록 밖)'}</option>`).join('');
|
||||
}
|
||||
function asmScadCountText(){return asmScadFiles.length?'· 이 프로젝트의 .scad '+asmScadFiles.length+'개(최근 수정 순)':'';}
|
||||
function refreshScadSelect(){
|
||||
const sel=document.getElementById('asm-scad-select');
|
||||
if(sel)sel.innerHTML=asmScadSelectHtml();
|
||||
const c=document.getElementById('asm-scad-count');
|
||||
if(c)c.textContent=asmScadCountText();
|
||||
}
|
||||
// 다른 .scad를 고르면: 부품이 비어 있으면 그 파일의 자동 초안+렌더, 이미 있으면 바꿀지 묻는다(취소하면 경로만 변경).
|
||||
async function asmPickScad(v){
|
||||
const a=ensureAssembly();
|
||||
if(!v||v===a.scad)return;
|
||||
const hadParts=a.parts.length>0;
|
||||
a.scad=v;asmScanMods=null;asmDraftInfo=null;
|
||||
const inp=document.getElementById('asm-scad');if(inp)inp.value=v;
|
||||
asmMarkChanged();
|
||||
if(hadParts&&!confirm('다른 .scad를 선택했습니다.\n\n기존 부품/단계를 "'+scadLabel(v)+'"의 자동 초안으로 바꿀까요?\n(취소하면 경로만 바꾸고 기존 부품은 그대로 둡니다 — 모듈 이름이 맞지 않으면 렌더가 실패할 수 있어요. 되돌리기는 🕘 이력에서 가능)')){
|
||||
renderAssembly();
|
||||
return;
|
||||
}
|
||||
if(await asmDraft(!hadParts,{skipConfirm:true}))await asmRender(true);
|
||||
else renderAssembly();
|
||||
}
|
||||
|
||||
// ── 편집 핸들러(입력 중엔 다시 그리지 않아 포커스 유지, 구조가 바뀔 때만 renderAssembly) ──
|
||||
function asmSet(k,v){
|
||||
const a=ensureAssembly();
|
||||
a[k]=k==='explode'?(Number(v)||1):(k==='autoRender'?!!v:v);
|
||||
if(k==='scad')refreshScadSelect();
|
||||
asmMarkChanged();
|
||||
}
|
||||
function asmView(k,v){ensureAssembly().view[k]=Number(v)||0;asmMarkChanged();}
|
||||
@@ -316,10 +357,10 @@ function asmDraftNoteHtml(){
|
||||
}
|
||||
|
||||
// .scad에서 부품/분해 offset/조립 순서 초안을 자동으로 만든다. 반환: 성공 여부(자동 흐름이 이어서 렌더할지 판단).
|
||||
async function asmDraft(auto){
|
||||
async function asmDraft(auto,opts){
|
||||
const a=ensureAssembly();
|
||||
if(!a.scad){if(!auto)alert('.scad 경로를 먼저 입력하세요.');return false;}
|
||||
if(!auto&&a.parts.length&&!confirm('기존 부품/단계를 자동 초안으로 덮어쓸까요?\n(이름·설명·체결부품 편집 내용이 사라집니다. 되돌리기는 🕘 이력에서 가능)'))return false;
|
||||
if(!auto&&!(opts&&opts.skipConfirm)&&a.parts.length&&!confirm('기존 부품/단계를 자동 초안으로 덮어쓸까요?\n(이름·설명·체결부품 편집 내용이 사라집니다. 되돌리기는 🕘 이력에서 가능)'))return false;
|
||||
const out=document.getElementById('asm-draft-note');
|
||||
if(out)out.innerHTML='<div class="rb-empty">.scad 분석 중…</div>';
|
||||
try{
|
||||
|
||||
Reference in New Issue
Block a user