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

This commit is contained in:
2026-09-27 20:18:44 +09:00
118 changed files with 3642 additions and 3065 deletions
@@ -0,0 +1,56 @@
// 스프레드시트 크기 재기 — vite 로 줄여 묶은 뒤 gzip 크기(바이트). `test_spreadsheet_size.py` 가 부름.
// node spreadsheet_size.mjs <저장소 뿌리> → {"engine": {js, css}, "total": {js, css}}
// 앱 번들에 이미 있는 `@ui/ui_template_elements*` 는 빼고 잼(스프레드시트가 새로 더하는 몫만).
import path from "node:path";
import { pathToFileURL } from "node:url";
import { gzipSync } from "node:zlib";
const root = path.resolve(process.argv[2] ?? ".");
const dir = path.join(root, "A00_Common", "spreadsheet");
const { build } = await import(
pathToFileURL(path.join(root, "config/node_modules/vite/dist/node/index.js")).href
);
const ENGINE = [
"spreadsheet_address",
"spreadsheet_parser",
"spreadsheet_eval",
"spreadsheet_graph",
"spreadsheet_refshift",
"spreadsheet_commands",
"spreadsheet_history",
"spreadsheet_recalc",
"spreadsheet_functions",
"spreadsheet_numfmt",
"spreadsheet_fill",
];
async function measure(entries) {
const out = await build({
configFile: false,
logLevel: "silent",
root,
resolve: { alias: { "@ui": path.join(root, "ui_template") } },
build: {
write: false,
minify: true,
target: "es2022",
cssCodeSplit: false,
lib: { entry: entries, formats: ["es"] },
rollupOptions: { external: [/ui_template_elements/] },
},
});
const outputs = (Array.isArray(out) ? out : [out]).flatMap((o) => o.output);
const size = { js: 0, css: 0 };
for (const o of outputs) {
const body = o.type === "chunk" ? o.code : o.source;
const gz = gzipSync(typeof body === "string" ? body : Buffer.from(body)).length;
if (o.fileName.endsWith(".css")) size.css += gz;
else if (o.fileName.endsWith(".js")) size.js += gz;
}
return size;
}
const engine = await measure(ENGINE.map((name) => path.join(dir, `${name}.ts`)));
const total = await measure([path.join(dir, "spreadsheet.ts")]);
console.log(JSON.stringify({ engine, total }));
@@ -0,0 +1,129 @@
"""F — 금빛 시험: 실무 구조도 xlsx 세 벌의 수식을 새 엔진으로 풀어 엑셀 캐시값과 대조.
xlsx 는 **시험에서만** openpyxl 로 읽음(앱에는 가져오기 없음). 식 = 엑셀 글 그대로 · 값 = 칸 값 ·
바깥 통합문서 참조(`[172]관집계!…`)는 식을 못 푸니 캐시값을 값으로 굳힘(대조에서 뺌).
갈린 칸은 조용히 맞추지 않음 — 목록을 `tmp/spreadsheet_golden_report.json` 에 적고 시험을 떨굼.
엔진(A · B)이 빈 몸이면 건너뜀 — 엔진이 차는 대로 다시 돌림.
"""
import datetime as dt
import json
import re
import shutil
from decimal import Decimal, InvalidOperation
from pathlib import Path
import pytest
openpyxl = pytest.importorskip("openpyxl")
from openpyxl.utils.datetime import to_excel # noqa: E402
from common_util import common_util_spreadsheet as spreadsheet # noqa: E402
ROOT = Path(__file__).resolve().parents[3]
PRACTICE = ROOT / "resources" / "knowledge" / "original" / "실무문서"
NAMES = ("07-구조도-소광리.xlsx", "5. 구조도(기번3).xlsx", "구조도(변경).xlsx")
FILES = [next(PRACTICE.rglob(name)) for name in NAMES]
REPORT = ROOT / "tmp" / "spreadsheet_golden_report.json"
EXPECTED_FORMULAS = 2699
_EXTERNAL = re.compile(r"\[\d+\]")
_ERRORS = {"#NULL!", "#DIV/0!", "#VALUE!", "#REF!", "#NAME?", "#NUM!", "#N/A"}
pytestmark = pytest.mark.skipif(shutil.which("node") is None, reason="node 가 없음")
def _plain(value):
"""칸 값 → JSON(날짜 · 시각 형식 칸은 엑셀 일련수)."""
if isinstance(value, (dt.datetime, dt.date, dt.time, dt.timedelta)):
return to_excel(value)
return value
def _book(path: Path):
"""xlsx → 통합문서 + 대조할 칸 `[(시트 id, A1, 캐시값)]`."""
formulas = openpyxl.load_workbook(path, data_only=False)
cached = openpyxl.load_workbook(path, data_only=True)
sheets, checks = [], []
for i, ws in enumerate(formulas.worksheets, start=1):
sid, cells = f"s{i}", {}
values = cached[ws.title]
for row in ws.iter_rows():
for c in row:
if c.value is None:
continue
keep = _plain(values[c.coordinate].value)
if c.data_type == "f" and isinstance(c.value, str):
if _EXTERNAL.search(c.value):
if keep is not None:
cells[c.coordinate] = {"값": keep}
continue
cells[c.coordinate] = {"식": c.value[1:]}
checks.append((sid, c.coordinate, keep))
elif c.data_type != "f":
cells[c.coordinate] = {"값": _plain(c.value)}
sheets.append({"id": sid, "이름": ws.title, "칸": cells})
book = {"종류": "통합문서", "판": 1, "열": path.stem, "서식": [{}], "시트": sheets}
return book, checks
def _same(want, got) -> bool:
if got is None:
return False
if isinstance(want, bool):
return got.get("참") is want
if isinstance(want, (int, float)):
try:
a, b = Decimal(got["수"]), Decimal(repr(float(want)))
except (KeyError, InvalidOperation):
return False
return abs(a - b) <= Decimal("1e-9") * max(Decimal(1), abs(b))
if want is None or want == "":
return got.get("글") == "" or got.get("수") == "0"
if isinstance(want, str) and want in _ERRORS:
return got.get("오류") == want
return got.get("글") == want
def test_실무_구조도_수식_엑셀과_같음():
loaded = [(path, *_book(path)) for path in FILES]
total = sum(len(checks) for _p, _b, checks in loaded)
external = 6 # 소광리 `[172]관집계` — 값으로 굳혀 대조에서 뺌
assert total + external == EXPECTED_FORMULAS
try:
results = spreadsheet.recalc_workbooks([book for _p, book, _c in loaded])
except spreadsheet.RecalcError as error:
if "아직 없음" in str(error):
pytest.skip(f"엔진 아직 — {error}")
raise
mismatches = []
for (path, book, checks), values in zip(loaded, results, strict=True):
names = {s["id"]: s["이름"] for s in book["시트"]}
for sid, a1, want in checks:
got = values.get(sid, {}).get(a1)
if not _same(want, got):
formula = next(s for s in book["시트"] if s["id"] == sid)["칸"][a1]["식"]
mismatches.append(
{
"파일": path.name,
"시트": names[sid],
"칸": a1,
"식": formula,
"엑셀": want,
"엔진": got,
}
)
REPORT.parent.mkdir(exist_ok=True)
REPORT.write_text(
json.dumps(
{"대조": total, "갈림": len(mismatches), "목록": mismatches},
ensure_ascii=False,
indent=1,
default=str,
),
encoding="utf-8",
)
head = "\n".join(
f"{m['파일']} {m['시트']}!{m['칸']} ={m['식']} 엑셀 {m['엑셀']!r} · 엔진 {m['엔진']}"
for m in mismatches[:30]
)
assert not mismatches, f"{len(mismatches)}/{total} 칸 갈림 — 전체 {REPORT}\n{head}"
@@ -0,0 +1,37 @@
"""F — 서버 재계산 다리: 파이썬 → Node 번들(`build:spreadsheet`) → 화면과 같은 TS 엔진.
엔진(A · B)이 아직 빈 몸이면 `아직 없음` 까닭이 그대로 올라와 저장을 막는지 봄 ·
엔진이 차면 저장 견본(`spreadsheet_sample.json`)의 `계산값` 과 서버 풀이가 같은지 봄.
"""
import json
import shutil
from pathlib import Path
import pytest
from common_util import common_util_spreadsheet as spreadsheet
ROOT = Path(__file__).resolve().parents[3]
SAMPLE = ROOT / "A00_Common" / "spreadsheet" / "spreadsheet_sample.json"
pytestmark = pytest.mark.skipif(shutil.which("node") is None, reason="node 가 없음")
def test_견본_계산값_같음():
sample = json.loads(SAMPLE.read_text(encoding="utf-8"))
try:
saved = spreadsheet.with_server_values({**sample, "계산값": {"s1": {"H4": {"수": "틀림"}}}})
except spreadsheet.RecalcError as error:
assert "아직 없음" in str(error), error # 엔진이 빈 몸일 때만 봐줌
pytest.skip(f"엔진 아직 — {error}")
assert saved["계산값"] == sample["계산값"]
assert {k: v for k, v in saved.items() if k != "계산값"} == {
k: v for k, v in sample.items() if k != "계산값"
}
def test_못_푸는_문서는_까닭과_함께_막음():
with pytest.raises(spreadsheet.RecalcError) as caught:
spreadsheet.recalc_workbooks([{"종류": "통합문서", "시트": "깨짐"}])
assert "스프레드시트 계산 실패" in str(caught.value)
@@ -0,0 +1,74 @@
"""F — 스프레드시트 크기 예산 · M02 만 늦게 불러옴(계약 `spreadsheet_types.ts` 머리).
예산(압축 gzip 뒤) — 엔진 A+B 22 KB · 화면 C+D+E 28 KB · css 4 KB · 합 54 KB.
엔진은 파일마다 따로 묶어 더하므로 실제보다 조금 크게 잼(넘치면 넘친 것).
"""
import json
import re
import shutil
import subprocess
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[3]
SCRIPT = Path(__file__).with_name("spreadsheet_size.mjs")
KB = 1024
BUDGET = {"engine": 22 * KB, "view": 28 * KB, "css": 4 * KB, "total": 54 * KB}
pytestmark = pytest.mark.skipif(shutil.which("node") is None, reason="node 가 없음")
def test_크기_예산():
out = subprocess.run(
["node", str(SCRIPT), str(ROOT)],
cwd=ROOT,
capture_output=True,
text=True,
encoding="utf-8",
timeout=300,
check=True,
)
size = json.loads(out.stdout.strip().splitlines()[-1])
engine, total = size["engine"]["js"], size["total"]
view = max(0, total["js"] - engine)
print(
f"gzip — 엔진 {engine} · 화면 {view} · css {total['css']} · 합 {total['js'] + total['css']}"
)
assert engine <= BUDGET["engine"], f"엔진 {engine} B > {BUDGET['engine']}"
assert view <= BUDGET["view"], f"화면 {view} B > {BUDGET['view']}"
assert total["css"] <= BUDGET["css"], f"css {total['css']} B > {BUDGET['css']}"
assert total["js"] + total["css"] <= BUDGET["total"]
# 정적 import 금지 — 타입만(`import type`) · 늦게(`import(`) 는 됨
_STATIC = re.compile(
r"^\s*import\s+(?!type\b)[^;]*?from\s+[\"'][^\"']*spreadsheet/spreadsheet", re.M
)
_SIDE = re.compile(r"^\s*import\s+[\"'][^\"']*spreadsheet/spreadsheet", re.M)
def test_M02_만_늦게_불러옴():
offenders, users = [], []
listed = subprocess.run(
["git", "ls-files", "--cached", "--others", "--exclude-standard", "*.ts"],
cwd=ROOT,
capture_output=True,
text=True,
encoding="utf-8",
check=True,
).stdout.splitlines()
for rel in listed:
if rel.startswith("A00_Common/spreadsheet/") or not (ROOT / rel).is_file():
continue
text = (ROOT / rel).read_text(encoding="utf-8", errors="replace")
if "spreadsheet/spreadsheet" not in text:
continue
if _STATIC.search(text) or _SIDE.search(text):
offenders.append(rel)
if "import(" in text:
users.append(rel)
# 서버 Node 진입만 정적(브라우저 번들 밖)
assert offenders == ["common_util/common_util_spreadsheet_node.ts"], offenders
assert users and all(u.startswith("M02_MasterTemplete/") for u in users), users
@@ -0,0 +1,138 @@
"""F — 산출근거 저장(basis 종류) · 옛 산출근거 옮김 · [저장] 때 서버 계산값 · 임시 시험 주소.
서버 Node 풀이는 `test_spreadsheet_node.py` 가 따로 봄 — 여기는 `with_server_values` 를 바꿔 끼워
「브라우저 값은 버리고 서버 값만 적는다」 · 「못 풀면 저장을 막는다」 만 잼.
"""
import json
import shutil
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from common_util import common_util_spreadsheet as spreadsheet
from M02_MasterTemplete import M02_MasterTemplete_Router as router_module
from M02_MasterTemplete import M02_MasterTemplete_Store as store
from M02_MasterTemplete import M02_Template_Layers as layers
REAL = store.FOLDER
SERVER = {"s1": {"A1": {"수": "42"}}}
@pytest.fixture()
def client(tmp_path, monkeypatch):
for kind in store.KINDS:
(tmp_path / kind).mkdir()
shutil.copy(REAL / "table/구조물집계표.json", tmp_path / "table")
monkeypatch.setattr(store, "FOLDER", tmp_path)
monkeypatch.setattr(router_module, "TRIAL_PATH", tmp_path / "trial.json")
def fake(book):
clean = {k: v for k, v in book.items() if k != "계산값"}
return {**clean, "계산값": SERVER}
monkeypatch.setattr(spreadsheet, "with_server_values", fake)
app = FastAPI()
app.include_router(router_module.router)
return TestClient(app)
def _old_structure(folder, column, basis):
doc = {"양식": "구조물도면", "종류": "structure", "판": 1, "열": column, "도번": "구-01"}
doc.update({"도면": {"format": 6, "entities": []}, "산출근거": basis})
(folder / "structure" / f"{column}.json").write_text(
json.dumps(doc, ensure_ascii=False), encoding="utf-8"
)
def test_옛_산출근거를_따로_파일로_옮김(client, tmp_path):
old = {
"열": [
{"id": "work", "머리": ["공종"]},
{"id": "detail", "머리": ["산출", "내역"]},
{"id": "qty", "머리": ["수량(m당)"]},
],
"줄": [{"id": "1", "값": {"work": "터파기", "qty": 1.5}}, {"id": "2", "값": {}}],
"보기": {"열너비": {"detail": 210}},
}
_old_structure(tmp_path, "pp_len", old)
done = store.migrate_basis()
assert done["옮김"] == ["pp_len"]
columns = store._column_ids()
assert set(done["새로"]) == set(columns) - {"pp_len"}
structure = json.loads((tmp_path / "structure/pp_len.json").read_text(encoding="utf-8"))
assert "산출근거" not in structure and structure["도번"] == "구-01"
basis = store.read("basis", "pp_len")["문서"]
sheet = basis["시트"][0]
assert (basis["종류"], basis["열"], sheet["id"]) == ("통합문서", "pp_len", "s1")
assert sheet["칸"] == {
"A1": {"값": "공종", "서식": 1},
"B1": {"값": "산출 내역", "서식": 1},
"C1": {"값": "수량(m당)", "서식": 1},
"A2": {"값": "터파기"},
"C2": {"값": 1.5},
}
assert sheet["열"] == {"B": {"폭": 30.0}}
assert "계산값" not in basis # 식 없는 새 문서 — 첫 [저장] 때 서버가 적음
# 두 번 돌려도 같음
before = {p.name: p.read_bytes() for p in (tmp_path / "basis").iterdir()}
assert store.migrate_basis() == {"옮김": [], "새로": []}
assert before == {p.name: p.read_bytes() for p in (tmp_path / "basis").iterdir()}
def test_새_구조물_도면은_산출근거_따로(client):
made = client.post("/api/m02/structures/rv").json()["문서"]
assert "산출근거" not in made
basis = client.get("/api/m02/templates/basis/rv").json()["문서"]
heads = [basis["시트"][0]["칸"][f"{c}1"]["값"] for c in "ABCDE"]
assert heads == ["공종", "규격", "산출 내역", "단위", "수량(m당)"]
def test_저장은_서버_계산값만_적음(client):
store.ensure_basis("rv")
got = client.get("/api/m02/templates/basis/rv").json()
doc = {**got["문서"], "계산값": {"s1": {"A1": {"수": "브라우저"}}}}
saved = client.put("/api/m02/templates/basis/rv", json={"판": got["판"], "문서": doc})
assert saved.status_code == 200
assert client.get("/api/m02/templates/basis/rv").json()["문서"]["계산값"] == SERVER
# 뼈대 없는 문서 · 판이 다르면 막음
bad = client.put("/api/m02/templates/basis/rv", json={"판": saved.json()["판"], "문서": {}})
assert bad.status_code == 400
stale = client.put("/api/m02/templates/basis/rv", json={"판": got["판"], "문서": doc})
assert stale.status_code == 409
def test_못_풀면_저장을_막음(client, monkeypatch):
store.ensure_basis("rv")
got = client.get("/api/m02/templates/basis/rv").json()
def broken(_book):
raise spreadsheet.RecalcError("스프레드시트 계산 실패 — 시험")
monkeypatch.setattr(spreadsheet, "with_server_values", broken)
r = client.put("/api/m02/templates/basis/rv", json={"판": got["판"], "문서": got["문서"]})
assert r.status_code == 503
assert client.get("/api/m02/templates/basis/rv").json()["판"] == got["판"]
def test_층_규칙과_프로젝트_복사(client, tmp_path, monkeypatch):
store.ensure_basis("rv")
doc = store.read("basis", "rv")["문서"]
layers.check_skeleton("basis", doc)
for bad in ({}, {**doc, "시트": {}}):
with pytest.raises(ValueError):
layers.check_skeleton("basis", bad)
layers.check_skeleton("structure", {"도면": {"entities": []}}) # 산출근거 없어도 됨
monkeypatch.setattr(layers, "SYSTEM_ROOT", tmp_path)
copied = layers.seed_project(tmp_path / "project")
assert "basis/rv" in copied["작업본"] and "basis/rv" in copied["초기"]
def test_임시_시험_주소(client):
first = client.get("/api/m02/spreadsheet-trial").json()["문서"]
assert [s["이름"] for s in first["시트"]] == ["시트1", "시트2", "시트3"]
first["시트"][0]["칸"] = {"A1": {"식": "1+41"}}
saved = client.put("/api/m02/spreadsheet-trial", json={"문서": first}).json()["문서"]
assert saved["계산값"] == SERVER
assert client.get("/api/m02/spreadsheet-trial").json()["문서"] == saved