diff --git a/resources/tester/test_est_global_vars.py b/resources/tester/test_est_global_vars.py new file mode 100644 index 000000000..77ab9c581 --- /dev/null +++ b/resources/tester/test_est_global_vars.py @@ -0,0 +1,45 @@ +"""EST 테스트 앱 단가산출 전역변수(원본 \x05 표지 — 호표를 넘어 값 유지, 지정·해제, 목록).""" + +import json +import subprocess +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] + + +def test_global_variable_crosses_groups_and_toggle(): + code = r""" + const fs = require('fs'), vm = require('vm'); + const ctx = vm.createContext({ structuredClone }); + for (const f of ['native-math.js', 'native-formula.js', 'native-text.js', 'native-global-vars.js']) + vm.runInContext(fs.readFileSync('tools/est-test/' + f, 'utf8'), ctx, { filename: f }); + const T = vm.runInContext('EstText', ctx), G = vm.runInContext('EstGlobalVars', ctx); + const rows = [ + { Index: 1, Code: 'D1', LineData: '호표1' }, + { Index: 0, SCheck: 0, LineData: '\x05Q\x05 = 2 × 3 = \x126\x12' }, + { Index: 1, Code: 'D2', LineData: '호표2' }, + { Index: 0, SCheck: -1, LineData: '노무 = Q × 10 = \x120\x12' }, + { Index: 1, Code: 'D3', LineData: '호표3' }, + { Index: 0, SCheck: 0, LineData: 'R = 1 = \x121\x12' }, + { Index: 1, Code: 'D4', LineData: '호표4' }, + { Index: 0, SCheck: -1, LineData: '노무 = R × 10 = \x120\x12' }, + ]; + const r = T.calculate(rows, {}); + let bad = ''; try { G.toggle('가 = 3', 0); } catch (e) { bad = e.message.slice(0, 12); } + process.stdout.write(JSON.stringify({ + used: r.rows[3].LineData.match(/\x12([^\x12]+)\x12/)[1], kept: r.rows[1].LineData.startsWith('\x05Q\x05'), + local: r.errors.length, globals: r.globals, + on: G.toggle('Q = 2', 0), off: G.toggle('⟦전역⟧Q⟦전역⟧ = 2', 5), bad, + clean: T.clean('\x05Q\x05 = 1'), shown: T.display('\x05V1\x05'), back: T.encode('⟦전역⟧V1⟦전역⟧') === '\x05V1\x05', + })); + """ + out = json.loads(subprocess.check_output(["node", "-e", code], cwd=ROOT).decode("utf-8")) + assert out["used"] == "60" + assert out["kept"] is True + assert out["local"] == 1 # R 은 전역이 아니라 D4 에서 못 찾음 + assert out["globals"] == [{"name": "Q", "value": 6, "index": 1}] + assert out["on"] == "⟦전역⟧Q⟦전역⟧ = 2" + assert out["off"] == "Q = 2" + assert out["bad"] == "선택한 변수는 전역변수" + assert out["clean"] == "Q = 1" + assert out["shown"] == "⟦전역⟧V1⟦전역⟧" and out["back"] is True diff --git a/tools/est-test/PLAN.md b/tools/est-test/PLAN.md index c61170313..02631c567 100644 --- a/tools/est-test/PLAN.md +++ b/tools/est-test/PLAN.md @@ -489,6 +489,12 @@ ative-standard.js에 표준시장단가 원천(SilJukDat)과 내역 행(EstMainD - 남음: 구성비·요율 전환(CalcBase), 약어 표시(SName), 제비율 기준 문서 보기(원본 한글 파일). - [x] 수량산출서 검색·전체보기(원본 TFRMMULLANG btnSerch·btnAllDisplay — 빈칸·|·괄호 무시 포함 검색) — 고르기 줄을 `native-quantity-sheets.js` 로 옮겨 700줄 제한 유지. - 검증완료: `test_est_quantity_sheets.py` 검색 시험 포함 수량 시험 22개 통과. ORCA 브라우저에서 「집수」 검색 → 항목에 집수정이 있는 「배수」만·선택 이동, 전체보기 → 3개 복귀, 샘플 복구. + - [x] 수량산출서 삽입·선택공종 삭제(원본 TFRMMULLANG btnInsert·btnSelectDel) — 「삽입」: 현재 산출서 앞에 공종명칭·규격을 물어 새 산출서, 「선택공종 삭제」: 고른 여러 산출서를 한 번에 지움. + - 검증완료: 산출서·바인딩 시험 7개 통과. ORCA 브라우저에서 둘째 위치에 「끼운 공종/1m」 삽입, 1·3번째 고른 삭제 → 「끼운 공종」만 남음, 샘플 복구. + - [x] 새내역서 파일로저장(원본 mnuSaveNewEst 0x113384c) — 현재 화면은 그대로, 표 12개의 「_」 당초값 칸 비움·신규 표시 끔·원가 안전관리비 특례 줄(CalcIndex 4)→1·당초 사본 뺀 새 작업을 이름 붙여 서버에 따로 저장하고 내려받기. 재계산 없음. + - 검증완료: 바인딩 시험 통과. ORCA 브라우저에서 GN_ 5·신규 표시·당초 사본 둔 샘플 → 새 작업에는 GN_ 없음·신규 끔·당초 사본 없음, 현재 화면은 GN_ 5 그대로, 시험 작업 지우고 샘플 복구. + - [x] 단가산출 전역변수(원본 mnuCostSetGlobalVar 0xf053cc · mnuCostGlobalvarList · 전역표 0x93c22c — spec_cost_tools §2) — LineData 의 이름(화면 ⟦전역⟧) 로 대입한 변수는 호표가 바뀌어도 남아 다른 호표에서 표지 없이 써도 찾음(그 호표 변수 → 전역표 차례). 「전역변수 지정,해제」: 마지막 산출근거 칸 커서 아래 낱말(영문자 포함 [0-9A-Za-z_])을 감싸거나 풂(원본 거부 문구). 「전역변수 목록」: 처음부터 계산한 대입마다 변수명·값·이동. 원본처럼 전역표는 저장하지 않고 계산 때마다 다시 만듦. + - 검증완료: `test_est_global_vars.py`(다른 호표 Q×10 = 60, 표지 유지, 비전역 R 은 못 찾음, 지정·해제·거부, 표시·되돌림) 및 REA 대조 39개 통과. ORCA 브라우저에서 샘플 단가산출 q 줄 지정 → 목록 「q 0.7」·이동으로 그 줄 선택, 도급액 불변, 샘플 복구. - [x] JDLExcel과 단가비교(원본 ActionJDLCompare, 숨은 Ctrl+Alt+J → TfrmJDLExcelCompare) — 일위대가·단가산출·기계경비 탭, 시트번호·코드칼럼(A)·합계셀칼럼(H), Excel(.xlsx·.xls — `/api/read-xls`) 1행~(호표 수+50)행에서 코드를 품은 칸의 같은 행 합계와 TC 비교, 차이·없음 표시. 보기 전용. 웹은 메뉴 단추로 엶. - 검증완료: `test_est_jdl_compare.py`(같음·차이 1·없음·+50행 한계·xls 셀 변환) 통과. ORCA 브라우저에서 일위대가 8개 중 같음 1·차이 10·없음 6 표시. - 제외: TfrmMultiEditor(인쇄 부품의 설계용 편집기 — 실행 파일에 클래스 없음), Est Plus(구버전 .est) 단가비교·내보내기(숨은 단축키, 394바이트 레코드 형식 — 구버전 불러오기와 함께 남음). diff --git a/tools/est-test/index.html b/tools/est-test/index.html index 6b762096e..64716fc48 100644 --- a/tools/est-test/index.html +++ b/tools/est-test/index.html @@ -63,6 +63,8 @@ + + @@ -96,6 +98,7 @@

