import json import subprocess from pathlib import Path ROOT = Path(__file__).resolve().parents[2] def run_js(code: str) -> dict: runner = f""" const fs = require('fs'); const vm = require('vm'); const ctx = {{ console, setTimeout, clearTimeout, structuredClone, URL, TextDecoder, TextEncoder, Buffer, document: {{ querySelector: () => ({{ setCustomValidity: () => {{}}, reportValidity: () => true }}), body: {{ append: () => {{}} }}, createElement: () => ({{ click: () => {{}} }}) }}, window: {{}}, confirm: () => true }}; ctx.window = ctx; vm.createContext(ctx); const files = [ 'tools/est-test/native-math.js', 'tools/est-test/native-formula.js', 'tools/est-test/native-model.js', 'tools/est-test/native-text.js', 'tools/est-test/native-machine.js', 'tools/est-test/native-wonga.js', 'tools/est-test/native-engine.js', 'tools/est-test/native-row-match.js', 'tools/est-test/native-rows.js', 'tools/est-test/native-business.js', 'tools/est-test/native-extra.js', 'tools/est-test/native-block-actions.js', 'tools/est-test/native-business-actions.js' ]; for (const f of files) {{ const content = fs.readFileSync(f, 'utf8'); vm.runInContext(content, ctx); }} const userCode = `{code}`; const promise = vm.runInContext(userCode, ctx); Promise.resolve(promise).then(result => {{ process.stdout.write(JSON.stringify(result)); }}).catch(err => {{ console.error(err); process.exit(1); }}); """ out = subprocess.check_output(["node", "-e", runner], cwd=ROOT) return json.loads(out.decode("utf-8")) def test_toggle_jc_bimog_and_sun_gongsa(): code = """(async () => { // 1. 프로젝트 초기 구성 const p = EstNative.empty(); p.tables.StuffcDat = [ { Code: "M01", Name: "자재1", TU: 1000, GU: 1000, Cost1: 1000, ValueIndex: 0, InputTC: 1000, TC: 1000 } ]; p.tables.LaborcDat = [ { Code: "L01", Name: "노임1", TU: 2000, NU: 2000, Cost1: 2000, ValueIndex: 0, InputTC: 2000, TC: 2000 } ]; p.tables.EstMainDat = [ { PkID: 1, Code: "M01", Name: "자재1", LinkIndex: 5, GN: 2, TU: 1000, GU: 1000, GC: 2000, TC: 2000, SCheck: 0 }, { PkID: 2, Code: "L01", Name: "노임1", LinkIndex: 6, GN: 3, TU: 2000, NU: 2000, NC: 6000, TC: 6000, SCheck: 0 }, { PkID: 3, Name: "공종", LinkIndex: 0, GN: 0, TC: 0 }, { PkID: 4, Name: "순공사비", LinkIndex: 54, GN: 0, TC: 8000, _origLinkIndex: 0, _origName: "공종" } ]; // 가상 EstApp 환경 const state = { project: p, view: "EstMainDat", selection: new Set([0]) }; const EstApp = { state, change(fn) { fn(); } }; // 1) toggle-jc-bimog 실행: 자재1 행 SCheck=0 -> 3 (경비성단가 지정) // GU=1000 -> JU=1000, GC=0, JC=2000, SCheck=3 let stateCopy = state; globalThis.EstApp = EstApp; globalThis.EstActions = { form: () => {} }; // 행 0 토글 -> SCheck 3 EstBusinessActions.handle("toggle-jc-bimog"); const r0_afterToggle = { ...p.tables.EstMainDat[0] }; // 행 0 재토글 -> SCheck 0 복원 EstBusinessActions.handle("toggle-jc-bimog"); const r0_restored = { ...p.tables.EstMainDat[0] }; // 행 1 (노임1, SCheck=2로 사전 설정) 경비성 토글 및 원래 SCheck=2 복원 확인 p.tables.EstMainDat[1].SCheck = 2; state.selection = new Set([1]); EstBusinessActions.handle("toggle-jc-bimog"); const r1_afterToggle = { ...p.tables.EstMainDat[1] }; EstBusinessActions.handle("toggle-jc-bimog"); const r1_restored = { ...p.tables.EstMainDat[1] }; // 2) toggle-sun-gongsa 실행: // 행 2(기존공종)을 선택하고 순공사비 지정 -> 기존 행 3(순공사비)은 LinkIndex=0으로 해제되고 행 2가 54로 전환 state.selection = new Set([2]); EstBusinessActions.handle("toggle-sun-gongsa"); const r2_afterSun = { ...p.tables.EstMainDat[2] }; const r3_afterSun = { ...p.tables.EstMainDat[3] }; // 행 2를 다시 토글 -> 54 해제 EstBusinessActions.handle("toggle-sun-gongsa"); const r2_restored = { ...p.tables.EstMainDat[2] }; // 3) 안전성 거부 확인 let rejected = 0; // 3-1) 일위대가나 내역서가 아닌 화면에서 jc-bimog 호출 거부 state.view = "StuffcDat"; try { await EstBusinessActions.handle("toggle-jc-bimog"); } catch { rejected++; } // 3-2) 선택 없는 상태에서 jc-bimog 호출 거부 state.view = "EstMainDat"; state.selection.clear(); try { await EstBusinessActions.handle("toggle-jc-bimog"); } catch { rejected++; } // 3-3) sun-gongsa는 2개 이상 선택 시 거부 state.selection = new Set([0, 1]); try { await EstBusinessActions.handle("toggle-sun-gongsa"); } catch { rejected++; } // 3-4) 자재/원천 연결 행이나 수량이 있는 행(행 0)을 sun-gongsa로 지정 시도 시 안전 차단 거부 state.selection = new Set([0]); try { await EstBusinessActions.handle("toggle-sun-gongsa"); } catch { rejected++; } return { r0_afterToggle, r0_restored, r1_afterToggle, r1_restored, r2_afterSun, r3_afterSun, r2_restored, rejected }; })()""" result = run_js(code) # 1) 경비성단가 지정 -> SCheck 3, JU=1000, GU=0 assert result["r0_afterToggle"]["SCheck"] == 3 assert result["r0_afterToggle"]["JU"] == 1000 assert result["r0_afterToggle"]["GU"] == 0 assert result["r0_afterToggle"]["TU"] == 1000 # 경비성단가 해제 -> SCheck 0, GU=1000, JU=0 assert result["r0_restored"]["SCheck"] == 0 assert result["r0_restored"]["GU"] == 1000 assert result["r0_restored"]["JU"] == 0 # 노무비(SCheck=2) 경비성 지정 -> SCheck 3, JU=2000, NU=0 assert result["r1_afterToggle"]["SCheck"] == 3 assert result["r1_afterToggle"]["JU"] == 2000 assert result["r1_afterToggle"]["NU"] == 0 # 노무비 경비성 해제 -> 원래 SCheck=2로 온전히 복원, NU=2000, JU=0 assert result["r1_restored"]["SCheck"] == 2 assert result["r1_restored"]["NU"] == 2000 assert result["r1_restored"]["JU"] == 0 # 2) 순공사비 경계 행 전환 -> 행 2가 54, 행 3은 0 assert result["r2_afterSun"]["LinkIndex"] == 54 assert result["r2_afterSun"]["Name"] == "순공사비" assert result["r3_afterSun"]["LinkIndex"] == 0 assert result["r3_afterSun"]["Name"] == "공종" # 순공사비 재토글 해제 -> 행 2가 0 assert result["r2_restored"]["LinkIndex"] == 0 # 3) 안전성 거부 4건 모두 통과 확인 assert result["rejected"] == 4 def test_uses_data_search_finds_usages_across_tables(): code = """(() => { const p = EstNative.empty(); p.tables.StuffcDat = [ { Code: "M001", Name: "시멘트", Size: "포대", Unite: "포", TC: 5000 }, { Code: "M002", Name: "모래", Size: null, Unite: "㎥", TC: 20000 } ]; p.tables.LaborcDat = [ { Code: "L001", Name: "보통인부", Size: null, Unite: "인", TC: 150000 } ]; p.tables.HeadMainDat = [ { Index: 1, Code: "U001", Name: "콘크리트타설", Size: "구조물", Unite: "㎥", TC: 50000 }, { Index: 0, Code: "M001", Name: "시멘트", Size: "포대", Unite: "포", GN: 2, TC: 10000 }, { Index: 0, Code: "L001", Name: "보통인부", Size: null, Unite: "인", GN: 0.1, TC: 15000 }, { Index: 2, Code: "U002", Name: "몰탈바름", Size: null, Unite: "㎡", TC: 30000 }, { Index: 0, Code: "M001", Name: "시멘트", Size: "포대", Unite: "포", GN: 0.5, TC: 2500 } ]; p.tables.CostMainDat = [ { Index: 1, Code: "D001", LineData: "\x0e기초터파기\x0e", TC: 10000 }, { Index: 0, Code: "M001", LineData: "\x0e시멘트\x0e", TC: 5000 } ]; p.tables.EstMainDat = [ { PkID: 1, Level: 0, Code: "U001", Name: "콘크리트타설", Size: "구조물", Unite: "㎥", GN: 10, TC: 500000 }, { PkID: 2, Level: 0, Code: "M001", Name: "시멘트", Size: "포대", Unite: "포", GN: 5, TC: 25000 }, { PkID: 3, Level: 0, Code: null, Name: "이름만일치모래", Size: null, Unite: "㎥", GN: 1, TC: 20000 } ]; const targetM001 = p.tables.StuffcDat[0]; const m001Usages = EstExtra.findDataUsages(p, targetM001); const targetU001 = p.tables.HeadMainDat[0]; const u001Usages = EstExtra.findDataUsages(p, targetU001); const targetNoCode = { Name: "이름만일치모래" }; const noCodeUsages = EstExtra.findDataUsages(p, targetNoCode); const targetNone = { Code: "NON_EXIST", Name: "없는항목" }; const noneUsages = EstExtra.findDataUsages(p, targetNone); return { m001Usages, u001Usages, noCodeUsages, noneUsages }; })()""" result = run_js(code) m001 = result["m001Usages"] # M001: HeadMainDat 2건 (U001 그룹 1건, U002 그룹 1건), CostMainDat 1건 (D001 그룹), EstMainDat 1건 = 총 4건 assert len(m001) == 4 tables = [u["table"] for u in m001] assert tables.count("HeadMainDat") == 2 assert tables.count("CostMainDat") == 1 assert tables.count("EstMainDat") == 1 # 그룹 코드 보존 검증 headGroups = [u["group"] for u in m001 if u["table"] == "HeadMainDat"] assert "U001" in headGroups and "U002" in headGroups # U001: EstMainDat 1건 검출 (HeadMainDat의 대표행 자신은 제외됨) u001 = result["u001Usages"] assert len(u001) == 1 assert u001[0]["table"] == "EstMainDat" assert u001[0]["quantity"] == 10 # 이름만 있는 항목 검색 noCode = result["noCodeUsages"] assert len(noCode) == 1 assert noCode[0]["table"] == "EstMainDat" # 존재하지 않는 항목 assert len(result["noneUsages"]) == 0 def test_toggle_safety_guards_prevent_arbitrary_zero_conversion_without_history(): code = """(() => { const p = EstNative.empty(); // 기존 원본 54 행: _origLinkIndex가 없고 LinkIndex_=54인 상태 (저장 후 재열기 또는 원본) p.tables.EstMainDat = [ { PkID: 1, Name: "원래순공사비", LinkIndex: 54, LinkIndex_: 54, GN: 0, TC: 5000 }, { PkID: 2, Name: "새로운공종", LinkIndex: 0, GN: 0, TC: 0 }, { PkID: 3, Code: "M01", Name: "원래경비성자재", LinkIndex: 5, SCheck: 3, SCheck_: 3, GN: 1, TU: 100, JU: 100, TC: 100 } ]; const state = { project: p, view: "EstMainDat", selection: new Set([0]) }; globalThis.EstApp = { state, change(fn) { fn(); } }; let blocked = 0; // 1) 기존 원본 54 행을 선택하고 해제 시도 -> 복원 정보가 없으므로 임의 0 변환 차단되어야 함 try { EstExtra.toggleSunGongsa(state); } catch (err) { if (err.message.includes("임의 0으로 변환할 수 없습니다")) blocked++; } // 2) 다른 행(새로운공종)을 54로 지정 시도 -> 기존 54 행의 원래 유형을 알 수 없으므로 차단되어야 함 state.selection = new Set([1]); try { EstExtra.toggleSunGongsa(state); } catch (err) { if (err.message.includes("다른 행으로 대체할 수 없습니다")) blocked++; } // 3) 원본부터 SCheck 3이었던 행(복원 정보 없는 행)의 경비성 해제 시도 -> 차단되어야 함 state.selection = new Set([2]); try { EstExtra.toggleJcBimog(state); } catch (err) { if (err.message.includes("임의 변환할 수 없습니다")) blocked++; } // 4) 임의 변환 시도가 모두 차단되었으므로 기존 행 속성이 온전하게 보존되었는지 검증 return { blocked, r0_LinkIndex: p.tables.EstMainDat[0].LinkIndex, r1_LinkIndex: p.tables.EstMainDat[1].LinkIndex, r2_SCheck: p.tables.EstMainDat[2].SCheck }; })()""" result = run_js(code) assert result["blocked"] == 3 assert result["r0_LinkIndex"] == 54 assert result["r1_LinkIndex"] == 0 assert result["r2_SCheck"] == 3 def test_set_cost_line_kind_action(): code = """(async () => { const p = EstNative.empty(); p.tables.CostMainDat = [ { Index: 1, Code: "D01", LineData: "\x0e기초터파기\x0e", TC: 1000 }, { Index: 0, Code: null, LineData: "노무비 산출라인", TC: 500, OCheck: 0, SCheck: 0 }, { Index: 0, Code: null, LineData: "재료비 산출라인", TC: 300, OCheck: -2, SCheck: -2 } ]; let formCallback = null; globalThis.EstActions = { form(title, fields, cb) { formCallback = cb; } }; const state = { project: p, view: "CostMainDat", selection: new Set([1]) }; globalThis.EstApp = { state, change(fn) { fn(); } }; // 1) 행 1을 노무비(-1)로 지정 await EstBusinessActions.handle("set-cost-line-kind"); formCallback({ kind: -1 }); const r1_afterNC = { OCheck: p.tables.CostMainDat[1].OCheck, SCheck: p.tables.CostMainDat[1].SCheck }; // 2) 행 2(기존 재료비 -2)를 일반 해제(0)로 전환 state.selection = new Set([2]); await EstBusinessActions.handle("set-cost-line-kind"); formCallback({ kind: 0 }); const r2_afterFree = { OCheck: p.tables.CostMainDat[2].OCheck, SCheck: p.tables.CostMainDat[2].SCheck }; // 3) 안전성 거부: CostMainDat가 아닌 화면에서 호출 시 거부 state.view = "EstMainDat"; let rejected = 0; try { await EstBusinessActions.handle("set-cost-line-kind"); } catch (err) { rejected++; } return { r1_afterNC, r2_afterFree, rejected }; })()""" result = run_js(code) assert result["r1_afterNC"] == {"OCheck": -1, "SCheck": -1} assert result["r2_afterFree"].get("OCheck") is None assert result["r2_afterFree"].get("SCheck") is None assert result["rejected"] == 1 def test_jc_bimog_multi_selection_atomic_and_json_roundtrip(): code = """(() => { const p = EstNative.empty(); p.tables.StuffcDat = [ { Code: "M01", Name: "자재1", TU: 100, GU: 100, Cost1: 100, ValueIndex: 0, InputTC: 100, TC: 100 } ]; p.tables.LaborcDat = [ { Code: "L01", Name: "노임1", TU: 200, NU: 200, Cost1: 200, ValueIndex: 0, InputTC: 200, TC: 200 } ]; p.tables.EstMainDat = [ // 행 0: 정상 비목별(SCheck=0) -> 경비성 지정 가능 { PkID: 1, Code: "M01", Name: "자재1", LinkIndex: 5, GN: 1, TU: 100, GU: 100, GC: 100, TC: 100, SCheck: 0, SCheck_: 0 }, // 행 1: 기존 파일 원본부터 SCheck 3이고 이력이 없는 행 -> 해제 시 차단되어야 함 { PkID: 2, Code: "L01", Name: "노임1", LinkIndex: 6, GN: 1, TU: 200, JU: 200, JC: 200, TC: 200, SCheck: 3, SCheck_: 3 } ]; const state = { project: p, view: "EstMainDat", selection: new Set([0, 1]) }; globalThis.EstApp = { state, change(fn) { fn(); } }; let atomicBlocked = false; try { // 복수 선택: 행 0(지정 시도)과 행 1(해제 시도인데 이력 없어 거부 대상) EstExtra.toggleJcBimog(state); } catch (err) { if (err.message.includes("임의 변환할 수 없습니다")) atomicBlocked = true; } // 원자적 검증: 행 1 때문에 에러 발생 시 앞선 행 0도 변경되지 않고 SCheck=0 유지 확인 const r0_SCheck_afterFail = p.tables.EstMainDat[0].SCheck; // 행 0 단독 지정 -> SCheck 3 및 _prevSCheck=0 보존 state.selection = new Set([0]); EstExtra.toggleJcBimog(state); const r0_afterToggle = { SCheck: p.tables.EstMainDat[0].SCheck, _prevSCheck: p.tables.EstMainDat[0]._prevSCheck }; // JSON 직렬화 & 역직렬화 (저장 후 재열기 시뮬레이션) const serialized = JSON.stringify(p); const reopened = JSON.parse(serialized); // 재열기 후 해제 실행 -> SCheck_=0 및 _prevSCheck=0을 통해 원래 SCheck 0으로 정확히 복원 const reopenedState = { project: reopened, view: "EstMainDat", selection: new Set([0]) }; globalThis.EstApp.state = reopenedState; EstExtra.toggleJcBimog(reopenedState); const r0_restoredReopened = { SCheck: reopened.tables.EstMainDat[0].SCheck }; return { atomicBlocked, r0_SCheck_afterFail, r0_afterToggle, r0_restoredReopened }; })()""" result = run_js(code) assert result["atomicBlocked"] is True # 실패 시 앞선 행 0도 변경되지 않고 0 유지 (원자적 검증) assert result["r0_SCheck_afterFail"] == 0 # 행 0 지정 시 SCheck=3 및 _prevSCheck=0 보존 assert result["r0_afterToggle"]["SCheck"] == 3 assert result["r0_afterToggle"]["_prevSCheck"] == 0 # JSON 재열기 후 해제 시 원래 0으로 온전 복원 assert result["r0_restoredReopened"]["SCheck"] == 0 def test_block_actions_range_selection_and_clear(): code = """(() => { const p = EstNative.empty(); p.tables.CostMainDat = [ { Index: 1, Code: "D01", LineData: "\\x0E기초터파기\\x0E", TC: 1000 }, { Index: 0, Code: null, LineData: "1행", TC: 100 }, { Index: 0, Code: null, LineData: "2행", TC: 200 }, { Index: 0, Code: null, LineData: "3행", TC: 300 }, { Index: 0, Code: null, LineData: "4행", TC: 400 }, { Index: 0, Code: null, LineData: "5행", TC: 500 } ]; const state = { project: p, view: "CostMainDat", selection: new Set([1]), notice: "" }; globalThis.EstApp = { state, render() {} }; // 1) 1행(index 1)을 블럭 시작으로 지정 EstBlockActions.setBlockStart(state); const startNotice = state.notice; // 2) 4행(index 4)을 블럭 끝으로 지정 -> 1~4행(총 4개) 일괄 선택 확인 state.selection = new Set([4]); EstBlockActions.setBlockEnd(state); const selectedIndices = [...state.selection].sort((a, b) => a - b); const endNotice = state.notice; // 3) 전체 선택 실행 EstBlockActions.selectAllRows(state); const allSelectedCount = state.selection.size; // 4) 블럭 해제 실행 EstBlockActions.clearBlockSelection(state); const clearedCount = state.selection.size; // 5) 시작 지정 없이 블럭 끝 호출 시 거부 let rejected = 0; try { state.selection = new Set([2]); EstBlockActions.setBlockEnd(state); } catch { rejected++; } return { startNotice, endNotice, selectedIndices, allSelectedCount, clearedCount, rejected }; })()""" result = run_js(code) assert "2행이 블럭 시작점으로 설정" in result["startNotice"] assert "2행부터 5행까지 총 4개 행이 블럭으로 선택" in result["endNotice"] assert result["selectedIndices"] == [1, 2, 3, 4] assert result["allSelectedCount"] == 6 assert result["clearedCount"] == 0 assert result["rejected"] == 1 def test_toggle_cost_comment_and_block_delete(): code = """(() => { const p = EstNative.empty(); p.tables.CostMainDat = [ { Index: 1, Code: "D01", LineData: "\\x0e단가산출 호표1\\x0e", TC: 1000, NC: 1000, GC: 0, JC: 0 }, { Index: 0, Code: null, LineData: "Q = 10 / 2 = \\x125\\x12", TC: 5, NC: 5, GC: 0, JC: 0 }, { Index: 0, Code: null, LineData: "Q2 = 20 / 2 = \\x1210\\x12", TC: 10, NC: 10, GC: 0, JC: 0 }, { Index: -20, Code: null, LineData: "구분선", TC: 0 }, { Index: 0, Code: null, LineData: "일반행 3", TC: 30 } ]; const state = { project: p, view: "CostMainDat", selection: new Set([1]), notice: "" }; globalThis.EstApp = { state, change(fn) { fn(); }, render() {} }; // 1) 주석 지정: 1번 행(Index=0)을 주석(Index=-1)으로 지정 EstBlockActions.toggleCostComment(state); const afterCommentIndex = p.tables.CostMainDat[1].Index; const origIndexSaved = p.tables.CostMainDat[1]._origIndex; // 2) 주석 해제: 다시 토글 시 원래 Index(0)로 복원 및 _origIndex 제거 EstBlockActions.toggleCostComment(state); const restoredIndex = p.tables.CostMainDat[1].Index; const origIndexDeleted = p.tables.CostMainDat[1]._origIndex === undefined; // 3) 호표 제목 행(Index > 0) 및 구분선 행(Index === -20) 주석 지정 거부 확인 let titleRejected = false; try { state.selection = new Set([0]); // 호표 제목 행 EstBlockActions.toggleCostComment(state); } catch { titleRejected = true; } let sepRejected = false; try { state.selection = new Set([3]); // 구분선(-20) 행 EstBlockActions.toggleCostComment(state); } catch { sepRejected = true; } // 4) 다중 행 선택 및 원자성 검증: 제목 행이 섞여 있으면 전체 거부 let mixedRejected = false; try { state.selection = new Set([1, 0, 2]); EstBlockActions.toggleCostComment(state); } catch { mixedRejected = true; } const mixedNotToggled = p.tables.CostMainDat[1].Index === 0; // 5) 블럭 삭제 기능 테스트: 1~2번 상세 행 블럭 삭제 state.selection = new Set([1, 2]); EstBlockActions.deleteBlockRows(state); const remainingCount = p.tables.CostMainDat.length; const remainingLines = p.tables.CostMainDat.map(r => r.LineData); const selectionCleared = state.selection.size === 0; // 6) 다른 테이블에서 참조 중인 호표 삭제 시 거부 확인 (숫자 LinkIndex 및 문자열 "104" LinkIndex) p.tables.EstMainDat = [ { Code: "D01", LinkIndex: "104", LineData: "내역서 항목", GN: 10 } ]; let refRejected = false; try { state.selection = new Set([0]); // D01 호표 제목 행 EstBlockActions.deleteBlockRows(state); } catch { refRejected = true; } // 7) 비목 행(-2 재료비, -3 경비, -5 소계) 주석 지정 및 원래 비목 복원 + JSON 저장 재열기 검증 p.tables.CostMainDat.push( { Index: 0, SCheck: -2, OCheck: -2, LineData: "재료비 행", TC: 200 }, { Index: 0, SCheck: -3, OCheck: -3, LineData: "경비 행", TC: 300 }, { Index: 0, SCheck: -5, OCheck: -5, LineData: "소계 행", TC: 500 } ); // 재료비 행(index 3) 주석 지정 state.selection = new Set([3]); EstBlockActions.toggleCostComment(state); const bimogCommentedSCheck = p.tables.CostMainDat[3].SCheck; const bimogCommentedIndex = p.tables.CostMainDat[3].Index; const bimogOrigSCheck = p.tables.CostMainDat[3]._origSCheck; // JSON 직렬화 & 역직렬화 (저장 재열기) 시뮬레이션 const jsonStr = JSON.stringify(p); const reloadedP = JSON.parse(jsonStr); state.project = reloadedP; // 재열기 후 주석 해제 -> 원래 SCheck(-2) 및 Index(0)로 정확히 복원 EstBlockActions.toggleCostComment(state); const restoredBimogSCheck = state.project.tables.CostMainDat[3].SCheck; const restoredBimogIndex = state.project.tables.CostMainDat[3].Index; const origSCheckDeleted = state.project.tables.CostMainDat[3]._origSCheck === undefined; // 경비(-3), 소계(-5) 다중 선택 일괄 주석 지정 및 해제 검증 state.selection = new Set([4, 5]); EstBlockActions.toggleCostComment(state); const multiCommented = [ state.project.tables.CostMainDat[4].SCheck, state.project.tables.CostMainDat[5].SCheck ]; EstBlockActions.toggleCostComment(state); const multiRestored = [ state.project.tables.CostMainDat[4].SCheck, state.project.tables.CostMainDat[5].SCheck ]; return { afterCommentIndex, origIndexSaved, restoredIndex, origIndexDeleted, titleRejected, sepRejected, mixedRejected, mixedNotToggled, remainingCount, remainingLines, selectionCleared, refRejected, bimogCommentedSCheck, bimogCommentedIndex, bimogOrigSCheck, restoredBimogSCheck, restoredBimogIndex, origSCheckDeleted, multiCommented, multiRestored }; })()""" result = run_js(code) assert result["afterCommentIndex"] == -1 assert result["origIndexSaved"] == 0 assert result["restoredIndex"] == 0 assert result["origIndexDeleted"] is True assert result["titleRejected"] is True assert result["sepRejected"] is True assert result["mixedRejected"] is True assert result["mixedNotToggled"] is True assert result["remainingCount"] == 3 assert result["selectionCleared"] is True assert result["refRejected"] is True assert result["bimogCommentedSCheck"] == -1 assert result["bimogCommentedIndex"] == -1 assert result["bimogOrigSCheck"] == -2 assert result["restoredBimogSCheck"] == -2 assert result["restoredBimogIndex"] == 0 assert result["origSCheckDeleted"] is True assert result["multiCommented"] == [-1, -1] assert result["multiRestored"] == [-3, -5] def test_align_wonga_total_action(): code = """(() => { const p = EstNative.empty(); p.tables.Wonga = [ { Gubun: "A", Name: "직접공사비", Cost: 1000000, LinkIndex: 0, CalcIndex: 0 }, { Gubun: "B", Name: "간접노무비", Cost: 0, VSusic: "A * 0.1", LinkIndex: 0, CalcIndex: 0 }, { Gubun: "C", Name: "이윤", Cost: 0, VSusic: "(A + B) * 0.1", LinkIndex: 0, CalcIndex: 0 }, { Gubun: "D", Name: "부가가치세", Cost: 0, VSusic: "(A + B + C) * 0.1", LinkIndex: 0, CalcIndex: 0 }, { Gubun: "E", Name: "총도급공사비", Cost: 0, VSusic: "A + B + C + D", LinkIndex: 55, CalcIndex: 0 } ]; // 1) 당초 일반 계산: 직접공사비 1,000,000 기준 -> 총도급 1,331,000원, 이윤 110,000원, 부가세 121,000원 const initialCalc = EstWonga.calculate(p.tables.Wonga, { TotalFix: false }, {}); const initialTotal = initialCalc.rows[4].Cost; const initialProfit = initialCalc.rows[2].Cost; p.tables.Wonga = initialCalc.rows; const state = { project: p, view: "Wonga", selection: new Set([4]), notice: "" }; globalThis.EstApp = { state, change(fn) { fn(); // EstApp.change recalculates via EstNative.calculate / EstWonga.calculate const recalc = EstWonga.calculate(p.tables.Wonga, p.options, {}); p.tables.Wonga = recalc.rows; }, render() {} }; // 2) 10,000원 단위(unitDigits: 4) 역산 맞춤: 1,331,000 -> 1,330,000원으로 절하 맞춤 const alignRes = EstWonga.alignTotal(p.tables.Wonga, 1331000, 4, p.options, {}); p.options.TotalFix = true; const alignedTotal = p.tables.Wonga[alignRes.targetIndex].Cost; const alignedProfit = p.tables.Wonga[alignRes.profitIndex].Cost; const alignedVat = p.tables.Wonga[3].Cost; // 전체 명세 합산 일치 검증: 직접(1,000,000) + 간접노무(100,000) + 이윤(109,091) + 부가세(120,909) === 1,330,000 const totalMatch = p.tables.Wonga[0].Cost + p.tables.Wonga[1].Cost + p.tables.Wonga[2].Cost + p.tables.Wonga[3].Cost === alignedTotal; // 3) JSON 직렬화 & 역직렬화 (저장 재열기) 후 재계산 일관성 검증 const jsonStr = JSON.stringify(p); const reloadedP = JSON.parse(jsonStr); const reloadCalc = EstWonga.calculate(reloadedP.tables.Wonga, reloadedP.options, {}); const reloadedTotal = reloadCalc.rows[4].Cost; const reloadedProfit = reloadCalc.rows[2].Cost; const reloadedVat = reloadCalc.rows[3].Cost; // 4) LinkIndex 55 부재 시 거부 확인 let noTargetRejected = false; try { EstWonga.alignTotal([{ Name: "항목1", Cost: 100 }], 1000, 3); } catch { noTargetRejected = true; } // 5) 이윤 행 부재 시 거부 확인 let noProfitRejected = false; try { EstWonga.alignTotal([{ Name: "총도급", Cost: 100, LinkIndex: 55 }], 1000, 3); } catch { noProfitRejected = true; } // 6) 계산 실패 시 기존 rows 상태 보존 검증: 순환 참조 수식 원가표 const circularRows = [ { Gubun: "01", Name: "순공사비", LinkIndex: 54, CalcIndex: 0, Cost: 1000000 }, { Gubun: "02", Name: "이윤", LinkIndex: 0, CalcIndex: 0, VSusic: "[부가가치세] * 0.1", Cost: 100000 }, { Gubun: "03", Name: "부가가치세", LinkIndex: 0, CalcIndex: 0, VSusic: "[이윤] * 0.1", Cost: 10000 }, { Gubun: "04", Name: "총도급공사비", LinkIndex: 55, CalcIndex: 0, VSusic: "[순공사비] + [이윤] + [부가가치세]", Cost: 1110000 } ]; const origCircularCost = circularRows[3].Cost; let circularErrorCaught = false; try { EstWonga.alignTotal(circularRows, 1100000, 3); } catch { circularErrorCaught = true; } const circularCostPreserved = circularRows[3].Cost === origCircularCost; return { initialTotal, initialProfit, alignedTotal, alignedProfit, alignedVat, totalMatch, reloadedTotal, reloadedProfit, reloadedVat, noTargetRejected, noProfitRejected, circularErrorCaught, circularCostPreserved }; })()""" result = run_js(code) assert result["initialTotal"] == 1331000 assert result["initialProfit"] == 110000 assert result["alignedTotal"] == 1330000 assert result["alignedProfit"] == 109091 assert result["alignedVat"] == 120909 assert result["totalMatch"] is True assert result["reloadedTotal"] == 1330000 assert result["reloadedProfit"] == 109091 assert result["reloadedVat"] == 120909 assert result["noTargetRejected"] is True assert result["noProfitRejected"] is True assert result["circularErrorCaught"] is True assert result["circularCostPreserved"] is True def test_level_batch_adjust_actions(): code = """(() => { const p = EstNative.empty(); p.tables.EstMainDat = [ { Index: 0, LineData: "공종 1", Level: 1 }, { Index: 0, LineData: "항목 1-1", Level: 2 }, { Index: 0, LineData: "항목 1-2", Level: 2 }, { Index: 0, LineData: "공종 2", Level: 0 }, { Index: 0, LineData: "공종 9", Level: 9 } ]; const state = { project: p, view: "EstMainDat", selection: new Set([0, 1]), notice: "" }; globalThis.EstApp = { state, change(fn) { fn(); }, render() {} }; // 1) 레벨 일괄 증가: 0번행(1->2), 1번행(2->3) EstBlockActions.adjustLevels(state, 1); const afterUp0 = p.tables.EstMainDat[0].Level; const afterUp1 = p.tables.EstMainDat[1].Level; const upNotice = state.notice; // 2) 레벨 일괄 감소: 0번행(2->1), 1번행(3->2) EstBlockActions.adjustLevels(state, -1); const afterDown0 = p.tables.EstMainDat[0].Level; const afterDown1 = p.tables.EstMainDat[1].Level; // 3) 상한(9) 및 하한(0) 클램프 검증: // 3번행(0) 감소 시 0 유지, 4번행(9) 증가 시 9 유지 state.selection = new Set([3, 4]); EstBlockActions.adjustLevels(state, -1); const clampMin = p.tables.EstMainDat[3].Level; // 0 유지 EstBlockActions.adjustLevels(state, 1); const clampMax = p.tables.EstMainDat[4].Level; // 9 유지 // 4) 지원되지 않는 화면(자재, 노임 등)에서 호출 시 안전 거부 state.view = "StuffcDat"; let viewRejected = false; try { EstBlockActions.adjustLevels(state, 1); } catch { viewRejected = true; } // 5) 선택 행 없을 시 거부 state.view = "EstMainDat"; state.selection = new Set([]); let emptyRejected = false; try { EstBlockActions.adjustLevels(state, 1); } catch { emptyRejected = true; } return { afterUp0, afterUp1, upNotice, afterDown0, afterDown1, clampMin, clampMax, viewRejected, emptyRejected }; })()""" result = run_js(code) assert result["afterUp0"] == 2 assert result["afterUp1"] == 3 assert "2개 행의 레벨이 일괄 증가" in result["upNotice"] assert result["afterDown0"] == 1 assert result["afterDown1"] == 2 assert result["clampMin"] == 0 assert result["clampMax"] == 9 assert result["viewRejected"] is True assert result["emptyRejected"] is True def test_reset_change_action(): code = """(() => { const p = EstNative.empty(); p.tables.EstMainDat = [ { Code: "A001", Name: "토공사", GN: 100, TU: 5000, TC: 500000, Level: 0, LinkIndex: 0, CalcIndex: 0 }, { Code: "A002", Name: "구조물공사", GN: 50, TU: 10000, TC: 500000, Level: 0, LinkIndex: 0, CalcIndex: 0, _origLinkIndex: 21, _prevSCheck: 1 } ]; // 타 업무(수량 연결 등) 및 원본 필드 모의 p.quantities = [ { id: "Q1", sheet: "토공", items: [{ code: "A001", qty: 100 }] } ]; // 1) baseline 부재 시 안전 차단 검증 const state = { project: p, view: "EstMainDat", selection: new Set([0]), notice: "" }; globalThis.confirm = () => true; globalThis.EstApp = { state, history: [], future: [], change(fn) { // undo history 기록 모의 this.history.push(JSON.stringify(state.project)); fn(); }, render() {} }; let rejectBeforeBaseline = false; try { EstBlockActions.resetChangeAction(state); } catch { rejectBeforeBaseline = true; } // 2) 당초 확정 (baseline 생성 및 Mode = "설계변경", _ 필드 보존) state.project = EstBusiness.baseline(state.project); // 3) 설계변경 작업 수행: 기존 행 변경, 신규 행 추가, 기존 행 삭제, 타 업무 필드 보존 확인 state.project.tables.EstMainDat[0].GN = 150; state.project.tables.EstMainDat[0].TC = 750000; // 신규 행 추가 state.project.tables.EstMainDat.push({ Code: "A003", Name: "신규공종", GN: 20, TU: 3000, TC: 60000, Level: 0, LinkIndex: 0, CalcIndex: 0, NewItem: true }); // 1번 행(_origLinkIndex, _prevSCheck 포함)의 변경 state.project.tables.EstMainDat[1].GN = 80; // 3-1) 신규 행을 참조하는 수량 연결(quantities)이 존재할 때 초기화 차단 검증 state.project.quantities.push({ Code: "Q2", TargetCode: "A003", // 신규 행 참조 GN: 20 }); let brokenQtyLinkRejected = false; try { EstBlockActions.resetChangeAction(state); } catch (e) { if (e.message.includes("수량산출 연결 무결성 오류")) { brokenQtyLinkRejected = true; } } // 차단 확인 후 정상 복원 테스트를 위해 신규 연결 해제 state.project.quantities.pop(); // 3-2) Excel 수량 연결 동일성(Identity) 입증 검증: // PkID 1(토공사)에 Excel 링크 연결 state.project.tables.EstMainDat[0].PkID = 1; state.project.baseline.tables.EstMainDat[0].PkID = 1; state.project.tables.EstMainDat[1].PkID = 2; state.project.baseline.tables.EstMainDat[1].PkID = 2; state.project.quantities.push({ TargetKey: "id:999", TargetCode: "A001", GN: 1 }); let missingKeyRejected = false; try { EstBlockActions.resetChangeAction(state); } catch (e) { missingKeyRejected = e.message.includes("수량산출 연결 무결성 오류"); } state.project.quantities.pop(); state.project.baseline.tables.EstMainDat.push({ Code: "A001", Level: 0 }); state.project.quantities.push({ TargetCode: "A001", GN: 1 }); let duplicateCodeRejected = false; try { EstBlockActions.resetChangeAction(state); } catch (e) { duplicateCodeRejected = e.message.includes("수량산출 연결 무결성 오류"); } state.project.quantities.pop(); state.project.baseline.tables.EstMainDat.pop(); if (!missingKeyRejected || !duplicateCodeRejected) throw Error("수량 연결 보호 실패"); state.project.excelQuantityLinks = [ { target: 1, file: "calc.xlsx", sheet: "Sheet1", cell: "B10" } ]; // 중간 행 삽입, 삭제, 재정렬 및 중복 코드 상황 모의: // 신규 행(중복 코드 A001를 가진 다른 항목)을 앞에 삽입해 인덱스 순서 변경 state.project.tables.EstMainDat.unshift({ Code: "A001", // 중복 코드 Name: "중복코드 신규토공", GN: 10, TU: 2000, TC: 20000, Level: 0, LinkIndex: 0, CalcIndex: 0, PkID: 99, NewItem: true }); // PkID 1 행은 이제 1번 인덱스로 밀림. PkID 1의 내용(토공사)은 baseline의 PkID 1(토공사)과 일치함. // 하지만 Excel 링크가 신규 PkID 99를 가리키도록 변조되었을 때 차단되는지 확인: state.project.excelQuantityLinks[0].target = 99; let brokenExcelLinkRejected = false; try { EstBlockActions.resetChangeAction(state); } catch (e) { if (e.message.includes("Excel 수량 연결 무결성 오류")) { brokenExcelLinkRejected = true; } } // 동일성 위조 검증: PkID 1을 가리키지만 품명이 변조되어 당초 행과 동일성이 깨진 경우 차단 state.project.excelQuantityLinks[0].target = 1; const originalName = state.project.tables.EstMainDat[1].Name; state.project.tables.EstMainDat[1].Name = "변조된 다른항목명"; let identityMismatchRejected = false; try { EstBlockActions.resetChangeAction(state); } catch (e) { if (e.message.includes("동일성이 입증되지 않아 초기화를 차단")) { identityMismatchRejected = true; } } // 원래 이름으로 복구하여 정상 연결 상태 복원 state.project.tables.EstMainDat[1].Name = originalName; // 신규 삽입 행 제거하여 원래 상태 맞춤 state.project.tables.EstMainDat.shift(); // 4) JSON 직렬화 & 역직렬화 (저장 재열기 왕복) const savedJson = JSON.stringify(state.project); state.project = JSON.parse(savedJson); // 5) 설계변경 초기화 (resetChangeAction) 실행 EstBlockActions.resetChangeAction(state); // 신규 행이 삭제되고 원래 2개 행으로 완전 복원되었는지 확인 const rowCountRestored = state.project.tables.EstMainDat.length === 2; const restoredGN0 = state.project.tables.EstMainDat[0].GN; const restoredTC0 = state.project.tables.EstMainDat[0].TC; const restoredGN1 = state.project.tables.EstMainDat[1].GN; const restoredTC1 = state.project.tables.EstMainDat[1].TC; const baselineCleared = state.project.baseline === undefined; const modeRestored = state.project.options.Mode === "내역서"; // 설계변경 _ 필드는 삭제되었지만, 타 업무 필드(_origLinkIndex, _prevSCheck) 및 quantities는 완전 보존되었는지 검증 const hasBaselineUnderscore = Object.keys(state.project.tables.EstMainDat[0]).some(k => k === "GN_" || k === "TC_"); const preservedOrigLink = state.project.tables.EstMainDat[1]._origLinkIndex === 21; const preservedPrevSCheck = state.project.tables.EstMainDat[1]._prevSCheck === 1; const quantitiesPreserved = state.project.quantities.length === 1 && state.project.quantities[0].id === "Q1"; // 6) Undo (실행 취소) 검증: 이전 변경 상태로 롤백 가능 확인 const canUndo = EstApp.history.length > 0; const lastSnapshot = JSON.parse(EstApp.history.pop()); const undoGN0 = lastSnapshot.tables.EstMainDat[0].GN; // 변경 전의 150 확인 return { rejectBeforeBaseline, rowCountRestored, restoredGN0, restoredTC0, restoredGN1, restoredTC1, baselineCleared, modeRestored, hasBaselineUnderscore, preservedOrigLink, preservedPrevSCheck, quantitiesPreserved, canUndo, undoGN0, brokenQtyLinkRejected, brokenExcelLinkRejected, identityMismatchRejected }; })()""" result = run_js(code) assert result["rejectBeforeBaseline"] is True assert result["rowCountRestored"] is True assert result["restoredGN0"] == 100 assert result["restoredTC0"] == 500000 assert result["restoredGN1"] == 50 assert result["restoredTC1"] == 500000 assert result["baselineCleared"] is True assert result["modeRestored"] is True assert result["hasBaselineUnderscore"] is False assert result["preservedOrigLink"] is True assert result["preservedPrevSCheck"] is True assert result["quantitiesPreserved"] is True assert result["canUndo"] is True assert result["undoGN0"] == 150 assert result["brokenQtyLinkRejected"] is True assert result["brokenExcelLinkRejected"] is True assert result["identityMismatchRejected"] is True def test_reset_wonga_action(): code = """(() => { const p = EstNative.empty(); p.tables.Wonga = [ { Gubun: "01", Name: "순공사비", LinkIndex: 54, CalcIndex: 0, Cost: 1000000, Cost_: 1000000 }, { Gubun: "02", Name: "일반관리비", LinkIndex: 0, CalcIndex: 0, VSusic: "[순공사비] * 0.05", VSusic_: "[순공사비] * 0.05", Cost: 50000, Cost_: 50000 }, { Gubun: "03", Name: "이윤", LinkIndex: 0, CalcIndex: 0, VSusic: "([순공사비] + [일반관리비]) * 0.1", VSusic_: "([순공사비] + [일반관리비]) * 0.1", Cost: 105000, Cost_: 105000 }, { Gubun: "04", Name: "부가가치세", LinkIndex: 0, CalcIndex: 0, VSusic: "([순공사비] + [일반관리비] + [이윤]) * 0.1", VSusic_: "([순공사비] + [일반관리비] + [이윤]) * 0.1", Cost: 115500, Cost_: 115500 }, { Gubun: "05", Name: "총도급공사비", LinkIndex: 55, CalcIndex: 0, VSusic: "[순공사비] + [일반관리비] + [이윤] + [부가가치세]", Cost: 1270500, Cost_: 1270500 } ]; const state = { project: p, view: "Wonga", selection: new Set([4]), notice: "" }; globalThis.confirm = () => true; globalThis.EstApp = { state, history: [], change(fn) { this.history.push(JSON.stringify(state.project)); fn(); const recalc = EstWonga.calculate(p.tables.Wonga, p.options, {}); p.tables.Wonga = recalc.rows; }, render() {} }; // 1) 1,250,000원으로 공사비역산 실행 (TotalFix = true, 이윤 및 부가세 수렴 변경) EstWonga.alignTotal(p.tables.Wonga, 1250000, 3, p.options, {}); p.options.TotalFix = true; const alignedTotal = p.tables.Wonga[4].Cost; const alignedProfit = p.tables.Wonga[2].Cost; // 2) JSON 직렬화 & 역직렬화 (저장 재열기 왕복) const savedJson = JSON.stringify(p); state.project = JSON.parse(savedJson); // 3) 원가계산서 항목 초기화 (resetWongaAction) 실행 EstBlockActions.resetWongaAction(state); const restoredTotal = state.project.tables.Wonga[4].Cost; const restoredProfit = state.project.tables.Wonga[2].Cost; const totalFixCleared = state.project.options.TotalFix === false; // 4) handle("reset-wonga") 연동 검증 // 다시 alignTotal 후 handle로 초기화 EstWonga.alignTotal(state.project.tables.Wonga, 1240000, 3, state.project.options, {}); state.project.options.TotalFix = true; const handled = EstBlockActions.handle("reset-wonga", state); const handledTotal = state.project.tables.Wonga[4].Cost; return { alignedTotal, alignedProfit, restoredTotal, restoredProfit, totalFixCleared, handled, handledTotal }; })()""" result = run_js(code) assert result["alignedTotal"] == 1250000 assert result["alignedProfit"] == 86364 assert result["restoredTotal"] == 1270500 assert result["restoredProfit"] == 105000 assert result["totalFixCleared"] is True assert result["handled"] is True assert result["handledTotal"] == 1270500 def test_reset_wonga_error_preserves_rows_and_options(): result = run_js("""(() => { const p = EstNative.empty(); p.options.TotalFix = true; p.tables.Wonga = [ {Gubun:'A', Name:'A', VSusic:'1', VSusic_:'[A]', Cost:10, Cost_:20} ]; const state = {project:p, view:'Wonga', selection:new Set()}; globalThis.confirm = () => true; globalThis.EstApp = {change(fn) { fn(); }}; const before = JSON.stringify(p); let rejected = false; try { EstBlockActions.resetWongaAction(state); } catch { rejected = true; } return {rejected, unchanged: JSON.stringify(p) === before}; })()""") assert result["rejected"] assert result["unchanged"] def test_reset_progress_inputs_action(): code = """(() => { const p = EstNative.empty(); p.tables.EstMainDat = [ { Code: "A001", Name: "토공사", GN: 100, TU: 5000, TC: 500000, NC: 200000, GC: 200000, JC: 100000, SC: 0, Level: 0, LinkIndex: 0, CalcIndex: 0 } ]; p.progress = [ { id: "R1", name: "1회 기성", locked: false, approved: false, inputs: { "A001": { GumMode: "quantity", Gum: 40, SaMode: "quantity", Sa: 35 } } }, { id: "R2", name: "2회 기성", locked: true, approved: false, inputs: { "A001": { GumMode: "quantity", Gum: 30 } } } ]; const state = { project: p, view: "progress", roundId: "R1", selection: new Set([0]), notice: "" }; globalThis.confirm = () => true; globalThis.EstApp = { state, history: [], change(fn) { this.history.push(JSON.stringify(state.project)); fn(); }, render() {} }; // 1) 미확정 R1 회차의 입력 존재 확인 const hasInputsBefore = Object.keys(state.project.progress[0].inputs).length > 0; // 2) JSON 직렬화 & 역직렬화 (저장 재열기 왕복) const savedJson = JSON.stringify(state.project); state.project = JSON.parse(savedJson); // 3) 회차 입력 초기화 실행 EstBlockActions.resetProgressInputsAction(state); const inputsClearedAfter = Object.keys(state.project.progress[0].inputs).length === 0; // 4) 확정된 R2 회차 초기화 시도 시 안전 차단 확인 state.roundId = "R2"; let lockedRoundRejected = false; try { EstBlockActions.resetProgressInputsAction(state); } catch { lockedRoundRejected = true; } const lockedInputsPreserved = Object.keys(state.project.progress[1].inputs).length > 0; state.roundId = "missing"; state.project.progress[0].inputs = { "A001": { Gum: 42 } }; let missingRoundRejected = false; try { EstBlockActions.resetProgressInputsAction(state); } catch { missingRoundRejected = true; } const missingRoundPreserved = state.project.progress[0].inputs.A001.Gum === 42; // 5) handle("reset-round-inputs") 연동 검증 state.roundId = "R1"; state.project.progress[0].inputs = { "A001": { Gum: 50 } }; const handled = EstBlockActions.handle("reset-round-inputs", state); const handledInputsCleared = Object.keys(state.project.progress[0].inputs).length === 0; // 6) 실행 취소(Undo) 가능 여부 확인 const canUndo = EstApp.history.length > 0; return { hasInputsBefore, inputsClearedAfter, lockedRoundRejected, lockedInputsPreserved, missingRoundRejected, missingRoundPreserved, handled, handledInputsCleared, canUndo }; })()""" result = run_js(code) assert result["hasInputsBefore"] is True assert result["inputsClearedAfter"] is True assert result["lockedRoundRejected"] is True assert result["lockedInputsPreserved"] is True assert result["missingRoundRejected"] is True assert result["missingRoundPreserved"] is True assert result["handled"] is True assert result["handledInputsCleared"] is True assert result["canUndo"] is True def test_reset_annual_action(): code = """(() => { const p = EstNative.empty(); p.tables.EstMainDat = [ { Code: "A001", Name: "토공사", GN: 100, TU: 5000, TC: 500000, NC: 200000, GC: 200000, JC: 100000, SC: 0, Level: 0, LinkIndex: 0, CalcIndex: 0 } ]; p.tables.Wonga = [ { Gubun: "01", Name: "순공사비", LinkIndex: 54, CalcIndex: 0, Cost: 500000 }, { Gubun: "05", Name: "총도급공사비", LinkIndex: 55, CalcIndex: 0, Cost: 550000 } ]; p.annual = [ { name: "1차", formula: "[기준]*0.4" }, { name: "2차", formula: "[기준]-[1차]" } ]; const state = { project: p, view: "annual", selection: new Set([0]), notice: "" }; globalThis.confirm = () => true; globalThis.EstApp = { state, history: [], change(fn) { this.history.push(JSON.stringify(state.project)); fn(); }, render() {} }; // 1) 연차 계산 수행 및 정상 차수 확인 const calcBefore = EstBusiness.annual(p, false); const tc1st = calcBefore.results[0]?.project.calculation.direct.TC; // 200,000원 (40%) const tc2nd = calcBefore.results[1]?.project.calculation.direct.TC; // 300,000원 (60%) // 2) JSON 직렬화 & 역직렬화 (저장 재열기 왕복) const savedJson = JSON.stringify(state.project); state.project = JSON.parse(savedJson); // 3) 연차 초기화 실행 EstBlockActions.resetAnnualAction(state); const annualCleared = state.project.annual.length === 0; // 4) 기존 내역서 및 원가계산서 데이터 온전 보존 확인 const estDatPreserved = state.project.tables.EstMainDat.length === 1 && state.project.tables.EstMainDat[0].GN === 100; const wongaPreserved = state.project.tables.Wonga.length === 2 && state.project.tables.Wonga[0].Cost === 500000; // 5) handle("reset-annual") 연동 검증 state.project.annual = [{ name: "1차", formula: "[기준]*0.5" }]; const handled = EstBlockActions.handle("reset-annual", state); const handledCleared = state.project.annual.length === 0; // 6) 빈 annual 상태에서 재호출 시 안전 거부 확인 let emptyRejected = false; try { EstBlockActions.resetAnnualAction(state); } catch { emptyRejected = true; } // 7) 실행 취소(Undo) 가능 여부 확인 const canUndo = EstApp.history.length > 0; return { tc1st, tc2nd, annualCleared, estDatPreserved, wongaPreserved, handled, handledCleared, emptyRejected, canUndo }; })()""" result = run_js(code) assert result["tc1st"] == 200000 assert result["tc2nd"] == 300000 assert result["annualCleared"] is True assert result["estDatPreserved"] is True assert result["wongaPreserved"] is True assert result["handled"] is True assert result["handledCleared"] is True assert result["emptyRejected"] is True assert result["canUndo"] is True def test_reset_escalation_action(): code = """(() => { const p = EstNative.empty(); p.tables.EstMainDat = [ { Code: "A001", Name: "토공사", GN: 100, TU: 5000, TC: 500000, Level: 0, LinkIndex: 0, CalcIndex: 0, EscPlanned: 20, EscActual: 15, EscProgress: 25, EscManualQuantity: 10, EscConstruction: 5, EscPS: 0, EscBefore: 0, EscAuto: false } ]; p.options.EscDesignTotal = 520000; const state = { project: p, view: "escalation", selection: new Set([0]), notice: "" }; globalThis.confirm = () => true; globalThis.EstApp = { state, history: [], change(fn) { this.history.push(JSON.stringify(state.project)); fn(); }, render() {} }; // 1) 초기화 전 입력 상태 확인 const plannedBefore = state.project.tables.EstMainDat[0].EscPlanned; const optBefore = state.project.options.EscDesignTotal; // 2) JSON 직렬화 & 역직렬화 (저장 재열기 왕복) const savedJson = JSON.stringify(state.project); state.project = JSON.parse(savedJson); // 3) 물가변동 입력 초기화 실행 EstBlockActions.resetEscalationAction(state); const r = state.project.tables.EstMainDat[0]; const escFieldsCleared = r.EscPlanned === undefined && r.EscActual === undefined && r.EscProgress === undefined && r.EscManualQuantity === undefined && r.EscConstruction === undefined && r.EscAuto === undefined; const optCleared = state.project.options.EscDesignTotal === undefined; // 기본 본선 데이터(수량, 단가, 금액)는 안전하게 보존되었는지 검증 const mainDatPreserved = r.GN === 100 && r.TU === 5000 && r.TC === 500000; // 4) handle("reset-escalation") 연동 검증 r.EscPlanned = 30; state.project.options.EscDesignTotal = 600000; const handled = EstBlockActions.handle("reset-escalation", state); const handledPlannedCleared = state.project.tables.EstMainDat[0].EscPlanned === undefined; const handledOptCleared = state.project.options.EscDesignTotal === undefined; // 5) 실행 취소(Undo) 가능 여부 확인 const canUndo = EstApp.history.length > 0; return { plannedBefore, optBefore, escFieldsCleared, optCleared, mainDatPreserved, handled, handledPlannedCleared, handledOptCleared, canUndo }; })()""" result = run_js(code) assert result["plannedBefore"] == 20 assert result["optBefore"] == 520000 assert result["escFieldsCleared"] is True assert result["optCleared"] is True assert result["mainDatPreserved"] is True assert result["handled"] is True assert result["handledPlannedCleared"] is True assert result["handledOptCleared"] is True assert result["canUndo"] is True