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

This commit is contained in:
2026-10-11 16:41:14 +09:00
27 changed files with 693 additions and 230 deletions
+4
View File
@@ -28,6 +28,10 @@ function loadTs(file) {
return box.exports;
}
// 관 길이 정수(265-3) = 실제 `pipeLengthWhole`(타입 import 만 있는 파일)
STUBS["./B06_Section_UI_Cross_Culvert_Const"] = loadTs(
path.join(ROOT, "B06_Section", "B06_Section_UI_Cross_Culvert_Const.ts"),
);
const cad = loadTs(path.join(ROOT, "B06_Section", "B06_Section_UI_Cross_Detail_Cad.ts"));
const wall = (role, form, height, length) => ({
+110
View File
@@ -0,0 +1,110 @@
/* PLAN 266-1 단위검증 헬퍼 — [상세] 처음 가로 범위 = 카드 반폭.
* 실제 TS(`_Cross_View_Metrics` 칸 맞춤 · `b_svg_zoom_pan` homeScale)를 Node 로 돌림.
* 처음 배율 = 뷰 `detailChart` 와 같은 식(base.ppm / fitted.ppm · 짝 시험이 원본 줄을 확인).
* 결과 = 표준출력 JSON 한 벌. 짝 = `test_266_detail_home.py`.
*/
const fs = require("fs");
const path = require("path");
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
const ROOT = path.join(__dirname, "..", "..");
const B06 = path.join(ROOT, "B06_Section");
const common = fs.readFileSync(path.join(B06, "B06_Section_UI_Section_Common.ts"), "utf8");
const CROSS_PAD = Function(`return ${/export const CROSS_PAD = (\{[^}]*\})/.exec(common)[1]}`)();
const CROSS_HEIGHT = Number(/export const CROSS_HEIGHT = (\d+)/.exec(common)[1]);
const STUBS = {
"./B06_Section_UI_Section_Common": {
CROSS_PAD,
CROSS_HEIGHT,
validElevation: (s) => s.valid !== false && Number.isFinite(s.elevation_m),
},
};
function loadTs(full) {
const js = ts.transpileModule(fs.readFileSync(full, "utf8"), {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
}).outputText;
const box = { exports: {} };
const localRequire = (request) => STUBS[request] ?? {};
new Function("exports", "module", "require", js)(box.exports, box, localRequire);
return box.exports;
}
const metrics = loadTs(path.join(B06, "B06_Section_UI_Cross_View_Metrics.ts"));
const { attachSvgZoomPan } = loadTs(path.join(ROOT, "A00_Common", "b_svg_zoom_pan.ts"));
function fakeSvg(width, height) {
const listeners = {};
const attrs = {};
return {
attrs,
setAttribute: (name, value) => (attrs[name] = String(value)),
getAttribute: (name) => attrs[name] ?? null,
classList: { add() {}, remove() {} },
addEventListener: (type, fn) => (listeners[type] ??= []).push(fn),
fire: (type, event) =>
(listeners[type] ?? []).forEach((fn) =>
fn({ preventDefault() {}, stopPropagation() {}, pointerId: 1, ...event }),
),
setPointerCapture() {},
releasePointerCapture() {},
getBoundingClientRect: () => ({ left: 0, top: 0, width, height }),
};
}
// 가파른 비탈 — ±30 m 샘플 · 1 m 마다 · 1 m 에 0.6 m 오름(칸 맞춤이 가로를 넓히게)
const section = {
samples: Array.from({ length: 61 }, (_, i) => ({
offset_m: i - 30,
elevation_m: 100 + 0.6 * (i - 30),
})),
};
/** 반폭 · 칸 크기로 상세 창을 연 셈 → 처음 · 줌 · 팬 · 원복 뒤 보이는 가로 범위(m). */
function open(halfWidth, W, H) {
const base = metrics.crossPlotBase(section, 1, W, halfWidth, 100);
const fitted = metrics.fitPlotBase(base, H);
const homeScale = base && fitted ? base.pixelsPerMeter / fitted.pixelsPerMeter : 1;
const svg = fakeSvg(W, H);
const layer = fakeSvg(W, H);
let state = null;
const handle = attachSvgZoomPan({
svg,
layer,
widthPx: W,
heightPx: H,
pad: CROSS_PAD,
maxScale: 16,
content: { x: -W, y: -H, width: 3 * W, height: 3 * H },
wheelZoom: true,
homeScale,
onChange: (next) => (state = next),
});
// 보이는 플롯 가로 끝(svg x) → 도형 층 x → 측점 거리(m)
const range = () => {
const s = state ?? { scale: 1, tx: 0, ty: 0 };
const toM = (x) =>
fitted.minOffset + ((x - s.tx) / s.scale - CROSS_PAD.left) / fitted.pixelsPerMeter;
return [toM(CROSS_PAD.left), toM(W - CROSS_PAD.right)].map((v) => Math.round(v * 1000) / 1000);
};
const out = { homeScale, widened: fitted !== base, first: range() };
svg.fire("wheel", { clientX: W / 2, clientY: H / 2, deltaY: 100 });
out.wheelScale = state.scale / homeScale;
svg.fire("pointerdown", { button: 1, clientX: 100, clientY: 100 });
svg.fire("pointermove", { clientX: 160, clientY: 100 });
svg.fire("pointerup", {});
out.panned = range()[0] !== out.first[0];
handle.reset(); // [⤢]
out.reset = range();
for (let i = 0; i < 40; i++) svg.fire("wheel", { clientX: W / 2, clientY: H / 2, deltaY: -100 });
out.minScale = state.scale; // 줄이면 칸 맞춤 전체(1배)까지
svg.fire("dblclick", {});
out.dblclick = range();
return out;
}
process.stdout.write(
JSON.stringify({
global15: open(15, 1200, 300),
station10: open(10, 1200, 300),
tall15: open(15, 1200, 900),
}),
);
+5 -2
View File
@@ -84,13 +84,16 @@ def test_모달_배선_단추_칸_비율():
css = _read("B06_Section_UI_Style_Cross_Detail.css")
assert "wheelZoom: true" in src and "attachSvgZoomPan({" in src
assert "cad.replaceChildren(clone, ...(handle ? [buildZoomControls(handle)] : []))" in src
assert "attachDetailZoom(clone, axes, zoom, (next) => (zoom = next))" in src
flat = " ".join(src.split())
assert (
"attachDetailZoom( clone, axes, zoom, (next) => (zoom = next), drawn.homeScale, )" in flat
)
assert "layer.append(...clone.childNodes)" not in src # 감싸기 지움(179-3)
assert 'clone.querySelector<SVGGElement>("g.b06-chart__plot-layer")' in src
assert "createCrossAxes(axisLayer, axes)" in src
view = _read("B06_Section_UI_Section_View.ts")
assert "const plot = fitted && crossPlotFromBase(fitted, heightPx);" in view
assert "return { svg, axes };" in view
assert "return { svg, axes, homeScale };" in view
assert "position: relative;" in css
# 173-4 — 그림 = 칸 크기로 새로 그림 · svg 칸 가득
assert ".b06-detail__cad > svg {\n display: block;\n width: 100%;\n height: 100%;" in css
+4 -1
View File
@@ -100,7 +100,10 @@ def test_상세_칸_크기로_새로_그림_배선():
assert "cardZoomStates.delete(stationId);" in view
types = _read("B06_Section_UI_Section_View_Types.ts")
# 179-3 — svg + 축 옵션
assert " ) => { svg: SVGSVGElement; axes: CrossAxesOptions | null } | null;" in types
assert (
" ) => { svg: SVGSVGElement; axes: CrossAxesOptions | null; homeScale: number } | null;"
in types
)
assert (
" detailChart: (\n stationId: string,\n widthPx: number,\n heightPx: number,"
in types
+1 -1
View File
@@ -139,6 +139,6 @@ console.log(JSON.stringify([refText(null),
]
# 램프 = 항목 칸 · 우클릭 = [단가연결] · 연결 있으면 [단가연결 풀기] · 창 = B09 와 같은 것
assert 'key !== "item" ? null : routeAt(i)?.ref ? `${LAMP} is-linked` : LAMP' in src
assert "openPriceLink({ apply: link })" in src and '"단가연결 풀기"' in src
assert "openHaulModal({" in src and '"단가연결 풀기"' in src # 263-1 = 운반 단가 모달 하나
diagram = (ROOT / "B08_Quantity" / "B08_Quantity_UI_Milestone_Diagram.ts").read_text("utf-8")
assert "Math.abs(h - lastH) > 1" in diagram # 틀 높이 바뀜 = 다시 그림(아래 잘림 없음)
+2 -2
View File
@@ -48,8 +48,8 @@ def test_내역_줄_거리():
def test_모달_여는_곳():
modal = _src("B09_Estimation/B09_Estimation_UI_Haul_Modal.ts")
for want in ("/estimation/logic-preview", "openLogicPicker", "fetchLogicInputs", "fillGiven",
"cycleInputs", "my !== seq", "CC000054", "거리에서", "[고르기]"): # fmt: skip
for want in ("/estimation/logic-preview", "openPriceLink", "fetchLogicInputs", "fillGiven",
"cycleInputs", "my !== seq", "거리에서", "[고르기]"): # fmt: skip
assert want in modal, want
milestone = _src("B08_Quantity/B08_Quantity_UI_Milestone.ts")
assert "openHaulModal({" in milestone and "L1: paved" in milestone
@@ -1,6 +1,6 @@
"""254-3 B08 거리이정표 상자 모달 제목 · 장소 그림(사용자 162-6 · 8).
① 상자 누름 모달 제목 = 「거래처 — 이름」 · 「기준점 — 이름」(표 머리 글 「항목 정보」 없음)
① 상자 누름 모달 제목 = 이름만(262-1 · 「—」 앞 글자 없음 · 표 머리 글 「항목 정보」 없음)
② 장소 그림(256-1 `place_images`) = 상자 안 글 아래 작게 · 그림 없으면 자리 없음(상자 높이 그대로)
"""
@@ -67,7 +67,7 @@ def test_장소_그림_상자_안_글_아래():
def test_모달_제목_화면_그림():
modal = _read(B08 + "UI_Milestone_Modal.ts")
assert "`거래처 — ${route!.name" in modal and "`기준점 — ${base.name" in modal
assert 'route!.name || "거래처"' in modal and 'base.name || "기준점"' in modal # 262-1
assert "항목 정보 —" not in modal
ui = _read(B08 + "UI_Milestone_Diagram.ts")
assert "placeImageBlobUrl(projectId, id)" in ui and "placeImageKey(id)" in ui
+3 -3
View File
@@ -107,7 +107,7 @@ def test_관_유입_유출_도수로_한_벌(out):
assert _labels(full) == [
"배수관",
"Φ1200m/m",
"L=22.0m",
"L=22m",
"NO.71+13",
"관보호공(유입) 기슭막이(찰,H=2.0) L=10.0m",
"관보호공(유출) 기슭막이(메,H=2.0) L=10.0m",
@@ -136,7 +136,7 @@ def test_도수로_옵션이_없으면_세트_구조만(out):
def test_보호공을_안_고르면_관과_측점만(out):
assert _labels(out["noSides"]) == ["배수관", "Φ1200m/m", "L=22.0m", "NO.71+13"]
assert _labels(out["noSides"]) == ["배수관", "Φ1200m/m", "L=22m", "NO.71+13"]
@pytest.mark.parametrize("case", ["none", "ford", "hidden", "neighbour"])
@@ -425,7 +425,7 @@ def test_칸_없음_만_미리_넣음_지운_칸_옛_글_설계없음_이웃은_
assert labels[2:] == [
"배수관",
"Φ1200m/m",
"L=22.0m",
"L=22m",
"NO.71+13",
"관보호공(유입) 기슭막이(찰,H=2.0) L=10.0m",
"관보호공(유출) 기슭막이(메,H=2.0) L=10.0m",
@@ -0,0 +1,40 @@
"""262 B08 거리이정표 상자 창 제목 · 소재지 지도 시점 표지(사용자 164-1).
262-1 거래처 · 기준점 창 제목 = 이름만(빈 이름 = 「거래처」 · 「기준점」) · 시점 창 = 「시점 · 중점」 · 요소 추가 창 그대로
262-2 소재지 지도 = 시점 표지 고정(끌기 없음 · 맞춤에서 빠짐) · 시점 없으면 표지 없음 · 소재지 없으면 시점 중심
"""
import re
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
B08 = "B08_Quantity/B08_Quantity_UI_Milestone"
def _read(name):
return (ROOT / f"{B08}{name}").read_text(encoding="utf-8")
def test_제목_이름만():
modal = _read("_Modal.ts")
title = modal[modal.index("const title = target.add") : modal.index("openModal({")]
assert '? base.name || "기준점"' in title and ': route!.name || "거래처"' in title
assert '"요소 추가 — 기준점 · 거래처"' in title
assert 'title: "시점 · 중점",' in modal and "공사지 —" not in modal
assert re.search(r"title: `?[^\n]*(거래처|기준점|공사지) —", modal) is None
def test_시점_표지_고정():
place = _read("_Place.ts")
assert "startPoint(liveMilestone(projectId))" in place # 거리이정표가 쓰는 시점
assert "center: start" in place # 소재지 없음 = 시점 중심
assert "movable: false, fixed: true" in place and 'name: "시점"' in place
assert "...(s" in place and " : [])," in place # 시점 없음 = 표지 없음
kakao = _read("_KakaoMap.ts")
assert "fixed?: boolean;" in kakao
assert "const moving = pins.filter((p) => !p.fixed);" in kakao
assert "...moving.map(" in kakao # 처음 보기 = 소재지 중심(시점은 맞춤에서 빠짐)
assert "moving.find(" in kakao # 보기 이동에서도 빠짐
assert "if (p.fixed)" in kakao and "b08-milestone__map-start" in kakao
css = _read(".css")
assert ".b08-milestone__map-start {" in css
+89
View File
@@ -0,0 +1,89 @@
"""263-1 「운반 단가」 모달 = 고른 항목 입력만 + 거리 자동 — 사이클 손 칸 · 8-2-8 표 고르기 없음 ·
[변경] = 단가연결 한 목록 · 거리 칸(운반거리 · Cm · 사이클시간 · t2) = 거리에서 · 손 값 이김 ·
옛 `cycle` = 서버 읽기 그대로(모달 [적용]하면 지움)."""
import json
import subprocess
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT))
from B08_Quantity.B08_Quantity_Haul_Cycle import FILL, cycle_time, fill_given # noqa: E402
from B09_Estimation.B09_Estimation_Sangeun import _input_names, hand_sources # noqa: E402
DIST = {"L1": 23.8, "L2": 10.5}
def _src(name: str) -> str:
return (ROOT / name).read_text(encoding="utf-8")
def test_왼쪽_칸_고른_로직_입력만():
modal = _src("B09_Estimation/B09_Estimation_UI_Haul_Modal.ts")
for gone in ("data-haul-cycle", "CC000054", "사이클시간 입력", "표에서 고르기", "다른 종류",
"numberField", "loadTable"): # fmt: skip
assert gone not in modal, gone
# 칸 = 로직 입력 하나에 한 줄(createValueControl) · 사이클 줄 = 거리 칸 있을 때만
assert "st.inputs.map((input) =>" in modal and "createValueControl(" in modal
assert "names().some((n) => n in CYCLE_FILL)" in modal
assert "포장 ${fmt(o.dist?.L1)} km + 비포장 ${fmt(o.dist?.L2)} km" in modal
# 거리 받는 마스터 로직 = 거리 칸 하나씩
for code, want in (("GF000284", "운반거리"), ("GF000134", "Cm"), ("GF000283", "사이클시간")):
names = _input_names(code)
assert [n for n in names if n in FILL] == [want], (code, names)
def test_변경_한_목록():
modal = _src("B09_Estimation/B09_Estimation_UI_Haul_Modal.ts")
assert "openPriceLink({ ...o, onPick: usePicked })" in modal and "importLib(o, p)" in modal
link = _src("B09_Estimation/B09_Estimation_UI_Tabs_Bill_Link.ts")
assert "o.onPick ??" in link and "openHaulModal({ ...link, ref" in link
milestone = _src("B08_Quantity/B08_Quantity_UI_Milestone.ts")
assert "other:" not in milestone and "openPriceLink" not in milestone
def test_거리_칸_자동_손_값_이김():
ref = {"given": {"재료": "토사"}, "cycle": {}, "own": []}
got = fill_given(ref, _input_names("GF000284"), DIST)
assert got["운반거리"] == 34.3 # L1 + L2
cm = cycle_time(DIST)["cm"]
assert fill_given(ref, ["Cm"], DIST)["Cm"] == cm
assert fill_given(ref, ["사이클시간", "t2"], DIST) == {
"재료": "토사", "사이클시간": cm, "t2": cycle_time(DIST)["t2"]} # fmt: skip
mine = {"given": {"운반거리": 5}, "own": ["운반거리"]}
assert fill_given(mine, ["운반거리"], DIST)["운반거리"] == 5
def test_ts_짝_사이클_비움():
"""모달 = cycle {} · 옛 own L1 · L2 · t1 뺌 → TS fillGiven = 파이썬과 같음."""
calc = ROOT / "B08_Quantity" / "B08_Quantity_Haul_Cycle.ts"
ref = {"given": {"qt": 15}, "cycle": {}, "own": []}
body = (
f"\nconsole.log(JSON.stringify(fillGiven({json.dumps(ref)}, ['Cm', '운반거리'],"
f" {json.dumps(DIST)})));"
)
result = subprocess.run(
["node", "--experimental-strip-types", "--no-warnings",
"--input-type=module-typescript", "-e", calc.read_text(encoding="utf-8") + body],
capture_output=True, text=True, encoding="utf-8", timeout=60,
) # fmt: skip
assert result.returncode == 0, result.stderr
assert json.loads(result.stdout) == fill_given(ref, ["Cm", "운반거리"], DIST)
modal = _src("B09_Estimation/B09_Estimation_UI_Haul_Modal.ts")
assert (
"cycle: {}, given: st.given" in modal and 'OLD_OWN = new Set(["L1", "L2", "t1"])' in modal
)
assert "cycle: st.cycle" not in modal # [적용] 새 연결에 cycle 없음
def test_옛_cycle_서버_읽기_그대로():
old = {"tab": "logic", "code": "GF000134", "given": {"Cm": 1}, "cycle": {"V1": 20}}
user = {"haul_refs": {"haul:r-u1": old}, "haul_dist": {"haul:r-u1": DIST}}
((_k, _u, src),) = hand_sources(user)
assert src["given"]["Cm"] == cycle_time({**DIST, "V1": 20})["cm"]
new = {**old, "cycle": None}
user["haul_refs"]["haul:r-u1"] = new
((_k, _u, src),) = hand_sources(user)
assert src["given"]["Cm"] == cycle_time(DIST)["cm"]
+66
View File
@@ -0,0 +1,66 @@
# -*- coding: utf-8 -*-
"""PLAN 265 — B06 [상세] 표 위 CAD 칸 · 접기 · 차례 · 구조물 글 · 관 길이 정수.
- 265-3 원형 마크 L = 사사오입 정수(TS `pipeLengthWhole` = 파이썬 짝 같은 값).
./venv/Scripts/python.exe -m pytest resources/tester/test_265_note_cad.py -q
"""
import json
import subprocess
import sys
from pathlib import Path
import pytest
PROJECT_ROOT = Path(__file__).resolve().parents[2]
if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
from B06_Section.B06_Section_Engine_Culvert import _culvert_set # noqa: E402
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_CulvertNote import ( # noqa: E402
culvert_note_entities,
)
HERE = Path(__file__).resolve().parent
_PIPE = {"pipe_diameter_mm": 800}
def _mirror(cases: list[dict]) -> list[dict]:
proc = subprocess.run( # noqa: S603 — 고정 실행 파일
["node", str(HERE / "helper_260_note_cad.cjs"), "mirror"],
input=json.dumps(cases, ensure_ascii=False),
capture_output=True,
text=True,
encoding="utf-8",
timeout=60,
)
assert proc.returncode == 0, proc.stderr
return json.loads(proc.stdout)
def _labels(entities: list[dict]) -> list[str]:
return [e["shapeData"]["label"] for e in entities if e["type"] == "Text"]
# ── 265-3 관 길이 정수 ─────────────────────────────────────────────────
@pytest.mark.parametrize(
("length", "label"),
[(8.5, "L=9m"), (8.4, "L=8m"), (8.45, "L=8m"), (7.0, "L=7m"), (None, "L=")],
)
def test_원형_마크_L_은_사사오입_정수_TS_파이썬_같음(length, label):
design = {} if length is None else {"pipe_length_m": length}
culvert = {**_culvert_set(_PIPE), "chainage_m": 40.0}
case = {
"section": {"chainage_m": 40.0, "culvert": culvert, "design": design},
"station": "NO.2",
"options": _PIPE,
"chainage": 40.0,
"interval": 20.0,
}
[ts] = _mirror([case])
py = culvert_note_entities(40.0, culvert, design, "NO.2", _PIPE)
assert _labels(ts["entities"])[2] == label
assert _labels(py)[2] == label
+85
View File
@@ -0,0 +1,85 @@
"""PLAN 266-1 — B06 [상세] 처음 가로 범위 = 좌측 「횡단 보기 설정」 반폭(개별 표시 반폭이면 그것).
짝: `B06_Section/B06_Section_UI_Section_View.ts`(`detailChart` 처음 배율) ·
`B06_Section_UI_Cross_Detail.ts`(`attachDetailZoom` homeScale) · `A00_Common/b_svg_zoom_pan.ts`.
실제 TS 를 `helper_266_detail_home.cjs` 가 Node 로 돌린다.
"""
from __future__ import annotations
import json
import os
import subprocess
import pytest
HERE = os.path.dirname(__file__)
ROOT = os.path.join(HERE, "..", "..")
B06 = os.path.join(ROOT, "B06_Section")
pytestmark = pytest.mark.skipif(
not os.path.isdir(os.path.join(ROOT, "config", "node_modules")),
reason="프론트엔드 의존성 미설치",
)
def _read(name: str) -> str:
with open(os.path.join(B06, name), encoding="utf-8") as f:
return f.read().replace(chr(13), "")
@pytest.fixture(scope="module")
def out() -> dict:
proc = subprocess.run( # noqa: S603 — 고정 실행 파일
["node", os.path.join(HERE, "helper_266_detail_home.cjs")],
capture_output=True,
text=True,
encoding="utf-8",
timeout=60,
)
assert proc.returncode == 0, proc.stderr
return json.loads(proc.stdout)
def test_처음_가로_반폭_15(out):
# 칸 맞춤이 가로를 넓혀도 처음 배율로 되좁힘 = −15 ~ +15
case = out["global15"]
assert case["widened"] is True and case["homeScale"] > 1
assert case["first"] == [-15, 15]
def test_개별_반폭_측점(out):
assert out["station10"]["first"] == [-10, 10]
def test_안_넓힌_칸_그대로(out):
case = out["tall15"]
assert case["widened"] is False and case["homeScale"] == 1
assert case["first"] == [-15, 15]
@pytest.mark.parametrize("name", ["global15", "station10", "tall15"])
def test_처음으로_같은_범위_줌_이동_그대로(out, name):
case = out[name]
assert case["reset"] == case["first"] # [⤢]
assert case["dblclick"] == case["first"] # 더블클릭
assert case["wheelScale"] == pytest.approx(1.15) # 휠 줌
assert case["panned"] is True # 가운데 단추 끌기
assert case["minScale"] == 1 # 줄이면 칸 맞춤 전체까지
def test_배선():
view = _read("B06_Section_UI_Section_View.ts")
assert (
"const homeScale = base && fitted ? base.pixelsPerMeter / fitted.pixelsPerMeter : 1;"
in view
)
assert "return { svg, axes, homeScale };" in view
# 반폭 = 개별 표시 반폭 › 좌측 「횡단 보기 설정」(카드와 같은 길)
assert (
"effectiveCardHalfWidth(section, stationWidth?.widthFor(section) ?? currentCrossHalfWidth)"
in view
)
src = " ".join(_read("B06_Section_UI_Cross_Detail.ts").split())
assert "drawn.homeScale" in src and "homeScale, onChange" in src
assert "homeScale: drawn.homeScale" in src
@@ -0,0 +1,44 @@
# -*- coding: utf-8 -*-
"""PLAN 267-1 — B06 횡단 카드 「관매설 …」 글 크기 = 「노폭 …」 글 크기 · 줄 겹침 없음.
./venv/Scripts/python.exe -m pytest resources/tester/test_267_1_pipe_info_font.py -q -p no:cacheprovider --basetemp=tmp/pt
"""
import re
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
CSS = (ROOT / "B06_Section" / "B06_Section_UI_Style_Cross_Areas.css").read_text(encoding="utf-8")
TYPES = (ROOT / "B06_Section" / "B06_Section_UI_Cross_Culvert_Types.ts").read_text(encoding="utf-8")
def _font_px(selector: str) -> float:
"""셀렉터 블록의 마지막 font-size(px) — 뒤에 오는 규칙이 이긴다."""
blocks = re.findall(re.escape(selector) + r"\s*\{([^}]*)\}", CSS)
sizes = [float(m) for b in blocks for m in re.findall(r"font-size:\s*([\d.]+)px", b)]
assert sizes, selector
return sizes[-1]
def test_관_정보_글_크기가_노폭_라벨과_같다() -> None:
road = _font_px(".b06-chart__carriageway-label")
info = _font_px(".b06-chart__culvert-label.b06-chart__pipe-info")
assert road == info == 11.0
# 다른 culvert-label(집수정 · 박스 이름)은 그대로 9px
assert _font_px(".b06-chart__culvert-label") == 9.0
def test_관_정보_글_줄이_노폭_라벨과_겹치지_않는다() -> None:
step = float(re.search(r"- (\d+) \* \(stacked \+ 1\)", TYPES).group(1))
font = _font_px(".b06-chart__culvert-label.b06-chart__pipe-info")
road_font = _font_px(".b06-chart__carriageway-label")
asc, desc = 0.8, 0.2 # 글자 상자 근사 — 기준선 위 80% · 아래 20%
road_top = 0 - asc * road_font # 노폭 라벨 기준선 y=0
for n in (1, 2, 3): # 관 정보 글이 쌓인 줄 수
info_y = -step * n
info_bottom = info_y + desc * font
assert info_bottom < road_top, (n, info_bottom, road_top)
if n > 1: # 윗줄 바닥 < 아랫줄 윗면
upper_bottom = -step * n + desc * font
lower_top = -step * (n - 1) - asc * font
assert upper_bottom < lower_top, n