Merge remote-tracking branches 'origin/CODEX' and 'origin/main_laptop_1' into sub_laptop_3
This commit is contained in:
@@ -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차"]
|
||||
|
||||
@@ -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")
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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()
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:"r000001";]]/>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"] == "일괄사유적용"
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user