1475 lines
55 KiB
Python
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
|