import io import json import subprocess import zipfile from pathlib import Path import xml.etree.ElementTree as ET from test_est_rea_parity import run ROOT = Path(__file__).resolve().parents[2] def test_report_excel_contains_numeric_values_and_separate_errors(): import base64 import openpyxl script = """const {server}=require('./tools/est-test/server.cjs'); server.listen(0,'127.0.0.1',async()=>{try{ const url=`http://127.0.0.1:${server.address().port}/api/export-report-xlsx`; const post=body=>fetch(url,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)}); const ok=await post({name:'test',columns:['Name','Value'],rows:[['=1+1',7]],detail:'summary',errors:['bad input']}); const bad=await post({columns:['Name'],rows:[[null]],errors:[]}); process.stdout.write(JSON.stringify({data:Buffer.from(await ok.arrayBuffer()).toString('base64'),status:bad.status})); }finally{server.close()}});""" result = json.loads( subprocess.run( ["node", "-e", script], cwd=ROOT, capture_output=True, check=True, timeout=15 ).stdout ) workbook = openpyxl.load_workbook(io.BytesIO(base64.b64decode(result["data"]))) assert workbook["Report"]["A2"].value == "=1+1" assert workbook["Report"]["A2"].data_type == "s" assert workbook["Report"]["B2"].value == 7 assert workbook["Summary"]["A2"].value == "summary" assert workbook["Errors"]["A2"].value == "bad input" assert result["status"] == 400 workbook.close() def export_workbook(project): result = subprocess.run( [ "node", "-e", "const fs=require('fs');process.stdout.write(require('./tools/est-test/exchange.cjs').workbook(JSON.parse(fs.readFileSync(0,'utf8'))))", ], input=json.dumps(project, ensure_ascii=False).encode("utf-8"), capture_output=True, cwd=ROOT, check=True, ) return result.stdout def test_excel_escalation_fields_and_settings_roundtrip_validation(): import openpyxl workbook = openpyxl.load_workbook( io.BytesIO( export_workbook( { "tables": { "EstMainDat": [{"PkID": 1, "Code": "R", "AGU": 80}, {"LinkIndex": 54}] }, "options": {"EscDesignTotal": 1200}, } ) ) ) sheet = workbook["Escalation"] assert "AGU" not in [cell.value for cell in workbook["EstMainDat"][1]] assert list(next(sheet.values)) == [ "RowKey", "ANU", "AGU", "AJU", "ASU", "EscPlanned", "EscActual", "EscProgress", "EscAuto", "EscManualQuantity", "EscConstruction", "EscPS", "EscBefore", ] assert workbook["EscalationSettings"]["A2"].value == 1200 result = run( "math", """(()=>{ const p=EstNative.empty();p.options.SourceRefresh=false; p.tables.EstMainDat=[{PkID:1,Code:'R',GN:10,GU:100},{LinkIndex:54}]; const before=JSON.stringify(p), sheets={Escalation:[{RowKey:'1',AGU:80,EscActual:3,EscAuto:false,EscManualQuantity:2}],EscalationSettings:[{EscDesignTotal:1200}]}; const q=EstExchange.apply(p,sheets), output=EstEscalation.calculate(q); let bad=0; for(const rows of [[{RowKey:'missing'}],[{RowKey:'1'},{RowKey:'1'}],[{RowKey:'1',EscAuto:'false'}],[{RowKey:'1',AGU:'wrong'}]]) { try{EstExchange.apply(p,{Escalation:rows})}catch{bad++} } try{EstExchange.apply(p,{EscalationSettings:[{EscDesignTotal:-1}]})}catch{bad++} return {q,output,bad,same:before===JSON.stringify(p)}; })()""", ) assert result["same"] and result["bad"] == 5 assert result["q"]["options"]["EscDesignTotal"] == 1200 assert result["q"]["tables"]["EstMainDat"][0]["AGU"] == 80 assert result["output"]["rows"][0]["EscExcluded"] == 2 def test_excel_is_valid_office_zip_with_numeric_text_types_and_no_executable_formulas(): data = export_workbook( { "name": "한글 공사", "tables": { "EstMainDat": [ { "PkID": 1, "Code": "001", "Name": '=HYPERLINK("bad")', "GN": 1.23456789, "TC": 99, "NewItem": False, "Editor": {"binary": "AAAA"}, } ], "CadMainDat": [{"Code": "excluded"}], }, } ) with zipfile.ZipFile(io.BytesIO(data)) as archive: assert archive.testzip() is None workbook = archive.read("xl/workbook.xml").decode("utf-8") assert "EstMainDat" in workbook and "CadMainDat" not in workbook root = ET.fromstring(archive.read("xl/worksheets/sheet2.xml")) ns = {"s": "http://schemas.openxmlformats.org/spreadsheetml/2006/main"} rows = root.findall(".//s:row", ns) assert "Editor" not in ET.tostring(rows[0], encoding="unicode") assert rows[1].find("s:c[@r='B2']", ns).get("t") == "inlineStr" assert rows[1].find("s:c[@r='D2']/s:v", ns).text == "1.23456789" assert root.findall(".//s:f", ns) == [] assert "=HYPERLINK" in ET.tostring(root, encoding="unicode") def test_print_settings_validation_blocks_css_injection_and_bad_pagination(): result = run( "math", """(()=>{ return [{paper:'A4;display:none'}, {rowsPerPage:0}, {rowsPerPage:1.5}, {font:100}, {margin:-1}, {orientation:'portrait',paper:'A4',rowsPerPage:20,font:9}].map(s=>{ const p=EstNative.empty();p.printSettings=s; try{EstModel.validate(p);return true}catch{return false} }); })()""", ) assert result == [False, False, False, False, False, True] def test_excel_price_edits_refresh_linked_values_and_keep_binary_fields(): result = run( "math", """(()=>{ const p=EstNative.empty();p.tables.StuffcDat=[{PkID:1,Code:'M',Name:'material',InputTC:100,ValueIndex:4,Editor:{binary:'AAAA'}}]; p.tables.EstMainDat=[{Code:'M',LinkIndex:5,GN:2},{LinkIndex:54}]; const q=EstNative.calculate(p),updated=EstExchange.apply(q,{StuffcDat:[{PkID:1,Code:'M',Name:'material',TC:150,InputTC:100,ValueIndex:4}]}); let rejected=false;try{EstExchange.apply(q,{StuffcDat:[{PkID:1,Code:'',TC:150}]})}catch{rejected=true} return {next:EstNative.calculate(updated),rejected}; })()""", ) assert result["rejected"] assert result["next"]["tables"]["EstMainDat"][-1]["TC"] == 300 assert result["next"]["tables"]["StuffcDat"][0]["Editor"]["binary"] == "AAAA" def test_excel_exports_manual_quantities_and_schedule(): import openpyxl data = export_workbook( { "name": "project", "tables": {}, "quantities": [ { "Code": "Q1", "Name": "quantity", "formula": "[A]*3", "digits": 2, "TargetKey": "id:5", } ], "schedule": [ { "id": "s1", "SourceKey": "id:5", "Code": "", "Name": "work", "start": "2026-01-01", "end": "2026-01-10", "weight": 100, "progress": 25, } ], } ) workbook = openpyxl.load_workbook(io.BytesIO(data), data_only=False) assert list(workbook["Quantities"].values)[1] == ("Q1", "quantity", None, "id:5", "[A]*3", 2) assert list(workbook["Schedule"].values)[1] == ( "s1", "id:5", "", "work", "2026-01-01", "2026-01-10", 100, 25, ) def test_excel_quantity_links_export_and_import_reject_invalid_targets(): import openpyxl link = {"target": 5, "file": "source.xlsx", "sheet": "Data", "cell": "B2"} data = export_workbook({"name": "project", "tables": {}, "excelQuantityLinks": [link]}) workbook = openpyxl.load_workbook(io.BytesIO(data), data_only=False) assert list(workbook["ExcelQuantityLinks"].values)[1] == (5, "source.xlsx", "Data", "B2") workbook.close() result = run( "math", """(()=>{ const p=EstNative.empty();p.tables.EstMainDat=[{PkID:5,Code:'X',GN:1}]; const link={target:5,file:'source.xlsx',sheet:'Data',cell:'B2'}; const q=EstExchange.apply(p,{ExcelQuantityLinks:[link]});let rejected=0; for(const rows of [[{...link,target:9}],[link,link],[{...link,cell:'XFE1'}]]){ try{EstExchange.apply(p,{ExcelQuantityLinks:rows,EstMainDat:[{PkID:5,Code:'X',GN:7}]})}catch{rejected++} } const fresh=EstExchange.apply(EstNative.empty(),{EstMainDat:[{PkID:5,Code:'X',GN:7}],ExcelQuantityLinks:[link]}); return {links:q.excelQuantityLinks,rejected,original:p.tables.EstMainDat[0].GN,fresh:fresh.excelQuantityLinks}; })()""", ) assert result == {"links": [link], "rejected": 3, "original": 1, "fresh": [link]} def test_excel_import_does_not_reuse_deleted_excel_link_target(): result = run( "math", """(()=>{ const p=EstNative.empty();p.tables.EstMainDat=[{PkID:1,Code:'A',GN:1}]; const link={target:2,file:'source.xlsx',sheet:'Data',cell:'B2'};p.excelQuantityLinks=[link]; const rows=[{PkID:1,Code:'A',GN:2},{PkID:2,Code:'B',GN:5}];let rejected=0; for(const data of [{EstMainDat:rows},{EstMainDat:rows,ExcelQuantityLinks:[link]}]){ try{EstExchange.apply(p,data)}catch{rejected++} } const cleared=EstExchange.apply(p,{EstMainDat:rows,ExcelQuantityLinks:[]}); return {rejected,original:p.tables.EstMainDat[0].GN,cleared:cleared.excelQuantityLinks.length}; })()""", ) assert result == {"rejected": 2, "original": 1, "cleared": 0} def test_excel_imports_manual_quantities_and_schedule_and_rejects_invalid(): result = run( "math", """(()=>{ const p=EstNative.empty();p.tables.EstMainDat=[{PkID:5,Code:'',Name:'work',GN:1}]; const qty={Code:'Q1',Name:'quantity',formula:'2*3',digits:2,TargetKey:'id:5',TargetCode:''}; const sched={id:'s1',SourceKey:'id:5',Code:'',Name:'work',start:'2026-01-01',end:'2026-01-10',weight:100,progress:25}; const q=EstExchange.apply(p,{Quantities:[qty],Schedule:[sched]}); EstQuantity.applyTotals(q,EstQuantity.totals(q,'manual')); let invalid=0;for(const bad of [{Quantities:[qty,qty]},{Quantities:[{...qty,digits:7}]}, {Schedule:[{...sched,progress:101}]}])try{EstExchange.apply(p,bad)}catch{invalid++} return {q,invalid,original:p}; })()""", ) assert result["q"]["tables"]["EstMainDat"][0]["GN"] == 6 assert result["q"]["schedule"][0]["SourceKey"] == "id:5" assert result["q"]["schedule"][0]["progress"] == 25 assert result["original"]["tables"]["EstMainDat"][0]["GN"] == 1 assert result["invalid"] == 3 def test_excel_writes_quantity_marker_controls_as_valid_xml(): import openpyxl data = export_workbook( { "name": "project", "tables": {"CostMainDat": [{"PkID": 1, "LineData": "A=\x121.5\x12 _x0012_"}]}, } ) workbook = openpyxl.load_workbook(io.BytesIO(data)) assert workbook["CostMainDat"]["B2"].value == "A=_x0012_1.5_x0012_ _x005F_x0012_" assert ( run("math", "EstExchange.decodeExcelText('A=_x0012_1.5_x0012_ _x005F_x0012_')") == "A=\x121.5\x12 _x0012_" ) def test_excel_exports_and_imports_quantity_documents_with_markers(): import openpyxl data = export_workbook( { "name": "project", "tables": {}, "quantitySheets": [ { "Code": "Q", "Name": "sheet", "options": {"digits": 2, "roundMode": 2}, "rows": [{"LineData": "2*3 = \x120\x12", "markers": ["r1"]}], "items": [{"Code": "r1", "Name": "item", "GN": 0, "TargetKey": "id:5"}], } ], } ) workbook = openpyxl.load_workbook(io.BytesIO(data)) assert workbook["QDocuments"]["A2"].value == "Q" assert workbook["QLines"]["C2"].value == "2*3 = _x0012_0_x0012_" assert workbook["QItems"]["I2"].value == "id:5" result = run( "math", """(()=>{ const p=EstNative.empty();p.tables.EstMainDat=[{PkID:5,Code:'',GN:1}]; p.quantitySheets=[{Code:'Q',Name:'old',options:{digits:2,roundMode:2}, rows:[{LineData:'1 = \\x120\\x12',markers:['r1']}], items:[{Code:'r1',Name:'old',GN:0,Extra:'preserved'}]}]; const tables={QDocuments:[{Code:'Q',Name:'edited',digits:2,roundMode:2}], QLines:[{DocumentCode:'Q',Position:1,LineData:'2*3 = \\x120\\x12',Markers:'["r1"]'}], QItems:[{DocumentCode:'Q',Position:1,Code:'r1',Name:'item',GN:0,TargetKey:'id:5'}]}; const q=EstExchange.apply(p,tables),out=EstQuantity.calculate(q.quantitySheets[0]); EstQuantity.applyTotals(q,EstQuantity.totals(q,'Q')); let rejected=0;for(const bad of [{QDocuments:tables.QDocuments}, {...tables,QLines:[{...tables.QLines[0],Markers:'not-json'}]}, {...tables,QItems:[...tables.QItems,...tables.QItems]}]) try{EstExchange.apply(p,bad)}catch{rejected++} return {q,out,rejected,original:p.quantitySheets[0].Name}; })()""", ) assert result["q"]["quantitySheets"][0]["Name"] == "edited" assert result["q"]["quantitySheets"][0]["items"][0]["Extra"] == "preserved" assert result["q"]["tables"]["EstMainDat"][0]["GN"] == 6 assert result["out"]["errors"] == [] assert result["rejected"] == 3 assert result["original"] == "old" def test_excel_quantity_document_roundtrip_keeps_optional_original_fields(): result = run( "math", """(()=>{ const p=EstNative.empty();p.quantitySheets=[{Code:'Q',Name:'sheet',options:{digits:2,roundMode:2}, rows:[{LineData:'1 = \\x120\\x12',markers:['r1']}], items:[{Code:'r1',Name:'item',Size:null,Unite:null,GN:null,LinkCode:'X',Extra:'keep'}]}]; const tables={QDocuments:[{Code:'Q',Name:'sheet',digits:2,roundMode:2}], QLines:[{DocumentCode:'Q',Position:1,LineData:'1 = \\x120\\x12',Markers:'["r1"]'}], QItems:[{DocumentCode:'Q',Position:1,Code:'r1',Name:'item',Size:null,Unite:null,GN:null,TargetCode:'X',TargetKey:null}]}; return {before:p.quantitySheets,after:EstExchange.apply(p,tables).quantitySheets}; })()""", ) assert result["before"] == result["after"] def test_excel_exports_and_imports_contract_review_inputs(): import openpyxl data = export_workbook( { "name": "project", "tables": {}, "contractReview": {"edits": {"row:1": {"GN": 2, "GU": 80, "Reason": "checked"}}}, } ) workbook = openpyxl.load_workbook(io.BytesIO(data)) assert workbook["Review"]["A2"].value == "row:1" assert workbook["Review"]["D2"].value == 80 assert workbook["Review"]["K2"].value == "checked" result = run( "math", """(()=>{ const p=EstNative.empty();p.tables.EstMainDat=[{PkID:1,Code:'A',GN:2,GU:100}]; const row={RowKey:'row:1',GN:1,GU:80,Reason:'checked'}; const q=EstExchange.apply(p,{Review:[row]});let rejected=0; for(const bad of [[row,row],[{...row,GU:'bad'}],[{...row,RowKey:'not-key'}], [{...row,RowKey:'row:missing'}],[{RowKey:'row:1',GC:4}]]) try{EstExchange.apply(p,{Review:bad})}catch{rejected++} return {review:EstReview.calculate(q),edit:q.contractReview.edits['row:1'],rejected}; })()""", ) assert result["review"]["after"] == 80 assert result["edit"]["Reason"] == "checked" assert result["rejected"] == 5 def test_excel_exports_only_unlocked_progress_and_annual_stages(): import openpyxl data = export_workbook( { "name": "project", "tables": {}, "annual": [{"name": "first", "formula": "[기준]*0.5"}], "progress": [ {"id": "locked", "name": "old", "locked": True, "inputs": {"1": {"Gum": 2}}}, { "id": "draft", "name": "new", "locked": False, "inputs": {"1": {"Gum": 3, "GumMode": "quantity"}}, }, ], } ) workbook = openpyxl.load_workbook(io.BytesIO(data)) assert workbook["Annual"]["A2"].value == "first" assert workbook["ProgressDrafts"]["A2"].value == "draft" assert workbook["ProgressDrafts"].max_row == 2 assert workbook["ProgressInputs"]["A2"].value == "draft" assert workbook["ProgressInputs"]["D2"].value == 3 def test_excel_imports_progress_drafts_without_changing_locked_rounds(): result = run( "math", """(()=>{ const p=EstNative.empty();p.options.SourceRefresh=false; p.tables.EstMainDat=[{PkID:1,Code:'A',GN:10,GU:100},{LinkIndex:54}]; p.progress=[{id:'locked',name:'old',inputs:{'1':{Gum:2}}}]; const q=EstProgress.lock(EstNative.calculate(p),'locked'); q.progress.push({id:'draft',name:'new',locked:false,inputs:{'1':{Gum:1}}}); const locked=JSON.stringify(q.progress[0]); const tables={ProgressDrafts:[{id:'draft',name:'new',approved:false,separateComponents:true}], ProgressInputs:[{RoundId:'draft',RowKey:'1',GumMode:'quantity',Gum:3}], Annual:[{name:'first',formula:'[기준]*0.5'}]}; const next=EstExchange.apply(q,tables),progress=EstProgress.calculate(next,'draft'); let rejected=0;for(const bad of [ {ProgressDrafts:tables.ProgressDrafts}, {...tables,ProgressInputs:[{RoundId:'locked',RowKey:'1',Gum:9}]}, {...tables,ProgressDrafts:[{id:'locked'}]}, {...tables,ProgressInputs:[{RoundId:'draft',RowKey:'1',GumMode:'invalid'}]}, {...tables,ProgressInputs:[{RoundId:'draft',RowKey:'missing',Gum:1}]}]) try{EstExchange.apply(q,bad)}catch{rejected++} return {same:JSON.stringify(next.progress[0])===locked,progress,annual:next.annual,rejected}; })()""", ) assert result["same"] assert result["progress"]["rows"][0]["JunTC"] == 200 assert result["progress"]["rows"][0]["TotalTC"] == 500 assert result["annual"] == [{"name": "first", "formula": "[기준]*0.5"}] assert result["rejected"] == 5