447 lines
18 KiB
Python
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
|