Merge remote-tracking branches 'origin/CODEX' and 'origin/main_laptop_1' into sub_laptop_3

This commit is contained in:
2026-11-08 14:23:15 +09:00
62 changed files with 11590 additions and 360 deletions
+12 -1
View File
@@ -2,7 +2,7 @@ const fs = require("node:fs");
const path = require("node:path");
const vm = require("node:vm");
const base = path.resolve(__dirname, "../../tools/est-test");
const context = vm.createContext({ structuredClone, console });
const context = vm.createContext({ structuredClone, console, Blob });
for (const file of [
"native-math.js",
"native-formula.js",
@@ -15,12 +15,23 @@ for (const file of [
"native-escalation.js",
"native-progress.js",
"native-business.js",
"native-review.js",
"native-resources.js",
"native-schedule.js",
"native-issues.js",
"native-haljung.js",
"native-vat.js",
"native-attachments.js",
"native-standard.js",
"native-estimate.js",
"native-basic-prices.js",
"native-model.js",
"native-c3r.js",
"native-exchange.js",
"native-verify.js",
"native-extra.js",
"native-business-actions.js",
"native-forms.js",
]) {
vm.runInContext(fs.readFileSync(path.join(base, file), "utf8"), context, { filename: file });
}
@@ -39,3 +39,29 @@ def test_annual_copy_rejects_invalid_or_forward_references():
})()""",
)
assert "참조 항목 없음" in result
def test_annual_rejects_names_that_shadow_contract_references():
result = run(
"math",
"""(()=>{
const p=EstNative.empty();p.tables.EstMainDat=[{Code:'R',GN:1,GC:100,TC:100,CalcIndex:1}];
return ['기준','계약','계약수량','first'].map(name=>{
p.annual=[{name,formula:'[기준]*0.5'},{name:'next',formula:'[기준]-['+name+']'}];
return EstBusiness.annual(p,false);
});
})()""",
)
assert all(item["errors"] for item in result[:3])
assert result[3]["errors"] == []
assert result[3]["results"][1]["project"]["tables"]["EstMainDat"][0]["TC"] == 50
def test_new_annual_name_does_not_reuse_existing_or_referenced_names():
result = run(
"math",
"""[EstBusiness.newAnnualName([{name:'2차',formula:'[기준]-[1차]'}]),
EstBusiness.newAnnualName([{name:'2차',formula:'[기준]'}]),
EstBusiness.newAnnualName([])]""",
)
assert result == ["3차", "1차", "1차"]
+124
View File
@@ -0,0 +1,124 @@
from test_est_rea_parity import run
import subprocess
from pathlib import Path
def test_attachment_size_prediction_includes_utf8_and_base64_overhead():
result = run(
"math",
"""(()=>{
const p=EstNative.empty();p.name='한글';const file={name:'파일.bin',type:'',size:4};
const expected={...p,id:'0'.repeat(24),attachments:[{id:'0'.repeat(36),...file,data:'AAECAw=='}]};
return {predicted:EstAttachments.requiredBytes(p,[file]),actual:new Blob([JSON.stringify(expected)]).size,
large:EstAttachments.requiredBytes(p,[{name:'large',type:'',size:210000000}]),count:p.attachments.length};
})()""",
)
assert result["predicted"] == result["actual"]
assert result["large"] > 256 * 1024 * 1024
assert result["count"] == 0
def test_attachment_validation_keeps_valid_data_and_rejects_malformed_content():
result = run(
"math",
"""(()=>{
const p=EstNative.empty();p.attachments=[{id:'one',name:'test.bin',type:'application/octet-stream',size:4,data:'AAECAw=='}];
const valid=EstModel.validate(p).attachments[0];let rejected=0;
for(const item of [{...valid,data:'AAECAw=A'},{...valid,size:5},{...valid,id:''},{...valid,name:''}]) {
const q=EstNative.empty();q.attachments=[item];try{EstModel.validate(q)}catch{rejected++}
}
const dup=EstNative.empty();dup.attachments=[valid,valid];try{EstModel.validate(dup)}catch{rejected++}
const empty=EstNative.empty();empty.attachments=[{id:'zero',name:'empty',type:'',size:0,data:''}];
return {valid,rejected,empty:EstModel.validate(empty).attachments[0].size};
})()""",
)
assert result["valid"]["data"] == "AAECAw=="
assert result["rejected"] == 5
assert result["empty"] == 0
def test_estx_export_rejects_web_attachments_instead_of_dropping_them():
script = """try{require('./tools/est-test/export-estx.cjs').exportEstx({originalArchive:'AA==',attachments:[{}]});process.exit(1)}catch(e){process.stdout.write(e.message)}"""
result = subprocess.run(
["node", "-e", script],
cwd=Path(__file__).resolve().parents[2],
capture_output=True,
check=True,
timeout=10,
)
assert "JSON" in result.stdout.decode("utf-8")
def test_estx_export_rejects_web_excel_links_instead_of_dropping_them():
script = """try{require('./tools/est-test/export-estx.cjs').exportEstx({originalArchive:'AA==',excelQuantityLinks:[{}]});process.exit(1)}catch(e){process.stdout.write(e.message)}"""
result = subprocess.run(
["node", "-e", script],
cwd=Path(__file__).resolve().parents[2],
capture_output=True,
check=True,
timeout=10,
)
assert "JSON" in result.stdout.decode("utf-8")
def test_estx_export_rejects_web_schedule_additions_or_changes_instead_of_dropping_them():
# 1) 일반 항목 추가
script_add = """try{require('./tools/est-test/export-estx.cjs').exportEstx({originalArchive:'AA==',schedule:[{id:'1',Code:'A',Name:'새공정',start:'',end:'',weight:0,progress:0}]});process.exit(1)}catch(e){process.stdout.write(e.message)}"""
result_add = subprocess.run(
["node", "-e", script_add],
cwd=Path(__file__).resolve().parents[2],
capture_output=True,
check=True,
timeout=10,
)
assert "JSON" in result_add.stdout.decode("utf-8")
assert "예정공정표" in result_add.stdout.decode("utf-8")
# 2) 빈 문자열 및 0값만 있는 행도 차단
script_empty = """try{require('./tools/est-test/export-estx.cjs').exportEstx({originalArchive:'AA==',schedule:[{id:'2',Code:'',Name:'',start:'',end:'',weight:0,progress:0}]});process.exit(1)}catch(e){process.stdout.write(e.message)}"""
result_empty = subprocess.run(
["node", "-e", script_empty],
cwd=Path(__file__).resolve().parents[2],
capture_output=True,
check=True,
timeout=10,
)
assert "JSON" in result_empty.stdout.decode("utf-8")
assert "예정공정표" in result_empty.stdout.decode("utf-8")
# 3) 다른 필드만 있거나 빈 객체 행도 차단
script_blank = """try{require('./tools/est-test/export-estx.cjs').exportEstx({originalArchive:'AA==',schedule:[{custom:'field'}]});process.exit(1)}catch(e){process.stdout.write(e.message)}"""
result_blank = subprocess.run(
["node", "-e", script_blank],
cwd=Path(__file__).resolve().parents[2],
capture_output=True,
check=True,
timeout=10,
)
assert "JSON" in result_blank.stdout.decode("utf-8")
assert "예정공정표" in result_blank.stdout.decode("utf-8")
def test_estx_export_rejects_web_custom_documents_instead_of_dropping_them():
script_doc = """try{require('./tools/est-test/export-estx.cjs').exportEstx({originalArchive:'AA==',documents:{'173':'주요심사내용'}});process.exit(1)}catch(e){process.stdout.write(e.message)}"""
result_doc = subprocess.run(
["node", "-e", script_doc],
cwd=Path(__file__).resolve().parents[2],
capture_output=True,
check=True,
timeout=10,
)
assert "JSON" in result_doc.stdout.decode("utf-8")
assert "서식 문서" in result_doc.stdout.decode("utf-8")
# 공백 문자열(" ")도 사용자 입력으로 보존하기 위해 ESTX 저장 차단
script_space = """try{require('./tools/est-test/export-estx.cjs').exportEstx({originalArchive:'AA==',documents:{'173':' '}});process.exit(1)}catch(e){process.stdout.write(e.message)}"""
result_space = subprocess.run(
["node", "-e", script_space],
cwd=Path(__file__).resolve().parents[2],
capture_output=True,
check=True,
timeout=10,
)
assert "JSON" in result_space.stdout.decode("utf-8")
assert "서식 문서" in result_space.stdout.decode("utf-8")
+364
View File
@@ -0,0 +1,364 @@
from test_est_rea_parity import run
def test_basic_prices_changes_for_material_and_labor():
result = run(
"math",
"""(()=>{
const p = EstNative.empty();
p.tables.EstMainDat = [
{ PkID: 1, Code: 'M1', Name: 'changed mat', LinkIndex: 5, GN: 2, TC: 200 },
{ PkID: 2, Code: 'L1', Name: 'changed lab', LinkIndex: 6, GN: 1, TC: 50 },
{ PkID: 4, Code: 'M2', Name: 'new mat', LinkIndex: 5, GN: 1, TC: 40 },
{ PkID: 5, Code: 'L2', Name: 'new lab', LinkIndex: 6, GN: 1, TC: 80 },
];
p.baseline = {
tables: {
EstMainDat: [
{ PkID: 1, Code: 'M1', Name: 'changed mat', LinkIndex: 5, GN: 1, TC: 100 },
{ PkID: 2, Code: 'L1', Name: 'changed lab', LinkIndex: 6, GN: 1, TC: 30 },
{ PkID: 3, Code: 'M3', Name: 'deleted mat', LinkIndex: 5, GN: 3, TC: 300 },
{ PkID: 6, Code: 'L3', Name: 'deleted lab', LinkIndex: 6, GN: 2, TC: 120 },
]
}
};
return {
matChanges: EstBasicPrices.changes(p, 'material'),
labChanges: EstBasicPrices.changes(p, 'labor'),
matPrint: EstForms.data('material-change', p),
labPrint: EstForms.data('labor-change', p),
};
})()""",
)
mat_rows = {row["Code"]: row for row in result["matChanges"]}
assert set(mat_rows) == {"M1", "M2", "M3"}
assert mat_rows["M1"]["difference"] == 100
assert mat_rows["M3"]["status"] == "삭제"
assert mat_rows["M2"]["status"] == "신규"
assert result["matPrint"]["rows"] == result["matChanges"]
lab_rows = {row["Code"]: row for row in result["labChanges"]}
assert set(lab_rows) == {"L1", "L2", "L3"}
assert lab_rows["L1"]["difference"] == 20
assert lab_rows["L3"]["status"] == "삭제"
assert lab_rows["L2"]["status"] == "신규"
assert result["labPrint"]["rows"] == result["labChanges"]
def test_basic_prices_usage_reports_applied_and_source_prices_without_mutation():
result = run(
"math",
"""(()=>{
const p = EstNative.empty();
p.tables.StuffcDat = [
{ Code: 'M1', Name: 'mat 1', ValueIndex: 0, Cost1: 1200 },
{ Code: 'M2', Name: 'mat 2', ValueIndex: 0, Cost1: 500 },
{ Code: 'M2', Name: 'mat 2 dup', ValueIndex: 0, Cost1: 600 },
];
p.tables.LaborcDat = [
{ Code: 'L1', Name: 'lab 1', ValueIndex: 0, Cost1: 200000 },
];
p.tables.EstMainDat = [
{ Code: 'M1', Name: 'mat 1', LinkIndex: 5, GN: 2, TU: 1000, GU: 1000, GC: 2000, TC: 2000 },
{ Code: 'M2', Name: 'dup mat', LinkIndex: 5, GN: 1, TU: 450, GU: 450, GC: 450, TC: 450 },
{ Code: 'M3', Name: 'missing mat', LinkIndex: 5, GN: 1, TU: 200, GU: 200, GC: 200, TC: 200 },
{ Code: 'L1', Name: 'lab 1', LinkIndex: 6, GN: 1, TU: 180000, NU: 180000, NC: 180000, TC: 180000 },
];
const before = JSON.stringify(p);
const matUsage = EstBasicPrices.usage(p, 'material');
const labUsage = EstBasicPrices.usage(p, 'labor');
const matPrint = EstForms.data('material-usage', p);
const labPrint = EstForms.data('labor-usage', p);
return { matUsage, labUsage, matPrint, labPrint, same: before === JSON.stringify(p) };
})()""",
)
assert result["same"]
assert [row["status"] for row in result["matUsage"]] == [
"연결됨",
"코드 중복",
"연결 없음",
]
assert result["matUsage"][0]["sourcePrice"] == 1200
assert result["matUsage"][0]["appliedPrice"] == 1000
assert result["matUsage"][0]["difference"] == -200
assert result["matUsage"][1]["sourcePrice"] is None
assert result["matPrint"]["rows"] == result["matUsage"]
assert len(result["labUsage"]) == 1
assert result["labUsage"][0]["status"] == "연결됨"
assert result["labUsage"][0]["sourcePrice"] == 200000
assert result["labUsage"][0]["appliedPrice"] == 180000
assert result["labUsage"][0]["difference"] == -20000
assert result["labPrint"]["rows"] == result["labUsage"]
def test_basic_prices_apply_row_and_all_prices_with_autocalc_modes():
result = run(
"math",
"""(()=>{
const p = EstNative.empty();
p.options.SourceRefresh = false;
p.options.AutoCalc = false; // 수동계산 모드
p.tables.StuffcDat = [
{ Code: 'M1', Name: 'mat 1', ValueIndex: 0, Cost1: 1500 },
{ Code: 'M2', Name: 'mat 2', ValueIndex: 0, Cost1: 3000 },
];
p.tables.LaborcDat = [
{ Code: 'L1', Name: 'lab 1', ValueIndex: 0, Cost1: 220000 },
];
p.tables.EstMainDat = [
{ Code: 'M1', Name: 'item mat 1', LinkIndex: 5, GN: 2, TU: 1000, GU: 1000, GC: 2000, TC: 2000, SCheck: 2 }, // SCheck=2: 노무비 몰아주기
{ Code: 'M2', Name: 'item mat 2', LinkIndex: 5, GN: 3, TU: 2000, GU: 2000, GC: 6000, TC: 6000 },
{ Code: 'L1', Name: 'item lab 1', LinkIndex: 6, GN: 2, TU: 200000, NU: 200000, NC: 400000, TC: 400000 },
];
// 1. 수동계산 상태에서 자재 행 0 단가 적용 (SCheck=2 보존 검증)
// AutoCalc=false이므로 단가는 NU=1500, TU=1500으로 갱신되나 기존 금액(GC=2000, TC=2000) 보존
EstBasicPrices.applyRowPrice(p, 0, 'material');
const afterRow0 = {
TU: p.tables.EstMainDat[0].TU,
GU: p.tables.EstMainDat[0].GU,
NU: p.tables.EstMainDat[0].NU,
GC: p.tables.EstMainDat[0].GC,
NC: p.tables.EstMainDat[0].NC,
TC: p.tables.EstMainDat[0].TC,
SCheck: p.tables.EstMainDat[0].SCheck,
};
// 2. 자재 일괄 적용: 남은 행 1 적용 (단가 3000 갱신, 수동계산 금액 GC=6000 보존)
const matCount = EstBasicPrices.applyAllPrices(p, 'material');
const afterMatAll = {
TU: p.tables.EstMainDat[1].TU,
GU: p.tables.EstMainDat[1].GU,
GC: p.tables.EstMainDat[1].GC,
TC: p.tables.EstMainDat[1].TC,
};
// 3. 노임 단가 적용: 행 2 적용 (단가 220000 갱신, 금액 NC=400000 보존)
const labCount = EstBasicPrices.applyAllPrices(p, 'labor');
const afterLab = {
TU: p.tables.EstMainDat[2].TU,
NU: p.tables.EstMainDat[2].NU,
NC: p.tables.EstMainDat[2].NC,
TC: p.tables.EstMainDat[2].TC,
};
// 4. AutoCalc=true로 전환 후 전체 재계산: GN(수량)을 반영한 금액으로 일괄 전파
p.options.AutoCalc = true;
const calculated = EstNative.calculate(p);
const afterRecalc = {
row0_NC: calculated.tables.EstMainDat[0].NC,
row0_TC: calculated.tables.EstMainDat[0].TC,
row1_GC: calculated.tables.EstMainDat[1].GC,
row1_TC: calculated.tables.EstMainDat[1].TC,
row2_NC: calculated.tables.EstMainDat[2].NC,
row2_TC: calculated.tables.EstMainDat[2].TC,
};
return { afterRow0, matCount, afterMatAll, labCount, afterLab, afterRecalc };
})()""",
)
# 행 0: SCheck=2 보존되어 NU=1500, GU=0, 금액은 수동계산 유지(GC=2000, NC=0, TC=2000)
assert result["afterRow0"]["TU"] == 1500
assert result["afterRow0"]["NU"] == 1500
assert result["afterRow0"]["GU"] == 0
assert result["afterRow0"]["GC"] == 2000
assert result["afterRow0"]["TC"] == 2000
assert result["afterRow0"]["SCheck"] == 2
# 행 1: 단가 3000, 수동 금액 6000 보존
assert result["matCount"] == 1
assert result["afterMatAll"]["TU"] == 3000
assert result["afterMatAll"]["GU"] == 3000
assert result["afterMatAll"]["GC"] == 6000
assert result["afterMatAll"]["TC"] == 6000
# 행 2: 단가 220000, 수동 금액 400000 보존
assert result["labCount"] == 1
assert result["afterLab"]["TU"] == 220000
assert result["afterLab"]["NU"] == 220000
assert result["afterLab"]["NC"] == 400000
assert result["afterLab"]["TC"] == 400000
# 전체 재계산 후: GN(수량) 반영 정상 산출
# 행 0: GN=2 -> NC=3000, TC=3000
assert result["afterRecalc"]["row0_NC"] == 3000
assert result["afterRecalc"]["row0_TC"] == 3000
# 행 1: GN=3 -> GC=9000, TC=9000
assert result["afterRecalc"]["row1_GC"] == 9000
assert result["afterRecalc"]["row1_TC"] == 9000
# 행 2: GN=2 -> NC=440000, TC=440000
assert result["afterRecalc"]["row2_NC"] == 440000
assert result["afterRecalc"]["row2_TC"] == 440000
def test_edge_basic_prices_material_and_labor_workflow_when_server_running():
from playwright.sync_api import sync_playwright
with sync_playwright() as p:
browser = p.chromium.launch(channel="msedge", headless=True)
page = browser.new_page()
try:
page.goto("http://100.65.141.15:5175", timeout=5000)
page.wait_for_function("() => Boolean(EstApp.state.project)")
except Exception:
browser.close()
import pytest
pytest.skip("EST 5175 server not reachable")
# 1. 자재단가 및 노임단가 테스트 데이터 세팅
page.evaluate(
"""(() => {
const p = EstNative.empty();
p.options.SourceRefresh = false;
p.tables.StuffcDat = [
{ Code: 'M01', Name: '시멘트 포대', Size: '40kg', Unite: '포', ValueIndex: 0, Cost1: 6000 },
];
p.tables.LaborcDat = [
{ Code: 'L01', Name: '특별인부', Unite: '인', ValueIndex: 0, Cost1: 180000 },
];
p.tables.EstMainDat = [
{ PkID: 301, Code: 'M01', Name: '시멘트 포대', Size: '40kg', Unite: '포', LinkIndex: 5, GN: 10, TU: 5000, GU: 5000, GC: 50000, TC: 50000 },
{ PkID: 302, Code: 'L01', Name: '특별인부', Unite: '인', LinkIndex: 6, GN: 5, TU: 160000, NU: 160000, NC: 800000, TC: 800000 },
];
EstApp.setProject(p);
EstApp.state.view = 'material-usage';
EstApp.render();
})()"""
)
# 2. 자재단가 사용 내역 화면 확인
assert page.locator("#view-title:has-text('자재단가 사용 내역')").is_visible()
btn_mat_apply = page.locator("button[data-basic-apply-row='0']")
assert btn_mat_apply.is_visible()
btn_mat_apply.click()
# 자재 단가 6,000으로 적용 확인
assert page.evaluate("EstApp.state.project.tables.EstMainDat[0].TU") == 6000
assert page.evaluate("EstApp.state.project.tables.EstMainDat[0].GU") == 6000
# 3. 노임단가 사용 내역 화면으로 전환
page.evaluate(
"""(() => {
EstApp.state.view = 'labor-usage';
EstApp.render();
})()"""
)
assert page.locator("#view-title:has-text('노임단가 사용 내역')").is_visible()
btn_lab_apply = page.locator("button[data-basic-apply-all='labor']")
assert btn_lab_apply.is_visible()
btn_lab_apply.click()
# 노임 단가 180,000으로 일괄 적용 확인
assert page.evaluate("EstApp.state.project.tables.EstMainDat[1].TU") == 180000
assert page.evaluate("EstApp.state.project.tables.EstMainDat[1].NU") == 180000
# 4. Undo(실행 취소) 검증
page.locator(".toolbar button[data-action='undo']").click()
assert page.evaluate("EstApp.state.project.tables.EstMainDat[1].TU") == 160000
page.locator(".toolbar button[data-action='undo']").click()
assert page.evaluate("EstApp.state.project.tables.EstMainDat[0].TU") == 5000
# 5. 기계경비 사용 내역 화면 전환 및 개별/일괄 적용, Undo 검증
page.evaluate(
"""(() => {
EstApp.navigate('machine-usage');
})()"""
)
assert page.locator("#view-title:has-text('기계경비 사용 내역')").is_visible()
browser.close()
def test_machine_price_usage_changes_and_safety_rejection():
result = run(
"math",
"""(()=>{
const p = structuredClone(project);
// 신규 항목(TC_ null) 추가하여 비교 검증
p.tables.MachTitleDat.push({
Code: 'E_NEW_999',
Name: '신규 기계',
Size: '10Ton',
Unite: 'hr',
TC: 50000,
NC: 30000,
GC: 10000,
JC: 10000,
TC_: null,
NC_: null,
GC_: null,
JC_: null
});
const usage = EstMachine.usage(p);
const changes = EstMachine.changes(p);
const newItemUsage = usage.find(u => u.Code === 'E_NEW_999');
const newItemChange = changes.find(c => c.Code === 'E_NEW_999');
const excUsage = usage.find(u => u.Code === 'E0000000000');
// 파괴적인 당초값 덮어쓰기 적용 차단 검증
let blockedSingle = 0, singleErrMsg = '';
try {
EstMachine.applyRowPrice(p, 0);
} catch (e) {
blockedSingle++;
singleErrMsg = e.message;
}
let blockedAll = 0, allErrMsg = '';
try {
EstMachine.applyAllPrices(p);
} catch (e) {
blockedAll++;
allErrMsg = e.message;
}
// 호표 단가 및 단가산출 금액 불변 보존 확인
const t0Unchanged = (p.tables.MachTitleDat[0].TC === 103047);
const costRow0 = p.tables.CostMainDat.find(r => r.LinkIndex === 105 && r.Code === 'E0000000000' && r.TC > 0);
const costUnchanged = (costRow0.TC === 1682.7);
return {
usageCount: usage.length,
changesCount: changes.length,
newItemUsage,
newItemChange,
excUsage,
blockedSingle,
singleErrMsg,
blockedAll,
allErrMsg,
t0Unchanged,
costUnchanged
};
})()""",
)
assert result["usageCount"] == 9
assert result["changesCount"] == 9
# 신규 항목 TC_ null 안전 처리 검증
assert result["newItemUsage"]["originalPrice"] is None
assert result["newItemUsage"]["difference"] is None
assert result["newItemUsage"]["status"] == "신규(당초없음)"
assert result["newItemUsage"]["canApply"] is False
assert result["newItemChange"]["status"] == "신규"
# 기존 항목 당초/현재 비교 검증
assert result["excUsage"]["appliedPrice"] == 103047
assert result["excUsage"]["originalPrice"] == 62207
assert result["excUsage"]["difference"] == 40840
assert result["excUsage"]["status"] == "당초차이"
assert result["excUsage"]["canApply"] is False
# 파괴적인 apply 차단 및 데이터 보존 검증
assert result["blockedSingle"] == 1
assert "덮어쓰기 적용은 지원하지 않습니다" in result["singleErrMsg"]
assert result["blockedAll"] == 1
assert "덮어쓰기 적용은 지원하지 않습니다" in result["allErrMsg"]
assert result["t0Unchanged"] is True
assert result["costUnchanged"] is True
+181
View File
@@ -0,0 +1,181 @@
import os
from pathlib import Path
import pytest
from playwright.sync_api import sync_playwright
ROOT = Path(__file__).resolve().parents[2]
def test_edge_indexeddb_cache_and_tab_isolation_when_server_running():
with sync_playwright() as p:
browser = p.chromium.launch(channel="msedge", headless=True)
context = browser.new_context()
page1 = context.new_page()
try:
page1.goto("http://100.65.141.15:5175", timeout=5000)
except Exception:
pytest.skip("EST 5175 server not reachable")
page1.wait_for_function("() => Boolean(EstApp.state.project)")
# 1. 탭 1에서 프로젝트 이름 수정 및 비동기 캐시 완료 대기
page1.evaluate("""async () => {
EstApp.change(p => { p.name = '탭 1 프로젝트 수정'; });
await new Promise(r => setTimeout(r, 100));
}""")
# 2. 탭 1 새로고침 후 복원 확인
page1.reload()
page1.wait_for_function("() => Boolean(EstApp.state.project)")
name1 = page1.evaluate("EstApp.state.project.name")
assert name1 == "탭 1 프로젝트 수정"
# 3. 탭 2 독립 생성
page2 = context.new_page()
page2.goto("http://100.65.141.15:5175")
page2.wait_for_function("() => Boolean(EstApp.state.project)")
# 탭 2는 탭 1의 세션 스토리지를 공유하지 않고 독립적으로 시작
page2.evaluate("EstApp.change(p => { p.name = '탭 2 독자 프로젝트'; })")
name2 = page2.evaluate("EstApp.state.project.name")
assert name2 == "탭 2 독자 프로젝트"
# 4. 탭 1 다시 새로고침 시 탭 2에 의해 덮어써지지 않는지 확인
page1.reload()
page1.wait_for_function("() => Boolean(EstApp.state.project)")
assert page1.evaluate("EstApp.state.project.name") == "탭 1 프로젝트 수정"
browser.close()
def test_edge_indexeddb_large_main_estx_reload_restore_when_server_running():
sample = Path("C:/Dasansoft/Est Plus 2/Data/ProjectData/main.estx")
if not sample.exists():
sample = Path("resources/knowledge/original/원가계산/다산소프트/2026-07-main.estx")
if not sample.exists():
pytest.skip("main.estx sample not available")
with sync_playwright() as p:
browser = p.chromium.launch(channel="msedge", headless=True)
page = browser.new_page()
try:
page.goto("http://100.65.141.15:5175", timeout=5000)
except Exception:
pytest.skip("EST 5175 server not reachable")
page.wait_for_function("() => Boolean(EstApp.state.project)")
# 대용량 main.estx 업로드
page.locator("#open-file").set_input_files(str(sample))
page.wait_for_function(
"() => EstApp.state.project.tables.EstMainDat.length > 50", timeout=30000
)
# 수정 후 캐시 대기
page.evaluate("EstApp.change(p => { p.name = '대용량 main 수정본'; })")
page.wait_for_timeout(2000)
# 새로고침 후 복원 확인
page.reload()
page.wait_for_function("() => Boolean(EstApp.state.project)", timeout=30000)
restored_name = page.evaluate("EstApp.state.project.name")
restored_rows = page.evaluate("EstApp.state.project.tables?.EstMainDat?.length || 0")
assert restored_name == "대용량 main 수정본"
assert restored_rows > 50
# 대용량 캐시 저장 실패 시 안내 메시지 검증
notice = page.evaluate("""async () => {
const originalSave = EstApp.cache;
// 인위적으로 IndexedDB 에러 유발 모의
const db = await (new Promise(r => {
const req = indexedDB.open("est-independent-cache", 1);
req.onsuccess = () => r(req.result);
}));
// 읽기 전용 모드로 변경 시도하거나 sessionStorage 차단 모의
const res = await EstApp.cache("x".repeat(100));
return EstApp.state.notice;
}""")
assert isinstance(notice, str)
browser.close()
def test_edge_duplicate_tab_isolation_and_inheritance_when_server_running():
with sync_playwright() as p:
browser = p.chromium.launch(channel="msedge", headless=True)
context = browser.new_context()
page1 = context.new_page()
try:
page1.goto("http://100.65.141.15:5175", timeout=5000)
except Exception:
pytest.skip("EST 5175 server not reachable")
page1.wait_for_function("() => Boolean(EstApp.state.project)")
# 탭 1에서 이름 수정
page1.evaluate("""async () => {
EstApp.change(p => { p.name = '원래 탭 프로젝트'; });
await new Promise(r => setTimeout(r, 200));
}""")
tab1_id = page1.evaluate("sessionStorage.getItem('est-tab-instance-id')")
assert tab1_id == page1.evaluate("window.name")
# 브라우저의 "탭 복제(Duplicate Tab)" 시뮬레이션:
# Chromium 복제 탭은 이전 탭의 sessionStorage 전체를 복제받지만, window.name은 빈 문자열("")로 시작합니다.
tab1_data = page1.evaluate(f"sessionStorage.getItem('est-web-v2-working-{tab1_id}')")
page_dup = context.new_page()
# 1회성 init script: 최초 로드 시 복제 순간 모의 후 플래그로 1회만 동작하도록 제어
# 브라우저 실제 환경에서는 복제 순간 1회만 window.name이 빈 문자열이고 sessionStorage에 부모 데이터가 복제됨.
# page_dup.reload() 시 add_init_script가 다시 실행되어 window.name을 지우거나 부모 id로 되돌리는 현상을 방지하기 위해 sessionStorage 플래그 사용
page_dup.add_init_script(f"""
if (!sessionStorage.getItem('__test_cloned_injected')) {{
sessionStorage.setItem('__test_cloned_injected', '1');
sessionStorage.setItem('est-tab-instance-id', '{tab1_id}');
if ({"true" if tab1_data else "false"}) {{
sessionStorage.setItem('est-web-v2-working-{tab1_id}', {repr(tab1_data) if tab1_data else '""'});
}}
window.name = '';
}}
""")
page_dup.goto("http://100.65.141.15:5175")
page_dup.wait_for_function("() => Boolean(EstApp.state.project)")
# 1) 복제 탭은 부모 탭의 데이터('원래 탭 프로젝트')를 성공적으로 상속받았는지 확인
info = page_dup.evaluate("""() => {
return {
windowName: window.name,
sessionTab: sessionStorage.getItem('est-tab-instance-id'),
parentTabId: EstApp.parentTabId,
name: EstApp.state.project.name,
keys: Object.keys(sessionStorage)
};
}""")
print("DEBUG INFO:", info)
dup_name = info["name"]
assert dup_name == "원래 탭 프로젝트"
# 2) 복제 탭은 부모 탭과 다른 새 고유 인스턴스 ID를 발급받았는지 확인
dup_tab_id = page_dup.evaluate("sessionStorage.getItem('est-tab-instance-id')")
assert dup_tab_id != tab1_id
assert dup_tab_id == page_dup.evaluate("window.name")
# 3) 복제 탭에서 데이터를 독립적으로 수정했을 때 원래 탭에 영향을 주지 않는지 확인
page_dup.evaluate("""async () => {
EstApp.change(p => { p.name = '복제 탭에서만 변경'; });
await new Promise(r => setTimeout(r, 200));
}""")
# 원래 탭 1 새로고침 시 원래 데이터 유지 확인
page1.reload()
page1.wait_for_function("() => Boolean(EstApp.state.project)")
assert page1.evaluate("EstApp.state.project.name") == "원래 탭 프로젝트"
# 복제 탭 새로고침 시 복제 탭 변경 사항 유지 확인
page_dup.reload()
page_dup.wait_for_function("() => Boolean(EstApp.state.project)")
assert page_dup.evaluate("EstApp.state.project.name") == "복제 탭에서만 변경"
browser.close()
+353
View File
@@ -3,6 +3,72 @@
from test_est_rea_parity import run
def test_estimate_classification_refreshes_only_selected_source_and_preserves_manual_mode():
result = run(
"math",
"""(()=>{
const p=EstNative.empty();p.options.SourceRefresh=false;p.options.AutoCalc=false;
p.tables.StuffcDat=[{Code:'M',Name:'material',Cost1:100,ValueIndex:0},{Code:'N',Name:'other',Cost1:800,ValueIndex:0}];
p.tables.EstMainDat=[{PkID:1,Code:'M',Name:'material',LinkIndex:5,GN:2,GU:100,GC:200,JC:0},
{PkID:2,Code:'N',Name:'other',LinkIndex:5,GN:1,GU:5,GC:5},{LinkIndex:54}];
const row=p.tables.EstMainDat[0],other=JSON.stringify(p.tables.EstMainDat[1]);
EstBusinessActions.setEstimateClassification(p,row,0,3);
const manual={JU:row.JU,GU:row.GU,GC:row.GC,JC:row.JC};
const auto=EstNative.calculate(p).tables.EstMainDat[0];
EstBusinessActions.setEstimateClassification(p,row,0,0);
const restored={GU:row.GU,JU:row.JU};
const missing={PkID:3,Code:'missing',LinkIndex:5,SCheck:0,GU:7};p.tables.EstMainDat.push(missing);
EstBusinessActions.setEstimateClassification(p,missing,3,3);
let rejected=0;try{EstBusinessActions.setEstimateClassification(p,missing,3,0)}catch{rejected++}
EstBusinessActions.setEstimateClassification(p,row,0,3);
p.tables.StuffcDat.push({...p.tables.StuffcDat[0]});try{EstBusinessActions.setEstimateClassification(p,row,0,0)}catch{rejected++}
const mismatch={PkID:4,Code:'N',LinkIndex:5,SCheck:0,GU:5};p.tables.EstMainDat.push(mismatch);
EstBusinessActions.setEstimateClassification(p,mismatch,4,3);
try{EstBusinessActions.setEstimateClassification(p,mismatch,4,0)}catch{rejected++}
p.tables.StuffcDat.push({Code:'L',Cost1:1000000000000,ValueIndex:0});
const large={PkID:5,Code:'L',LinkIndex:5,SCheck:0,GU:999999999900};p.tables.EstMainDat.push(large);
EstBusinessActions.setEstimateClassification(p,large,5,3);
try{EstBusinessActions.setEstimateClassification(p,large,5,0)}catch{rejected++}
return {manual,auto:{GC:auto.GC,JC:auto.JC},restored,rejected,missing,mismatch,large,other:JSON.stringify(p.tables.EstMainDat[1])===other,flag:p.options.SourceRefresh};
})()""",
)
assert result["manual"] == {"JU": 100, "GU": 0, "GC": 200, "JC": 0}
assert result["auto"] == {"GC": 0, "JC": 200}
assert result["restored"] == {"GU": 100, "JU": 0}
assert result["rejected"] == 4
assert result["missing"]["SCheck"] == 3 and result["missing"]["JU"] == 7
assert result["mismatch"]["SCheck"] == 3 and result["mismatch"]["JU"] == 5
assert result["large"]["SCheck"] == 3 and result["large"]["JU"] == 999999999900
assert result["other"] and not result["flag"]
def test_head_classification_preserves_other_groups_and_rejects_unsafe_restoration():
result = run(
"math",
"""(()=>{
const p=EstNative.empty();p.options.SourceRefresh=false;p.options.AutoCalc=false;
p.tables.StuffcDat=[{Code:'M',Name:'material',Cost1:100,ValueIndex:0},{Code:'N',Name:'other',Cost1:800,ValueIndex:0}];
p.tables.HeadMainDat=[{Index:1,Code:'H1',Name:'first'},{Index:0,Code:'M',LinkIndex:5,GN:2,GU:100,GC:200,JC:0},
{Index:2,Code:'H2',Name:'second'},{Index:0,Code:'N',LinkIndex:5,GN:1,GU:5,GC:5}];
const row=p.tables.HeadMainDat[1],other=JSON.stringify(p.tables.HeadMainDat[3]);
EstBusinessActions.setEstimateClassification(p,row,1,3,'HeadMainDat');
const otherPreserved=JSON.stringify(p.tables.HeadMainDat[3])===other;
const manual={JU:row.JU,GU:row.GU,GC:row.GC,JC:row.JC};
const auto=EstNative.calculate(p).tables.HeadMainDat[1];
EstBusinessActions.setEstimateClassification(p,row,1,0,'HeadMainDat');
const restored={GU:row.GU,JU:row.JU};
const mismatch=p.tables.HeadMainDat[3];EstBusinessActions.setEstimateClassification(p,mismatch,3,3,'HeadMainDat');
let rejected=false;try{EstBusinessActions.setEstimateClassification(p,mismatch,3,0,'HeadMainDat')}catch{rejected=true}
return {manual,auto:{GC:auto.GC,JC:auto.JC},restored,rejected,mismatch,otherPreserved,flag:p.options.SourceRefresh};
})()""",
)
assert result["manual"] == {"JU": 100, "GU": 0, "GC": 200, "JC": 0}
assert result["auto"] == {"GC": 0, "JC": 200}
assert result["restored"] == {"GU": 100, "JU": 0}
assert result["rejected"] and result["mismatch"]["JU"] == 5
assert result["otherPreserved"] and not result["flag"]
def test_annual_formulas_split_component_amounts_and_preserve_remainder():
result = run(
"math",
@@ -312,3 +378,290 @@ def test_large_original_baseline_stays_within_the_save_request_limit():
result = run("baseline-size", str(archive))
assert result["archivePreserved"]
assert result["bytes"] < 256 * 1024 * 1024
def test_unit_calc_group_conversion_preserves_percentages_subtotals_fixed_and_other_groups():
result = run(
"math",
"""(()=>{
const p = EstNative.empty();
p.options.SourceRefresh = true;
p.tables.LaborcDat = [
{ Code: "L001", Name: "보통인부", Unite: "인", Cost1: 100000, TC: 100000 }
];
p.tables.StuffcDat = [
{ Code: "M001", Name: "시멘트", Unite: "포", Cost1: 10000, TC: 10000 }
];
p.tables.HeadMainDat = [
// 대상 공종 H001
{ Index: 1, Code: "H001", Name: "기존공종", Size: "100", Unite: "m3", GN: 0, TC: 0 },
// 1) 일반 비목 행 (수량 환산 적용)
{ Index: 0, Code: "L001", LinkIndex: 6, Name: "보통인부", Size: "", Unite: "인", GN: 1.0, TU: 100000, NU: 100000, GU: 0, JU: 0, NC: 100000, GC: 0, JC: 0, TC: 100000 },
// 2) 고정금액 행 (CalcIndex = 1, 환산 제외)
{ Index: 0, Code: "FIX01", LinkIndex: 0, CalcIndex: 1, Name: "고정항목", Size: "", Unite: "식", GN: 1.0, TU: 5000, NU: 0, GU: 0, JU: 5000, NC: 0, GC: 0, JC: 5000, TC: 5000 },
// 3) 비율 행 (LinkIndex = 58, GN = 10(%), 환산 제외)
{ Index: 0, Code: "", LinkIndex: 58, Name: "공구손료", Size: "10%", Unite: "%", GN: 10.0, TU: 0, NU: 0, GU: 0, JU: 0, NC: 0, GC: 0, JC: 0, TC: 0 },
// 4) 소계 행 (LinkIndex = 51, 환산 제외)
{ Index: 0, Code: "", LinkIndex: 51, Name: "소계", Size: "", Unite: "", GN: 0, TU: 0, NU: 0, GU: 0, JU: 0, NC: 0, GC: 0, JC: 0, TC: 0 },
// 5) 낙찰률 행 (LinkIndex = 52, GN = 80(%), 환산 제외)
{ Index: 0, Code: "", LinkIndex: 52, Name: "낙찰적용", Size: "80%", Unite: "%", GN: 80.0, TU: 0, NU: 0, GU: 0, JU: 0, NC: 0, GC: 0, JC: 0, TC: 0 },
// 비대상 공종 H002 (보존 검증)
{ Index: 1, Code: "H002", Name: "다른공종", Size: "공통", Unite: "식", GN: 0, TC: 0 },
{ Index: 0, Code: "M001", LinkIndex: 5, Name: "시멘트", Size: "", Unite: "포", GN: 3.0, TU: 10000, NU: 0, GU: 10000, JU: 0, NC: 0, GC: 30000, JC: 0, TC: 30000 }
];
// 1) factor = 0.5 환산
const halfConverted = EstBusiness.convertGroupUnit(p, "H001", {
name: "환산공종",
size: "50",
factor: 0.5,
unit: "식"
});
// 2) factor = 2 환산 (halfConverted 기준 재환산 -> 원래 수량 1.0으로 복귀)
const doubleConverted = EstBusiness.convertGroupUnit(halfConverted, "H001", {
name: "환산공종2",
size: "100",
factor: 2.0,
unit: "식"
});
const h1Half = halfConverted.tables.HeadMainDat;
const h1Double = doubleConverted.tables.HeadMainDat;
return {
// factor 0.5 결과
halfR1GN: h1Half[1].GN,
halfFixedGN: h1Half[2].GN,
halfPercentGN: h1Half[3].GN,
halfSubtotalGN: h1Half[4].GN,
halfBidGN: h1Half[5].GN,
halfOtherGroupGN: h1Half[7].GN,
// factor 2.0 결과
doubleR1GN: h1Double[1].GN,
doubleFixedGN: h1Double[2].GN,
doublePercentGN: h1Double[3].GN,
doubleSubtotalGN: h1Double[4].GN,
doubleBidGN: h1Double[5].GN,
doubleOtherGroupGN: h1Double[7].GN,
// AutoCalc false 옵션 보존 검증
autoCalcFalsePreserved: (() => {
const p2 = structuredClone(p);
p2.options.AutoCalc = false;
const res = EstBusiness.convertGroupUnit(p2, "H001", { factor: 0.5 });
return res.options.AutoCalc === false;
})()
};
})()""",
)
# factor 0.5
assert result["halfR1GN"] == 0.5
assert result["halfFixedGN"] == 1.0 # 고정금액 행 GN 불변
assert result["halfPercentGN"] == 10.0 # 비율 행 GN 불변
assert result["halfSubtotalGN"] == 0 # 소계 행 불변
assert result["halfBidGN"] == 80.0 # 낙찰률 행 GN 불변
assert result["halfOtherGroupGN"] == 3.0 # 다른 공종 H002 행 GN 불변
# factor 2.0 (원상복귀)
assert result["doubleR1GN"] == 1.0
assert result["doubleFixedGN"] == 1.0
assert result["doublePercentGN"] == 10.0
assert result["doubleBidGN"] == 80.0
assert result["doubleOtherGroupGN"] == 3.0
# AutoCalc false 옵션 보존
assert result["autoCalcFalsePreserved"] is True
def test_haljung_calculation_apply_to_head_and_cost_groups():
result = run(
"math",
"""(()=>{
// 1) 일위대가 공종에 할증 적용: 노무비 1000원의 10% 할증을 노무비에 가산
const p = EstNative.empty();
p.tables.HeadMainDat = [
{ Index: 1, Code: "H01", Name: "일위공종1", TC: 0 },
{ Index: 0, LinkIndex: 6, Name: "보통인부", GN: 1, NU: 1000, NC: 1000, TU: 1000, TC: 1000 },
{ Index: 0, LinkIndex: 5, Name: "모래", GN: 2, GU: 500, GC: 1000, TU: 500, TC: 1000 },
{ Index: 0, LinkIndex: 51, Name: "소계", TC: 0 }
];
p.tables.HeadTitleDat = [{ Code: "H01" }];
// 노무비의 10% 할증을 노무비에 적용 (sourceKind=2, targetKind=2, rate=10)
const p1 = EstHalJung.apply(p, "HeadMainDat", "H01", {
name: "할 증",
sourceKind: 2,
targetKind: 2,
rate: 10
});
const hRows = p1.tables.HeadMainDat;
const haljungRow = hRows[3]; // 보통인부, 모래 뒤, 소계 앞
const subtotalRow = hRows[4];
const titleRow = hRows[0];
// 1-2) 특정 비목(노무비 sourceKind=2)에서 적용비목이 비목별(targetKind=0)일 때 원천 비목(노무비 NC)에 가산되는지 확인
const p1_target0 = EstHalJung.apply(p, "HeadMainDat", "H01", {
name: "노무비별할증",
sourceKind: 2,
targetKind: 0,
rate: 10
});
const haljungRow_t0 = p1_target0.tables.HeadMainDat[3];
// 2) 일위대가 공종에 합계의 5% 할증을 경비에 적용 (sourceKind=4, targetKind=3, rate=5)
p1.tables.HeadMainDat.push(
{ Index: 1, Code: "H02", Name: "일위공종2" },
{ Index: 0, LinkIndex: 5, Name: "재료A", GN: 1, GU: 2000, GC: 2000, TU: 2000, TC: 2000 },
{ Index: 0, LinkIndex: 51, Name: "소계" }
);
p1.tables.HeadTitleDat.push({ Code: "H02" });
const p2 = EstHalJung.apply(p1, "HeadMainDat", "H02", {
name: "야간할증",
sourceKind: 4,
targetKind: 3,
rate: 5
});
const h2Rows = p2.tables.HeadMainDat;
const cHaljungRow = h2Rows[7]; // H01 5행 뒤 H02(제목, 재료A, 야간할증, 소계)
// 3) 잘못된 입력 거부 (0 또는 유효하지 않은 rate, 존재하지 않는 공종, 다른 테이블)
let rejected = 0;
try { EstHalJung.apply(p, "HeadMainDat", "H01", { rate: 0 }); } catch { rejected++; }
try { EstHalJung.apply(p, "HeadMainDat", "NON_EXIST", { rate: 10 }); } catch { rejected++; }
try { EstHalJung.apply(p, "CostMainDat", "H01", { rate: 10 }); } catch { rejected++; }
return {
haljungRow: {
Name: haljungRow.Name,
Size: haljungRow.Size,
Unite: haljungRow.Unite,
GN: haljungRow.GN,
NC: haljungRow.NC,
GC: haljungRow.GC,
JC: haljungRow.JC,
TC: haljungRow.TC,
SCheck: haljungRow.SCheck,
OCheck: haljungRow.OCheck
},
subtotalNU: subtotalRow.NU,
subtotalTU: subtotalRow.TU,
titleTC: titleRow.TC,
titleNC: titleRow.NC,
haljungTarget0: {
NC: haljungRow_t0.NC,
GC: haljungRow_t0.GC,
JC: haljungRow_t0.JC,
TC: haljungRow_t0.TC,
SCheck: haljungRow_t0.SCheck
},
cHaljungRow: {
Name: cHaljungRow.Name,
Size: cHaljungRow.Size,
Unite: cHaljungRow.Unite,
JC: cHaljungRow.JC,
TC: cHaljungRow.TC
},
rejected
};
})()""",
)
# 1) 일위대가 노무비 1000원의 10% = 100원 노무비 가산 확인
assert result["haljungRow"]["Name"] == "할 증"
assert result["haljungRow"]["Size"] == "노무비의"
assert result["haljungRow"]["Unite"] == "%"
assert result["haljungRow"]["GN"] == 10
assert result["haljungRow"]["NC"] == 100
assert result["haljungRow"]["GC"] == 0
assert result["haljungRow"]["JC"] == 0
assert result["haljungRow"]["TC"] == 100
assert result["haljungRow"]["SCheck"] == 2 # 노무비에
assert result["haljungRow"]["OCheck"] == 2 # 노무비의
# 소계(LinkIndex 51)의 단가 성분(NU/TU) 및 공종 제목행(Index 1) 합계:
# 노무비 1000 + 100 = 1100, 재료비 1000 => 합계 2100
assert result["subtotalNU"] == 1100
assert result["subtotalTU"] == 2100
assert result["titleNC"] == 1100
assert result["titleTC"] == 2100
# 1-2) sourceKind=2(노무비) & targetKind=0(비목별) -> 노무비(NC)에 100원 반영 확인 (경비 JC로 안 감)
assert result["haljungTarget0"]["NC"] == 100
assert result["haljungTarget0"]["GC"] == 0
assert result["haljungTarget0"]["JC"] == 0
assert result["haljungTarget0"]["TC"] == 100
assert result["haljungTarget0"]["SCheck"] == 2
# 2) 단가산출 합계 2000원의 5% = 100원 경비 가산 확인
assert result["cHaljungRow"]["Name"] == "야간할증"
assert result["cHaljungRow"]["Size"] == "합계의"
assert result["cHaljungRow"]["JC"] == 100
assert result["cHaljungRow"]["TC"] == 100
# 3) 안전성 거부 3건 확인
assert result["rejected"] == 3
def test_vat_calculation_exclude_include_and_unit_rounding_for_materials():
result = run(
"math",
"""(()=>{
const p = EstNative.empty();
p.tables.StuffcDat = [
{ Code: "M01", Name: "시멘트", Cost1: 11000, Cost2: 12100, ValueIndex: 0, InputTC: 11000, TC: 11000 },
{ Code: "M02", Name: "철근", Cost1: 10000, ValueIndex: 4, InputTC: 10000, TC: 10000 },
{ Code: "M03", Name: "단단위테스트", Cost1: 12345, InputTC: 12345, TC: 12345 }
];
// 1) M01 선택 후 부가세 제외 (/1.1)
const p1 = EstVat.apply(p, [0], {
calcMethod: 1, // 부가세제외 (/1.1)
diget: 0, // 원단위
digetMethod: 1 // 절하
});
// 2) M02에 부가세 포함 (*1.1) 및 십원 단위(-1자리) 절하 적용
const p2 = EstVat.apply(p, [1], {
calcMethod: 0, // 부가세포함 (*1.1)
diget: 1, // 십원 단위 절하
digetMethod: 1
});
// 3) M03에 부가세 제외 (/1.1) 후 단단위 사사오입(digetMethod: 0) 및 절상(digetMethod: 2)
// 12345 / 1.1 = 11222.7272...
// diget=1 (십원 단위 사사오입): 11220
// diget=1 (십원 단위 절상): 11230
const p3Round = EstVat.apply(p, [2], { calcMethod: 1, diget: 1, digetMethod: 0 });
const p3Up = EstVat.apply(p, [2], { calcMethod: 1, diget: 1, digetMethod: 2 });
return {
m1Cost1: p1.tables.StuffcDat[0].Cost1,
m1Cost2: p1.tables.StuffcDat[0].Cost2,
m1TC: p1.tables.StuffcDat[0].TC,
m2Cost1: p2.tables.StuffcDat[1].Cost1,
m2TC: p2.tables.StuffcDat[1].TC,
m3RoundCost: p3Round.tables.StuffcDat[2].Cost1,
m3UpCost: p3Up.tables.StuffcDat[2].Cost1
};
})()""",
)
# M01: 11000 / 1.1 = 10000, 12100 / 1.1 = 11000
# ValueIndex=0(최저단가) -> 재계산 후 TC도 10000으로 보존됨
assert result["m1Cost1"] == 10000
assert result["m1Cost2"] == 11000
assert result["m1TC"] == 10000
# M02: 10000 * 1.1 = 11000, InputTC/TC도 11000 확인
assert result["m2Cost1"] == 11000
assert result["m2TC"] == 11000
# M03: 12345 / 1.1 = 11222.72... -> 십원 단위 사사오입(11220), 절상(11230)
assert result["m3RoundCost"] == 11220
assert result["m3UpCost"] == 11230
@@ -1,6 +1,504 @@
from test_est_rea_parity import run
def test_estimate_move_preserves_row_identity_and_blocks_ambiguous_or_total_rows():
result = run(
"math",
"""(()=>{
const rows=[{PkID:1,Code:'A'},{PkID:2,Code:'B'},{PkID:3,Code:'C'},{LinkIndex:54}];
const moved=EstBusiness.moveEstimateRows(rows,new Set([1,2]),-1);
const order=rows.map(row=>row.PkID||0);
const restored=EstBusiness.moveEstimateRows(rows,new Set(moved),1);
let blocked=0;
for (const [data,selection,direction] of [
[[{Code:'A'},{Code:'A'}],[0],1],
[[{PkID:1},{LinkIndex:54}],[0],1],
[[{PkID:1},{PkID:2},{PkID:3}],[0,2],1],
[[{PkID:1},{Name:'unknown'}],[0],1]]) {
try{EstBusiness.moveEstimateRows(data,new Set(selection),direction)}catch{blocked++}
}
return {moved,order,restored,blocked};
})()""",
)
assert result == {"moved": [0, 1], "order": [2, 3, 1, 0], "restored": [1, 2], "blocked": 4}
def test_edge_estimate_move_rows_up_and_down_with_undo():
from playwright.sync_api import sync_playwright
import pytest
with sync_playwright() as p:
browser = p.chromium.launch(channel="msedge", headless=True)
page = browser.new_page()
try:
page.goto("http://100.65.141.15:5175", timeout=5000)
except Exception:
pytest.skip("EST 5175 server not reachable")
page.wait_for_function("() => Boolean(EstApp.state.project)")
# 내역서 화면 확인
page.evaluate("EstApp.navigate('EstMainDat')")
page.wait_for_function("() => EstApp.state.view === 'EstMainDat'")
# 1번 행의 고유 코드 또는 PkID 기록
row_id_target = page.evaluate("EstApp.state.project.tables.EstMainDat[1].PkID")
# 1번 행(2번째 행)을 선택하고 아래로 이동 시험
page.evaluate("""() => {
EstApp.state.selection = new Set([1]);
EstApp.render();
}""")
btn_down = page.locator("#move-estimate-down")
assert btn_down.is_visible()
btn_down.click()
# 이동 후 해당 행이 2번 위치로 이동했는지 확인
pos_after_down = page.evaluate(f"""() => {{
return EstApp.state.project.tables.EstMainDat.findIndex(r => r.PkID === {row_id_target});
}}""")
assert pos_after_down == 2
sel_after_down = page.evaluate("Array.from(EstApp.state.selection)")
assert sel_after_down == [2]
# 다시 위로 이동 시험
btn_up = page.locator("#move-estimate-up")
assert btn_up.is_visible()
btn_up.click()
pos_after_up = page.evaluate(f"""() => {{
return EstApp.state.project.tables.EstMainDat.findIndex(r => r.PkID === {row_id_target});
}}""")
assert pos_after_up == 1
sel_after_up = page.evaluate("Array.from(EstApp.state.selection)")
assert sel_after_up == [1]
# 실행 취소(Undo) 시험: 툴바 버튼 클릭 시 다시 2번 위치로 되돌아감
btn_undo = page.locator(".toolbar button[data-action='undo']")
assert btn_undo.is_visible()
btn_undo.click()
page.wait_for_timeout(500)
pos_after_undo = page.evaluate(f"""() => {{
return EstApp.state.project.tables.EstMainDat.findIndex(r => r.PkID === {row_id_target});
}}""")
assert pos_after_undo == 2
browser.close()
def test_group_detail_move_preserves_row_identity_and_blocks_title_and_boundaries():
result = run(
"math",
"""(()=>{
const rows = [
{Index:1,Code:'H1',Name:'Title 1',PkID:100},
{Index:0,Code:'D1',Name:'Detail 1',PkID:101},
{Index:0,Code:'D2',Name:'Detail 2',PkID:102},
{Index:0,Code:'D3',Name:'Detail 3',PkID:103},
{Index:1,Code:'H2',Name:'Title 2',PkID:200},
{Index:0,Code:'D4',Name:'Detail 4',PkID:201}
];
const moved = EstBusiness.moveGroupDetailRows(rows, 'H1', new Set([2]), 1);
const order = rows.slice(0, 4).map(r => r.Code);
const restored = EstBusiness.moveGroupDetailRows(rows, 'H1', new Set(moved), -1);
let blocked = 0;
// 1: 제목 행(Index 0) 이동 시도
try { EstBusiness.moveGroupDetailRows(rows, 'H1', new Set([0]), 1); } catch { blocked++; }
// 2: 다른 호표 행 포함 시도
try { EstBusiness.moveGroupDetailRows(rows, 'H1', new Set([4]), 1); } catch { blocked++; }
// 3: 비연속 행 선택 시도
try { EstBusiness.moveGroupDetailRows(rows, 'H1', new Set([1, 3]), 1); } catch { blocked++; }
// 4: 상한 경계(제목 바로 다음 행을 위로) - 이동 없이 원래 인덱스 반환
const topBoundary = EstBusiness.moveGroupDetailRows(rows, 'H1', new Set([1]), -1);
// 5: 하한 경계(호표 마지막 행을 아래로) - 이동 없이 원래 인덱스 반환
const bottomBoundary = EstBusiness.moveGroupDetailRows(rows, 'H1', new Set([3]), 1);
return { moved, order, restored, blocked, topBoundary, bottomBoundary };
})()""",
)
assert result == {
"moved": [3],
"order": ["H1", "D1", "D3", "D2"],
"restored": [2],
"blocked": 3,
"topBoundary": [1],
"bottomBoundary": [3],
}
def test_edge_group_detail_move_rows_up_and_down_with_undo():
from playwright.sync_api import sync_playwright
import pytest
with sync_playwright() as p:
browser = p.chromium.launch(channel="msedge", headless=True)
page = browser.new_page()
try:
page.goto("http://100.65.141.15:5175", timeout=5000)
except Exception:
pytest.skip("EST 5175 server not reachable")
page.wait_for_function("() => Boolean(EstApp.state.project)")
# 일위대가(HeadMainDat) 화면으로 이동
page.evaluate("EstApp.navigate('HeadMainDat')")
page.wait_for_function("() => EstApp.state.view === 'HeadMainDat'")
# 첫 번째 호표 코드 및 2번 상세 행(전체 인덱스 2)의 PkID 확인
group_code = page.evaluate("EstApp.state.group")
assert group_code
target_pkid = page.evaluate("EstApp.state.project.tables.HeadMainDat[2].PkID")
# 2번 상세 행 선택
page.evaluate("""() => {
EstApp.state.selection = new Set([2]);
EstApp.render();
}""")
btn_down = page.locator("#move-estimate-down")
assert btn_down.is_visible()
btn_down.click()
# 이동 후 해당 행이 3번 위치로 이동했는지 확인
pos_after_down = page.evaluate(f"""() => {{
return EstApp.state.project.tables.HeadMainDat.findIndex(r => r.PkID === {target_pkid});
}}""")
assert pos_after_down == 3
sel_after_down = page.evaluate("Array.from(EstApp.state.selection)")
assert sel_after_down == [3]
# 다시 위로 이동 시험
btn_up = page.locator("#move-estimate-up")
assert btn_up.is_visible()
btn_up.click()
pos_after_up = page.evaluate(f"""() => {{
return EstApp.state.project.tables.HeadMainDat.findIndex(r => r.PkID === {target_pkid});
}}""")
assert pos_after_up == 2
sel_after_up = page.evaluate("Array.from(EstApp.state.selection)")
assert sel_after_up == [2]
# 실행 취소(Undo) 시험: 툴바 버튼 클릭 시 다시 3번 위치로 복원
btn_undo = page.locator(".toolbar button[data-action='undo']")
assert btn_undo.is_visible()
btn_undo.click()
page.wait_for_timeout(500)
pos_after_undo = page.evaluate(f"""() => {{
return EstApp.state.project.tables.HeadMainDat.findIndex(r => r.PkID === {target_pkid});
}}""")
assert pos_after_undo == 3
browser.close()
def test_cost_group_detail_move_preserves_subtotal_and_blocks_order_dependent_errors():
result = run(
"math",
"""(()=>{
const cost = project.tables.CostMainDat;
const grps = EstNative.groups(cost);
const grp4 = grps.get('D0000000004');
const start4 = grp4.start;
const p = structuredClone(project);
// 1: D0000000004 호표의 포장공(start+10, Index -20) 행을 아래(보통인부 start+12 건너)로 이동
// Index -20 시작 표지가 선두로 유지되면서 소계(14,373.6) 및 호표 총액(16,421) 완벽 보존
const moved4 = EstBusiness.moveGroupDetailRows(p.tables.CostMainDat, 'D0000000004', new Set([start4 + 10]), 1, 'CostMainDat');
const calcAfterMove = EstNative.calculate(p);
const subtotal4 = calcAfterMove.tables.CostMainDat[start4 + 14].TC;
const total4 = calcAfterMove.tables.CostMainDat[start4].TC;
// 2: 다시 위로 원복 이동
const restored4 = EstBusiness.moveGroupDetailRows(p.tables.CostMainDat, 'D0000000004', new Set(moved4), -1, 'CostMainDat');
const calcAfterRestore = EstNative.calculate(p);
const subtotalRestore = calcAfterRestore.tables.CostMainDat[start4 + 14].TC;
// 3: 보호 차단 시험
let blockedSubtotal = 0;
try {
EstBusiness.moveGroupDetailRows(p.tables.CostMainDat, 'D0000000004', new Set([start4 + 14]), 1, 'CostMainDat');
} catch { blockedSubtotal++; }
let blockedRatio = 0;
try {
EstBusiness.moveGroupDetailRows(p.tables.CostMainDat, 'D0000000004', new Set([start4 + 18]), 1, 'CostMainDat');
} catch { blockedRatio++; }
const grp0 = grps.get('D0000000000');
const start0 = grp0.start;
let blockedVar = 0;
try {
// D0000000000의 Q 변수 산식 행(start0+11)을 아래로 이동 시 참조 오류 차단
EstBusiness.moveGroupDetailRows(p.tables.CostMainDat, 'D0000000000', new Set([start0 + 11]), 1, 'CostMainDat');
} catch { blockedVar++; }
// 4: D0000000005 다중 -20 구간 보존 시험
const grp5 = grps.get('D0000000005');
const start5 = grp5.start;
const moved5 = EstBusiness.moveGroupDetailRows(p.tables.CostMainDat, 'D0000000005', new Set([start5 + 15]), 1, 'CostMainDat');
const m20Count5 = p.tables.CostMainDat.slice(start5, start5 + grp5.rows.length).filter(r => Number(r.Index) === -20).length;
const calc5 = EstNative.calculate(p);
const sub1_5 = calc5.tables.CostMainDat[start5 + 19].TC;
const sub2_5 = calc5.tables.CostMainDat[start5 + 27].TC;
// 5: 앞줄 참조/소계 결과가 다른 정상 값으로 바뀌는 안전하지 않은 이동 차단 시험
// D0000000004에서 재료비 행(start4+6)을 단계별로 내려서 노무비 포장공(start4+10) 위치로 침범 시도
let blockedSubtotalShift = 0;
try {
const m1 = EstBusiness.moveGroupDetailRows(p.tables.CostMainDat, 'D0000000004', new Set([start4 + 6]), 1, 'CostMainDat');
const m2 = EstBusiness.moveGroupDetailRows(p.tables.CostMainDat, 'D0000000004', new Set(m1), 1, 'CostMainDat');
const m3 = EstBusiness.moveGroupDetailRows(p.tables.CostMainDat, 'D0000000004', new Set(m2), 1, 'CostMainDat');
EstBusiness.moveGroupDetailRows(p.tables.CostMainDat, 'D0000000004', new Set(m3), 1, 'CostMainDat');
} catch {
blockedSubtotalShift++;
}
// 6: PkID 없는 행, 다중 선택 내 구간 경계 포함 및 앞줄 참조 복수 행 이동 보수적 차단 시험
let blockedMultiM20 = 0;
try {
// D0000000005에서 -20이 중간에 포함된 3개 연속 행 이동 시도
EstBusiness.moveGroupDetailRows(p.tables.CostMainDat, 'D0000000005', new Set([start5 + 22, start5 + 23, start5 + 24]), 1, 'CostMainDat');
} catch { blockedMultiM20++; }
let blockedRefMulti = 0;
try {
// 앞줄 참조(\x0c) 행을 포함한 복수 행 동시 이동 시도
EstBusiness.moveGroupDetailRows(p.tables.CostMainDat, 'D0000000004', new Set([start4 + 17, start4 + 18]), 1, 'CostMainDat');
} catch { blockedRefMulti++; }
// 7: A/B 동일값 선행 행 교환 시 뒤따르는 직전 결과 참조 대상 왜곡 재현 및 보수적 차단 시험
const ambigRows = [
{ Index: 1, Code: 'CAMBIG' },
{ PkID: 1, SCheck: -1, LineData: 'A = 100 = \x12100\x12' },
{ PkID: 2, SCheck: -1, LineData: 'B = 100 = \x12100\x12' },
{ PkID: 3, LinkIndex: 58, SCheck: -3, LineData: '\x0c100\x0c * 2 = \x12200\x12' }
];
// A와 B의 교환을 허용할 경우 B가 변경되었을 때 C가 참조하는 대상이 원래 B에서 A로 왜곡됨을 재현:
const origAmbig = EstText.calculate(structuredClone(ambigRows));
const swappedAmbig = [ambigRows[0], ambigRows[2], ambigRows[1], ambigRows[3]];
const ambigBChangedOrig = structuredClone(ambigRows);
ambigBChangedOrig[2].LineData = 'B = 200 = \x12200\x12';
const ambigBChangedSwapped = structuredClone(swappedAmbig);
ambigBChangedSwapped[1].LineData = 'B = 200 = \x12200\x12';
const origRes = EstText.calculate(ambigBChangedOrig);
const swappedRes = EstText.calculate(ambigBChangedSwapped);
const shiftReproduced = (origRes.rows[3].TC === 400 && swappedRes.rows[3].TC === 200);
// moveGroupDetailRows에서 이와 같은 모호한 참조 대상 구간 행 이동을 확실히 차단하는지 검증:
let blockedAmbigRef = 0;
let ambigErrMsg = '';
try {
EstBusiness.moveGroupDetailRows(ambigRows, 'CAMBIG', new Set([1]), 1, 'CostMainDat');
} catch (e) {
blockedAmbigRef++;
ambigErrMsg = e.message;
}
// 8: LinkIndex 58 이외 일반 행의 \x0c 앞줄 참조 모호성 차단 시험
const generalRefRows = [
{ Index: 1, Code: 'CGENREF' },
{ PkID: 10, SCheck: 0, LineData: 'X1 = 50 = \x1250\x12' },
{ PkID: 11, SCheck: 0, LineData: 'X2 = 50 = \x1250\x12' },
{ PkID: 12, SCheck: 0, LinkIndex: 0, LineData: '일반행 : \x0c50\x0c + 10 = \x1260\x12' }
];
let blockedGenRef = 0;
try {
EstBusiness.moveGroupDetailRows(generalRefRows, 'CGENREF', new Set([1]), 1, 'CostMainDat');
} catch {
blockedGenRef++;
}
// 9: 동일 변수 재정의(중복 할당) 구간 행 교환 보수적 차단 시험
const varRedefineRows = [
{ Index: 1, Code: 'CVARREDEF' },
{ PkID: 20, LineData: 'W = 10 = \x1210\x12' },
{ PkID: 21, LineData: 'W = 10 = \x1210\x12' },
{ PkID: 22, LineData: 'ANS = W * 3 = \x1230\x12' }
];
let blockedVarRedef = 0;
let varRedefErrMsg = '';
try {
EstBusiness.moveGroupDetailRows(varRedefineRows, 'CVARREDEF', new Set([1]), 1, 'CostMainDat');
} catch (e) {
blockedVarRedef++;
varRedefErrMsg = e.message;
}
return {
moved4,
restored4,
subtotal4,
total4,
subtotalRestore,
blockedSubtotal,
blockedRatio,
blockedVar,
m20Count5,
sub1_5,
sub2_5,
blockedSubtotalShift,
blockedMultiM20,
blockedRefMulti,
shiftReproduced,
blockedAmbigRef,
ambigErrMsg,
blockedGenRef,
blockedVarRedef,
varRedefErrMsg
};
})()""",
)
assert result["shiftReproduced"] is True
assert result["blockedAmbigRef"] == 1
assert "직전 결과를 참조하는 행이 뒤따라" in result["ambigErrMsg"]
assert result["moved4"] == [172]
assert result["restored4"] == [171]
assert result["subtotal4"] == 14373.6
assert result["total4"] == 16421
assert result["subtotalRestore"] == 14373.6
assert result["blockedSubtotal"] == 1
assert result["blockedRatio"] == 1
assert result["blockedVar"] == 1
assert result["m20Count5"] == 2
assert result["sub1_5"] == 1383.4
assert result["sub2_5"] == 1407.8
assert result["blockedSubtotalShift"] == 1
assert result["blockedMultiM20"] == 1
assert result["blockedRefMulti"] == 1
assert result["blockedGenRef"] == 1
assert result["blockedVarRedef"] == 1
assert "동일 변수(W)를 재정의하는 구간에서는" in result["varRedefErrMsg"]
def test_edge_cost_group_detail_move_rows_with_subtotal_and_undo():
from playwright.sync_api import sync_playwright
import pytest
with sync_playwright() as p:
browser = p.chromium.launch(channel="msedge", headless=True)
page = browser.new_page()
try:
page.goto("http://100.65.141.15:5175", timeout=5000)
except Exception:
pytest.skip("EST 5175 server not reachable")
page.wait_for_function("() => Boolean(EstApp.state.project)")
# 단가산출(CostMainDat) 화면 및 D0000000004 호표로 이동
page.evaluate("EstApp.navigate('CostMainDat', 'D0000000004')")
page.wait_for_function(
"() => EstApp.state.view === 'CostMainDat' && EstApp.state.group === 'D0000000004'"
)
# 시작 인덱스 조회 및 포장공 행(start + 10) 선택
start_idx = page.evaluate(
"EstNative.groups(EstApp.state.project.tables.CostMainDat).get('D0000000004').start"
)
target_row_idx = start_idx + 10
target_pkid = page.evaluate(
f"EstApp.state.project.tables.CostMainDat[{target_row_idx}].PkID"
)
page.evaluate(f"""() => {{
EstApp.state.selection = new Set([{target_row_idx}]);
EstApp.render();
}}""")
btn_down = page.locator("#move-estimate-down")
assert btn_down.is_visible()
btn_down.click()
# 이동 후 위치 확인 및 소계/총액 보존 확인
pos_after_down = page.evaluate(f"""() => {{
return EstApp.state.project.tables.CostMainDat.findIndex(r => r.PkID === {target_pkid});
}}""")
assert pos_after_down == target_row_idx + 1
subtotal_after_down = page.evaluate(
f"EstApp.state.project.tables.CostMainDat[{start_idx + 14}].TC"
)
assert subtotal_after_down == 14373.6
# 실행 취소(Undo)로 원래 위치 원복
btn_undo = page.locator(".toolbar button[data-action='undo']")
assert btn_undo.is_visible()
btn_undo.click()
page.wait_for_timeout(500)
pos_after_undo = page.evaluate(f"""() => {{
return EstApp.state.project.tables.CostMainDat.findIndex(r => r.PkID === {target_pkid});
}}""")
assert pos_after_undo == target_row_idx
browser.close()
def test_duplicate_codes_match_distinct_original_rows_and_track_deletions():
result = run(
"math",
"""(()=>{
const p=EstNative.empty();p.tables.EstMainDat=[{Code:'A',Name:'first',GN:2,TC:200},
{Code:'A',Name:'second',GN:4,TC:400}];
p.baseline={name:'old',tables:{EstMainDat:[{Code:'A',Name:'first',GN:1,TC:100},
{Code:'A',Name:'second',GN:3,TC:300},{Code:'A',Name:'removed',GN:5,TC:500}]}};
return EstBusiness.changes(p);
})()""",
)
assert [row["originalTC"] for row in result] == [100, 300, 500]
assert [row["difference"] for row in result] == [100, 100, -500]
assert result[-1]["status"] == "deleted"
def test_design_change_costs_include_duplicate_new_and_deleted_cost_rows():
result = run(
"math",
"""(()=>{
const p=EstNative.empty();p.tables.Wonga=[
{Gubun:'A',Name:'first',Cost:200},{Gubun:'A',Name:'second',Cost:60},
{Gubun:'C',Name:'new',Cost:25}];
p.baseline={name:'old',tables:{EstMainDat:[],Wonga:[
{Gubun:'A',Name:'first',Cost:100},{Gubun:'A',Name:'second',Cost:50},
{Gubun:'B',Name:'removed',Cost:30}]}};
const before=JSON.stringify(p), rows=EstBusiness.costChanges(p);
const output=EstForms.data('change-costs',p);
const node={innerHTML:''};globalThis.EstApp={esc:x=>String(x??''),number:x=>String(x??0),byId:()=>node};
EstExtra.render({project:p,view:'change'});
return {rows,output,html:node.innerHTML,same:before===JSON.stringify(p)};
})()""",
)
assert result["same"]
assert [row["difference"] for row in result["rows"]] == [100, 10, 25, -30]
assert result["output"]["rows"] == result["rows"]
assert "removed" in result["html"]
def test_stored_original_costs_remain_attached_to_each_duplicate_row():
result = run(
"math",
"""(()=>{
const p=EstNative.empty();p.tables.Wonga=[
{Gubun:'A',Name:'new',Cost:10},{Gubun:'A',Name:'existing',Cost:50,Cost_:30}];
return EstBusiness.costChanges(p);
})()""",
)
assert [row["originalCost"] for row in result] == [0, 30]
assert [row["difference"] for row in result] == [10, 20]
def test_change_view_includes_named_rows_without_codes():
result = run(
"math",
"""(()=>{
const p=EstNative.empty();p.tables.EstMainDat=[{PkID:1,Code:'',Name:'earthwork',GN:2,TC:200}];
p.baseline={name:'base',tables:{EstMainDat:[{PkID:1,Code:'',Name:'earthwork',GN:1,TC:100}]}};
const node={innerHTML:''};globalThis.EstApp={esc:x=>String(x??''),number:x=>String(x??0),byId:()=>node};
const state={project:p,view:'change'};EstExtra.render(state);return {html:node.innerHTML,changes:EstBusiness.changes(p)};
})()""",
)
assert "earthwork" in result["html"]
assert result["changes"][0]["difference"] == 100
def test_original_recalculation_modes_keep_old_quantities_and_only_refresh_selected_prices():
result = run(
"math",
@@ -67,3 +565,151 @@ def test_blank_codes_have_distinct_keys_and_metadata_changes_are_reported():
assert len(set(result["keys"][1:])) == 2
assert result["rows"][0]["status"] == "changed"
assert result["rows"][0]["difference"] == 0
def test_bid_rates_apply_and_restore_original_rates():
result = run(
"math",
"""(()=>{
const p = EstNative.empty();
p.options.SourceRefresh = false;
p.tables.EstMainDat = [
{ PkID: 1, Code: 'R1', Name: '노무재료경비항목', GN: 2, NU: 1000, GU: 2000, JU: 500, SU: 0, TU: 3500, TC: 7000 }
];
p.tables.HeadMainDat = [
{ PkID: 10, Code: 'H1', Name: '일위대가항목', Index: 0, GN: 1, NU: 10000, GU: 5000, JU: 1000, SU: 0, TU: 16000, TC: 16000 }
];
// 0) 원본에 기존 O필드가 존재하는 행 준비
p.tables.EstMainDat.push({
PkID: 2, Code: 'R2', Name: '기존O필드항목', GN: 1, NU: 500, GU: 500, JU: 0, SU: 0, TU: 1000, TC: 1000,
OTU: 1000, ONU: 500, OGU: 500, OJU: 0, OSU: 0
});
// 1) 낙찰률 80% 적용
const bidded = EstBusiness.bid(p, [80, 80, 80, 80]);
const bidEstRow = bidded.tables.EstMainDat[0];
const bidHeadRow = bidded.tables.HeadMainDat[0];
const bidExistingORow = bidded.tables.EstMainDat[1];
// 2) 당초(100%) 복원
const restored = EstBusiness.restoreBid(bidded);
const resEstRow = restored.tables.EstMainDat[0];
const resHeadRow = restored.tables.HeadMainDat[0];
const resExistingORow = restored.tables.EstMainDat[1];
return {
bidEst: { NU: bidEstRow.NU, GU: bidEstRow.GU, JU: bidEstRow.JU, TU: bidEstRow.TU, ApplyNacChal: bidEstRow.ApplyNacChal },
bidHead: { NU: bidHeadRow.NU, GU: bidHeadRow.GU, JU: bidHeadRow.JU, TU: bidHeadRow.TU, ApplyNacChal: bidHeadRow.ApplyNacChal },
resEst: { NU: resEstRow.NU, GU: resEstRow.GU, JU: resEstRow.JU, TU: resEstRow.TU, hasApplyNacChal: 'ApplyNacChal' in resEstRow },
resHead: { NU: resHeadRow.NU, GU: resHeadRow.GU, JU: resHeadRow.JU, TU: resHeadRow.TU, hasApplyNacChal: 'ApplyNacChal' in resHeadRow },
resExistingO: { NU: resExistingORow.NU, OTU: resExistingORow.OTU, ONU: resExistingORow.ONU, OGU: resExistingORow.OGU }
};
})()""",
)
assert result["bidEst"]["NU"] == 800
assert result["bidEst"]["GU"] == 1600
assert result["bidEst"]["JU"] == 400
assert result["bidEst"]["TU"] == 2800
assert result["bidEst"]["ApplyNacChal"] == 1
assert result["bidHead"]["NU"] == 8000
assert result["bidHead"]["GU"] == 4000
assert result["bidHead"]["JU"] == 800
assert result["bidHead"]["TU"] == 12800
assert result["bidHead"]["ApplyNacChal"] == 1
assert result["resEst"]["NU"] == 1000
assert result["resEst"]["GU"] == 2000
assert result["resEst"]["JU"] == 500
assert result["resEst"]["TU"] == 3500
assert result["resEst"]["hasApplyNacChal"] is False
assert result["resHead"]["NU"] == 10000
assert result["resHead"]["GU"] == 5000
assert result["resHead"]["JU"] == 1000
assert result["resHead"]["TU"] == 16000
assert result["resHead"]["hasApplyNacChal"] is False
# 기존 O필드 보존 검증: 원래 있던 OTU/ONU/OGU가 삭제되지 않고 유지됨
assert result["resExistingO"]["NU"] == 500
assert result["resExistingO"]["OTU"] == 1000
assert result["resExistingO"]["ONU"] == 500
assert result["resExistingO"]["OGU"] == 500
def test_bid_rates_consecutive_apply_source_refresh_and_roundtrip_restore():
result = run(
"math",
"""(()=>{
// 1) 원천 단가(StuffcDat 200)와 내역 초기단가(GU 150)가 다르고 SourceRefresh=true인 프로젝트
const p = EstNative.empty();
p.options = { SourceRefresh: true, AutoCalc: true };
p.tables.StuffcDat = [{ Code: 'M1', Name: '자재', ValueIndex: 4, InputTC: 200 }];
p.tables.CostMainDat = [
{ Index: 1, Code: 'C1' },
{ Code: 'M1', LinkIndex: 5, SCheck: -2, LineData: ' \\x0e자재\\x0e: \\x03200\\x03 = \\x12200\\x12' }
];
p.tables.CostTitleDat = [{ Code: 'C1' }];
p.tables.EstMainDat = [
{ PkID: 1, Code: 'C1', LinkIndex: 104, GN: 10, NU: 100, GU: 150, JU: 50, TU: 300, TC: 3000 }
];
// 2) 1차 낙찰률 적용 (80%) -> 2차 연속 낙찰률 적용 (70%)
const bid1 = EstBusiness.bid(p, [80, 80, 80, 80]);
const bid2 = EstBusiness.bid(bid1, [70, 70, 70, 70]);
// 2차 적용 후 단가: 최초(100, 150, 50) 기준 70%인 (70, 105, 35)이어야 하며, 80%의 70%(56%)로 왜곡되면 안 됨
const bid2Row = bid2.tables.EstMainDat[0];
// 3) 직렬화 및 역직렬화 (저장 -> 재열기 시뮬레이션)
const reloaded = JSON.parse(JSON.stringify(bid2));
// 4) 당초(100%) 복원
const restored = EstBusiness.restoreBid(reloaded);
const resRow = restored.tables.EstMainDat[0];
// 5) 수동계산(AutoCalc: false) 모드에서의 연속 적용 및 복원
const manualP = structuredClone(p);
manualP.options.AutoCalc = false;
const manualBid = EstBusiness.bid(EstBusiness.bid(manualP, [80, 80, 80, 80]), [70, 70, 70, 70]);
const manualRestored = EstBusiness.restoreBid(manualBid);
return {
bid2Row: { NU: bid2Row.NU, GU: bid2Row.GU, JU: bid2Row.JU, TU: bid2Row.TU },
bid2Option: bid2.options.SourceRefresh,
resRow: { NU: resRow.NU, GU: resRow.GU, JU: resRow.JU, TU: resRow.TU, TC: resRow.TC },
resOption: restored.options.SourceRefresh,
hasPreBidRates: 'PreBidRates' in resRow,
manualBidRow: { NU: manualBid.tables.EstMainDat[0].NU, GU: manualBid.tables.EstMainDat[0].GU },
manualResRow: { NU: manualRestored.tables.EstMainDat[0].NU, GU: manualRestored.tables.EstMainDat[0].GU }
};
})()""",
)
# 연속 낙찰률 적용 시 최초 단가(100, 150, 50) 기준 70% = (70, 105, 35) 유지
assert result["bid2Row"]["NU"] == 70
assert result["bid2Row"]["GU"] == 105
assert result["bid2Row"]["JU"] == 35
assert result["bid2Row"]["TU"] == 210
# 낙찰 중에는 원천 갱신 차단
assert result["bid2Option"] is False
# 복원 후: 원천 단가(200)로 덮이지 않고 최초 내역 단가(100, 150, 50)로 정확히 복원
assert result["resRow"]["NU"] == 100
assert result["resRow"]["GU"] == 150
assert result["resRow"]["JU"] == 50
assert result["resRow"]["TU"] == 300
assert result["resRow"]["TC"] == 3000
# 사용자의 원래 SourceRefresh 옵션(true) 복원 및 임시 키 정리
assert result["resOption"] is True
assert result["hasPreBidRates"] is False
# 수동 계산(AutoCalc: false) 모드에서도 최초 단가 및 복원 일관성 확인
assert result["manualBidRow"]["NU"] == 70
assert result["manualBidRow"]["GU"] == 105
assert result["manualResRow"]["NU"] == 100
assert result["manualResRow"]["GU"] == 150
+446
View File
@@ -1,6 +1,206 @@
from test_est_rea_parity import run
def test_print_margins_validate_independently_and_keep_legacy_setting():
result = run(
"math",
"""(()=>{
const p=EstNative.empty(); p.printSettings={margin:10,marginTop:11,marginBottom:12,marginLeft:13,marginRight:14};
const valid=EstModel.validate(p).printSettings; let rejected=0;
for (const key of ['marginTop','marginBottom','marginLeft','marginRight']) {
const q=structuredClone(p);q.printSettings[key]=51;try{EstModel.validate(q)}catch{rejected++}
}
return {valid,rejected};
})()""",
)
assert result["valid"] == {
"margin": 10,
"marginTop": 11,
"marginBottom": 12,
"marginLeft": 13,
"marginRight": 14,
}
assert result["rejected"] == 4
def test_custom_print_form_documents_are_saved_and_unknown_keys_rejected():
result = run(
"math",
"""(()=>{
const p=EstNative.empty();p.documents={'annual-items':'stage note','progress-costs':'progress note','standard-usage':'standard note'};
const valid=EstModel.validate(p).documents;let rejected=false;
const q=EstNative.empty();q.documents={'invalid-key':'bad'};try{EstModel.validate(q)}catch{rejected=true}
return {valid,rejected};
})()""",
)
assert result["valid"] == {
"annual-items": "stage note",
"progress-costs": "progress note",
"standard-usage": "standard note",
}
assert result["rejected"]
def test_schedule_refreshes_progress_from_latest_locked_round_without_losing_dates():
result = run(
"math",
"""(()=>{
const p=EstNative.empty();p.tables.EstMainDat=[{PkID:1,Code:'A',GN:10},{PkID:2,Code:'B',GN:0}];
p.schedule=[{id:'a',SourceKey:'id:1',Code:'A',Name:'work',start:'2026-01-01',end:'2026-02-01',weight:100,progress:10},
{id:'b',SourceKey:'id:2',Code:'B',Name:'zero',start:'',end:'',weight:0,progress:40},
{id:'c',Code:'X',Name:'manual',start:'',end:'',weight:10,progress:20}];
p.progress=[{id:'one',locked:true,result:{rows:[{PkID:1,Code:'A',GN:10,TotalGN:2}]}},
{id:'two',locked:true,result:{rows:[{PkID:1,Code:'A',GN:10,TotalGN:3},{PkID:2,Code:'B',GN:0,TotalGN:0}]}}];
const before=JSON.stringify(p), updated=EstSchedule.refreshProgress(p);let bad=0;
p.progress[1].result.rows[0].TotalGN=11;try{EstSchedule.refreshProgress(p)}catch{bad++}
p.progress=[];try{EstSchedule.refreshProgress(p)}catch{bad++}
return {updated,bad,unchanged:JSON.parse(before).schedule};
})()""",
)
assert [row["progress"] for row in result["updated"]] == [30, 40, 20]
assert result["updated"][0]["start"] == "2026-01-01"
assert result["updated"][0]["weight"] == 100
assert result["unchanged"][0]["progress"] == 10
assert result["bad"] == 2
def test_annual_print_contains_each_stage_and_calculation_errors():
result = run(
"math",
"""(()=>{
const p=EstNative.empty();p.options.SourceRefresh=false;
p.tables.EstMainDat=[{PkID:1,Code:'R',GN:3,GU:100},{LinkIndex:54}];
p.annual=[{name:'first',formula:'[기준]*0.25'},{name:'second',formula:'[기준]-[first]'}];
const q=EstNative.calculate(p), good=EstForms.data('annual-summary',q), items=EstForms.data('annual-items',q);
q.annual[0].formula='[unknown]';
return {good,items,bad:EstForms.data('annual-summary',q),badItems:EstForms.data('annual-items',q)};
})()""",
)
assert [row["directCost"] for row in result["good"]["rows"]] == [75, 225]
assert result["good"]["rows"][1]["formula"] == "[기준]-[first]"
assert result["good"]["errors"] == []
assert result["bad"]["errors"]
assert [row["stage"] for row in result["items"]["rows"]] == ["first", "second"]
assert [row["TC"] for row in result["items"]["rows"]] == [75, 225]
assert result["badItems"]["errors"]
def test_progress_and_escalation_print_use_selected_calculations():
result = run(
"math",
"""(()=>{
const p=EstNative.empty();p.options.SourceRefresh=false;p.options.EscDesignTotal=1000;
p.tables.EstMainDat=[{PkID:1,Code:'R',Name:'work',GN:10,GU:100,AGU:100,EscActual:3},{LinkIndex:54}];
p.progress=[{id:'one',name:'first',inputs:{'1':{Gum:2}}}];
const q=EstProgress.lock(EstNative.calculate(p),'one');
q.tables.EstMainDat[0].GU=200;
q.progress.push({id:'two',name:'second',inputs:{'1':{Gum:3}}});
return {locked:EstForms.data('progress-summary',q,{roundId:'one'}),
draft:EstForms.data('progress-summary',q,{roundId:'two'}),
escalation:EstForms.data('escalation-summary',p),
empty:EstForms.data('progress-summary',EstNative.empty())};
})()""",
)
assert result["locked"]["rows"][0]["GumTC"] == 200
assert result["locked"]["rows"][0]["GU"] == 100
assert result["draft"]["rows"][0]["GumTC"] == 600
assert result["draft"]["rows"][0]["TotalTC"] == 800
assert result["escalation"]["rows"][0]["EscExcluded"] == 3
assert result["escalation"]["rows"][0]["EscApplied"] == 7
assert result["empty"]["errors"]
def test_progress_cost_print_preserves_selected_round_costs():
result = run(
"math",
"""(()=>{
const p=EstNative.empty();p.options.SourceRefresh=false;
p.tables.EstMainDat=[{PkID:1,Code:'R',GN:10,GU:100},{LinkIndex:54}];
p.tables.Wonga=[{Gubun:'D',Name:'direct',BaseField:'GC'},{Gubun:'T',Name:'total',LinkIndex:55,VSusic:'D'}];
p.progress=[{id:'one',inputs:{'1':{Gum:2,Sa:1}},approved:true}];
const q=EstProgress.lock(EstNative.calculate(p),'one');
q.tables.EstMainDat[0].GU=200;q.progress.push({id:'two',inputs:{'1':{Gum:3}}});
return {one:EstForms.data('progress-costs',q,{roundId:'one'}),two:EstForms.data('progress-costs',q,{roundId:'two'})};
})()""",
)
assert result["one"]["rows"][-1]["ContractCost"] == 1000
assert result["one"]["rows"][-1]["GumCost"] == 200
assert result["one"]["rows"][-1]["SaCost"] == 100
assert result["two"]["rows"][-1]["JunCost"] == 100
assert result["two"]["rows"][-1]["GumCost"] == 600
assert result["two"]["rows"][-1]["TotalCost"] == 700
def test_escalation_cost_print_uses_computed_cost_breakdowns():
result = run(
"math",
"""(()=>{
const p=EstNative.empty();p.options.SourceRefresh=false;p.options.EscDesignTotal=1000;
p.tables.EstMainDat=[{PkID:1,Code:'R',GN:10,GU:80},{LinkIndex:54}];
p.tables.Wonga=[{Gubun:'D',Name:'direct',BaseField:'GC'},{Gubun:'T',Name:'total',LinkIndex:55,VSusic:'D'}];
const q=EstBusiness.baseline(EstNative.calculate(p));
Object.assign(q.tables.EstMainDat[0],{AGU:100,GU:120,EscActual:3});
return EstForms.data('escalation-costs',q);
})()""",
)
assert result["errors"] == []
assert result["rows"][-1]["ContractCost"] == 800
assert result["rows"][-1]["adjustedCost"] == 960
assert result["rows"][-1]["excludedCost"] == 240
def test_resource_view_filtering_and_by_work_expansion():
result = run(
"math",
"""(()=>{
const p = structuredClone(project);
const allRows = EstResources.calculate(p, false).rows;
const byWorkRows = EstResources.calculate(p, true).rows;
const laborOnly = allRows.filter(r => r.kind === 6);
const materialOnly = allRows.filter(r => r.kind === 5);
const machineOnly = allRows.filter(r => r.kind === 105);
const laborByWork = byWorkRows.filter(r => r.kind === 6);
// 공종별 집계 합산과 전체 집계의 금액/수량 일치 검증
const laborAllQty = laborOnly.reduce((s, r) => s + r.quantity, 0);
const laborAllAmt = laborOnly.reduce((s, r) => s + r.amount, 0);
const laborWorkQty = laborByWork.reduce((s, r) => s + r.quantity, 0);
const laborWorkAmt = laborByWork.reduce((s, r) => s + r.amount, 0);
// 서식 데이터(EstForms.data) 연동 확인
const f112 = EstForms.data(112, p, {project: p});
const f13 = EstForms.data(13, p, {project: p});
return {
allCount: allRows.length,
byWorkCount: byWorkRows.length,
laborCount: laborOnly.length,
materialCount: materialOnly.length,
machineCount: machineOnly.length,
laborByWorkCount: laborByWork.length,
qtyDiff: Math.abs(laborAllQty - laborWorkQty),
amtDiff: Math.abs(laborAllAmt - laborWorkAmt),
f112Count: f112.rows.length,
f13Count: f13.rows.length,
f112HasWork: f112.columns.includes('WorkCode') && f112.columns.includes('WorkName')
};
})()""",
)
assert result["allCount"] == 27
assert result["byWorkCount"] == 72
assert result["laborCount"] == 7
assert result["materialCount"] == 13
assert result["machineCount"] == 7
assert result["laborByWorkCount"] == 30
assert result["qtyDiff"] < 1e-9
assert result["amtDiff"] < 1e-6
assert result["f112Count"] == 30
assert result["f13Count"] == 7
assert result["f112HasWork"] is True
def test_resource_report_separates_each_work_item_without_changing_aggregate():
result = run(
"math",
@@ -44,3 +244,249 @@ def test_schedule_dates_ranges_and_weighted_user_progress():
assert result["valid"]["rows"][0]["width"] == 50
assert result["valid"]["rows"][1]["left"] == 50
assert result["bad"] == [True] * 4
def test_schedule_import_and_update_weights_preserve_user_schedule():
result = run(
"math",
"""(()=>{
const p=EstNative.empty();
p.tables.EstMainDat=[{Code:'A',Name:'one',TC:100}, {Code:'B',Name:'two',TC:300},
{Code:'HEADER',Level:1,TC:999},{Code:'TOTAL',LinkIndex:54,TC:400}];
p.schedule=[{id:'old',Code:'A',Name:'custom',start:'2026-01-01',end:'2026-01-10',weight:1,progress:40},
{id:'manual',Code:'other',Name:'manual',start:'',end:'',weight:5,progress:20}];
let id=0;const added=EstSchedule.fromEstimate(p,()=>String(++id));p.schedule.push(...added);
p.tables.EstMainDat[0].TC=200;p.schedule=EstSchedule.refreshWeights(p);
let duplicate='';p.tables.EstMainDat.push({Code:'A',TC:20});
try{EstSchedule.fromEstimate(p,()=>String(++id))}catch(e){duplicate=e.message}
return {added,rows:p.schedule,duplicate};
})()""",
)
assert [row["Code"] for row in result["added"]] == ["B"]
assert [row["weight"] for row in result["rows"]] == [200, 5, 300]
assert result["rows"][0]["Name"] == "custom"
assert result["rows"][0]["start"] == "2026-01-01"
assert result["rows"][0]["progress"] == 40
assert result["duplicate"]
def test_schedule_imports_distinct_rows_with_repeated_or_empty_codes():
result = run(
"math",
"""(()=>{
const p=EstNative.empty();p.tables.EstMainDat=[
{PkID:1,Code:'X',Name:'one',TC:100},{PkID:2,Code:'X',Name:'two',TC:200},
{PkID:3,Code:null,Name:'three',TC:300}];
let id=0;p.schedule=EstSchedule.fromEstimate(p,()=>String(++id));
p.schedule[0].start='2026-01-01';p.schedule[0].progress=50;
p.tables.EstMainDat.reverse();p.tables.EstMainDat[0].TC=400;
const updated=EstSchedule.refreshWeights(p);
return {rows:p.schedule,updated,again:EstSchedule.fromEstimate(p,()=>String(++id))};
})()""",
)
assert [row["SourceKey"] for row in result["rows"]] == ["id:1", "id:2", "id:3"]
assert [row["weight"] for row in result["updated"]] == [100, 200, 400]
assert result["updated"][0]["start"] == "2026-01-01"
assert result["updated"][0]["progress"] == 50
assert result["again"] == []
def test_schedule_move_row_and_clear_operations():
result = run(
"math",
"""(()=>{
const p = EstNative.empty();
p.schedule = [
{id:'1', Code:'A', Name:'첫번째', start:'2026-01-01', end:'2026-01-10', weight:100, progress:10},
{id:'2', Code:'B', Name:'두번째', start:'2026-01-11', end:'2026-01-20', weight:200, progress:20},
{id:'3', Code:'C', Name:'세번째', start:'2026-01-21', end:'2026-01-31', weight:300, progress:30}
];
// 상하 경계 이동 무반응 확인
EstSchedule.moveRow(p, 0, -1);
const atTop = p.schedule.map(r => r.Code);
EstSchedule.moveRow(p, 2, 1);
const atBottom = p.schedule.map(r => r.Code);
// 중간 행 아래로 이동
EstSchedule.moveRow(p, 1, 1);
const afterDown = p.schedule.map(r => r.Code);
// 마지막 행 위로 이동
EstSchedule.moveRow(p, 2, -1);
const afterUp = p.schedule.map(r => r.Code);
// 공정 전체 비우기
const countBeforeClear = p.schedule.length;
EstSchedule.clearSchedule(p);
const countAfterClear = p.schedule.length;
return {
atTop,
atBottom,
afterDown,
afterUp,
countBeforeClear,
countAfterClear
};
})()""",
)
assert result["atTop"] == ["A", "B", "C"]
assert result["atBottom"] == ["A", "B", "C"]
assert result["afterDown"] == ["A", "C", "B"]
assert result["afterUp"] == ["A", "B", "C"]
assert result["countBeforeClear"] == 3
assert result["countAfterClear"] == 0
def test_quantity_print_includes_manual_and_sheet_formulas_and_errors():
result = run(
"math",
"""(()=>{
const p=EstNative.empty();p.quantities=[
{Code:'A',Name:'first',formula:'2',TargetCode:'X'},
{Code:'B',Name:'second',formula:'[A]*3',TargetCode:'X'},
{Code:'C',Name:'bad',formula:'[missing]',TargetCode:'X'}];
p.quantitySheets=[{Code:'Q',Name:'sheet',options:{},rows:[
{LineData:'4+5 = \\x120\\x12',markers:['r1']}],
items:[{Code:'r1',Name:'sheet item',GN:0,TargetCode:'Y'}]}];
return EstForms.data(14,p);
})()""",
)
assert [row["GN"] for row in result["rows"]] == [2, 6, None, 9]
assert result["rows"][1]["formula"] == "[A]*3"
assert "4+5" in result["rows"][3]["formula"]
assert result["rows"][3]["TargetCode"] == "Y"
assert result["rows"][2]["Error"]
assert result["errors"]
def test_public_view_forms_print_gong_method_options():
result = run(
"math",
"""(()=>{
const p = EstNative.empty();
p.tables.EstMainDat = [
{ PkID: 1, Level: 1, Code: 'C01', Name: '토공사' },
{ PkID: 2, Level: 0, Code: 'A01', Name: '터파기', Size: '일반', Unite: 'm3', GN: 100, TU: 500, TC: 50000, NC: 20000, GC: 30000, JC: 0, SC: 0 }
];
p.tables.HeadMainDat = [
{ Index: 1, Code: 'H01', Name: '터파기 호표', TC: 500 },
{ Index: 0, Code: 'L01', Name: '인부', Unite: '인', GN: 0.1, NU: 1000, GU: 0, JU: 0, SU: 0, TC: 100 }
];
// 1) 기본(PrintGongMethod=0): 공람용 내역서(117), 일위대가(115), 총괄표(116, 118) 수량만/금액제외
p.printSettings = { PrintGongMethod: 0 };
const d117_m0 = EstForms.data(117, p);
const d115_m0 = EstForms.data(115, p);
const d118_m0 = EstForms.data(118, p);
const d116_m0 = EstForms.data(116, p);
// 2) PrintGongMethod=1: 단가/금액만 (수량 제외)
p.printSettings = { PrintGongMethod: 1 };
const d117_m1 = EstForms.data(117, p);
const d115_m1 = EstForms.data(115, p);
// 3) PrintGongMethod=2: 수량 + 단가/금액 모두 포함
p.printSettings = { PrintGongMethod: 2 };
const d117_m2 = EstForms.data(117, p);
const d115_m2 = EstForms.data(115, p);
const d118_m2 = EstForms.data(118, p);
// 4) 표준 내역서(1) 및 일위대가(2)는 공람옵션 영향 없이 전체 열 출력
const d1 = EstForms.data(1, p);
const d2 = EstForms.data(2, p);
return {
d117_m0: d117_m0.columns,
d115_m0: d115_m0.columns,
d118_m0: d118_m0.columns,
d116_m0: d116_m0.columns,
d117_m1: d117_m1.columns,
d115_m1: d115_m1.columns,
d117_m2: d117_m2.columns,
d115_m2: d115_m2.columns,
d118_m2: d118_m2.columns,
d1: d1.columns,
d2: d2.columns
};
})()""",
)
# 1) m0 (수량만): GN만 포함, 단가/금액 비목 제외
assert (
"GN" in result["d117_m0"]
and "TC" not in result["d117_m0"]
and "TU" not in result["d117_m0"]
)
assert (
"GN" in result["d115_m0"]
and "TC" not in result["d115_m0"]
and "NU" not in result["d115_m0"]
)
assert "TC" not in result["d118_m0"] and "NC" not in result["d118_m0"]
assert "TC" not in result["d116_m0"] and "NC" not in result["d116_m0"]
# 2) m1 (단가만): GN 제외, 단가/금액 포함
assert "GN" not in result["d117_m1"] and "TC" in result["d117_m1"] and "TU" in result["d117_m1"]
assert "GN" not in result["d115_m1"] and "TC" in result["d115_m1"] and "NU" in result["d115_m1"]
# 3) m2 (수량, 단가 모두): GN, 단가, 금액 모두 포함
assert "GN" in result["d117_m2"] and "TC" in result["d117_m2"]
assert "GN" in result["d115_m2"] and "TC" in result["d115_m2"]
assert "TC" in result["d118_m2"]
# 4) 일반 서식(1, 2)은 불변 유지
assert "GN" in result["d1"] and "TC" in result["d1"]
assert "GN" in result["d2"] and "TC" in result["d2"]
def test_print_apply_field_and_sum_bottom_preserve_all_rows_and_no_destructive_filtering():
result = run(
"math",
"""(()=>{
const p = EstNative.empty();
// 공종(레벨1), 소계, 0원 항목, 음수 변경분, 일반 상세항목 혼재
p.tables.EstMainDat = [
{ Code: "", Name: "토공사", Size: "", Unite: "", GN: 0, TU: 0, TC: 0, Level: 1 },
{ Code: "A01", Name: "터파기", Size: "토사", Unite: "m3", GN: 100, TU: 5000, TC: 500000, NC: 300000, GC: 100000, JC: 100000, SC: 0, Level: 0 },
{ Code: "A02", Name: "잔토처리", Size: "덤프15t", Unite: "m3", GN: 50, TU: 0, TC: 0, NC: 0, GC: 0, JC: 0, SC: 0, Level: 0 },
{ Code: "A03", Name: "설계변경감소", Size: "규격", Unite: "식", GN: -10, TU: 10000, TC: -100000, NC: -50000, GC: -50000, JC: 0, SC: 0, Level: 0 },
{ Code: "", Name: "소계", Size: "", Unite: "", GN: 0, TU: 0, TC: 400000, Level: 0 }
];
p.tables.HeadMainDat = [
{ Code: "H01", Name: "일위대가항목", Size: "", Unite: "식", GN: 1, NU: 1000, GU: 2000, JU: 0, SU: 0, TC: 3000 },
{ Code: "H02", Name: "0원대가항목", Size: "", Unite: "", GN: 1, NU: 0, GU: 0, JU: 0, SU: 0, TC: 0 }
];
// PrintApplyField / PrintSumBottom 옵션이 어떤 값이든 행과 제목·0원·음수 행이 삭제되지 않고 100% 보존되는지 확인
p.printSettings = { PrintApplyField: 1, PrintSumBottom: 1 };
const d1 = EstForms.data(1, p);
const d2 = EstForms.data(2, p);
const d117 = EstForms.data(117, p);
const d115 = EstForms.data(115, p);
const grid1 = EstForms.grid(d1);
return {
d1Count: d1.rows.length,
d2Count: d2.rows.length,
d117Count: d117.rows.length,
d115Count: d115.rows.length,
hasZeroRow: d1.rows.some(r => r.Code === "A02" && r.TU === 0),
hasNegativeRow: d1.rows.some(r => r.Code === "A03" && r.TC === -100000),
hasTitleRow: d1.rows.some(r => r.Name === "토공사" && r.Level === 1),
hasSubtotalRow: d1.rows.some(r => r.Name === "소계"),
noInvalidSumBottomTfoot: !grid1.includes("<tfoot>")
};
})()""",
)
assert result["d1Count"] == 5
assert result["d2Count"] == 2
assert result["d117Count"] == 5
assert result["d115Count"] == 2
assert result["hasZeroRow"] is True
assert result["hasNegativeRow"] is True
assert result["hasTitleRow"] is True
assert result["hasSubtotalRow"] is True
assert result["noInvalidSumBottomTfoot"] is True
@@ -1,6 +1,42 @@
from test_est_rea_parity import run
def test_escalation_distinguishes_repeated_codes_without_row_ids():
result = run(
"math",
"""(()=>{
const p=EstNative.empty();p.options.SourceRefresh=false;p.options.EscDesignTotal=1000;
p.tables.EstMainDat=[{Code:'A',Name:'first',GN:10,GU:80},
{Code:'A',Name:'second',GN:20,GU:50},{LinkIndex:54}];
const q=EstBusiness.baseline(EstNative.calculate(p));
Object.assign(q.tables.EstMainDat[0],{AGU:100,GU:120,EscActual:2});
Object.assign(q.tables.EstMainDat[1],{AGU:100,GU:120,EscActual:5});
return EstEscalation.calculate(q);
})()""",
)
assert [row["EscApplied"] for row in result["rows"][:2]] == [8, 15]
assert [row["EGU"] for row in result["rows"][:2]] == [16, 10]
assert [row["ContractGU"] for row in result["rows"][:2]] == [80, 50]
assert result["excludedTotal"] == 410
def test_progress_exclusions_preserve_new_rows_and_reject_ambiguous_keys():
result = run(
"math",
"""(()=>{
const p=EstNative.empty();p.tables.EstMainDat=[{PkID:1,Code:'A',EscProgress:9},
{PkID:2,Code:'B',EscProgress:7}];
const before=JSON.stringify(p), saved={rows:[{PkID:1,Code:'A',TotalGN:3}]};
const updates=EstBusiness.progressExclusions(p,saved);let blocked=0;
try{EstBusiness.progressExclusions(p,{rows:[{Code:'A',TotalGN:1},{Code:'A',TotalGN:2}]})}catch{blocked++}
const q=structuredClone(p);q.tables.EstMainDat=[{Code:'A'},{Code:'A'}];
try{EstBusiness.progressExclusions(q,{rows:[{Code:'A',TotalGN:1}]})}catch{blocked++}
return {updates,blocked,same:before===JSON.stringify(p)};
})()""",
)
assert result == {"updates": [{"index": 0, "value": 3}], "blocked": 2, "same": True}
def test_escalation_preserves_explicit_zero_total_and_falls_back_only_without_wonga():
result = run(
"math",
+262
View File
@@ -0,0 +1,262 @@
from test_est_rea_parity import run
def test_estimate_changes_include_new_deleted_and_changed_link_types():
result = run(
"math",
"""(()=>{
const p=EstNative.empty();p.tables.EstMainDat=[
{PkID:1,Code:'A',Name:'changed',LinkIndex:101,GN:2,TC:200},
{PkID:2,Code:'B',Name:'changed link',LinkIndex:5,GN:1,TC:50},
{PkID:4,Code:'D',Name:'new',LinkIndex:101,GN:1,TC:40}];
p.baseline={tables:{EstMainDat:[
{PkID:1,Code:'A',Name:'changed',LinkIndex:101,GN:1,TC:100},
{PkID:2,Code:'B',Name:'changed link',LinkIndex:101,GN:1,TC:50},
{PkID:3,Code:'C',Name:'deleted',LinkIndex:101,GN:3,TC:300},
{PkID:5,Code:'E',Name:'other',LinkIndex:5,GN:1,TC:10}]}};
return {rows:EstEstimate.changes(p),print:EstForms.data('estimate-change',p)};
})()""",
)
rows = {row["Code"]: row for row in result["rows"]}
assert set(rows) == {"A", "B", "C", "D"}
assert rows["A"]["difference"] == 100
assert rows["B"]["status"] == "변경"
assert rows["C"]["status"] == "삭제" and rows["C"]["index"] is None
assert rows["D"]["status"] == "신규"
assert result["print"]["rows"] == result["rows"]
def test_estimate_usage_reports_applied_and_source_prices_without_mutation():
result = run(
"math",
"""(()=>{
const p=EstNative.empty();p.tables.EstiMateDat=[
{Code:'A',Name:'source',InputTC:12000,InputJC:12000},
{Code:'B',InputTC:5000,InputJC:5000},{Code:'B',InputTC:6000,InputJC:6000}
];
p.tables.EstMainDat=[
{Code:'A',Name:'work',LinkIndex:101,GN:2,TU:10000,JC:20000,TC:20000},
{Code:'B',Name:'duplicate',LinkIndex:101,GN:1,TU:4500,JC:4500,TC:4500},
{Code:'C',Name:'missing',LinkIndex:101,GN:1,TU:2000,JC:2000,TC:2000},
{Code:'A',Name:'material',LinkIndex:5,GN:1,TU:1000}
];
const before=JSON.stringify(p), rows=EstEstimate.usage(p), print=EstForms.data('estimate-usage',p);
return {rows,print,same:before===JSON.stringify(p)};
})()""",
)
assert result["same"]
assert [row["status"] for row in result["rows"]] == ["연결됨", "코드 중복", "연결 없음"]
assert result["rows"][0]["sourcePrice"] == 12000
assert result["rows"][0]["appliedPrice"] == 10000
assert result["rows"][0]["difference"] == -2000
assert result["rows"][1]["sourcePrice"] is None
assert result["print"]["rows"] == result["rows"]
def test_estimate_apply_row_and_all_prices():
result = run(
"math",
"""(()=>{
const p = EstNative.empty();
p.options.SourceRefresh = false;
p.options.AutoCalc = false;
p.tables.EstiMateDat = [
{ Code: 'A', Name: 'estimate A', InputTC: 15000, InputGC: 5000, InputNC: 7000, InputJC: 3000, InputSC: 0 },
{ Code: 'B', Name: 'estimate B', InputTC: 30000, InputJC: 30000 },
{ Code: 'D', Name: 'dup', InputTC: 5000 },
{ Code: 'D', Name: 'dup', InputTC: 6000 },
];
p.tables.EstMainDat = [
{ Code: 'A', Name: 'item A', LinkIndex: 101, GN: 2, TU: 10000, GU: 3000, NU: 5000, JU: 2000, GC: 6000, NC: 10000, JC: 4000, TC: 20000, SCheck: 1 },
{ Code: 'B', Name: 'item B', LinkIndex: 101, GN: 3, TU: 20000, JU: 20000, JC: 60000, TC: 60000 },
{ Code: 'C', Name: 'item C', LinkIndex: 101, GN: 1, TU: 5000, JU: 5000, JC: 5000, TC: 5000 },
{ Code: 'D', Name: 'item D', LinkIndex: 101, GN: 1, TU: 4000, JU: 4000, JC: 4000, TC: 4000 },
{ Code: 'OTHER', Name: 'other row', LinkIndex: 5, GN: 5, TU: 1000, GC: 5000, TC: 5000 },
];
const initialUsage = EstEstimate.usage(p);
const otherBefore = JSON.stringify(p.tables.EstMainDat[4]);
// 행 0(SCheck=1: 재료비 몰아주기 설정)에 단가 적용:
// SCheck=1이 보존되어 GU=15000, NU=0, JU=0, SU=0 및 GN=2 -> GC=30000, TC=30000
EstEstimate.applyRowPrice(p, 0);
const afterRow0 = {
row0_TU: p.tables.EstMainDat[0].TU,
row0_GU: p.tables.EstMainDat[0].GU,
row0_NU: p.tables.EstMainDat[0].NU,
row0_JU: p.tables.EstMainDat[0].JU,
row0_GC: p.tables.EstMainDat[0].GC,
row0_TC: p.tables.EstMainDat[0].TC,
row0_SCheck: p.tables.EstMainDat[0].SCheck,
otherPreserved: JSON.stringify(p.tables.EstMainDat[4]) === otherBefore,
sourceRefresh: p.options.SourceRefresh,
autoCalc: p.options.AutoCalc,
};
// SCheck를 0으로 초기화한 뒤 다시 적용하면 원천의 재료/노무/경비 비목 분할(5000/7000/3000)이 유지됨
p.tables.EstMainDat[0].SCheck = 0;
EstEstimate.applyRowPrice(p, 0);
const afterRow0_split = {
row0_TU: p.tables.EstMainDat[0].TU,
row0_GU: p.tables.EstMainDat[0].GU,
row0_NU: p.tables.EstMainDat[0].NU,
row0_JU: p.tables.EstMainDat[0].JU,
row0_GC: p.tables.EstMainDat[0].GC,
row0_NC: p.tables.EstMainDat[0].NC,
row0_JC: p.tables.EstMainDat[0].JC,
row0_TC: p.tables.EstMainDat[0].TC,
};
let errMissing = false;
try { EstEstimate.applyRowPrice(p, 2); } catch { errMissing = true; }
let errDup = false;
try { EstEstimate.applyRowPrice(p, 3); } catch { errDup = true; }
// 남은 대상인 행 1(수량 3)에 대해 applyAllPrices 적용
const appliedCount = EstEstimate.applyAllPrices(p);
const afterRow1 = {
row1_TU: p.tables.EstMainDat[1].TU,
row1_JU: p.tables.EstMainDat[1].JU,
row1_JC: p.tables.EstMainDat[1].JC,
row1_TC: p.tables.EstMainDat[1].TC,
};
// AutoCalc=true 전환 후 전체 재계산: 금액이 수량(GN)을 반영해 갱신됨
p.options.AutoCalc = true;
const calculated = EstNative.calculate(p);
const afterRecalc = {
row0_GC: calculated.tables.EstMainDat[0].GC,
row0_NC: calculated.tables.EstMainDat[0].NC,
row0_JC: calculated.tables.EstMainDat[0].JC,
row0_TC: calculated.tables.EstMainDat[0].TC,
row1_JC: calculated.tables.EstMainDat[1].JC,
row1_TC: calculated.tables.EstMainDat[1].TC,
};
return {
initialCanApply: initialUsage.filter(u => u.canApply).length,
afterRow0,
afterRow0_split,
errMissing,
errDup,
appliedCount,
afterRow1,
afterRecalc,
};
})()""",
)
assert result["initialCanApply"] == 2
# AutoCalc=false 시 단가는 GU=15000으로 갱신되나 기존 금액(GC=6000, TC=20000) 보존
assert result["afterRow0"]["row0_TU"] == 15000
assert result["afterRow0"]["row0_GU"] == 15000
assert result["afterRow0"]["row0_NU"] == 0
assert result["afterRow0"]["row0_JU"] == 0
assert result["afterRow0"]["row0_GC"] == 6000
assert result["afterRow0"]["row0_TC"] == 20000
assert result["afterRow0"]["row0_SCheck"] == 1
assert result["afterRow0"]["otherPreserved"] is True
assert result["afterRow0"]["sourceRefresh"] is False
assert result["afterRow0"]["autoCalc"] is False
# SCheck=0 시 원천 비목 분할(5000/7000/3000) 유지 및 기존 금액(GC=6000, NC=10000, JC=4000, TC=20000) 보존
assert result["afterRow0_split"]["row0_TU"] == 15000
assert result["afterRow0_split"]["row0_GU"] == 5000
assert result["afterRow0_split"]["row0_NU"] == 7000
assert result["afterRow0_split"]["row0_JU"] == 3000
assert result["afterRow0_split"]["row0_GC"] == 6000
assert result["afterRow0_split"]["row0_NC"] == 10000
assert result["afterRow0_split"]["row0_JC"] == 4000
assert result["afterRow0_split"]["row0_TC"] == 20000
assert result["errMissing"] is True
assert result["errDup"] is True
assert result["appliedCount"] == 1
# 행 1: 단가 30000 갱신, 수동계산 상태이므로 기존 금액(JC=60000, TC=60000) 보존
assert result["afterRow1"]["row1_TU"] == 30000
assert result["afterRow1"]["row1_JU"] == 30000
assert result["afterRow1"]["row1_JC"] == 60000
assert result["afterRow1"]["row1_TC"] == 60000
# AutoCalc=true 재계산 후 GN(수량)을 반영한 금액으로 정상 계산
assert result["afterRecalc"]["row0_GC"] == 10000
assert result["afterRecalc"]["row0_NC"] == 14000
assert result["afterRecalc"]["row0_JC"] == 6000
assert result["afterRecalc"]["row0_TC"] == 30000
assert result["afterRecalc"]["row1_JC"] == 90000
assert result["afterRecalc"]["row1_TC"] == 90000
def test_edge_estimate_usage_apply_price_and_undo_when_server_running():
from playwright.sync_api import sync_playwright
with sync_playwright() as p:
browser = p.chromium.launch(channel="msedge", headless=True)
page = browser.new_page()
try:
page.goto("http://100.65.141.15:5175", timeout=5000)
page.wait_for_function("() => Boolean(EstApp.state.project)")
except Exception:
browser.close()
import pytest
pytest.skip("EST 5175 server not reachable")
# 1. 테스트 프로젝트 데이터 세팅 (견적단가 및 내역 행 주입)
page.evaluate(
"""(() => {
const p = EstNative.empty();
p.options.SourceRefresh = false;
p.tables.EstiMateDat = [
{ Code: 'E001', Name: '견적비목 1', Size: '규격A', Unite: '식', InputTC: 50000, InputJC: 50000 },
{ Code: 'E002', Name: '견적비목 2', Size: '규격B', Unite: '식', InputTC: 80000, InputJC: 80000 },
];
p.tables.EstMainDat = [
{ PkID: 201, Code: 'E001', Name: '견적비목 1', Size: '규격A', Unite: '식', LinkIndex: 101, GN: 10, TU: 40000, JU: 40000, JC: 400000, TC: 400000 },
{ PkID: 202, Code: 'E002', Name: '견적비목 2', Size: '규격B', Unite: '식', LinkIndex: 101, GN: 5, TU: 70000, JU: 70000, JC: 350000, TC: 350000 },
];
EstApp.setProject(p);
EstApp.state.view = 'estimate-usage';
EstApp.render();
})()"""
)
# 2. 견적단가 사용 내역 화면 확인
assert page.locator("#view-title:has-text('견적단가 사용 내역')").is_visible()
btn_apply_all = page.locator("button[data-estimate-apply-all]")
assert btn_apply_all.is_visible()
assert "기초단가 일괄 적용 (2건)" in btn_apply_all.text_content()
# 3. 개별 단가 적용 버튼 클릭 (1행 E001)
btn_row_apply = page.locator("button[data-estimate-apply-row='0']")
assert btn_row_apply.is_visible()
btn_row_apply.click()
# 1행이 50,000으로 갱신되었는지 확인
tu_row0 = page.evaluate("EstApp.state.project.tables.EstMainDat[0].TU")
tc_row0 = page.evaluate("EstApp.state.project.tables.EstMainDat[0].TC")
assert tu_row0 == 50000
assert tc_row0 == 500000
# 버튼 라벨이 (1건)으로 변경되었는지 확인
assert "기초단가 일괄 적용 (1건)" in btn_apply_all.text_content()
# 4. 일괄 적용 버튼 클릭 (남은 1건 E002 적용)
btn_apply_all.click()
tu_row1 = page.evaluate("EstApp.state.project.tables.EstMainDat[1].TU")
tc_row1 = page.evaluate("EstApp.state.project.tables.EstMainDat[1].TC")
assert tu_row1 == 80000
assert tc_row1 == 400000
# 이제 일괄 적용 버튼은 disabled 상태인지 확인
assert btn_apply_all.is_disabled()
# 5. Undo(실행 취소) 검증
page.locator(".toolbar button[data-action='undo']").click()
# 1단계 취소 후 2행은 원래대로 70,000 복구
assert page.evaluate("EstApp.state.project.tables.EstMainDat[1].TU") == 70000
page.locator(".toolbar button[data-action='undo']").click()
# 2단계 취소 후 1행도 원래대로 40,000 복구
assert page.evaluate("EstApp.state.project.tables.EstMainDat[0].TU") == 40000
browser.close()
+358
View File
@@ -10,6 +10,33 @@ from test_est_rea_parity import run
ROOT = Path(__file__).resolve().parents[2]
def test_report_excel_contains_numeric_values_and_separate_errors():
import base64
import openpyxl
script = """const {server}=require('./tools/est-test/server.cjs');
server.listen(0,'127.0.0.1',async()=>{try{
const url=`http://127.0.0.1:${server.address().port}/api/export-report-xlsx`;
const post=body=>fetch(url,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});
const ok=await post({name:'test',columns:['Name','Value'],rows:[['=1+1',7]],detail:'summary',errors:['bad input']});
const bad=await post({columns:['Name'],rows:[[null]],errors:[]});
process.stdout.write(JSON.stringify({data:Buffer.from(await ok.arrayBuffer()).toString('base64'),status:bad.status}));
}finally{server.close()}});"""
result = json.loads(
subprocess.run(
["node", "-e", script], cwd=ROOT, capture_output=True, check=True, timeout=15
).stdout
)
workbook = openpyxl.load_workbook(io.BytesIO(base64.b64decode(result["data"])))
assert workbook["Report"]["A2"].value == "=1+1"
assert workbook["Report"]["A2"].data_type == "s"
assert workbook["Report"]["B2"].value == 7
assert workbook["Summary"]["A2"].value == "summary"
assert workbook["Errors"]["A2"].value == "bad input"
assert result["status"] == 400
workbook.close()
def export_workbook(project):
result = subprocess.run(
[
@@ -25,6 +52,60 @@ def export_workbook(project):
return result.stdout
def test_excel_escalation_fields_and_settings_roundtrip_validation():
import openpyxl
workbook = openpyxl.load_workbook(
io.BytesIO(
export_workbook(
{
"tables": {
"EstMainDat": [{"PkID": 1, "Code": "R", "AGU": 80}, {"LinkIndex": 54}]
},
"options": {"EscDesignTotal": 1200},
}
)
)
)
sheet = workbook["Escalation"]
assert "AGU" not in [cell.value for cell in workbook["EstMainDat"][1]]
assert list(next(sheet.values)) == [
"RowKey",
"ANU",
"AGU",
"AJU",
"ASU",
"EscPlanned",
"EscActual",
"EscProgress",
"EscAuto",
"EscManualQuantity",
"EscConstruction",
"EscPS",
"EscBefore",
]
assert workbook["EscalationSettings"]["A2"].value == 1200
result = run(
"math",
"""(()=>{
const p=EstNative.empty();p.options.SourceRefresh=false;
p.tables.EstMainDat=[{PkID:1,Code:'R',GN:10,GU:100},{LinkIndex:54}];
const before=JSON.stringify(p), sheets={Escalation:[{RowKey:'1',AGU:80,EscActual:3,EscAuto:false,EscManualQuantity:2}],EscalationSettings:[{EscDesignTotal:1200}]};
const q=EstExchange.apply(p,sheets), output=EstEscalation.calculate(q);
let bad=0;
for(const rows of [[{RowKey:'missing'}],[{RowKey:'1'},{RowKey:'1'}],[{RowKey:'1',EscAuto:'false'}],[{RowKey:'1',AGU:'wrong'}]]) {
try{EstExchange.apply(p,{Escalation:rows})}catch{bad++}
}
try{EstExchange.apply(p,{EscalationSettings:[{EscDesignTotal:-1}]})}catch{bad++}
return {q,output,bad,same:before===JSON.stringify(p)};
})()""",
)
assert result["same"] and result["bad"] == 5
assert result["q"]["options"]["EscDesignTotal"] == 1200
assert result["q"]["tables"]["EstMainDat"][0]["AGU"] == 80
assert result["output"]["rows"][0]["EscExcluded"] == 2
def test_excel_is_valid_office_zip_with_numeric_text_types_and_no_executable_formulas():
data = export_workbook(
{
@@ -86,3 +167,280 @@ def test_excel_price_edits_refresh_linked_values_and_keep_binary_fields():
assert result["rejected"]
assert result["next"]["tables"]["EstMainDat"][-1]["TC"] == 300
assert result["next"]["tables"]["StuffcDat"][0]["Editor"]["binary"] == "AAAA"
def test_excel_exports_manual_quantities_and_schedule():
import openpyxl
data = export_workbook(
{
"name": "project",
"tables": {},
"quantities": [
{
"Code": "Q1",
"Name": "quantity",
"formula": "[A]*3",
"digits": 2,
"TargetKey": "id:5",
}
],
"schedule": [
{
"id": "s1",
"SourceKey": "id:5",
"Code": "",
"Name": "work",
"start": "2026-01-01",
"end": "2026-01-10",
"weight": 100,
"progress": 25,
}
],
}
)
workbook = openpyxl.load_workbook(io.BytesIO(data), data_only=False)
assert list(workbook["Quantities"].values)[1] == ("Q1", "quantity", None, "id:5", "[A]*3", 2)
assert list(workbook["Schedule"].values)[1] == (
"s1",
"id:5",
"",
"work",
"2026-01-01",
"2026-01-10",
100,
25,
)
def test_excel_quantity_links_export_and_import_reject_invalid_targets():
import openpyxl
link = {"target": 5, "file": "source.xlsx", "sheet": "Data", "cell": "B2"}
data = export_workbook({"name": "project", "tables": {}, "excelQuantityLinks": [link]})
workbook = openpyxl.load_workbook(io.BytesIO(data), data_only=False)
assert list(workbook["ExcelQuantityLinks"].values)[1] == (5, "source.xlsx", "Data", "B2")
workbook.close()
result = run(
"math",
"""(()=>{
const p=EstNative.empty();p.tables.EstMainDat=[{PkID:5,Code:'X',GN:1}];
const link={target:5,file:'source.xlsx',sheet:'Data',cell:'B2'};
const q=EstExchange.apply(p,{ExcelQuantityLinks:[link]});let rejected=0;
for(const rows of [[{...link,target:9}],[link,link],[{...link,cell:'XFE1'}]]){
try{EstExchange.apply(p,{ExcelQuantityLinks:rows,EstMainDat:[{PkID:5,Code:'X',GN:7}]})}catch{rejected++}
}
const fresh=EstExchange.apply(EstNative.empty(),{EstMainDat:[{PkID:5,Code:'X',GN:7}],ExcelQuantityLinks:[link]});
return {links:q.excelQuantityLinks,rejected,original:p.tables.EstMainDat[0].GN,fresh:fresh.excelQuantityLinks};
})()""",
)
assert result == {"links": [link], "rejected": 3, "original": 1, "fresh": [link]}
def test_excel_import_does_not_reuse_deleted_excel_link_target():
result = run(
"math",
"""(()=>{
const p=EstNative.empty();p.tables.EstMainDat=[{PkID:1,Code:'A',GN:1}];
const link={target:2,file:'source.xlsx',sheet:'Data',cell:'B2'};p.excelQuantityLinks=[link];
const rows=[{PkID:1,Code:'A',GN:2},{PkID:2,Code:'B',GN:5}];let rejected=0;
for(const data of [{EstMainDat:rows},{EstMainDat:rows,ExcelQuantityLinks:[link]}]){
try{EstExchange.apply(p,data)}catch{rejected++}
}
const cleared=EstExchange.apply(p,{EstMainDat:rows,ExcelQuantityLinks:[]});
return {rejected,original:p.tables.EstMainDat[0].GN,cleared:cleared.excelQuantityLinks.length};
})()""",
)
assert result == {"rejected": 2, "original": 1, "cleared": 0}
def test_excel_imports_manual_quantities_and_schedule_and_rejects_invalid():
result = run(
"math",
"""(()=>{
const p=EstNative.empty();p.tables.EstMainDat=[{PkID:5,Code:'',Name:'work',GN:1}];
const qty={Code:'Q1',Name:'quantity',formula:'2*3',digits:2,TargetKey:'id:5',TargetCode:''};
const sched={id:'s1',SourceKey:'id:5',Code:'',Name:'work',start:'2026-01-01',end:'2026-01-10',weight:100,progress:25};
const q=EstExchange.apply(p,{Quantities:[qty],Schedule:[sched]});
EstQuantity.applyTotals(q,EstQuantity.totals(q,'manual'));
let invalid=0;for(const bad of [{Quantities:[qty,qty]},{Quantities:[{...qty,digits:7}]},
{Schedule:[{...sched,progress:101}]}])try{EstExchange.apply(p,bad)}catch{invalid++}
return {q,invalid,original:p};
})()""",
)
assert result["q"]["tables"]["EstMainDat"][0]["GN"] == 6
assert result["q"]["schedule"][0]["SourceKey"] == "id:5"
assert result["q"]["schedule"][0]["progress"] == 25
assert result["original"]["tables"]["EstMainDat"][0]["GN"] == 1
assert result["invalid"] == 3
def test_excel_writes_quantity_marker_controls_as_valid_xml():
import openpyxl
data = export_workbook(
{
"name": "project",
"tables": {"CostMainDat": [{"PkID": 1, "LineData": "A=\x121.5\x12 _x0012_"}]},
}
)
workbook = openpyxl.load_workbook(io.BytesIO(data))
assert workbook["CostMainDat"]["B2"].value == "A=_x0012_1.5_x0012_ _x005F_x0012_"
assert (
run("math", "EstExchange.decodeExcelText('A=_x0012_1.5_x0012_ _x005F_x0012_')")
== "A=\x121.5\x12 _x0012_"
)
def test_excel_exports_and_imports_quantity_documents_with_markers():
import openpyxl
data = export_workbook(
{
"name": "project",
"tables": {},
"quantitySheets": [
{
"Code": "Q",
"Name": "sheet",
"options": {"digits": 2, "roundMode": 2},
"rows": [{"LineData": "2*3 = \x120\x12", "markers": ["r1"]}],
"items": [{"Code": "r1", "Name": "item", "GN": 0, "TargetKey": "id:5"}],
}
],
}
)
workbook = openpyxl.load_workbook(io.BytesIO(data))
assert workbook["QDocuments"]["A2"].value == "Q"
assert workbook["QLines"]["C2"].value == "2*3 = _x0012_0_x0012_"
assert workbook["QItems"]["I2"].value == "id:5"
result = run(
"math",
"""(()=>{
const p=EstNative.empty();p.tables.EstMainDat=[{PkID:5,Code:'',GN:1}];
p.quantitySheets=[{Code:'Q',Name:'old',options:{digits:2,roundMode:2},
rows:[{LineData:'1 = \\x120\\x12',markers:['r1']}],
items:[{Code:'r1',Name:'old',GN:0,Extra:'preserved'}]}];
const tables={QDocuments:[{Code:'Q',Name:'edited',digits:2,roundMode:2}],
QLines:[{DocumentCode:'Q',Position:1,LineData:'2*3 = \\x120\\x12',Markers:'["r1"]'}],
QItems:[{DocumentCode:'Q',Position:1,Code:'r1',Name:'item',GN:0,TargetKey:'id:5'}]};
const q=EstExchange.apply(p,tables),out=EstQuantity.calculate(q.quantitySheets[0]);
EstQuantity.applyTotals(q,EstQuantity.totals(q,'Q'));
let rejected=0;for(const bad of [{QDocuments:tables.QDocuments},
{...tables,QLines:[{...tables.QLines[0],Markers:'not-json'}]},
{...tables,QItems:[...tables.QItems,...tables.QItems]}])
try{EstExchange.apply(p,bad)}catch{rejected++}
return {q,out,rejected,original:p.quantitySheets[0].Name};
})()""",
)
assert result["q"]["quantitySheets"][0]["Name"] == "edited"
assert result["q"]["quantitySheets"][0]["items"][0]["Extra"] == "preserved"
assert result["q"]["tables"]["EstMainDat"][0]["GN"] == 6
assert result["out"]["errors"] == []
assert result["rejected"] == 3
assert result["original"] == "old"
def test_excel_quantity_document_roundtrip_keeps_optional_original_fields():
result = run(
"math",
"""(()=>{
const p=EstNative.empty();p.quantitySheets=[{Code:'Q',Name:'sheet',options:{digits:2,roundMode:2},
rows:[{LineData:'1 = \\x120\\x12',markers:['r1']}],
items:[{Code:'r1',Name:'item',Size:null,Unite:null,GN:null,LinkCode:'X',Extra:'keep'}]}];
const tables={QDocuments:[{Code:'Q',Name:'sheet',digits:2,roundMode:2}],
QLines:[{DocumentCode:'Q',Position:1,LineData:'1 = \\x120\\x12',Markers:'["r1"]'}],
QItems:[{DocumentCode:'Q',Position:1,Code:'r1',Name:'item',Size:null,Unite:null,GN:null,TargetCode:'X',TargetKey:null}]};
return {before:p.quantitySheets,after:EstExchange.apply(p,tables).quantitySheets};
})()""",
)
assert result["before"] == result["after"]
def test_excel_exports_and_imports_contract_review_inputs():
import openpyxl
data = export_workbook(
{
"name": "project",
"tables": {},
"contractReview": {"edits": {"row:1": {"GN": 2, "GU": 80, "Reason": "checked"}}},
}
)
workbook = openpyxl.load_workbook(io.BytesIO(data))
assert workbook["Review"]["A2"].value == "row:1"
assert workbook["Review"]["D2"].value == 80
assert workbook["Review"]["K2"].value == "checked"
result = run(
"math",
"""(()=>{
const p=EstNative.empty();p.tables.EstMainDat=[{PkID:1,Code:'A',GN:2,GU:100}];
const row={RowKey:'row:1',GN:1,GU:80,Reason:'checked'};
const q=EstExchange.apply(p,{Review:[row]});let rejected=0;
for(const bad of [[row,row],[{...row,GU:'bad'}],[{...row,RowKey:'not-key'}],
[{...row,RowKey:'row:missing'}],[{RowKey:'row:1',GC:4}]])
try{EstExchange.apply(p,{Review:bad})}catch{rejected++}
return {review:EstReview.calculate(q),edit:q.contractReview.edits['row:1'],rejected};
})()""",
)
assert result["review"]["after"] == 80
assert result["edit"]["Reason"] == "checked"
assert result["rejected"] == 5
def test_excel_exports_only_unlocked_progress_and_annual_stages():
import openpyxl
data = export_workbook(
{
"name": "project",
"tables": {},
"annual": [{"name": "first", "formula": "[기준]*0.5"}],
"progress": [
{"id": "locked", "name": "old", "locked": True, "inputs": {"1": {"Gum": 2}}},
{
"id": "draft",
"name": "new",
"locked": False,
"inputs": {"1": {"Gum": 3, "GumMode": "quantity"}},
},
],
}
)
workbook = openpyxl.load_workbook(io.BytesIO(data))
assert workbook["Annual"]["A2"].value == "first"
assert workbook["ProgressDrafts"]["A2"].value == "draft"
assert workbook["ProgressDrafts"].max_row == 2
assert workbook["ProgressInputs"]["A2"].value == "draft"
assert workbook["ProgressInputs"]["D2"].value == 3
def test_excel_imports_progress_drafts_without_changing_locked_rounds():
result = run(
"math",
"""(()=>{
const p=EstNative.empty();p.options.SourceRefresh=false;
p.tables.EstMainDat=[{PkID:1,Code:'A',GN:10,GU:100},{LinkIndex:54}];
p.progress=[{id:'locked',name:'old',inputs:{'1':{Gum:2}}}];
const q=EstProgress.lock(EstNative.calculate(p),'locked');
q.progress.push({id:'draft',name:'new',locked:false,inputs:{'1':{Gum:1}}});
const locked=JSON.stringify(q.progress[0]);
const tables={ProgressDrafts:[{id:'draft',name:'new',approved:false,separateComponents:true}],
ProgressInputs:[{RoundId:'draft',RowKey:'1',GumMode:'quantity',Gum:3}],
Annual:[{name:'first',formula:'[기준]*0.5'}]};
const next=EstExchange.apply(q,tables),progress=EstProgress.calculate(next,'draft');
let rejected=0;for(const bad of [
{ProgressDrafts:tables.ProgressDrafts},
{...tables,ProgressInputs:[{RoundId:'locked',RowKey:'1',Gum:9}]},
{...tables,ProgressDrafts:[{id:'locked'}]},
{...tables,ProgressInputs:[{RoundId:'draft',RowKey:'1',GumMode:'invalid'}]},
{...tables,ProgressInputs:[{RoundId:'draft',RowKey:'missing',Gum:1}]}])
try{EstExchange.apply(q,bad)}catch{rejected++}
return {same:JSON.stringify(next.progress[0])===locked,progress,annual:next.annual,rejected};
})()""",
)
assert result["same"]
assert result["progress"]["rows"][0]["JunTC"] == 200
assert result["progress"]["rows"][0]["TotalTC"] == 500
assert result["annual"] == [{"name": "first", "formula": "[기준]*0.5"}]
assert result["rejected"] == 5
+536
View File
@@ -0,0 +1,536 @@
from test_est_rea_parity import run
def test_data_issues_detect_duplicate_missing_and_mismatched_links_without_modifying_data():
result = run(
"math",
"""(()=>{
const p=EstNative.empty();p.tables.StuffcDat=[{Code:'A',Name:'sand',Size:'large'},
{Code:'B',Name:'first'},{Code:'B',Name:'second'}];
p.tables.HeadMainDat=[{Index:1,Code:'H',Name:'group'},{Index:0,Code:'A',LinkIndex:5,Name:'sand',Size:'large'}];
p.tables.EstMainDat=[{Code:'A',LinkIndex:5,Name:'stone',Size:'small'},
{Code:'B',LinkIndex:5,Name:'ambiguous'},{Code:'missing',LinkIndex:5,Name:'missing'},
{Code:'H',LinkIndex:103,Name:'group'}];
const before=JSON.stringify(p),issues=EstIssues.scan(p);
return {issues,same:before===JSON.stringify(p)};
})()""",
)
assert result["same"]
items = {(row["table"], row["index"], row["message"]) for row in result["issues"]}
assert ("StuffcDat", 1, "코드 중복") in items
assert ("StuffcDat", 2, "코드 중복") in items
assert ("EstMainDat", 0, "연결 품명 차이") in items
assert ("EstMainDat", 0, "연결 규격 차이") in items
assert ("EstMainDat", 1, "연결 코드 중복") in items
assert ("EstMainDat", 2, "연결 자료 없음") in items
assert len(items) == 6
def test_data_issues_detect_cost_main_dat_missing_and_duplicate_links():
result = run(
"math",
"""(()=>{
const p = EstNative.empty();
p.tables.StuffcDat = [{Code:'M_GOOD', Name:'자재1'}, {Code:'M_DUP', Name:'자재2'}, {Code:'M_DUP', Name:'자재2-복사'}];
p.tables.CostMainDat = [
{Index:1, Code:'D001', Name:'단가산출호표1'},
{Index:0, Code:'M_GOOD', LinkIndex:5, LineData:'정상 자재행'},
{Index:0, Code:'M_MISSING', LinkIndex:5, LineData:'미연결 자재행'},
{Index:0, Code:'M_DUP', LinkIndex:5, LineData:'중복 자재행'}
];
const issues = EstIssues.scan(p);
return {
costIssues: issues.filter(x => x.table === 'CostMainDat')
};
})()""",
)
items = {(row["index"], row["code"], row["message"]) for row in result["costIssues"]}
assert (2, "M_MISSING", "연결 자료 없음") in items
assert (3, "M_DUP", "연결 코드 중복") in items
assert len(items) == 2
def test_data_issues_filtering_by_table_and_type():
result = run(
"math",
"""(()=>{
const p = EstNative.empty();
p.tables.StuffcDat = [{Code:'M_DUP', Name:'A'}, {Code:'M_DUP', Name:'B'}];
p.tables.EstMainDat = [{Code:'M_MISS', LinkIndex:5, Name:'없음'}];
const all = EstIssues.scan(p);
const stuffOnly = all.filter(i => (i.table || '') === 'StuffcDat');
const dupOnly = all.filter(i => i.message === '코드 중복');
return {
total: all.length,
stuffCount: stuffOnly.length,
dupCount: dupOnly.length
};
})()""",
)
assert result["total"] == 3
assert result["stuffCount"] == 2
assert result["dupCount"] == 2
def test_cost_error_location_requires_unique_group_and_valid_row():
result = run(
"math",
"""(()=>{
const p=EstNative.empty();
p.tables.CostMainDat=[{Index:1,Code:'A'}, {Index:0}, {Index:0},
{Index:1,Code:'B'}, {Index:0}, {Index:1,Code:'B'}, {Index:0}];
p.calculation={errors:['A:2 bad formula','A:8 invalid row','B:2 duplicate','other error']};
return EstIssues.scan(p).filter(x=>x.message.includes('formula') ||
x.message.includes('row') || x.message.includes('duplicate') || x.message==='other error');
})()""",
)
assert result[0]["table"] == "CostMainDat"
assert result[0]["index"] == 1
assert all(row["table"] == "" for row in result[1:])
def test_data_issues_detect_quantity_target_missing_and_duplicate():
result = run(
"math",
"""(()=>{
const p = EstNative.empty();
p.tables.EstMainDat = [
{ PkID: 101, Code: 'E1', Name: '단일 대상' },
{ PkID: 201, Code: 'DUP', Name: '중복 대상 1' },
{ PkID: 202, Code: 'DUP', Name: '중복 대상 2' }
];
p.quantitySheets = [
{
Code: 'Q1',
items: [
{ Code: 'r1', TargetCode: 'E1' },
{ Code: 'r2', TargetCode: 'MISSING' },
{ Code: 'r3', TargetCode: 'DUP' },
// 두 행이 코드 중복('DUP')이지만 TargetKey('id:201')로 고유하게 연결된 정상 사례
{ Code: 'r4', TargetCode: 'DUP', TargetKey: 'id:201' },
// LinkCode fallback으로 정상 연결된 사례
{ Code: 'r5', LinkCode: 'E1' },
// 잘못된 TargetKey 사례
{ Code: 'r6', TargetKey: 'id:999' }
]
}
];
p.quantities = [
{ Code: 'm1', TargetKey: 'id:202' },
{ Code: 'm2', TargetCode: 'MISSING_M' }
];
return EstIssues.scan(p).filter(x => x.table === 'quantity');
})()""",
)
items = {(row["code"], row["message"], row["sheetCode"]) for row in result}
# r1: 정상 (E1 단일 매칭) -> 오류 없음
# r4: 정상 (DUP이지만 TargetKey id:201 지정) -> 오류 없음
# r5: 정상 (LinkCode E1 지정) -> 오류 없음
# m1: 정상 (수식표 id:202 지정) -> 오류 없음
assert ("r2", "수량 대상 내역 없음: MISSING", "Q1") in items
assert ("r3", "수량 대상 내역 중복: DUP", "Q1") in items
assert ("r6", "수량 대상 내역 없음: id:999", "Q1") in items
assert ("m2", "수량 대상 내역 없음: MISSING_M", "manual") in items
assert len(items) == 4
def test_edge_issues_click_quantity_navigates_to_sheet():
from playwright.sync_api import sync_playwright
import pytest
with sync_playwright() as p:
browser = p.chromium.launch(channel="msedge", headless=True)
page = browser.new_page()
try:
page.goto("http://100.65.141.15:5175", timeout=5000)
except Exception:
pytest.skip("EST 5175 server not reachable")
page.wait_for_function("() => Boolean(EstApp.state.project)")
# 오류 검색 화면으로 전환 및 인위적 오류 수량산출서 주입
page.evaluate("""() => {
EstApp.change(p => {
p.quantitySheets ||= [];
p.quantitySheets.push({
Code: 'TEST_ERR_Q',
Name: '오류 테스트 산출서',
rows: [{ LineData: '1 = \\x120\\x12', markers: ['e_r1'] }],
items: [{ Code: 'e_r1', TargetCode: 'NON_EXISTENT_CODE_XYZ', GN: 10 }]
});
}, false);
EstApp.navigate('issues');
}""")
# 오류 검색 화면에서 '산출서 열기' 버튼 탐색 및 클릭
btn = page.locator("button[data-issue-quantity='TEST_ERR_Q']")
assert btn.count() >= 1
btn.first.click()
# 수량산출 화면으로 전환되었고 해당 산출서가 활성화되었는지 확인
page.wait_for_function("() => EstApp.state.view === 'quantity'")
current_view = page.evaluate("EstApp.state.view")
current_sheet = page.evaluate("EstApp.state.quantitySheet")
assert current_view == "quantity"
assert current_sheet == "TEST_ERR_Q"
# 다시 오류 검색 화면으로 돌아가서 바로 내역 연결 드롭다운 선택 시험
page.evaluate("""() => {
EstApp.navigate('issues');
}""")
page.wait_for_function("() => EstApp.state.view === 'issues'")
# select[data-issue-target-select='TEST_ERR_Q'] 요소 확인 및 옵션 선택
select = page.locator("select[data-issue-target-select='TEST_ERR_Q']")
assert select.count() >= 1
# 첫 번째 유효 내역 행의 value를 선택
options = select.locator("option")
assert options.count() > 1
target_val = options.nth(1).get_attribute("value")
select.select_option(target_val)
# 상태 변경이 작업본(project)에 즉시 반영되었는지 확인
linked_key = page.evaluate("""() => {
const sheet = EstApp.state.project.quantitySheets.find(s => s.Code === 'TEST_ERR_Q');
return sheet ? sheet.items[0].TargetKey : null;
}""")
assert linked_key == target_val
browser.close()
def test_data_issues_detect_wonga_formula_syntax_and_missing_reference_errors():
result = run(
"math",
"""(()=>{
const p = structuredClone(project);
const normalIssues = EstIssues.scan(p).filter(x => x.table === 'Wonga');
// 원가 계산서 항목에 잘못된 참조 및 잘못된 범위 산식 주입
const badP = structuredClone(project);
badP.tables.Wonga[5].VSusic = '4 * 0.165 + UNKNOWN_VAR';
badP.tables.Wonga[21].VSusic = '(6:INVALID_RANGE_END)';
const badIssues = EstIssues.scan(badP).filter(x => x.table === 'Wonga');
return {
normalCount: normalIssues.length,
badIssues
};
})()""",
)
assert result["normalCount"] == 0
items = {(row["index"], row["code"], row["message"]) for row in result["badIssues"]}
assert (5, "5", "참조 항목 없음: UNKNOWN_VAR") in items
assert (21, "C", "범위 참조 없음: 6:INVALID_RANGE_END") in items
assert len(items) == 2
def test_wonga_scan_handles_division_zero_values_cycles_ranges_and_basefield():
result = run(
"math",
"""(()=>{
// 1) 실제 값이 다른 A, B의 유효한 1/(A-B) 나눗셈 (scan에서 1로 단순 치환하지 않고 실제 값으로 평가해 0으로 나눔 오탐 없음)
const w1 = [{Gubun:'A',Cost:10},{Gubun:'B',Cost:5},{Gubun:'C',VSusic:'1/(A-B)'}];
const sc1 = EstWonga.scan(w1);
// 2) 실제 0값 참조 계산 (A가 0이고 A + 10 정상 평가)
const w2 = [{Gubun:'A',Cost:0},{Gubun:'B',VSusic:'A + 10'}];
const sc2 = EstWonga.scan(w2);
// 3) 상호 순환 참조 감지 (A -> B -> A)
const w3 = [{Gubun:'A',VSusic:'B'},{Gubun:'B',VSusic:'A'}];
const sc3 = EstWonga.scan(w3);
// 4) BaseField 우선: VSusic에 유효하지 않은 식이어도 BaseField가 우선 적용되어 오류 없음
const w4 = [{Gubun:'A',BaseField:'NC',VSusic:'INVALID_IF_EVALUATED',Cost:0}];
const sc4 = EstWonga.scan(w4, {}, {NC: 500});
// 5) 정상 범위 참조 (A:B 역순/정순 포함)
const w5 = [{Gubun:'B',Cost:10},{Gubun:'A',Cost:20},{Gubun:'C',VSusic:'(A:B)'}];
const sc5 = EstWonga.scan(w5);
// 6) 유효하지 않은 범위 참조
const w6 = [{Gubun:'A',Cost:10},{Gubun:'B',VSusic:'(A:INVALID)'}];
const sc6 = EstWonga.scan(w6);
return {
sc1, sc2, sc3, sc4, sc5, sc6
};
})()""",
)
# 1) A!=B 나눗셈: 오탐 0건
assert result["sc1"] == []
# 2) 0값 참조: 오탐 0건
assert result["sc2"] == []
# 3) 순환 참조: calculate와 동일하게 순환 탐지
assert any("원가 수식 순환 참조" in item["message"] for item in result["sc3"])
assert len(result["sc3"]) >= 1
# 4) BaseField 우선: 수식 무시되고 기본 비목 매핑되어 오탐 0건
assert result["sc4"] == []
# 5) 정상 범위 (A:B): 정상 합산되어 오탐 0건
assert result["sc5"] == []
# 6) 비정상 범위: 오류 정확 탐지
assert any("범위 참조 없음: A:INVALID" in item["message"] for item in result["sc6"])
assert len(result["sc6"]) == 1
def test_wonga_scan_parity_with_calculate_forward_refs_old_cost_bases_ranges_and_cycles():
result = run(
"math",
"""(()=>{
// 1) 전방 참조 및 오래된 Cost:
// B의 수식이 'A*2'로 실제 값은 20이지만 저장된 Cost=0 상태.
// C가 '100/B'를 계산할 때, 오래된 Cost(0)를 쓰면 0으로 나눔 오류이지만,
// calculate와 동일하게 재귀 평가 시 B=20이 되어 C=5 정상 계산되고 오탐 없어야 함.
const w1 = [
{Gubun:'B',Cost:0,VSusic:'A*2'},
{Gubun:'A',Cost:10},
{Gubun:'C',VSusic:'100/B'}
];
const calc1 = EstWonga.calculate(w1);
const scan1 = EstWonga.scan(w1);
// 2) 기초 비목 0 및 양수 전달:
// NC=1000이 전달되면 BaseField: 'NC' 또는 식 내 'NC' 참조가 정상 작동.
// scan에서 bases가 전달되지 않아 0으로 판정되는 문제가 없어야 함.
const w2 = [
{Gubun:'A',BaseField:'NC',Cost:0},
{Gubun:'B',VSusic:'NC * 0.1'},
{Gubun:'C',VSusic:'1000 / B'}
];
const bases = { NC: 1000, 직접공사비: 1000 };
const calc2 = EstWonga.calculate(w2, {}, bases);
const scan2 = EstWonga.scan(w2, {}, bases);
// 3) 구간 제외 행(CalcIndex: 2 또는 3):
// 범위 (A:B)에서 A가 CalcIndex: 2(제외)인 경우 합산에서 제외되나,
// 순환 참조 및 범위 유효성 규칙은 calculate와 일치.
const w3 = [
{Gubun:'A',Cost:10,CalcIndex:2},
{Gubun:'B',Cost:20,CalcIndex:0},
{Gubun:'C',VSusic:'(A:B)'}
];
const calc3 = EstWonga.calculate(w3);
const scan3 = EstWonga.scan(w3);
// 4) 순환 참조 일치성:
// calculate에서 발생하는 순환 오류와 scan의 오류 메시지가 동일
const w4 = [
{Gubun:'X',VSusic:'Y'},
{Gubun:'Y',VSusic:'X'}
];
const calc4 = EstWonga.calculate(w4);
const scan4 = EstWonga.scan(w4);
// 5) issues.scan(project)에서 project.calculation.direct 기초금액 전달 검증
const project = EstNative.empty();
project.tables.Wonga = [
{Gubun:'M',BaseField:'NC',Cost:0},
{Gubun:'N',VSusic:'NC * 0.5'},
{Gubun:'O',VSusic:'500 / N'}
];
project.calculation = {
direct: { NC: 1000, GC: 0, JC: 0, SC: 0, TC: 1000 }
};
const projectIssues = EstIssues.scan(project).filter(i => i.table === 'Wonga');
return {
calc1Errors: calc1.errors,
scan1,
calc1CostB: calc1.rows[0].Cost,
calc1CostC: calc1.rows[2].Cost,
calc2Errors: calc2.errors,
scan2,
calc3Errors: calc3.errors,
scan3,
calc3CostC: calc3.rows[2].Cost,
calc4Errors: calc4.errors,
scan4,
projectIssues
};
})()""",
)
# 1) 전방 참조 & 오래된 Cost(0): calculate와 scan 모두 오류 0건, 정확한 값 평가
assert result["calc1Errors"] == []
assert result["scan1"] == []
assert result["calc1CostB"] == 20
assert result["calc1CostC"] == 5
# 2) 기초 비목 양수 전달: bases 전달로 0 나눔 없이 정상 평가
assert result["calc2Errors"] == []
assert result["scan2"] == []
# 3) 구간 제외 행(CalcIndex: 2): A=10 제외, B=20만 합산되어 C=20, scan 오류 0건
assert result["calc3Errors"] == []
assert result["scan3"] == []
assert result["calc3CostC"] == 20
# 4) 순환 참조: calculate와 scan 메시지 일치
assert "원가 수식 순환 참조: X" in result["calc4Errors"][0]
assert any("원가 수식 순환 참조" in item["message"] for item in result["scan4"])
# 5) project.calculation.direct를 통한 issues.scan 연동: 오탐 0건
assert result["projectIssues"] == []
def test_data_issues_detect_unite_mismatch_and_sync_source_names():
result = run(
"math",
"""(()=>{
const p = EstNative.empty();
p.tables.LaborcDat = [
{ Code: "L001", Name: "보통인부", Size: "공통", Unite: "인" },
{ Code: "L002", Name: "특별인부", Size: "숙련(원천)", Unite: "인" },
{ Code: "L003", Name: "용접공", Size: "1급", Unite: "인" }
];
p.tables.EstMainDat = [
// 1) 품명 불일치 (원천 규격, 단위와 동일)
{ Code: "L001", LinkIndex: 6, Name: "보통 인부(오타)", Size: "공통", Unite: "인" },
// 2) 규격 불일치
{ Code: "L002", LinkIndex: 6, Name: "특별인부", Size: "숙련(내역)", Unite: "인" },
// 3) 단위 불일치 (일괄 반영 대상에서 제외되어야 함)
{ Code: "L003", LinkIndex: 6, Name: "용접공", Size: "1급", Unite: "시간" }
];
const beforeScan = EstIssues.scan(p);
const nameIssue = beforeScan.find(i => i.message === "연결 품명 차이");
const sizeIssue = beforeScan.find(i => i.code === "L002" && i.message === "연결 규격 차이");
const uniteIssue = beforeScan.find(i => i.message === "연결 단위 차이");
return {
hasNameIssue: !!nameIssue,
hasSizeIssue: !!sizeIssue,
hasUniteIssue: !!uniteIssue,
nameSource: nameIssue?.sourceName,
sizeSource: sizeIssue?.sourceSize,
uniteSource: uniteIssue?.sourceUnite
};
})()""",
)
assert result["hasNameIssue"] is True
assert result["hasSizeIssue"] is True
assert result["hasUniteIssue"] is True
assert result["nameSource"] == "보통인부"
assert result["sizeSource"] == "숙련(원천)"
assert result["uniteSource"] == "인"
def test_data_issues_selective_sync_preserves_unit_and_non_empty_size():
from playwright.sync_api import sync_playwright
import pytest
with sync_playwright() as p:
browser = p.chromium.launch(channel="msedge", headless=True)
page = browser.new_page()
try:
page.goto("http://100.65.141.15:5175", timeout=5000)
except Exception:
pytest.skip("EST 5175 server not reachable")
page.wait_for_selector("body")
page.wait_for_function("() => EstApp && EstApp.state && EstApp.state.project !== null")
# 테스트용 데이터 주입
page.evaluate("""() => {
EstApp.change(p => {
p.tables.LaborcDat ||= [];
p.tables.LaborcDat.push(
{ Code: "TEST_L1", Name: "원천 보통인부", Size: "원천 규격A", Unite: "인" },
{ Code: "TEST_L2", Name: "원천 특별인부", Size: "원천 규격B(신규)", Unite: "인" },
{ Code: "TEST_L3", Name: "원천 용접공", Size: "원천 규격C", Unite: "인" }
);
p.tables.EstMainDat ||= [];
p.tables.EstMainDat.push(
// 1) 품명만 다름 (내역 규격은 동일하므로 품명만 동기화)
{ Code: "TEST_L1", LinkIndex: 6, Name: "내역 보통인부(오타)", Size: "원천 규격A", Unite: "인" },
// 2) 규격 차이 (원천 규격으로 동기화)
{ Code: "TEST_L2", LinkIndex: 6, Name: "원천 특별인부", Size: "내역 구규격", Unite: "인" },
// 3) 단위 차이 (일괄 반영 대상에서 제외되어야 함)
{ Code: "TEST_L3", LinkIndex: 6, Name: "원천 용접공", Size: "원천 규격C", Unite: "시간" }
);
}, false);
EstApp.navigate('issues');
}""")
page.wait_for_function("() => EstApp.state.view === 'issues'")
# 1) '불일치 원천 명칭/규격 일괄 반영' 버튼 클릭
sync_all_btn = page.locator("button[data-issue-sync-all='1']")
assert sync_all_btn.count() >= 1
sync_all_btn.click()
# 일괄 반영 후 결과 점검
state1 = page.evaluate("""() => {
const est = EstApp.state.project.tables.EstMainDat;
const r1 = est.find(r => r.Code === "TEST_L1");
const r2 = est.find(r => r.Code === "TEST_L2");
const r3 = est.find(r => r.Code === "TEST_L3");
return {
r1: { Name: r1.Name, Size: r1.Size, Unite: r1.Unite },
r2: { Name: r2.Name, Size: r2.Size, Unite: r2.Unite },
r3: { Name: r3.Name, Size: r3.Size, Unite: r3.Unite }
};
}""")
# TEST_L1: 품명은 원천으로 동기화, 규격/단위는 일치 유지
assert state1["r1"]["Name"] == "원천 보통인부"
assert state1["r1"]["Size"] == "원천 규격A"
assert state1["r1"]["Unite"] == "인"
# TEST_L2: 유효한 원천 규격으로 동기화됨
assert state1["r2"]["Size"] == "원천 규격B(신규)"
# TEST_L3: 단위 불일치는 일괄 반영에서 제외되어 "시간" 그대로 유지됨
assert state1["r3"]["Unite"] == "시간"
# 2) TEST_L3의 '원천단위 반영' 개별 버튼 명시적 클릭 확인
sync_unit_btn = page.locator("button[data-issue-sync-msg='연결 단위 차이']")
assert sync_unit_btn.count() >= 1
assert "원천단위 반영" in sync_unit_btn.first.inner_text()
sync_unit_btn.first.click()
# 개별 반영 후 단위가 동기화되었는지 확인
r3_unite_after = page.evaluate("""() => {
const est = EstApp.state.project.tables.EstMainDat;
const r3 = est.find(r => r.Code === "TEST_L3");
return r3.Unite;
}""")
assert r3_unite_after == "인"
# 3) 실행 취소(Undo)로 이전 복원 검증
page.evaluate("EstActions.undo()")
r3_unite_undone = page.evaluate("""() => {
const est = EstApp.state.project.tables.EstMainDat;
const r3 = est.find(r => r.Code === "TEST_L3");
return r3.Unite;
}""")
assert r3_unite_undone == "시간"
browser.close()
def test_data_issues_detect_percentage_unit_mismatch():
result = run(
"math",
"""(()=>{
const p = structuredClone(project);
p.tables.HeadMainDat.push(
{ Index: 0, Level: 0, LinkIndex: 58, Code: "R1", Name: "비율항목1", Unite: "식", GN: 5 },
{ Index: 0, Level: 0, LinkIndex: 62, Code: "R2", Name: "구간비율2", Unite: "M", GN: 10 },
{ Index: 0, Level: 0, LinkIndex: 52, Code: "R3", Name: "낙찰률3", Unite: "%", GN: 87.745 },
{ Index: 0, Level: 0, LinkIndex: 67, Code: "R4", Name: "제잡비4", Unite: "%", GN: 3 }
);
p.tables.EstMainDat.push(
{ Level: 0, LinkIndex: 57, Code: "R5", Name: "조달수수료5", Unite: "원", GN: 1 }
);
const issues = EstIssues.scan(p).filter(x => x.message === "단위 백분율(%) 불일치");
return issues.map(x => ({ table: x.table, code: x.code, message: x.message, rowUnite: x.rowUnite }));
})()""",
)
assert len(result) == 3
items = {(x["table"], x["code"], x["rowUnite"]) for x in result}
assert ("HeadMainDat", "R1", "식") in items
assert ("HeadMainDat", "R2", "M") in items
assert ("EstMainDat", "R5", "원") in items
+176 -13
View File
@@ -52,7 +52,7 @@ def test_estx_print_edits_patch_only_requested_fields_when_sample_exists():
pytest.skip("installed EST sample unavailable")
script = """const fs=require('fs'),m=require('./tools/est-test/import-estx.cjs'),e=require('./tools/est-test/export-estx.cjs'),s=require('./tools/est-test/server.cjs');
const bytes=fs.readFileSync(process.argv[1]),p=m.importEstx(bytes),before=structuredClone(p.printCatalog);
p.nativePrintChanges={'1':{PrintTop:13,PrintBottom:13,PrintLeft:13,PrintRight:13,PrintLineCount:19}};
p.nativePrintChanges={'1':{PrintTop:13,PrintBottom:14,PrintLeft:15,PrintRight:16,PrintLineCount:19}};
const retained=s.calculate(structuredClone(p)).nativePrintChanges['1'].PrintLineCount===19;
const out=e.exportEstx(p),q=m.importEstx(out),a=m.unzip(bytes),b=m.unzip(out),form=q.printCatalog.find(r=>r.Index===1);
const other=before.filter(r=>r.Index!==1).every(r=>JSON.stringify(r)===JSON.stringify(q.printCatalog.find(x=>x.Index===r.Index)));
@@ -65,9 +65,9 @@ def test_estx_print_edits_patch_only_requested_fields_when_sample_exists():
)
assert result == {
"top": 13,
"bottom": 13,
"left": 13,
"right": 13,
"bottom": 14,
"left": 15,
"right": 16,
"lines": 19,
"other": True,
"sameForm": True,
@@ -90,6 +90,62 @@ def test_quantity_document_edits_keep_styles_and_bookmarks_and_block_marker_edit
assert result == {"same": True, "tags": True, "rejected": True}
def test_estx_quantity_item_labels_round_trip_when_sample_exists():
sample = Path("C:/Dasansoft/Est Plus 2/Data/ProjectData/main.estx")
if not sample.exists():
pytest.skip("installed EST sample unavailable")
script = """const fs=require('fs'),m=require('./tools/est-test/import-estx.cjs'),e=require('./tools/est-test/export-estx.cjs');
const p=m.importEstx(fs.readFileSync(process.argv[1])),before=structuredClone(p.quantitySheets),sheet=p.quantitySheets[0];
sheet.items[0].Name='수정된 품명';sheet.items[0].Size='수정 규격';sheet.items[0].Unite='m2';
const mismatch=p.quantitySheets.find(s=>s.Code==='N0000000083');mismatch.items[4].Name='수량 보존';
const q=m.importEstx(e.exportEstx(p)),item=q.quantitySheets[0].items[0];
const restored=q.quantitySheets.find(s=>s.Code==='N0000000083');
const other=JSON.stringify(q.quantitySheets[0].items.slice(1))===JSON.stringify(before[0].items.slice(1));
const rest=JSON.stringify(q.quantitySheets.filter(s=>s.Code!==sheet.Code && s.Code!==mismatch.Code))===JSON.stringify(before.filter(s=>s.Code!==sheet.Code && s.Code!==mismatch.Code));
const preserved=restored.items[4].Name==='수량 보존' && restored.items[4].GN===286 && JSON.stringify(restored.rows)===JSON.stringify(before.find(s=>s.Code===mismatch.Code).rows);
p.quantitySheets[0].items[0].TargetCode='new target';let rejected=false;try{e.exportEstx(p)}catch{rejected=true}
console.log(JSON.stringify({name:item.Name,size:item.Size,unite:item.Unite,other,rest,preserved,rejected}));"""
result = json.loads(
subprocess.check_output(["node", "-e", script, str(sample)], cwd=ROOT, timeout=45).decode(
"utf-8"
)
)
assert result == {
"name": "수정된 품명",
"size": "수정 규격",
"unite": "m2",
"other": True,
"rest": True,
"preserved": True,
"rejected": True,
}
def test_estx_quantity_description_preserves_mismatched_result_when_sample_exists():
sample = Path("C:/Dasansoft/Est Plus 2/Data/ProjectData/main.estx")
if not sample.exists():
pytest.skip("installed EST sample unavailable")
script = """const fs=require('fs'),m=require('./tools/est-test/import-estx.cjs'),e=require('./tools/est-test/export-estx.cjs'),w=require('./tools/est-test/export-quantity.cjs');
const bytes=fs.readFileSync(process.argv[1]),p=m.importEstx(bytes),base=m.importEstx(bytes),sheet=p.quantitySheets.find(s=>s.Code==='N0000000083');
sheet.rows[0].LineData=sheet.rows[0].LineData.replace('석축쌓기','석축공사');
const q=m.importEstx(e.exportEstx(p)),saved=q.quantitySheets.find(s=>s.Code===sheet.Code);
sheet.rows[3].LineData=sheet.rows[3].LineData.replace('220','221');let rejected=false;
try{w.updateQuantities(p,base,e.cp949,e.memory)}catch{rejected=true}
sheet.rows[3].LineData=sheet.rows[3].LineData.replace('221','220');
sheet.rows[5].LineData=sheet.rows[5].LineData.replace('286','287');let markerRejected=false;
try{w.updateQuantities(p,base,e.cp949,e.memory)}catch{markerRejected=true}
console.log(JSON.stringify({text:saved.rows[0].LineData,quantity:saved.items[4].GN,
preserved:JSON.stringify(saved.rows.slice(1))===JSON.stringify(base.quantitySheets.find(s=>s.Code===sheet.Code).rows.slice(1)),rejected,markerRejected}));"""
result = json.loads(
subprocess.check_output(["node", "-e", script, str(sample)], cwd=ROOT, timeout=45).decode(
"utf-8"
)
)
assert result["text"].startswith("석축공사")
assert result["quantity"] == 286
assert result["preserved"] and result["rejected"] and result["markerRejected"]
def test_quantity_document_edit_across_character_styles_preserves_tags():
script = r"""const q=require('./tools/est-test/native-quantity.cjs'),w=require('./tools/est-test/export-quantity.cjs');
const doc='<cs nr=1/><cs nr=2/><div><c nr=1/>abc<c nr=2/>def<c nr=1/>ghi</div>';
@@ -101,6 +157,47 @@ def test_quantity_document_edit_across_character_styles_preserves_tags():
assert result == {"same": True, "tags": True}
def test_quantity_document_edit_across_styles_and_superscript_preserves_structure():
script = r"""const q=require('./tools/est-test/native-quantity.cjs'),w=require('./tools/est-test/export-quantity.cjs');
const doc='<cs nr=1 CharStyleON:16/><cs nr=2/><div><c nr=2/>abc<c nr=1/>2<c nr=2/>def</div>';
const old=q.fromWpt('A','문서',doc,[],{}),edited=structuredClone(old);
edited.rows[0].LineData='abc^(3)xyz';
const next=w.editDocument(doc,old,edited),again=q.fromWpt('A','문서',next,[],{});
process.stdout.write(JSON.stringify({same:JSON.stringify(again.rows)===JSON.stringify(edited.rows),doc:next}));"""
result = json.loads(subprocess.check_output(["node", "-e", script], cwd=ROOT).decode("utf-8"))
assert result["same"] is True
assert "<c nr=1/>3<c nr=2/>xyz" in result["doc"]
def test_quantity_document_edit_across_styles_and_separate_marker():
script = r"""const q=require('./tools/est-test/native-quantity.cjs'),w=require('./tools/est-test/export-quantity.cjs');
const doc='<cs nr=1/><cs nr=2/><div><c nr=1/>abc<c nr=2/>def<begin Tag=2 wpsty=[[Name:&#34;r000001&#34;;]]/>6<end Tag=2/></div>';
const old=q.fromWpt('A','문서',doc,[],{}),edited=structuredClone(old);
edited.rows[0].LineData=edited.rows[0].LineData.replace('bcde','변경').replace('6','12');
const next=w.editDocument(doc,old,edited),again=q.fromWpt('A','문서',next,[],{}),tags=x=>x.match(/<[^>]*>/g);
process.stdout.write(JSON.stringify({same:JSON.stringify(again)===JSON.stringify(edited),tags:JSON.stringify(tags(doc))===JSON.stringify(tags(next))}));"""
result = json.loads(subprocess.check_output(["node", "-e", script], cwd=ROOT).decode("utf-8"))
assert result == {"same": True, "tags": True}
def test_quantity_document_edit_across_actual_style_boundary_when_sample_exists():
sample = Path("C:/Dasansoft/Est Plus 2/Data/ProjectData/main.estx")
if not sample.exists():
pytest.skip("installed EST main sample unavailable")
script = """const fs=require('fs'),z=require('zlib'),m=require('./tools/est-test/import-estx.cjs'),w=require('./tools/est-test/export-quantity.cjs'),q=require('./tools/est-test/native-quantity.cjs');
const p=m.importEstx(fs.readFileSync(process.argv[1])),old=p.quantitySheets.find(s=>s.Code==='N0000000113');
const r=p.tables.MulMainDat.find(x=>x.Code===old.Code),b=Buffer.from(r.Editor.binary,'base64');
const text=new TextDecoder('euc-kr').decode(b[0]===120?z.inflateSync(b):b),edited=structuredClone(old);
const i=edited.rows.findIndex(r=>r.LineData.includes('27.22 ='));if(i<0)throw Error('sample line missing');
edited.rows[i].LineData=edited.rows[i].LineData.replace('27.22 =','27.22XY');
const out=w.editDocument(text,old,edited),tags=x=>x.match(/<[^>]*>/g);
process.stdout.write(JSON.stringify({same:JSON.stringify(q.fromWpt(edited.Code,edited.Name,out,edited.items,edited.options))===JSON.stringify(edited),tags:JSON.stringify(tags(text))===JSON.stringify(tags(out))}));"""
result = json.loads(
subprocess.check_output(["node", "-e", script, str(sample)], cwd=ROOT).decode("utf-8")
)
assert result == {"same": True, "tags": True}
def test_multiple_quantity_text_segments_preserve_original_tags_when_sample_exists():
sample = Path("C:/Dasansoft/Est Plus 2/Data/ProjectData/main.estx")
if not sample.exists():
@@ -123,18 +220,20 @@ def test_estx_quantity_edit_preserves_other_documents_and_indexes_when_sample_ex
pytest.skip("installed EST main sample unavailable")
script = """const fs=require('fs'),m=require('./tools/est-test/import-estx.cjs'),e=require('./tools/est-test/export-estx.cjs');
const bytes=fs.readFileSync(process.argv[1]),p=m.importEstx(bytes),first=p.quantitySheets[0];
first.items[0].Name='changed name';first.items[0].Size='changed size';
first.rows[0].LineData=first.rows[0].LineData.replace('표준','시험 표준');
const index=first.rows.findIndex(r=>r.LineData.includes('B = 9.00'));
if(index<0)throw Error('기준 폭 행 없음');
first.rows[index].LineData=first.rows[index].LineData.replace('9.00','10.00');
const out=e.exportEstx(p),q=m.importEstx(out),a=m.unzip(bytes),b=m.unzip(out);
const unchanged=[...a].filter(([k])=>k.endsWith('.idx')||k.startsWith('cad')).every(([k,v])=>v.equals(b.get(k)));
process.stdout.write(JSON.stringify({line:q.quantitySheets[0].rows[0].LineData,quantity:q.quantitySheets[0].items[0].GN,result:q.quantitySheets[0].rows.find(r=>r.markers.includes('r000000')).LineData,others:JSON.stringify(q.quantitySheets.slice(1))===JSON.stringify(p.quantitySheets.slice(1)),unchanged}));"""
process.stdout.write(JSON.stringify({line:q.quantitySheets[0].rows[0].LineData,quantity:q.quantitySheets[0].items[0].GN,name:q.quantitySheets[0].items[0].Name,size:q.quantitySheets[0].items[0].Size,result:q.quantitySheets[0].rows.find(r=>r.markers.includes('r000000')).LineData,others:JSON.stringify(q.quantitySheets.slice(1))===JSON.stringify(p.quantitySheets.slice(1)),unchanged}));"""
result = json.loads(
subprocess.check_output(["node", "-e", script, str(sample)], cwd=ROOT).decode("utf-8")
)
assert "시험 표준" in result["line"]
assert result["quantity"] == 116
assert result["name"] == "changed name" and result["size"] == "changed size"
assert "\x12116\x12" in result["result"]
assert result["others"] and result["unchanged"]
@@ -167,21 +266,35 @@ def test_estx_options_and_expanded_blob_preserve_unrelated_data_when_sample_exis
def test_recalculation_and_native_export_keep_original_title_identities_when_sample_exists():
sample = Path("C:/Dasansoft/Est Plus 2/Data/ProjectData/sample.estx")
sample = Path("C:/Dasansoft/Est Plus 2/Data/ProjectData/main.estx")
if not sample.exists():
sample = Path("resources/knowledge/original/원가계산/다산소프트/2026-07-main.estx")
if not sample.exists():
sample = Path("C:/Dasansoft/Est Plus 2/Data/ProjectData/sample.estx")
if not sample.exists():
pytest.skip("installed EST sample unavailable")
script = """const fs=require('fs'),m=require('./tools/est-test/import-estx.cjs'),e=require('./tools/est-test/export-estx.cjs'),s=require('./tools/est-test/server.cjs');
const bytes=fs.readFileSync(process.argv[1]),p=m.importEstx(bytes),q=s.calculate(p);
const ids=['HeadTitleDat','CostTitleDat','MachTitleDat'].every(key=>p.tables[key].every((r,i)=>r.PkID===q.tables[key][i].PkID));
const row=q.tables.EstMainDat.find(r=>r.GN&&r.TC);row.GN+=1;
const edited=s.calculate(q),out=e.exportEstx(edited),again=m.importEstx(out),original=m.unzip(bytes),files=m.unzip(out);
const bytes=fs.readFileSync(process.argv[1]),p=m.importEstx(bytes);
const hasQuantity=Boolean(p.quantitySheets?.[0]?.items?.[0]);
if (hasQuantity) p.quantitySheets[0].items[0].Unite = 'm2';
const q=s.calculate(p);
const memoKeys=['DifineMemo','SpaceMemo','EscData'];
const preserved=['HeadTitleDat','CostTitleDat','MachTitleDat'].every(key=>
p.tables[key].every((r,i)=>{
const target=q.tables[key][i];
return r.PkID===target.PkID && r.Index===target.Index && r.RecNo===target.RecNo && r.Flag===target.Flag &&
memoKeys.every(k=>JSON.stringify(r[k])===JSON.stringify(target[k]));
})
);
const out=e.exportEstx(q),again=m.importEstx(out),original=m.unzip(bytes),files=m.unzip(out);
const indexes=[...original].filter(([name])=>name.endsWith('.idx')).every(([name,value])=>value.equals(files.get(name)));
process.stdout.write(JSON.stringify({ids,indexes,gn:again.tables.EstMainDat.find(r=>r.PkID===row.PkID).GN,expected:row.GN}));"""
const reimportedUnite=hasQuantity ? again.quantitySheets?.[0]?.items?.[0]?.Unite : 'm2';
process.stdout.write(JSON.stringify({preserved,indexes,reimportedUnite}));"""
result = json.loads(
subprocess.check_output(["node", "-e", script, str(sample)], cwd=ROOT).decode("utf-8")
)
assert result["ids"] and result["indexes"]
assert result["gn"] == result["expected"]
assert result["preserved"] and result["indexes"]
assert result["reimportedUnite"] == "m2"
def fixture(path):
@@ -239,3 +352,53 @@ def test_estx_patches_values_and_preserves_indexes_drawings_and_rejects_structur
assert result["same"] and result["blocked"] and result["index"] and result["drawing"]
assert result["row"]["Name"] == "변경"
assert result["row"]["GN"] == 3 and result["row"]["TC"] == 300
def test_estx_export_schedule_and_documents_safety_guards(tmp_path):
source = tmp_path / "native.estx"
fixture(source)
script = """const fs=require('fs'),m=require('./tools/est-test/import-estx.cjs'),e=require('./tools/est-test/export-estx.cjs');
const b=fs.readFileSync(process.argv[1]);
// 1) 원본 그대로(빈 schedule/빈 documents/null 등)는 정상 통과
const p1 = m.importEstx(b);
p1.schedule = [];
p1.documents = {};
const same1 = e.exportEstx(p1).equals(b);
// 2) schedule 항목 추가 시 차단 검증
let blockedSchedule = false, schedErrMsg = '';
const p2 = m.importEstx(b);
p2.schedule = [{id:'1', Code:'A', Name:'새공정', start:'2026-01-01', end:'2026-01-05', weight:100, progress:10}];
try { e.exportEstx(p2); } catch (err) { blockedSchedule = true; schedErrMsg = err.message; }
// 2-1) 빈 문자열 또는 0값만 있는 schedule 항목 추가 시도 차단 검증
let blockedEmptySchedule = false;
const p2Empty = m.importEstx(b);
p2Empty.schedule = [{id:'2', Code:'', Name:'', start:'', end:'', weight:0, progress:0}];
try { e.exportEstx(p2Empty); } catch (err) { blockedEmptySchedule = true; }
// 3) documents 텍스트 입력 시 차단 검증
let blockedDocs = false, docErrMsg = '';
const p3 = m.importEstx(b);
p3.documents = {'173': '심사사유'};
try { e.exportEstx(p3); } catch (err) { blockedDocs = true; docErrMsg = err.message; }
// 3-1) documents 공백(" ") 입력 시도 사용자 데이터 보존을 위해 차단 검증
let blockedSpaceDocs = false;
const p3Space = m.importEstx(b);
p3Space.documents = {'173': ' '};
try { e.exportEstx(p3Space); } catch (err) { blockedSpaceDocs = true; }
process.stdout.write(JSON.stringify({same1, blockedSchedule, schedErrMsg, blockedEmptySchedule, blockedDocs, docErrMsg, blockedSpaceDocs}));"""
result = json.loads(
subprocess.check_output(["node", "-e", script, str(source)], cwd=ROOT).decode("utf-8")
)
assert result["same1"] is True
assert result["blockedSchedule"] is True
assert "예정공정표" in result["schedErrMsg"] and "JSON" in result["schedErrMsg"]
assert result["blockedEmptySchedule"] is True
assert result["blockedDocs"] is True
assert "서식 문서" in result["docErrMsg"] and "JSON" in result["docErrMsg"]
assert result["blockedSpaceDocs"] is True
assert "서식 문서" in result["docErrMsg"] and "JSON" in result["docErrMsg"]
@@ -1,6 +1,32 @@
from test_est_rea_parity import run
def test_new_progress_name_avoids_existing_names_with_whitespace():
result = run(
"math",
"""[EstBusiness.newProgressName([{name:'2회 기성'},{name:' 1회 기성 '}]),
EstBusiness.newProgressName([{name:'2회 기성'}]),EstBusiness.newProgressName([])]""",
)
assert result == ["3회 기성", "1회 기성", "1회 기성"]
def test_progress_rejects_ambiguous_inputs_without_applying_them_to_multiple_rows():
result = run(
"math",
"""(()=>{
const p=EstNative.empty();p.options.SourceRefresh=false;
p.tables.EstMainDat=[{Code:'A',GN:10,GU:100},{Code:'A',GN:20,GU:100},
{PkID:3,Code:'B',GN:10,GU:100},{LinkIndex:54}];
p.progress=[{id:'one',inputs:{A:{Gum:2},'3':{Gum:3}}}];
const out=EstProgress.calculate(EstNative.calculate(p),'one');let rejected=false;
try{EstProgress.lock(EstNative.calculate(p),'one')}catch{rejected=true}
return {out,rejected};
})()""",
)
assert result["rejected"] and result["out"]["errors"]
assert [row["GumTC"] for row in result["out"]["rows"][:3]] == [0, 0, 300]
def test_progress_amount_input_preserves_exact_money_and_component_total():
result = run(
"math",
@@ -121,3 +147,22 @@ def test_progress_preserves_zero_wonga_total_and_falls_back_only_when_absent():
assert result["zero"]["rows"][0]["GumTC"] == 200
assert result["zero"]["total"] == 0
assert result["absent"]["total"] == 200
def test_progress_preserves_prior_costs_for_multiple_blank_codes():
result = run(
"math",
"""(()=>{
const p=EstNative.empty();p.options.SourceRefresh=false;
p.tables.EstMainDat=[{PkID:1,Code:'R',GN:10,GU:100,NU:50},{LinkIndex:54}];
p.tables.Wonga=[{Gubun:'',Name:'material',BaseField:'GC'},
{Gubun:'',Name:'labor',BaseField:'NC'}];
p.progress=[{id:'one',inputs:{'1':{Gum:2}}}];
const q=EstProgress.lock(EstNative.calculate(p),'one');
q.progress.push({id:'two',inputs:{'1':{Gum:3}}});
return EstProgress.calculate(q,'two');
})()""",
)
assert result["errors"] == []
assert [row["JunCost"] for row in result["costs"]] == [200, 100]
assert [row["TotalCost"] for row in result["costs"]] == [500, 250]
@@ -0,0 +1,290 @@
from test_est_rea_parity import run
def test_excel_quantity_cell_rejects_invalid_address_or_non_numeric_value():
result = run(
"math",
"""(()=>{
let type='n',value='6.25';
const sheet={getAttribute:k=>k==='name'?'Data':null,getAttributeNS:()=> 'r1'};
const rel={getAttribute:k=>({Id:'r1',Target:'/xl/worksheets/sheet1.xml'})[k]};
const cell={getAttribute:k=>({r:'B2',t:type})[k],getElementsByTagNameNS:()=>[{textContent:value}]};
const doc=items=>({querySelector:()=>null,getElementsByTagNameNS:()=>items});
globalThis.DOMParser=class{parseFromString(x){return x==='workbook'?doc([sheet]):x==='rels'?doc([rel]):doc([cell])}};
const files={'xl/workbook.xml':'workbook','xl/_rels/workbook.xml.rels':'rels','xl/worksheets/sheet1.xml':'sheet'};
const valid=EstQuantity.excelCell(files,'Data','b2');let rejected=0;
for(const [name,address,t,v] of [['Other','B2','n','1'],['Data','XFE1','n','1'],['Data','B3','n','1'],['Data','B2','s','1'],['Data','B2','n',''],['Data','B2','n','1e100']]){
type=t;value=v;try{EstQuantity.excelCell(files,name,address)}catch{rejected++}
}
type='n';value='8';const p={tables:{EstMainDat:[{PkID:1,GN:2},{PkID:2,GN:3}]},excelQuantityLinks:[{target:1,file:'a.xlsx',sheet:'Data',cell:'B2'},{target:2,file:'a.xlsx',sheet:'Data',cell:'B2'}]};
const updates=EstQuantity.excelUpdates(p,'a.xlsx',files).map(x=>x.value),before=p.tables.EstMainDat.map(x=>x.GN);let bad=0;
p.excelQuantityLinks[1].cell='B3';try{EstQuantity.excelUpdates(p,'a.xlsx',files)}catch{bad++}
p.tables.EstMainDat.push({PkID:1});try{EstQuantity.excelUpdates(p,'a.xlsx',files)}catch{bad++}
try{EstQuantity.excelUpdates(p,'missing.xlsx',files)}catch{bad++}
return {valid,rejected,updates,before,bad};
})()""",
)
assert result == {"valid": 6.25, "rejected": 6, "updates": [8, 8], "before": [2, 3], "bad": 3}
def test_excel_quantity_links_validate_and_survive_project_validation():
result = run(
"math",
"""(()=>{
const p=EstNative.empty();p.excelQuantityLinks=[{target:1,file:'a.xlsx',sheet:'Data',cell:'B2'}];
const valid=EstModel.validate(p).excelQuantityLinks;let rejected=0;
for(const bad of [[valid[0],valid[0]],[{...valid[0],target:'1'}],[{...valid[0],cell:'wrong'}],[{...valid[0],cell:'XFE1'}],[{...valid[0],cell:'A1048577'}]]){
const q=EstNative.empty();q.excelQuantityLinks=bad;try{EstModel.validate(q)}catch{rejected++}
}
return {valid,rejected};
})()""",
)
assert result["valid"] == [{"target": 1, "file": "a.xlsx", "sheet": "Data", "cell": "B2"}]
assert result["rejected"] == 5
def test_deleted_excel_link_target_id_is_reserved_for_new_estimate_rows():
result = run(
"math",
"""(()=>{
const p=EstNative.empty();p.tables.EstMainDat=[{PkID:1,Code:'A'}];
p.excelQuantityLinks=[{target:2,file:'a.xlsx',sheet:'Data',cell:'B2'}];
const row=EstModel.newRow('EstMainDat',p.tables.EstMainDat,p);
const other=EstModel.newRow('StuffcDat',[],p);
return {next:row.PkID,other:other.PkID};
})()""",
)
assert result == {"next": 3, "other": 1}
def test_apply_all_quantities_sums_sheets_and_rejects_errors_atomically():
result = run(
"math",
"""(()=>{
const p=EstNative.empty();p.tables.EstMainDat=[{PkID:1,Code:'A',GN:1},{PkID:2,Code:'B',GN:2}];
p.quantities=[{Code:'Q1',formula:'2',TargetKey:'id:1'}];
p.quantitySheets=[{Code:'S1',Name:'sheet',options:{},rows:[{LineData:'3 = \\x120\\x12',markers:['a']}],
items:[{Code:'a',TargetKey:'id:1'}]},
{Code:'S2',Name:'sheet2',options:{},rows:[{LineData:'4 = \\x120\\x12',markers:['b']}],
items:[{Code:'b',TargetKey:'id:2'}]}];
const values=EstQuantity.allTotals(p);EstQuantity.applyTotals(p,values);
const updated=p.tables.EstMainDat.map(row=>row.GN),before=JSON.stringify(p.tables.EstMainDat);
p.quantitySheets[1].rows[0].LineData='bad';let rejected=false;
try{EstQuantity.applyTotals(p,EstQuantity.allTotals(p))}catch{rejected=true}
return {updated,rejected,unchanged:before===JSON.stringify(p.tables.EstMainDat)};
})()""",
)
assert result == {"updated": [5, 4], "rejected": True, "unchanged": True}
def test_apply_all_quantities_rejects_duplicate_or_reserved_sheet_codes():
result = run(
"math",
"""(()=>{
const p=EstNative.empty();p.tables.EstMainDat=[{PkID:1,Code:'A',GN:1}];
p.quantities=[{Code:'Q1',formula:'2',TargetKey:'id:1'}];
let rejected=0;
for(const codes of [['S','S'],['manual'],['']]) {
p.quantitySheets=codes.map(Code=>({Code,rows:[],items:[],options:{}}));
try{EstQuantity.allTotals(p)}catch{rejected++}
}
return {rejected,GN:p.tables.EstMainDat[0].GN};
})()""",
)
assert result == {"rejected": 3, "GN": 1}
def test_quantity_apply_requires_one_detail_row_per_code():
result = run(
"math",
"""(()=>{
function apply(rows,items=[{Code:'r1',GN:7,TargetCode:'X'}]){
const handlers={};
globalThis.document={addEventListener:(name,fn)=>handlers[name]=fn};
const sheet={Code:'Q',rows:[],items,options:{}};
const state={quantitySheet:'Q',project:{quantitySheets:[sheet],tables:{EstMainDat:rows}}};
globalThis.EstApp={state,change:fn=>fn(),render:()=>{}};
EstQuantity.install();handlers.click({target:{closest:selector=>selector==='[data-quantity-apply]'?{}:null}});
return {rows,notice:state.notice||''};
}
return {duplicate:apply([{Code:'X',Level:0,GN:1},{Code:'X',Level:0,GN:2}]),
heading:apply([{Code:'X',Level:1,GN:1}]),
valid:apply([{Code:'X',Level:1,GN:1},{Code:'X',Level:0,GN:2}]),
partial:apply([{Code:'X',Level:0,GN:1}],[{Code:'r1',GN:7,TargetCode:'X'},{Code:'r2',GN:4,TargetCode:'missing'}]),
disconnected:apply([{Code:'X',Level:0,GN:1}],[{Code:'r1',GN:7,TargetCode:'',LinkCode:'X'}])};
})()""",
)
assert [r["GN"] for r in result["duplicate"]["rows"]] == [1, 2]
assert result["duplicate"]["notice"]
assert result["heading"]["rows"][0]["GN"] == 1
assert result["heading"]["notice"]
assert [r["GN"] for r in result["valid"]["rows"]] == [1, 7]
assert result["valid"]["notice"] == ""
assert result["partial"]["rows"][0]["GN"] == 1
assert result["partial"]["notice"]
assert result["disconnected"]["rows"][0]["GN"] == 1
assert result["disconnected"]["notice"]
def test_create_edit_apply_and_delete_quantity_sheet():
result = run(
"math",
"""(()=>{
const handlers={};let id=0;
globalThis.document={addEventListener:(name,fn)=>handlers[name]=fn};
globalThis.confirm=()=>true;
const state={quantitySheet:'manual',project:{quantitySheets:[],tables:{EstMainDat:[{Code:'X',Level:0,GN:1}]}}};
globalThis.EstApp={state,uid:()=>String(++id),change:fn=>fn(),render:()=>{}};
EstQuantity.install();
const click=(selector,dataset={})=>handlers.click({target:{closest:s=>s===selector?{dataset}:null}});
const change=(dataset,value)=>handlers.change({target:{dataset,value}});
click('[data-quantity-new]');click('[data-quantity-add]');
change({quantityName:''},'Earthwork');
change({quantityItem:'Name',index:'0'},'excavation');
change({quantityItem:'Unite',index:'0'},'m3');
change({quantityTarget:'0'},'X');
change({quantityLine:'0'},'2*3 = ⟦결과⟧0⟦결과⟧');
click('[data-quantity-add-item]');
const withNewItem = state.project.quantitySheets[0].items.length;
// 참조 중인 0번 항목 삭제 시도 -> 차단 및 오류 메시지 설정 확인
click('[data-quantity-delete-item]', { quantityDeleteItem: '0' });
const noticeBlocked = state.notice;
const blockedCount = state.project.quantitySheets[0].items.length;
// 미참조 항목인 1번 항목 삭제 시도 -> 정상 삭제
click('[data-quantity-delete-item]', { quantityDeleteItem: '1' });
const afterItemDeleted = state.project.quantitySheets[0].items.length;
const saved=JSON.parse(JSON.stringify(state.project));
click('[data-quantity-apply]');
const applied=state.project.tables.EstMainDat[0].GN;
const sheet=state.project.quantitySheets[0];
const computed=EstQuantity.calculate(sheet);
click('[data-quantity-delete-row]',{quantityDeleteRow:'0'});
const empty=[sheet.rows.length,sheet.items.length];
click('[data-quantity-delete-sheet]');
return {saved,applied,computed,empty,withNewItem,blockedCount,afterItemDeleted,noticeBlocked,remaining:state.project.quantitySheets.length,selection:state.quantitySheet};
})()""",
)
assert result["withNewItem"] == 2
assert result["blockedCount"] == 2
assert "연결된 산출 행을 삭제하거나" in result["noticeBlocked"]
assert result["afterItemDeleted"] == 1
assert result["saved"]["quantitySheets"][0]["Name"] == "Earthwork"
assert result["saved"]["quantitySheets"][0]["items"][0]["Name"] == "excavation"
assert result["saved"]["quantitySheets"][0]["items"][0]["Unite"] == "m3"
assert result["computed"]["errors"] == []
assert result["computed"]["items"][0]["GN"] == 6
assert result["applied"] == 6
assert result["empty"] == [0, 0]
assert result["remaining"] == 0
assert result["selection"] == "manual"
def test_insert_and_move_quantity_rows_keeps_item_results_linked():
result = run(
"math",
"""(()=>{
const handlers={};let id=0;
globalThis.document={addEventListener:(name,fn)=>handlers[name]=fn};
const state={quantitySheet:'manual',project:{quantitySheets:[],tables:{EstMainDat:[]}}};
globalThis.EstApp={state,uid:()=>String(++id),change:fn=>fn(),render:()=>{}};EstQuantity.install();
const click=(selector,dataset={})=>handlers.click({target:{closest:s=>s===selector?{dataset}:null}});
click('[data-quantity-new]');click('[data-quantity-add]');click('[data-quantity-add]');
const sheet=state.project.quantitySheets[0];
sheet.rows[0].LineData='1+2 = \\x120\\x12';sheet.rows[1].LineData='4+5 = \\x120\\x12';
click('[data-quantity-add]',{index:'0'});
sheet.rows[1].LineData='6+7 = \\x120\\x12';
const before=EstQuantity.calculate(sheet).items.map(x=>x.GN);
click('[data-quantity-move]',{index:'2',quantityMove:'-1'});
const after=EstQuantity.calculate(sheet).items.map(x=>x.GN);
const order=sheet.rows.map(x=>x.LineData);
sheet.rows[0].LineData='A=2 = \\x120\\x12';sheet.rows[1].LineData='A*3 = \\x120\\x12';
const linked=EstQuantity.calculate(sheet);
click('[data-quantity-move]',{index:'1',quantityMove:'-1'});
const forward=EstQuantity.calculate(sheet);
return {before,after,order,linked,forward};
})()""",
)
assert result["before"] == result["after"] == [3, 9, 13]
assert result["order"][1].startswith("4+5")
assert result["linked"]["errors"] == []
assert result["linked"]["items"][1]["GN"] == 6
assert result["forward"]["errors"]
def test_manual_and_selected_sheet_totals_validate_before_application():
result = run(
"math",
"""(()=>{
const p=EstNative.empty();
p.tables.EstMainDat=[{Code:'X',Level:0,GN:1,MulCode:{binary:'YWJj'}}];
p.quantities=[{Code:'A',formula:'2',TargetCode:'X'},
{Code:'B',formula:'[A]*3',TargetCode:'X'}];
const manual=EstQuantity.totals(p,'manual');EstQuantity.applyTotals(p,manual);
const a=structuredClone(p.tables.EstMainDat[0]);
p.quantitySheets=[{Code:'Q',options:{},rows:[{LineData:'4+5 = \\x120\\x12',markers:['r1']}],
items:[{Code:'r1',GN:0,TargetCode:'X'}]}];
const sheet=EstQuantity.totals(p,'Q');EstQuantity.applyTotals(p,sheet);
let invalid='';p.quantities.push({Code:'C',formula:'1',TargetCode:'missing'});
try{EstQuantity.totals(p,'manual')}catch(e){invalid=e.message}
return {a,b:p.tables.EstMainDat[0],invalid};
})()""",
)
assert result["a"]["GN"] == 8
assert result["a"]["MulCode"] == {"binary": "YWJj"}
assert result["b"]["GN"] == 9
assert result["b"]["MulCode"] == {"binary": "YWJj"}
assert result["invalid"]
def test_new_quantity_code_does_not_reuse_existing_or_referenced_codes():
result = run(
"math",
"""[EstQuantity.newCode([{Code:'Q2',formula:'[Q1]*3'}]),
EstQuantity.newCode([{Code:'Q2',formula:'1'}]), EstQuantity.newCode([])]""",
)
assert result == ["Q3", "Q1", "Q1"]
def test_target_row_ids_support_blank_and_duplicate_codes_for_both_modes():
result = run(
"math",
"""(()=>{
const p=EstNative.empty();p.tables.EstMainDat=[
{PkID:1,Code:'X',GN:1},{PkID:2,Code:'X',GN:2},{PkID:3,Code:'',GN:3}];
p.quantities=[{Code:'A',formula:'7',TargetCode:'X',TargetKey:'id:2'},
{Code:'B',formula:'4',TargetCode:'',TargetKey:'id:3'}];
EstQuantity.applyTotals(p,EstQuantity.totals(p,'manual'));
const manual=p.tables.EstMainDat.map(row=>row.GN);
p.quantitySheets=[{Code:'Q',options:{},rows:[{LineData:'2+3 = \\x120\\x12',markers:['r1']}],
items:[{Code:'r1',GN:0,TargetCode:'X',TargetKey:'id:1'}]}];
p.tables.EstMainDat.reverse();EstQuantity.applyTotals(p,EstQuantity.totals(p,'Q'));
const sheet=p.tables.EstMainDat.map(row=>[row.PkID,row.GN]);
p.quantitySheets[0].items[0].TargetKey='id:missing';let error='';
try{EstQuantity.totals(p,'Q')}catch(e){error=e.message}
return {manual,sheet,error};
})()""",
)
assert result["manual"] == [1, 7, 4]
assert result["sheet"] == [[3, 4], [2, 7], [1, 5]]
assert result["error"]
def test_missing_or_extra_result_markers_block_quantity_application():
result = run(
"math",
"""(()=>{
const p=EstNative.empty();p.tables.EstMainDat=[{PkID:1,Code:'X',GN:1}];
p.quantitySheets=[{Code:'Q',options:{},rows:[{LineData:'2*3 = \\x120\\x12',markers:['r1']}],
items:[{Code:'r1',GN:9,TargetKey:'id:1'}]}];
const sheet=p.quantitySheets[0],valid=EstQuantity.calculate(sheet);
const bad=[];for(const line of ['2*3 = 0','2*3 = \\x120\\x12 + \\x120\\x12']){
sheet.rows[0].LineData=line;let blocked=false;
try{EstQuantity.applyTotals(p,EstQuantity.totals(p,'Q'))}catch{blocked=true}
bad.push({blocked,errors:EstQuantity.calculate(sheet).errors,quantity:p.tables.EstMainDat[0].GN});
}
return {valid,bad};
})()""",
)
assert result["valid"]["errors"] == []
assert result["valid"]["items"][0]["GN"] == 6
assert all(row["blocked"] and row["errors"] and row["quantity"] == 1 for row in result["bad"])
@@ -0,0 +1,210 @@
from test_est_rea_parity import run
def test_review_summary_updates_the_print_document_and_active_settings():
result = run(
"math",
"""(()=>{
const handlers={},p=EstNative.empty();p.printSettings={form:'173',notes:'old'};
globalThis.document={addEventListener:(name,fn)=>handlers[name]=fn};
globalThis.EstApp={state:{project:p},change:fn=>fn(),render:()=>{}};
EstReview.install();handlers.change({target:{dataset:{reviewSummary:''},value:'review summary'}});
return {document:p.documents['173'],notes:p.printSettings.notes};
})()""",
)
assert result == {"document": "review summary", "notes": "review summary"}
def test_review_rejects_unallocated_fixed_amounts_and_ambiguous_targets():
result = run(
"math",
"""(()=>{
const p=EstNative.empty();p.tables.EstMainDat=[
{Name:'fixed',PkID:1,GN:1,TC:100,CalcIndex:1},
{Name:'first',Code:'A',GN:1,GU:10},{Name:'second',Code:'A',GN:1,GU:20}];
p.contractReview={edits:{'row:A':{GU:999}}};
const out=EstReview.calculate(p);let rejected=false;
try{EstReview.copy(p)}catch{rejected=true}
return {errors:out.errors,rejected,units:out.reviewed.tables.EstMainDat.slice(1).map(row=>row.GU)};
})()""",
)
assert len(result["errors"]) == 2 and result["rejected"]
assert result["units"] == [10, 20]
def test_review_recalculates_user_inputs_and_opens_independent_copy():
result = run(
"math",
"""(()=>{
const p=EstNative.empty();p.name='contract';p.id='123456789012345678901234';
p.options.SourceRefresh=false;p.tables.EstMainDat=[
{PkID:1,Code:'',Name:'normal',GN:10,GU:100},
{PkID:2,Code:'',Name:'fixed',GN:1,GC:100,TC:100,CalcIndex:1},
{LinkIndex:54}];
p.tables.Wonga=[{Gubun:'D',BaseField:'TC'},
{Gubun:'V',VSusic:'D*0.1'},{Gubun:'T',LinkIndex:55,VSusic:'D+V'}];
const q=EstNative.calculate(p);q.contractReview={edits:{'row:1':{GN:2,GU:80,Reason:'checked'},'row:2':{GC:20}}};
const before=JSON.stringify(q),review=EstReview.calculate(q),copy=EstReview.copy(q);
const same=JSON.stringify(q)===before;copy.tables.EstMainDat[0].GN=99;
return {review,same,original:q.tables.EstMainDat[0].GN,copy};
})()""",
)
assert result["same"] and result["original"] == 10
assert result["review"]["errors"] == []
assert result["review"]["before"] == 1210
assert result["review"]["after"] == 198
assert result["review"]["reviewed"]["tables"]["EstMainDat"][1]["TC"] == 20
assert "id" not in result["copy"]
assert "contractReview" not in result["copy"]
def test_review_rejects_invalid_and_stale_inputs():
result = run(
"math",
"""(()=>{
const p=EstNative.empty();p.tables.EstMainDat=[{PkID:1,Code:'A',GN:1,GU:10}];
const invalid=[];for(const edit of [{GN:'bad'},{GN:Infinity},{unrelated:1}]){
p.contractReview={edits:{'row:1':edit}};
try{EstModel.validate(p);invalid.push(false)}catch{invalid.push(true)}
}
p.contractReview={edits:{'row:missing':{GN:2}}};
let rejected=false;try{EstReview.copy(p)}catch{rejected=true}
return {invalid,rejected};
})()""",
)
assert result["invalid"] == [True, True, True]
assert result["rejected"]
def test_review_summary_print_contains_totals_and_item_differences():
result = run(
"math",
"""(()=>{
const p=EstNative.empty();p.tables.EstMainDat=[{PkID:1,Code:'',Name:'item',GN:2,GU:100}];
p.contractReview={edits:{'row:1':{GN:1,GU:80,Reason:'quantity reviewed'}}};return EstForms.data(171,p);
})()""",
)
assert result["errors"] == []
assert [result["rows"][0][key] for key in ["originalTC", "TC", "difference"]] == [
200,
80,
-120,
]
assert result["rows"][1]["Name"] == "item"
assert result["rows"][1]["difference"] == -120
assert result["rows"][1]["Reason"] == "quantity reviewed"
def test_review_reset_one_row_keeps_other_inputs():
result = run(
"math",
"""(()=>{
const handlers={},p=EstNative.empty();
p.contractReview={edits:{'row:1':{GU:80,Reason:'first'},'row:2':{GN:3,Reason:'second'}}};
globalThis.document={addEventListener:(name,fn)=>handlers[name]=fn};
globalThis.EstApp={state:{project:p},change:fn=>fn(),render:()=>{}};
EstReview.install();
const click=key=>handlers.click({target:{closest:s=>s==='[data-review-reset]'?{dataset:{reviewReset:key}}:null}});
click('row:1');const after=structuredClone(p.contractReview);click('row:2');
return {after,removed:!Object.hasOwn(p,'contractReview')};
})()""",
)
assert result["after"]["edits"] == {"row:2": {"GN": 3, "Reason": "second"}}
assert result["removed"]
def test_fixed_review_preserves_amount_for_reason_or_quantity_only():
result = run(
"math",
"""(()=>{
const p=EstNative.empty();p.tables.EstMainDat=[{PkID:1,Name:'fixed',GN:1,TC:100,CalcIndex:1}];
return [{Reason:'checked'},{GN:3},{GC:20}].map(edit=>{
p.contractReview={edits:{'row:1':edit}};
return EstReview.calculate(p).reviewed.tables.EstMainDat[0].TC;
});
})()""",
)
assert result == [100, 100, 20]
def test_review_view_filtering_and_batch_reason_application():
result = run(
"math",
"""(()=>{
const handlers={},p=EstNative.empty();
p.tables.EstMainDat=[
{PkID:1,Code:'A01',Name:'Item1',GN:10,GU:100,TC:1000},
{PkID:2,Code:'A02',Name:'Item2',GN:5,GU:200,TC:1000},
{PkID:3,Code:'A03',Name:'Item3',GN:2,GU:300,TC:600}
];
p.contractReview={
edits:{
'row:1':{GN:8,Reason:'수량조정'},
'row:2':{GU:180}
}
};
let html='';
const domNodes = [
{dataset:{key:'row:1',review:'Reason'}},
{dataset:{key:'row:2',review:'Reason'}},
{dataset:{key:'row:3',review:'Reason'}}
];
globalThis.document={
addEventListener:(name,fn)=>handlers[name]=fn,
querySelectorAll:(sel)=>domNodes
};
globalThis.prompt=(msg)=>'일괄사유적용';
globalThis.EstApp={
state:{project:p,reviewFilter:'all',reviewSelection:new Set()},
change:fn=>fn(),
render:()=>{},
byId:()=>({innerHTML:h=>{html=h;},set innerHTML(h){html=h;}}),
esc:s=>String(s||''),
number:n=>String(n||0)
};
EstReview.render(EstApp.state);
const allRowsCount = (html.match(/<tr class=/g) || []).length;
// 1) changed 필터
EstApp.state.reviewFilter = 'changed';
EstReview.render(EstApp.state);
const changedRowsCount = (html.match(/<tr class=/g) || []).length;
// 2) has-reason 필터
EstApp.state.reviewFilter = 'has-reason';
EstReview.render(EstApp.state);
const hasReasonRowsCount = (html.match(/<tr class=/g) || []).length;
// 3) no-reason 필터
EstApp.state.reviewFilter = 'no-reason';
EstReview.render(EstApp.state);
const noReasonRowsCount = (html.match(/<tr class=/g) || []).length;
// 4) 일괄 사유 적용 (선택 행 있을 때)
EstReview.install();
EstApp.state.reviewSelection.add('row:2');
const clickBatch = () => handlers.click({target:{closest:s=>s==='[data-review-batch-reason]'?{}:null}});
clickBatch();
const afterSelectBatch = p.contractReview.edits['row:2'].Reason;
// 5) 일괄 사유 적용 (선택 행 없을 때 표시 행 전체 적용)
EstApp.state.reviewSelection.clear();
clickBatch();
const afterAllBatch = p.contractReview.edits['row:3']?.Reason;
return {
allRowsCount,
changedRowsCount,
hasReasonRowsCount,
noReasonRowsCount,
afterSelectBatch,
afterAllBatch
};
})()""",
)
assert result["allRowsCount"] == 3
assert result["changedRowsCount"] == 2
assert result["hasReasonRowsCount"] == 1
assert result["noReasonRowsCount"] == 1
assert result["afterSelectBatch"] == "일괄사유적용"
assert result["afterAllBatch"] == "일괄사유적용"
+728
View File
@@ -0,0 +1,728 @@
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
+22
View File
@@ -6,6 +6,28 @@ ROOT = Path(__file__).resolve().parents[2]
CALC = ROOT / "tools" / "est-test" / "calc.js"
def test_work_ids_do_not_require_secure_context_random_uuid():
script = """const fs = require('fs'), vm = require('vm');
const webcrypto = require('crypto').webcrypto;
const context = {crypto: {getRandomValues: a => webcrypto.getRandomValues(a)},
document: {addEventListener() {}}};
vm.runInNewContext(fs.readFileSync(process.argv[1], 'utf8') +
';this.ids = [EstApp.uid(), EstApp.uid()]', context);
console.log(JSON.stringify(context.ids));"""
ids = json.loads(
subprocess.check_output(
["node", "-e", script, str(ROOT / "tools" / "est-test" / "native-app.js")],
text=True,
encoding="utf-8",
timeout=10,
)
)
import uuid
assert ids[0] != ids[1]
assert all(str(uuid.UUID(value, version=4)) == value for value in ids)
def calculate(data):
script = 'const vm=require("vm"), fs=require("fs"); const ctx={}; vm.runInNewContext(fs.readFileSync(process.argv[1],"utf8")+";this.calc=EstCalc",ctx); console.log(JSON.stringify(ctx.calc.calculate(JSON.parse(process.argv[2]))));'
return json.loads(
+313
View File
@@ -0,0 +1,313 @@
from test_est_rea_parity import run
def test_standard_changes_include_new_deleted_and_changed_link_types():
result = run(
"math",
"""(()=>{
const p=EstNative.empty();p.tables.EstMainDat=[
{PkID:1,Code:'A',Name:'changed',LinkIndex:21,GN:2,TC:200},
{PkID:2,Code:'B',Name:'changed link',LinkIndex:5,GN:1,TC:50},
{PkID:4,Code:'D',Name:'new',LinkIndex:21,GN:1,TC:40}];
p.baseline={tables:{EstMainDat:[
{PkID:1,Code:'A',Name:'changed',LinkIndex:21,GN:1,TC:100},
{PkID:2,Code:'B',Name:'changed link',LinkIndex:21,GN:1,TC:50},
{PkID:3,Code:'C',Name:'deleted',LinkIndex:21,GN:3,TC:300},
{PkID:5,Code:'E',Name:'other',LinkIndex:5,GN:1,TC:10}]}};
return {rows:EstStandard.changes(p),print:EstForms.data('standard-change',p)};
})()""",
)
rows = {row["Code"]: row for row in result["rows"]}
assert set(rows) == {"A", "B", "C", "D"}
assert rows["A"]["difference"] == 100
assert rows["B"]["status"] == "변경"
assert rows["C"]["status"] == "삭제" and rows["C"]["index"] is None
assert rows["D"]["status"] == "신규"
assert result["print"]["rows"] == result["rows"]
def test_standard_usage_reports_applied_and_source_prices_without_mutation():
result = run(
"math",
"""(()=>{
const p=EstNative.empty();p.tables.SilJukDat=[{Code:'A',Name:'source',ValueIndex:4,InputTC:120},
{Code:'B',ValueIndex:4,InputTC:50},{Code:'B',ValueIndex:4,InputTC:60}];
p.tables.EstMainDat=[{Code:'A',Name:'work',LinkIndex:21,GN:2,TU:100,TC:200},
{Code:'B',Name:'duplicate',LinkIndex:21,GN:1,TU:45,TC:45},
{Code:'C',Name:'missing',LinkIndex:21,GN:1,TU:20,TC:20},
{Code:'A',Name:'material',LinkIndex:5,GN:1,TU:10}];
const before=JSON.stringify(p), rows=EstStandard.usage(p), print=EstForms.data('standard-usage',p);
return {rows,print,same:before===JSON.stringify(p)};
})()""",
)
assert result["same"]
assert [row["status"] for row in result["rows"]] == ["연결됨", "코드 중복", "연결 없음"]
assert result["rows"][0]["sourcePrice"] == 120
assert result["rows"][0]["appliedPrice"] == 100
assert result["rows"][0]["difference"] == -20
assert result["rows"][1]["sourcePrice"] is None
assert result["print"]["rows"] == result["rows"]
def test_standard_apply_row_and_all_prices():
result = run(
"math",
"""(()=>{
const p = EstNative.empty();
p.options.SourceRefresh = false;
p.options.AutoCalc = false;
p.tables.SilJukDat = [
{ Code: 'A', Name: 'standard A', ValueIndex: 4, InputTC: 150, InputGC: 50, InputNC: 70, InputJC: 30, InputSC: 0 },
{ Code: 'B', Name: 'standard B', ValueIndex: 4, InputTC: 300, InputSC: 300 },
{ Code: 'D', Name: 'dup', ValueIndex: 4, InputTC: 50 },
{ Code: 'D', Name: 'dup', ValueIndex: 4, InputTC: 60 },
];
p.tables.EstMainDat = [
{ Code: 'A', Name: 'item A', LinkIndex: 21, GN: 2, TU: 100, GU: 30, NU: 50, JU: 20, GC: 60, NC: 100, JC: 40, TC: 200, SCheck: 1 },
{ Code: 'B', Name: 'item B', LinkIndex: 21, GN: 3, TU: 200, SU: 200, SC: 600, TC: 600 },
{ Code: 'C', Name: 'item C', LinkIndex: 21, GN: 1, TU: 50, SU: 50, SC: 50, TC: 50 },
{ Code: 'D', Name: 'item D', LinkIndex: 21, GN: 1, TU: 40, SU: 40, SC: 40, TC: 40 },
{ Code: 'OTHER', Name: 'other row', LinkIndex: 5, GN: 5, TU: 10, GC: 50, TC: 50 },
];
const initialUsage = EstStandard.usage(p);
const otherBefore = JSON.stringify(p.tables.EstMainDat[4]);
// 행 0(SCheck=1: 재료비 몰아주기 설정)에 단가 적용:
// SCheck=1이 보존되어 GU=150, NU=0, JU=0, SU=0 및 GN=2 -> GC=300, TC=300
EstStandard.applyRowPrice(p, 0);
const afterRow0 = {
row0_TU: p.tables.EstMainDat[0].TU,
row0_GU: p.tables.EstMainDat[0].GU,
row0_NU: p.tables.EstMainDat[0].NU,
row0_JU: p.tables.EstMainDat[0].JU,
row0_GC: p.tables.EstMainDat[0].GC,
row0_TC: p.tables.EstMainDat[0].TC,
row0_SCheck: p.tables.EstMainDat[0].SCheck,
otherPreserved: JSON.stringify(p.tables.EstMainDat[4]) === otherBefore,
sourceRefresh: p.options.SourceRefresh,
autoCalc: p.options.AutoCalc,
};
// SCheck를 0으로 초기화한 뒤 다시 적용하면 원천의 재료/노무/경비 비목 분할(50/70/30)이 유지됨
p.tables.EstMainDat[0].SCheck = 0;
EstStandard.applyRowPrice(p, 0);
const afterRow0_split = {
row0_TU: p.tables.EstMainDat[0].TU,
row0_GU: p.tables.EstMainDat[0].GU,
row0_NU: p.tables.EstMainDat[0].NU,
row0_JU: p.tables.EstMainDat[0].JU,
row0_GC: p.tables.EstMainDat[0].GC,
row0_NC: p.tables.EstMainDat[0].NC,
row0_JC: p.tables.EstMainDat[0].JC,
row0_TC: p.tables.EstMainDat[0].TC,
};
let errMissing = false;
try { EstStandard.applyRowPrice(p, 2); } catch { errMissing = true; }
let errDup = false;
try { EstStandard.applyRowPrice(p, 3); } catch { errDup = true; }
// 남은 대상인 행 1(수량 3)에 대해 applyAllPrices 적용
const appliedCount = EstStandard.applyAllPrices(p);
const afterAll = {
row1_TU: p.tables.EstMainDat[1].TU,
row1_SU: p.tables.EstMainDat[1].SU,
row1_SC: p.tables.EstMainDat[1].SC,
row1_TC: p.tables.EstMainDat[1].TC,
row2_TU: p.tables.EstMainDat[2].TU,
row3_TU: p.tables.EstMainDat[3].TU,
otherPreserved: JSON.stringify(p.tables.EstMainDat[4]) === otherBefore,
};
const finalUsage = EstStandard.usage(p);
// AutoCalc=true 전환 후 전체 재계산: 금액이 수량(GN)을 반영해 갱신됨
p.options.AutoCalc = true;
const calculated = EstNative.calculate(p);
const afterRecalc = {
row0_GC: calculated.tables.EstMainDat[0].GC,
row0_NC: calculated.tables.EstMainDat[0].NC,
row0_JC: calculated.tables.EstMainDat[0].JC,
row0_TC: calculated.tables.EstMainDat[0].TC,
row1_SC: calculated.tables.EstMainDat[1].SC,
row1_TC: calculated.tables.EstMainDat[1].TC,
};
return { initialUsage, afterRow0, afterRow0_split, errMissing, errDup, appliedCount, afterAll, finalUsage, afterRecalc };
})()""",
)
assert result["initialUsage"][0]["canApply"] is True
assert result["initialUsage"][1]["canApply"] is True
assert result["initialUsage"][2]["canApply"] is False
assert result["initialUsage"][3]["canApply"] is False
assert result["afterRow0"] == {
"row0_TU": 150,
"row0_GU": 150,
"row0_NU": 0,
"row0_JU": 0,
"row0_GC": 60,
"row0_TC": 200,
"row0_SCheck": 1,
"otherPreserved": True,
"sourceRefresh": False,
"autoCalc": False,
}
assert result["afterRow0_split"] == {
"row0_TU": 150,
"row0_GU": 50,
"row0_NU": 70,
"row0_JU": 30,
"row0_GC": 60,
"row0_NC": 100,
"row0_JC": 40,
"row0_TC": 200,
}
assert result["errMissing"] is True
assert result["errDup"] is True
assert result["appliedCount"] == 1
assert result["afterAll"] == {
"row1_TU": 300,
"row1_SU": 300,
"row1_SC": 600,
"row1_TC": 600,
"row2_TU": 50,
"row3_TU": 40,
"otherPreserved": True,
}
assert result["afterRecalc"] == {
"row0_GC": 100,
"row0_NC": 140,
"row0_JC": 60,
"row0_TC": 300,
"row1_SC": 900,
"row1_TC": 900,
}
assert result["finalUsage"][0]["canApply"] is False
assert result["finalUsage"][1]["canApply"] is False
def test_edge_standard_usage_apply_price_and_undo_when_server_running():
from playwright.sync_api import sync_playwright
with sync_playwright() as p:
browser = p.chromium.launch(channel="msedge", headless=True)
page = browser.new_page()
try:
page.goto("http://100.65.141.15:5175", timeout=5000)
page.wait_for_function("() => Boolean(EstApp.state.project)")
except Exception:
browser.close()
import pytest
pytest.skip("EST 5175 server not reachable")
# 1. 테스트 프로젝트 데이터 세팅 (표준시장단가 및 내역 행 주입)
page.evaluate(
"""(() => {
const p = EstNative.empty();
p.options.SourceRefresh = false;
p.tables.SilJukDat = [
{ Code: 'S001', Name: '표준토공', Size: '일반', Unite: 'm3', ValueIndex: 4, InputTC: 15000 },
{ Code: 'S002', Name: '표준포장', Size: 't=10cm', Unite: 'm2', ValueIndex: 4, InputTC: 32000 },
];
p.tables.EstMainDat = [
{ PkID: 101, Code: 'S001', Name: '표준토공', Size: '일반', Unite: 'm3', LinkIndex: 21, GN: 10, TU: 12000, SU: 12000, SC: 120000, TC: 120000 },
{ PkID: 102, Code: 'S002', Name: '표준포장', Size: 't=10cm', Unite: 'm2', LinkIndex: 21, GN: 5, TU: 30000, SU: 30000, SC: 150000, TC: 150000 },
];
EstApp.setProject(p);
EstApp.state.view = 'standard';
EstApp.render();
})()"""
)
# 2. 표준시장단가 사용 내역 화면 확인
assert page.locator("#view-title:has-text('표준시장단가 사용 내역')").is_visible()
btn_apply_all = page.locator("button[data-standard-apply-all]")
assert btn_apply_all.is_visible()
assert "기초단가 일괄 적용 (2건)" in btn_apply_all.text_content()
# 3. 개별 단가 적용 버튼 클릭 (1행 S001)
btn_row_apply = page.locator("button[data-standard-apply-row='0']")
assert btn_row_apply.is_visible()
btn_row_apply.click()
# 1행이 15,000으로 갱신되었는지 확인
tu_row0 = page.evaluate("EstApp.state.project.tables.EstMainDat[0].TU")
tc_row0 = page.evaluate("EstApp.state.project.tables.EstMainDat[0].TC")
assert tu_row0 == 15000
assert tc_row0 == 150000
# 버튼 라벨이 (1건)으로 변경되었는지 확인
assert "기초단가 일괄 적용 (1건)" in btn_apply_all.text_content()
# 4. 일괄 적용 버튼 클릭 (남은 1건 S002 적용)
btn_apply_all.click()
tu_row1 = page.evaluate("EstApp.state.project.tables.EstMainDat[1].TU")
tc_row1 = page.evaluate("EstApp.state.project.tables.EstMainDat[1].TC")
assert tu_row1 == 32000
assert tc_row1 == 160000
# 이제 일괄 적용 버튼이 disabled 상태인지 확인
assert btn_apply_all.is_disabled()
# 5. Undo(실행 취소) 검증
page.locator(".toolbar button[data-action='undo']").click()
# 1단계 취소 시 2행은 원래대로 30,000 복귀
assert page.evaluate("EstApp.state.project.tables.EstMainDat[1].TU") == 30000
page.locator(".toolbar button[data-action='undo']").click()
# 2단계 취소 시 1행도 원래대로 12,000 복귀
assert page.evaluate("EstApp.state.project.tables.EstMainDat[0].TU") == 12000
browser.close()
def test_standard_manual_mode_autocalc_false_and_recalc_switch():
result = run(
"math",
"""(()=>{
const p = EstNative.empty();
p.options.SourceRefresh = false;
p.options.AutoCalc = false; // 수동계산 모드
p.tables.SilJukDat = [
{ Code: 'S10', Name: '표준단가 10', ValueIndex: 4, InputTC: 20000, InputSC: 20000 },
];
p.tables.EstMainDat = [
{ Level: 1, Code: '', Name: '공종1', TC: 50000 },
{ Code: 'S10', Name: '품목 10', LinkIndex: 21, GN: 5, TU: 10000, SU: 10000, SC: 50000, TC: 50000 },
];
// 1. 수동계산 상태에서 applyRowPrice 실행
// 해당 행(인덱스 1)의 단가(TU, SU)는 20,000으로 갱신되나,
// AutoCalc=false이므로 자체 금액(SC=50000, TC=50000) 및 상위 공종 행(Level 1, TC=50000) 금액이 모두 보존됨
EstStandard.applyRowPrice(p, 1);
const afterManual = {
row1_TU: p.tables.EstMainDat[1].TU,
row1_SC: p.tables.EstMainDat[1].SC,
row1_TC: p.tables.EstMainDat[1].TC,
row0_Level1_TC: p.tables.EstMainDat[0].TC,
};
// 2. 전체 재계산(AutoCalc=true 전환 및 EstNative.calculate 실행)
p.options.AutoCalc = true;
const calculated = EstNative.calculate(p);
const afterRecalc = {
row1_TU: calculated.tables.EstMainDat[1].TU,
row1_SC: calculated.tables.EstMainDat[1].SC,
row1_TC: calculated.tables.EstMainDat[1].TC,
row0_Level1_TC: calculated.tables.EstMainDat[0].TC,
};
return { afterManual, afterRecalc };
})()""",
)
assert result["afterManual"]["row1_TU"] == 20000
assert result["afterManual"]["row1_SC"] == 50000
assert result["afterManual"]["row1_TC"] == 50000
# 수동 상태에서는 자체 금액 및 상위 공종(Level 1) 금액 모두 보존됨
assert result["afterManual"]["row0_Level1_TC"] == 50000
# 전체 재계산 전환 후 수량(GN=5)을 반영해 자체 금액 및 상위 공종 정상 합산됨
assert result["afterRecalc"]["row1_TU"] == 20000
assert result["afterRecalc"]["row1_SC"] == 100000
assert result["afterRecalc"]["row1_TC"] == 100000
assert result["afterRecalc"]["row0_Level1_TC"] == 100000
+24
View File
@@ -3,6 +3,30 @@
from test_est_rea_parity import run
def test_replace_cost_words_preserves_markers_and_checks_lengths_before_changes():
result = run(
"math",
r"""(()=>{
const rows=[{index:3,row:{LineData:'\x0e모래\x0e + 모래 = \x121\x12'}},{index:5,row:{LineData:'유지'}}];
const before=JSON.stringify(rows),changed=EstText.replaceWords(rows,'모래','자갈');let rejected=0;
for(const [a,b] of [['','x'],['모래','\x12'],['모래','x'.repeat(200001)]]){
try{EstText.replaceWords(rows,a,b)}catch{rejected++}
}
const markers=EstText.replaceWords([{index:7,row:{LineData:'Q=1 = \x121\x12; cost \x031\x03'}}],'1','2');
const calcRows=[{Index:1,Code:'H',LineData:'head'},{LineData:'Q=1 = \x121\x12'}];
const update=EstText.replaceWords([{row:calcRows[1],index:1}],'1','2')[0];calcRows[1].LineData=update.value;
const recalculated=EstText.calculate(calcRows);
return {changed,rejected,markers,unchanged:JSON.stringify(rows)===before,
crossing:EstText.replaceWords(rows,'모래 +','x').length,recalculated:recalculated.rows[1].LineData,errors:recalculated.errors};
})()""",
)
assert result["changed"] == [{"index": 3, "value": "\x0e자갈\x0e + 자갈 = \x121\x12"}]
assert result["rejected"] == 3
assert result["markers"] == [{"index": 7, "value": "Q=2 = \x121\x12; cost \x031\x03"}]
assert "\x122\x12" in result["recalculated"] and not result["errors"]
assert result["unchanged"] and result["crossing"] == 0
def test_text_units_and_description_columns_preserve_numeric_assignments():
result = run(
"math",