Merge remote-tracking branches 'origin/main_laptop_1', 'origin/sub_laptop_4', 'origin/sub_laptop_5' and 'origin/sub_laptop_6' into sub_laptop_2

This commit is contained in:
2026-10-25 08:04:17 +09:00
30 changed files with 878 additions and 115 deletions
@@ -187,8 +187,11 @@ const sheets = () =>
);
const copy = () => JSON.parse(JSON.stringify(state));
const API = {
fetchPlanKinds: async () => copy(),
fetchDesignDrawingList: async () => ({ drawings: [{ id: "cover", kind: "cover" }, ...sheets()] }),
fetchPlanKinds: async () => (calls.push(["kinds"]), copy()),
fetchDesignDrawingList: async () => (
calls.push(["list"]),
{ drawings: [{ id: "cover", kind: "cover" }, ...sheets()] }
),
fetchDesignDrawing: async (_, id) => (
calls.push(["fetchDrawing", id]),
{ id, confirmed: confirmed.has(id), drawing: { entities: [{ id }], layers: [] } }
@@ -232,6 +235,12 @@ const { loadTs } = makeLoader({
if (request === "@ui/ui_template_collapsible")
return loadTs(path.join(ROOT, "ui_template", "ui_template_collapsible.ts"));
if (request === "./B07_DesignDetail_Api_Fetch") return API;
// 522-3 미리받기 — 장 받기는 `openDrawing` · 고친 뒤 목록 맞춤은 `syncDrawings`
if (request === "./B07_DesignDetail_Prefetch")
return {
openDrawing: (item) => API.fetchDesignDrawing("p1", item.id),
syncDrawings: (_, list) => calls.push(["sync", list.length]),
};
if (request.startsWith(".")) return loadTs(path.join(path.dirname(full), `${request}.ts`));
throw new Error(`stub 없음: ${request}`);
},
@@ -245,9 +254,15 @@ const { openPlanManager } = loadTs(path.join(B07, "B07_DesignDetail_UI_PlanManag
const out = {};
const closes = [];
const selected = [];
const plan = { projectId: "p1", onClosed: (changed, openId) => closes.push([changed, openId]) };
// 페이지가 받은 목록을 넘김(`openDrawingSettings` 와 같은 길 · 522-3)
const pageList = [{ id: "cover", kind: "cover", label: "표지", confirmed: false }, ...sheets()];
const plan = {
projectId: "p1",
drawings: pageList,
onClosed: (changed, openId) => closes.push([changed, openId]),
};
const panel = buildDrawingSidePanel(
[{ id: "cover", kind: "cover", label: "표지", confirmed: false }, ...sheets()],
pageList,
(drawing) => selected.push(drawing.id),
undefined,
false,
@@ -288,6 +303,8 @@ const { openPlanManager } = loadTs(path.join(B07, "B07_DesignDetail_UI_PlanManag
hasMarks: all(modal.body).some((n) => n.textContent === "대피소 · 사토장 자리"),
preview: cad.loads.at(-1).drawing.entities[0].id,
extra: cad.loads.at(-1).extra,
bare: cad.opts.bare,
calls: calls.map((c) => c[0]),
};
// 종류 추가 — 「새 종류」 · 이름 · 조각 하나
@@ -297,7 +314,12 @@ const { openPlanManager } = loadTs(path.join(B07, "B07_DesignDetail_UI_PlanManag
action().click();
await settle();
await settle();
out.add = { kinds: kindRows(), sheets: sheetIds(), call: calls.find((c) => c[0] === "add") };
out.add = {
kinds: kindRows(),
sheets: sheetIds(),
call: calls.find((c) => c[0] === "add"),
after: calls.slice(calls.findIndex((c) => c[0] === "add")).map((c) => c[0]),
};
// 업데이트 — 새 종류를 골라 이름 · 축척 바꿈
kindPick().change("k_new");
@@ -393,10 +415,17 @@ const { openPlanManager } = loadTs(path.join(B07, "B07_DesignDetail_UI_PlanManag
out.close = { closes: [...closes], destroyed: cad.destroyed, open: modal.open };
// 다시 열어 고치지 않고 [CAD 에서 열기] → changed = false · 그 장 id
const callsBefore = calls.length;
gear().click();
await settle();
await settle();
const modal2 = modals[1];
out.reopen = {
calls: calls.slice(callsBefore).map((c) => c[0]),
kinds: modal2.body
.querySelectorAll(".b07-plan-manage__sheets .b07-plan-manage__row")
.map((row) => row.dataset.planKind),
};
modal2.body
.querySelectorAll(".b07-plan-manage__sheet")
.find((b) => b.dataset.drawingId === "plan_k_route")
+15 -3
View File
@@ -97,19 +97,19 @@ const calls = [];
let failConfirm = false;
const SCALES = {
scales: {
longitudinal: { x: 1000, y: 200 },
longitudinal: { x: 1000, y: 200, plan: null },
cross: { x: 100, y: 100 },
mass_haul: { x: null, y: 50000 },
development: { x: null, y: null },
},
defaults: {
longitudinal: { x: 1000, y: 200 },
longitudinal: { x: 1000, y: 200, plan: null },
cross: { x: 100, y: 100 },
mass_haul: { x: null, y: 50000 },
development: { x: null, y: null },
},
ranges: {
longitudinal: { x: [100, 10000], y: [10, 2000] },
longitudinal: { x: [100, 10000], y: [10, 2000], plan: [500, 50000] },
cross: { x: [10, 1000], y: [10, 1000] },
mass_haul: { x: [100, 20000], y: [1000, 1000000] },
development: { x: [10, 10000], y: [10, 10000] },
@@ -413,6 +413,18 @@ const drawings = [
s.btn("축척 저장").click();
await tick();
scale.same = { calls: calls.length, toasts: toasts.slice(toastAt).map((t) => t[1]) };
// 평면 위치도 칸(521-2) — 셋째 칸 · 자동 끔 → 범위 밖 붉음 → 1/3,000 저장
s = await scaleOf("longitudinal");
scale.planStart = s.state();
s.toggle(s.autos[0]);
s.type(s.inputs[2], "400");
scale.planLow = s.state();
s.type(s.inputs[2], "3000");
calls.length = 0;
s.btn("축척 저장").click();
await tick();
await tick();
scale.planSent = calls.find((c) => c[0] === "scales")?.[1];
s = await scaleOf("mass_haul");
scale.massStart = s.state();
@@ -113,7 +113,19 @@ const common = {
"../A00_Common/b_page_scaffold": { workflowSteps: () => [] },
"../A00_Common/b_workflow_nav": {
WORKFLOW_STEP_ROUTES: ["b03", "b04", "b05", "b06", "b07", "b08", "b09"],
goToWorkflowStage: (_p, route) => calls.push(`go:${route}`),
// 520-3 공용 페이지 넘김 — 단계 차례 · false · 던짐 = 머묾(잠금 · 진행 글은 test_520_3)
moveStage: async (_p, target, steps) => {
for (const step of steps)
try {
if ((await step.run()) === false) return false;
} catch {
calls.push("toast:error");
return false;
}
if (typeof target === "function") target();
else calls.push(`go:${target}`);
return true;
},
},
"../A00_Common/b_page_state": {
readState: (name) => store.get(name) ?? null,
+13 -1
View File
@@ -101,7 +101,19 @@ const common = {
"../A00_Common/b_page_scaffold": { workflowSteps: () => [] },
"../A00_Common/b_workflow_nav": {
WORKFLOW_STEP_ROUTES: ["b03", "b04", "b05", "b06", "b07", "b08", "b09"],
goToWorkflowStage: (_p, route) => calls.push(`go:${route}`),
// 520-3 공용 페이지 넘김 — 단계 차례 · false · 던짐 = 머묾(잠금 · 진행 글은 test_520_3)
moveStage: async (_p, target, steps) => {
for (const step of steps)
try {
if ((await step.run()) === false) return false;
} catch {
calls.push("toast:error");
return false;
}
if (typeof target === "function") target();
else calls.push(`go:${target}`);
return true;
},
},
"../A00_Common/b_page_state": {
readState: (name) => store.get(name) ?? null,
@@ -0,0 +1,79 @@
/* 520-3 페이지 넘김 공용 `moveStage` — 진짜 `b_workflow_nav.ts` 를 트랜스파일해 가짜 잠금 · 토스트 · 이동 위에서 돌리고
* 잠금 · 진행 글 · 머묾 · 이동을 JSON 으로 냄. */
const fs = require("fs");
const path = require("path");
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
const ROOT = path.join(__dirname, "..", "..");
const calls = [];
let lock = 0;
const stubs = {
"@config/config_frontend": { API_BASE_URL: "/api", CURRENT_PROJECT_ID_KEY: "pid", ROUTES: {} },
"@ui/ui_template_elements": {
showLoadingOverlay: () => (lock += 1),
hideLoadingOverlay: () => (lock = Math.max(0, lock - 1)),
setLoadingOverlayProgress: (ratio, label) => calls.push(`p:${ratio.toFixed(2)}:${label}`),
showToast: (text, kind) => calls.push(`toast:${kind}:${text}`),
},
"../B04_PreProcess/B04_PreProcess_Api_Fetch": {},
"./b_asset_cache": {},
"./b_project_switch": { enterProject: async () => calls.push("enter") },
"./router": { navigateTo: (route) => calls.push(`go:${route}`) },
};
const js = ts.transpileModule(
fs.readFileSync(path.join(ROOT, "A00_Common/b_workflow_nav.ts"), "utf8"),
{
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
},
).outputText;
const mod = { exports: {} };
global.window = { setInterval, clearInterval };
global.localStorage = { setItem() {} };
new Function("exports", "module", "require", js)(mod.exports, mod, (id) => stubs[id] ?? {});
const { moveStage } = mod.exports;
const wait = (ms) => new Promise((r) => setTimeout(r, ms));
const run = async (target, steps) => {
calls.length = 0;
const ok = await moveStage("P1", target, steps);
return { ok, lock, calls: calls.splice(0) };
};
(async () => {
const out = {};
let lockSeen = -1;
out.ok = await run("b08", [
{ name: "저장", run: async () => void (lockSeen = lock) },
{ name: "완료", run: async () => true },
]);
out.ok.lockSeen = lockSeen;
out.stop = await run("b08", [
{ name: "저장", run: async () => false },
{ name: "완료", run: async () => calls.push("완료 돎") },
]);
out.fail = await run("b08", [
{
name: "저장",
run: async () => {
throw new Error("Failed to fetch");
},
},
]);
let asked = 0;
out.poll = await run(
() => calls.push("go:fn"),
[
{
name: "서버 저장",
run: () => wait(1150),
progress: async () => ((asked += 1), { ratio: 0.5, label: "관 차례" }),
},
],
);
out.poll.asked = asked;
// 넘기는 중 또 누름 = 무시
const first = moveStage("P1", "b07", [{ name: "느림", run: () => wait(50) }]);
out.busy = await moveStage("P1", "b09", []);
await first;
process.stdout.write(JSON.stringify(out));
})();
@@ -9,6 +9,8 @@
- 장 단추 → 드롭다운 따라감 · 드롭다운 → 그 종류 첫 장 · 줄 끌어 놓기 = 종류 순서(놓는 즉시 · 140-2).
- 미리보기 장 바꾸기 · 캔버스만(bare) · 확정 배지만(확정 풀기 없음 140-1) · 닫으면 changed=True · [CAD 에서 열기] = 그 장 id.
- 522 처음 = 페이지 목록 · 미리받기 장 · 고친 뒤만 목록 새로 + 미리받기 맞춤 · 같은 목록 다시 열기 = 요청 없음 · iframe `?bare`.
Node 헬퍼 `helper_130_b07_plan_manage.cjs` 가 화면 그릇 흉내로 돌림. 화면은 사용자 확인.
"""
@@ -59,6 +61,21 @@ def test_첫_장_미리보기는_보기_전용(out):
assert extra["readOnly"] is True and extra["viewOnly"] is True
assert extra["bare"] is True # 140-1 캔버스만(제목줄 · 리본 · 상태막대 없음)
assert extra["recoveryScope"] == "b07-plan-preview" # 본 CAD 자동백업 칸과 안 겹침
assert out["open"]["bare"] is True # 522-2 iframe 을 `?bare` 로 — 도면 오기 전에도 리본 없음
def test_처음_열기는_페이지_목록(out):
# 522-3 목록은 페이지가 받은 것 · 장은 미리받기(`openDrawing`) — 목록 다시 안 받음
assert out["open"]["calls"] == ["kinds", "fetchDrawing"]
def test_고친_뒤엔_목록_새로_맞춤(out):
assert out["add"]["after"][:4] == ["add", "kinds", "list", "sync"]
def test_같은_목록으로_다시_열면_요청_없음(out):
assert out["reopen"]["calls"] == ["fetchDrawing"] # 진짜 길은 미리받기 캐시
assert out["reopen"]["kinds"] == ["k_route", "k_terrain"] # 고친 뒤 받은 순서 그대로
def test_종류_추가(out):
@@ -244,7 +244,14 @@ def test_전개도_가로_세로(project: Path) -> None:
def test_범위_검사() -> None:
assert checked_scales({"cross": {"x": 100, "y": None}}) == {"cross": {"x": 100, "y": None}}
# 종단 평면 위치도(521-2) = 1/500~1/50,000 · null = 자동
assert checked_scales({"longitudinal": {"plan": 3000}}) == {
"longitudinal": {"x": None, "y": None, "plan": 3000}
}
with pytest.raises(ValueError, match="평면도 축척은 1/500~1/50,000"):
checked_scales({"longitudinal": {"plan": 400}})
for bad in (
{"longitudinal": {"plan": 50001}},
{"plan": {"x": 500}},
{"standard": {"x": 50}},
{"cross": {"x": 5}},
@@ -287,7 +294,7 @@ def test_바꾼_종류만_저장본_풂(project: Path) -> None:
drawings = _read_manifest(project)["drawings"]
assert not drawings["longitudinal_1"]["confirmed"]
assert drawings["cross_00020m"]["confirmed"] and drawings["cover"]["confirmed"]
assert read_drawing_scales(project)["longitudinal"] == {"x": 500, "y": 200}
assert read_drawing_scales(project)["longitudinal"] == {"x": 500, "y": 200, "plan": None}
# 보낸 종류만 바뀜 — 앞서 쓴 종단은 남음 · 횡단 = 측점 설계 잠정 몫
invalidated, stale = write_drawing_scales(
project, project / "none.json", {"cross": {"x": 100, "y": 50}}
@@ -314,5 +321,5 @@ def test_GET_PUT_범위_밖_400(project: Path, monkeypatch: pytest.MonkeyPatch)
assert not drawing_scales_path(project).exists() # 거절 = 안 씀
put = client.put(url, json={"scales": {"longitudinal": {"x": 500, "y": 200}}})
assert put.status_code == 200
assert put.json()["scales"]["longitudinal"] == {"x": 500, "y": 200}
assert put.json()["scales"]["longitudinal"] == {"x": 500, "y": 200, "plan": None}
assert put.json()["invalidated"] == {}
+15 -10
View File
@@ -142,12 +142,12 @@ def test_페이지_확정_단추_잠금_자동이동_없음():
assert gone not in page, gone
assert "confirmed: false, // 잠금 없음(요구 281)" in page
assert "frameRow.append(frameEditor.button);" in page # 298-2 — [도각 편집] = 윗줄
assert "onStepClick: (stepIndex) => void moveToStage(stepIndex)," in page
assert "onStepClick: (stepIndex) => moveToStage(stepIndex)," in page
move = page[
page.index("const moveToStage = async") : page.index("const layout = createWorkflowLayout")
page.index("const moveToStage = (") : page.index("const layout = createWorkflowLayout")
]
assert move.index("saveEditedDrawings(store)") < move.index("completeDesignStage(projectId)")
assert move.index("completeDesignStage(projectId)") < move.index("goToWorkflowStage(")
assert move.index("completeDesignStage(projectId)") < move.index("moveStage(projectId")
assert "if (stepIndex > 4 && drawings.length > 0)" in move
assert "currentStage: 5," in page
assert len(page.splitlines()) <= 700
@@ -229,22 +229,27 @@ def test_485_2_축척_칸_판정(out):
def test_485_2_종단_축척_저장(out):
scale = out["scale"]
assert scale["longLabels"] == ["X(가로) 1/", "Y(세로) 1/"]
assert scale["longStart"]["values"] == ["1000", "200"]
assert scale["longStart"]["autos"] == [] # 종단 = 자동 없음
assert scale["longLabels"] == ["X(가로) 1/", "Y(세로) 1/", "평면도 1/"]
assert scale["longStart"]["values"] == ["1000", "200", ""]
assert scale["longStart"]["autos"] == [True] # 종단 = 평면도 칸만 자동(521-2)
for bad in ("longLow", "longFrac"): # 범위 밖 · 소수 = 붉음 + 저장 꺼짐
assert scale[bad]["errors"] == [RANGE, None]
assert scale[bad]["errors"] == [RANGE, None, None]
assert scale[bad]["save"] is False
assert scale["longOk"]["errors"] == [None, None] and scale["longOk"]["save"] is True
assert scale["longOk"]["errors"] == [None, None, None] and scale["longOk"]["save"] is True
# 저장본 2장 — 물음 [취소] = 요청 없음 · 모달 그대로
assert scale["cancel"] == {"calls": ["ask"], "open": True, "closed": 0}
save = scale["save"]
assert "저장본 2장" in save["ask"]
assert save["sent"] == {"longitudinal": {"x": 500, "y": None}} # Y = 기본 → null
assert save["sent"] == {"longitudinal": {"x": 500, "y": None, "plan": None}} # 기본 → null
assert save["open"] is False
assert save["closed"] == [[True, "longitudinal"]] # 화면 다시 세움 · 같은 도면
assert scale["reset"]["values"] == ["1000", "200"] # [기본값] = 칸만
assert scale["reset"]["values"] == ["1000", "200", ""] # [기본값] = 칸만
assert scale["same"] == {"calls": 0, "toasts": ["바뀐 축척이 없습니다."]}
# 평면 위치도 칸(521-2) — 처음 자동(칸 꺼짐) · 1/500 밑 = 붉음 · 1/3,000 저장
assert scale["planStart"]["disabled"] == [False, False, True]
assert scale["planLow"]["errors"] == [None, None, "1/500~1/50,000 사이 정수"]
assert scale["planLow"]["save"] is False
assert scale["planSent"] == {"longitudinal": {"x": None, "y": None, "plan": 3000}}
def test_485_2_토적_전개도_자동(out):
+1 -1
View File
@@ -59,6 +59,6 @@ def test_빈_초안은_고침_아님_취향은_둠(out):
def test_공용_이동_한_곳에서_부름():
nav = (ROOT / "A00_Common/b_workflow_nav.ts").read_text(encoding="utf-8")
assert "void enterProject(" in nav and "./b_project_switch" in nav
assert "return enterProject(" in nav and "./b_project_switch" in nav
ui = (ROOT / "ui_template/ui_template_elements.ts").read_text(encoding="utf-8")
assert 'cancelLabel = "취소"' in ui and "label: cancelLabel" in ui
+71
View File
@@ -0,0 +1,71 @@
# -*- coding: utf-8 -*-
"""520-3 페이지 넘김 공용 `moveStage` — 잠금 → 「n/N 이름 · 지난 초」 → 실패면 잠금 풀고 머묾 + 까닭 → 성공이면 이동.
`helper_520_3_move_stage.cjs` 가 진짜 `A00_Common/b_workflow_nav.ts` 를 가짜 잠금 · 토스트 · 이동 위에서 돌림.
"""
import json
import os
import subprocess
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[2]
HERE = os.path.dirname(os.path.abspath(__file__))
@pytest.fixture(scope="module")
def r():
proc = subprocess.run(
["node", os.path.join(HERE, "helper_520_3_move_stage.cjs")],
capture_output=True,
text=True,
encoding="utf-8",
timeout=60,
)
assert proc.returncode == 0, proc.stderr
return json.loads(proc.stdout)
def test_성공_잠금_단계_글_이동_뒤_풀림(r):
o = r["ok"]
assert o["ok"] is True and o["lock"] == 0 and o["lockSeen"] == 1
assert o["calls"][0] == "p:0.00:1/2 저장 · 0초"
assert o["calls"][1] == "p:0.50:2/2 완료 · 0초"
assert o["calls"][2] == "p:1.00:페이지 여는 중 · 0초"
assert o["calls"][-2:] == ["enter", "go:b08"]
def test_단계가_거짓이면_머묾_뒤_단계_안_돎(r):
o = r["stop"]
assert o["ok"] is False and o["lock"] == 0
assert not any(c.startswith("go:") or c == "완료 돎" for c in o["calls"])
def test_던지면_까닭_토스트_풀림_머묾(r):
o = r["fail"]
assert o["ok"] is False and o["lock"] == 0
assert "toast:error:저장에서 멈췄습니다 — Failed to fetch" in o["calls"]
assert not any(c.startswith("go:") for c in o["calls"])
def test_서버_진행_묻기_글_비율_함수_이동(r):
o = r["poll"]
assert o["asked"] >= 1 and o["lock"] == 0
assert any(c.startswith("p:0.50:1/1 서버 저장 — 관 차례 · 1초") for c in o["calls"])
assert o["calls"][-1] == "go:fn"
def test_넘기는_중_또_누름_무시(r):
assert r["busy"] is False
def test_페이지_셋이_공용_길():
for rel in (
"B07_DesignDetail/B07_DesignDetail_UI_Page.ts",
"B08_Quantity/B08_Quantity_UI_Page.ts",
"B09_Estimation/B09_Estimation_UI_Shell.ts",
):
page = (ROOT / rel).read_text(encoding="utf-8")
assert "void moveStage(projectId" in page and "goToWorkflowStage(" not in page, rel
+142 -2
View File
@@ -1,4 +1,10 @@
"""B07 종단도 — 장마다 그림 좌하단 = 작도영역 좌하단 · 고정 축척 · 넘치면 장을 더 나눔 (59-3)."""
"""B07 종단도 — 장마다 그림 좌하단 = 작도영역 좌하단 · 고정 축척 · 넘치면 장을 더 나눔 (59-3).
우측 상단 평면 위치도(521-2) — 칸 자리 · 그래프와 안 겹침 · 장마다 강조 구간 · 척도.
"""
import json
import math
import pytest
@@ -7,7 +13,21 @@ from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Long import (
build_longitudinal_drawing,
longitudinal_chunks,
)
from B07_DesignDetail.B07_DesignDetail_Engine_Template import entities_bbox, usable_area
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_LongPlan import (
LONG_PLAN_LAYER_ID,
plan_box_overlaps,
)
from B07_DesignDetail.B07_DesignDetail_Engine_Template import (
drawing_scales_path,
entities_bbox,
usable_area,
use_project_templates,
)
from B07_DesignDetail.B07_DesignDetail_Router_Support_Io import (
_read_manifest,
_write_manifest,
release_old_long_sheets,
)
def _longitudinal(count: int, interval: float, slope: float) -> dict:
@@ -57,3 +77,123 @@ def test_wide_interval_splits_more() -> None:
# 40 m 간격 30측점 = 1,160 mm > 작도영역 폭 → 30개보다 적게 담는다
chunks = longitudinal_chunks(_longitudinal(40, 40.0, 0.0))
assert len(chunks) > 2
# ── 우측 상단 평면 위치도(521-2 · 요구 340-2) ─────────────────────────────────
def _with_xy(longitudinal: dict) -> dict:
for station in longitudinal["stations"]:
chainage = station["chainage_m"]
station["center_x"] = 200000.0 + chainage
station["center_y"] = 500000.0 + 60.0 * math.sin(chainage / 150.0)
return longitudinal
def _plan_sheets(longitudinal: dict) -> list[dict]:
"""장마다 그래프 bbox · 칸 엔티티 · 굵은(이 장) 선 꼭짓점 · 글."""
sheets = []
for chunk in longitudinal_chunks(longitudinal):
entities = build_longitudinal_drawing(longitudinal, chunk["id"], chunk)["entities"]
graph = [e for e in entities if e["layerId"] not in (FRAME_LAYER_ID, LONG_PLAN_LAYER_ID)]
plan = [e for e in entities if e["layerId"] == LONG_PLAN_LAYER_ID]
bold = [
(child["shapeData"][key]["x"], child["shapeData"][key]["y"])
for entity in plan
for child in entity.get("children") or []
if child["lineWidth"] == 3
for key in ("startPoint", "endPoint")
]
texts = [e["shapeData"]["label"] for e in plan if e["type"] == "Text"]
sheets.append(
{
"graph": graph,
"bbox": entities_bbox(graph),
"plan": plan,
"bold": bold,
"texts": texts,
}
)
return sheets
def test_평면_칸_작도영역_우상단_안_겹침_장마다_강조() -> None:
longitudinal = _with_xy(_longitudinal(66, 20.0, 0.05))
sheets = _plan_sheets(longitudinal)
usable_w, usable_h = usable_area()
lows = []
for number, sheet in enumerate(sheets, start=1):
x0, y0 = sheet["bbox"][0], sheet["bbox"][1]
box = entities_bbox(sheet["plan"])
# 칸 = 작도 영역 우상단 5 mm 안쪽 150×100 mm
assert box == pytest.approx(
(x0 + usable_w - 155.0, y0 + usable_h - 105.0, x0 + usable_w - 5.0, y0 + usable_h - 5.0)
)
assert not plan_box_overlaps(longitudinal, sheet["graph"], sheet["bbox"])
assert f"{number}/{len(sheets)} 장" in sheet["texts"]
assert any(text.startswith("평면도 S=1/") for text in sheet["texts"])
assert sheet["bold"] # 이 장 구간 굵게
lows.append(min(x for x, _y in sheet["bold"]) - box[0])
# 자동 = 모든 장이 같은 축척 · 같은 자리 — 굵은 구간만 장마다 오른쪽으로 옮겨 감
assert lows == sorted(lows) and len(set(lows)) == len(lows)
def test_칸을_덮으면_측점_줄여_장_더_나눔() -> None:
# 25 m 간격 30 측점 = 725 mm(폭 안) · 오르막 그래프가 우상단 칸을 덮음
plain = longitudinal_chunks(_longitudinal(60, 25.0, 0.1))
with_box = _with_xy(_longitudinal(60, 25.0, 0.1))
assert longitudinal_chunks(with_box)[0]["end_m"] < plain[0]["end_m"]
for sheet in _plan_sheets(with_box):
assert not plan_box_overlaps(with_box, sheet["graph"], sheet["bbox"])
def test_좌표_없으면_칸_없음() -> None:
assert all(not sheet["plan"] for sheet in _plan_sheets(_longitudinal(40, 20.0, 0.0)))
def test_척도_정하면_이_장_확대_밖_흐린_파선(tmp_path) -> None:
longitudinal = _with_xy(_longitudinal(66, 20.0, 0.05))
use_project_templates(tmp_path)
try:
path = drawing_scales_path(tmp_path)
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps({"longitudinal": {"plan": 3000}}), encoding="utf-8")
sheets = _plan_sheets(longitudinal)
finally:
use_project_templates(None)
for sheet in sheets:
assert "평면도 S=1/3,000" in sheet["texts"]
assert any(entity.get("lineDash") for entity in sheet["plan"]) # 밖 노선 파선
box = entities_bbox(sheet["plan"])
xs = [x for x, _y in sheet["bold"]]
# 이 장 중심 = 칸 노선 자리 가운데
assert (min(xs) + max(xs)) / 2.0 == pytest.approx((box[0] + box[2]) / 2.0, abs=1.0)
def test_칸_앞_확정_종단만_한_번_풂(tmp_path) -> None:
drawings = tmp_path / "B07_DesignDetail" / "drawings"
drawings.mkdir(parents=True)
old = {"kind": "longitudinal", "confirmed": True, "file": "x"}
_write_manifest(
tmp_path,
{
"drawings": {
"longitudinal_1": dict(old),
"longitudinal_2": dict(old),
"cross_00020m": {"kind": "cross", "confirmed": True, "file": "x"},
}
},
)
for name in ("longitudinal_1", "longitudinal_2", "cross_00020m"):
(drawings / f"{name}.json").write_text('{"layers": []}', encoding="utf-8")
release_old_long_sheets(tmp_path)
after = _read_manifest(tmp_path)["drawings"]
assert not after["longitudinal_1"]["confirmed"] and not after["longitudinal_2"]["confirmed"]
assert after["cross_00020m"]["confirmed"]
assert not (drawings / "longitudinal_1.json").exists() # 옛 저장본 걷힘
# 새 확정본(칸 층 있음)은 그대로
after["longitudinal_1"] = dict(old)
_write_manifest(tmp_path, {"drawings": after})
(drawings / "longitudinal_1.json").write_text(
json.dumps({"layers": [{"id": LONG_PLAN_LAYER_ID}]}), encoding="utf-8"
)
release_old_long_sheets(tmp_path)
assert _read_manifest(tmp_path)["drawings"]["longitudinal_1"]["confirmed"]