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", """(()=>{ const rows=[{PkID:1,Code:'A'},{PkID:2,Code:'B'},{PkID:3,Code:'C'},{LinkIndex:54}]; const moved=EstBusiness.moveEstimateRows(rows,new Set([1,2]),-1); const order=rows.map(row=>row.PkID||0); const restored=EstBusiness.moveEstimateRows(rows,new Set(moved),1); let blocked=0; for (const [data,selection,direction] of [ [[{Code:'A'},{Code:'A'}],[0],1], [[{PkID:1},{LinkIndex:54}],[0],1], [[{PkID:1},{PkID:2},{PkID:3}],[0,2],1], [[{PkID:1},{Name:'unknown'}],[0],1]]) { try{EstBusiness.moveEstimateRows(data,new Set(selection),direction)}catch{blocked++} } return {moved,order,restored,blocked}; })()""", ) assert result == {"moved": [0, 1], "order": [2, 3, 1, 0], "restored": [1, 2], "blocked": 4} def test_edge_estimate_move_rows_up_and_down_with_undo(): from playwright.sync_api import sync_playwright import pytest with sync_playwright() as p: browser = p.chromium.launch(channel="msedge", headless=True) page = browser.new_page() try: page.goto("http://100.65.141.15:5175", timeout=5000) except Exception: pytest.skip("EST 5175 server not reachable") page.wait_for_function("() => Boolean(EstApp.state.project)") # 내역서 화면 확인 page.evaluate("EstApp.navigate('EstMainDat')") page.wait_for_function("() => EstApp.state.view === 'EstMainDat'") # 1번 행의 고유 코드 또는 PkID 기록 row_id_target = page.evaluate("EstApp.state.project.tables.EstMainDat[1].PkID") # 1번 행(2번째 행)을 선택하고 아래로 이동 시험 page.evaluate("""() => { EstApp.state.selection = new Set([1]); EstApp.render(); }""") btn_down = page.locator("#move-estimate-down") assert btn_down.is_visible() btn_down.click() # 이동 후 해당 행이 2번 위치로 이동했는지 확인 pos_after_down = page.evaluate(f"""() => {{ return EstApp.state.project.tables.EstMainDat.findIndex(r => r.PkID === {row_id_target}); }}""") assert pos_after_down == 2 sel_after_down = page.evaluate("Array.from(EstApp.state.selection)") assert sel_after_down == [2] # 다시 위로 이동 시험 btn_up = page.locator("#move-estimate-up") assert btn_up.is_visible() btn_up.click() pos_after_up = page.evaluate(f"""() => {{ return EstApp.state.project.tables.EstMainDat.findIndex(r => r.PkID === {row_id_target}); }}""") assert pos_after_up == 1 sel_after_up = page.evaluate("Array.from(EstApp.state.selection)") assert sel_after_up == [1] # 실행 취소(Undo) 시험: 툴바 버튼 클릭 시 다시 2번 위치로 되돌아감 btn_undo = page.locator(".toolbar button[data-action='undo']") assert btn_undo.is_visible() btn_undo.click() page.wait_for_timeout(500) pos_after_undo = page.evaluate(f"""() => {{ return EstApp.state.project.tables.EstMainDat.findIndex(r => r.PkID === {row_id_target}); }}""") assert pos_after_undo == 2 browser.close() def test_group_detail_move_preserves_row_identity_and_blocks_title_and_boundaries(): result = run( "math", """(()=>{ const rows = [ {Index:1,Code:'H1',Name:'Title 1',PkID:100}, {Index:0,Code:'D1',Name:'Detail 1',PkID:101}, {Index:0,Code:'D2',Name:'Detail 2',PkID:102}, {Index:0,Code:'D3',Name:'Detail 3',PkID:103}, {Index:1,Code:'H2',Name:'Title 2',PkID:200}, {Index:0,Code:'D4',Name:'Detail 4',PkID:201} ]; const moved = EstBusiness.moveGroupDetailRows(rows, 'H1', new Set([2]), 1); const order = rows.slice(0, 4).map(r => r.Code); const restored = EstBusiness.moveGroupDetailRows(rows, 'H1', new Set(moved), -1); let blocked = 0; // 1: 제목 행(Index 0) 이동 시도 try { EstBusiness.moveGroupDetailRows(rows, 'H1', new Set([0]), 1); } catch { blocked++; } // 2: 다른 호표 행 포함 시도 try { EstBusiness.moveGroupDetailRows(rows, 'H1', new Set([4]), 1); } catch { blocked++; } // 3: 비연속 행 선택 시도 try { EstBusiness.moveGroupDetailRows(rows, 'H1', new Set([1, 3]), 1); } catch { blocked++; } // 4: 상한 경계(제목 바로 다음 행을 위로) - 이동 없이 원래 인덱스 반환 const topBoundary = EstBusiness.moveGroupDetailRows(rows, 'H1', new Set([1]), -1); // 5: 하한 경계(호표 마지막 행을 아래로) - 이동 없이 원래 인덱스 반환 const bottomBoundary = EstBusiness.moveGroupDetailRows(rows, 'H1', new Set([3]), 1); return { moved, order, restored, blocked, topBoundary, bottomBoundary }; })()""", ) assert result == { "moved": [3], "order": ["H1", "D1", "D3", "D2"], "restored": [2], "blocked": 3, "topBoundary": [1], "bottomBoundary": [3], } def test_edge_group_detail_move_rows_up_and_down_with_undo(): from playwright.sync_api import sync_playwright import pytest with sync_playwright() as p: browser = p.chromium.launch(channel="msedge", headless=True) page = browser.new_page() try: page.goto("http://100.65.141.15:5175", timeout=5000) except Exception: pytest.skip("EST 5175 server not reachable") page.wait_for_function("() => Boolean(EstApp.state.project)") # 일위대가(HeadMainDat) 화면으로 이동 page.evaluate("EstApp.navigate('HeadMainDat')") page.wait_for_function("() => EstApp.state.view === 'HeadMainDat'") # 첫 번째 호표 코드 및 2번 상세 행(전체 인덱스 2)의 PkID 확인 group_code = page.evaluate("EstApp.state.group") assert group_code target_pkid = page.evaluate("EstApp.state.project.tables.HeadMainDat[2].PkID") # 2번 상세 행 선택 page.evaluate("""() => { EstApp.state.selection = new Set([2]); EstApp.render(); }""") btn_down = page.locator("#move-estimate-down") assert btn_down.is_visible() btn_down.click() # 이동 후 해당 행이 3번 위치로 이동했는지 확인 pos_after_down = page.evaluate(f"""() => {{ return EstApp.state.project.tables.HeadMainDat.findIndex(r => r.PkID === {target_pkid}); }}""") assert pos_after_down == 3 sel_after_down = page.evaluate("Array.from(EstApp.state.selection)") assert sel_after_down == [3] # 다시 위로 이동 시험 btn_up = page.locator("#move-estimate-up") assert btn_up.is_visible() btn_up.click() pos_after_up = page.evaluate(f"""() => {{ return EstApp.state.project.tables.HeadMainDat.findIndex(r => r.PkID === {target_pkid}); }}""") assert pos_after_up == 2 sel_after_up = page.evaluate("Array.from(EstApp.state.selection)") assert sel_after_up == [2] # 실행 취소(Undo) 시험: 툴바 버튼 클릭 시 다시 3번 위치로 복원 btn_undo = page.locator(".toolbar button[data-action='undo']") assert btn_undo.is_visible() btn_undo.click() page.wait_for_timeout(500) pos_after_undo = page.evaluate(f"""() => {{ return EstApp.state.project.tables.HeadMainDat.findIndex(r => r.PkID === {target_pkid}); }}""") assert pos_after_undo == 3 browser.close() def test_cost_group_detail_move_preserves_subtotal_and_blocks_order_dependent_errors(): result = run( "math", """(()=>{ const cost = project.tables.CostMainDat; const grps = EstNative.groups(cost); const grp4 = grps.get('D0000000004'); const start4 = grp4.start; const p = structuredClone(project); // 1: D0000000004 호표의 포장공(start+10, Index -20) 행을 아래(보통인부 start+12 건너)로 이동 // Index -20 시작 표지가 선두로 유지되면서 소계(14,373.6) 및 호표 총액(16,421) 완벽 보존 const moved4 = EstBusiness.moveGroupDetailRows(p.tables.CostMainDat, 'D0000000004', new Set([start4 + 10]), 1, 'CostMainDat'); const calcAfterMove = EstNative.calculate(p); const subtotal4 = calcAfterMove.tables.CostMainDat[start4 + 14].TC; const total4 = calcAfterMove.tables.CostMainDat[start4].TC; // 2: 다시 위로 원복 이동 const restored4 = EstBusiness.moveGroupDetailRows(p.tables.CostMainDat, 'D0000000004', new Set(moved4), -1, 'CostMainDat'); const calcAfterRestore = EstNative.calculate(p); const subtotalRestore = calcAfterRestore.tables.CostMainDat[start4 + 14].TC; // 3: 보호 차단 시험 let blockedSubtotal = 0; try { EstBusiness.moveGroupDetailRows(p.tables.CostMainDat, 'D0000000004', new Set([start4 + 14]), 1, 'CostMainDat'); } catch { blockedSubtotal++; } let blockedRatio = 0; try { EstBusiness.moveGroupDetailRows(p.tables.CostMainDat, 'D0000000004', new Set([start4 + 18]), 1, 'CostMainDat'); } catch { blockedRatio++; } const grp0 = grps.get('D0000000000'); const start0 = grp0.start; let blockedVar = 0; try { // D0000000000의 Q 변수 산식 행(start0+11)을 아래로 이동 시 참조 오류 차단 EstBusiness.moveGroupDetailRows(p.tables.CostMainDat, 'D0000000000', new Set([start0 + 11]), 1, 'CostMainDat'); } catch { blockedVar++; } // 4: D0000000005 다중 -20 구간 보존 시험 const grp5 = grps.get('D0000000005'); const start5 = grp5.start; const moved5 = EstBusiness.moveGroupDetailRows(p.tables.CostMainDat, 'D0000000005', new Set([start5 + 15]), 1, 'CostMainDat'); const m20Count5 = p.tables.CostMainDat.slice(start5, start5 + grp5.rows.length).filter(r => Number(r.Index) === -20).length; const calc5 = EstNative.calculate(p); const sub1_5 = calc5.tables.CostMainDat[start5 + 19].TC; const sub2_5 = calc5.tables.CostMainDat[start5 + 27].TC; // 5: 앞줄 참조/소계 결과가 다른 정상 값으로 바뀌는 안전하지 않은 이동 차단 시험 // D0000000004에서 재료비 행(start4+6)을 단계별로 내려서 노무비 포장공(start4+10) 위치로 침범 시도 let blockedSubtotalShift = 0; try { const m1 = EstBusiness.moveGroupDetailRows(p.tables.CostMainDat, 'D0000000004', new Set([start4 + 6]), 1, 'CostMainDat'); const m2 = EstBusiness.moveGroupDetailRows(p.tables.CostMainDat, 'D0000000004', new Set(m1), 1, 'CostMainDat'); const m3 = EstBusiness.moveGroupDetailRows(p.tables.CostMainDat, 'D0000000004', new Set(m2), 1, 'CostMainDat'); EstBusiness.moveGroupDetailRows(p.tables.CostMainDat, 'D0000000004', new Set(m3), 1, 'CostMainDat'); } catch { blockedSubtotalShift++; } // 6: PkID 없는 행, 다중 선택 내 구간 경계 포함 및 앞줄 참조 복수 행 이동 보수적 차단 시험 let blockedMultiM20 = 0; try { // D0000000005에서 -20이 중간에 포함된 3개 연속 행 이동 시도 EstBusiness.moveGroupDetailRows(p.tables.CostMainDat, 'D0000000005', new Set([start5 + 22, start5 + 23, start5 + 24]), 1, 'CostMainDat'); } catch { blockedMultiM20++; } let blockedRefMulti = 0; try { // 앞줄 참조(\x0c) 행을 포함한 복수 행 동시 이동 시도 EstBusiness.moveGroupDetailRows(p.tables.CostMainDat, 'D0000000004', new Set([start4 + 17, start4 + 18]), 1, 'CostMainDat'); } catch { blockedRefMulti++; } // 7: A/B 동일값 선행 행 교환 시 뒤따르는 직전 결과 참조 대상 왜곡 재현 및 보수적 차단 시험 const ambigRows = [ { Index: 1, Code: 'CAMBIG' }, { PkID: 1, SCheck: -1, LineData: 'A = 100 = \x12100\x12' }, { PkID: 2, SCheck: -1, LineData: 'B = 100 = \x12100\x12' }, { PkID: 3, LinkIndex: 58, SCheck: -3, LineData: '\x0c100\x0c * 2 = \x12200\x12' } ]; // A와 B의 교환을 허용할 경우 B가 변경되었을 때 C가 참조하는 대상이 원래 B에서 A로 왜곡됨을 재현: const origAmbig = EstText.calculate(structuredClone(ambigRows)); const swappedAmbig = [ambigRows[0], ambigRows[2], ambigRows[1], ambigRows[3]]; const ambigBChangedOrig = structuredClone(ambigRows); ambigBChangedOrig[2].LineData = 'B = 200 = \x12200\x12'; const ambigBChangedSwapped = structuredClone(swappedAmbig); ambigBChangedSwapped[1].LineData = 'B = 200 = \x12200\x12'; const origRes = EstText.calculate(ambigBChangedOrig); const swappedRes = EstText.calculate(ambigBChangedSwapped); const shiftReproduced = (origRes.rows[3].TC === 400 && swappedRes.rows[3].TC === 200); // moveGroupDetailRows에서 이와 같은 모호한 참조 대상 구간 행 이동을 확실히 차단하는지 검증: let blockedAmbigRef = 0; let ambigErrMsg = ''; try { EstBusiness.moveGroupDetailRows(ambigRows, 'CAMBIG', new Set([1]), 1, 'CostMainDat'); } catch (e) { blockedAmbigRef++; ambigErrMsg = e.message; } // 8: LinkIndex 58 이외 일반 행의 \x0c 앞줄 참조 모호성 차단 시험 const generalRefRows = [ { Index: 1, Code: 'CGENREF' }, { PkID: 10, SCheck: 0, LineData: 'X1 = 50 = \x1250\x12' }, { PkID: 11, SCheck: 0, LineData: 'X2 = 50 = \x1250\x12' }, { PkID: 12, SCheck: 0, LinkIndex: 0, LineData: '일반행 : \x0c50\x0c + 10 = \x1260\x12' } ]; let blockedGenRef = 0; try { EstBusiness.moveGroupDetailRows(generalRefRows, 'CGENREF', new Set([1]), 1, 'CostMainDat'); } catch { blockedGenRef++; } // 9: 동일 변수 재정의(중복 할당) 구간 행 교환 보수적 차단 시험 const varRedefineRows = [ { Index: 1, Code: 'CVARREDEF' }, { PkID: 20, LineData: 'W = 10 = \x1210\x12' }, { PkID: 21, LineData: 'W = 10 = \x1210\x12' }, { PkID: 22, LineData: 'ANS = W * 3 = \x1230\x12' } ]; let blockedVarRedef = 0; let varRedefErrMsg = ''; try { EstBusiness.moveGroupDetailRows(varRedefineRows, 'CVARREDEF', new Set([1]), 1, 'CostMainDat'); } catch (e) { blockedVarRedef++; varRedefErrMsg = e.message; } return { moved4, restored4, subtotal4, total4, subtotalRestore, blockedSubtotal, blockedRatio, blockedVar, m20Count5, sub1_5, sub2_5, blockedSubtotalShift, blockedMultiM20, blockedRefMulti, shiftReproduced, blockedAmbigRef, ambigErrMsg, blockedGenRef, blockedVarRedef, varRedefErrMsg }; })()""", ) assert result["shiftReproduced"] is True assert result["blockedAmbigRef"] == 1 assert "직전 결과를 참조하는 행이 뒤따라" in result["ambigErrMsg"] assert result["moved4"] == [172] assert result["restored4"] == [171] assert result["subtotal4"] == 14373.6 assert result["total4"] == 16421 assert result["subtotalRestore"] == 14373.6 assert result["blockedSubtotal"] == 1 assert result["blockedRatio"] == 1 assert result["blockedVar"] == 1 assert result["m20Count5"] == 2 assert result["sub1_5"] == 1383.4 assert result["sub2_5"] == 1407.8 assert result["blockedSubtotalShift"] == 1 assert result["blockedMultiM20"] == 1 assert result["blockedRefMulti"] == 1 assert result["blockedGenRef"] == 1 assert result["blockedVarRedef"] == 1 assert "동일 변수(W)를 재정의하는 구간에서는" in result["varRedefErrMsg"] def test_edge_cost_group_detail_move_rows_with_subtotal_and_undo(): from playwright.sync_api import sync_playwright import pytest with sync_playwright() as p: browser = p.chromium.launch(channel="msedge", headless=True) page = browser.new_page() try: page.goto("http://100.65.141.15:5175", timeout=5000) except Exception: pytest.skip("EST 5175 server not reachable") page.wait_for_function("() => Boolean(EstApp.state.project)") # 단가산출(CostMainDat) 화면 및 D0000000004 호표로 이동 page.evaluate("EstApp.navigate('CostMainDat', 'D0000000004')") page.wait_for_function( "() => EstApp.state.view === 'CostMainDat' && EstApp.state.group === 'D0000000004'" ) # 시작 인덱스 조회 및 포장공 행(start + 10) 선택 start_idx = page.evaluate( "EstNative.groups(EstApp.state.project.tables.CostMainDat).get('D0000000004').start" ) target_row_idx = start_idx + 10 target_pkid = page.evaluate( f"EstApp.state.project.tables.CostMainDat[{target_row_idx}].PkID" ) page.evaluate(f"""() => {{ EstApp.state.selection = new Set([{target_row_idx}]); EstApp.render(); }}""") btn_down = page.locator("#move-estimate-down") assert btn_down.is_visible() btn_down.click() # 이동 후 위치 확인 및 소계/총액 보존 확인 pos_after_down = page.evaluate(f"""() => {{ return EstApp.state.project.tables.CostMainDat.findIndex(r => r.PkID === {target_pkid}); }}""") assert pos_after_down == target_row_idx + 1 subtotal_after_down = page.evaluate( f"EstApp.state.project.tables.CostMainDat[{start_idx + 14}].TC" ) assert subtotal_after_down == 14373.6 # 실행 취소(Undo)로 원래 위치 원복 btn_undo = page.locator(".toolbar button[data-action='undo']") assert btn_undo.is_visible() btn_undo.click() page.wait_for_timeout(500) pos_after_undo = page.evaluate(f"""() => {{ return EstApp.state.project.tables.CostMainDat.findIndex(r => r.PkID === {target_pkid}); }}""") assert pos_after_undo == target_row_idx browser.close() def test_duplicate_codes_match_distinct_original_rows_and_track_deletions(): result = run( "math", """(()=>{ const p=EstNative.empty();p.tables.EstMainDat=[{Code:'A',Name:'first',GN:2,TC:200}, {Code:'A',Name:'second',GN:4,TC:400}]; p.baseline={name:'old',tables:{EstMainDat:[{Code:'A',Name:'first',GN:1,TC:100}, {Code:'A',Name:'second',GN:3,TC:300},{Code:'A',Name:'removed',GN:5,TC:500}]}}; return EstBusiness.changes(p); })()""", ) assert [row["originalTC"] for row in result] == [100, 300, 500] assert [row["difference"] for row in result] == [100, 100, -500] assert result[-1]["status"] == "deleted" def test_design_change_costs_include_duplicate_new_and_deleted_cost_rows(): result = run( "math", """(()=>{ const p=EstNative.empty();p.tables.Wonga=[ {Gubun:'A',Name:'first',Cost:200},{Gubun:'A',Name:'second',Cost:60}, {Gubun:'C',Name:'new',Cost:25}]; p.baseline={name:'old',tables:{EstMainDat:[],Wonga:[ {Gubun:'A',Name:'first',Cost:100},{Gubun:'A',Name:'second',Cost:50}, {Gubun:'B',Name:'removed',Cost:30}]}}; const before=JSON.stringify(p), rows=EstBusiness.costChanges(p); const output=EstForms.data('change-costs',p); const node={innerHTML:''};globalThis.EstApp={esc:x=>String(x??''),number:x=>String(x??0),byId:()=>node}; EstExtra.render({project:p,view:'change'}); return {rows,output,html:node.innerHTML,same:before===JSON.stringify(p)}; })()""", ) assert result["same"] assert [row["difference"] for row in result["rows"]] == [100, 10, 25, -30] assert result["output"]["rows"] == result["rows"] assert "removed" in result["html"] def test_stored_original_costs_remain_attached_to_each_duplicate_row(): result = run( "math", """(()=>{ const p=EstNative.empty();p.tables.Wonga=[ {Gubun:'A',Name:'new',Cost:10},{Gubun:'A',Name:'existing',Cost:50,Cost_:30}]; return EstBusiness.costChanges(p); })()""", ) assert [row["originalCost"] for row in result] == [0, 30] assert [row["difference"] for row in result] == [10, 20] def test_change_view_includes_named_rows_without_codes(): result = run( "math", """(()=>{ const p=EstNative.empty();p.tables.EstMainDat=[{PkID:1,Code:'',Name:'earthwork',GN:2,TC:200}]; p.baseline={name:'base',tables:{EstMainDat:[{PkID:1,Code:'',Name:'earthwork',GN:1,TC:100}]}}; const node={innerHTML:''};globalThis.EstApp={esc:x=>String(x??''),number:x=>String(x??0),byId:()=>node}; const state={project:p,view:'change'};EstExtra.render(state);return {html:node.innerHTML,changes:EstBusiness.changes(p)}; })()""", ) assert "earthwork" in result["html"] assert result["changes"][0]["difference"] == 100 def test_original_recalculation_modes_keep_old_quantities_and_only_refresh_selected_prices(): result = run( "math", """(()=>{ const p=EstNative.empty(); p.tables.StuffcDat=[{PkID:1,Code:'M',ValueIndex:4,InputTC:100}]; p.tables.EstMainDat=[{PkID:1,Code:'M',LinkIndex:5,GN:2},{PkID:2,LinkIndex:54}]; const q=EstBusiness.baseline(EstNative.calculate(p)); const before=JSON.stringify(q.baseline); q.tables.StuffcDat[0].InputTC=150;q.tables.EstMainDat[0].GN=7; q.tables.EstMainDat.splice(1,0,{PkID:3,Code:'M',LinkIndex:5,GN:1,NewItem:true}); const modes=[0,1,2].map(method=>EstBusiness.original(q,method).tables.EstMainDat); return {modes,unchanged:before===JSON.stringify(q.baseline)}; })()""", ) assert result["unchanged"] for mode, rows in enumerate(result["modes"]): assert rows[0]["GN"] == 2 assert rows[0]["TC"] == (200 if mode == 0 else 300) assert rows[1]["TC"] == 150 assert rows[-1]["TC"] == (350 if mode == 0 else 450) def test_design_change_includes_deleted_and_new_rows_without_modifying_contract(): result = run( "math", """(()=>{ const p=EstNative.empty();p.options.SourceRefresh=false; p.tables.EstMainDat=[{PkID:1,Code:'A',Name:'kept',GN:2,GU:100}, {PkID:3,Code:'B',Name:'removed',GN:3,NU:50},{PkID:2,LinkIndex:54}]; let q=EstBusiness.baseline(EstNative.calculate(p)); const before=JSON.stringify(q.baseline); q.tables.EstMainDat.splice(1,1); q.tables.EstMainDat[0].GN=4; q.tables.EstMainDat.splice(1,0,{...EstModel.newRow('EstMainDat',q.tables.EstMainDat,q),Code:'C',Name:'new',GN:1,JU:25}); q=EstNative.calculate(q); return {changes:EstBusiness.changes(q),same:before===JSON.stringify(q.baseline)}; })()""", ) assert result["same"] rows = {row["Code"]: row for row in result["changes"] if row["Code"]} assert rows["A"]["status"] == "changed" assert rows["A"]["difference"] == 200 assert rows["B"]["status"] == "deleted" assert rows["B"]["originalGN"] == 3 assert rows["B"]["GN"] == rows["B"]["TC"] == 0 assert rows["B"]["difference"] == -150 assert rows["C"]["status"] == "new" assert rows["C"]["difference"] == 25 def test_blank_codes_have_distinct_keys_and_metadata_changes_are_reported(): result = run( "math", """(()=>{ const p=EstNative.empty();p.tables.EstMainDat=[ {PkID:1,Code:'A',Name:'renamed',GN:1,TC:10}, {Code:'',Name:'first'},{Code:'',Name:'second'}]; p.baseline={name:'old',tables:{EstMainDat:[ {PkID:1,Code:'A',Name:'old',GN:1,TC:10}]}}; return {rows:EstBusiness.changes(p),keys:p.tables.EstMainDat.map(EstBusiness.rowKey)}; })()""", ) assert len(set(result["keys"][1:])) == 2 assert result["rows"][0]["status"] == "changed" assert result["rows"][0]["difference"] == 0 def test_bid_rates_apply_and_restore_original_rates(): result = run( "math", """(()=>{ const p = EstNative.empty(); p.options.SourceRefresh = false; p.tables.EstMainDat = [ { PkID: 1, Code: 'R1', Name: '노무재료경비항목', GN: 2, NU: 1000, GU: 2000, JU: 500, SU: 0, TU: 3500, TC: 7000 } ]; p.tables.HeadMainDat = [ { PkID: 10, Code: 'H1', Name: '일위대가항목', Index: 0, GN: 1, NU: 10000, GU: 5000, JU: 1000, SU: 0, TU: 16000, TC: 16000 } ]; // 0) 원본에 기존 O필드가 존재하는 행 준비 p.tables.EstMainDat.push({ PkID: 2, Code: 'R2', Name: '기존O필드항목', GN: 1, NU: 500, GU: 500, JU: 0, SU: 0, TU: 1000, TC: 1000, OTU: 1000, ONU: 500, OGU: 500, OJU: 0, OSU: 0 }); // 1) 낙찰률 80% 적용 const bidded = EstBusiness.bid(p, [80, 80, 80, 80]); const bidEstRow = bidded.tables.EstMainDat[0]; const bidHeadRow = bidded.tables.HeadMainDat[0]; const bidExistingORow = bidded.tables.EstMainDat[1]; // 2) 당초(100%) 복원 const restored = EstBusiness.restoreBid(bidded); const resEstRow = restored.tables.EstMainDat[0]; const resHeadRow = restored.tables.HeadMainDat[0]; const resExistingORow = restored.tables.EstMainDat[1]; return { bidEst: { NU: bidEstRow.NU, GU: bidEstRow.GU, JU: bidEstRow.JU, TU: bidEstRow.TU, ApplyNacChal: bidEstRow.ApplyNacChal }, bidHead: { NU: bidHeadRow.NU, GU: bidHeadRow.GU, JU: bidHeadRow.JU, TU: bidHeadRow.TU, ApplyNacChal: bidHeadRow.ApplyNacChal }, resEst: { NU: resEstRow.NU, GU: resEstRow.GU, JU: resEstRow.JU, TU: resEstRow.TU, hasApplyNacChal: 'ApplyNacChal' in resEstRow }, resHead: { NU: resHeadRow.NU, GU: resHeadRow.GU, JU: resHeadRow.JU, TU: resHeadRow.TU, hasApplyNacChal: 'ApplyNacChal' in resHeadRow }, resExistingO: { NU: resExistingORow.NU, OTU: resExistingORow.OTU, ONU: resExistingORow.ONU, OGU: resExistingORow.OGU } }; })()""", ) assert result["bidEst"]["NU"] == 800 assert result["bidEst"]["GU"] == 1600 assert result["bidEst"]["JU"] == 400 assert result["bidEst"]["TU"] == 2800 assert result["bidEst"]["ApplyNacChal"] == 1 assert result["bidHead"]["NU"] == 8000 assert result["bidHead"]["GU"] == 4000 assert result["bidHead"]["JU"] == 800 assert result["bidHead"]["TU"] == 12800 assert result["bidHead"]["ApplyNacChal"] == 1 assert result["resEst"]["NU"] == 1000 assert result["resEst"]["GU"] == 2000 assert result["resEst"]["JU"] == 500 assert result["resEst"]["TU"] == 3500 assert result["resEst"]["hasApplyNacChal"] is False assert result["resHead"]["NU"] == 10000 assert result["resHead"]["GU"] == 5000 assert result["resHead"]["JU"] == 1000 assert result["resHead"]["TU"] == 16000 assert result["resHead"]["hasApplyNacChal"] is False # 기존 O필드 보존 검증: 원래 있던 OTU/ONU/OGU가 삭제되지 않고 유지됨 assert result["resExistingO"]["NU"] == 500 assert result["resExistingO"]["OTU"] == 1000 assert result["resExistingO"]["ONU"] == 500 assert result["resExistingO"]["OGU"] == 500 def test_bid_rates_consecutive_apply_source_refresh_and_roundtrip_restore(): result = run( "math", """(()=>{ // 1) 원천 단가(StuffcDat 200)와 내역 초기단가(GU 150)가 다르고 SourceRefresh=true인 프로젝트 const p = EstNative.empty(); p.options = { SourceRefresh: true, AutoCalc: true }; p.tables.StuffcDat = [{ Code: 'M1', Name: '자재', ValueIndex: 4, InputTC: 200 }]; p.tables.CostMainDat = [ { Index: 1, Code: 'C1' }, { Code: 'M1', LinkIndex: 5, SCheck: -2, LineData: ' \\x0e자재\\x0e: \\x03200\\x03 = \\x12200\\x12' } ]; p.tables.CostTitleDat = [{ Code: 'C1' }]; p.tables.EstMainDat = [ { PkID: 1, Code: 'C1', LinkIndex: 104, GN: 10, NU: 100, GU: 150, JU: 50, TU: 300, TC: 3000 } ]; // 2) 1차 낙찰률 적용 (80%) -> 2차 연속 낙찰률 적용 (70%) const bid1 = EstBusiness.bid(p, [80, 80, 80, 80]); const bid2 = EstBusiness.bid(bid1, [70, 70, 70, 70]); // 2차 적용 후 단가: 최초(100, 150, 50) 기준 70%인 (70, 105, 35)이어야 하며, 80%의 70%(56%)로 왜곡되면 안 됨 const bid2Row = bid2.tables.EstMainDat[0]; // 3) 직렬화 및 역직렬화 (저장 -> 재열기 시뮬레이션) const reloaded = JSON.parse(JSON.stringify(bid2)); // 4) 당초(100%) 복원 const restored = EstBusiness.restoreBid(reloaded); const resRow = restored.tables.EstMainDat[0]; // 5) 수동계산(AutoCalc: false) 모드에서의 연속 적용 및 복원 const manualP = structuredClone(p); manualP.options.AutoCalc = false; const manualBid = EstBusiness.bid(EstBusiness.bid(manualP, [80, 80, 80, 80]), [70, 70, 70, 70]); const manualRestored = EstBusiness.restoreBid(manualBid); return { bid2Row: { NU: bid2Row.NU, GU: bid2Row.GU, JU: bid2Row.JU, TU: bid2Row.TU }, bid2Option: bid2.options.SourceRefresh, resRow: { NU: resRow.NU, GU: resRow.GU, JU: resRow.JU, TU: resRow.TU, TC: resRow.TC }, resOption: restored.options.SourceRefresh, hasPreBidRates: 'PreBidRates' in resRow, manualBidRow: { NU: manualBid.tables.EstMainDat[0].NU, GU: manualBid.tables.EstMainDat[0].GU }, manualResRow: { NU: manualRestored.tables.EstMainDat[0].NU, GU: manualRestored.tables.EstMainDat[0].GU } }; })()""", ) # 연속 낙찰률 적용 시 최초 단가(100, 150, 50) 기준 70% = (70, 105, 35) 유지 assert result["bid2Row"]["NU"] == 70 assert result["bid2Row"]["GU"] == 105 assert result["bid2Row"]["JU"] == 35 assert result["bid2Row"]["TU"] == 210 # 낙찰 중에는 원천 갱신 차단 assert result["bid2Option"] is False # 복원 후: 원천 단가(200)로 덮이지 않고 최초 내역 단가(100, 150, 50)로 정확히 복원 assert result["resRow"]["NU"] == 100 assert result["resRow"]["GU"] == 150 assert result["resRow"]["JU"] == 50 assert result["resRow"]["TU"] == 300 assert result["resRow"]["TC"] == 3000 # 사용자의 원래 SourceRefresh 옵션(true) 복원 및 임시 키 정리 assert result["resOption"] is True assert result["hasPreBidRates"] is False # 수동 계산(AutoCalc: false) 모드에서도 최초 단가 및 복원 일관성 확인 assert result["manualBidRow"]["NU"] == 70 assert result["manualBidRow"]["GU"] == 105 assert result["manualResRow"]["NU"] == 100 assert result["manualResRow"]["GU"] == 150