Merge remote-tracking branches 'origin/sub_laptop_1', 'origin/sub_laptop_3' and 'origin/sub_laptop_7' into main_laptop_1

This commit is contained in:
2026-10-10 20:06:04 +09:00
16 changed files with 535 additions and 43 deletions
@@ -0,0 +1,65 @@
/* 맞춤 기본 = 가운데 · 가운데(237-1) 시험 진입 — vite --ssr 로 번들해 Node 로 돌림.
* 결과 = JSON {pass, fail, failures} 한 줄(`test_spreadsheet_align_default.py` 가 읽음) */
import {
applyCellStyle,
shownHAlign,
shownVAlign,
type ResolvedBorders,
} from "../../../A00_Common/spreadsheet/spreadsheet_cellstyle";
import { blockToClipboard } from "../../../A00_Common/spreadsheet/spreadsheet_clipboard";
import type { CellStyle, Workbook } from "../../../A00_Common/spreadsheet/spreadsheet_types";
let pass = 0;
const failures: string[] = [];
const eq = (name: string, got: unknown, want: unknown): void => {
if (JSON.stringify(got) === JSON.stringify(want)) pass += 1;
else failures.push(`${name}: ${JSON.stringify(got)} ≠ ${JSON.stringify(want)}`);
};
// 도구줄 · 대화상자가 보이는 값 — 안 준 칸(없음 · 일반) = 가운데 · 준 칸 그대로
eq("가로 없음", shownHAlign(undefined), "center");
eq("가로 일반", shownHAlign("general"), "center");
eq("가로 왼쪽", shownHAlign("left"), "left");
eq("가로 오른쪽", shownHAlign("right"), "right");
eq("가로 균등", shownHAlign("distributed"), "distributed");
eq("세로 없음", shownVAlign(undefined), "center");
eq("세로 위", shownVAlign("top"), "top");
eq("세로 아래", shownVAlign("bottom"), "bottom");
// 칸 CSS — 가짜 칸(DOM 없이 style 만 봄)
function paint(style: CellStyle, hAlign: Parameters<typeof applyCellStyle>[3]) {
const text = { classList: { contains: () => true }, style: {} as Record<string, string> };
const cell = {
style: {} as Record<string, string>,
firstElementChild: text,
replaceChildren: () => undefined,
};
applyCellStyle(cell as unknown as HTMLElement, style, {} as ResolvedBorders, hAlign);
return { v: cell.style.justifyContent, h: text.style.textAlign };
}
eq("칸 안 준 = 가운데 · 가운데", paint({}, "general"), { v: "center", h: "center" });
eq("칸 왼쪽 · 아래 그대로", paint({ 세로: "bottom" }, "left"), { v: "flex-end", h: "left" });
eq("칸 오른쪽 · 위 그대로", paint({ 세로: "top" }, "right"), { v: "flex-start", h: "right" });
// 내보내기(복사 HTML) — 안 준 칸 = 가운데 · 가운데 · 준 칸 그대로
const book = {
종류: "통합문서",
판: 1,
열: "rv",
서식: [{}, { 가로: "left", 세로: "top" }],
시트: [{ id: "s1", 이름: "시트1", 칸: { A1: { 값: "가" }, B1: { 값: "나", 서식: 1 } } }],
활성: "s1",
} as unknown as Workbook;
const { html } = blockToClipboard(book, "s1", { r0: 0, c0: 0, r1: 0, c1: 1 }, (_r, c) =>
c === 0 ? "가" : "나",
);
const tds = [...html.matchAll(/<td[^>]*style="([^"]*)"/g)].map((m) => m[1]);
eq("내보내기 칸 수", tds.length, 2);
eq(
"내보내기 안 준 칸",
/text-align:center/.test(tds[0]) && /vertical-align:middle/.test(tds[0]),
true,
);
eq("내보내기 준 칸", /text-align:left/.test(tds[1]) && /vertical-align:top/.test(tds[1]), true);
console.log(JSON.stringify({ pass, fail: failures.length, failures }));
@@ -196,7 +196,9 @@ def test_왼쪽_맞춤_레벨_들여쓰기_글꼴_공용_표():
def test_셸_배선():
src = (B09 / "B09_Estimation_UI_Shell.ts").read_text("utf-8")
assert "makeTable(bill ? (i) => pickedClass(bill.rows[i]) : undefined)" in src
assert (
"? (i) => [pickedClass(bill.rows[i]), pickClass(jump, i)]" in src
) # 239-1 이동 줄 빨강 곁
assert "if (level) bars.setTools(level.tools);" in src # 229-1 단추 = 보조 탭 줄
assert "panel.replaceChildren(table.root, ...(level ? [level.guide] : [])" in src
lvl = (B09 / "B09_Estimation_UI_Tabs_Bill_Level.ts").read_text("utf-8")
@@ -0,0 +1,163 @@
"""238-1 B05 [저장]이 구조물 측점을 지우지 않음.
[저장](경로 확정 길 · `_append_irregular_cross_sections`)은 종단 비정규 측점을 통째로 갈아 끼운다.
그 목록을 화면이 보낸 「관 + A군」으로 쓰면 서버가 심어 둔 소단 · 옹벽 · 흙막이 · 독립 기슭막이
시작 · 종료 측점이 빠진다 → 서버 측점 규칙(`resolve_extra_stations`)으로 만든 목록을 씀.
- 사본: 임시 폴더 프로젝트(직선 노선 · 관 하나 + 기슭막이 · 소단 · 옹벽 · 흙막이)
- 앞: B06 [측점 만들기]와 같은 길(서버 목록으로 심음) · 뒤: 화면 목록(관만)을 실은 [저장]
- 판정: 앞뒤 구조물 측점 같음 · 소단 측점(깊이 patch 가 붙는 자리) 남음 · 관 옮김은 반영
"""
import asyncio
import json
from pathlib import Path
import numpy as np
import B05_Profile.B05_Profile_Engine_Sections as sections
import B05_Profile.B05_Profile_Router_Confirm as confirm
from B05_Profile.B05_Profile_Schema import RouteConfirmRequest
from common_util.common_util_drainage_pipes import route_signature
from common_util.common_util_route_geometry import RouteVertex
LINE = [[0.0, 0.0, 100.0], [350.0, 0.0, 100.0]]
ROUTE = "B05_Profile/route/route.geojson"
LONG = "B06_Section/longitudinal/longitudinal.json"
PIPE = {"chainage_m": 150.0, "source": "user", "options": {"pipe_diameter_mm": 800}}
REVET = {
"chainage_m": 301.0,
"source": "user",
"facility": "revetment",
"start_m": 296.0,
"end_m": 316.0,
}
STRUCTURES = [
{"type_id": "soil_guard", "chainage_m": 45.0, "start_m": 41.0, "end_m": 51.0},
{"type_id": "berm", "chainage_m": 201.0, "start_m": 196.0, "end_m": 206.0},
{"type_id": "retaining_wall", "chainage_m": 251.0, "start_m": 246.0, "end_m": 256.0},
]
class _Flat:
def sample_xy(self, xy):
count = len(np.asarray(xy, dtype=np.float64))
return np.full(count, 100.0), np.ones(count, dtype=bool)
def _write(root: Path, rel: str, data) -> None:
path = root / rel
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(data, ensure_ascii=False), encoding="utf-8")
def _write_pipes(root: Path, points) -> None:
vertices = [RouteVertex(x=p[0], y=p[1], z=p[2], chainage_m=0.0) for p in LINE]
_write(
root,
"B04_PreProcess/drainage/edits/pipe_points.json",
{"route_signature": route_signature(vertices), "points": points},
)
def _project(root: Path) -> None:
_write(root, ROUTE, {"geometry": {"type": "LineString", "coordinates": LINE}})
_write_pipes(root, [PIPE, REVET])
for item in STRUCTURES:
item["placement"] = "interval"
_write(root, "B05_Profile/route/structures.json", {"revision": 1, "structures": STRUCTURES})
regular = [{"chainage_m": float(c), "kind": "regular"} for c in range(0, 351, 20)]
_write(root, LONG, {"stations": regular})
def _irregular(root: Path) -> list[tuple[str, str]]:
data = json.loads((root / LONG).read_text(encoding="utf-8"))
return [
(f"{float(s['chainage_m']):.2f}", s.get("structure"))
for s in data["stations"]
if s.get("kind") == "irregular"
]
def _patch(monkeypatch, root: Path) -> None:
async def _value(value, *_a, **_k):
return value
monkeypatch.setattr(sections, "build_surface_sampler", lambda *_a, **_k: _Flat())
monkeypatch.setattr(confirm, "get_project_storage_relative_path", lambda *_a: _value("p"))
monkeypatch.setattr(confirm, "resolve_stored_project_path", lambda _p: str(root))
monkeypatch.setattr(confirm, "get_latest_section_options", lambda *_a: _value(None))
monkeypatch.setattr(confirm, "get_surface_crs_epsg", lambda *_a: _value(None))
monkeypatch.setattr(
confirm,
"get_longitudinal_section",
lambda *_a: _value({"longitudinal_file_path": LONG}),
)
def _plant_like_b06(root: Path) -> None:
"""B06 [측점 만들기]와 같은 길 — 서버 목록으로 뜨고 종단에 병합."""
pipes = sections._load_pipe_points(root, LINE)
stations = sections.generate_irregular_sections(
root,
ROUTE,
"csf",
"tin",
False,
extra_stations=sections.resolve_extra_stations(root, pipes),
)
confirm._merge_irregular_into_longitudinal(root, LONG, stations)
def _save(root: Path, screen: list[tuple[float, str]]) -> None:
"""B05 [저장] — 화면은 관 · A군 목록만 실어 보냄."""
request = RouteConfirmRequest(
filter_key="csf",
method="tin",
surface_model_id=1,
irregular_stations=[{"chainage_m": c, "structure": s} for c, s in screen],
)
assert request.can_regenerate()
asyncio.run(
confirm._append_irregular_cross_sections(
None, "p", {"id": 1, "route_data_path": ROUTE}, request
)
)
def test_save_keeps_server_planted_stations(tmp_path, monkeypatch):
_project(tmp_path)
_patch(monkeypatch, tmp_path)
_plant_like_b06(tmp_path)
before = _irregular(tmp_path)
labels = {label for _key, label in before}
assert {"소단", "옹벽", "흙막이", "기슭막이"} <= labels
assert [k for k, label in before if label == "기슭막이"] == ["296.00", "301.00", "316.00"]
_save(tmp_path, [(150.0, "파형강관 D800")])
assert _irregular(tmp_path) == before
# 소단 깊이(194 · 측점 patch)가 붙는 자리 = 소단 측점 — 그대로 섬
assert [k for k, label in _irregular(tmp_path) if label == "소단"] == [
"196.00",
"201.00",
"206.00",
]
def test_save_follows_moved_pipe(tmp_path, monkeypatch):
_project(tmp_path)
_patch(monkeypatch, tmp_path)
_plant_like_b06(tmp_path)
before = _irregular(tmp_path)
# 관 150 → 165 (관 초안이 [저장] 차례상 먼저 정본에 들어감 · design_save.ts)
_write_pipes(tmp_path, [{**PIPE, "chainage_m": 165.0}, REVET])
_save(tmp_path, [(165.0, "파형강관 D800")])
after = _irregular(tmp_path)
expected = sorted(
[item for item in before if item[0] != "150.00"] + [("165.00", "파형강관 D800")],
key=lambda item: float(item[0]),
)
assert after == expected
@@ -0,0 +1,117 @@
"""239-1 B09 총괄설계내역서 맨 위 = 설계내역서 레벨 1 · 2 집계 · 누름 = 설계내역서 그 줄 빨강.
- 맨 위 내역 줄 = 설계내역서 레벨 1 · 2 폴더 줄(관급 빼고 · 레벨 1 항목도) · 설계내역서 차례 · 레벨 그대로
- 금액(합계 · 노무 · 재료 · 경비) = 설계내역서 그 줄 금액(Calc_Bill) · 끝 「순공사비 계」 = 총괄 가. 순공사비계
- 줄 누름 = link {tab: bill, key} → Shell 이 설계내역서 그 줄에 is-pick(빨강 외곽선 · 고른 줄 상자와 따로)
- 아래 원가계산 줄 = 그대로(요율 입력 줄부터 같은 차례)
"""
import json
import re
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
from test_182_b09_bill_level import BOOKS, HAND, INPUTS, USER # noqa: E402
from test_184_3_b09_bill_menu import _node, _ts # noqa: E402
ROOT = Path(__file__).resolve().parents[2]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
from B09_Estimation.B09_Estimation_Calc_Bill import compute_estimation # noqa: E402
from B09_Estimation.B09_Estimation_Calc_Total import compute_total # noqa: E402
SRC = ["Calc_Base", "Calc_Total", "UI_Tabs", "UI_Tabs_Total"]
#: Shell 이 부르는 줄 class(목록표 → 탭 길 · 181-2) — 묶음 탭 파일에서 그 함수만
GROUPS = (ROOT / "B09_Estimation" / "B09_Estimation_UI_Tabs_Groups.ts").read_text("utf-8")
PICK = re.search(r"export function pickClass\(.*?\n}\n", GROUPS, re.DOTALL).group(0)
PARTS = ["sum", "labor", "material", "expense"]
#: 빈 줄 없는 판(연결 없는 earth:5 · hand:c 뺌) — 총괄 순공사비계가 값을 받음
FULL_INPUTS = {**INPUTS, "bill": {"items": [i for i in INPUTS["bill"]["items"] if i["ref"]]}}
FULL_USER = {"bill": {"rows": [h for h in HAND if h["key"] != "hand:c"]}}
JS = """
const sheets = __SHEETS__;
const ctx = { sheets, payload: {}, user: { estimate: {} }, setUser: () => {} };
const spec = buildTotal(ctx);
const plain = buildTotal({ ...ctx, sheets: { total: sheets.total } });
console.log(JSON.stringify({
rows: spec.rows.map((r, i) => ({ ...r, link: spec.link(i, "name"), cls: spec.rowClass(i),
edit: ["amount", "rate", "hand"].some((k) => spec.editable(i, k)) })),
plain: plain.rows.map((r) => r.key),
pick: [pickClass([3, 3], 2), pickClass([3, 3], 3), pickClass(null, 3)],
}));
"""
def _run(inputs, user):
bill = compute_estimation(inputs, user, None, BOOKS)["bill"]
total = compute_total(bill["sums"], {})
sheets = {"bill": bill, "total": total}
out = json.loads(_node(_ts(SRC) + PICK + JS.replace("__SHEETS__", json.dumps(sheets))))
return bill, total, out
def test_맨_위_줄_레벨_1_2_폴더_차례_금액_같음():
bill, _, out = _run(INPUTS, USER)
want = [
r
for r in bill["rows"]
if r["cost"] == "direct" and (r["level"] == 1 or (r["level"] == 2 and r["kind"] != "item"))
]
top = [r for r in out["rows"] if r["key"].startswith("bill:")]
assert [r["key"] for r in top] == [f"bill:{r['key']}" for r in want]
assert {r["level"] for r in top} == {1, 2}
assert "bill:sec:G" not in {r["key"] for r in top} # 관급 = 순공사비 밖
assert "bill:haul:1" not in {r["key"] for r in top} # 레벨 2 항목(파일) 빠짐
for got, src in zip(top, want):
assert got["name"] == f"{src['no']} {src['name']}"
assert [got["amount"], got["labor"], got["material"], got["expense"]] == [
src[p] for p in PARTS
]
assert got["cls"] == ("is-title" if src["level"] == 1 else None)
assert got["edit"] is False
# 맨 위부터 내역 줄 → 순공사비 계 → 요율 입력 줄
keys = [r["key"] for r in out["rows"]]
assert keys[: len(top) + 2] == [r["key"] for r in top] + ["bill_sum", "in_price"]
def test_합_순공사비_레벨1_합_총괄_같음():
bill, total, out = _run(FULL_INPUTS, FULL_USER)
assert bill["totals"]["direct"]["blank"] == 0
rows = {r["key"]: r for r in out["rows"]}
s = rows["bill_sum"]
lv1 = [r for r in out["rows"] if r["key"].startswith("bill:") and r["level"] == 1]
for col, p in zip(["amount", "labor", "material", "expense"], PARTS):
assert s[col] == bill["totals"]["direct"][p]
assert s[col] == sum(r[col] for r in lv1)
assert s["amount"] == rows["direct"]["amount"] == total["sums"]["direct"] > 0
assert s["level"] == 1 and s["link"] is None
def test_빈_줄_있으면_계_안내():
bill, _, out = _run(INPUTS, USER)
s = next(r for r in out["rows"] if r["key"] == "bill_sum")
assert bill["totals"]["direct"]["blank"] == 2
assert s["amount"] == bill["totals"]["direct"]["sum"] and "빈 줄 2" in s["note"]
def test_누름_설계내역서_그_줄_빨강():
bill, _, out = _run(INPUTS, USER)
keys = {r["key"] for r in bill["rows"]}
for r in out["rows"]:
if r["key"].startswith("bill:"):
assert r["link"] == {"tab": "bill", "key": r["key"][5:]} and r["link"]["key"] in keys
else:
assert r["link"] is None # 원가계산 줄 = 이동 없음(그대로)
assert out["pick"] == [None, "is-pick is-pick-top is-pick-end", None]
shell = (ROOT / "B09_Estimation" / "B09_Estimation_UI_Shell.ts").read_text("utf-8")
assert "const jump: [number, number] | null = at >= 0 ? [at, at] : null;" in shell
assert "[pickedClass(bill.rows[i]), pickClass(jump, i)]" in shell
def test_설계내역서_없으면_원가계산_줄_그대로():
_, _, out = _run(INPUTS, USER)
assert out["plain"][0] == "in_price"
assert out["plain"] == [r["key"] for r in out["rows"] if not r["key"].startswith("bill")]
+21 -3
View File
@@ -33,6 +33,7 @@ SHEET = {
"A2": {"값": "합"},
"B2": {"식": "B1*2", "서식": 1},
"A3": {"값": "숨김행"},
"F1": {"값": "왼쪽", "서식": 3},
"C1": {"값": "x"},
},
"병합": ["A1:A2", "C1:E5"],
@@ -43,7 +44,7 @@ BOOK = {
"종류": "통합문서",
"판": 1,
"열": "rv",
"서식": [{}, {"형식": "#,##0.00", "굵게": True}, {"가로": "center"}],
"서식": [{}, {"형식": "#,##0.00", "굵게": True}, {"가로": "center"}, {"가로": "left"}],
"시트": [SHEET],
"활성": "s1",
}
@@ -56,7 +57,12 @@ def _one(**ask) -> dict:
def test_표시_글_형식_굵게_정렬_칸_key():
got = _one(시트="면적산출", 범위="A1:B2")
cells = got["표"]["shapeData"]["cells"]
assert cells[0][1] == {"text": "1,234.50", "align": "right", "bold": True, "key": "s1!B1"}
assert cells[0][1] == {
"text": "1,234.50",
"align": "center",
"bold": True,
"key": "s1!B1",
} # 안 준 수 칸 = 가운데(237-1)
assert cells[1][1]["text"] == "2,469.00" # 식 칸 = 풀이 값에 형식
assert cells[0][0]["align"] == "center" # 가로 서식
assert (got["시트"], got["범위"]) == ("s1", "A1:B2")
@@ -64,6 +70,14 @@ def test_표시_글_형식_굵게_정렬_칸_key():
assert got["표"]["shapeData"]["style"] == {"fontSize": 2.5, "padding": 1, "연결": ""}
def test_맞춤_안_준_칸은_가운데_준_칸은_그대로_237_1():
cells = _one(시트="s1", 범위="A1:F1")["표"]["shapeData"]["cells"][0]
by_key = {c["key"]: c["align"] for c in cells if c}
assert by_key["s1!A1"] == "center" # 가로 center 를 준 칸
assert by_key["s1!B1"] == "center" # 안 준 수 칸 (옛: right)
assert by_key["s1!F1"] == "left" # 가로 left 를 준 칸 그대로
def test_병합_잘라냄_숨긴_행열_뺌_빈_칸():
shape = _one(시트="s1", 범위="A1:D4", 글자크기=5)["표"]["shapeData"]
cells = shape["cells"]
@@ -71,7 +85,11 @@ def test_병합_잘라냄_숨긴_행열_뺌_빈_칸():
assert cells[0][0]["rowSpan"] == 2 and cells[1][0] is None
assert cells[0][2]["rowSpan"] == 3 and "colSpan" not in cells[0][2] # C1:E5 → C1:C4(D 숨김)
assert cells[1][2] is None and cells[2][2] is None
assert cells[2][0] == {"text": "", "align": "left", "key": "s1!A4"} # 빈 칸
assert cells[2][0] == {
"text": "",
"align": "center",
"key": "s1!A4",
} # 빈 칸 · 안 준 칸 = 가운데
assert shape["style"]["fontSize"] == 5
assert shape["columnWidths"][1] == pytest.approx(12 * 5 * 0.7) # 열 폭 비율
assert shape["rowHeights"] == [pytest.approx(5 * 1.8)] * 3
@@ -0,0 +1,57 @@
"""맞춤 기본 = 가운데 · 가운데(237-1) — 안 준 칸 · 준 칸 · 도구줄 기본 표시 · 내보내기(복사 HTML).
`spreadsheet_align_default_entry.ts` 를 vite `--ssr` 로 번들해 Node 로 돌림. 영역 표 맞춤은
`test_m02_area_table.py`. 화면(도구줄 · 칸 · 편집창)은 사용자가 확인.
"""
from __future__ import annotations
import json
import shutil
import subprocess
import tempfile
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[2]
ENTRY = ROOT / "resources" / "tester" / "spreadsheet" / "spreadsheet_align_default_entry.ts"
VITE = ROOT / "config" / "node_modules" / "vite" / "bin" / "vite.js"
@pytest.mark.skipif(shutil.which("node") is None, reason="node 가 없음")
def test_맞춤_기본_가운데_237_1():
with tempfile.TemporaryDirectory(prefix="spreadsheet_align_default_") as outdir:
entry_rel = "../" + str(ENTRY.relative_to(ROOT)).replace("\\", "/")
build = subprocess.run(
[
"node",
str(VITE),
"build",
"--configLoader",
"runner",
"--ssr",
entry_rel,
"--outDir",
outdir,
],
cwd=str(ROOT),
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
timeout=120,
)
assert build.returncode == 0, f"vite ssr 번들 실패:\n{build.stdout}\n{build.stderr}"
run = subprocess.run(
["node", str(Path(outdir) / "spreadsheet_align_default_entry.js")],
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
timeout=60,
)
assert run.stdout.strip(), run.stderr
result = json.loads(run.stdout)
assert result["fail"] == 0, result["failures"]
assert result["pass"] == 14