feat(abut): 교대 복합명(A1-완도 등)과 가져오기 문제 목록
교대 이름을 전체 명칭으로 해석하고, 엑셀 가져오기 문제를 목록으로 모은다. 교각 이름과의 중복을 저장 파일 검사에서도 막는다(checkStoredAbutments). 검사 스크립트 verify-abut-names · verify-abut-workflow 포함. Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
0c23173a1a
commit
3922b330d5
8 files changed
+610
-13
No files matched your search
@@ -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();
|
||||
});
|
||||
@@ -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 <input.xlsx> <output-directory> <download-directory>
|
||||
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();
|
||||
});
|
||||
@@ -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<string, { name: string; issues: Issue[] }>();
|
||||
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 };
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 })),
|
||||
|
||||
+19
-6
@@ -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<string, number>();
|
||||
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]);
|
||||
|
||||
+12
-4
@@ -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<AbutRecord>, 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} 값이 맞지 않습니다.`);
|
||||
|
||||
@@ -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<unknown[]>(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<unknown[]>(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<unknown[]>(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');
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user