Files

447 lines
18 KiB
Python

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