feat(est-test): 찾기 창 다음찾기와 한 건 바꾸기

- 원본 찾기 창처럼 처음부터/현재위치부터 다음찾기로 행 이동, 호표 자동 전환
- 단가산출 찾은 행 한 건 바꾸기 뒤 다음 위치로 이동
- 편집 메뉴 찾기와 Ctrl+F 로 열기

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-11 14:56:47 +09:00
co-authored by Claude Opus 5.5
parent 02ceb1e456
commit 982ba07fea
8 changed files with 211 additions and 4 deletions
+78
View File
@@ -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}
+4 -1
View File
@@ -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).
+2
View File
@@ -22,6 +22,8 @@
내역서·일위대가 화면의 `편집 → 단가 고르기`는 자재·노임·기계경비·일위대가·단가산출·견적·실적 자료를 검색어로 찾아 선택 행 아래에 넣거나, 선택 행 하나의 연결을 바꿉니다. 여러 개를 골라 한 번에 넣을 수 있고, 바꿀 때는 변경 전·후를 확인한 뒤 같은 코드를 쓰는 이 표의 다른 행도 함께 바꿀지 고릅니다. 넣거나 바꾼 행만 원천 단가를 다시 읽습니다. 단가산출 산출근거 행은 대상이 아닙니다.
`편집 → 찾기·다음찾기`(Ctrl+F)는 원본 찾기 창처럼 검색어를 처음부터 또는 현재 위치부터 찾아 해당 행으로 이동합니다. 일위대가·단가산출·기계경비는 찾은 행의 호표로 넘어갑니다. 단가산출에서는 찾은 행의 문구를 한 건씩 바꾸고 다음 위치로 이동하며, 모두 바꾸기는 `산출근거 문구 바꾸기`를 씁니다. 표 위 검색창은 걸러 보기로 그대로 남습니다.
수량산출 화면의 수량 항목마다 `끝수 처리`에서 AUTO(산출서 기본)·절사·올림·반올림 0~5자리를 고릅니다. 원본처럼 결과 코드 뒤에 방식과 자리수를 붙이며(`r000001` → `r00000131`), 산출 행의 결과 표지도 함께 바꿉니다. 이 변경이 있는 작업은 ESTX 수정 저장을 거부하고 JSON 저장을 안내합니다.
내역서 편집 메뉴의 `내역 수량 초기화`는 선택한 상세 행 또는 모든 상세 행의 수량을 0으로 바꿉니다. 비율·합계 자동행은 건드리지 않으며 실행 취소할 수 있습니다.
+2 -1
View File
@@ -38,6 +38,7 @@
<script defer src="native-c3r.js"></script>
<script defer src="native-block-actions.js"></script>
<script defer src="native-pick.js"></script>
<script defer src="native-find.js"></script>
<script defer src="native-actions.js"></script>
<script defer src="native-business-actions.js"></script>
<script defer src="native-app.js"></script>
@@ -88,7 +89,7 @@
><button data-action="select-all-rows">전체 선택</button
><button data-action="block-del">블럭 삭제</button>
<hr />
<button data-action="find">찾기</button>
<button data-action="find">찾기·다음찾기(Ctrl+F)</button>
<button data-action="pick-open">
단가 고르기(자재·노임·기계·일위·단가산출·견적·실적)
</button>
+1 -1
View File
@@ -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);
+2 -1
View File
@@ -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") {
+121
View File
@@ -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)",
`<div class="form-fields"><label>검색어<input name="find-query" value="${esc(state.find.query)}"></label>${cost ? `<label>바꿀 내용<input name="find-replacement" value="${esc(state.find.replacement)}"></label>` : ""}<label>시작<select name="find-from"><option value="current" ${state.find.from === "current" ? "selected" : ""}>현재위치부터 검색</option><option value="first" ${state.find.from === "first" ? "selected" : ""}>처음부터 검색</option></select></label></div><button data-action="find-next">다음찾기</button>${cost ? ' <button data-action="find-replace">바꾸기</button>' : ""}<p id="find-result" role="status"></p>`,
);
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 };
})();
+1
View File
@@ -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",