fix(est-test): 구버전 EST Plus 저장 — cp949 밖 글자·기계경비 Index·% 수량, 경쟁사 실물 시험

- cp949 에 없는 글자는 「?」 로 바꿔 씀(22MB 공사 저장 중 멈춤)
- 기계경비 본문 Index(−3·−2·−1) 유지
- % 단위 수량은 소수로 써서 음수·큰 값도 되돌아옴
- 경쟁사 예제 estx 2건 저장값 대조·구버전 왕복 시험 추가

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-12 20:35:13 +09:00
co-authored by Claude Opus 5.5
parent 708aeb5cc4
commit ae3ff96983
4 changed files with 80 additions and 7 deletions
+44
View File
@@ -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
+3
View File
@@ -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 열 배치)는 웹 격자와 호환되지 않아 하지 않음.
+18 -1
View File
@@ -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,
+15 -6
View File
@@ -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",