From 982ba07fea7da9af78943e2bc2d8afbd8e795f7c Mon Sep 17 00:00:00 2001 From: umsangdon Date: Wed, 11 Nov 2026 14:56:47 +0900 Subject: [PATCH] =?UTF-8?q?feat(est-test):=20=EC=B0=BE=EA=B8=B0=20?= =?UTF-8?q?=EC=B0=BD=20=EB=8B=A4=EC=9D=8C=EC=B0=BE=EA=B8=B0=EC=99=80=20?= =?UTF-8?q?=ED=95=9C=20=EA=B1=B4=20=EB=B0=94=EA=BE=B8=EA=B8=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 원본 찾기 창처럼 처음부터/현재위치부터 다음찾기로 행 이동, 호표 자동 전환 - 단가산출 찾은 행 한 건 바꾸기 뒤 다음 위치로 이동 - 편집 메뉴 찾기와 Ctrl+F 로 열기 Co-Authored-By: Claude Opus 5.5 --- resources/tester/test_est_find.py | 78 ++++++++++++++ tools/est-test/PLAN.md | 5 +- tools/est-test/README.md | 2 + tools/est-test/index.html | 3 +- tools/est-test/native-actions.js | 2 +- tools/est-test/native-business-actions.js | 3 +- tools/est-test/native-find.js | 121 ++++++++++++++++++++++ tools/est-test/server.cjs | 1 + 8 files changed, 211 insertions(+), 4 deletions(-) create mode 100644 resources/tester/test_est_find.py create mode 100644 tools/est-test/native-find.js diff --git a/resources/tester/test_est_find.py b/resources/tester/test_est_find.py new file mode 100644 index 000000000..5c2cc80d3 --- /dev/null +++ b/resources/tester/test_est_find.py @@ -0,0 +1,78 @@ +"""EST 테스트 앱 찾기 창: 다음찾기 순서·호표 위치·산출근거 문구.""" + +import json +import subprocess +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +FILES = [ + "native-math.js", + "native-formula.js", + "native-machine.js", + "native-rows.js", + "native-text.js", + "native-quantity.cjs", + "native-wonga.js", + "native-engine.js", + "native-find.js", +] + + +def run_js(code: str): + runner = f""" + const fs = require('fs'), vm = require('vm'); + const ctx = vm.createContext({{ structuredClone, console }}); + for (const f of {json.dumps(FILES)}) + vm.runInContext(fs.readFileSync('tools/est-test/' + f, 'utf8'), ctx, {{ filename: f }}); + ctx.project = JSON.parse(fs.readFileSync('tools/est-test/sample.json', 'utf8')); + process.stdout.write(JSON.stringify(vm.runInContext({json.dumps(code)}, ctx))); + """ + out = subprocess.check_output(["node", "-e", runner], cwd=ROOT) + return json.loads(out.decode("utf-8")) + + +def test_next_walks_matches_in_order_and_stops_at_end(): + result = run_js("""(() => { + const rows = [{Name:'터파기'},{Name:'되메우기'},{Name:'잔토 터파기',Size:'백호'},{Name:'포장'}]; + const first = EstFind.next(rows, '터파기', 0); + const second = EstFind.next(rows, '터파기', first + 1); + const end = EstFind.next(rows, '터파기', second + 1); + const spec = EstFind.next(rows, '백호', 0); + const loose = EstFind.next(rows, ' 잔토 ', 0); + let empty = ''; + try { EstFind.next(rows, ' ', 0); } catch (e) { empty = e.message; } + return {first, second, end, spec, loose, empty}; + })()""") + assert result == { + "first": 0, + "second": 2, + "end": -1, + "spec": 2, + "loose": 2, + "empty": "검색어를 입력하세요.", + } + + +def test_search_reads_cost_line_text_and_skips_previous_values(): + result = run_js("""(() => { + const rows = [ + {LineData:'\\x0e콘크리트\\x0e \\x13타설\\x13', Name_:'옛이름'}, + {Name:'다른 행'} + ]; + return { + line: EstFind.next(rows, '타설', 0), + old: EstFind.next(rows, '옛이름', 0) + }; + })()""") + assert result == {"line": 0, "old": -1} + + +def test_group_position_for_head_rows(): + result = run_js("""(() => { + const rows = project.tables.HeadMainDat; + const titles = rows.map((r, i) => [i, r]).filter(([, r]) => Number(r.Index) > 0); + const [start, title] = titles[1]; + const hit = EstFind.groupOf(rows, start + 1); + return {code: hit.code === title.Code, position: hit.position}; + })()""") + assert result == {"code": True, "position": 1} diff --git a/tools/est-test/PLAN.md b/tools/est-test/PLAN.md index 9138ff8e8..eea4daade 100644 --- a/tools/est-test/PLAN.md +++ b/tools/est-test/PLAN.md @@ -432,9 +432,12 @@ ative-standard.js에 표준시장단가 원천(SilJukDat)과 내역 행(EstMainD - [x] 수량 항목 끝수 처리(원본 TFRMMULLANG mnuAuto·mnuTrunc·mnuUp·mnuRound 0~5) — 결과 코드 뒤 방식+자리수(1 반올림·2 절사·3 올림)로 항목·결과 표지 함께 바꿈, 중복 코드 거부. 새 항목 코드가 끝수 붙은 코드의 앞 7자리와 겹치던 오류 수정. ESTX 수정 저장은 명확한 문구로 거부. 연결 내역코드 칸의 선택 상자가 옆 칸을 덮던 배치 수정. - 검증완료: `test_est_quantity_format.py` 4개(1.2345 → AUTO 1.23·올림1 1.3·반올림3 1.235·AUTO 복귀, 거부 3종, 코드 생성, 설치 main.estx 저장 거부 문구), 수량·ESTX 저장 시험 포함 50개 통과. ORCA 브라우저에서 선택 상자로 올림 1자리 → 재계산 1.3·코드 r00000031 확인, 샘플 다시 열어 원상 복구. - 남음: 끝수 변경의 ESTX 저장(원본 항목 코드 키 변경 — 인덱스 재생성 필요). + - [x] 찾기 창(원본 TFRMESTSERCH·TFRMCOSTWORDCHANGE) — 검색어·처음부터/현재위치부터·다음찾기로 행 이동(호표 자동 전환·쪽 이동), 단가산출 한 건씩 바꾸기 후 다음 위치, Ctrl+F. 이전 값(`*_`) 필드는 찾지 않음. + - 검증완료: `test_est_find.py` 3개, 단추·단가 고르기·행 동작 포함 24개 통과. ORCA 브라우저에서 Ctrl+F로 창 열기, 내역 「합판거푸집」 8·15·22·26행 순서 이동, 단가산출 마지막 호표 행 찾기(호표 D0000000006 전환·선택 표시), 바꾸기 후 다음 위치(323행)·되돌리기 원상 확인. + - 남음: 통합검색(TFRMMULTISERCH — 다른 공사·폴더 자료 검색). - 대조 합계(CAD 제외 882개): 구현 283 · 부분 193 · 없음 273 · 제외 133. - [ ] 남은 핵심(실무 빈도순): - 1. 찾기 — 다음 찾기·한 건씩 바꾸기·전체 자료 통합검색(지금은 현재 표 걸러 보기만). + 1. 통합검색 — 저장된 다른 작업에서 단가·호표 찾기. 2. 공내역 파일로 저장(단가 비운 사본). 3. 수량산출서 — 기존 줄 수량 지정·해제, 누계·4방향 합계, 산출서 복사·붙여넣기·삽입·순서, 제출용 전체 인쇄. 4. Excel → EST 열 맞춤 가져오기(남의 공내역 Excel). diff --git a/tools/est-test/README.md b/tools/est-test/README.md index 4a3112584..b273285e3 100644 --- a/tools/est-test/README.md +++ b/tools/est-test/README.md @@ -22,6 +22,8 @@ 내역서·일위대가 화면의 `편집 → 단가 고르기`는 자재·노임·기계경비·일위대가·단가산출·견적·실적 자료를 검색어로 찾아 선택 행 아래에 넣거나, 선택 행 하나의 연결을 바꿉니다. 여러 개를 골라 한 번에 넣을 수 있고, 바꿀 때는 변경 전·후를 확인한 뒤 같은 코드를 쓰는 이 표의 다른 행도 함께 바꿀지 고릅니다. 넣거나 바꾼 행만 원천 단가를 다시 읽습니다. 단가산출 산출근거 행은 대상이 아닙니다. +`편집 → 찾기·다음찾기`(Ctrl+F)는 원본 찾기 창처럼 검색어를 처음부터 또는 현재 위치부터 찾아 해당 행으로 이동합니다. 일위대가·단가산출·기계경비는 찾은 행의 호표로 넘어갑니다. 단가산출에서는 찾은 행의 문구를 한 건씩 바꾸고 다음 위치로 이동하며, 모두 바꾸기는 `산출근거 문구 바꾸기`를 씁니다. 표 위 검색창은 걸러 보기로 그대로 남습니다. + 수량산출 화면의 수량 항목마다 `끝수 처리`에서 AUTO(산출서 기본)·절사·올림·반올림 0~5자리를 고릅니다. 원본처럼 결과 코드 뒤에 방식과 자리수를 붙이며(`r000001` → `r00000131`), 산출 행의 결과 표지도 함께 바꿉니다. 이 변경이 있는 작업은 ESTX 수정 저장을 거부하고 JSON 저장을 안내합니다. 내역서 편집 메뉴의 `내역 수량 초기화`는 선택한 상세 행 또는 모든 상세 행의 수량을 0으로 바꿉니다. 비율·합계 자동행은 건드리지 않으며 실행 취소할 수 있습니다. diff --git a/tools/est-test/index.html b/tools/est-test/index.html index 9a01f9acc..9112f8474 100644 --- a/tools/est-test/index.html +++ b/tools/est-test/index.html @@ -38,6 +38,7 @@ + @@ -88,7 +89,7 @@ >
- + diff --git a/tools/est-test/native-actions.js b/tools/est-test/native-actions.js index 8e9861ff1..fa70740ff 100644 --- a/tools/est-test/native-actions.js +++ b/tools/est-test/native-actions.js @@ -227,7 +227,7 @@ const EstActions = (() => { EstApp.calculate(); EstApp.cache(); EstApp.render(); - } else if (name === "find") EstApp.byId("search").focus(); + } else if (name === "find") EstFind.handle(name, EstApp.state); else if (name === "level") { if (!selected().length) throw Error("레벨을 바꿀 행을 선택하세요."); const level = Number(node.dataset.level); diff --git a/tools/est-test/native-business-actions.js b/tools/est-test/native-business-actions.js index df7548127..6be4570c1 100644 --- a/tools/est-test/native-business-actions.js +++ b/tools/est-test/native-business-actions.js @@ -599,7 +599,8 @@ const EstBusinessActions = (() => { ); } else if ( EstBlockActions.handle(name, state) || - (typeof EstPick !== "undefined" && EstPick.handle(name, state)) + (typeof EstPick !== "undefined" && EstPick.handle(name, state)) || + (typeof EstFind !== "undefined" && EstFind.handle(name, state)) ) { EstApp.render(); } else if (name === "load-contract" || name === "load-bid") { diff --git a/tools/est-test/native-find.js b/tools/est-test/native-find.js new file mode 100644 index 000000000..bd5193de2 --- /dev/null +++ b/tools/est-test/native-find.js @@ -0,0 +1,121 @@ +// 원본 찾기(TFRMESTSERCH: 검색어·처음부터/현재위치부터·다음찾기)와 +// 산출근거 바꾸기(TFRMCOSTWORDCHANGE: 다음찾기·바꾸기)를 옮긴 찾기 창. 모두 바꾸기는 기존 `산출근거 문구 바꾸기`. +const EstFind = (() => { + const grouped = ["HeadMainDat", "CostMainDat", "MachMainDat"]; + const fold = (value) => + String(value ?? "") + .normalize("NFKC") + .toLowerCase(); + + function text(row) { + return Object.entries(row) + .filter(([key, value]) => typeof value === "string" && !key.endsWith("_")) + .map(([key, value]) => (key === "LineData" ? EstText.clean(value) : value)) + .join("\n"); + } + + function next(rows, query, from = 0) { + const q = fold(query).trim(); + if (!q) throw Error("검색어를 입력하세요."); + for (let index = Math.max(0, from); index < rows.length; index++) + if (fold(text(rows[index])).includes(q)) return index; + return -1; + } + + function groupOf(rows, index) { + for (const [code, group] of EstNative.groups(rows)) + if (index >= group.start && index < group.start + group.rows.length) + return { code, position: index - group.start }; + return { code: "", position: index }; + } + + function open(state) { + const rows = state.project.tables[state.view]; + if (!Array.isArray(rows)) throw Error("표 화면에서 찾기를 사용하세요."); + state.find ||= { query: "", replacement: "", from: "current" }; + const { esc } = EstApp, + cost = state.view === "CostMainDat"; + EstApp.showDialog( + "찾기(Ctrl+F)", + `
${cost ? `` : ""}
${cost ? ' ' : ""}

`, + ); + document.querySelector('#dialog-content [name="find-query"]').focus(); + } + + function read(state) { + const value = (name) => document.querySelector(`#dialog-content [name="${name}"]`)?.value; + state.find.query = value("find-query") ?? ""; + state.find.replacement = value("find-replacement") ?? state.find.replacement; + state.find.from = value("find-from") || "current"; + } + + function show(state, index) { + const rows = state.project.tables[state.view], + { code, position } = grouped.includes(state.view) + ? groupOf(rows, index) + : { code: state.group, position: index }; + state.group = code; + state.query = ""; + EstApp.byId("search").value = ""; + state.page = Math.floor(position / 100); + state.selection = new Set([index]); + EstApp.render(); + } + + function findNext(state) { + read(state); + const rows = state.project.tables[state.view], + start = + state.find.from === "first" + ? 0 + : Math.max(-1, ...[...state.selection].map(Number).filter(Number.isFinite)) + 1; + const index = next(rows, state.find.query, start); + const result = EstApp.byId("find-result"); + if (index < 0) { + result.textContent = "끝까지 찾았습니다. 더 찾을 항목이 없습니다."; + return; + } + state.find.from = "current"; + document.querySelector('#dialog-content [name="find-from"]').value = "current"; + show(state, index); + result.textContent = `${index + 1}번째 행을 찾았습니다.`; + } + + function replaceCurrent(state) { + read(state); + if (state.view !== "CostMainDat") throw Error("단가산출 화면에서 바꾸기를 사용하세요."); + const rows = state.project.tables.CostMainDat, + index = [...state.selection][0]; + if (state.selection.size !== 1 || !rows[index]) throw Error("먼저 다음찾기로 행을 찾으세요."); + const updates = EstText.replaceWords( + [{ row: rows[index], index }], + state.find.query, + state.find.replacement, + ); + if (!updates.length) throw Error("이 행에는 바꿀 문구가 없습니다. 다음찾기를 누르세요."); + EstApp.change((project) => (project.tables.CostMainDat[index].LineData = updates[0].value)); + findNext(state); + } + + function handle(name, state) { + if (name === "find") open(state); + else if (name === "find-next") findNext(state); + else if (name === "find-replace") replaceCurrent(state); + else return false; + return true; + } + + if (typeof document !== "undefined" && document.addEventListener) + document.addEventListener("keydown", (event) => { + if (!(event.ctrlKey || event.metaKey) || event.key.toLowerCase() !== "f") return; + event.preventDefault(); + try { + open(EstApp.state); + } catch (error) { + EstApp.state.notice = error.message; + EstApp.render(); + } + }); + + return { text, next, groupOf, handle }; +})(); diff --git a/tools/est-test/server.cjs b/tools/est-test/server.cjs index 87a9a3c9c..a206156f0 100644 --- a/tools/est-test/server.cjs +++ b/tools/est-test/server.cjs @@ -58,6 +58,7 @@ const allowed = new Set([ "native-business-actions.js", "native-block-actions.js", "native-pick.js", + "native-find.js", "native-issues.js", "native-attachments.js", "native-standard.js",