654 lines
26 KiB
Python
654 lines
26 KiB
Python
"""REA 저장 샘플과 웹앱 엔진의 대조. 원본 프로그램 실행 시험이 아님."""
|
||
|
||
import json
|
||
import subprocess
|
||
from pathlib import Path
|
||
|
||
ROOT = Path(__file__).resolve().parents[2]
|
||
HELPER = Path(__file__).with_name("helper_est_native.cjs")
|
||
EXPECTED = json.loads(
|
||
(ROOT / "resources/tester/fixtures/est_rea_expected.json").read_text(encoding="utf-8")
|
||
)
|
||
SAMPLE = json.loads((ROOT / "tools/est-test/sample.json").read_text(encoding="utf-8"))
|
||
|
||
|
||
def run(command, *args):
|
||
return json.loads(
|
||
subprocess.check_output(["node", str(HELPER), command, *args], text=True, encoding="utf-8")
|
||
)
|
||
|
||
|
||
def test_machine_stored_components_heads_and_titles():
|
||
result = run("machine")
|
||
assert result["errors"] == []
|
||
count = 0
|
||
for expected in EXPECTED["machine"]["rows"]:
|
||
if "stored" not in expected:
|
||
continue
|
||
row = result["rows"][expected["slot"]]
|
||
if isinstance(expected["stored"], dict):
|
||
for key, value in expected["stored"].items():
|
||
assert abs((row.get(key) or 0) - (value or 0)) < 1e-6, (
|
||
expected["slot"],
|
||
key,
|
||
row.get(key),
|
||
value,
|
||
)
|
||
else:
|
||
assert abs(row[expected["field"]] - expected["stored"]) < 1e-6, expected
|
||
count += 1
|
||
by_code = {row["Code"]: row for row in result["titles"]}
|
||
for expected in SAMPLE["tables"]["MachTitleDat"]:
|
||
actual = by_code[expected["Code"]]
|
||
for key in ["NC", "GC", "JC", "TC"]:
|
||
assert actual[key] == expected[key]
|
||
count += 1
|
||
assert count == 48
|
||
|
||
|
||
def test_numeric_helpers_distinguish_rounding_modes():
|
||
assert run(
|
||
"math",
|
||
"[EstMath.even(2.5),EstMath.even(3.5),EstMath.floating(-1.25,1,1),EstMath.fixed(1.23999,2)]",
|
||
) == [2, 4, -1.3, 1.24]
|
||
|
||
|
||
def test_large_currency_boundary_and_unit_conversion_caption():
|
||
assert run("math", "EstMath.floating(172068*0.1*756,1,2)") == 13008340.8
|
||
result = run(
|
||
"math",
|
||
"""EstRows.head([
|
||
{Index:1,Code:'H'},
|
||
{Index:0,GN:1,NU:14341},
|
||
{Index:0,GN:0.1,LinkIndex:68,Size:'소계 / 10단자'}
|
||
],{HeadDigits:0})""",
|
||
)
|
||
assert result["rows"][0]["NC"] == 1434
|
||
assert result["rows"][2]["NU"] == 14341
|
||
assert result["rows"][2]["NC"] == 1434
|
||
|
||
|
||
def test_procurement_zero_place_keeps_integer_total():
|
||
row = run(
|
||
"math",
|
||
"EstRows.procurement({Index:3,LinkIndex:57,GN:0.54,Gangub:0,OCheck:1,SCheck:3},[{LinkIndex:5,GC:30029967,TC:30029967}])",
|
||
)
|
||
assert row["JC"] == row["TC"] == 162161
|
||
|
||
|
||
def test_blob_reused_capacity_and_incomplete_payload_are_distinct():
|
||
result = run("blob-tail")
|
||
assert result["value"] == "abc"
|
||
assert "BLOB 총 길이 오류" in result["truncated"]
|
||
|
||
|
||
def test_procurement_exclusion_only_changes_industrial_safety_base():
|
||
result = run(
|
||
"math",
|
||
"""EstWonga.calculate([
|
||
{Gubun:'J',Name:'관급자재대',Cost:1000,NC:100,VSusic:'원금액:1,000, {조달수수료:100}'},
|
||
{Gubun:'S',Name:'산업안전보건관리비',VSusic:'J*0.1'},
|
||
{Gubun:'P',Name:'other',VSusic:'J*0.1'}
|
||
],{GangubExceptJodalRatio:true})""",
|
||
)
|
||
assert result["errors"] == []
|
||
assert [row["Cost"] for row in result["rows"]] == [1000, 90, 100]
|
||
|
||
|
||
def test_resource_factor_includes_trailing_multiplier():
|
||
row = run(
|
||
"math",
|
||
r"""EstText.calculate([
|
||
{Index:1,Code:'C',LineData:''},
|
||
{Index:0,LinkIndex:5,SCheck:-2,LineData:'\x0eM\x0e : \x03100\x03 * 2 = \x12200\x12 * 0.5 = \x12100\x12'}
|
||
],{},()=>({Code:'M',Name:'M',SourceKind:5,GC:100,TC:100})).rows[1]""",
|
||
)
|
||
assert row["ResourceFactor"] == 1
|
||
assert row["TC"] == 100
|
||
|
||
|
||
def test_direct_material_scope_is_the_marked_section():
|
||
result = run(
|
||
"math",
|
||
"""(() => {
|
||
const rows=[{Level:2,Name:'순직접공사비'},{Level:0,LinkIndex:5,GC:100},
|
||
{Level:2,Name:'사급자재대*'},{Level:0,LinkIndex:5,GC:200},{LinkIndex:54}];
|
||
return [EstNative.materialBase(rows,{GCToWonga:false}),EstNative.materialBase(rows,{GCToWonga:true})];
|
||
})()""",
|
||
)
|
||
assert result == [200, 300]
|
||
|
||
|
||
def test_practical_fire_projects_keep_stored_values_and_report_bad_source_formula():
|
||
files = list((ROOT / "resources/knowledge/original/실무문서").rglob("*.estx"))
|
||
assert len(files) >= 3
|
||
for file in files:
|
||
output = subprocess.check_output(
|
||
["node", str(HELPER.with_name("helper_est_archive.cjs")), str(file)],
|
||
text=True,
|
||
encoding="utf-8",
|
||
timeout=120,
|
||
)
|
||
result = json.loads(output)
|
||
assert result["differences"] == [], file.name
|
||
# The original text has an unclosed parenthesis in this transport
|
||
# calculation; preserve its stored price and show the source error.
|
||
assert all(
|
||
error.startswith("D0000000194:") and "괄호" in error for error in result["errors"]
|
||
), (file.name, result["errors"])
|
||
|
||
|
||
def test_head_41_stored_checks():
|
||
result = run("head")
|
||
assert result["errors"] == []
|
||
rows = result["rows"]
|
||
for expected in EXPECTED["head_est"]["head"]:
|
||
actual = rows[expected["source_slot"]]
|
||
for key, value in expected["stored"].items():
|
||
assert abs((actual.get(key) or 0) - value) < 1e-6, (expected, key, actual.get(key))
|
||
assert len(EXPECTED["head_est"]["head"]) == 41
|
||
|
||
|
||
def test_estimate_37_stored_checks():
|
||
result = run("estimate")
|
||
assert result["errors"] == []
|
||
for expected in EXPECTED["head_est"]["est"]:
|
||
row = result["rows"][expected["slot"]]
|
||
for key, value in expected["stored"].items():
|
||
assert abs((row.get(key) or 0) - value) < 1e-6, (
|
||
expected["slot"],
|
||
key,
|
||
row.get(key),
|
||
value,
|
||
)
|
||
assert len(EXPECTED["head_est"]["est"]) == 37
|
||
|
||
|
||
def test_linedata_118_results_and_8_titles():
|
||
result = run("text")
|
||
assert result["errors"] == []
|
||
assert len(result["checks"]) == 118
|
||
for item in result["checks"]:
|
||
assert abs(item["stored"] - item["calculated"]) < 1e-6, item
|
||
for expected in EXPECTED["linedata"]["rows"]:
|
||
if expected["kind"] == "title":
|
||
actual = result["rows"][expected["slot"]]
|
||
for key, value in expected["stored"].items():
|
||
assert actual[key] == value
|
||
|
||
|
||
def test_wonga_all_23_formulas_and_total_fix():
|
||
result = run("wonga")
|
||
assert result["errors"] == []
|
||
for expected in EXPECTED["wonga"]["results"]:
|
||
row = result["rows"][expected["row_position"]]
|
||
assert row["Cost"] == float(expected["stored"]), (
|
||
row["Gubun"],
|
||
row["Cost"],
|
||
expected["stored"],
|
||
)
|
||
assert len(EXPECTED["wonga"]["results"]) == 23
|
||
assert result["total"] == 79603000
|
||
assert [step["difference"] for step in result["adjustments"]] == [324, -32, 3, 0]
|
||
|
||
|
||
def test_entire_refresh_pipeline_preserves_stored_outputs():
|
||
result = run("refresh")
|
||
assert result["calculation"]["errors"] == []
|
||
for table in ["MachMainDat", "HeadMainDat", "EstMainDat"]:
|
||
for index, (before, after) in enumerate(
|
||
zip(SAMPLE["tables"][table], result["tables"][table])
|
||
):
|
||
for key in ["NC", "GC", "JC", "SC", "TC"]:
|
||
assert abs((before.get(key) or 0) - (after.get(key) or 0)) < 1e-6, (
|
||
table,
|
||
index,
|
||
key,
|
||
)
|
||
for table in ["CostTitleDat", "HeadTitleDat", "MachTitleDat"]:
|
||
lookup = {row["Code"]: row for row in result["tables"][table]}
|
||
for before in SAMPLE["tables"][table]:
|
||
for key in ["NC", "GC", "JC", "SC", "TC"]:
|
||
assert abs((before.get(key) or 0) - (lookup[before["Code"]].get(key) or 0)) < 1e-6
|
||
|
||
|
||
def test_recalculation_after_price_edit_is_idempotent():
|
||
first, second = run("repeat")
|
||
assert first["calculation"]["errors"] == second["calculation"]["errors"] == []
|
||
assert first["tables"]["CostTitleDat"] == second["tables"]["CostTitleDat"]
|
||
assert first["tables"]["EstMainDat"] == second["tables"]["EstMainDat"]
|
||
|
||
|
||
def test_progress_accepted_rounds_annual_allocation_and_resources():
|
||
result = run("business")
|
||
first, second = result["first"], result["second"]
|
||
assert first["errors"] == second["errors"] == []
|
||
# c914d4 rounds ratio-derived quantities to the contract quantity's digits.
|
||
assert first["rows"][0]["GumGN"] == 3
|
||
assert first["rows"][0]["TotalGN"] == 1.5
|
||
assert first["total"] == 330
|
||
assert second["rows"][0]["JunGN"] == 1.5
|
||
assert second["rows"][0]["GumGN"] == 2.5
|
||
assert second["rows"][0]["TotalGN"] == 4
|
||
assert second["rows"][0]["TotalTC"] == 800
|
||
assert first["rows"][-1]["GumTC"] == 660
|
||
assert first["rows"][-1]["SaTC"] == first["rows"][-1]["TotalTC"] == 330
|
||
assert second["rows"][-1]["JunTC"] == 330
|
||
assert second["rows"][-1]["TotalTC"] == 880
|
||
assert result["years"]["errors"] == []
|
||
assert [r["project"]["calculation"]["total"] for r in result["years"]["results"]] == [880, 1320]
|
||
assert result["resources"]["errors"] == []
|
||
assert result["resources"]["rows"][0]["quantity"] == 20
|
||
escalation = result["escalation"]
|
||
assert escalation["errors"] == []
|
||
assert escalation["rows"][0]["EscExcluded"] == 3
|
||
assert escalation["rows"][0]["EscApplied"] == 7
|
||
assert escalation["contractTotal"] == 2200
|
||
assert escalation["adjustedTotal"] == 2640
|
||
assert escalation["excludedTotal"] == 660
|
||
assert escalation["applied"] == 1540
|
||
# REA 10a8fa8: investigation amount 1,980 is the denominator, not contract 2,200.
|
||
assert escalation["designTotal"] == 1980
|
||
assert escalation["rate"] == 22.22
|
||
assert escalation["adjustment"] == 342
|
||
assert [
|
||
next(row["TC"] for row in item["tables"]["EstMainDat"] if row.get("LinkIndex") == 54)
|
||
for item in result["originals"]
|
||
] == [2120, 2520, 2520]
|
||
assert result["bid"]["calculation"]["direct"]["TC"] == 1800
|
||
assert result["bidAgain"]["tables"]["EstMainDat"] == result["bid"]["tables"]["EstMainDat"]
|
||
assert result["bidResources"]["errors"] == []
|
||
assert result["bidResources"]["rows"][0]["quantity"] == 18
|
||
assert result["bidResources"]["rows"][0]["amount"] == 1800
|
||
assert result["convertedResources"]["errors"] == []
|
||
assert result["convertedResources"]["rows"][0]["quantity"] == 10
|
||
assert result["convertedResources"]["rows"][0]["amount"] == 1000
|
||
assert result["equipmentResources"]["errors"] == []
|
||
assert result["equipmentResources"]["rows"][0]["quantity"] == 20
|
||
assert result["equipmentResources"]["rows"][0]["amount"] == 2000
|
||
|
||
|
||
def test_estx_import_reads_original_tables_options_and_templates(tmp_path):
|
||
import zipfile
|
||
|
||
bundle = next((ROOT / "resources/knowledge/original").glob("*/EST Plus 2/*/05_*/sample_bundle"))
|
||
archive = tmp_path / "sample.estx"
|
||
with zipfile.ZipFile(archive, "w", zipfile.ZIP_DEFLATED) as output:
|
||
for file in bundle.iterdir():
|
||
if file.is_file():
|
||
output.write(file, file.name)
|
||
result = run("import", str(archive))
|
||
assert result["schema"] == "est-web-2"
|
||
assert len(result["tables"]["EstMainDat"]) == len(SAMPLE["tables"]["EstMainDat"])
|
||
assert len(result["originalOptions"]) == 17
|
||
assert len(result["wongaTemplates"]) == 4
|
||
assert result["options"]["TotalFix"] is True
|
||
assert result["options"]["GCToWonga"] is False
|
||
assert result["options"]["CostValueTrunc"] is False
|
||
assert result["options"]["MidCostDiget"] == 2
|
||
assert result["originalArchive"]
|
||
|
||
|
||
def test_source_price_edit_changes_downstream_costs():
|
||
first, _ = run("repeat")
|
||
original = run("refresh")
|
||
assert first["calculation"]["direct"]["TC"] != original["calculation"]["direct"]["TC"]
|
||
assert first["tables"]["Wonga"] != original["tables"]["Wonga"]
|
||
|
||
|
||
def test_extended_original_rows_cover_conversion_fractions_and_fuel():
|
||
fixture = json.loads(
|
||
(ROOT / "resources/tester/fixtures/est_native_extended.json").read_text(encoding="utf-8")
|
||
)
|
||
result = run("extended")
|
||
for name, table in [("head", "HeadMainDat"), ("machine", "MachMainDat")]:
|
||
assert result[name]["errors"] == []
|
||
for index, (before, after) in enumerate(
|
||
zip(fixture["tables"][table], result[name]["rows"])
|
||
):
|
||
for key in ["NC", "GC", "JC", "SC", "TC"]:
|
||
assert abs((before.get(key) or 0) - (after.get(key) or 0)) < 1e-6, (
|
||
table,
|
||
index,
|
||
key,
|
||
)
|
||
assert result["text"]["errors"] == []
|
||
for check in result["text"]["checks"]:
|
||
assert abs(check["stored"] - check["calculated"]) < 1e-6, check
|
||
|
||
|
||
def test_installed_main_archive_all_stored_values_when_available():
|
||
import os
|
||
import pytest
|
||
|
||
archive = Path(
|
||
os.environ.get("EST_REA_MAIN", "C:/Dasansoft/Est Plus 2/Data/ProjectData/main.estx")
|
||
)
|
||
if not archive.is_file():
|
||
pytest.skip("Original EST main.estx is not installed; portable fixtures still run")
|
||
output = subprocess.check_output(
|
||
["node", str(HELPER.with_name("helper_est_archive.cjs")), str(archive)],
|
||
text=True,
|
||
encoding="utf-8",
|
||
timeout=120,
|
||
)
|
||
result = json.loads(output)
|
||
assert result["errors"] == []
|
||
assert result["differences"] == []
|
||
assert all(item["checked"] == item["matched"] for item in result["counts"].values())
|
||
assert sum(item["checked"] for item in result["counts"].values()) > 100000
|
||
|
||
|
||
def test_original_quantity_sheets_and_three_saved_formula_disagreements():
|
||
sheets = run("quantity")
|
||
assert len(sheets) == 75
|
||
# Original editor formulas disagree with MulList saved numbers in three
|
||
# places. Keep both visible instead of changing the formulas to replay them.
|
||
expected = {
|
||
("N0000000083", "r000004"): (286, 0.35 * 1.3 * 220),
|
||
("N0000000086", "r000004"): (0, 1 * 1),
|
||
("N0000000106", "r000000"): (138.72, 1.25 * 2 * 68 * 1.02),
|
||
}
|
||
differences = set()
|
||
checked = 0
|
||
for sheet in sheets:
|
||
assert sheet["errors"] == []
|
||
for before, after in zip(sheet["original"], sheet["items"]):
|
||
checked += 1
|
||
key = (sheet["Code"], before["Code"])
|
||
if key in expected:
|
||
assert before["GN"] == expected[key][0]
|
||
assert abs(after["GN"] - expected[key][1]) < 1e-6
|
||
differences.add(key)
|
||
else:
|
||
assert abs((before.get("GN") or 0) - (after.get("GN") or 0)) < 1e-6, key
|
||
assert checked == 449
|
||
assert differences == set(expected)
|
||
|
||
|
||
def test_legacy_project_preserves_running_cost_basis_and_final_total():
|
||
result = run(
|
||
"math",
|
||
"""EstNative.calculate(EstModel.legacy({
|
||
sources:[{id:'s',kind:'재료비',price:10.129}],
|
||
units:[{id:'u',lines:[{sourceId:'s',quantity:2,digits:2}]}],
|
||
estimate:[{unitId:'u',quantity:3}],
|
||
costs:[{base:'직접공사비',rate:10},{base:'누계',rate:5}]
|
||
})).calculation""",
|
||
)
|
||
assert result["errors"] == []
|
||
assert result["direct"]["TC"] == 60
|
||
assert result["total"] == 69
|
||
|
||
|
||
def test_manual_calculation_server_preserves_stored_amounts():
|
||
result = run("manual")
|
||
assert result["tables"]["EstMainDat"][1]["GN"] == SAMPLE["tables"]["EstMainDat"][1]["GN"] + 100
|
||
for table in ["EstMainDat", "HeadMainDat", "Wonga"]:
|
||
for original, saved in zip(SAMPLE["tables"][table], result["tables"][table]):
|
||
for key in ["TC", "NC", "GC", "JC", "SC", "Cost"]:
|
||
assert saved.get(key) == original.get(key)
|
||
|
||
|
||
def test_text_fraction_spacing_ascii_multiplication_and_tree_units():
|
||
result = run(
|
||
"math",
|
||
r"""EstText.calculate([
|
||
{Index:1,Code:'T'},
|
||
{LineData:' n = 6.55 Cms = 37.4 A = 1 N = 1'},
|
||
{LineData:' L = 50m 25kg/1회'},
|
||
{LineData:' ○ 거리 Z=17m, 7m마다 1인으로 하면'},
|
||
{LineData:' n × Cms 6.55 × 37.4'},
|
||
{LineData:' t1 = ────── = ─────── = \x1211.67\x12'},
|
||
{LineData:' 60×0.35 60×0.35'},
|
||
{LineData:' D=0.2xAxN+2 = \x122.2\x12'},
|
||
{LineData:' 길이 = L + Z = \x1267\x12'},
|
||
{LineData:' * 윤 주 : 0.94m (0.65×0.65×3.14÷4)÷2 = \x120.17\x12㎥'},
|
||
{LineData:' 노무비: \x03235204\x03 × 3인 / 125주 = \x125644.8\x12',SCheck:-1}
|
||
])""",
|
||
)
|
||
assert result["errors"] == []
|
||
assert [row["TC"] for row in result["rows"] if "TC" in row][1:] == [
|
||
11.67,
|
||
2.2,
|
||
67,
|
||
0.17,
|
||
5644.8,
|
||
]
|
||
|
||
|
||
def test_text_material_only_subtotal_does_not_reuse_prior_labor():
|
||
result = run(
|
||
"math",
|
||
r"""EstText.calculate([
|
||
{Index:1,Code:'T'},
|
||
{LineData:' \x1a노무비: 100\x1a',SCheck:-1,Level:-1},
|
||
{LineData:' \x1a재료비: 20\x1a',SCheck:-2,Level:-1},
|
||
{LineData:' \x1a계:\x1a \x12120\x12',SCheck:-5,Level:-1},
|
||
{LineData:' 다음 재료 구획',Level:1},
|
||
{LineData:' \x1a재료비: 30\x1a',SCheck:-2,Level:-1},
|
||
{LineData:' \x1a계:\x1a \x1230\x12',SCheck:-5,Level:-1}
|
||
])""",
|
||
)
|
||
assert result["errors"] == []
|
||
assert result["rows"][3]["TC"] == 120
|
||
assert result["rows"][6]["TC"] == 30
|
||
|
||
|
||
def test_original_text_layouts_and_selected_group_subtotals():
|
||
results = run("text-layout")
|
||
assert len(results) == 4
|
||
checked = 0
|
||
for result in results:
|
||
assert result["errors"] == [], result["code"]
|
||
for check in result["checks"]:
|
||
assert abs(check["stored"] - check["calculated"]) < 1e-6, (result["code"], check)
|
||
checked += 1
|
||
assert checked > 100
|
||
|
||
|
||
def test_text_preserves_braced_signs_and_comments_inside_parentheses():
|
||
result = run(
|
||
"math",
|
||
r"""EstText.calculate([
|
||
{Index:1,Code:'T'},
|
||
{LineData:' hm={-}6.76m {?} H={-}7.5m {계획수심}'},
|
||
{LineData:' d = hm-H = \x120.74\x12'},
|
||
{LineData:' L = 4인÷(4.8개{?}×8hr) = \x120.1\x12'},
|
||
{LineData:' q = 1 × 2조/회 = \x122\x12 t=15분 〔접이안시간〕'},
|
||
{LineData:' 전력비 = 0.74hr × 52.1 = \x1238.5\x12 원/본',SCheck:-3}
|
||
])""",
|
||
)
|
||
assert result["errors"] == []
|
||
assert result["rows"][2]["TC"] == 0.74
|
||
assert result["rows"][3]["TC"] == 0.1
|
||
assert result["rows"][4]["TC"] == 2
|
||
assert result["rows"][5]["TC"] == 38.5
|
||
|
||
|
||
def test_source_code_edit_updates_links_and_preserves_original_contract():
|
||
result = run(
|
||
"math",
|
||
"""(()=>{
|
||
const p=EstNative.empty();
|
||
p.tables.StuffcDat=[{PkID:1,Code:'M',Name:'material',ValueIndex:4,InputTC:100},{PkID:2,Code:'OTHER',ValueIndex:4,InputTC:10}];
|
||
p.tables.HeadMainDat=[{PkID:1,Index:1,Code:'H',Name:'head'},{PkID:2,Index:0,Code:'M',TitleCode:'H',LinkIndex:5,GN:2}];
|
||
p.tables.HeadTitleDat=[{Code:'H',Name:'head'}];
|
||
p.tables.EstMainDat=[{PkID:1,Code:'H',LinkIndex:103,GN:10},{LinkIndex:54}];
|
||
let q=EstBusiness.baseline(EstNative.calculate(p));
|
||
const before=JSON.stringify(q.baseline);
|
||
EstModel.editCode(q,'StuffcDat',0,'M2');
|
||
EstModel.editCode(q,'HeadMainDat',0,'H2');
|
||
q.tables.StuffcDat[0].InputTC=120;
|
||
q=EstNative.calculate(q);
|
||
const original=EstBusiness.original(q,2);
|
||
let duplicate=false;try{EstModel.editCode(q,'StuffcDat',0,'OTHER')}catch{duplicate=true;}
|
||
return {q,original,preserved:before===JSON.stringify(q.baseline),duplicate};
|
||
})()""",
|
||
)
|
||
project = result["q"]
|
||
assert project["calculation"]["errors"] == []
|
||
assert project["calculation"]["direct"]["TC"] == 2400
|
||
assert project["tables"]["HeadMainDat"][1]["Code"] == "M2"
|
||
assert project["tables"]["HeadMainDat"][1]["TitleCode"] == "H2"
|
||
assert project["tables"]["EstMainDat"][0]["Code"] == "H2"
|
||
assert project["tables"]["HeadTitleDat"][0]["Code"] == "H2"
|
||
assert result["original"]["tables"]["EstMainDat"][-1]["TC"] == 2400
|
||
assert result["preserved"] and result["duplicate"]
|
||
|
||
|
||
def test_text_fraction_columns_preserve_numbered_variables_and_unmarked_result():
|
||
result = run(
|
||
"math",
|
||
r"""EstText.calculate([
|
||
{Index:1,Code:'T'},
|
||
{LineData:' L11=0.35 L21=0.35 V51=15 V52=15 N=2'},
|
||
{LineData:' L11 L21'},
|
||
{LineData:' t23=(──+──)×60 = \x122.8\x12'},
|
||
{LineData:' V51 V52'},
|
||
{LineData:' t23 + t23'},
|
||
{LineData:' Tc = ────────────'},
|
||
{LineData:' 1 · N'},
|
||
{LineData:' Q=Tc×2 = \x125.6\x12'},
|
||
{LineData:' to1 = 0.6 min/m2 to2 = 3 min/m2'},
|
||
{LineData:' T=2×30×3.5×to2/60=\x1210.5\x12'}
|
||
])""",
|
||
)
|
||
assert result["errors"] == []
|
||
assert result["rows"][3]["TC"] == 2.8
|
||
assert result["rows"][8]["TC"] == 5.6
|
||
assert result["rows"][10]["TC"] == 10.5
|
||
|
||
|
||
def test_original_cost_truncation_and_intermediate_precision_options():
|
||
result = run(
|
||
"math",
|
||
r"""(()=>{
|
||
const rows=[{Index:1,Code:'T'},
|
||
{LineData:' 노무비: \x03100.19\x03 = \x12100.1\x12',SCheck:-1}];
|
||
const intermediate=[{Index:1,Code:'I'},
|
||
{LineData:' 노무비: \x0359020\x03 / 81.8 = \x12721.51\x12 ×0.5=\x12360.7\x12',SCheck:-1}];
|
||
return {normal:EstText.calculate(rows),whole:EstText.calculate(rows,{CostValueTrunc:true}),
|
||
mid1:EstText.calculate(intermediate,{MidCostDiget:1}),mid2:EstText.calculate(intermediate,{MidCostDiget:2}),
|
||
override:EstText.calculate([rows[0],{...rows[1],Precision:2}],{CostValueTrunc:true})};
|
||
})()""",
|
||
)
|
||
assert all(item["errors"] == [] for item in result.values())
|
||
assert result["normal"]["rows"][1]["TC"] == 100.1
|
||
assert result["whole"]["rows"][1]["TC"] == 100
|
||
assert result["override"]["rows"][1]["TC"] == 100.19
|
||
assert result["mid1"]["checks"][-1]["calculated"] == 721.5
|
||
assert result["mid2"]["checks"][-1]["calculated"] == 721.51
|
||
|
||
|
||
def test_source_price_refresh_recalculates_text_subtotal_and_percentage_reference():
|
||
result = run(
|
||
"math",
|
||
r"""(()=>{
|
||
const p=EstNative.empty();
|
||
p.tables.StuffcDat=[{Code:'M',Name:'material',ValueIndex:4,InputTC:150}];
|
||
p.tables.CostMainDat=[{Index:1,Code:'C'},
|
||
{Level:1,LineData:' material section'},
|
||
{Index:0,Code:'M',LinkIndex:5,SCheck:-2,LineData:' \x0ematerial\x0e: \x03100\x03 ×2 = \x12200\x12'},
|
||
{Index:-20,LinkIndex:61,SCheck:-2,LineData:' material subtotal: \x0c200\x0c'},
|
||
{LinkIndex:58,SCheck:-3,OCheck:-2,LineData:' \x0c200\x0c ×5% = \x1210\x12'},
|
||
{Level:1,LineData:' total'},
|
||
{Level:-1,SCheck:-2,LineData:' \x1a재료비:200\x1a'},
|
||
{Level:-1,SCheck:-3,LineData:' \x1a경비:10\x1a'},
|
||
{Level:-1,SCheck:-5,LineData:' \x1a계:\x1a \x12210\x12'}];
|
||
p.tables.CostTitleDat=[{Code:'C'}];
|
||
return EstNative.calculate(p);
|
||
})()""",
|
||
)
|
||
assert result["calculation"]["errors"] == []
|
||
rows = result["tables"]["CostMainDat"]
|
||
assert rows[2]["TC"] == 300
|
||
assert rows[3]["TC"] == 300
|
||
assert "\x0c300\x0c" in rows[3]["LineData"]
|
||
assert rows[4]["TC"] == 15
|
||
assert rows[0]["TC"] == 315
|
||
|
||
|
||
def test_selected_text_group_uses_distinct_rows_with_equal_original_amounts():
|
||
result = run(
|
||
"math",
|
||
r"""EstText.calculate([
|
||
{Index:1,Code:'C'},
|
||
{Code:'M',LinkIndex:5,SCheck:-2,LineData:' \x0ematerial\x0e: \x03100\x03 = \x06A\x06\x12100\x12'},
|
||
{Code:'L',LinkIndex:6,SCheck:-1,LineData:' \x0elabor\x0e: \x03100\x03 = \x06A\x06\x12100\x12'},
|
||
{LinkIndex:62,SCheck:-3,LineData:' \x07A\x07\x1a100 + 100\x1a = \x12200\x12 ×10% = \x1220\x12'}
|
||
],{},row=>row.Code==='M'?{GC:150,TC:150}:{NC:200,TC:200})""",
|
||
)
|
||
assert result["errors"] == []
|
||
assert result["rows"][3]["TC"] == 35
|
||
assert "150 + 200" in result["rows"][3]["LineData"]
|
||
|
||
|
||
def test_text_bid_rate_row_preserves_native_component_walk_and_currency_precision():
|
||
result = run(
|
||
"math",
|
||
r"""EstText.calculate([
|
||
{Index:1,Code:'C'},
|
||
{Level:1,LineData:' bid section'},
|
||
{SCheck:-1,LineData:' 노무비: 100 = \x12100\x12'},
|
||
{SCheck:-1,LineData:' 노무비: 50 = \x1250\x12'},
|
||
{SCheck:-2,LineData:' 재료비: 20 = \x1220\x12'},
|
||
{SCheck:-2,LineData:' 재료비: 30 = \x1230\x12'},
|
||
{SCheck:-3,LineData:' 경비: 12.3456 = \x1212.3456\x12',Precision:4},
|
||
{LinkIndex:52,LineData:' 낙찰률 \x1590\x15 %'}
|
||
])""",
|
||
)
|
||
assert result["errors"] == []
|
||
assert result["rows"][-1]["NC"] == result["rows"][0]["NC"] == 90
|
||
assert result["rows"][0]["GC"] == 45
|
||
assert result["rows"][0]["JC"] == 11.111
|
||
assert result["rows"][0]["TC"] == 146.111
|
||
|
||
|
||
def test_text_group_markers_are_editable_and_missing_group_is_reported():
|
||
result = run(
|
||
"math",
|
||
r"""(()=>{
|
||
const line='\x06A\x06\x12100\x12 + \x07A\x07';
|
||
return {shown:EstText.display(line),roundtrip:EstText.encode(EstText.display(line))===line,
|
||
missing:EstText.calculate([{Index:1,Code:'C'},
|
||
{LinkIndex:62,SCheck:-3,LineData:'\x07A\x07\x1a100\x1a = \x12100\x12 ×10% = \x1210\x12'}])};
|
||
})()""",
|
||
)
|
||
assert result["roundtrip"]
|
||
assert "⟦구간지정⟧A⟦구간지정⟧" in result["shown"]
|
||
assert "⟦구간합계⟧A⟦구간합계⟧" in result["shown"]
|
||
assert len(result["missing"]["errors"]) == 1
|
||
assert "A" in result["missing"]["rows"][1]["Error"]
|
||
|
||
|
||
def test_fixed_text_formula_clears_the_previous_row_error():
|
||
result = run(
|
||
"math",
|
||
r"""(()=>{
|
||
const bad=EstText.calculate([{Index:1,Code:'C'},
|
||
{LineData:' Q=(1+2 = \x123\x12'}]);
|
||
bad.rows[1].LineData=' Q=1+2 = \x123\x12';
|
||
return {bad:bad.errors,good:EstText.calculate(bad.rows)};
|
||
})()""",
|
||
)
|
||
assert len(result["bad"]) == 1
|
||
assert result["good"]["errors"] == []
|
||
assert "Error" not in result["good"]["rows"][1]
|
||
|
||
|
||
def test_text_total_without_result_marker_evaluates_its_expression():
|
||
result = run(
|
||
"math",
|
||
r"""EstText.calculate([
|
||
{Index:1,Code:'C'},
|
||
{Level:-1,SCheck:-1,LineData:' \x1a노무비:10 + 10\x1a'},
|
||
{Level:-1,SCheck:-2,LineData:' \x1a재료비:\x1a'},
|
||
{Level:-1,SCheck:-5,LineData:' \x1a계:\x1a \x1220\x12'}
|
||
])""",
|
||
)
|
||
assert result["errors"] == []
|
||
assert result["rows"][0]["TC"] == 20
|
||
assert result["rows"][1]["TC"] == 20
|
||
assert result["rows"][2]["TC"] == 0
|