793 lines
34 KiB
Python
793 lines
34 KiB
Python
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
|