Files
Aislo/resources/tester/test_est_change_workflow.py

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