Merge remote-tracking branch 'origin/dev' into sub_laptop_3

This commit is contained in:
2026-11-11 15:22:41 +09:00
25 changed files with 1306 additions and 174 deletions
@@ -149,6 +149,11 @@ COLS = {
),
}
COLS["한국은행환율"] = COLS["유가전력"]
ALIAS = "별칭" # 요소 줄 덧칸(815-2) — 열 한 벌 밖 · 있으면 빈 글 아님
def _cols_of(row: dict) -> tuple:
return tuple(k for k in row if k != ALIAS)
def _detail(name: str) -> str:
@@ -310,7 +315,9 @@ def check_form(name: str, data: dict) -> list[str]:
out.append(f"{where} · 출처 모양 「{row['출처']}」")
if data.get("그룹") == "인력":
out += _check_labor(where, row)
if cols and tuple(row) != cols:
if ALIAS in row and not (isinstance(row[ALIAS], str) and row[ALIAS].strip()):
out.append(f"{where} · 「{ALIAS}」 이 빈 글 · 글 아님(빈 칸이면 칸을 두지 않음)")
if cols and _cols_of(row) != cols:
out.append(f"{where} · 열이 한 벌이 아님 — 「{' · '.join(cols)}」")
if kind == "품셈재료":
out += _check_linked(where, row)
@@ -328,7 +335,7 @@ def check_form(name: str, data: dict) -> list[str]:
def _check_labor(where: str, row: dict) -> list[str]:
"""인력 줄 — 열 한 벌(차례까지 같음) · 상태 값 · 옛이름 목록."""
out = []
if tuple(row) != LABOR_COLS:
if _cols_of(row) != LABOR_COLS:
out.append(f"{where} · 열이 한 벌이 아님 — 「{' · '.join(LABOR_COLS)}」")
if row.get("상태") not in LABOR_STATUS:
out.append(f"{where} · 상태 「{row.get('상태')}」 — {' · '.join(LABOR_STATUS)} 아님")
+32 -18
View File
@@ -12574,7 +12574,8 @@
"조종원": "건설품셈 공통 8-4-4",
"조종원직종": "건설품셈 공통 8-1-3 5 · 건설노임 1048"
},
"비고": "조종원직종 원문 이름: 건설기계운전사 · 주연료종류: 원문 글을 가름"
"비고": "조종원직종 원문 이름: 건설기계운전사 · 주연료종류: 원문 글을 가름",
"별칭": "아스팔트페이버(피니셔)"
},
{
"키": "EQ000193",
@@ -12635,7 +12636,8 @@
"조종원": "건설품셈 공통 8-4-4",
"조종원직종": "건설품셈 공통 8-1-3 5 · 건설노임 1048"
},
"비고": "조종원직종 원문 이름: 건설기계운전사 · 주연료종류: 원문 글을 가름"
"비고": "조종원직종 원문 이름: 건설기계운전사 · 주연료종류: 원문 글을 가름",
"별칭": "아스팔트페이버(피니셔)"
},
{
"키": "EQ000194",
@@ -16031,9 +16033,8 @@
"조종원직종": "건설품셈 공통 8-1-3 5"
},
"비고": "규격 단위: (m/m) · 주연료종류: 원문 글을 가름",
"값원문": {
"8-4-5 주연료": "휘발유1.0"
}
"값원문": { "8-4-5 주연료": "휘발유1.0" },
"별칭": "바이브레이트 봉상후렉시블"
},
{
"키": "EQ000249",
@@ -25212,7 +25213,8 @@
"조종원": "건설품셈 공통 8-4-7",
"조종원직종": "건설품셈 공통 8-1-3 5"
},
"비고": "주연료종류: 원문 글을 가름"
"비고": "주연료종류: 원문 글을 가름",
"별칭": "워터제트"
},
{
"키": "EQ000401",
@@ -26734,7 +26736,8 @@
"조종원": "건설품셈 공통 8-4-8",
"조종원직종": "건설품셈 공통 8-1-3 5"
},
"비고": "주연료종류: 원문 글을 가름"
"비고": "주연료종류: 원문 글을 가름",
"별칭": "버킷준설기"
},
{
"키": "EQ000426",
@@ -31131,7 +31134,8 @@
"조종원": "건설품셈 공통 8-4-8",
"조종원직종": "건설품셈 공통 8-1-3 5"
},
"비고": "규격 단위: 기종 · 규격(㎾) · 주연료종류: 원문 글을 가름"
"비고": "규격 단위: 기종 · 규격(㎾) · 주연료종류: 원문 글을 가름",
"별칭": "디젤 엔진"
},
{
"키": "EQ000498",
@@ -31192,7 +31196,8 @@
"조종원": "건설품셈 공통 8-4-8",
"조종원직종": "건설품셈 공통 8-1-3 5"
},
"비고": "규격 단위: 기종 · 규격(㎾) · 주연료종류: 원문 글을 가름"
"비고": "규격 단위: 기종 · 규격(㎾) · 주연료종류: 원문 글을 가름",
"별칭": "디젤 엔진"
},
{
"키": "EQ000499",
@@ -31253,7 +31258,8 @@
"조종원": "건설품셈 공통 8-4-8",
"조종원직종": "건설품셈 공통 8-1-3 5"
},
"비고": "규격 단위: 기종 · 규격(㎾) · 주연료종류: 원문 글을 가름"
"비고": "규격 단위: 기종 · 규격(㎾) · 주연료종류: 원문 글을 가름",
"별칭": "디젤 엔진"
},
{
"키": "EQ000500",
@@ -31314,7 +31320,8 @@
"조종원": "건설품셈 공통 8-4-8",
"조종원직종": "건설품셈 공통 8-1-3 5"
},
"비고": "규격 단위: 기종 · 규격(㎾) · 주연료종류: 원문 글을 가름"
"비고": "규격 단위: 기종 · 규격(㎾) · 주연료종류: 원문 글을 가름",
"별칭": "디젤 엔진"
},
{
"키": "EQ000501",
@@ -31375,7 +31382,8 @@
"조종원": "건설품셈 공통 8-4-8",
"조종원직종": "건설품셈 공통 8-1-3 5"
},
"비고": "규격 단위: 기종 · 규격(㎾) · 주연료종류: 원문 글을 가름"
"비고": "규격 단위: 기종 · 규격(㎾) · 주연료종류: 원문 글을 가름",
"별칭": "디젤 엔진"
},
{
"키": "EQ000502",
@@ -31436,7 +31444,8 @@
"조종원": "건설품셈 공통 8-4-8",
"조종원직종": "건설품셈 공통 8-1-3 5"
},
"비고": "규격 단위: 기종 · 규격(㎾) · 주연료종류: 원문 글을 가름"
"비고": "규격 단위: 기종 · 규격(㎾) · 주연료종류: 원문 글을 가름",
"별칭": "디젤 엔진"
},
{
"키": "EQ000503",
@@ -31497,7 +31506,8 @@
"조종원": "건설품셈 공통 8-4-8",
"조종원직종": "건설품셈 공통 8-1-3 5"
},
"비고": "규격 단위: 기종 · 규격(㎾) · 주연료종류: 원문 글을 가름"
"비고": "규격 단위: 기종 · 규격(㎾) · 주연료종류: 원문 글을 가름",
"별칭": "디젤 엔진"
},
{
"키": "EQ000504",
@@ -31558,7 +31568,8 @@
"조종원": "건설품셈 공통 8-4-8",
"조종원직종": "건설품셈 공통 8-1-3 5"
},
"비고": "규격 단위: 기종 · 규격(㎾) · 주연료종류: 원문 글을 가름"
"비고": "규격 단위: 기종 · 규격(㎾) · 주연료종류: 원문 글을 가름",
"별칭": "디젤 엔진"
},
{
"키": "EQ000505",
@@ -31619,7 +31630,8 @@
"조종원": "건설품셈 공통 8-4-8",
"조종원직종": "건설품셈 공통 8-1-3 5"
},
"비고": "규격 단위: 기종 · 규격(㎾) · 주연료종류: 원문 글을 가름"
"비고": "규격 단위: 기종 · 규격(㎾) · 주연료종류: 원문 글을 가름",
"별칭": "디젤 엔진"
},
{
"키": "EQ000506",
@@ -31680,7 +31692,8 @@
"조종원": "건설품셈 공통 8-4-8",
"조종원직종": "건설품셈 공통 8-1-3 5"
},
"비고": "규격 단위: 기종 · 규격(㎾) · 주연료종류: 원문 글을 가름"
"비고": "규격 단위: 기종 · 규격(㎾) · 주연료종류: 원문 글을 가름",
"별칭": "디젤 엔진"
},
{
"키": "EQ000507",
@@ -31741,7 +31754,8 @@
"조종원": "건설품셈 공통 8-4-8",
"조종원직종": "건설품셈 공통 8-1-3 5"
},
"비고": "규격 단위: 기종 · 규격(㎾) · 주연료종류: 원문 글을 가름"
"비고": "규격 단위: 기종 · 규격(㎾) · 주연료종류: 원문 글을 가름",
"별칭": "디젤 엔진"
},
{
"키": "EQ000508",
@@ -1,124 +0,0 @@
{
"줄": {
"아스팔트페이버(피니셔)||시간": {
"원래": {
"품명": "아스팔트페이버(피니셔)",
"규격": "",
"단위": "hr"
},
"갈래": "짝",
"짝": [
{
"갈래": "기계",
"키": "EQ000193",
"입력값": {},
"배": 1,
"몫": null
}
],
"엑셀조항": [],
"누가": {
"id": "요구735-5·736",
"이름": "사용자 승인(시스템 관리자)"
},
"때": "2026-11-09T11:13:18Z",
"처음파일": null
},
"디젤엔진||시간": {
"원래": {
"품명": "디젤 엔진",
"규격": "",
"단위": "hr"
},
"갈래": "짝",
"짝": [
{
"갈래": "기계",
"키": "EQ000500",
"입력값": {},
"배": 1,
"몫": null
}
],
"엑셀조항": [],
"누가": {
"id": "요구735-5·736",
"이름": "사용자 승인(시스템 관리자)"
},
"때": "2026-11-09T11:13:18Z",
"처음파일": null
},
"워터제트||시간": {
"원래": {
"품명": "워터제트",
"규격": "",
"단위": "hr"
},
"갈래": "짝",
"짝": [
{
"갈래": "기계",
"키": "EQ000400",
"입력값": {},
"배": 1,
"몫": null
}
],
"엑셀조항": [],
"누가": {
"id": "요구735-5·736",
"이름": "사용자 승인(시스템 관리자)"
},
"때": "2026-11-09T11:13:18Z",
"처음파일": null
},
"버킷준설기||시간": {
"원래": {
"품명": "버킷준설기",
"규격": "",
"단위": "hr"
},
"갈래": "짝",
"짝": [
{
"갈래": "기계",
"키": "EQ000425",
"입력값": {},
"배": 1,
"몫": null
}
],
"엑셀조항": [],
"누가": {
"id": "요구735-5·736",
"이름": "사용자 승인(시스템 관리자)"
},
"때": "2026-11-09T11:13:18Z",
"처음파일": null
},
"바이브레이트|봉상후렉시블|시간": {
"원래": {
"품명": "바이브레이트",
"규격": "봉상후렉시블",
"단위": "hr"
},
"갈래": "짝",
"짝": [
{
"갈래": "기계",
"키": "EQ000248",
"입력값": {},
"배": 1,
"몫": null
}
],
"엑셀조항": [],
"누가": {
"id": "요구735-5·736",
"이름": "사용자 승인(시스템 관리자)"
},
"때": "2026-11-09T11:13:18Z",
"처음파일": null
}
}
}
+104
View File
@@ -0,0 +1,104 @@
"""PLAN 815-2 — M01 요소 「별칭」 한 열(요구 764 · 765).
① 별칭 칸 저장 = 그 줄만 바뀜 · 앞뒤 빈칸 뗌 · 빈 글이면 칸 없음(임시 사본 폴더만)
② M01 길은 시스템 관리자만(아니면 403)
③ 마스터 기계 별칭 다섯 → 기계 줄 `별칭` · 옛 장부 파일 걷음
"""
from __future__ import annotations
import json
import shutil
from pathlib import Path
import pytest
from fastapi import Depends, FastAPI
from fastapi.testclient import TestClient
from common_util import common_util_auth as auth
from M01_MasterData import M01_MasterData_Router as router_module
from M01_MasterData import M01_MasterData_Store as store
ROOT = Path(__file__).resolve().parents[2]
REAL = store.FOLDER
MACHINE = "기계.json"
KEY = "EQ000001"
FIVE = {
"EQ000192": "아스팔트페이버(피니셔)",
"EQ000193": "아스팔트페이버(피니셔)",
**{f"EQ000{n}": "디젤 엔진" for n in range(497, 508)},
"EQ000400": "워터제트",
"EQ000425": "버킷준설기",
"EQ000248": "바이브레이트 봉상후렉시블",
}
@pytest.fixture
def copy(tmp_path: Path, monkeypatch) -> Path:
for path in REAL.glob("*.json"):
if not path.name.startswith("_") or path.name == store.mk.BOOK.name:
shutil.copy(path, tmp_path / path.name)
monkeypatch.setattr(store, "FOLDER", tmp_path)
return tmp_path
def _app(role: str = "SYSTEM_ADMIN") -> TestClient:
app = FastAPI()
app.dependency_overrides[auth.verify_session] = lambda: {
"user_id": 1,
"name": "시험",
"role": role,
"company_id": 1,
}
deps = [Depends(auth.verify_session), Depends(auth.require_system_admin)]
app.include_router(router_module.router, dependencies=deps)
return TestClient(app)
def _save_alias(client: TestClient, text: str) -> None:
one = client.get("/api/m01/rows", params={"file": MACHINE, "q": KEY}).json()
row = next(r for r in one["rows"] if r["키"] == KEY)
part = {
"file": MACHINE,
"version": one["version"],
"changes": [{"op": "edit", "key": KEY, "row": {**row, "별칭": text}}],
}
res = client.post("/api/m01/save", json={"files": [part]})
assert res.status_code == 200, res.text
def _around(text: str) -> tuple[str, str, str]:
"""(그 줄 앞 글, 그 줄 글, 뒤 글)."""
start = text.rfind("{", 0, text.index(f'"키": "{KEY}"'))
_, end = json.JSONDecoder().raw_decode(text, start)
return text[:start], text[start:end], text[end:]
def test_별칭_저장_그_줄만_빈칸이면_칸_없음(copy: Path) -> None:
client = _app()
before = (copy / MACHINE).read_bytes()
_save_alias(client, " 시험별칭A, 시험별칭B ")
after = (copy / MACHINE).read_bytes().decode("utf-8")
row = next(r for r in json.loads(after)["줄"] if r["키"] == KEY)
assert row["별칭"] == "시험별칭A, 시험별칭B" # 글 그대로 · 앞뒤 빈칸만 뗌
old, new = _around(before.decode("utf-8")), _around(after)
assert old[0] == new[0] and old[2] == new[2] # 그 줄 밖 글 · 서식 그대로
assert json.loads(new[1]) == {**json.loads(old[1]), "별칭": "시험별칭A, 시험별칭B"}
_save_alias(client, " ")
last = _around((copy / MACHINE).read_bytes().decode("utf-8"))
assert last[0] == old[0] and last[2] == old[2]
assert json.loads(last[1]) == json.loads(old[1]) # 빈 글 = 칸 없음 = 처음 값
def test_M01_길은_시스템_관리자만() -> None:
for role in ("USER", "COMPANY_ADMIN"):
res = _app(role).post("/api/m01/save", json={"files": []})
assert res.status_code == 403
main = (ROOT / "main.py").read_text(encoding="utf-8")
assert "include_router(m01_master_data_router, dependencies=system_admin_only)" in main
def test_마스터_기계_별칭_다섯_옮김_장부_걷음() -> None:
rows = {r["키"]: r for r in json.loads((REAL / MACHINE).read_text(encoding="utf-8"))["줄"]}
assert {k: r.get("별칭") for k, r in rows.items() if r.get("별칭")} == FIVE
assert not (ROOT / "resources" / "master_template" / "실무자료" / "맞춤.json").exists()
@@ -0,0 +1,28 @@
"""EST 테스트 앱: 처리기가 있는 화면 동작은 모두 단추·메뉴로 부를 수 있어야 함."""
import re
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2] / "tools" / "est-test"
def test_every_handled_action_has_a_trigger():
sources = {
p.name: p.read_text(encoding="utf-8")
for p in [*ROOT.glob("native-*.js"), *ROOT.glob("native-*.cjs")]
}
sources["index.html"] = (ROOT / "index.html").read_text(encoding="utf-8")
handled = set()
for text in sources.values():
handled.update(re.findall(r'name === "([a-z0-9-]+)"', text))
assert handled
unbound = []
for name in sorted(handled):
uses = sum(
len(re.findall(rf'"{re.escape(name)}"', text))
- len(re.findall(rf'name === "{re.escape(name)}"', text))
for text in sources.values()
)
if uses <= 0:
unbound.append(name)
assert unbound == []
+77
View File
@@ -0,0 +1,77 @@
"""EST 테스트 앱 공내역 파일 저장(원본 mnuFileNullSave · PrintGongMethod 0/1/2)."""
import json
import subprocess
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[2]
SAMPLE = Path("C:/Dasansoft/Est Plus 2/Data/ProjectData/sample.estx")
LOAD = """
const fs = require('fs'), vm = require('vm');
const ctx = vm.createContext({ structuredClone, console });
vm.runInContext(fs.readFileSync('tools/est-test/native-blank.js', 'utf8'), ctx);
"""
def node(script: str, *args: str):
out = subprocess.check_output(["node", "-e", LOAD + script, *args], cwd=ROOT)
return json.loads(out.decode("utf-8"))
def test_methods_clear_amounts_and_chosen_values_only():
result = node("""
ctx.project = JSON.parse(fs.readFileSync('tools/est-test/sample.json', 'utf8'));
const out = vm.runInContext(`(() => {
const pick = (p) => p.tables.EstMainDat.find((r) => Number(r.GN) > 0 && Number(r.TU) > 0);
const src = pick(project), at = project.tables.EstMainDat.indexOf(src);
const r = (m) => { const b = EstBlank.make(project, m), x = b.tables.EstMainDat[at];
return [x.TC, x.NC, x.TU > 0, x.GN > 0, b.options.AutoCalc, b.name.endsWith('(공내역)'), 'id' in b]; };
const head = EstBlank.make(project, 0).tables.HeadMainDat.every((x) => !Number(x.TC) && !Number(x.TU));
const wonga = EstBlank.make(project, 2).tables.Wonga.every((x) => !Number(x.Cost));
let bad = ''; try { EstBlank.make(project, 3); } catch (e) { bad = e.message; }
return { m0: r(0), m1: r(1), m2: r(2), head, wonga, bad,
untouched: Number(src.TC) > 0 && Number(src.TU) > 0 };
})()`, ctx);
process.stdout.write(JSON.stringify(out));
""")
assert result == {
"m0": [0, 0, False, True, False, True, False],
"m1": [0, 0, True, False, False, True, False],
"m2": [0, 0, True, True, False, True, False],
"head": True,
"wonga": True,
"bad": "공내역 방법을 고르세요.",
"untouched": True,
}
def test_blank_estx_round_trip_keeps_quantities_and_clears_prices():
if not SAMPLE.exists():
pytest.skip("installed EST sample unavailable")
result = node(
"""
const imp = require('./tools/est-test/import-estx.cjs'), ex = require('./tools/est-test/export-estx.cjs');
const project = imp.importEstx(fs.readFileSync(process.argv[1]));
ctx.project = project;
const blank = vm.runInContext('EstBlank.make(project, 0)', ctx);
const back = imp.importEstx(ex.exportEstx(blank));
const rows = (p) => p.tables.EstMainDat.filter((r) => Number(r.GN) > 0);
process.stdout.write(JSON.stringify({
count: rows(back).length === rows(project).length,
quantities: rows(back).every((r, i) => Number(r.GN) === Number(rows(project)[i].GN)),
prices: back.tables.EstMainDat.every((r) => !Number(r.TU) && !Number(r.TC)),
heads: back.tables.HeadMainDat.every((r) => !Number(r.TC)),
sources: back.tables.LaborcDat.length === project.tables.LaborcDat.length,
}));
""",
str(SAMPLE),
)
assert result == {
"count": True,
"quantities": True,
"prices": True,
"heads": True,
"sources": True,
}
+78
View File
@@ -0,0 +1,78 @@
"""EST 테스트 앱 찾기 창: 다음찾기 순서·호표 위치·산출근거 문구."""
import json
import subprocess
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
FILES = [
"native-math.js",
"native-formula.js",
"native-machine.js",
"native-rows.js",
"native-text.js",
"native-quantity.cjs",
"native-wonga.js",
"native-engine.js",
"native-find.js",
]
def run_js(code: str):
runner = f"""
const fs = require('fs'), vm = require('vm');
const ctx = vm.createContext({{ structuredClone, console }});
for (const f of {json.dumps(FILES)})
vm.runInContext(fs.readFileSync('tools/est-test/' + f, 'utf8'), ctx, {{ filename: f }});
ctx.project = JSON.parse(fs.readFileSync('tools/est-test/sample.json', 'utf8'));
process.stdout.write(JSON.stringify(vm.runInContext({json.dumps(code)}, ctx)));
"""
out = subprocess.check_output(["node", "-e", runner], cwd=ROOT)
return json.loads(out.decode("utf-8"))
def test_next_walks_matches_in_order_and_stops_at_end():
result = run_js("""(() => {
const rows = [{Name:'터파기'},{Name:'되메우기'},{Name:'잔토 터파기',Size:'백호'},{Name:'포장'}];
const first = EstFind.next(rows, '터파기', 0);
const second = EstFind.next(rows, '터파기', first + 1);
const end = EstFind.next(rows, '터파기', second + 1);
const spec = EstFind.next(rows, '백호', 0);
const loose = EstFind.next(rows, ' 잔토 ', 0);
let empty = '';
try { EstFind.next(rows, ' ', 0); } catch (e) { empty = e.message; }
return {first, second, end, spec, loose, empty};
})()""")
assert result == {
"first": 0,
"second": 2,
"end": -1,
"spec": 2,
"loose": 2,
"empty": "검색어를 입력하세요.",
}
def test_search_reads_cost_line_text_and_skips_previous_values():
result = run_js("""(() => {
const rows = [
{LineData:'\\x0e콘크리트\\x0e \\x13타설\\x13', Name_:'옛이름'},
{Name:'다른 행'}
];
return {
line: EstFind.next(rows, '타설', 0),
old: EstFind.next(rows, '옛이름', 0)
};
})()""")
assert result == {"line": 0, "old": -1}
def test_group_position_for_head_rows():
result = run_js("""(() => {
const rows = project.tables.HeadMainDat;
const titles = rows.map((r, i) => [i, r]).filter(([, r]) => Number(r.Index) > 0);
const [start, title] = titles[1];
const hit = EstFind.groupOf(rows, start + 1);
return {code: hit.code === title.Code, position: hit.position};
})()""")
assert result == {"code": True, "position": 1}
+136
View File
@@ -0,0 +1,136 @@
"""EST 테스트 앱 단가 고르기: 검색·넣기·바꾸기와 계산 연결."""
import json
import subprocess
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
FILES = [
"native-math.js",
"native-formula.js",
"native-machine.js",
"native-rows.js",
"native-text.js",
"native-quantity.cjs",
"native-wonga.js",
"native-engine.js",
"native-row-match.js",
"native-escalation.js",
"native-progress.js",
"native-business.js",
"native-review.js",
"native-resources.js",
"native-schedule.js",
"native-model.js",
"native-verify.js",
"native-pick.js",
]
def run_js(code: str):
runner = f"""
const fs = require('fs'), vm = require('vm');
const ctx = vm.createContext({{ structuredClone, console }});
for (const f of {json.dumps(FILES)})
vm.runInContext(fs.readFileSync('tools/est-test/' + f, 'utf8'), ctx, {{ filename: f }});
ctx.project = JSON.parse(fs.readFileSync('tools/est-test/sample.json', 'utf8'));
process.stdout.write(JSON.stringify(vm.runInContext({json.dumps(code)}, ctx)));
"""
out = subprocess.check_output(["node", "-e", runner], cwd=ROOT)
return json.loads(out.decode("utf-8"))
def test_search_filters_by_kind_and_all_words():
result = run_js("""(() => {
const labor = project.tables.LaborcDat.find((r) => r.Code && r.Name);
const word = String(labor.Name).replace(/\\s+/g, "").slice(0, 2);
const one = EstPick.search(project, 6, word);
const all = EstPick.search(project, 0, labor.Code);
const none = EstPick.search(project, 6, word + " 없는말없는말");
return {
kinds: [...new Set(one.rows.map((r) => r.kind))],
hit: one.rows.some((r) => r.Code === labor.Code),
exact: all.rows.filter((r) => r.Code === labor.Code).length,
none: none.total,
};
})()""")
assert result == {"kinds": [6], "hit": True, "exact": 1, "none": 0}
def test_insert_links_rows_and_engine_prices_them():
result = run_js("""(() => {
const labor = project.tables.LaborcDat.find((r) => r.Code && Number(r.TC) > 0);
const pick = EstPick.items(project, 6).find((r) => r.Code === labor.Code);
const rows = project.tables.EstMainDat, before = rows.length;
const added = EstPick.insert(project, "EstMainDat", 0, "", [pick, pick]);
const done = EstNative.calculate(EstModel.validate(project));
const row = done.tables.EstMainDat[added[0]];
return {
added, grew: done.tables.EstMainDat.length - before,
link: row.LinkIndex, code: row.Code === labor.Code, name: row.Name === labor.Name,
priced: Number(row.NU) > 0 && Number(row.NU) === Number(pick.price),
};
})()""")
assert result == {
"added": [1, 2],
"grew": 2,
"link": 6,
"code": True,
"name": True,
"priced": True,
}
def test_replace_current_row_or_all_same_code():
result = run_js("""(() => {
const rows = project.tables.EstMainDat;
const idx = rows.findIndex((r) => r.Code && !(Number(r.Index) > 0) && [5, 6, 103, 104].includes(Number(r.LinkIndex)));
const target = rows[idx];
const twin = { ...target, PkID: 999999 };
rows.splice(idx + 1, 0, twin);
const pick = EstPick.items(project, 5).find((r) => r.Code !== target.Code);
const one = EstPick.replace(structuredClone(project), "EstMainDat", idx, "", pick, false);
const copy = structuredClone(project);
const all = EstPick.replace(copy, "EstMainDat", idx, "", pick, true);
return {
one: one.length, all: all.length >= 2,
moved: copy.tables.EstMainDat[idx + 1].Code === pick.Code,
kind: copy.tables.EstMainDat[idx].LinkIndex,
untouched: project.tables.EstMainDat[idx].Code === target.Code,
};
})()""")
assert result == {"one": 1, "all": True, "moved": True, "kind": 5, "untouched": True}
def test_guards_reject_wrong_view_self_reference_and_title_rows():
result = run_js("""(() => {
const out = [];
const tryIt = (fn) => { try { fn(); out.push("ok"); } catch (e) { out.push(e.message.slice(0, 12)); } };
const head = EstPick.items(project, 103)[0];
tryIt(() => EstPick.insert(project, "CostMainDat", 0, "", [head]));
tryIt(() => EstPick.insert(project, "HeadMainDat", 0, head.Code, [head]));
tryIt(() => EstPick.insert(project, "EstMainDat", 0, "", []));
const title = project.tables.HeadMainDat.findIndex((r) => Number(r.Index) > 0);
tryIt(() => EstPick.replace(project, "HeadMainDat", title, "", EstPick.items(project, 6)[0], false));
return out;
})()""")
expected = [
"내역서·일위대가 화면",
"일위대가 자신을 하위",
"넣을 자료를 하나 이상",
"일반 내역 행 하나를",
]
assert len(result) == len(expected)
assert all(message.startswith(head[:8]) for message, head in zip(result, expected))
def test_insert_into_head_group_prices_from_source():
result = run_js("""(() => {
const rows = project.tables.HeadMainDat;
const title = rows.findIndex((r) => Number(r.Index) > 0);
const stuff = EstPick.items(project, 5).find((r) => r.price > 0);
const added = EstPick.insert(project, "HeadMainDat", title, rows[title].Code, [stuff]);
const row = project.tables.HeadMainDat[added[0]];
return { at: added[0] === title + 1, link: row.LinkIndex, unit: Number(row.GU) === stuff.price };
})()""")
assert result == {"at": True, "link": 5, "unit": True}
@@ -0,0 +1,96 @@
"""EST 테스트 앱 수량 항목 끝수 처리(원본 AUTO·절사·올림·반올림 0~5)."""
from test_est_rea_parity import run
SHEET = """const sheet={Code:'N1',Name:'시험',options:{digits:2,roundMode:2},
items:[{Code:'r000000',Name:'가'},{Code:'r000001',Name:'나'}],
rows:[{LineData:' 가 1.2345×1 = \\x121.23\\x12',markers:['r000000']},
{LineData:' 나 2.0001×1 = \\x122\\x12',markers:['r000001']}]};"""
def test_format_renames_item_and_marker_and_changes_result():
result = run(
"math",
"(()=>{"
+ SHEET
+ """
const auto=EstQuantity.calculate(sheet).items[0].GN;
EstQuantity.setResultFormat(sheet,0,3,1);
const up=EstQuantity.calculate(sheet).items[0].GN;
const code=sheet.items[0].Code, marker=sheet.rows[0].markers[0];
EstQuantity.setResultFormat(sheet,0,1,3);
const half=EstQuantity.calculate(sheet).items[0].GN;
EstQuantity.setResultFormat(sheet,0,0);
return {auto,up,code,marker,half,back:sheet.items[0].Code,backMarker:sheet.rows[0].markers[0],
other:sheet.items[1].Code};
})()""",
)
assert result == {
"auto": 1.23,
"up": 1.3,
"code": "r00000031",
"marker": "r00000031",
"half": 1.235,
"back": "r000000",
"backMarker": "r000000",
"other": "r000001",
}
def test_format_rejects_bad_digits_collision_and_unknown_code():
result = run(
"math",
"(()=>{"
+ SHEET
+ """
const out=[];
const t=(fn)=>{try{fn();out.push('ok');}catch(e){out.push(e.message.slice(0,6));}};
t(()=>EstQuantity.setResultFormat(sheet,0,2,6));
sheet.items[1].Code='r00000021'; sheet.rows[1].markers=['r00000021'];
t(()=>EstQuantity.setResultFormat(sheet,0,2,1));
sheet.items.push({Code:'X1'});
t(()=>EstQuantity.setResultFormat(sheet,2,2,1));
return {out,unchanged:sheet.items[0].Code};
})()""",
)
assert result["unchanged"] == "r000000"
assert [m[:2] for m in result["out"]] == ["자리", "같은", "이 "]
def test_new_item_code_skips_base_used_by_formatted_code():
result = run(
"math",
"(()=>{"
+ SHEET
+ """
EstQuantity.setResultFormat(sheet,0,2,3);
return EstQuantity.nextItemCode(sheet);
})()""",
)
assert result == "r000002"
def test_estx_export_refuses_format_change_with_clear_message():
import json
import subprocess
from pathlib import Path
import pytest
source = Path("C:/Dasansoft/Est Plus 2/Data/ProjectData/main.estx")
if not source.exists():
pytest.skip("installed EST main sample unavailable")
root = Path(__file__).resolve().parents[2]
script = """
const fs=require('fs'), path=process.argv[1];
const imp=require('./tools/est-test/import-estx.cjs'), q=require('./tools/est-test/native-quantity.cjs');
const ex=require('./tools/est-test/export-estx.cjs'), p=imp.importEstx(fs.readFileSync(path));
const used=(s,c)=>s.rows.some(r=>(r.markers||[]).includes(c));
const s=p.quantitySheets.find(s=>s.items.some(i=>/^r\d{6}$/.test(i.Code)&&used(s,i.Code)));
q.setResultFormat(s,s.items.findIndex(i=>/^r\d{6}$/.test(i.Code)&&used(s,i.Code)),1,3);
let message='';
try { ex.exportEstx(p); } catch (e) { message=e.message; }
process.stdout.write(JSON.stringify(message));
"""
out = subprocess.check_output(["node", "-e", script, str(source)], cwd=root)
assert json.loads(out.decode("utf-8")).startswith("수량 항목 끝수 처리 변경은 ESTX")
@@ -0,0 +1,74 @@
"""EST 테스트 앱 수량산출서 복사·순서 이동과 ESTX 저장 거부."""
import json
import subprocess
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[2]
MAIN = Path("C:/Dasansoft/Est Plus 2/Data/ProjectData/main.estx")
LOAD = """
const fs = require('fs'), vm = require('vm');
const ctx = vm.createContext({ structuredClone, console });
vm.runInContext(fs.readFileSync('tools/est-test/native-quantity-sheets.js', 'utf8'), ctx);
const run = (code) => vm.runInContext(code, ctx);
"""
def node(script: str, *args: str):
out = subprocess.check_output(["node", "-e", LOAD + script, *args], cwd=ROOT)
return json.loads(out.decode("utf-8"))
def test_copy_inserts_below_with_new_code_and_cleared_links():
result = node("""
ctx.project = { quantitySheets: [
{ Code: 'A', Name: '토공', items: [{ Code: 'r000000', TargetCode: 'D1', TargetKey: 'id:3', LinkCode: 'D1' }], rows: [{ LineData: 'x', markers: ['r000000'] }] },
{ Code: 'B', Name: '포장', items: [], rows: [] },
] };
const first = run("EstQuantitySheets.copy(project, 'A', 'u1')");
const second = run("EstQuantitySheets.copy(project, 'A', 'u1')");
const p = ctx.project;
process.stdout.write(JSON.stringify({
first, second, order: p.quantitySheets.map((s) => s.Code), name: p.quantitySheets[1].Name,
link: p.quantitySheets[1].items[0], original: p.quantitySheets[0].items[0].TargetCode,
rows: p.quantitySheets[1].rows[0].markers,
}));
""")
assert result == {
"first": "Qu1",
"second": "Qu1_2",
"order": ["A", "Qu1_2", "Qu1", "B"],
"name": "토공 복사본",
"link": {"Code": "r000000", "TargetCode": "", "TargetKey": ""},
"original": "D1",
"rows": ["r000000"],
}
def test_move_swaps_and_stops_at_edges():
result = node("""
ctx.project = { quantitySheets: [{ Code: 'A' }, { Code: 'B' }, { Code: 'C' }] };
const out = [run("EstQuantitySheets.move(project, 'B', -1)"), run("EstQuantitySheets.move(project, 'B', -1)"),
run("EstQuantitySheets.move(project, 'C', 1)")];
process.stdout.write(JSON.stringify({ out, order: ctx.project.quantitySheets.map((s) => s.Code) }));
""")
assert result == {"out": [True, False, False], "order": ["B", "A", "C"]}
def test_estx_export_refuses_reordered_sheets():
if not MAIN.exists():
pytest.skip("installed EST main sample unavailable")
result = node(
"""
const imp = require('./tools/est-test/import-estx.cjs'), ex = require('./tools/est-test/export-estx.cjs');
ctx.project = imp.importEstx(fs.readFileSync(process.argv[1]));
run("EstQuantitySheets.move(project, project.quantitySheets[0].Code, 1)");
let message = '';
try { ex.exportEstx(ctx.project); } catch (e) { message = e.message; }
process.stdout.write(JSON.stringify(message));
""",
str(MAIN),
)
assert result.startswith("수량산출서 순서 변경은 ESTX")