diff --git a/resources/tester/helper_est_native.cjs b/resources/tester/helper_est_native.cjs index e1f95dd86..c1b0fb606 100644 --- a/resources/tester/helper_est_native.cjs +++ b/resources/tester/helper_est_native.cjs @@ -12,6 +12,7 @@ for (const file of [ "native-quantity.cjs", "native-wonga.js", "native-engine.js", + "native-row-match.js", "native-escalation.js", "native-progress.js", "native-business.js", @@ -29,6 +30,7 @@ for (const file of [ "native-c3r.js", "native-exchange.js", "native-verify.js", + "native-block-actions.js", "native-extra.js", "native-business-actions.js", "native-forms.js", diff --git a/resources/tester/test_est_basic_prices.py b/resources/tester/test_est_basic_prices.py index b4777d13c..5ab512257 100644 --- a/resources/tester/test_est_basic_prices.py +++ b/resources/tester/test_est_basic_prices.py @@ -1,6 +1,132 @@ from test_est_rea_parity import run +def test_machine_restore_group_uses_saved_baseline_without_changing_other_groups(): + result = run( + "math", + """(()=>{ + const p=EstNative.empty(); + p.tables.MachMainDat=[{PkID:1,Index:1,Code:'M1',Name:'장비1'},{PkID:2,Index:0,GN:2}, + {PkID:3,Index:1,Code:'M2',Name:'장비2'},{PkID:4,Index:0,GN:3}]; + p.tables.MachTitleDat=[{Code:'M1',Name:'장비1'},{Code:'M2',Name:'장비2'}]; + const q=EstModel.validate(JSON.parse(JSON.stringify(EstBusiness.baseline(p)))); + q.tables.MachMainDat[1].GN=9;q.tables.MachMainDat[3].GN=7; + const before=JSON.stringify(q), rows=EstMachine.restoreGroup(q,'M1'); + const originalUntouched=JSON.stringify(q)===before; + let missing=false,duplicate=false,collision=false; + const legacy=structuredClone(q);delete legacy.baseline.tables.MachMainDat; + try { EstMachine.restoreGroup(legacy,'M1'); } catch { missing=true; } + const conflict=structuredClone(q);conflict.tables.MachMainDat[3].PkID=2; + try { EstMachine.restoreGroup(conflict,'M1'); } catch { collision=true; } + q.tables.MachMainDat.push({Index:1,Code:'M1'}); + try { EstMachine.restoreGroup(q,'M1'); } catch { duplicate=true; } + return {restored:rows[1].GN,other:rows[3].GN,originalUntouched, + baseline:q.baseline.tables.MachMainDat[1].GN,missing,duplicate,collision}; + })()""", + ) + assert result == { + "restored": 2, + "other": 7, + "originalUntouched": True, + "baseline": 2, + "missing": True, + "duplicate": True, + "collision": True, + } + + +def test_machine_change_includes_new_changed_and_deleted_titles(): + result = run( + "math", + """(()=>{ + const p=EstNative.empty(); + p.tables.MachTitleDat=[{Code:'M1',Name:'장비1',TC:150},{Code:'M3',Name:'신규',TC:50}]; + p.baseline={tables:{EstMainDat:[],MachTitleDat:[ + {Code:'M1',Name:'장비1',TC:100},{Code:'M2',Name:'삭제',TC:200}]}}; + return EstMachine.changes(p).map(({Code,originalTC,TC,difference,status,index})=> + ({Code,originalTC,TC,difference,status,index})); + })()""", + ) + assert result == [ + { + "Code": "M1", + "originalTC": 100, + "TC": 150, + "difference": 50, + "status": "변경", + "index": 0, + }, + { + "Code": "M3", + "originalTC": None, + "TC": 50, + "difference": None, + "status": "신규", + "index": 1, + }, + { + "Code": "M2", + "originalTC": 200, + "TC": 0, + "difference": -200, + "status": "삭제", + "index": None, + }, + ] + + +def test_recover_deleted_machine_group_and_reject_id_collision(): + result = run( + "math", + """(()=>{ + const p=EstNative.empty(); + p.tables.MachMainDat=[{PkID:1,Index:1,Code:'M1',Name:'first'}, + {PkID:2,Index:-2,GN:2,TU:100},{PkID:3,Index:1,Code:'M2',Name:'second'}, + {PkID:4,Index:-2,GN:3,TU:100}]; + p.tables.MachTitleDat=[{PkID:10,Code:'M1',Name:'first',TC:200}, + {PkID:11,Code:'M2',Name:'second',TC:300}]; + const q=EstBusiness.baseline(p); + q.tables.MachMainDat=q.tables.MachMainDat.slice(2); + q.tables.MachTitleDat=q.tables.MachTitleDat.slice(1); + const before=JSON.stringify(q), restored=EstMachine.recoverGroup(q,'M1'); + const unchanged=JSON.stringify(q)===before; + const collision=structuredClone(q);collision.tables.MachMainDat[1].PkID=2; + let rejected=false;try { EstMachine.recoverGroup(collision,'M1'); } catch { rejected=true; } + q.tables.MachMainDat=restored.rows;q.tables.MachTitleDat=restored.titles; + return {codes:q.tables.MachTitleDat.map(x=>x.Code),quantity:q.tables.MachMainDat[1].GN, + original:EstMachine.usage(q).find(x=>x.Code==='M1').originalPrice,unchanged,rejected}; + })()""", + ) + assert result == { + "codes": ["M1", "M2"], + "quantity": 2, + "original": 200, + "unchanged": True, + "rejected": True, + } + + +def test_recovered_machine_group_recalculates_linked_estimate_after_reopen(): + result = run( + "math", + """(()=>{ + const p=EstNative.empty();p.options.SourceRefresh=true; + p.tables.MachMainDat=[{PkID:1,Index:1,Code:'M1',Name:'machine'}, + {PkID:2,Index:-2,Name:'fuel',GN:2,TU:100}]; + p.tables.MachTitleDat=[{PkID:10,Code:'M1',Name:'machine'}]; + p.tables.EstMainDat=[{PkID:20,Code:'M1',Name:'machine',LinkIndex:105,GN:2}]; + const q=EstBusiness.baseline(EstNative.calculate(p)); + q.tables.MachMainDat=[];q.tables.MachTitleDat=[]; + const restored=EstMachine.recoverGroup(JSON.parse(JSON.stringify(q)),'M1'); + q.tables.MachMainDat=restored.rows;q.tables.MachTitleDat=restored.titles; + const out=EstNative.calculate(EstModel.validate(JSON.parse(JSON.stringify(q)))); + return {machine:out.tables.MachTitleDat[0].TC, estimate:out.tables.EstMainDat[0].TC, + errors:out.calculation.errors}; + })()""", + ) + assert result == {"machine": 200, "estimate": 400, "errors": []} + + def test_basic_prices_changes_for_material_and_labor(): result = run( "math", diff --git a/resources/tester/test_est_business.py b/resources/tester/test_est_business.py index a1fb4a7e2..db2b397e2 100644 --- a/resources/tester/test_est_business.py +++ b/resources/tester/test_est_business.py @@ -665,3 +665,12 @@ def test_vat_calculation_exclude_include_and_unit_rounding_for_materials(): # M03: 12345 / 1.1 = 11222.72... -> 십원 단위 사사오입(11220), 절상(11230) assert result["m3RoundCost"] == 11220 assert result["m3UpCost"] == 11230 + + +def test_vat_round_up_handles_small_remainders_and_exact_boundaries(): + result = run( + "math", + """[EstVat.convertPrice(100.1, 0, 1, 2), + EstVat.convertPrice(100, 0, 1, 2)]""", + ) + assert result == [120, 110] diff --git a/resources/tester/test_est_change_workflow.py b/resources/tester/test_est_change_workflow.py index 613b3db9c..f9cf42a48 100644 --- a/resources/tester/test_est_change_workflow.py +++ b/resources/tester/test_est_change_workflow.py @@ -1,6 +1,84 @@ from test_est_rea_parity import run +def test_change_comparison_matches_reordered_duplicate_codes(): + result = run( + "math", + """(()=>{ + const p=EstNative.empty();p.options.SourceRefresh=false; + p.tables.EstMainDat=[{Code:'A',Name:'first',GN:10,GU:80}, + {Code:'A',Name:'second',GN:20,GU:50}]; + const q=EstBusiness.baseline(EstNative.calculate(p)); + [q.tables.EstMainDat[0],q.tables.EstMainDat[1]]=[q.tables.EstMainDat[1],q.tables.EstMainDat[0]]; + q.tables.EstMainDat[0].GN=22; + return EstBusiness.changes(JSON.parse(JSON.stringify(q))).map(({Name,originalGN,GN,status})=>({Name,originalGN,GN,status})); + })()""", + ) + assert result == [ + {"Name": "second", "originalGN": 20, "GN": 22, "status": "changed"}, + {"Name": "first", "originalGN": 10, "GN": 10, "status": "unchanged"}, + ] + + +def test_change_comparison_tracks_duplicate_code_additions_and_deletions(): + result = run( + "math", + """(()=>{ + const p=EstNative.empty();p.options.SourceRefresh=false; + p.tables.EstMainDat=[{Code:'A',Name:'first',GN:10,GU:80}, + {Code:'A',Name:'second',GN:20,GU:50}]; + const q=EstBusiness.baseline(EstNative.calculate(p)); + q.tables.EstMainDat=[q.tables.EstMainDat[1],{Code:'A',Name:'new',GN:3,GU:10}, + {Code:'A',Name:'another',GN:4,GU:10}]; + return EstBusiness.changes(q).map(({Name,originalGN,status})=>({Name,originalGN,status})); + })()""", + ) + assert result == [ + {"Name": "second", "originalGN": 20, "status": "unchanged"}, + {"Name": "new", "originalGN": 0, "status": "new"}, + {"Name": "another", "originalGN": 0, "status": "new"}, + {"Name": "first", "originalGN": 10, "status": "deleted"}, + ] + + +def test_change_comparison_matches_duplicate_when_other_row_is_renamed(): + result = run( + "math", + """(()=>{ + const p=EstNative.empty();p.options.SourceRefresh=false; + p.tables.EstMainDat=[{Code:'A',Name:'first',GN:10,GU:80}, + {Code:'A',Name:'second',GN:20,GU:50}]; + const q=EstBusiness.baseline(EstNative.calculate(p)); + [q.tables.EstMainDat[0],q.tables.EstMainDat[1]]=[q.tables.EstMainDat[1],q.tables.EstMainDat[0]]; + q.tables.EstMainDat[1].Name='renamed'; + return EstBusiness.changes(q).map(({Name,originalGN,status})=>({Name,originalGN,status})); + })()""", + ) + assert result == [ + {"Name": "second", "originalGN": 20, "status": "unchanged"}, + {"Name": "renamed", "originalGN": 10, "status": "changed"}, + ] + + +def test_change_costs_match_reordered_duplicate_codes(): + result = run( + "math", + """(()=>{ + const p=EstNative.empty();p.tables.Wonga=[{Gubun:'A',Name:'second',Cost:70}, + {Gubun:'A',Name:'new',Cost:5},{Gubun:'A',Name:'other',Cost:7}]; + p.baseline={name:'old',tables:{EstMainDat:[],Wonga:[ + {Gubun:'A',Name:'first',Cost:100},{Gubun:'A',Name:'second',Cost:60}]}}; + return EstBusiness.costChanges(p).map(({Name,originalCost,changedCost})=>({Name,originalCost,changedCost})); + })()""", + ) + assert result == [ + {"Name": "second", "originalCost": 60, "changedCost": 70}, + {"Name": "new", "originalCost": 0, "changedCost": 5}, + {"Name": "other", "originalCost": 0, "changedCost": 7}, + {"Name": "first", "originalCost": 100, "changedCost": 0}, + ] + + def test_estimate_move_preserves_row_identity_and_blocks_ambiguous_or_total_rows(): result = run( "math", @@ -712,4 +790,3 @@ def test_bid_rates_consecutive_apply_source_refresh_and_roundtrip_restore(): assert result["manualBidRow"]["GU"] == 105 assert result["manualResRow"]["NU"] == 100 assert result["manualResRow"]["GU"] == 150 - diff --git a/resources/tester/test_est_escalation_workflow.py b/resources/tester/test_est_escalation_workflow.py index 182cc3fbd..086c2a088 100644 --- a/resources/tester/test_est_escalation_workflow.py +++ b/resources/tester/test_est_escalation_workflow.py @@ -20,6 +20,25 @@ def test_escalation_distinguishes_repeated_codes_without_row_ids(): assert result["excludedTotal"] == 410 +def test_escalation_matches_reordered_duplicate_codes_to_original_rows(): + result = run( + "math", + """(()=>{ + const p=EstNative.empty();p.options.SourceRefresh=false;p.options.EscDesignTotal=1000; + p.tables.EstMainDat=[{Code:'A',Name:'first',GN:10,GU:80}, + {Code:'A',Name:'second',GN:20,GU:50},{LinkIndex:54}]; + const q=EstBusiness.baseline(EstNative.calculate(p)); + [q.tables.EstMainDat[0],q.tables.EstMainDat[1]]=[q.tables.EstMainDat[1],q.tables.EstMainDat[0]]; + Object.assign(q.tables.EstMainDat[0],{AGU:100,GU:120,EscActual:5}); + Object.assign(q.tables.EstMainDat[1],{AGU:100,GU:120,EscActual:2}); + return EstEscalation.calculate(JSON.parse(JSON.stringify(q))); + })()""", + ) + assert [row["ContractGU"] for row in result["rows"][:2]] == [50, 80] + assert [row["EscApplied"] for row in result["rows"][:2]] == [15, 8] + assert result["excludedTotal"] == 410 + + def test_progress_exclusions_preserve_new_rows_and_reject_ambiguous_keys(): result = run( "math", diff --git a/resources/tester/test_est_machine_baseline.py b/resources/tester/test_est_machine_baseline.py new file mode 100644 index 000000000..85e12cb6b --- /dev/null +++ b/resources/tester/test_est_machine_baseline.py @@ -0,0 +1,21 @@ +from test_est_row_actions import run_js + + +def test_change_reset_restores_machine_details_and_preserves_legacy_machine_rows(): + result = run_js("""(() => { + const p=EstNative.empty(); + p.tables.MachMainDat=[{PkID:1,Index:1,Code:'M1'}, {PkID:2,Index:-2,GN:2,GN_:123,TU:100}]; + const q=EstBusiness.baseline(p); + q.tables.MachMainDat[1].GN=9; + const state={project:q}; + globalThis.confirm=()=>true;globalThis.EstApp={change:fn=>fn()}; + EstBlockActions.resetChangeAction(state); + const restored=q.tables.MachMainDat[1].GN, original=q.tables.MachMainDat[1].GN_; + const legacy=EstBusiness.baseline(p); + delete legacy.baseline.tables.MachMainDat; + legacy.tables.MachMainDat[1].GN=7;legacy.tables.MachMainDat[1].GN_=123; + EstBlockActions.resetChangeAction({project:legacy}); + return {restored, original, legacy:legacy.tables.MachMainDat[1].GN, + legacyOriginal:legacy.tables.MachMainDat[1].GN_}; + })()""") + assert result == {"restored": 2, "original": 123, "legacy": 7, "legacyOriginal": 123} diff --git a/resources/tester/test_est_progress_paging.py b/resources/tester/test_est_progress_paging.py new file mode 100644 index 000000000..f7f4ce7c3 --- /dev/null +++ b/resources/tester/test_est_progress_paging.py @@ -0,0 +1,47 @@ +from test_est_row_actions import run_js + + +def test_progress_page_preserves_original_indices_and_clamps(): + result = run_js("""(() => { + const rows = Array.from({length:205}, (_, i) => ({Code:'R'+i, Level:0})); + rows.splice(1, 0, {Code:'heading', Level:1}); + const state = {progressPage:1}; + const second = EstBlockActions.progressPage(state, rows); + state.progressPage = 99; + const last = EstBlockActions.progressPage(state, rows); + return {secondCount:second.rows.length, secondIndex:second.rows[0].index, + lastCount:last.rows.length, page:state.progressPage, pages:last.pages}; + })()""") + assert result == {"secondCount": 100, "secondIndex": 101, "lastCount": 5, "page": 2, "pages": 3} + + +def test_escalation_page_excludes_summary_and_preserves_original_indices(): + result = run_js("""(() => { + const rows = Array.from({length:201}, (_, i) => ({Code:'R'+i, Level:0})); + rows.splice(2, 0, {Name:'소계', LinkIndex:54}); + const state = {escalationPage:1}; + const page = EstBlockActions.escalationPage(state, rows); + return {count:page.rows.length, index:page.rows[0].index, total:page.total, pages:page.pages}; + })()""") + assert result == {"count": 100, "index": 101, "total": 201, "pages": 3} + + +def test_change_page_includes_deleted_and_new_rows(): + result = run_js("""(() => { + const rows = Array.from({length:200}, (_, i) => ({Code:'R'+i, status:'unchanged'})); + rows.push({Code:'old',status:'deleted'}, {Code:'new',status:'new'}); + const state = {changePage:2}; + const page = EstBlockActions.changePage(state, rows); + return {statuses:page.rows.map(x=>x.row.status), total:page.total, pages:page.pages}; + })()""") + assert result == {"statuses": ["deleted", "new"], "total": 202, "pages": 3} + + +def test_business_paging_filters_without_changing_original_indices(): + result = run_js("""(() => { + const rows = [{Code:'A',Name:'첫 항목'}, {Code:'B',Name:'둘째 항목',Size:'특수 규격'}]; + const state = {query:'특수', progressPage:2}; + const page = EstBlockActions.progressPage(state, rows); + return {total:page.total, index:page.rows[0].index, page:state.progressPage}; + })()""") + assert result == {"total": 1, "index": 1, "page": 0} diff --git a/resources/tester/test_est_progress_workflow.py b/resources/tester/test_est_progress_workflow.py index 4b717a9c5..ae94c9051 100644 --- a/resources/tester/test_est_progress_workflow.py +++ b/resources/tester/test_est_progress_workflow.py @@ -27,6 +27,25 @@ def test_progress_cannot_lock_when_previous_result_is_missing(): assert result["rejected"] +def test_progress_cannot_lock_when_previous_costs_are_missing(): + result = run( + "math", + """(()=>{ + const p=EstNative.empty(); + p.tables.EstMainDat=[{PkID:1,Code:'R',GN:10,GU:100},{LinkIndex:54}]; + p.tables.Wonga=[{Gubun:'D',BaseField:'TC'},{LinkIndex:55,VSusic:'D'}]; + p.progress=[{id:'one',locked:true,inputs:{},result:{rows:[]}}, + {id:'two',inputs:{'1':{Gum:2}}}]; + const calculated=EstProgress.calculate(p,'two'); + let rejected=false; + try { EstProgress.lock(p,'two'); } catch { rejected=true; } + return {errors:calculated.errors,rejected}; + })()""", + ) + assert any("전회 기성 원가 결과가 없습니다" in error for error in result["errors"]) + assert result["rejected"] + + def test_progress_rejects_ambiguous_inputs_without_applying_them_to_multiple_rows(): result = run( "math", diff --git a/resources/tester/test_est_quantity_workflow.py b/resources/tester/test_est_quantity_workflow.py index acecbba2a..24e085695 100644 --- a/resources/tester/test_est_quantity_workflow.py +++ b/resources/tester/test_est_quantity_workflow.py @@ -1,6 +1,44 @@ from test_est_rea_parity import run +def test_quantity_totals_handles_large_estimate_and_ambiguous_targets(): + result = run( + "math", + """(()=>{ + const p=EstNative.empty(); + p.tables.EstMainDat=Array.from({length:5000},(_,i)=>({PkID:i+1,Code:'C'+i,Level:0})); + p.quantities=Array.from({length:5000},(_,i)=>({Code:'Q'+i,formula:'1',TargetKey:'id:'+(i+1)})); + const totals=EstQuantity.totals(p,'manual'); + p.tables.EstMainDat.push({PkID:1,Code:'other',Level:0}); + let duplicate=false; + try { EstQuantity.totals(p,'manual'); } catch(e) { duplicate=e.message.includes('중복'); } + return {count:totals.size,first:totals.get(0),last:totals.get(4999),duplicate}; + })()""", + ) + assert result == {"count": 5000, "first": 1, "last": 1, "duplicate": True} + + +def test_quantity_target_options_are_loaded_when_focused(): + result = run( + "math", + """(()=>{ + const p=EstNative.empty(); + p.tables.EstMainDat=[{PkID:1,Code:'A',Name:'첫 행'}, + {PkID:2,Code:'B',Name:'둘째 행'},{PkID:3,Code:'C',Level:1}]; + globalThis.EstApp={esc:v=>String(v),state:{project:p}}; + const handlers={}; globalThis.document={addEventListener:(name,fn)=>handlers[name]=fn}; + const html=EstQuantity.targetSelect(p,{TargetKey:'id:2',TargetCode:'B'},'manual',0); + EstQuantity.install(); + const select={dataset:{quantitySelect:'manual'},value:'id:2',innerHTML:''}; + handlers.focusin({target:select}); + return {initialOptions:(html.match(/