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("
") }; })()""", ) 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