291 lines
15 KiB
Python
291 lines
15 KiB
Python
from test_est_rea_parity import run
|
|
|
|
|
|
def test_excel_quantity_cell_rejects_invalid_address_or_non_numeric_value():
|
|
result = run(
|
|
"math",
|
|
"""(()=>{
|
|
let type='n',value='6.25';
|
|
const sheet={getAttribute:k=>k==='name'?'Data':null,getAttributeNS:()=> 'r1'};
|
|
const rel={getAttribute:k=>({Id:'r1',Target:'/xl/worksheets/sheet1.xml'})[k]};
|
|
const cell={getAttribute:k=>({r:'B2',t:type})[k],getElementsByTagNameNS:()=>[{textContent:value}]};
|
|
const doc=items=>({querySelector:()=>null,getElementsByTagNameNS:()=>items});
|
|
globalThis.DOMParser=class{parseFromString(x){return x==='workbook'?doc([sheet]):x==='rels'?doc([rel]):doc([cell])}};
|
|
const files={'xl/workbook.xml':'workbook','xl/_rels/workbook.xml.rels':'rels','xl/worksheets/sheet1.xml':'sheet'};
|
|
const valid=EstQuantity.excelCell(files,'Data','b2');let rejected=0;
|
|
for(const [name,address,t,v] of [['Other','B2','n','1'],['Data','XFE1','n','1'],['Data','B3','n','1'],['Data','B2','s','1'],['Data','B2','n',''],['Data','B2','n','1e100']]){
|
|
type=t;value=v;try{EstQuantity.excelCell(files,name,address)}catch{rejected++}
|
|
}
|
|
type='n';value='8';const p={tables:{EstMainDat:[{PkID:1,GN:2},{PkID:2,GN:3}]},excelQuantityLinks:[{target:1,file:'a.xlsx',sheet:'Data',cell:'B2'},{target:2,file:'a.xlsx',sheet:'Data',cell:'B2'}]};
|
|
const updates=EstQuantity.excelUpdates(p,'a.xlsx',files).map(x=>x.value),before=p.tables.EstMainDat.map(x=>x.GN);let bad=0;
|
|
p.excelQuantityLinks[1].cell='B3';try{EstQuantity.excelUpdates(p,'a.xlsx',files)}catch{bad++}
|
|
p.tables.EstMainDat.push({PkID:1});try{EstQuantity.excelUpdates(p,'a.xlsx',files)}catch{bad++}
|
|
try{EstQuantity.excelUpdates(p,'missing.xlsx',files)}catch{bad++}
|
|
return {valid,rejected,updates,before,bad};
|
|
})()""",
|
|
)
|
|
assert result == {"valid": 6.25, "rejected": 6, "updates": [8, 8], "before": [2, 3], "bad": 3}
|
|
|
|
|
|
def test_excel_quantity_links_validate_and_survive_project_validation():
|
|
result = run(
|
|
"math",
|
|
"""(()=>{
|
|
const p=EstNative.empty();p.excelQuantityLinks=[{target:1,file:'a.xlsx',sheet:'Data',cell:'B2'}];
|
|
const valid=EstModel.validate(p).excelQuantityLinks;let rejected=0;
|
|
for(const bad of [[valid[0],valid[0]],[{...valid[0],target:'1'}],[{...valid[0],cell:'wrong'}],[{...valid[0],cell:'XFE1'}],[{...valid[0],cell:'A1048577'}]]){
|
|
const q=EstNative.empty();q.excelQuantityLinks=bad;try{EstModel.validate(q)}catch{rejected++}
|
|
}
|
|
return {valid,rejected};
|
|
})()""",
|
|
)
|
|
assert result["valid"] == [{"target": 1, "file": "a.xlsx", "sheet": "Data", "cell": "B2"}]
|
|
assert result["rejected"] == 5
|
|
|
|
|
|
def test_deleted_excel_link_target_id_is_reserved_for_new_estimate_rows():
|
|
result = run(
|
|
"math",
|
|
"""(()=>{
|
|
const p=EstNative.empty();p.tables.EstMainDat=[{PkID:1,Code:'A'}];
|
|
p.excelQuantityLinks=[{target:2,file:'a.xlsx',sheet:'Data',cell:'B2'}];
|
|
const row=EstModel.newRow('EstMainDat',p.tables.EstMainDat,p);
|
|
const other=EstModel.newRow('StuffcDat',[],p);
|
|
return {next:row.PkID,other:other.PkID};
|
|
})()""",
|
|
)
|
|
assert result == {"next": 3, "other": 1}
|
|
|
|
|
|
def test_apply_all_quantities_sums_sheets_and_rejects_errors_atomically():
|
|
result = run(
|
|
"math",
|
|
"""(()=>{
|
|
const p=EstNative.empty();p.tables.EstMainDat=[{PkID:1,Code:'A',GN:1},{PkID:2,Code:'B',GN:2}];
|
|
p.quantities=[{Code:'Q1',formula:'2',TargetKey:'id:1'}];
|
|
p.quantitySheets=[{Code:'S1',Name:'sheet',options:{},rows:[{LineData:'3 = \\x120\\x12',markers:['a']}],
|
|
items:[{Code:'a',TargetKey:'id:1'}]},
|
|
{Code:'S2',Name:'sheet2',options:{},rows:[{LineData:'4 = \\x120\\x12',markers:['b']}],
|
|
items:[{Code:'b',TargetKey:'id:2'}]}];
|
|
const values=EstQuantity.allTotals(p);EstQuantity.applyTotals(p,values);
|
|
const updated=p.tables.EstMainDat.map(row=>row.GN),before=JSON.stringify(p.tables.EstMainDat);
|
|
p.quantitySheets[1].rows[0].LineData='bad';let rejected=false;
|
|
try{EstQuantity.applyTotals(p,EstQuantity.allTotals(p))}catch{rejected=true}
|
|
return {updated,rejected,unchanged:before===JSON.stringify(p.tables.EstMainDat)};
|
|
})()""",
|
|
)
|
|
assert result == {"updated": [5, 4], "rejected": True, "unchanged": True}
|
|
|
|
|
|
def test_apply_all_quantities_rejects_duplicate_or_reserved_sheet_codes():
|
|
result = run(
|
|
"math",
|
|
"""(()=>{
|
|
const p=EstNative.empty();p.tables.EstMainDat=[{PkID:1,Code:'A',GN:1}];
|
|
p.quantities=[{Code:'Q1',formula:'2',TargetKey:'id:1'}];
|
|
let rejected=0;
|
|
for(const codes of [['S','S'],['manual'],['']]) {
|
|
p.quantitySheets=codes.map(Code=>({Code,rows:[],items:[],options:{}}));
|
|
try{EstQuantity.allTotals(p)}catch{rejected++}
|
|
}
|
|
return {rejected,GN:p.tables.EstMainDat[0].GN};
|
|
})()""",
|
|
)
|
|
assert result == {"rejected": 3, "GN": 1}
|
|
|
|
|
|
def test_quantity_apply_requires_one_detail_row_per_code():
|
|
result = run(
|
|
"math",
|
|
"""(()=>{
|
|
function apply(rows,items=[{Code:'r1',GN:7,TargetCode:'X'}]){
|
|
const handlers={};
|
|
globalThis.document={addEventListener:(name,fn)=>handlers[name]=fn};
|
|
const sheet={Code:'Q',rows:[],items,options:{}};
|
|
const state={quantitySheet:'Q',project:{quantitySheets:[sheet],tables:{EstMainDat:rows}}};
|
|
globalThis.EstApp={state,change:fn=>fn(),render:()=>{}};
|
|
EstQuantity.install();handlers.click({target:{closest:selector=>selector==='[data-quantity-apply]'?{}:null}});
|
|
return {rows,notice:state.notice||''};
|
|
}
|
|
return {duplicate:apply([{Code:'X',Level:0,GN:1},{Code:'X',Level:0,GN:2}]),
|
|
heading:apply([{Code:'X',Level:1,GN:1}]),
|
|
valid:apply([{Code:'X',Level:1,GN:1},{Code:'X',Level:0,GN:2}]),
|
|
partial:apply([{Code:'X',Level:0,GN:1}],[{Code:'r1',GN:7,TargetCode:'X'},{Code:'r2',GN:4,TargetCode:'missing'}]),
|
|
disconnected:apply([{Code:'X',Level:0,GN:1}],[{Code:'r1',GN:7,TargetCode:'',LinkCode:'X'}])};
|
|
})()""",
|
|
)
|
|
assert [r["GN"] for r in result["duplicate"]["rows"]] == [1, 2]
|
|
assert result["duplicate"]["notice"]
|
|
assert result["heading"]["rows"][0]["GN"] == 1
|
|
assert result["heading"]["notice"]
|
|
assert [r["GN"] for r in result["valid"]["rows"]] == [1, 7]
|
|
assert result["valid"]["notice"] == ""
|
|
assert result["partial"]["rows"][0]["GN"] == 1
|
|
assert result["partial"]["notice"]
|
|
assert result["disconnected"]["rows"][0]["GN"] == 1
|
|
assert result["disconnected"]["notice"]
|
|
|
|
|
|
def test_create_edit_apply_and_delete_quantity_sheet():
|
|
result = run(
|
|
"math",
|
|
"""(()=>{
|
|
const handlers={};let id=0;
|
|
globalThis.document={addEventListener:(name,fn)=>handlers[name]=fn};
|
|
globalThis.confirm=()=>true;
|
|
const state={quantitySheet:'manual',project:{quantitySheets:[],tables:{EstMainDat:[{Code:'X',Level:0,GN:1}]}}};
|
|
globalThis.EstApp={state,uid:()=>String(++id),change:fn=>fn(),render:()=>{}};
|
|
EstQuantity.install();
|
|
const click=(selector,dataset={})=>handlers.click({target:{closest:s=>s===selector?{dataset}:null}});
|
|
const change=(dataset,value)=>handlers.change({target:{dataset,value}});
|
|
click('[data-quantity-new]');click('[data-quantity-add]');
|
|
change({quantityName:''},'Earthwork');
|
|
change({quantityItem:'Name',index:'0'},'excavation');
|
|
change({quantityItem:'Unite',index:'0'},'m3');
|
|
change({quantityTarget:'0'},'X');
|
|
change({quantityLine:'0'},'2*3 = ⟦결과⟧0⟦결과⟧');
|
|
click('[data-quantity-add-item]');
|
|
const withNewItem = state.project.quantitySheets[0].items.length;
|
|
// 참조 중인 0번 항목 삭제 시도 -> 차단 및 오류 메시지 설정 확인
|
|
click('[data-quantity-delete-item]', { quantityDeleteItem: '0' });
|
|
const noticeBlocked = state.notice;
|
|
const blockedCount = state.project.quantitySheets[0].items.length;
|
|
// 미참조 항목인 1번 항목 삭제 시도 -> 정상 삭제
|
|
click('[data-quantity-delete-item]', { quantityDeleteItem: '1' });
|
|
const afterItemDeleted = state.project.quantitySheets[0].items.length;
|
|
const saved=JSON.parse(JSON.stringify(state.project));
|
|
click('[data-quantity-apply]');
|
|
const applied=state.project.tables.EstMainDat[0].GN;
|
|
const sheet=state.project.quantitySheets[0];
|
|
const computed=EstQuantity.calculate(sheet);
|
|
click('[data-quantity-delete-row]',{quantityDeleteRow:'0'});
|
|
const empty=[sheet.rows.length,sheet.items.length];
|
|
click('[data-quantity-delete-sheet]');
|
|
return {saved,applied,computed,empty,withNewItem,blockedCount,afterItemDeleted,noticeBlocked,remaining:state.project.quantitySheets.length,selection:state.quantitySheet};
|
|
})()""",
|
|
)
|
|
assert result["withNewItem"] == 2
|
|
assert result["blockedCount"] == 2
|
|
assert "연결된 산출 행을 삭제하거나" in result["noticeBlocked"]
|
|
assert result["afterItemDeleted"] == 1
|
|
assert result["saved"]["quantitySheets"][0]["Name"] == "Earthwork"
|
|
assert result["saved"]["quantitySheets"][0]["items"][0]["Name"] == "excavation"
|
|
assert result["saved"]["quantitySheets"][0]["items"][0]["Unite"] == "m3"
|
|
assert result["computed"]["errors"] == []
|
|
assert result["computed"]["items"][0]["GN"] == 6
|
|
assert result["applied"] == 6
|
|
assert result["empty"] == [0, 0]
|
|
assert result["remaining"] == 0
|
|
assert result["selection"] == "manual"
|
|
|
|
|
|
def test_insert_and_move_quantity_rows_keeps_item_results_linked():
|
|
result = run(
|
|
"math",
|
|
"""(()=>{
|
|
const handlers={};let id=0;
|
|
globalThis.document={addEventListener:(name,fn)=>handlers[name]=fn};
|
|
const state={quantitySheet:'manual',project:{quantitySheets:[],tables:{EstMainDat:[]}}};
|
|
globalThis.EstApp={state,uid:()=>String(++id),change:fn=>fn(),render:()=>{}};EstQuantity.install();
|
|
const click=(selector,dataset={})=>handlers.click({target:{closest:s=>s===selector?{dataset}:null}});
|
|
click('[data-quantity-new]');click('[data-quantity-add]');click('[data-quantity-add]');
|
|
const sheet=state.project.quantitySheets[0];
|
|
sheet.rows[0].LineData='1+2 = \\x120\\x12';sheet.rows[1].LineData='4+5 = \\x120\\x12';
|
|
click('[data-quantity-add]',{index:'0'});
|
|
sheet.rows[1].LineData='6+7 = \\x120\\x12';
|
|
const before=EstQuantity.calculate(sheet).items.map(x=>x.GN);
|
|
click('[data-quantity-move]',{index:'2',quantityMove:'-1'});
|
|
const after=EstQuantity.calculate(sheet).items.map(x=>x.GN);
|
|
const order=sheet.rows.map(x=>x.LineData);
|
|
sheet.rows[0].LineData='A=2 = \\x120\\x12';sheet.rows[1].LineData='A*3 = \\x120\\x12';
|
|
const linked=EstQuantity.calculate(sheet);
|
|
click('[data-quantity-move]',{index:'1',quantityMove:'-1'});
|
|
const forward=EstQuantity.calculate(sheet);
|
|
return {before,after,order,linked,forward};
|
|
})()""",
|
|
)
|
|
assert result["before"] == result["after"] == [3, 9, 13]
|
|
assert result["order"][1].startswith("4+5")
|
|
assert result["linked"]["errors"] == []
|
|
assert result["linked"]["items"][1]["GN"] == 6
|
|
assert result["forward"]["errors"]
|
|
|
|
|
|
def test_manual_and_selected_sheet_totals_validate_before_application():
|
|
result = run(
|
|
"math",
|
|
"""(()=>{
|
|
const p=EstNative.empty();
|
|
p.tables.EstMainDat=[{Code:'X',Level:0,GN:1,MulCode:{binary:'YWJj'}}];
|
|
p.quantities=[{Code:'A',formula:'2',TargetCode:'X'},
|
|
{Code:'B',formula:'[A]*3',TargetCode:'X'}];
|
|
const manual=EstQuantity.totals(p,'manual');EstQuantity.applyTotals(p,manual);
|
|
const a=structuredClone(p.tables.EstMainDat[0]);
|
|
p.quantitySheets=[{Code:'Q',options:{},rows:[{LineData:'4+5 = \\x120\\x12',markers:['r1']}],
|
|
items:[{Code:'r1',GN:0,TargetCode:'X'}]}];
|
|
const sheet=EstQuantity.totals(p,'Q');EstQuantity.applyTotals(p,sheet);
|
|
let invalid='';p.quantities.push({Code:'C',formula:'1',TargetCode:'missing'});
|
|
try{EstQuantity.totals(p,'manual')}catch(e){invalid=e.message}
|
|
return {a,b:p.tables.EstMainDat[0],invalid};
|
|
})()""",
|
|
)
|
|
assert result["a"]["GN"] == 8
|
|
assert result["a"]["MulCode"] == {"binary": "YWJj"}
|
|
assert result["b"]["GN"] == 9
|
|
assert result["b"]["MulCode"] == {"binary": "YWJj"}
|
|
assert result["invalid"]
|
|
|
|
|
|
def test_new_quantity_code_does_not_reuse_existing_or_referenced_codes():
|
|
result = run(
|
|
"math",
|
|
"""[EstQuantity.newCode([{Code:'Q2',formula:'[Q1]*3'}]),
|
|
EstQuantity.newCode([{Code:'Q2',formula:'1'}]), EstQuantity.newCode([])]""",
|
|
)
|
|
assert result == ["Q3", "Q1", "Q1"]
|
|
|
|
|
|
def test_target_row_ids_support_blank_and_duplicate_codes_for_both_modes():
|
|
result = run(
|
|
"math",
|
|
"""(()=>{
|
|
const p=EstNative.empty();p.tables.EstMainDat=[
|
|
{PkID:1,Code:'X',GN:1},{PkID:2,Code:'X',GN:2},{PkID:3,Code:'',GN:3}];
|
|
p.quantities=[{Code:'A',formula:'7',TargetCode:'X',TargetKey:'id:2'},
|
|
{Code:'B',formula:'4',TargetCode:'',TargetKey:'id:3'}];
|
|
EstQuantity.applyTotals(p,EstQuantity.totals(p,'manual'));
|
|
const manual=p.tables.EstMainDat.map(row=>row.GN);
|
|
p.quantitySheets=[{Code:'Q',options:{},rows:[{LineData:'2+3 = \\x120\\x12',markers:['r1']}],
|
|
items:[{Code:'r1',GN:0,TargetCode:'X',TargetKey:'id:1'}]}];
|
|
p.tables.EstMainDat.reverse();EstQuantity.applyTotals(p,EstQuantity.totals(p,'Q'));
|
|
const sheet=p.tables.EstMainDat.map(row=>[row.PkID,row.GN]);
|
|
p.quantitySheets[0].items[0].TargetKey='id:missing';let error='';
|
|
try{EstQuantity.totals(p,'Q')}catch(e){error=e.message}
|
|
return {manual,sheet,error};
|
|
})()""",
|
|
)
|
|
assert result["manual"] == [1, 7, 4]
|
|
assert result["sheet"] == [[3, 4], [2, 7], [1, 5]]
|
|
assert result["error"]
|
|
|
|
|
|
def test_missing_or_extra_result_markers_block_quantity_application():
|
|
result = run(
|
|
"math",
|
|
"""(()=>{
|
|
const p=EstNative.empty();p.tables.EstMainDat=[{PkID:1,Code:'X',GN:1}];
|
|
p.quantitySheets=[{Code:'Q',options:{},rows:[{LineData:'2*3 = \\x120\\x12',markers:['r1']}],
|
|
items:[{Code:'r1',GN:9,TargetKey:'id:1'}]}];
|
|
const sheet=p.quantitySheets[0],valid=EstQuantity.calculate(sheet);
|
|
const bad=[];for(const line of ['2*3 = 0','2*3 = \\x120\\x12 + \\x120\\x12']){
|
|
sheet.rows[0].LineData=line;let blocked=false;
|
|
try{EstQuantity.applyTotals(p,EstQuantity.totals(p,'Q'))}catch{blocked=true}
|
|
bad.push({blocked,errors:EstQuantity.calculate(sheet).errors,quantity:p.tables.EstMainDat[0].GN});
|
|
}
|
|
return {valid,bad};
|
|
})()""",
|
|
)
|
|
assert result["valid"]["errors"] == []
|
|
assert result["valid"]["items"][0]["GN"] == 6
|
|
assert all(row["blocked"] and row["errors"] and row["quantity"] == 1 for row in result["bad"])
|