diff --git a/resources/tester/test_est_pick.py b/resources/tester/test_est_pick.py new file mode 100644 index 000000000..282bbc673 --- /dev/null +++ b/resources/tester/test_est_pick.py @@ -0,0 +1,136 @@ +"""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-row-match.js", + "native-escalation.js", + "native-progress.js", + "native-business.js", + "native-review.js", + "native-resources.js", + "native-schedule.js", + "native-model.js", + "native-verify.js", + "native-pick.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_search_filters_by_kind_and_all_words(): + result = run_js("""(() => { + const labor = project.tables.LaborcDat.find((r) => r.Code && r.Name); + const word = String(labor.Name).replace(/\\s+/g, "").slice(0, 2); + const one = EstPick.search(project, 6, word); + const all = EstPick.search(project, 0, labor.Code); + const none = EstPick.search(project, 6, word + " 없는말없는말"); + return { + kinds: [...new Set(one.rows.map((r) => r.kind))], + hit: one.rows.some((r) => r.Code === labor.Code), + exact: all.rows.filter((r) => r.Code === labor.Code).length, + none: none.total, + }; + })()""") + assert result == {"kinds": [6], "hit": True, "exact": 1, "none": 0} + + +def test_insert_links_rows_and_engine_prices_them(): + result = run_js("""(() => { + const labor = project.tables.LaborcDat.find((r) => r.Code && Number(r.TC) > 0); + const pick = EstPick.items(project, 6).find((r) => r.Code === labor.Code); + const rows = project.tables.EstMainDat, before = rows.length; + const added = EstPick.insert(project, "EstMainDat", 0, "", [pick, pick]); + const done = EstNative.calculate(EstModel.validate(project)); + const row = done.tables.EstMainDat[added[0]]; + return { + added, grew: done.tables.EstMainDat.length - before, + link: row.LinkIndex, code: row.Code === labor.Code, name: row.Name === labor.Name, + priced: Number(row.NU) > 0 && Number(row.NU) === Number(pick.price), + }; + })()""") + assert result == { + "added": [1, 2], + "grew": 2, + "link": 6, + "code": True, + "name": True, + "priced": True, + } + + +def test_replace_current_row_or_all_same_code(): + result = run_js("""(() => { + const rows = project.tables.EstMainDat; + const idx = rows.findIndex((r) => r.Code && !(Number(r.Index) > 0) && [5, 6, 103, 104].includes(Number(r.LinkIndex))); + const target = rows[idx]; + const twin = { ...target, PkID: 999999 }; + rows.splice(idx + 1, 0, twin); + const pick = EstPick.items(project, 5).find((r) => r.Code !== target.Code); + const one = EstPick.replace(structuredClone(project), "EstMainDat", idx, "", pick, false); + const copy = structuredClone(project); + const all = EstPick.replace(copy, "EstMainDat", idx, "", pick, true); + return { + one: one.length, all: all.length >= 2, + moved: copy.tables.EstMainDat[idx + 1].Code === pick.Code, + kind: copy.tables.EstMainDat[idx].LinkIndex, + untouched: project.tables.EstMainDat[idx].Code === target.Code, + }; + })()""") + assert result == {"one": 1, "all": True, "moved": True, "kind": 5, "untouched": True} + + +def test_guards_reject_wrong_view_self_reference_and_title_rows(): + result = run_js("""(() => { + const out = []; + const tryIt = (fn) => { try { fn(); out.push("ok"); } catch (e) { out.push(e.message.slice(0, 12)); } }; + const head = EstPick.items(project, 103)[0]; + tryIt(() => EstPick.insert(project, "CostMainDat", 0, "", [head])); + tryIt(() => EstPick.insert(project, "HeadMainDat", 0, head.Code, [head])); + tryIt(() => EstPick.insert(project, "EstMainDat", 0, "", [])); + const title = project.tables.HeadMainDat.findIndex((r) => Number(r.Index) > 0); + tryIt(() => EstPick.replace(project, "HeadMainDat", title, "", EstPick.items(project, 6)[0], false)); + return out; + })()""") + expected = [ + "내역서·일위대가 화면", + "일위대가 자신을 하위", + "넣을 자료를 하나 이상", + "일반 내역 행 하나를", + ] + assert len(result) == len(expected) + assert all(message.startswith(head[:8]) for message, head in zip(result, expected)) + + +def test_insert_into_head_group_prices_from_source(): + result = run_js("""(() => { + const rows = project.tables.HeadMainDat; + const title = rows.findIndex((r) => Number(r.Index) > 0); + const stuff = EstPick.items(project, 5).find((r) => r.price > 0); + const added = EstPick.insert(project, "HeadMainDat", title, rows[title].Code, [stuff]); + const row = project.tables.HeadMainDat[added[0]]; + return { at: added[0] === title + 1, link: row.LinkIndex, unit: Number(row.GU) === stuff.price }; + })()""") + assert result == {"at": True, "link": 5, "unit": True} diff --git a/tools/est-test/PLAN.md b/tools/est-test/PLAN.md index d772aa9f3..f5736b2cf 100644 --- a/tools/est-test/PLAN.md +++ b/tools/est-test/PLAN.md @@ -426,6 +426,9 @@ ative-standard.js에 표준시장단가 원천(SilJukDat)과 내역 행(EstMainD - [ ] 원본 메뉴·단추 대조(CAD 제외 약 880개)에서 나온 핵심 미구현 처리. 대조표 = 작업 폴더 `tmp/est-audit/gap_*.md`. - [x] 처리기만 있고 단추가 없던 동작 4개 연결 — 레벨 일괄 증가·감소(LEVEL 메뉴), 원가계산서 초기화(원가계산서 메뉴), 설계변경 초기화(설계변경 화면). 처리기마다 단추가 있는지 보는 지킴 시험 `test_est_action_bindings.py` 추가. - 검증완료: 옛 코드에서 시험 실패(4개 검출)·새 코드 통과, `test_est_row_actions.py` 포함 16개 통과. ORCA 브라우저에서 두 행 레벨 2→3→2 왕복·되돌리기 복원, 원가 초기화 확인창, 당초 없음 차단 문구 확인. + - [x] 단가 고르기(원본 TFRMSERCHPROJECT·TFRMDBLCLICKRTN) — 내역서·일위대가에서 7종 자료 검색·여러 개 선택·선택 행 아래 넣기, 선택 행 연결 바꾸기(변경 전후 확인·같은 코드 전체 변경). 넣거나 바꾼 행만 원천 단가 재조회, 실패 시 되돌림. 자기 일위대가 순환·제목·요율 행 차단. + - 검증완료: `test_est_pick.py` 5개 통과(검색·넣기·단가 반영·바꾸기·차단·일위대가 경로). ORCA 브라우저에서 보통인부 넣기 → 노무단가 172,068 반영, 경유로 바꾸기 → 재료단가 1,376 반영, 되돌리기 2회로 76행·도급액 79,603,000 복원. + - 남음: 단가산출 산출근거(LineData) 행 넣기, 원본 「Main값우선」 선택, 기계경비 상세 행. - [x] 연차 수수료의 원본 연결값 `LinkIndex_`가 57로 보존된 경우 ESTX 재열기에서 원가 계산에 반영하고, 없는 경우 저장 차단을 유지한다. - 자체 검증: 설치 sample의 조달수수료 2건을 연차 사본으로 저장하고 관급 수수료 제외 옵션을 켠 상태에서 재열어 원가 전 항목과 인덱스 바이트가 일치함을 확인. 기존 연결값이 없는 경우 저장 거부. 연차·기성·인덱스 관련 통합 회귀 43개 통과. `99`·고정 행·기존 연결값 `57` 조합에만 적용하며 원본 EST 실행 비교는 하지 못했다. diff --git a/tools/est-test/README.md b/tools/est-test/README.md index 1bfd25b02..acc1c0e7a 100644 --- a/tools/est-test/README.md +++ b/tools/est-test/README.md @@ -20,6 +20,8 @@ 단가산출 화면의 `편집 → 산출근거 문구 바꾸기`는 현재 호표 또는 전체 산출근거에서 문구를 일괄 변경합니다. 결과값·연결 단가 등의 계산 표지는 치환하지 않으며, 자동계산이 켜져 있으면 변경 후 재계산합니다. 실행 취소로 복원할 수 있습니다. +내역서·일위대가 화면의 `편집 → 단가 고르기`는 자재·노임·기계경비·일위대가·단가산출·견적·실적 자료를 검색어로 찾아 선택 행 아래에 넣거나, 선택 행 하나의 연결을 바꿉니다. 여러 개를 골라 한 번에 넣을 수 있고, 바꿀 때는 변경 전·후를 확인한 뒤 같은 코드를 쓰는 이 표의 다른 행도 함께 바꿀지 고릅니다. 넣거나 바꾼 행만 원천 단가를 다시 읽습니다. 단가산출 산출근거 행은 대상이 아닙니다. + 내역서 편집 메뉴의 `내역 수량 초기화`는 선택한 상세 행 또는 모든 상세 행의 수량을 0으로 바꿉니다. 비율·합계 자동행은 건드리지 않으며 실행 취소할 수 있습니다. 원천 자료에 연결된 내역 또는 일위대가 상세 행의 결과비목을 재료·노무·경비로 바꾸면 현재 단가 합계를 유지해 지정한 비목으로 옮깁니다. 비목별 구성으로 되돌릴 때 원천 단가의 합계가 현재 단가와 다르거나 원천이 불명확하면 임의로 복원하지 않고 오류를 표시합니다. 자동계산이 꺼져 있으면 금액은 자동 재계산을 켤 때까지 유지합니다. diff --git a/tools/est-test/index.html b/tools/est-test/index.html index 1b966ed3f..9a01f9acc 100644 --- a/tools/est-test/index.html +++ b/tools/est-test/index.html @@ -37,6 +37,7 @@ + @@ -88,6 +89,9 @@ >
| 구분 | 코드 | 품명 | 규격 | 단위 | 단가 |
|---|
찾은 자료 ${total}건${total > limit ? ` 중 앞 ${limit}건 표시 — 검색어를 더 넣으세요` : ""}
`; + } + + function open(state) { + if (!views.includes(state.view)) + throw Error("내역서·일위대가 화면에서만 단가를 골라 넣을 수 있습니다."); + state.pick ||= { kind: 0, query: "" }; + const options = [[0, "전체 동시검색"], ...kinds.map(([k, , label]) => [k, label])] + .map( + ([k, label]) => + ``, + ) + .join(""); + EstApp.showDialog( + "단가 고르기", + `