+ >
diff --git a/tools/est-test/native-business-actions.js b/tools/est-test/native-business-actions.js index 25191017d..c0f860ad5 100644 --- a/tools/est-test/native-business-actions.js +++ b/tools/est-test/native-business-actions.js @@ -634,7 +634,9 @@ const EstBusinessActions = (() => { (typeof EstCellClip !== "undefined" && EstCellClip.handle(name, state)) || (typeof EstRowLinks !== "undefined" && EstRowLinks.handle(name, state)) || (typeof EstEsInfo !== "undefined" && EstEsInfo.handle(name, state)) || - (typeof EstJdlCompare !== "undefined" && EstJdlCompare.handle(name, state, node)) + (typeof EstJdlCompare !== "undefined" && EstJdlCompare.handle(name, state, node)) || + (typeof EstGlobalVars !== "undefined" && EstGlobalVars.handle(name, state, node)) || + (typeof EstNewEstimate !== "undefined" && EstNewEstimate.handle(name, state)) ) { EstApp.render(); } else if (name === "insert-annual") { diff --git a/tools/est-test/native-global-vars.js b/tools/est-test/native-global-vars.js new file mode 100644 index 000000000..66f134bf8 --- /dev/null +++ b/tools/est-test/native-global-vars.js @@ -0,0 +1,70 @@ +// 원본 단가산출 「전역변수 지정,해제」(mnuCostSetGlobalVar 0xf053cc)·「전역변수 목록」(mnuCostGlobalvarList → TfrmGlobalVarList). +// 지정·해제: 마지막으로 누른 산출근거 칸의 커서 아래 낱말([0-9A-Za-z_], 영문자 하나 이상)을 ⟦전역⟧ 표지로 감싸거나 푼다. +// 목록: 단가산출을 처음부터 계산해 전역변수 대입마다 변수명·값과 그 줄로 이동. +const EstGlobalVars = (() => { + const MARK = "⟦전역⟧"; + let last = null; + if (typeof document !== "undefined" && document.addEventListener) + document.addEventListener("focusin", (event) => { + const t = event.target.closest?.('[data-table="CostMainDat"][data-field="LineData"]'); + if (t) last = t; + }); + + function toggle(text, caret) { + const word = /[0-9A-Za-z_]/; + let a = caret, + b = caret; + while (a > 0 && word.test(text[a - 1])) a--; + while (b < text.length && word.test(text[b])) b++; + const name = text.slice(a, b); + if (!name || !/[A-Za-z]/.test(name)) + throw Error( + '선택한 변수는 전역변수로 지정할수 없습니다.\n\n변수는 알파벳과 숫자,"_"로 이루어져야합니다.\n\n예) Q , V1 , L_1', + ); + const before = text.slice(0, a), + after = text.slice(b); + if (before.endsWith(MARK) && after.startsWith(MARK)) + return before.slice(0, -MARK.length) + name + after.slice(MARK.length); + return `${before}${MARK}${name}${MARK}${after}`; + } + + function list(state) { + const out = EstText.calculate(state.project.tables.CostMainDat || [], state.project.options); + const { esc, number } = EstApp; + EstApp.showDialog( + "전역변수 목록", + out.globals.length + ? `${out.globals.map((g) => ``).join("")}
변수명값이동
${esc(g.name)}${number(g.value)}
` + : "

전역변수가 없습니다. 단가산출 산출근거의 변수에 커서를 두고 「전역변수 지정,해제」를 누르세요.

", + ); + } + + function handle(name, state, node) { + if (name === "global-var") { + if (!last) throw Error("단가산출 산출근거 칸의 변수에 커서를 두세요."); + const index = Number(last.dataset.row), + text = last.value, + next = toggle(text, last.selectionStart ?? 0); + EstApp.change( + (project) => (project.tables.CostMainDat[index].LineData = EstText.encode(next)), + ); + } else if (name === "global-var-list") list(state); + else if (name === "global-var-go") { + EstApp.byId("dialog").close(); + const index = Number(node.dataset.row); + state.view = "CostMainDat"; + const rows = state.project.tables.CostMainDat; + let head = index; + while (head > 0 && !(Number(rows[head].Index) > 0)) head--; + state.group = rows[head]?.Code || state.group; + state.selection.clear(); + state.selection.add(index); + EstApp.render(); + document + .querySelector(`[data-table="CostMainDat"][data-row="${index}"][data-field="LineData"]`) + ?.scrollIntoView({ block: "center" }); + } else return false; + return true; + } + return { toggle, handle }; +})(); diff --git a/tools/est-test/native-new-estimate.js b/tools/est-test/native-new-estimate.js new file mode 100644 index 000000000..e7d5c9d9b --- /dev/null +++ b/tools/est-test/native-new-estimate.js @@ -0,0 +1,54 @@ +// 원본 「새내역서 파일로저장」(mnuSaveNewEst 0x113384c) — 현재 문서는 그대로 두고 설계변경 흔적을 지운 새 작업을 따로 저장. +// 표 12개(노임·자재·견적·표준·기계기준·기계 본문/호표·단산 본문/호표·일위 본문/호표·내역서)의 「_」로 끝나는 칸(당초값)을 비우고 +// 신규 표시(NewItem)를 끄며, 원가 안전관리비 특례 줄(CalcIndex 4)은 1 로, 당초 사본(baseline)은 뺀다. 재계산 없음. +const EstNewEstimate = (() => { + const TABLES = [ + "LaborcDat", + "StuffcDat", + "EstiMateDat", + "SilJukDat", + "MachGijunDat", + "MachMainDat", + "MachTitleDat", + "CostMainDat", + "CostTitleDat", + "HeadMainDat", + "HeadTitleDat", + "EstMainDat", + ]; + function build(project, name) { + const next = structuredClone(project); + delete next.id; + delete next.baseline; + next.name = name; + for (const table of TABLES) + for (const row of next.tables[table] || []) { + for (const key of Object.keys(row)) if (key.endsWith("_")) row[key] = null; + if ("NewItem" in row) row.NewItem = false; + } + for (const row of next.tables.Wonga || []) + if ((Number(row.CalcIndex) & 0xff) === 4) row.CalcIndex = 1; + return next; + } + async function run(state) { + const name = prompt("새 내역서 이름", `${state.project.name || "내역서"} (새 내역서)`)?.trim(); + if (!name) return; + const result = await EstApp.api("/api/save", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(build(state.project, name)), + }); + EstApp.download(`${result.name}.estweb.json`, JSON.stringify(result, null, 2)); + state.notice = `새 내역서 「${result.name}」를 따로 저장했습니다. 지금 화면은 그대로입니다.`; + EstApp.render(); + } + function handle(name, state) { + if (name !== "save-new-estimate") return false; + run(state).catch((error) => { + state.notice = error.message; + EstApp.render(); + }); + return true; + } + return { build, handle }; +})(); diff --git a/tools/est-test/native-quantity-sheets.js b/tools/est-test/native-quantity-sheets.js index 9c0f64371..0e07d6441 100644 --- a/tools/est-test/native-quantity-sheets.js +++ b/tools/est-test/native-quantity-sheets.js @@ -129,7 +129,7 @@ const EstQuantitySheets = (() => { ``, ) .join(""); - return `
`; + return `
`; } function handle(name, state, node) { @@ -153,6 +153,55 @@ const EstQuantitySheets = (() => { state.quantitySheet = first.Code; return true; } + if (name === "quantity-sheet-insert") { + // 삽입(TFRMMULLANG btnInsert): 현재 산출서 앞에 새 산출서, 공종명칭(품명·규격) 입력 + const title = prompt("공종명칭", "새 수량산출서"); + if (title == null) return true; + const size = prompt("규격", "") ?? ""; + EstApp.change((project) => { + const sheets = (project.quantitySheets ||= []), + at = Math.max( + 0, + sheets.findIndex((s) => s.Code === code), + ), + next = `Q${EstApp.uid()}`; + sheets.splice(at, 0, { + Code: next, + Name: title.trim() || "새 수량산출서", + Size: size.trim(), + rows: [], + items: [], + options: { digits: 2, roundMode: 2 }, + }); + state.quantitySheet = next; + }, false); + return true; + } + if (name === "quantity-sheet-multi-delete") { + // 선택공종 삭제(btnSelectDel): 고른 산출서들을 한 번에 지움 + const { esc } = EstApp; + EstApp.showDialog( + "선택공종 삭제", + `
${(state.project.quantitySheets || []).map((s) => ``).join("")}
`, + ); + return true; + } + if (name === "quantity-sheet-multi-delete-ok") { + const codes = new Set( + [...document.querySelectorAll('#dialog-content [name="qd-sheet"]:checked')].map( + (n) => n.value, + ), + ); + if (!codes.size) throw Error("지울 산출서를 고르세요."); + if (!confirm(`산출서 ${codes.size}개를 삭제하시겠습니까?`)) return true; + EstApp.byId("dialog").close(); + EstApp.change((project) => { + project.quantitySheets = project.quantitySheets.filter((s) => !codes.has(s.Code)); + if (codes.has(state.quantitySheet)) + state.quantitySheet = project.quantitySheets[0]?.Code || "manual"; + }, false); + return true; + } if (name === "quantity-sheet-copy") { EstApp.change((project) => { state.quantitySheet = copy(project, code, EstApp.uid()); diff --git a/tools/est-test/native-text.js b/tools/est-test/native-text.js index 71d0c8ed5..29f4715ae 100644 --- a/tools/est-test/native-text.js +++ b/tools/est-test/native-text.js @@ -1,4 +1,6 @@ // REA f2a1fc/f254c8: LineData 표지와 일반·성분·마지막 합계의 정밀도. +// 전역변수(spec_cost_tools §2 · 0x93c22c): \x05이름\x05 표지로 대입한 변수는 호표가 바뀌어도 남아(전역표) 다른 호표에서 표지 없이 써도 찾는다. +// 찾는 차례 = 그 호표 변수 → 전역표. 표지는 계산 동안 떼고 계산 뒤 같은 낱말에 다시 붙인다. const EstText = (() => { const { num, components, floating, sum } = EstMath; const marker = /\x12([-\d.,]+)\x12/; @@ -6,7 +8,7 @@ const EstText = (() => { const clean = (s) => String(s || "") .replace(/[\x06\x07][^\x06\x07]*[\x06\x07]/g, "") - .replace(/[\x03\x0c\x0e\x11\x12\x13\x15\x1a]/g, ""); + .replace(/[\x03\x05\x0c\x0e\x11\x12\x13\x15\x1a]/g, ""); const numeric = (s) => Number(String(s).replaceAll(",", "")); const sumCurrency = (values) => values.reduce((n, value) => n + EstMath.even(num(value) * 10000), 0) / 10000; @@ -56,8 +58,10 @@ const EstText = (() => { titles = [], errors = [], checks = []; + const globals = Object.create(null), + globalList = []; let head = null, - variables = Object.create(null), + variables = Object.create(globals), changed = new Map(), currentSource = null, finalOverride = null, @@ -94,7 +98,7 @@ const EstText = (() => { if (num(row.Index) > 0) { finish(); head = row; - variables = Object.create(null); + variables = Object.create(globals); changed = new Map(); currentSource = null; finalOverride = null; @@ -170,7 +174,10 @@ const EstText = (() => { errors.push(`${head.Code}:${index + 1} ${error.message}`); } } - let text = original.replace(/\{([+-])\}/g, "$1"); + const globalNames = [...original.matchAll(/\x05([A-Za-z][A-Za-z0-9_]*)\x05/g)].map( + (m) => m[1], + ); + let text = original.replace(/\x05/g, "").replace(/\{([+-])\}/g, "$1"); let selectionError = ""; if (num(row.LinkIndex) === 62) { text = text.replace(/(\x07([A-Z])\x07\s*)\x1a[^\x1a]*\x1a/g, (whole, reference, group) => { @@ -599,16 +606,28 @@ const EstText = (() => { errors.push(`${head.Code}:${index + 1} ${error.message}`); row.Error = error.message; } + for (const name of globalNames) { + if (Object.hasOwn(variables, name)) { + globals[name] = variables[name]; + globalList.push({ name, value: variables[name], index }); + } + if (!String(row.LineData).includes(`\x05${name}\x05`)) + row.LineData = String(row.LineData).replace( + new RegExp(`(? { }; const display = (text) => String(text || "").replace( - /[\x03\x06\x07\x0c\x0e\x11\x12\x13\x15\x16\x1a]/g, + /[\x03\x05\x06\x07\x0c\x0e\x11\x12\x13\x15\x16\x1a]/g, (ch) => `⟦${names[ch]}⟧`, ); const encode = (text) => diff --git a/tools/est-test/server.cjs b/tools/est-test/server.cjs index e92d91f8f..f0b3eed65 100644 --- a/tools/est-test/server.cjs +++ b/tools/est-test/server.cjs @@ -85,6 +85,8 @@ const allowed = new Set([ "native-row-links.js", "native-es-info.js", "native-jdl-compare.js", + "native-global-vars.js", + "native-new-estimate.js", "native-wonga-tools.js", "native-estimate-print.js", "native-xml-check.js",