Files
Aislo/resources/tester/test_est_documents.py

493 lines
21 KiB
Python

from test_est_rea_parity import run
def test_print_margins_validate_independently_and_keep_legacy_setting():
result = run(
"math",
"""(()=>{
const p=EstNative.empty(); p.printSettings={margin:10,marginTop:11,marginBottom:12,marginLeft:13,marginRight:14};
const valid=EstModel.validate(p).printSettings; let rejected=0;
for (const key of ['marginTop','marginBottom','marginLeft','marginRight']) {
const q=structuredClone(p);q.printSettings[key]=51;try{EstModel.validate(q)}catch{rejected++}
}
return {valid,rejected};
})()""",
)
assert result["valid"] == {
"margin": 10,
"marginTop": 11,
"marginBottom": 12,
"marginLeft": 13,
"marginRight": 14,
}
assert result["rejected"] == 4
def test_custom_print_form_documents_are_saved_and_unknown_keys_rejected():
result = run(
"math",
"""(()=>{
const p=EstNative.empty();p.documents={'annual-items':'stage note','progress-costs':'progress note','standard-usage':'standard note'};
const valid=EstModel.validate(p).documents;let rejected=false;
const q=EstNative.empty();q.documents={'invalid-key':'bad'};try{EstModel.validate(q)}catch{rejected=true}
return {valid,rejected};
})()""",
)
assert result["valid"] == {
"annual-items": "stage note",
"progress-costs": "progress note",
"standard-usage": "standard note",
}
assert result["rejected"]
def test_schedule_refreshes_progress_from_latest_locked_round_without_losing_dates():
result = run(
"math",
"""(()=>{
const p=EstNative.empty();p.tables.EstMainDat=[{PkID:1,Code:'A',GN:10},{PkID:2,Code:'B',GN:0}];
p.schedule=[{id:'a',SourceKey:'id:1',Code:'A',Name:'work',start:'2026-01-01',end:'2026-02-01',weight:100,progress:10},
{id:'b',SourceKey:'id:2',Code:'B',Name:'zero',start:'',end:'',weight:0,progress:40},
{id:'c',Code:'X',Name:'manual',start:'',end:'',weight:10,progress:20}];
p.progress=[{id:'one',locked:true,result:{rows:[{PkID:1,Code:'A',GN:10,TotalGN:2}]}},
{id:'two',locked:true,result:{rows:[{PkID:1,Code:'A',GN:10,TotalGN:3},{PkID:2,Code:'B',GN:0,TotalGN:0}]}}];
const before=JSON.stringify(p), updated=EstSchedule.refreshProgress(p);let bad=0;
p.progress[1].result.rows[0].TotalGN=11;try{EstSchedule.refreshProgress(p)}catch{bad++}
p.progress=[];try{EstSchedule.refreshProgress(p)}catch{bad++}
return {updated,bad,unchanged:JSON.parse(before).schedule};
})()""",
)
assert [row["progress"] for row in result["updated"]] == [30, 40, 20]
assert result["updated"][0]["start"] == "2026-01-01"
assert result["updated"][0]["weight"] == 100
assert result["unchanged"][0]["progress"] == 10
assert result["bad"] == 2
def test_annual_print_contains_each_stage_and_calculation_errors():
result = run(
"math",
"""(()=>{
const p=EstNative.empty();p.options.SourceRefresh=false;
p.tables.EstMainDat=[{PkID:1,Code:'R',GN:3,GU:100},{LinkIndex:54}];
p.annual=[{name:'first',formula:'[기준]*0.25'},{name:'second',formula:'[기준]-[first]'}];
const q=EstNative.calculate(p), good=EstForms.data('annual-summary',q), items=EstForms.data('annual-items',q);
q.annual[0].formula='[unknown]';
return {good,items,bad:EstForms.data('annual-summary',q),badItems:EstForms.data('annual-items',q)};
})()""",
)
assert [row["directCost"] for row in result["good"]["rows"]] == [75, 225]
assert result["good"]["rows"][1]["formula"] == "[기준]-[first]"
assert result["good"]["errors"] == []
assert result["bad"]["errors"]
assert [row["stage"] for row in result["items"]["rows"]] == ["first", "second"]
assert [row["TC"] for row in result["items"]["rows"]] == [75, 225]
assert result["badItems"]["errors"]
def test_progress_and_escalation_print_use_selected_calculations():
result = run(
"math",
"""(()=>{
const p=EstNative.empty();p.options.SourceRefresh=false;p.options.EscDesignTotal=1000;
p.tables.EstMainDat=[{PkID:1,Code:'R',Name:'work',GN:10,GU:100,AGU:100,EscActual:3},{LinkIndex:54}];
p.progress=[{id:'one',name:'first',inputs:{'1':{Gum:2}}}];
const q=EstProgress.lock(EstNative.calculate(p),'one');
q.tables.EstMainDat[0].GU=200;
q.progress.push({id:'two',name:'second',inputs:{'1':{Gum:3}}});
return {locked:EstForms.data('progress-summary',q,{roundId:'one'}),
draft:EstForms.data('progress-summary',q,{roundId:'two'}),
escalation:EstForms.data('escalation-summary',p),
empty:EstForms.data('progress-summary',EstNative.empty())};
})()""",
)
assert result["locked"]["rows"][0]["GumTC"] == 200
assert result["locked"]["rows"][0]["GU"] == 100
assert result["draft"]["rows"][0]["GumTC"] == 600
assert result["draft"]["rows"][0]["TotalTC"] == 800
assert result["escalation"]["rows"][0]["EscExcluded"] == 3
assert result["escalation"]["rows"][0]["EscApplied"] == 7
assert result["empty"]["errors"]
def test_progress_cost_print_preserves_selected_round_costs():
result = run(
"math",
"""(()=>{
const p=EstNative.empty();p.options.SourceRefresh=false;
p.tables.EstMainDat=[{PkID:1,Code:'R',GN:10,GU:100},{LinkIndex:54}];
p.tables.Wonga=[{Gubun:'D',Name:'direct',BaseField:'GC'},{Gubun:'T',Name:'total',LinkIndex:55,VSusic:'D'}];
p.progress=[{id:'one',inputs:{'1':{Gum:2,Sa:1}},approved:true}];
const q=EstProgress.lock(EstNative.calculate(p),'one');
q.tables.EstMainDat[0].GU=200;q.progress.push({id:'two',inputs:{'1':{Gum:3}}});
return {one:EstForms.data('progress-costs',q,{roundId:'one'}),two:EstForms.data('progress-costs',q,{roundId:'two'})};
})()""",
)
assert result["one"]["rows"][-1]["ContractCost"] == 1000
assert result["one"]["rows"][-1]["GumCost"] == 200
assert result["one"]["rows"][-1]["SaCost"] == 100
assert result["two"]["rows"][-1]["JunCost"] == 100
assert result["two"]["rows"][-1]["GumCost"] == 600
assert result["two"]["rows"][-1]["TotalCost"] == 700
def test_escalation_cost_print_uses_computed_cost_breakdowns():
result = run(
"math",
"""(()=>{
const p=EstNative.empty();p.options.SourceRefresh=false;p.options.EscDesignTotal=1000;
p.tables.EstMainDat=[{PkID:1,Code:'R',GN:10,GU:80},{LinkIndex:54}];
p.tables.Wonga=[{Gubun:'D',Name:'direct',BaseField:'GC'},{Gubun:'T',Name:'total',LinkIndex:55,VSusic:'D'}];
const q=EstBusiness.baseline(EstNative.calculate(p));
Object.assign(q.tables.EstMainDat[0],{AGU:100,GU:120,EscActual:3});
return EstForms.data('escalation-costs',q);
})()""",
)
assert result["errors"] == []
assert result["rows"][-1]["ContractCost"] == 800
assert result["rows"][-1]["adjustedCost"] == 960
assert result["rows"][-1]["excludedCost"] == 240
def test_resource_view_filtering_and_by_work_expansion():
result = run(
"math",
"""(()=>{
const p = structuredClone(project);
const allRows = EstResources.calculate(p, false).rows;
const byWorkRows = EstResources.calculate(p, true).rows;
const laborOnly = allRows.filter(r => r.kind === 6);
const materialOnly = allRows.filter(r => r.kind === 5);
const machineOnly = allRows.filter(r => r.kind === 105);
const laborByWork = byWorkRows.filter(r => r.kind === 6);
// 공종별 집계 합산과 전체 집계의 금액/수량 일치 검증
const laborAllQty = laborOnly.reduce((s, r) => s + r.quantity, 0);
const laborAllAmt = laborOnly.reduce((s, r) => s + r.amount, 0);
const laborWorkQty = laborByWork.reduce((s, r) => s + r.quantity, 0);
const laborWorkAmt = laborByWork.reduce((s, r) => s + r.amount, 0);
// 서식 데이터(EstForms.data) 연동 확인
const f112 = EstForms.data(112, p, {project: p});
const f13 = EstForms.data(13, p, {project: p});
return {
allCount: allRows.length,
byWorkCount: byWorkRows.length,
laborCount: laborOnly.length,
materialCount: materialOnly.length,
machineCount: machineOnly.length,
laborByWorkCount: laborByWork.length,
qtyDiff: Math.abs(laborAllQty - laborWorkQty),
amtDiff: Math.abs(laborAllAmt - laborWorkAmt),
f112Count: f112.rows.length,
f13Count: f13.rows.length,
f112HasWork: f112.columns.includes('WorkCode') && f112.columns.includes('WorkName')
};
})()""",
)
assert result["allCount"] == 27
assert result["byWorkCount"] == 72
assert result["laborCount"] == 7
assert result["materialCount"] == 13
assert result["machineCount"] == 7
assert result["laborByWorkCount"] == 30
assert result["qtyDiff"] < 1e-9
assert result["amtDiff"] < 1e-6
assert result["f112Count"] == 30
assert result["f13Count"] == 7
assert result["f112HasWork"] is True
def test_resource_report_separates_each_work_item_without_changing_aggregate():
result = run(
"math",
"""(()=>{
const p=EstNative.empty();p.options.SourceRefresh=false;
p.tables.StuffcDat=[{Code:'M',Name:'material',InputTC:100,ValueIndex:4}];
p.tables.EstMainDat=[{PkID:1,Code:'M',LinkIndex:5,GN:2,GU:100},{PkID:2,Code:'M',LinkIndex:5,GN:3,GU:100},{LinkIndex:54}];
return {all:EstResources.calculate(p),split:EstResources.calculate(p,true)};
})()""",
)
assert len(result["all"]["rows"]) == 1
assert len(result["split"]["rows"]) == 2
assert (
sum(r["quantity"] for r in result["split"]["rows"])
== result["all"]["rows"][0]["quantity"]
== 5
)
assert (
sum(r["amount"] for r in result["split"]["rows"])
== result["all"]["rows"][0]["amount"]
== 500
)
def test_schedule_dates_ranges_and_weighted_user_progress():
result = run(
"math",
"""(()=>{
const p=EstNative.empty();p.schedule=[
{id:'a',Code:'A',Name:'first',start:'2026-11-01',end:'2026-11-05',weight:100,progress:25},
{id:'b',Code:'B',Name:'second',start:'2026-11-06',end:'2026-11-10',weight:300,progress:50}];
const valid=EstSchedule.calculate(p),bad=[];
for(const change of [{start:'2026-02-30'},{progress:101},{weight:-1},{end:'2026-10-01'}]) {
const q=structuredClone(p);Object.assign(q.schedule[0],change);
try{EstModel.validate(q);bad.push(false)}catch{bad.push(true)}
}
return {valid,bad};
})()""",
)
assert result["valid"]["progress"] == 43.75
assert result["valid"]["rows"][0]["width"] == 50
assert result["valid"]["rows"][1]["left"] == 50
assert result["bad"] == [True] * 4
def test_schedule_import_and_update_weights_preserve_user_schedule():
result = run(
"math",
"""(()=>{
const p=EstNative.empty();
p.tables.EstMainDat=[{Code:'A',Name:'one',TC:100}, {Code:'B',Name:'two',TC:300},
{Code:'HEADER',Level:1,TC:999},{Code:'TOTAL',LinkIndex:54,TC:400}];
p.schedule=[{id:'old',Code:'A',Name:'custom',start:'2026-01-01',end:'2026-01-10',weight:1,progress:40},
{id:'manual',Code:'other',Name:'manual',start:'',end:'',weight:5,progress:20}];
let id=0;const added=EstSchedule.fromEstimate(p,()=>String(++id));p.schedule.push(...added);
p.tables.EstMainDat[0].TC=200;p.schedule=EstSchedule.refreshWeights(p);
let duplicate='';p.tables.EstMainDat.push({Code:'A',TC:20});
try{EstSchedule.fromEstimate(p,()=>String(++id))}catch(e){duplicate=e.message}
return {added,rows:p.schedule,duplicate};
})()""",
)
assert [row["Code"] for row in result["added"]] == ["B"]
assert [row["weight"] for row in result["rows"]] == [200, 5, 300]
assert result["rows"][0]["Name"] == "custom"
assert result["rows"][0]["start"] == "2026-01-01"
assert result["rows"][0]["progress"] == 40
assert result["duplicate"]
def test_schedule_imports_distinct_rows_with_repeated_or_empty_codes():
result = run(
"math",
"""(()=>{
const p=EstNative.empty();p.tables.EstMainDat=[
{PkID:1,Code:'X',Name:'one',TC:100},{PkID:2,Code:'X',Name:'two',TC:200},
{PkID:3,Code:null,Name:'three',TC:300}];
let id=0;p.schedule=EstSchedule.fromEstimate(p,()=>String(++id));
p.schedule[0].start='2026-01-01';p.schedule[0].progress=50;
p.tables.EstMainDat.reverse();p.tables.EstMainDat[0].TC=400;
const updated=EstSchedule.refreshWeights(p);
return {rows:p.schedule,updated,again:EstSchedule.fromEstimate(p,()=>String(++id))};
})()""",
)
assert [row["SourceKey"] for row in result["rows"]] == ["id:1", "id:2", "id:3"]
assert [row["weight"] for row in result["updated"]] == [100, 200, 400]
assert result["updated"][0]["start"] == "2026-01-01"
assert result["updated"][0]["progress"] == 50
assert result["again"] == []
def test_schedule_move_row_and_clear_operations():
result = run(
"math",
"""(()=>{
const p = EstNative.empty();
p.schedule = [
{id:'1', Code:'A', Name:'첫번째', start:'2026-01-01', end:'2026-01-10', weight:100, progress:10},
{id:'2', Code:'B', Name:'두번째', start:'2026-01-11', end:'2026-01-20', weight:200, progress:20},
{id:'3', Code:'C', Name:'세번째', start:'2026-01-21', end:'2026-01-31', weight:300, progress:30}
];
// 상하 경계 이동 무반응 확인
EstSchedule.moveRow(p, 0, -1);
const atTop = p.schedule.map(r => r.Code);
EstSchedule.moveRow(p, 2, 1);
const atBottom = p.schedule.map(r => r.Code);
// 중간 행 아래로 이동
EstSchedule.moveRow(p, 1, 1);
const afterDown = p.schedule.map(r => r.Code);
// 마지막 행 위로 이동
EstSchedule.moveRow(p, 2, -1);
const afterUp = p.schedule.map(r => r.Code);
// 공정 전체 비우기
const countBeforeClear = p.schedule.length;
EstSchedule.clearSchedule(p);
const countAfterClear = p.schedule.length;
return {
atTop,
atBottom,
afterDown,
afterUp,
countBeforeClear,
countAfterClear
};
})()""",
)
assert result["atTop"] == ["A", "B", "C"]
assert result["atBottom"] == ["A", "B", "C"]
assert result["afterDown"] == ["A", "C", "B"]
assert result["afterUp"] == ["A", "B", "C"]
assert result["countBeforeClear"] == 3
assert result["countAfterClear"] == 0
def test_quantity_print_includes_manual_and_sheet_formulas_and_errors():
result = run(
"math",
"""(()=>{
const p=EstNative.empty();p.quantities=[
{Code:'A',Name:'first',formula:'2',TargetCode:'X'},
{Code:'B',Name:'second',formula:'[A]*3',TargetCode:'X'},
{Code:'C',Name:'bad',formula:'[missing]',TargetCode:'X'}];
p.quantitySheets=[{Code:'Q',Name:'sheet',options:{},rows:[
{LineData:'4+5 = \\x120\\x12',markers:['r1']}],
items:[{Code:'r1',Name:'sheet item',GN:0,TargetCode:'Y'}]}];
return EstForms.data(14,p);
})()""",
)
assert [row["GN"] for row in result["rows"]] == [2, 6, None, 9]
assert result["rows"][1]["formula"] == "[A]*3"
assert "4+5" in result["rows"][3]["formula"]
assert result["rows"][3]["TargetCode"] == "Y"
assert result["rows"][2]["Error"]
assert result["errors"]
def test_public_view_forms_print_gong_method_options():
result = run(
"math",
"""(()=>{
const p = EstNative.empty();
p.tables.EstMainDat = [
{ PkID: 1, Level: 1, Code: 'C01', Name: '토공사' },
{ PkID: 2, Level: 0, Code: 'A01', Name: '터파기', Size: '일반', Unite: 'm3', GN: 100, TU: 500, TC: 50000, NC: 20000, GC: 30000, JC: 0, SC: 0 }
];
p.tables.HeadMainDat = [
{ Index: 1, Code: 'H01', Name: '터파기 호표', TC: 500 },
{ Index: 0, Code: 'L01', Name: '인부', Unite: '인', GN: 0.1, NU: 1000, GU: 0, JU: 0, SU: 0, TC: 100 }
];
// 1) 기본(PrintGongMethod=0): 공람용 내역서(117), 일위대가(115), 총괄표(116, 118) 수량만/금액제외
p.printSettings = { PrintGongMethod: 0 };
const d117_m0 = EstForms.data(117, p);
const d115_m0 = EstForms.data(115, p);
const d118_m0 = EstForms.data(118, p);
const d116_m0 = EstForms.data(116, p);
// 2) PrintGongMethod=1: 단가/금액만 (수량 제외)
p.printSettings = { PrintGongMethod: 1 };
const d117_m1 = EstForms.data(117, p);
const d115_m1 = EstForms.data(115, p);
// 3) PrintGongMethod=2: 수량 + 단가/금액 모두 포함
p.printSettings = { PrintGongMethod: 2 };
const d117_m2 = EstForms.data(117, p);
const d115_m2 = EstForms.data(115, p);
const d118_m2 = EstForms.data(118, p);
// 4) 표준 내역서(1) 및 일위대가(2)는 공람옵션 영향 없이 전체 열 출력
const d1 = EstForms.data(1, p);
const d2 = EstForms.data(2, p);
return {
d117_m0: d117_m0.columns,
d115_m0: d115_m0.columns,
d118_m0: d118_m0.columns,
d116_m0: d116_m0.columns,
d117_m1: d117_m1.columns,
d115_m1: d115_m1.columns,
d117_m2: d117_m2.columns,
d115_m2: d115_m2.columns,
d118_m2: d118_m2.columns,
d1: d1.columns,
d2: d2.columns
};
})()""",
)
# 1) m0 (수량만): GN만 포함, 단가/금액 비목 제외
assert (
"GN" in result["d117_m0"]
and "TC" not in result["d117_m0"]
and "TU" not in result["d117_m0"]
)
assert (
"GN" in result["d115_m0"]
and "TC" not in result["d115_m0"]
and "NU" not in result["d115_m0"]
)
assert "TC" not in result["d118_m0"] and "NC" not in result["d118_m0"]
assert "TC" not in result["d116_m0"] and "NC" not in result["d116_m0"]
# 2) m1 (단가만): GN 제외, 단가/금액 포함
assert "GN" not in result["d117_m1"] and "TC" in result["d117_m1"] and "TU" in result["d117_m1"]
assert "GN" not in result["d115_m1"] and "TC" in result["d115_m1"] and "NU" in result["d115_m1"]
# 3) m2 (수량, 단가 모두): GN, 단가, 금액 모두 포함
assert "GN" in result["d117_m2"] and "TC" in result["d117_m2"]
assert "GN" in result["d115_m2"] and "TC" in result["d115_m2"]
assert "TC" in result["d118_m2"]
# 4) 일반 서식(1, 2)은 불변 유지
assert "GN" in result["d1"] and "TC" in result["d1"]
assert "GN" in result["d2"] and "TC" in result["d2"]
def test_print_apply_field_and_sum_bottom_preserve_all_rows_and_no_destructive_filtering():
result = run(
"math",
"""(()=>{
const p = EstNative.empty();
// 공종(레벨1), 소계, 0원 항목, 음수 변경분, 일반 상세항목 혼재
p.tables.EstMainDat = [
{ Code: "", Name: "토공사", Size: "", Unite: "", GN: 0, TU: 0, TC: 0, Level: 1 },
{ Code: "A01", Name: "터파기", Size: "토사", Unite: "m3", GN: 100, TU: 5000, TC: 500000, NC: 300000, GC: 100000, JC: 100000, SC: 0, Level: 0 },
{ Code: "A02", Name: "잔토처리", Size: "덤프15t", Unite: "m3", GN: 50, TU: 0, TC: 0, NC: 0, GC: 0, JC: 0, SC: 0, Level: 0 },
{ Code: "A03", Name: "설계변경감소", Size: "규격", Unite: "식", GN: -10, TU: 10000, TC: -100000, NC: -50000, GC: -50000, JC: 0, SC: 0, Level: 0 },
{ Code: "", Name: "소계", Size: "", Unite: "", GN: 0, TU: 0, TC: 400000, Level: 0 }
];
p.tables.HeadMainDat = [
{ Code: "H01", Name: "일위대가항목", Size: "", Unite: "식", GN: 1, NU: 1000, GU: 2000, JU: 0, SU: 0, TC: 3000 },
{ Code: "H02", Name: "0원대가항목", Size: "", Unite: "", GN: 1, NU: 0, GU: 0, JU: 0, SU: 0, TC: 0 }
];
// PrintApplyField / PrintSumBottom 옵션이 어떤 값이든 행과 제목·0원·음수 행이 삭제되지 않고 100% 보존되는지 확인
p.printSettings = { PrintApplyField: 1, PrintSumBottom: 1 };
const d1 = EstForms.data(1, p);
const d2 = EstForms.data(2, p);
const d117 = EstForms.data(117, p);
const d115 = EstForms.data(115, p);
const grid1 = EstForms.grid(d1);
return {
d1Count: d1.rows.length,
d2Count: d2.rows.length,
d117Count: d117.rows.length,
d115Count: d115.rows.length,
hasZeroRow: d1.rows.some(r => r.Code === "A02" && r.TU === 0),
hasNegativeRow: d1.rows.some(r => r.Code === "A03" && r.TC === -100000),
hasTitleRow: d1.rows.some(r => r.Name === "토공사" && r.Level === 1),
hasSubtotalRow: d1.rows.some(r => r.Name === "소계"),
noInvalidSumBottomTfoot: !grid1.includes("<tfoot>")
};
})()""",
)
assert result["d1Count"] == 5
assert result["d2Count"] == 2
assert result["d117Count"] == 5
assert result["d115Count"] == 2
assert result["hasZeroRow"] is True
assert result["hasNegativeRow"] is True
assert result["hasTitleRow"] is True
assert result["hasSubtotalRow"] is True
assert result["noInvalidSumBottomTfoot"] is True