diff --git a/resources/tester/test_est_estplus.py b/resources/tester/test_est_estplus.py index bceea96bd..67ae1ec7e 100644 --- a/resources/tester/test_est_estplus.py +++ b/resources/tester/test_est_estplus.py @@ -51,3 +51,47 @@ def test_pair_flags_sum_mismatch_in_order(): })()""", ) assert result == [["U1", False], ["U2", True]] + + +def test_percent_quantity_machine_index_and_non_cp949_text(): + result = node( + """ +const L=require('./legacy-estplus.cjs'); +const p={name:'t',options:{},tables:{EstMainDat:[],HeadMainDat:[{Index:1,Name:'a'}, + {Index:0,LinkIndex:0,Name:'사용고재',Unite:'%',GN:-500},{Index:0,LinkIndex:0,Name:'공구손료',Unite:'%',GN:3}, + {Index:0,LinkIndex:0,Name:'x'+String.fromCodePoint(0x1F600)+'y',GN:1}], + MachMainDat:[{Index:1,Name:'굴삭기',TC:10},{Index:-3,Selected:1,Name:'손료',GN:2,JC:5},{Index:-1,Selected:11,Name:'운전사',GN:1,NC:7}], + CostMainDat:[],LaborcDat:[],StuffcDat:[],SilJukDat:[],Wonga:[]}}; +const b=L.read(L.write(p),'t'); +console.log(JSON.stringify({gn:b.tables.HeadMainDat.slice(1,3).map(r=>r.GN),name:b.tables.HeadMainDat[3].Name, + mach:b.tables.MachMainDat.map(r=>[r.Index,r.Selected,r.TC])})); +""") + assert result["gn"] == [-500, 3] + assert result["name"] == "x?y" + assert result["mach"] == [[1, 0, 10], [-3, 1, 5], [-1, 11, 7]] + + +def test_competitor_examples_keep_stored_values_and_round_trip_to_legacy(): + folder = ROOT / "resources/knowledge/original/경쟁사 프로그램/EST Plus 2/example" + files = sorted(folder.glob("*.estx")) + assert len(files) >= 2 + for file in files: + parity = json.loads( + subprocess.check_output( + ["node", str(ROOT / "resources/tester/helper_est_archive.cjs"), str(file)], + text=True, + encoding="utf-8", + timeout=300, + ) + ) + assert parity["differences"] == [], file.name + result = node( + f""" +const L=require('./legacy-estplus.cjs'),{{importEstx}}=require('./import-estx.cjs'); +const p=importEstx(require('fs').readFileSync({json.dumps(str(file))}),'x'),b=L.read(L.write(p),'t'); +const diff=(t,ks)=>p.tables[t].filter((r,i)=>ks.some(k=>Math.abs((Number(r[k])||0)-(Number(b.tables[t][i][k])||0))>1e-6)).length; +console.log(JSON.stringify({{est:diff('EstMainDat',['GN','TC','NC','GC','JC']),head:diff('HeadMainDat',['GN','TC','NC','GC','JC']), + cmp:L.compare(L.write(p),2).length===p.tables.HeadMainDat.filter(r=>r.Index>0).length}})); +""" + ) + assert result == {"est": 0, "head": 0, "cmp": True}, file.name diff --git a/tools/est-test/PLAN.md b/tools/est-test/PLAN.md index 16d60431d..e4559e4f9 100644 --- a/tools/est-test/PLAN.md +++ b/tools/est-test/PLAN.md @@ -515,6 +515,9 @@ ative-standard.js에 표준시장단가 원천(SilJukDat)과 내역 행(EstMainD - [x] 구버전 EST Plus 자료 불러오기·저장·단가비교(spec_misc §2 · 원본 역분석 spec_estplus — 레코드 394/565 바이트, Extended·ShortString) — 불러오기: .est/.ebk 를 풀어 내역·일위·단산·기계·노임·자재·표준·견적(565)·원가와 설계변경 c* 를 표로, 저장 금액 유지(자동계산 끔), 호표 머리에 코드를 매기고 본문 연결 행은 같은 품명·규격 원천 코드를 붙임(웹 보충). 저장(Ctrl+Shift+F12): 394 판 .est. 단가비교(Ctrl+Alt+E): 기계경비·단가산출·일위대가·내역서 중 골라 구형 파일 머리 레코드와 차례 짝, 합계 다르면 표시, 더블클릭 이동. 메모(.MEM)·기계 기준표(.mot)는 읽지 않음. - 미결: 실제 구버전 .est 표본이 없어 웹이 만든 파일로만 왕복 확인 — 원본 파일 대조 필요(문자 코드 cp949 가정). - 검증완료: 구버전 시험 2개 통과(Extended 왕복, 샘플 공사 쓰기→읽기 금액 일치, 단가산출 글줄·머리 꼬리표, 품명·규격 연결, 단가비교 자리). ORCA 브라우저에서 .est 저장 17,864 바이트 → 다시 불러오기 76행, 단가비교 창·일위 8건 일치, 원복. + - 경쟁사 실물 시험(지식DB 경쟁사 폴더 estx 3건 — 예제 2 · 다산소프트 main 22MB, 구버전 .est 실물은 없음): 저장값 대조 차이 0(예제1 원본 괄호 오류 1건은 원본 그대로) · ESTX 그대로 저장 = 원본과 같은 바이트 · 근거 메모 125건 글자 일치·그림 42개 보존·고친 메모 ESTX 저장 왕복 · 구버전 .est 쓰기→읽기 내역·일위 금액 차이 0 · 단가비교 불일치 0 · 화면 13종·환경설정·인쇄 미리보기·작업모드 전환 오류 없음. + - 고친 것: 구버전 저장 때 cp949 에 없는 글자는 「?」, 기계경비 본문 Index(−3·−2·−1) 유지, % 단위 수량은 소수로 써서 음수·큰 값도 되돌아옴. + - 남은 관찰: 동원 확인에서 단산·일위 일부(예제 15·1건, main 133·57건)가 10원 넘게 어긋남 — 웹 동원 계산의 줄별 절사 차이로 보임, 원본 확인 탭도 같은지 미확인. - [x] 다른 공사 불러오기 창(원본 barbtnSelfLoad → TfrmOtherLoad, 찾기 popSerch) — 저장된 다른 작업을 골라 같은 표를 띄우고 찾기(품명·규격, A&B 둘 다)·고른 줄을 하위 자료와 함께 가져오기(내역서는 고른 줄 아래에 줄, 나머지는 자료·호표 합침, 같은 코드는 이 공사 것). 검증완료: 바인딩 통과, ORCA 브라우저에서 산불진화임도 작업의 일위대가 3개 → 「취수장」 1개 → 가져오기 호표 8→9·하위 41건, 샘플 복구. - [x] 사용위치 검색 창(원본 TfrmUsesDataSerch btnSerch·btnAllDisplay) — 현재 표 항목을 품명·규격(빈칸·|·괄호 무시)으로 거르고 골라 사용위치 보기. 검증완료: 바인딩 통과, ORCA 브라우저에서 노임 9개 → 「보 통」 1개 → 보통인부 사용위치 11곳. - [x] 인쇄양식 불러오기·저장하기(원본 mnuPrintFormatLoad·Save 0x10ed318·0x10ed000 — spec_pc §3) — .pfot(인쇄정보표 MIDAS 이진) 읽기·쓰기 `print-format.cjs`(설명 BLOB·빈 칸 표지까지 보존, 원본 파일 그대로 다시 쓰면 바이트 같음). 불러오면 공사 인쇄표를 그 값으로, 저장은 지금 인쇄표를 원본 설치 main.pfot 필드 설명자 위에(`EST_PFOT_TEMPLATE`). .gfot(cxGrid 열 배치)는 웹 격자와 호환되지 않아 하지 않음. diff --git a/tools/est-test/legacy-estplus-layout.cjs b/tools/est-test/legacy-estplus-layout.cjs index c9cdaebff..92ffda768 100644 --- a/tools/est-test/legacy-estplus-layout.cjs +++ b/tools/est-test/legacy-estplus-layout.cjs @@ -1,5 +1,21 @@ // 구버전 EST Plus 레코드 형식(spec_estplus.md): 머리·표별 가변부 자리, Extended·ShortString 읽기·쓰기, 레코드 풀기. -const { cp949 } = require("./export-estx.cjs"); +const { cp949: strict } = require("./export-estx.cjs"); +// cp949 에 없는 글자(제어 기호 밖 특수 문자 등)는 「?」 로 바꿔 씀 — 구형 레코드는 cp949 만 담음 +function cp949(text) { + try { + return strict(text); + } catch { + return Buffer.concat( + [...String(text)].map((ch) => { + try { + return strict(ch); + } catch { + return Buffer.from("?"); + } + }), + ); + } +} const HEAD = { 394: { @@ -206,6 +222,7 @@ function decode(rec, size, type) { } module.exports = { + cp949, HEAD, AMOUNTS, BODY, diff --git a/tools/est-test/legacy-estplus.cjs b/tools/est-test/legacy-estplus.cjs index 48879b53b..cba2c072d 100644 --- a/tools/est-test/legacy-estplus.cjs +++ b/tools/est-test/legacy-estplus.cjs @@ -6,7 +6,6 @@ // 쓰기(ActionConvertToEstPlus → 0xd96354/0xd960b8): 394 판으로 위 표 파일 + .ini 를 ZIP 으로. // 단가비교(TfrmCompare 0xf8998c): 고른 종류 파일의 Index > 0 레코드 노무·재료·경비 금액을 차례로. const { pack, unpack } = require("./archive.cjs"); -const { cp949 } = require("./export-estx.cjs"); const num = (v) => Number(v) || 0; const { @@ -22,6 +21,7 @@ const { writeS, records, decode, + cp949, } = require("./legacy-estplus-layout.cjs"); const link = (v) => LINK_IN[v] ?? v; @@ -221,7 +221,7 @@ function convert(files, name) { } else if (r.Index === -3) t.MachMainDat.push({ ...base, - Index: 0, + Index: r.Index, Selected: 1, Size: "", GN: r.X200, @@ -233,7 +233,7 @@ function convert(files, name) { else if (r.Index === -2) t.MachMainDat.push({ ...base, - Index: 0, + Index: r.Index, Selected: 6, Size: clean(r.Size), GN: r.X190, @@ -248,7 +248,7 @@ function convert(files, name) { else if (r.Index === -1) t.MachMainDat.push({ ...base, - Index: 0, + Index: r.Index, Selected: 11, Size: clean(r.Size), GN: r.X190, @@ -502,8 +502,17 @@ function write(project) { out = []; const add = (ext, type, rows) => rows.length && out.push([`data.${ext}`, Buffer.concat(rows.map((r) => record(type, r)))]); - add("dtm", "dtm", t.EstMainDat || []); - add("hdt", "hdt", t.HeadMainDat || []); + // % 단위 수량은 소수로 씀(읽을 때 1 미만이면 ×100 — 음수·100 넘는 값도 되돌아오게) + const pct = (rows) => + rows.map((r) => + ["%", "%"].includes(String(r.Unite ?? "").trim()) && + ![51, 52, 57, 58, 62, 64].includes(num(r.LinkIndex)) && + !(num(r.Index) > 0) + ? { ...r, GN: num(r.GN) / 100 } + : r, + ); + add("dtm", "dtm", pct(t.EstMainDat || [])); + add("hdt", "hdt", pct(t.HeadMainDat || [])); add( "cct", "cct",