feat(est-test): 찾기 창 다음찾기와 한 건 바꾸기
- 원본 찾기 창처럼 처음부터/현재위치부터 다음찾기로 행 이동, 호표 자동 전환 - 단가산출 찾은 행 한 건 바꾸기 뒤 다음 위치로 이동 - 편집 메뉴 찾기와 Ctrl+F 로 열기 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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}
|
||||
@@ -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).
|
||||
|
||||
@@ -22,6 +22,8 @@
|
||||
|
||||
내역서·일위대가 화면의 `편집 → 단가 고르기`는 자재·노임·기계경비·일위대가·단가산출·견적·실적 자료를 검색어로 찾아 선택 행 아래에 넣거나, 선택 행 하나의 연결을 바꿉니다. 여러 개를 골라 한 번에 넣을 수 있고, 바꿀 때는 변경 전·후를 확인한 뒤 같은 코드를 쓰는 이 표의 다른 행도 함께 바꿀지 고릅니다. 넣거나 바꾼 행만 원천 단가를 다시 읽습니다. 단가산출 산출근거 행은 대상이 아닙니다.
|
||||
|
||||
`편집 → 찾기·다음찾기`(Ctrl+F)는 원본 찾기 창처럼 검색어를 처음부터 또는 현재 위치부터 찾아 해당 행으로 이동합니다. 일위대가·단가산출·기계경비는 찾은 행의 호표로 넘어갑니다. 단가산출에서는 찾은 행의 문구를 한 건씩 바꾸고 다음 위치로 이동하며, 모두 바꾸기는 `산출근거 문구 바꾸기`를 씁니다. 표 위 검색창은 걸러 보기로 그대로 남습니다.
|
||||
|
||||
수량산출 화면의 수량 항목마다 `끝수 처리`에서 AUTO(산출서 기본)·절사·올림·반올림 0~5자리를 고릅니다. 원본처럼 결과 코드 뒤에 방식과 자리수를 붙이며(`r000001` → `r00000131`), 산출 행의 결과 표지도 함께 바꿉니다. 이 변경이 있는 작업은 ESTX 수정 저장을 거부하고 JSON 저장을 안내합니다.
|
||||
|
||||
내역서 편집 메뉴의 `내역 수량 초기화`는 선택한 상세 행 또는 모든 상세 행의 수량을 0으로 바꿉니다. 비율·합계 자동행은 건드리지 않으며 실행 취소할 수 있습니다.
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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 };
|
||||
})();
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user