diff --git a/resources/tester/test_est_text_units.py b/resources/tester/test_est_text_units.py index 2293fa04d..20cb36b34 100644 --- a/resources/tester/test_est_text_units.py +++ b/resources/tester/test_est_text_units.py @@ -3,6 +3,30 @@ from test_est_rea_parity import run +def test_replace_cost_words_preserves_markers_and_checks_lengths_before_changes(): + result = run( + "math", + r"""(()=>{ + const rows=[{index:3,row:{LineData:'\x0e모래\x0e + 모래 = \x121\x12'}},{index:5,row:{LineData:'유지'}}]; + const before=JSON.stringify(rows),changed=EstText.replaceWords(rows,'모래','자갈');let rejected=0; + for(const [a,b] of [['','x'],['모래','\x12'],['모래','x'.repeat(200001)]]){ + try{EstText.replaceWords(rows,a,b)}catch{rejected++} + } + const markers=EstText.replaceWords([{index:7,row:{LineData:'Q=1 = \x121\x12; cost \x031\x03'}}],'1','2'); + const calcRows=[{Index:1,Code:'H',LineData:'head'},{LineData:'Q=1 = \x121\x12'}]; + const update=EstText.replaceWords([{row:calcRows[1],index:1}],'1','2')[0];calcRows[1].LineData=update.value; + const recalculated=EstText.calculate(calcRows); + return {changed,rejected,markers,unchanged:JSON.stringify(rows)===before, + crossing:EstText.replaceWords(rows,'모래 +','x').length,recalculated:recalculated.rows[1].LineData,errors:recalculated.errors}; + })()""", + ) + assert result["changed"] == [{"index": 3, "value": "\x0e자갈\x0e + 자갈 = \x121\x12"}] + assert result["rejected"] == 3 + assert result["markers"] == [{"index": 7, "value": "Q=2 = \x121\x12; cost \x031\x03"}] + assert "\x122\x12" in result["recalculated"] and not result["errors"] + assert result["unchanged"] and result["crossing"] == 0 + + def test_text_units_and_description_columns_preserve_numeric_assignments(): result = run( "math", diff --git a/tools/est-test/PLAN.md b/tools/est-test/PLAN.md index 1f42c0e20..4e21fd277 100644 --- a/tools/est-test/PLAN.md +++ b/tools/est-test/PLAN.md @@ -1,4 +1,6 @@ ## Codex · EST 독립 테스트 앱 (2026-11-04) +- [x] 산출근거 문구 일괄 바꾸기 → 현재 호표·전체 범위, 표지 보존·재계산·실행 취소 확인. + - 자체 검증: Tailscale HTTP Edge에서 현재 호표·전체 치환, 일치 없음 안내, 결과·단가 표지 보존과 실행 취소 확인. 산출근거 시험 24개 통과, 수식 변경 후 재계산 확인. - [x] Excel 파일 읽는 동안 파일·셀 선택이 바뀌면 이전 요청 반영 차단 → 지연 응답 시험. - 자체 검증: 지연된 Tailscale HTTP 응답 동안 셀과 파일을 각각 변경하면 이전 요청 반영이 차단되고 다시 실행은 성공. 수량 시험 12개 통과. - 자체 검증: 수량·Excel·설계변경·기성·물가변동·연차·문서·오류 기능 회귀 69개 통과(openpyxl 경고 8건). 원본 동등성 대조는 실행하지 않음. diff --git a/tools/est-test/README.md b/tools/est-test/README.md index 109728e24..d25ba0dbc 100644 --- a/tools/est-test/README.md +++ b/tools/est-test/README.md @@ -14,6 +14,8 @@ 행을 선택한 뒤 편집 메뉴의 `선택 행 자릿수 설정`에서 처리방식과 소수 자릿수를 일괄 적용할 수 있습니다. 기존 계산 설정은 실행 취소로 되돌릴 수 있습니다. +단가산출 화면의 `편집 → 산출근거 문구 바꾸기`는 현재 호표 또는 전체 산출근거에서 문구를 일괄 변경합니다. 결과값·연결 단가 등의 계산 표지는 치환하지 않으며, 자동계산이 켜져 있으면 변경 후 재계산합니다. 실행 취소로 복원할 수 있습니다. + 내역서 편집 메뉴의 `내역 수량 초기화`는 선택한 상세 행 또는 모든 상세 행의 수량을 0으로 바꿉니다. 비율·합계 자동행은 건드리지 않으며 실행 취소할 수 있습니다. 인쇄 미리보기는 화면의 100행 페이지 제한 없이 검색 결과/현재 호표의 전체 행을 표시합니다. 출력 범위에서 현재 자료의 모든 호표·행도 선택할 수 있습니다. 원본 인쇄 옵션의 41개 항목 중 CAD를 제외한 40개 서식 이름을 선택하며, ESTX에서 가져온 해당 프로젝트의 인쇄 설정을 우선 사용합니다. 내역·원가·호표/총괄·증감·동원·수량·공정은 표 자료로 출력합니다. 표지·갑지·설명서·시방서·심사 문서는 서식별 본문을 직접 입력합니다. 용지·방향·여백·글자 크기·페이지당 행·시작 쪽·업체명을 저장하고 인쇄/PDF로 출력합니다. ESTX 수정 저장은 서식별 여백과 페이지당 행 수를 반영합니다. 다른 웹 인쇄 설정의 ESTX 쓰기와 원본 인쇄 도형·병합·글꼴 배치의 동등성은 지원하지 않습니다. diff --git a/tools/est-test/index.html b/tools/est-test/index.html index 5500d22bb..93aa61582 100644 --- a/tools/est-test/index.html +++ b/tools/est-test/index.html @@ -75,6 +75,7 @@ >
+ diff --git a/tools/est-test/native-business-actions.js b/tools/est-test/native-business-actions.js index b8ec5ec0e..dfab4fc40 100644 --- a/tools/est-test/native-business-actions.js +++ b/tools/est-test/native-business-actions.js @@ -45,7 +45,56 @@ const EstBusinessActions = (() => { const { state } = EstApp, project = state.project, round = currentRound(); - if (name === "clear-estimate-quantities") { + if (name === "replace-cost-words") { + if (state.view !== "CostMainDat") throw Error("단가산출 화면에서 문구를 바꾸세요."); + const revision = state.revision, + groupCode = state.group; + EstActions.form( + "산출근거 문구 바꾸기", + [ + { name: "search", label: "찾을 문구", value: "" }, + { name: "replacement", label: "바꿀 문구", value: "" }, + { + name: "scope", + label: "범위", + value: "group", + choices: [ + ["group", "현재 호표"], + ["all", "전체 산출근거"], + ], + }, + ], + (values) => { + if ( + state.project !== project || + state.revision !== revision || + state.view !== "CostMainDat" || + state.group !== groupCode + ) + throw Error("작업이 바뀌었습니다. 문구 바꾸기를 다시 열어주세요."); + const rows = project.tables.CostMainDat, + group = EstNative.groups(rows).get(groupCode), + input = document.querySelector('#dialog-content [name="search"]'); + if (values.scope === "group" && !group) throw Error("현재 호표를 선택하세요."); + input.setCustomValidity(values.search ? "" : "찾을 문구를 입력하세요."); + if (!input.reportValidity()) throw Error("찾을 문구를 입력하세요."); + const candidates = rows + .map((row, index) => ({ row, index })) + .filter( + ({ index }) => + values.scope === "all" || + (index >= group.start && index < group.start + group.rows.length), + ), + updates = EstText.replaceWords(candidates, values.search, values.replacement); + input.setCustomValidity(updates.length ? "" : "변경할 문구가 없습니다."); + if (!input.reportValidity()) throw Error("변경할 문구가 없습니다."); + if (confirm(`${updates.length}개 행의 문구를 바꾸시겠습니까?`)) + EstApp.change(() => + updates.forEach(({ index, value }) => (rows[index].LineData = value)), + ); + }, + ); + } else if (name === "clear-estimate-quantities") { if (state.view !== "EstMainDat") throw Error("내역서에서 수량을 초기화하세요."); const rows = project.tables.EstMainDat, selected = [...state.selection]; diff --git a/tools/est-test/native-text.js b/tools/est-test/native-text.js index e3c8d1ca2..e014f1279 100644 --- a/tools/est-test/native-text.js +++ b/tools/est-test/native-text.js @@ -582,5 +582,31 @@ const EstText = (() => { /⟦([^⟦⟧]+)⟧/g, (whole, name) => Object.keys(names).find((key) => names[key] === name) || whole, ); - return { calculate, clean, display, encode }; + function replaceWords(rows, search, replacement) { + if ( + typeof search !== "string" || + typeof replacement !== "string" || + !search || + /[\x00-\x1f]/.test(search + replacement) + ) + throw Error("찾을 문구를 입력하고 제어 문자를 제외하세요."); + const protectedMarkers = new Set(["\x03", "\x06", "\x07", "\x0c", "\x11", "\x12"]); + return rows.flatMap(({ row, index }) => { + if (typeof row.LineData !== "string") return []; + let protectedMarker = ""; + const next = row.LineData.split(/([\x00-\x1f])/) + .map((part, i) => { + if (i % 2) { + if (part === protectedMarker) protectedMarker = ""; + else if (!protectedMarker && protectedMarkers.has(part)) protectedMarker = part; + return part; + } + return protectedMarker ? part : part.split(search).join(replacement); + }) + .join(""); + if (next.length > 200000) throw Error("변경 후 산출근거의 길이가 한도를 초과합니다."); + return next === row.LineData ? [] : [{ index, value: next }]; + }); + } + return { calculate, clean, display, encode, replaceWords }; })();