729 lines
27 KiB
Python
729 lines
27 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-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-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: 50000, LinkIndex: 0, CalcIndex: 0 },
|
|
{ Gubun: "C", Name: "이윤", Cost: 78564, LinkIndex: 0, CalcIndex: 0 },
|
|
{ Gubun: "D", Name: "부가가치세", Cost: 112856, LinkIndex: 0, CalcIndex: 0 },
|
|
{ Gubun: "E", Name: "총도급공사비", Cost: 1241420, LinkIndex: 55, CalcIndex: 0 }
|
|
];
|
|
|
|
const state = {
|
|
project: p,
|
|
view: "Wonga",
|
|
selection: new Set([4]),
|
|
notice: ""
|
|
};
|
|
globalThis.EstApp = {
|
|
state,
|
|
change(fn) { fn(); },
|
|
render() {}
|
|
};
|
|
|
|
// 1) 1,000원 단위(unitDigits: 3) 절하 맞춤 테스트
|
|
const alignRes1000 = EstWonga.alignTotal(p.tables.Wonga, 1241420, 3);
|
|
const total1000 = p.tables.Wonga[alignRes1000.targetIndex].Cost;
|
|
|
|
// 2) 10,000원 단위(unitDigits: 4) 절하 맞춤 테스트
|
|
const alignRes10000 = EstWonga.alignTotal(p.tables.Wonga, 1241420, 4);
|
|
const total10000 = p.tables.Wonga[alignRes10000.targetIndex].Cost;
|
|
|
|
// 3) LinkIndex 55 부재 시 거부 확인
|
|
let noTargetRejected = false;
|
|
try {
|
|
EstWonga.alignTotal([{ Name: "항목1", Cost: 100 }], 1000, 3);
|
|
} catch {
|
|
noTargetRejected = true;
|
|
}
|
|
|
|
// 4) 이윤 행 부재 시 거부 확인
|
|
let noProfitRejected = false;
|
|
try {
|
|
EstWonga.alignTotal([{ Name: "총도급", Cost: 100, LinkIndex: 55 }], 1000, 3);
|
|
} catch {
|
|
noProfitRejected = true;
|
|
}
|
|
|
|
return {
|
|
total1000,
|
|
total10000,
|
|
roundedTarget1000: alignRes1000.roundedTarget,
|
|
roundedTarget10000: alignRes10000.roundedTarget,
|
|
profitIndex: alignRes1000.profitIndex,
|
|
noTargetRejected,
|
|
noProfitRejected
|
|
};
|
|
})()"""
|
|
|
|
result = run_js(code)
|
|
assert result["total1000"] == 1241000
|
|
assert result["roundedTarget1000"] == 1241000
|
|
assert result["total10000"] == 1240000
|
|
assert result["roundedTarget10000"] == 1240000
|
|
assert result["profitIndex"] == 2
|
|
assert result["noTargetRejected"] is True
|
|
assert result["noProfitRejected"] is True
|
|
|
|
|