import json import struct import subprocess import zipfile import pytest from pathlib import Path ROOT = Path(__file__).resolve().parents[2] def test_existing_quantity_links_respect_native_mode_and_avoid_ambiguous_targets(): script = """const {existingTargets}=require('./tools/est-test/native-links.cjs'); const sheet={Code:'N1',items:[{Code:'r1'},{Code:'r2'},{Code:'r3'},{Code:'r4'}]}; const blob=(v,enc='utf8')=>({binary:Buffer.from(v,enc).toString('base64')}); const rows=[{Code:'E1',MullangIndex:1,MulCode:blob('N1\\tr1')}, {Code:'E2',MullangIndex:1,MulCode:blob('N1\\tr2','utf16le')}, {Code:'E3',MullangIndex:2,MulCode:blob('N1\\tr3')}, {Code:'E4',MullangIndex:1,MulCode:blob('N1\\tr4')},{Code:'E4'}]; const warnings=new Set();existingTargets({EstMainDat:rows},[sheet],warnings); process.stdout.write(JSON.stringify({items:sheet.items,warnings:[...warnings]}));""" result = json.loads(subprocess.check_output(["node", "-e", script], cwd=ROOT).decode("utf-8")) assert [item.get("TargetCode") for item in result["items"]] == ["E1", "E2", None, None] assert len(result["warnings"]) == 2 def test_estx_print_catalog_uses_project_options_when_sample_exists(): sample = Path("C:/Dasansoft/Est Plus 2/Data/ProjectData/sample.estx") if not sample.exists(): pytest.skip("installed EST sample unavailable") script = """const fs=require('fs'),m=require('./tools/est-test/import-estx.cjs'),p=require('./tools/est-test/import-print.cjs'),s=require('./tools/est-test/server.cjs'); const bytes=fs.readFileSync(process.argv[1]),project=m.importEstx(bytes),form=project.printCatalog.find(r=>r.Index===1); let invalid=false;try{p.readPrintOptions(Buffer.from([1,2,3]))}catch{invalid=true} process.stdout.write(JSON.stringify({count:project.printCatalog.length,name:form.Name,project:form.ProjectName, lines:form.PrintLineCount,left:form.PrintLeft,preserved:s.calculate(project).printCatalog.length,invalid}));""" result = json.loads( subprocess.check_output(["node", "-e", script, str(sample)], cwd=ROOT).decode("utf-8") ) assert result == { "count": 41, "name": "내역서", "project": "연습공사", "lines": 21, "left": 20, "preserved": 41, "invalid": True, } def test_estx_print_edits_patch_only_requested_fields_when_sample_exists(): sample = Path("C:/Dasansoft/Est Plus 2/Data/ProjectData/sample.estx") if not sample.exists(): pytest.skip("installed EST sample unavailable") script = """const fs=require('fs'),m=require('./tools/est-test/import-estx.cjs'),e=require('./tools/est-test/export-estx.cjs'),s=require('./tools/est-test/server.cjs'); const bytes=fs.readFileSync(process.argv[1]),p=m.importEstx(bytes),before=structuredClone(p.printCatalog); p.nativePrintChanges={'1':{PrintTop:13,PrintBottom:14,PrintLeft:15,PrintRight:16,PrintLineCount:19}}; const retained=s.calculate(structuredClone(p)).nativePrintChanges['1'].PrintLineCount===19; const out=e.exportEstx(p),q=m.importEstx(out),a=m.unzip(bytes),b=m.unzip(out),form=q.printCatalog.find(r=>r.Index===1); const other=before.filter(r=>r.Index!==1).every(r=>JSON.stringify(r)===JSON.stringify(q.printCatalog.find(x=>x.Index===r.Index))); const sameForm=Object.keys(form).filter(k=>!['PrintTop','PrintBottom','PrintLeft','PrintRight','PrintLineCount'].includes(k)).every(k=>JSON.stringify(form[k])===JSON.stringify(before.find(r=>r.Index===1)[k])); const files=[...a].filter(([k])=>k!=='datprintoption.opt').every(([k,v])=>v.equals(b.get(k))); p.nativePrintChanges={'1':{PrintLeft:1000}};let invalid=false;try{e.exportEstx(p)}catch{invalid=true} process.stdout.write(JSON.stringify({top:form.PrintTop,bottom:form.PrintBottom,left:form.PrintLeft,right:form.PrintRight,lines:form.PrintLineCount,other,sameForm,files,invalid,retained}));""" result = json.loads( subprocess.check_output(["node", "-e", script, str(sample)], cwd=ROOT).decode("utf-8") ) assert result == { "top": 13, "bottom": 14, "left": 15, "right": 16, "lines": 19, "other": True, "sameForm": True, "files": True, "invalid": True, "retained": True, } def test_quantity_document_edits_keep_styles_and_bookmarks_and_block_marker_edits(): script = r"""const q=require('./tools/est-test/native-quantity.cjs'),w=require('./tools/est-test/export-quantity.cjs'); const doc='
폭= 6 m
'; const old=q.fromWpt('A','문서',doc,[],{}),edited=structuredClone(old); edited.rows[0].LineData=edited.rows[0].LineData.replace('폭=','높이=').replace('6','12'); const next=w.editDocument(doc,old,edited),again=q.fromWpt('A','문서',next,[],{}); const bad=structuredClone(old);bad.rows[0].LineData='완전히 다른 줄'; let rejected=false;try{w.editDocument(doc,old,bad)}catch{rejected=true} process.stdout.write(JSON.stringify({same:JSON.stringify(again)===JSON.stringify(edited),tags:next.includes('s.Code==='N0000000083');mismatch.items[4].Name='수량 보존'; const q=m.importEstx(e.exportEstx(p)),item=q.quantitySheets[0].items[0]; const restored=q.quantitySheets.find(s=>s.Code==='N0000000083'); const other=JSON.stringify(q.quantitySheets[0].items.slice(1))===JSON.stringify(before[0].items.slice(1)); const rest=JSON.stringify(q.quantitySheets.filter(s=>s.Code!==sheet.Code && s.Code!==mismatch.Code))===JSON.stringify(before.filter(s=>s.Code!==sheet.Code && s.Code!==mismatch.Code)); const preserved=restored.items[4].Name==='수량 보존' && restored.items[4].GN===286 && JSON.stringify(restored.rows)===JSON.stringify(before.find(s=>s.Code===mismatch.Code).rows); p.quantitySheets[0].items[0].TargetCode='new target';let rejected=false;try{e.exportEstx(p)}catch{rejected=true} console.log(JSON.stringify({name:item.Name,size:item.Size,unite:item.Unite,other,rest,preserved,rejected}));""" result = json.loads( subprocess.check_output(["node", "-e", script, str(sample)], cwd=ROOT, timeout=45).decode( "utf-8" ) ) assert result == { "name": "수정된 품명", "size": "수정 규격", "unite": "m2", "other": True, "rest": True, "preserved": True, "rejected": True, } def test_estx_quantity_description_preserves_mismatched_result_when_sample_exists(): sample = Path("C:/Dasansoft/Est Plus 2/Data/ProjectData/main.estx") if not sample.exists(): pytest.skip("installed EST sample unavailable") script = """const fs=require('fs'),m=require('./tools/est-test/import-estx.cjs'),e=require('./tools/est-test/export-estx.cjs'),w=require('./tools/est-test/export-quantity.cjs'); const bytes=fs.readFileSync(process.argv[1]),p=m.importEstx(bytes),base=m.importEstx(bytes),sheet=p.quantitySheets.find(s=>s.Code==='N0000000083'); sheet.rows[0].LineData=sheet.rows[0].LineData.replace('석축쌓기','석축공사'); const q=m.importEstx(e.exportEstx(p)),saved=q.quantitySheets.find(s=>s.Code===sheet.Code); sheet.rows[3].LineData=sheet.rows[3].LineData.replace('220','221');let rejected=false; try{w.updateQuantities(p,base,e.cp949,e.memory)}catch{rejected=true} sheet.rows[3].LineData=sheet.rows[3].LineData.replace('221','220'); sheet.rows[5].LineData=sheet.rows[5].LineData.replace('286','287');let markerRejected=false; try{w.updateQuantities(p,base,e.cp949,e.memory)}catch{markerRejected=true} console.log(JSON.stringify({text:saved.rows[0].LineData,quantity:saved.items[4].GN, preserved:JSON.stringify(saved.rows.slice(1))===JSON.stringify(base.quantitySheets.find(s=>s.Code===sheet.Code).rows.slice(1)),rejected,markerRejected}));""" result = json.loads( subprocess.check_output(["node", "-e", script, str(sample)], cwd=ROOT, timeout=45).decode( "utf-8" ) ) assert result["text"].startswith("석축공사") assert result["quantity"] == 286 assert result["preserved"] and result["rejected"] and result["markerRejected"] def test_quantity_document_edit_across_character_styles_preserves_tags(): script = r"""const q=require('./tools/est-test/native-quantity.cjs'),w=require('./tools/est-test/export-quantity.cjs'); const doc='
abcdefghi
'; const old=q.fromWpt('A','문서',doc,[],{}),edited=structuredClone(old); edited.rows[0].LineData='ab변경hi'; const next=w.editDocument(doc,old,edited),again=q.fromWpt('A','문서',next,[],{}),tags=x=>x.match(/<[^>]*>/g); process.stdout.write(JSON.stringify({same:JSON.stringify(again)===JSON.stringify(edited),tags:JSON.stringify(tags(doc))===JSON.stringify(tags(next))}));""" result = json.loads(subprocess.check_output(["node", "-e", script], cwd=ROOT).decode("utf-8")) assert result == {"same": True, "tags": True} def test_quantity_document_edit_across_styles_and_superscript_preserves_structure(): script = r"""const q=require('./tools/est-test/native-quantity.cjs'),w=require('./tools/est-test/export-quantity.cjs'); const doc='
abc2def
'; const old=q.fromWpt('A','문서',doc,[],{}),edited=structuredClone(old); edited.rows[0].LineData='abc^(3)xyz'; const next=w.editDocument(doc,old,edited),again=q.fromWpt('A','문서',next,[],{}); process.stdout.write(JSON.stringify({same:JSON.stringify(again.rows)===JSON.stringify(edited.rows),doc:next}));""" result = json.loads(subprocess.check_output(["node", "-e", script], cwd=ROOT).decode("utf-8")) assert result["same"] is True assert "3xyz" in result["doc"] def test_quantity_document_edit_across_styles_and_separate_marker(): script = r"""const q=require('./tools/est-test/native-quantity.cjs'),w=require('./tools/est-test/export-quantity.cjs'); const doc='
abcdef6
'; const old=q.fromWpt('A','문서',doc,[],{}),edited=structuredClone(old); edited.rows[0].LineData=edited.rows[0].LineData.replace('bcde','변경').replace('6','12'); const next=w.editDocument(doc,old,edited),again=q.fromWpt('A','문서',next,[],{}),tags=x=>x.match(/<[^>]*>/g); process.stdout.write(JSON.stringify({same:JSON.stringify(again)===JSON.stringify(edited),tags:JSON.stringify(tags(doc))===JSON.stringify(tags(next))}));""" result = json.loads(subprocess.check_output(["node", "-e", script], cwd=ROOT).decode("utf-8")) assert result == {"same": True, "tags": True} def test_quantity_document_edit_across_actual_style_boundary_when_sample_exists(): sample = Path("C:/Dasansoft/Est Plus 2/Data/ProjectData/main.estx") if not sample.exists(): pytest.skip("installed EST main sample unavailable") script = """const fs=require('fs'),z=require('zlib'),m=require('./tools/est-test/import-estx.cjs'),w=require('./tools/est-test/export-quantity.cjs'),q=require('./tools/est-test/native-quantity.cjs'); const p=m.importEstx(fs.readFileSync(process.argv[1])),old=p.quantitySheets.find(s=>s.Code==='N0000000113'); const r=p.tables.MulMainDat.find(x=>x.Code===old.Code),b=Buffer.from(r.Editor.binary,'base64'); const text=new TextDecoder('euc-kr').decode(b[0]===120?z.inflateSync(b):b),edited=structuredClone(old); const i=edited.rows.findIndex(r=>r.LineData.includes('27.22 ='));if(i<0)throw Error('sample line missing'); edited.rows[i].LineData=edited.rows[i].LineData.replace('27.22 =','27.22XY'); const out=w.editDocument(text,old,edited),tags=x=>x.match(/<[^>]*>/g); process.stdout.write(JSON.stringify({same:JSON.stringify(q.fromWpt(edited.Code,edited.Name,out,edited.items,edited.options))===JSON.stringify(edited),tags:JSON.stringify(tags(text))===JSON.stringify(tags(out))}));""" result = json.loads( subprocess.check_output(["node", "-e", script, str(sample)], cwd=ROOT).decode("utf-8") ) assert result == {"same": True, "tags": True} def test_multiple_quantity_text_segments_preserve_original_tags_when_sample_exists(): sample = Path("C:/Dasansoft/Est Plus 2/Data/ProjectData/main.estx") if not sample.exists(): pytest.skip("installed EST main sample unavailable") script = """const fs=require('fs'),z=require('zlib'),m=require('./tools/est-test/import-estx.cjs'),w=require('./tools/est-test/export-quantity.cjs'),q=require('./tools/est-test/native-quantity.cjs'); const p=m.importEstx(fs.readFileSync(process.argv[1])),old=p.quantitySheets[0],r=p.tables.MulMainDat.find(x=>x.Code===old.Code),b=Buffer.from(r.Editor.binary,'base64'); const text=new TextDecoder('euc-kr').decode(b[0]===120?z.inflateSync(b):b),next=structuredClone(old),i=next.rows.findIndex(r=>r.LineData.includes('B ×0.05')); if(i<0)throw Error('sample line missing');next.rows[i].LineData=next.rows[i].LineData.replace('표 층','표층').replace('B ×0.05','B ×0.06'); const out=w.editDocument(text,old,next),tags=x=>x.match(/<[^>]*>/g); process.stdout.write(JSON.stringify({same:JSON.stringify(q.fromWpt(next.Code,next.Name,out,next.items,next.options))===JSON.stringify(next),tags:JSON.stringify(tags(text))===JSON.stringify(tags(out))}));""" result = json.loads( subprocess.check_output(["node", "-e", script, str(sample)], cwd=ROOT).decode("utf-8") ) assert result == {"same": True, "tags": True} def test_estx_quantity_edit_preserves_other_documents_and_indexes_when_sample_exists(): sample = Path("C:/Dasansoft/Est Plus 2/Data/ProjectData/main.estx") if not sample.exists(): pytest.skip("installed EST main sample unavailable") script = """const fs=require('fs'),m=require('./tools/est-test/import-estx.cjs'),e=require('./tools/est-test/export-estx.cjs'); const bytes=fs.readFileSync(process.argv[1]),p=m.importEstx(bytes),first=p.quantitySheets[0]; first.items[0].Name='changed name';first.items[0].Size='changed size'; first.rows[0].LineData=first.rows[0].LineData.replace('표준','시험 표준'); const index=first.rows.findIndex(r=>r.LineData.includes('B = 9.00')); if(index<0)throw Error('기준 폭 행 없음'); first.rows[index].LineData=first.rows[index].LineData.replace('9.00','10.00'); const out=e.exportEstx(p),q=m.importEstx(out),a=m.unzip(bytes),b=m.unzip(out); const unchanged=[...a].filter(([k])=>k.endsWith('.idx')||k.startsWith('cad')).every(([k,v])=>v.equals(b.get(k))); process.stdout.write(JSON.stringify({line:q.quantitySheets[0].rows[0].LineData,quantity:q.quantitySheets[0].items[0].GN,name:q.quantitySheets[0].items[0].Name,size:q.quantitySheets[0].items[0].Size,result:q.quantitySheets[0].rows.find(r=>r.markers.includes('r000000')).LineData,others:JSON.stringify(q.quantitySheets.slice(1))===JSON.stringify(p.quantitySheets.slice(1)),unchanged}));""" result = json.loads( subprocess.check_output(["node", "-e", script, str(sample)], cwd=ROOT).decode("utf-8") ) assert "시험 표준" in result["line"] assert result["quantity"] == 116 assert result["name"] == "changed name" and result["size"] == "changed size" assert "\x12116\x12" in result["result"] assert result["others"] and result["unchanged"] def test_estx_options_and_expanded_blob_preserve_unrelated_data_when_sample_exists(): sample = Path("C:/Dasansoft/Est Plus 2/Data/ProjectData/sample.estx") if not sample.exists(): pytest.skip("installed EST sample unavailable") script = """const fs=require('fs'),m=require('./tools/est-test/import-estx.cjs'),e=require('./tools/est-test/export-estx.cjs'); const bytes=fs.readFileSync(process.argv[1]),p=m.importEstx(bytes),before=structuredClone(p); p.name='길이가 달라진 새 공사명';p.options.AutoCalc=!p.options.AutoCalc; p.options.CostValueTrunc=!p.options.CostValueTrunc;p.options.MidCostDiget=3; const row=p.tables.EstMainDat.find(r=>Object.values(r).some(v=>v&&v.binary)); const field=Object.keys(row).find(k=>row[k]&&row[k].binary); const content=Buffer.alloc(12000, 65);row[field]={binary:content.toString('base64')}; const out=e.exportEstx(p),q=m.importEstx(out),a=m.unzip(bytes),b=m.unzip(out); const unchanged=[...a].filter(([k])=>k.endsWith('.idx')||k.startsWith('cad')).every(([k,v])=>v.equals(b.get(k))); const other=p.tables.EstMainDat.every((r,i)=>Object.keys(r).every(k=>JSON.stringify(r[k])===JSON.stringify(q.tables.EstMainDat[i][k]))); const options=before.originalOptions.every(x=>{const y=q.originalOptions.find(z=>z.className===x.className); return Object.entries(x.properties).every(([k,v])=>['ProjectName','CalcAuto','CostValueTrunc','MidCostDiget'].includes(k)||JSON.stringify(v)===JSON.stringify(y.properties[k]));}); process.stdout.write(JSON.stringify({unchanged,other,options,name:q.name,auto:q.options.AutoCalc,cost:q.options.CostValueTrunc,digits:q.options.MidCostDiget,expected:p.options,content:Buffer.from(q.tables.EstMainDat.find(r=>r.PkID===row.PkID)[field].binary,'base64').equals(content)}));""" result = json.loads( subprocess.check_output(["node", "-e", script, str(sample)], cwd=ROOT).decode("utf-8") ) assert result["unchanged"] and result["other"] and result["options"] and result["content"] assert result["name"] == "길이가 달라진 새 공사명" assert result["auto"] == result["expected"]["AutoCalc"] assert result["cost"] == result["expected"]["CostValueTrunc"] assert result["digits"] == 3 def test_recalculation_and_native_export_keep_original_title_identities_when_sample_exists(): sample = Path("C:/Dasansoft/Est Plus 2/Data/ProjectData/main.estx") if not sample.exists(): sample = Path("resources/knowledge/original/원가계산/다산소프트/2026-07-main.estx") if not sample.exists(): sample = Path("C:/Dasansoft/Est Plus 2/Data/ProjectData/sample.estx") if not sample.exists(): pytest.skip("installed EST sample unavailable") script = """const fs=require('fs'),m=require('./tools/est-test/import-estx.cjs'),e=require('./tools/est-test/export-estx.cjs'),s=require('./tools/est-test/server.cjs'); const bytes=fs.readFileSync(process.argv[1]),p=m.importEstx(bytes); const hasQuantity=Boolean(p.quantitySheets?.[0]?.items?.[0]); if (hasQuantity) p.quantitySheets[0].items[0].Unite = 'm2'; const q=s.calculate(p); const memoKeys=['DifineMemo','SpaceMemo','EscData']; const preserved=['HeadTitleDat','CostTitleDat','MachTitleDat'].every(key=> p.tables[key].every((r,i)=>{ const target=q.tables[key][i]; return r.PkID===target.PkID && r.Index===target.Index && r.RecNo===target.RecNo && r.Flag===target.Flag && memoKeys.every(k=>JSON.stringify(r[k])===JSON.stringify(target[k])); }) ); const out=e.exportEstx(q),again=m.importEstx(out),original=m.unzip(bytes),files=m.unzip(out); const indexes=[...original].filter(([name])=>name.endsWith('.idx')).every(([name,value])=>value.equals(files.get(name))); const reimportedUnite=hasQuantity ? again.quantitySheets?.[0]?.items?.[0]?.Unite : 'm2'; process.stdout.write(JSON.stringify({preserved,indexes,reimportedUnite}));""" result = json.loads( subprocess.check_output(["node", "-e", script, str(sample)], cwd=ROOT).decode("utf-8") ) assert result["preserved"] and result["indexes"] assert result["reimportedUnite"] == "m2" def fixture(path): fields = [ ("PkID", 2, 4), ("Code", 1, 16), ("Name", 1, 32), ("GN", 7, 8), ("GU", 7, 8), ("TC", 7, 8), ] stride = 25 + sum(width + 1 for _, _, width in fields) data = bytearray(512 + 768 * len(fields) + stride) data[8:24] = bytes.fromhex("068abe8e592364cb403d71d2e3bc64d0") struct.pack_into("