Files
Aislo/resources/tester/test_est_row_actions.py
T

1475 lines
55 KiB
Python

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