diff --git a/scripts/verify-abut-names.cjs b/scripts/verify-abut-names.cjs new file mode 100644 index 0000000..2fa5404 --- /dev/null +++ b/scripts/verify-abut-names.cjs @@ -0,0 +1,166 @@ +// Build non-destructive fixtures and verify full-name identity and error disclosure via CDP. +// Run with Windows Node while the local app and a dedicated Chrome CDP port 9444 are running. +const fs = require('node:fs'); +const path = require('node:path'); +const assert = require('node:assert/strict'); +const XLSX = require('xlsx'); +const root = path.resolve(__dirname, '..'); +const out = path.join(root, '.verify', 'abut-names-20261105'); +const report = { checks: [], errors: [], started: new Date().toISOString() }; +const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); +fs.mkdirSync(out, { recursive: true }); +const base = XLSX.readFile(path.resolve(root, '../../RAG/운곡교P1입력.xlsx')); +const blocks = { info: [], ips: [], bearings: [] }; +for (const sheet of base.SheetNames) { + const rows = XLSX.utils.sheet_to_json(base.Sheets[sheet], { header: 1, defval: null }); + for (let h = 0; h < rows.length; h++) { + if (rows[h][0] !== '교대명') continue; + const kind = ({ 형식: 'info', IP점: 'ips', 받침번호: 'bearings' })[rows[h][1]]; + if (!kind) continue; + let name; + for (let i = h + 1; i < rows.length && rows[i][1] != null && rows[i][1] !== ''; i++) { + name = rows[i][0] || name; + if (name === 'A1') blocks[kind].push(['A2-완도', ...rows[i].slice(1)]); + } + } + for (const row of rows) if (row[0] === 'A1') row[0] = 'A1-완도'; + base.Sheets[sheet] = XLSX.utils.aoa_to_sheet(rows); +} +assert.equal(blocks.info.length, 1); +blocks.info[0][1] = 'A2'; +XLSX.utils.book_append_sheet(base, XLSX.utils.aoa_to_sheet([ + ['교대명', '형식'], ...blocks.info, [], + ['교대명', 'IP점'], ...blocks.ips, [], + ['교대명', '받침번호'], ...blocks.bearings, +]), '종점교대'); +const validFile = path.join(out, 'full-names.xlsx'); +XLSX.writeFile(base, validFile); +const bad = XLSX.readFile(validFile); +for (const sheet of bad.SheetNames) { + const rows = XLSX.utils.sheet_to_json(bad.Sheets[sheet], { header: 1, defval: null }); + for (const row of rows) if (row[0] === 'A1-완도' && row[1] === 'A1') row[1] = 'A3'; + bad.Sheets[sheet] = XLSX.utils.aoa_to_sheet(rows); +} +const badFile = path.join(out, 'invalid-type.xlsx'); +XLSX.writeFile(bad, badFile); +let ws; +let send; +async function main() { + const target = await (await fetch('http://127.0.0.1:9444/json/new?about:blank', { method: 'PUT' })).json(); + ws = new WebSocket(target.webSocketDebuggerUrl); + await new Promise((resolve, reject) => { ws.onopen = resolve; ws.onerror = reject; }); + let seq = 0; + let loaded; + let downloadGuid; + const done = new Set(); + const pending = new Map(); + send = (method, params = {}) => new Promise((resolve, reject) => { + const id = ++seq; + const timer = setTimeout(() => { pending.delete(id); reject(new Error(`CDP timeout ${method}`)); }, 20000); + pending.set(id, { resolve, reject, timer }); + ws.send(JSON.stringify({ id, method, params })); + }); + ws.onmessage = ({ data }) => { + const msg = JSON.parse(data); + if (msg.id) { + const wait = pending.get(msg.id); if (!wait) return; + clearTimeout(wait.timer); pending.delete(msg.id); + if (msg.error) wait.reject(new Error(JSON.stringify(msg.error))); else wait.resolve(msg.result); + } else if (msg.method === 'Page.loadEventFired') loaded?.(); + else if (msg.method === 'Runtime.exceptionThrown') report.errors.push(msg.params.exceptionDetails.exception?.description || msg.params.exceptionDetails.text); + else if (msg.method === 'Runtime.consoleAPICalled' && msg.params.type === 'error') report.errors.push(msg.params.args.map((a) => a.value ?? a.description).join(' ')); + else if (msg.method === 'Browser.downloadWillBegin') downloadGuid = msg.params.guid; + else if (msg.method === 'Browser.downloadProgress' && msg.params.state === 'completed') done.add(msg.params.guid); + }; + const ev = async (expression) => { + const r = await send('Runtime.evaluate', { expression, awaitPromise: true, returnByValue: true }); + if (r.exceptionDetails) throw new Error(r.exceptionDetails.exception?.description || r.exceptionDetails.text); + return r.result.value; + }; + const until = async (expression) => { + for (let i = 0; i < 100; i++) { if (await ev(expression)) return; await delay(150); } + throw new Error(`Timeout: ${expression}`); + }; + const click = (expression) => ev(`(() => { const e = ${expression}; if (!e || e.disabled) throw new Error('Missing/disabled target'); e.click(); })()`); + const check = (name, condition) => { assert.ok(condition, name); report.checks.push(name); console.log('PASS', name); }; + const shot = async (name) => { + await delay(250); + const r = await send('Page.captureScreenshot', { format: 'png' }); + fs.writeFileSync(path.join(out, `${name}.png`), Buffer.from(r.data, 'base64')); + }; + const file = async (selector, name) => { + const doc = await send('DOM.getDocument'); + const node = await send('DOM.querySelector', { nodeId: doc.root.nodeId, selector }); + await send('DOM.setFileInputFiles', { nodeId: node.nodeId, files: [name] }); + }; + const navigate = async () => { + let timer; + const load = new Promise((resolve, reject) => { loaded = resolve; timer = setTimeout(() => reject(new Error('Load timeout')), 20000); }); + await send('Page.navigate', { url: 'http://127.0.0.1:5173/' }); + try { await load; } finally { clearTimeout(timer); loaded = undefined; } + await until(`typeof window.loadBytesToApp === 'function' && !!document.querySelector('[data-card="excel"] button')`); + }; + const select = (name) => click(`[...document.querySelectorAll('#rebar-pier-items button')].find(b => b.textContent.includes(${JSON.stringify(name)}) && b.textContent.includes('교대'))`); + await send('Page.enable'); await send('Runtime.enable'); await send('DOM.enable'); + await send('Emulation.setDeviceMetricsOverride', { width: 1600, height: 1000, deviceScaleFactor: 1, mobile: false }); + const downloads = 'C:\\Users\\User\\AppData\\Local\\Temp\\pierzainer-abut-name-20261105\\downloads'; + await send('Browser.setDownloadBehavior', { behavior: 'allowAndName', downloadPath: downloads, eventsEnabled: true }); + await navigate(); + await click(`document.querySelector('[data-card="excel"] button')`); + await ev(`document.getElementById('project-name').value = '복합 교대명 검증'; document.getElementById('project-name').dispatchEvent(new Event('input'));`); + await file('#file', validFile); + await until(`document.getElementById('dlg-apply').textContent.includes('교대 2기')`); + check('both full names recognized without cascade errors', await ev(`document.getElementById('pv-abuts').textContent.includes('A1-완도') && document.getElementById('pv-abuts').textContent.includes('A2-완도') && !document.querySelector('#sheets .bad')`)); + check('full-name checkboxes stay distinct', await ev(`document.getElementById('import-abut-A1-완도').checked && document.getElementById('import-abut-A2-완도').checked`)); + await shot('01-full-names-import'); + await click(`document.getElementById('dlg-apply')`); + await click(`document.getElementById('nav-rebar')`); + await select('A1-완도'); + check('full name selects the start abutment', await ev(`document.getElementById('rebar-note').textContent.startsWith('A1-완도') && document.querySelectorAll('#rebar-tabs button').length === 6`)); + await select('A2-완도'); + check('full name selects the end abutment independently', await ev(`document.getElementById('rebar-note').textContent.startsWith('A2-완도')`)); + const order = await ev(`[...document.querySelectorAll('#rebar-pier-items button')].map(b => b.textContent)`); + check('type-based order is preserved', order[0].includes('A1-완도') && order[1].includes('P1') && order[2].includes('A2-완도')); + await shot('02-independent-abutments'); + await click(`document.getElementById('save-project')`); + await until(`document.getElementById('save-dialog').open`); + await click(`document.getElementById('sv-save')`); + for (let i = 0; i < 100 && !(downloadGuid && done.has(downloadGuid)); i++) await delay(150); + assert.ok(downloadGuid && done.has(downloadGuid), 'actual download completes'); + const text = fs.readFileSync(path.join(downloads, downloadGuid), 'utf8'); + const project = JSON.parse(text); + const expectedNames = ['A1-완도', 'A2-완도']; + assert.deepEqual(project.abutments.map((a) => a.name), expectedNames); + assert.deepEqual(project.abutDefaults.map((a) => a.name), expectedNames); + report.checks.push('actual download preserves both names and defaults'); + const saved = path.join(out, 'full-names.pzr.json'); fs.writeFileSync(saved, text); + await navigate(); + await ev('window.openSampleProject()'); + await click(`document.getElementById('open-project')`); + await file('#project-file', saved); + await until(`document.querySelector('#toast-host').textContent.includes('열었습니다')`); + await click(`document.getElementById('nav-rebar')`); + await select('A1-완도'); await select('A2-완도'); + check('fresh reopen regenerates full-name abutments', await ev(`document.getElementById('save-status').textContent === '저장됨' && document.getElementById('rebar-note').textContent.startsWith('A2-완도')`)); + await shot('03-reopened'); + await click(`document.getElementById('open-import')`); + await file('#file', badFile); + await until(`document.getElementById('dlg-apply').textContent.includes('교대 1기') && !!document.querySelector('#sheets details')`); + check('one bad definition does not disable another name', await ev(`document.getElementById('import-abut-A2-완도').checked && !document.getElementById('import-abut-A2-완도').disabled`)); + check('dependent rows are collapsed under the root', await ev(`document.querySelectorAll('#sheets details').length === 1 && !document.querySelector('#sheets details').open && document.querySelector('#sheets details summary').textContent.includes('8개 행') && document.querySelectorAll('#sheets details p').length === 8`)); + await shot('04-root-error-collapsed'); + await click(`document.querySelector('#sheets details summary')`); + check('dependent rows remain available for diagnosis', await ev(`document.querySelector('#sheets details').open && document.querySelector('#sheets details').textContent.includes('IP') && document.querySelector('#sheets details').textContent.includes('받침')`)); + await shot('05-related-rows'); + check('no browser runtime errors', report.errors.length === 0); + report.result = 'PASS'; +} +main().catch((error) => { report.result = 'FAIL'; report.failure = error.stack; process.exitCode = 1; }).finally(async () => { + if (report.result === 'FAIL' && send) { + try { const shot = await send('Page.captureScreenshot', { format: 'png' }); fs.writeFileSync(path.join(out, 'failure.png'), Buffer.from(shot.data, 'base64')); } catch { /* Keep the original error. */ } + } + report.finished = new Date().toISOString(); fs.writeFileSync(path.join(out, 'report.json'), JSON.stringify(report, null, 2)); + console.log(JSON.stringify({ result: report.result, checks: report.checks.length, errors: report.errors, failure: report.failure })); + if (send) { try { await send('Page.close'); } catch { /* The tab may already be closed. */ } } + ws?.close(); +}); diff --git a/scripts/verify-abut-workflow.cjs b/scripts/verify-abut-workflow.cjs new file mode 100644 index 0000000..bc150c3 --- /dev/null +++ b/scripts/verify-abut-workflow.cjs @@ -0,0 +1,241 @@ +// Verify A1's six rebar cards, rendered views, edits and project round trip via CDP. +// Run with Windows Node when CDP is served by Windows Chrome: +// node scripts/verify-abut-workflow.cjs +const fs = require('node:fs'); +const path = require('node:path'); +const assert = require('node:assert/strict'); + +const [input, out, downloads, mode] = process.argv.slice(2); +if (!input || !out || !downloads) throw new Error('input, output and download directories are required'); +const base = process.env.CDP || 'http://127.0.0.1:9444'; +const url = process.env.APP_URL || 'http://127.0.0.1:5173/'; +const report = mode === '--resume' + ? JSON.parse(fs.readFileSync(path.join(out, 'report.json'), 'utf8')) + : { input, url, started: new Date().toISOString(), cards: [], errors: [], checks: [] }; +const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); +fs.mkdirSync(out, { recursive: true }); +let ws; + +async function main() { + console.log('Connecting to Chrome', base); + const targets = await (await fetch(`${base}/json`, { signal: AbortSignal.timeout(10000) })).json(); + const target = targets.find((t) => t.type === 'page'); + assert.ok(target, 'Chrome page target'); + ws = new WebSocket(target.webSocketDebuggerUrl); + await new Promise((resolve, reject) => { ws.onopen = resolve; ws.onerror = reject; }); + let next = 0; + const pending = new Map(); + const completed = new Set(); + let downloadGuid; + let loaded; + ws.onmessage = ({ data }) => { + const message = JSON.parse(data); + if (message.id) { + const waiter = pending.get(message.id); + if (!waiter) return; + clearTimeout(waiter.timer); + pending.delete(message.id); + if (message.error) waiter.reject(new Error(JSON.stringify(message.error))); + else waiter.resolve(message.result); + } else if (message.method === 'Page.loadEventFired') loaded?.(); + else if (message.method === 'Runtime.exceptionThrown') { + report.errors.push(message.params.exceptionDetails.exception?.description || message.params.exceptionDetails.text); + } else if (message.method === 'Runtime.consoleAPICalled' && message.params.type === 'error') { + report.errors.push(message.params.args.map((v) => v.value ?? v.description ?? '').join(' ')); + } else if (message.method === 'Browser.downloadWillBegin') downloadGuid = message.params.guid; + else if (message.method === 'Browser.downloadProgress' && message.params.state === 'completed') completed.add(message.params.guid); + }; + const send = (method, params = {}) => new Promise((resolve, reject) => { + const id = ++next; + const timer = setTimeout(() => { pending.delete(id); reject(new Error(`CDP timeout: ${method}`)); }, 20000); + pending.set(id, { resolve, reject, timer }); + ws.send(JSON.stringify({ id, method, params })); + }); + const evaluate = async (expression) => { + const result = await send('Runtime.evaluate', { expression, awaitPromise: true, returnByValue: true }); + if (result.exceptionDetails) throw new Error(result.exceptionDetails.exception?.description || result.exceptionDetails.text); + return result.result.value; + }; + const navigate = async () => { + let timer; + const event = new Promise((resolve, reject) => { + timer = setTimeout(() => reject(new Error('Page load timeout')), 20000); + loaded = resolve; + }); + await send('Page.navigate', { url }); + try { await event; } finally { clearTimeout(timer); loaded = undefined; } + await until(() => evaluate(`typeof window.loadBytesToApp === 'function'`), 'app event handlers loaded'); + }; + const until = async (test, label) => { + const end = Date.now() + 20000; + while (Date.now() < end) { + if (await test()) return; + await delay(150); + } + report.timeoutPage = await evaluate('document.body.innerText'); + const shot = await send('Page.captureScreenshot', { format: 'png' }); + fs.writeFileSync(path.join(out, 'failure.png'), Buffer.from(shot.data, 'base64')); + throw new Error(`Timeout: ${label}`); + }; + const click = (expression) => evaluate(`(() => { const node = ${expression}; if (!node) throw new Error('Missing click target'); if (node.disabled) throw new Error('Disabled click target'); node.click(); return true; })()`); + const selectCard = async (label) => { + await click(`[...document.querySelectorAll('#rebar-tabs button')].find(b => b.textContent.startsWith(${JSON.stringify(label)}))`); + await until(() => evaluate(`document.querySelector('#rebar-table svg') !== null`), label); + await delay(250); + }; + const screenshot = async (name) => { + const shot = await send('Page.captureScreenshot', { format: 'png' }); + fs.writeFileSync(path.join(out, `${name}.png`), Buffer.from(shot.data, 'base64')); + }; + const upload = async (selector, file) => { + const doc = await send('DOM.getDocument'); + const node = await send('DOM.querySelector', { nodeId: doc.root.nodeId, selector }); + assert.ok(node.nodeId, selector); + await send('DOM.setFileInputFiles', { nodeId: node.nodeId, files: [file] }); + }; + const editFirst = async (value) => evaluate(`(() => { + const node = document.querySelector('#rebar-table .abut-rebar-inputs input[type=number]'); + if (!node) throw new Error('Missing rebar input'); + const old = { id: node.id, value: node.value }; + node.value = ${JSON.stringify(value)}; + node.dispatchEvent(new Event('change', { bubbles: true })); + return old; + })()`); + const rows = () => evaluate(`[...document.querySelectorAll('#rebar-table table.rebar-table tbody tr')].filter(r => r.cells.length > 2).map(r => r.innerText)`); + const restoreView = async (card) => { + for (const text of card.context) { + await click(`[...document.querySelectorAll('#rebar-table .abut-optt')].find(b => b.textContent === ${JSON.stringify(text)})`); + } + }; + + await send('Page.enable'); + await send('Runtime.enable'); + await send('DOM.enable'); + await send('Emulation.setDeviceMetricsOverride', { width: 1600, height: 1000, deviceScaleFactor: 1, mobile: false }); + await send('Browser.setDownloadBehavior', { behavior: 'allowAndName', downloadPath: downloads, eventsEnabled: true }); + await send('Page.addScriptToEvaluateOnNewDocument', { source: ` + window.__drawCalls = 0; + for (const type of [window.WebGLRenderingContext, window.WebGL2RenderingContext]) { + if (!type) continue; + for (const method of ['drawElements', 'drawArrays', 'drawElementsInstanced', 'drawArraysInstanced']) { + const original = type.prototype[method]; + if (original) type.prototype[method] = function(...args) { window.__drawCalls++; return original.apply(this, args); }; + } + } + ` }); + if (mode !== '--resume') { + await navigate(); + await until(() => evaluate(`document.querySelector('[data-card="excel"] button') !== null && typeof window.loadBytesToApp === 'function'`), 'app loaded'); + await click(`document.querySelector('[data-card="excel"] button')`); + await evaluate(`const name = document.getElementById('project-name'); name.value = 'A1 화면 검증'; name.dispatchEvent(new Event('input', { bubbles: true }));`); + await upload('#file', input); + await until(() => evaluate(`!document.getElementById('dlg-apply').disabled`), 'Excel import'); + report.import = await evaluate(`({ sheets: document.getElementById('sheets').innerText, selection: document.getElementById('dlg-selected').textContent })`); + await screenshot('01-import'); + await click(`document.getElementById('dlg-apply')`); + await click(`document.getElementById('nav-rebar')`); + await until(() => evaluate(`[...document.querySelectorAll('#rebar-pier-items button')].some(b => b.textContent.includes('교대'))`), 'abutment list'); + await click(`[...document.querySelectorAll('#rebar-pier-items button')].find(b => b.textContent.includes('교대') && b.textContent.includes('A1'))`); + await until(() => evaluate(`document.querySelectorAll('#rebar-tabs button').length === 6`), 'six member cards'); + + for (const [index, label] of ['받침블럭', '벽체', '흉벽', '확대기초', '날개벽', '접속슬래브'].entries()) { + await selectCard(label); + const before = await rows(); + assert.ok(before.length, `${label}: non-empty rebar table`); + const card = { label, rows: before, views: [] }; + const views = await evaluate(`[...document.querySelectorAll('#rebar-table .abut-optt')].map(b => b.textContent)`); + assert.ok(views.length >= 2, `${label}: view tabs`); + for (const [viewIndex, view] of views.entries()) { + const drawBefore = await evaluate('window.__drawCalls'); + await click(`[...document.querySelectorAll('#rebar-table .abut-optt')].find(b => b.textContent === ${JSON.stringify(view)})`); + await delay(350); + const facts = await evaluate(`({ svg: !!document.querySelector('#rebar-table svg'), badSvg: /(?:NaN|Infinity)/.test(document.querySelector('#rebar-table svg')?.outerHTML || ''), drawCalls: window.__drawCalls, canvases: [...document.querySelectorAll('canvas')].map(c => ({ width: c.width, height: c.height })) })`); + assert.ok(facts.svg && !facts.badSvg, `${label}/${view}: finite SVG`); + assert.ok(facts.drawCalls > drawBefore, `${label}/${view}: WebGL draw calls`); + await screenshot(`${String(index + 2).padStart(2, '0')}-${index}-${viewIndex}`); + fs.writeFileSync(path.join(out, `${index}-${viewIndex}.svg`), await evaluate(`document.querySelector('#rebar-table svg').outerHTML`)); + card.views.push({ view, ...facts }); + } + const originalValue = await evaluate(`document.querySelector('#rebar-table .abut-rebar-inputs input[type=number]').value`); + const changedValue = originalValue === '150' ? '200' : '150'; + const old = await editFirst(changedValue); + const changed = await rows(); + assert.notDeepEqual(changed, before, `${label}: input recomputes rows`); + assert.equal(await evaluate(`document.getElementById(${JSON.stringify(old.id)}).value`), changedValue); + card.edit = { ...old, changedValue, changedRows: changed }; + { + const marks = await evaluate(`[...document.querySelectorAll('#rebar-table table.rebar-table tbody tr')].filter(r => r.cells.length > 2).map(r => r.cells[2].textContent)`); + assert.ok(marks[0], `${label}: selectable mark`); + await click(`document.querySelector('#rebar-table table.rebar-table tbody tr')`); + await delay(150); + if (!await evaluate(`document.querySelector('#rebar-table .rebar-select button:last-child').getAttribute('aria-pressed') === 'true'`)) { + await click(`[...document.querySelectorAll('#rebar-table .rebar-select button')].find(b => b.textContent === '선택만 보기')`); + } + await delay(250); + card.selection = await evaluate(`document.querySelector('#rebar-table .rebar-select').innerText`); + assert.ok(card.selection.includes('선택 1 /'), `${label}: one selected bar`); + await screenshot(`${String(index + 2).padStart(2, '0')}-${index}-selected`); + } + card.context = await evaluate(`[...document.querySelectorAll('#rebar-table .abut-optt[aria-pressed="true"]')].map(b => b.textContent)`); + report.cards.push(card); + console.log('Verified card', label); + fs.writeFileSync(path.join(out, 'report.json'), JSON.stringify(report, null, 2)); + } + + // Snapshot all tables after every edit, including dependent members and the wing side. + for (const card of report.cards) { + await selectCard(card.label); + await restoreView(card); + card.edit.changedRows = await rows(); + } + fs.writeFileSync(path.join(out, 'report.json'), JSON.stringify(report, null, 2)); + await click(`document.getElementById('save-project')`); + await until(() => evaluate(`document.getElementById('save-dialog').open`), 'save dialog'); + await evaluate(`document.getElementById('sv-name').value = 'A1 화면 검증'; document.getElementById('sv-bridge').value = 'A1-verification'`); + await click(`document.getElementById('sv-save')`); + await until(async () => downloadGuid && completed.has(downloadGuid), 'download completes'); + const downloaded = fs.readFileSync(path.join(downloads, downloadGuid), 'utf8'); + const project = JSON.parse(downloaded); + report.savedAbutments = project.abutments; + assert.ok(project.abutments?.length, 'project contains abutments'); + const savedFile = path.join(out, 'A1-roundtrip.pzr.json'); + fs.writeFileSync(savedFile, downloaded); + report.checks.push('actual project download'); + fs.writeFileSync(path.join(out, 'report.json'), JSON.stringify(report, null, 2)); + } + await navigate(); + // Enter the app shell through its existing sample entry, then open the saved file. + // This uses no original Excel file for reconstruction. + await evaluate('window.openSampleProject()'); + await click(`document.getElementById('open-project')`); + await upload('#project-file', path.join(out, 'A1-roundtrip.pzr.json')); + await until(() => evaluate(`document.querySelector('#toast-host')?.textContent.includes('열었습니다') && document.getElementById('save-status').textContent === '저장됨'`), 'open saved project'); + await click(`document.getElementById('nav-rebar')`); + await click(`[...document.querySelectorAll('#rebar-pier-items button')].find(b => b.textContent.includes('교대') && b.textContent.includes('A1'))`); + for (const card of report.cards) { + await selectCard(card.label); + await restoreView(card); + const restored = await evaluate(`document.getElementById(${JSON.stringify(card.edit.id)})?.value`); + assert.equal(restored, card.edit.changedValue, `${card.label}: restored input`); + assert.deepEqual(await rows(), card.edit.changedRows, `${card.label}: restored computed rows`); + card.roundtrip = 'PASS'; + } + await screenshot('09-restored'); + report.checks.push('6 member cards', 'all SVG view tabs', 'WebGL drawing', '6 input edits', '6 bar selections', 'actual project download', 'fresh page project reopen', '6 restored inputs and computed tables'); + assert.equal(report.errors.length, 0, 'browser runtime errors'); + delete report.failure; + delete report.timeoutPage; + report.checks = [...new Set(report.checks)]; + report.result = 'PASS'; +} + +main().catch((error) => { + report.result = 'FAIL'; + report.failure = error.stack; + process.exitCode = 1; +}).finally(() => { + report.finished = new Date().toISOString(); + fs.writeFileSync(path.join(out, 'report.json'), JSON.stringify(report, null, 2)); + console.log(JSON.stringify({ result: report.result, cards: report.cards.length, errors: report.errors, failure: report.failure })); + ws?.close(); +}); diff --git a/src/core/abut/import-issues.ts b/src/core/abut/import-issues.ts new file mode 100644 index 0000000..7a19133 --- /dev/null +++ b/src/core/abut/import-issues.ts @@ -0,0 +1,35 @@ +import type { Issue } from '../types'; + +export interface AbutImportIssueGroup { + name?: string; + primary: Issue[]; + related: Issue[]; +} + +/** Keep root errors visible while retaining every dependent row in a disclosure. */ +export function groupAbutImportIssues(issues: readonly Issue[]): AbutImportIssueGroup[] { + const groups: { name?: string; issues: Issue[] }[] = []; + const byName = new Map(); + for (const issue of issues) { + if (issue.pier === undefined) { + groups.push({ issues: [issue] }); + continue; + } + let group = byName.get(issue.pier); + if (!group) { + group = { name: issue.pier, issues: [] }; + byName.set(issue.pier, group); + groups.push(group); + } + group.issues.push(issue); + } + return groups.map(({ name, issues: items }) => { + const primary = items.filter((issue) => issue.code !== 'AP04'); + const related = items.filter((issue) => issue.code === 'AP04'); + if (!primary.length && related.length) primary.push({ + ...related[0], + message: `교대 ${name ?? ''}: 교대 정보 행이 없어 IP·받침 ${related.length}개 행을 연결하지 못했습니다. 전체 교대명을 확인하세요.`, + }); + return { name, primary, related }; + }); +} diff --git a/src/core/abut/name.ts b/src/core/abut/name.ts new file mode 100644 index 0000000..fc569f6 --- /dev/null +++ b/src/core/abut/name.ts @@ -0,0 +1,6 @@ +/** Shared display-name policy for Excel input and saved project records. */ +export function abutNameError(value: unknown): string | null { + if (typeof value !== 'string' || !value.trim()) return '교대명을 입력하세요.'; + if (/[\u0000-\u001f\u007f-\u009f]/u.test(value)) return '교대명에는 줄바꿈·제어 문자를 사용할 수 없습니다.'; + return null; +} diff --git a/src/core/abut/order.ts b/src/core/abut/order.ts index 22b03f9..dd6198d 100644 --- a/src/core/abut/order.ts +++ b/src/core/abut/order.ts @@ -9,11 +9,11 @@ export interface StructureRef { type?: 'A1' | 'A2'; } -const numberOf = (name: string) => Number(name.replace(/\D/g, '')) || 0; +const nameOrder = new Intl.Collator('ko', { numeric: true }); export function structureOrder(abutments: { name: string; type: 'A1' | 'A2' }[], piers: string[]): StructureRef[] { - const start = abutments.filter((a) => a.type === 'A1').sort((a, b) => numberOf(a.name) - numberOf(b.name)); - const end = abutments.filter((a) => a.type === 'A2').sort((a, b) => numberOf(a.name) - numberOf(b.name)); + const start = abutments.filter((a) => a.type === 'A1').sort((a, b) => nameOrder.compare(a.name, b.name)); + const end = abutments.filter((a) => a.type === 'A2').sort((a, b) => nameOrder.compare(a.name, b.name)); const others = abutments.filter((a) => a.type !== 'A1' && a.type !== 'A2'); return [ ...start.map((a) => ({ kind: 'abut' as const, name: a.name, type: a.type })), diff --git a/src/core/abut/parse.ts b/src/core/abut/parse.ts index 25dcd95..0534dde 100644 --- a/src/core/abut/parse.ts +++ b/src/core/abut/parse.ts @@ -9,6 +9,7 @@ import * as XLSX from 'xlsx'; import { parseDms } from '../dms'; import { swapXYAzimuth } from '../coordinate-frame'; import type { Issue } from '../types'; +import { abutNameError } from './name'; import type { AbutInput, AbutPoint, AbutType, WingForm } from './geometry'; import type { AbutShape } from './shape'; import type { AbutBearingRule } from './bearing-table'; @@ -104,7 +105,7 @@ function readBlock(grid: Grid, header: number): { cells: Cell[]; row: number }[] return out; } -export function parseAbutments(wb: XLSX.WorkBook): AbutParseResult { +export function parseAbutments(wb: XLSX.WorkBook, reservedNames: readonly string[] = []): AbutParseResult { const issues: Issue[] = []; const info: { cells: Cell[]; row: number; sheet: string }[] = []; const ips: { cells: Cell[]; row: number; sheet: string }[] = []; @@ -124,12 +125,24 @@ export function parseAbutments(wb: XLSX.WorkBook): AbutParseResult { } const abutments: AbutRecord[] = []; + const counts = new Map(); + for (const rec of info) { + const name = String(rec.cells[0] ?? '').trim(); + counts.set(name, (counts.get(name) ?? 0) + 1); + } + const reserved = new Set(reservedNames.map((name) => name.trim())); for (const rec of info) { const c = rec.cells; - const name = String(c[0] ?? '').trim(); + const rawName = String(c[0] ?? ''); + const name = rawName.trim(); const where = `${rec.sheet} ${rec.row}행`; - if (!/^A\d+$/.test(name)) { - issues.push({ code: 'AP01', level: 'E', sheet: rec.sheet, message: `${where}: 교대명 "${String(c[0] ?? '')}"이 올바르지 않습니다 (A1, A2 …).` }); + const nameError = abutNameError(rawName); + if (nameError) { + issues.push({ code: 'AP01', level: 'E', pier: name, sheet: rec.sheet, message: `${where}: ${nameError}` }); + continue; + } + if ((counts.get(name) ?? 0) > 1 || reserved.has(name)) { + issues.push({ code: 'AP09', level: 'E', pier: name, sheet: rec.sheet, message: `${where}: 구조물명 "${name}"이 중복되었습니다. 교각·교대마다 서로 다른 이름을 사용하세요.` }); continue; } const type = String(c[1] ?? '').trim(); @@ -165,7 +178,7 @@ export function parseAbutments(wb: XLSX.WorkBook): AbutParseResult { const name = String(rec.cells[0] ?? '').trim(); const ab = abutments.find((a) => a.name === name); if (!ab) { - issues.push({ code: 'AP04', level: 'E', sheet: rec.sheet, message: `${rec.sheet} ${rec.row}행: 교대명 ${name}의 교대 정보 행이 없습니다.` }); + issues.push({ code: 'AP04', level: 'E', pier: name, sheet: rec.sheet, message: `${rec.sheet} ${rec.row}행: 교대명 ${name}의 교대 정보 행이 없습니다.` }); continue; } const point = { e: num(rec.cells[3]), n: num(rec.cells[2]) }; @@ -180,7 +193,7 @@ export function parseAbutments(wb: XLSX.WorkBook): AbutParseResult { const name = String(c[0] ?? '').trim(); const ab = abutments.find((a) => a.name === name); if (!ab) { - issues.push({ code: 'AP04', level: 'E', sheet: rec.sheet, message: `${rec.sheet} ${rec.row}행: 교대명 ${name}의 교대 정보 행이 없습니다.` }); + issues.push({ code: 'AP04', level: 'E', pier: name, sheet: rec.sheet, message: `${rec.sheet} ${rec.row}행: 교대명 ${name}의 교대 정보 행이 없습니다.` }); continue; } const h = num(c[8]); diff --git a/src/core/project.ts b/src/core/project.ts index d974b8e..837aeff 100644 --- a/src/core/project.ts +++ b/src/core/project.ts @@ -2,6 +2,7 @@ import type { FckByMember, Material } from './rebar/common'; import { checkStoredGroundModel, type GroundModelFile } from './ground-model'; import type { PierInput } from './types'; import type { AbutRecord } from './abut/parse'; +import { abutNameError } from './abut/name'; import { normalizeBearingAzimuths } from './coordinate-frame'; /** Project file schema (spec §8). Bump only with a migration. */ @@ -74,8 +75,9 @@ export function parseProject(text: string): ProjectFile { } } if (obj.groundModel !== undefined) checkStoredGroundModel(obj.groundModel); - if (obj.abutments !== undefined) checkStoredAbutments(obj.abutments); - if (obj.abutDefaults !== undefined) checkStoredAbutments(obj.abutDefaults); + const pierNames = obj.piers.map((p) => p?.input?.name).filter((name): name is string => typeof name === 'string'); + if (obj.abutments !== undefined) checkStoredAbutments(obj.abutments, pierNames); + if (obj.abutDefaults !== undefined) checkStoredAbutments(obj.abutDefaults, pierNames); obj.piers.forEach((p, i) => { if (!p || typeof p.id !== 'string' || !p.input) throw new Error(`교각 ${i + 1}: id 또는 input이 없습니다.`); const input = p.input; @@ -111,11 +113,17 @@ const finite = (v: unknown) => typeof v === 'number' && Number.isFinite(v); const isPoint = (v: unknown) => !!v && typeof v === 'object' && finite((v as { e?: unknown }).e) && finite((v as { n?: unknown }).n); /** 교대 기록 check (AB): refuses a damaged abutment record with a message that names it. */ -export function checkStoredAbutments(list: unknown): void { +export function checkStoredAbutments(list: unknown, reservedNames: readonly string[] = []): void { if (!Array.isArray(list)) throw new Error('교대 목록 형식이 맞지 않습니다.'); + const names = new Set(reservedNames.map((name) => name.trim())); list.forEach((a: Partial, i) => { const who = `교대 ${i + 1}(${String(a?.name)})`; - if (!a || typeof a.name !== 'string' || !/^A\d+$/.test(a.name)) throw new Error(`${who}: 교대명이 맞지 않습니다.`); + const nameError = abutNameError(a?.name); + if (nameError) throw new Error(`${who}: ${nameError}`); + const name = a.name!.trim(); + if (names.has(name)) throw new Error(`${who}: 구조물명 "${name}"이 중복되었습니다.`); + names.add(name); + a.name = name; if (a.type !== 'A1' && a.type !== 'A2') throw new Error(`${who}: 형식은 A1 또는 A2여야 합니다.`); for (const key of ['seatEl', 'foundationBottomEl', 'groundEl', 'pileLengthMm', 'skewDeg', 'slopeI'] as const) { if (!finite(a[key])) throw new Error(`${who}: ${key} 값이 맞지 않습니다.`); diff --git a/tests/abut-name.test.ts b/tests/abut-name.test.ts new file mode 100644 index 0000000..ad42916 --- /dev/null +++ b/tests/abut-name.test.ts @@ -0,0 +1,128 @@ +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import * as XLSX from 'xlsx'; +import { describe, expect, it } from 'vitest'; +import { parseAbutments } from '../src/core/abut/parse'; +import { checkStoredAbutments, parseProject, serializeProject, SCHEMA_VERSION } from '../src/core/project'; +import { abutNameError } from '../src/core/abut/name'; +import { groupAbutImportIssues } from '../src/core/abut/import-issues'; +import { structureOrder } from '../src/core/abut/order'; +import { loadP1 } from './helpers'; + +const file = fileURLToPath(new URL('../../../RAG/운곡교P1입력.xlsx', import.meta.url)); +function workbook(name: string) { + const wb = XLSX.read(readFileSync(file), { type: 'buffer' }); + for (const sheet of wb.SheetNames) { + const rows = XLSX.utils.sheet_to_json(wb.Sheets[sheet], { header: 1, defval: null }); + for (const row of rows) if (row[0] === 'A1') row[0] = name; + wb.Sheets[sheet] = XLSX.utils.aoa_to_sheet(rows); + } + return wb; +} + +describe('full abutment display names are preserved across import and project reopen', () => { + it.each(['A1-완도', 'A2-완도', '완도 A1 (상행) #2'])('connects all IPs and bearings for %s', (name) => { + const original = parseAbutments(workbook('A1')).abutments[0]; + const result = parseAbutments(workbook(name)); + expect(result.issues).toEqual([]); + expect(result.abutments).toEqual([{ ...original, name }]); + expect(result.abutments[0].type).toBe('A1'); // The name must not choose the structural type. + }); + + it('uses the same trimmed full name in info, IP and bearing tables', () => { + const result = parseAbutments(workbook(' A1-완도 ')); + expect(result.issues).toEqual([]); + expect(result.abutments[0].name).toBe('A1-완도'); + expect(result.abutments[0].bearings).toHaveLength(5); + }); + + it('reopens full names and imported defaults without losing identity', () => { + const base = parseAbutments(workbook('A1')).abutments[0]; + const records = [{ ...base, name: 'A1-완도' }, { ...base, name: 'A2-완도', type: 'A2' as const }]; + const text = serializeProject({ + schemaVersion: SCHEMA_VERSION, + project: { name: '복합명', bridge: '완도', client: '', author: '', crs: 'KATEC', memo: '' }, + source: { fileName: '입력.xlsx', sheets: ['Pier', 'IP', 'Bearing'] }, + piers: [{ id: 'P1', input: loadP1() }], + abutments: records, + abutDefaults: records, + history: [], + }); + const reopened = parseProject(text); + expect(reopened.abutments).toEqual(records); + expect(reopened.abutDefaults).toEqual(records); + }); +}); + +describe('ambiguous and damaged names remain errors', () => { + it.each(['', ' ', 'A1\n완도', 'A1\u0000완도', null, 123])('rejects an empty, non-string or control-character name: %s', (name) => { + expect(abutNameError(name)).not.toBeNull(); + const rec = parseAbutments(workbook('A1')).abutments[0]; + expect(() => checkStoredAbutments([{ ...rec, name }])).toThrow(); + }); + + it('rejects duplicate info rows instead of connecting to the first definition', () => { + const wb = workbook('A1-완도'); + const rows = wb.SheetNames.flatMap((sheet) => XLSX.utils.sheet_to_json(wb.Sheets[sheet], { header: 1, defval: null })); + const info = rows.find((row) => row[0] === 'A1-완도' && row[1] === 'A1')!; + XLSX.utils.book_append_sheet(wb, XLSX.utils.aoa_to_sheet([['교대명', '형식'], info]), '중복정보'); + const result = parseAbutments(wb); + expect(result.abutments).toEqual([]); + expect(result.issues.filter((issue) => issue.code === 'AP09')).toHaveLength(2); + const rec = parseAbutments(workbook('A1')).abutments[0]; + expect(() => checkStoredAbutments([{ ...rec, name: 'A1-완도' }, { ...rec, name: ' A1-완도 ' }])).toThrow('중복'); + }); + + it('rejects collisions with pier names in input and project validation', () => { + expect(parseAbutments(workbook('P1'), ['P1']).issues.some((issue) => issue.code === 'AP09')).toBe(true); + const rec = parseAbutments(workbook('A1')).abutments[0]; + expect(() => checkStoredAbutments([{ ...rec, name: 'P1' }], ['P1'])).toThrow('중복'); + }); + + it('rejects invalid workbook names and groups every dependent row under the root', () => { + const result = parseAbutments(workbook('A1\n완도')); + expect(result.abutments).toEqual([]); + const groups = groupAbutImportIssues(result.issues); + expect(groups).toHaveLength(1); + expect(groups[0].primary.map((issue) => issue.code)).toEqual(['AP01']); + expect(groups[0].related).toHaveLength(8); // Three IPs and five bearings remain traceable. + }); + + it('keeps genuine orphan references visible without dropping any rows', () => { + const wb = workbook('A1-완도'); + for (const sheet of wb.SheetNames) { + const rows = XLSX.utils.sheet_to_json(wb.Sheets[sheet], { header: 1, defval: null }); + for (const row of rows) if (row[0] === 'A1-완도' && row[1] === 'A1') row[0] = '다른 교대'; + wb.Sheets[sheet] = XLSX.utils.aoa_to_sheet(rows); + } + const result = parseAbutments(wb); + const groups = groupAbutImportIssues(result.issues); + const missing = groups.find((group) => group.name === 'A1-완도')!; + expect(missing.primary[0].code).toBe('AP04'); + expect(missing.primary[0].message).toContain('8개 행'); + expect(missing.related).toHaveLength(8); + expect(groups.some((group) => group.name === '다른 교대' && group.primary.some((issue) => issue.code === 'AP05'))).toBe(true); + }); + + it('does not merge errors for different names or promote warnings to errors', () => { + const groups = groupAbutImportIssues([ + { code: 'AP02', level: 'E', pier: 'A1-완도', message: '형식 오류' }, + { code: 'AP04', level: 'E', pier: 'A1-완도', message: 'IP 3행' }, + { code: 'AP04', level: 'E', pier: 'A2-완도', message: 'IP 4행' }, + { code: 'AP07', level: 'W', pier: 'A3-완도', message: '높이 차이' }, + ]); + expect(groups.map((group) => group.name)).toEqual(['A1-완도', 'A2-완도', 'A3-완도']); + expect(groups[2].primary[0].level).toBe('W'); + expect(groups[2].related).toEqual([]); + }); + + it('sorts full names naturally inside type groups, ignoring suffix numbers as pier indices', () => { + const records = [ + { name: 'A2-1', type: 'A1' as const }, + { name: 'A1-20', type: 'A1' as const }, + { name: '완도 종점', type: 'A2' as const }, + ]; + expect(structureOrder(records, ['P1']).map((entry) => entry.name)).toEqual(['A1-20', 'A2-1', 'P1', '완도 종점']); + expect(records[0].name).toBe('A2-1'); + }); +});