feat(구조물표): 323-7 새 종류 · 정리 — M02 종류 더하기(본보기 복사 · 일곱 갈래 본보기 그대로) · 손코딩 동작 · 명세 = 본보기 키 · 특수 동작 = 동작 이름 · 정본 목록 = 표 · drawing_views 걷음 · 옛 이름 되짚기 걷음(자리로 가름) · 허용 거리 파이썬 한 곳 · 벽 툴팁 = 종류 이름

- M02 종류별 탭 [종류 더하기] = 본보기(구조물 정본 원본) 복사 — 키 자동 · 이름 「본보기 2」 · 옵션 · 표시 · 그룹 · 배치 · 규칙 · 종류 덧칸 · 반영 그대로 · 종류.{새}.본보기 · 관 지점 종류 · 복사본은 본보기 밖
- 구조물 표 = 본보기 처음값 이어받음(resolveCatalog · resolve_catalog 짝 · 줄 칸 base) · baseType/base_type · specFor(명세) · 덧단 · 길이 전후 풀기 · 옹벽 추가 단 콘크리트
- 검사(화면 = 서버 짝) — 본보기 = 원본 · 같은 군 · 자리꼴 · 정본 · 만든 뒤 고정 · 복사본은 본보기가 숨긴 칸 이어받음 · 서버 덧칸 본보기 받음
- 특수 동작 = 동작 이름 — 바로 안 놓기 · 포장 나누기 = 포장_물넘이_겹침 · 월류 높이 기다림 = 월류단면
- 정본 목록 = 표 — 파이썬 관 지점 종류 = catalog owner · B05 시설 서브폼 = 표 형상 · 안 쓰는 손 목록 걷음
- drawing_views — 스키마에서 빼고 읽을 때 버림 · M02 [저장] 때 빠짐(코드는 마스터 안 고침)
- 이관 = 지난 판 이름 대신 측점 계획 자리(planted)로 가름 · all_type_names 걷음
- 허용 거리 = common_util_structure_station.py 한 곳(SAME_STATION_M · EDGE_TOLERANCE_M · PIPE_MATCH_M) · TS 표는 station.ts 머리
- 시험: test_323_7_new_type(템플릿 · 일곱 갈래 · 그림 · 검사 짝 · 서버 읽기) · 322-0 유령 시험 = 자리 · 옛 도움 파일 동작 · 표 길 · 기대 글 고침

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ji4nuyLVHcxrXWHTdb5nRD
This commit is contained in:
2026-10-14 18:59:13 +09:00
co-authored by Claude Opus 5.5
parent 6744524669
commit 81f7d98e6f
48 changed files with 595 additions and 169 deletions
@@ -49,7 +49,17 @@ const { addsAtOnce, finishAdd } = loadTs(
const { loadForm } = loadTs(path.join(B05, "B05_Profile_UI_Structures_Panel_Commit.ts"));
const entry = (required, value) => ({ required, isEmpty: () => value === "" });
const type = (type_id, managed_by = null) => ({ type_id, group: "C", managed_by });
// 동작 = 정의 종류 덧칸(앱의 `withDefinition` 과 같음 · 323-7 종류 키 대신 동작 이름)
const DEFINED = JSON.parse(
fs.readFileSync(path.join(ROOT, "resources", "master_template", "구조물정의.json"), "utf8"),
);
const behaviors = (typeId) => DEFINED.종류?.[typeId]?.동작 ?? [];
const type = (type_id, managed_by = null) => ({
type_id,
group: "C",
managed_by,
behaviors: behaviors(type_id),
});
/** 목록 알림 → 페이지가 임시 id 를 붙여 패널 배열 안을 갈아 채우는 것(`withLocalIds` · `setStructures`). */
function fakeCommit(structures, adds) {
+18 -2
View File
@@ -43,7 +43,16 @@ const { fordHeightM } = loadTs(
path.join(ROOT, "A00_Common", "design", "design_structure_ford_calc.ts"),
);
const type = (type_id) => ({ type_id, group: "A", managed_by: "pipe_points" });
// 동작 = 정의 종류 덧칸(앱의 `withDefinition` 과 같음 · 323-7 종류 키 대신 동작 이름)
const DEFINED = JSON.parse(
fs.readFileSync(path.join(ROOT, "resources", "master_template", "구조물정의.json"), "utf8"),
);
const type = (type_id) => ({
type_id,
group: "A",
managed_by: "pipe_points",
behaviors: DEFINED.종류?.[type_id]?.동작 ?? [],
});
/** 우클릭 → 바로 추가 — 관 지점 정본에 한 번 넘기고 그 측점 관을 고름 · 알림 한 줄. */
async function rightClick(typeId) {
@@ -116,7 +125,14 @@ function respond(points, basins, pending) {
once: {
fordBridge: addsAtOnce(type("ford_bridge"), []),
fordPavement: addsAtOnce(type("ford_pavement"), []),
pavement: addsAtOnce({ type_id: "pavement_concrete", managed_by: null }, []),
pavement: addsAtOnce(
{
type_id: "pavement_concrete",
managed_by: null,
behaviors: DEFINED.종류?.pavement_concrete?.동작 ?? [],
},
[],
),
},
bridge: await rightClick("ford_bridge"),
paveRc: await rightClick("ford_pavement"),
@@ -4,6 +4,7 @@
*/
const fs = require("fs");
const path = require("path");
const CATALOG = require("./helper_322_catalog_module.cjs"); // 구조물 표(322-1)
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
const ROOT = path.join(__dirname, "..", "..");
@@ -18,6 +19,7 @@ function loadTs(file) {
const box = { exports: {} };
cache.set(full, box.exports);
const localRequire = (request) => {
if (CATALOG.isCatalogRequest(request)) return CATALOG; // 구조물 표(본보기 · 323-7)
if (request.startsWith(".")) return loadTs(path.join(path.dirname(full), `${request}.ts`));
return require(request);
};
+104
View File
@@ -0,0 +1,104 @@
/* PLAN 323-7 종류 더하기 헬퍼 — 진짜 `M02_SO_Def_Add.ts`(본보기 복사) · 구조물 표 · 측점 계획 · 형상 얹기 · 벽 단 ·
* 명세 고르기 · M02 검사를 그 자리에서 트랜스파일해 Node 로 돌림(마스터는 읽기만 · 사본에서 더함) · 결과 = JSON.
* 짝 = `test_323_7_new_type.py`(파이썬 검사 · 표 · 벽 제원이 같은 사본을 읽음).
*/
const fs = require("fs");
const path = require("path");
const ROOT = path.join(__dirname, "..", "..");
const ts = require(path.join(ROOT, "config", "node_modules", "typescript"));
const cache = new Map();
function load(file) {
const full = path.resolve(file);
if (cache.has(full)) return cache.get(full);
if (full.endsWith(".json")) return JSON.parse(fs.readFileSync(full, "utf8"));
const js = ts.transpileModule(fs.readFileSync(full, "utf8"), {
compilerOptions: {
module: ts.ModuleKind.CommonJS,
target: ts.ScriptTarget.ES2020,
esModuleInterop: true,
},
}).outputText;
const box = { exports: {} };
cache.set(full, box.exports);
const need = (spec) => {
if (spec.startsWith("@ui/")) return new Proxy({}, { get: () => () => {} });
const base = path.join(path.dirname(full), spec);
const hit = [`${base}.ts`, base.replace(/\.js$/, ".ts"), base].find(
(p) => fs.existsSync(p) && fs.statSync(p).isFile(),
);
if (!hit) throw new Error(`모름: ${spec} (${full})`);
return load(hit);
};
new Function("exports", "module", "require", js)(box.exports, box, need);
return box.exports;
}
const C = (name) => path.join(ROOT, "common_util", `${name}.ts`);
const M = (name) => path.join(ROOT, "M02_MasterTemplete", "StructOptions", `${name}.ts`);
const master = JSON.parse(
fs.readFileSync(path.join(ROOT, "resources", "master_template", "구조물정의.json"), "utf8"),
);
const def = JSON.parse(JSON.stringify(master));
const add = load(M("M02_SO_Def_Add"));
const catalog = load(C("common_util_structure_catalog"));
const station = load(C("common_util_structure_station"));
const attach = load(C("common_util_structure_attach"));
const walls = load(C("common_util_structure_walls"));
const pipeSpecs = load(path.join(ROOT, "A00_Common", "design", "design_structure_pipe.ts"));
const check = load(M("M02_SO_Def_Check"));
const out = {};
out.templates = add.templates(def).map(([key]) => key);
out.pipeRefused = add.addFromTemplate(def, "pipe");
const wall = add.addFromTemplate(def, "retaining_wall");
const pave = add.addFromTemplate(def, "pavement_concrete");
out.keys = [wall, pave];
const types = def.types.map((t) => ({ managed_by: null, enabled: true, ...t }));
catalog.useCatalogDefinition({ ...def, types });
const BRANCHES = [
"list",
"station",
"title",
"shape",
"shapeOpts",
"model3d",
"placement",
"owner",
];
const pick = (row) => Object.fromEntries(BRANCHES.map((k) => [k, row[k]]));
out.rows = {
wall: [pick(catalog.catalogRow("retaining_wall")), pick(catalog.catalogRow(wall))],
pave: [pick(catalog.catalogRow("pavement_concrete")), pick(catalog.catalogRow(pave))],
};
out.base = [catalog.baseType(wall), catalog.baseType("retaining_wall")];
out.names = [
types.find((t) => t.type_id === wall).name,
types.find((t) => t.type_id === pave).name,
];
const structure = {
structure_id: "n1",
type_id: wall,
placement: "interval",
start_m: 40,
chainage_m: 50,
end_m: 60,
options: { height_m: 2, tier_1_height: 1 },
};
out.plan = station.planStations([], [structure], types).map((s) => [s.chainage_m, s.title]);
const sections = [40, 50, 60].map((at) => ({ chainage_m: at }));
attach.attachShapes(sections, [], [structure], types);
out.attached = sections.map((s) => [s.revetment?.type_id ?? null, s.revetment?.name ?? null]);
out.tier = walls.tierExtras(wall, { tier_1_height: 1 });
out.spec =
!!pipeSpecs.specFor(pave) && pipeSpecs.specFor(pave) === pipeSpecs.specFor("pavement_concrete");
out.problems = check.catalogProblems(def, master);
// 본보기 바꿈 · 복사본의 복사 = 거절(만든 뒤 고정 · 원본만)
const bad = JSON.parse(JSON.stringify(def));
bad.종류[wall].본보기 = "soil_guard";
out.badBase = check.catalogProblems(bad, def).length > 0;
const chain = JSON.parse(JSON.stringify(def));
chain.종류[pave].본보기 = wall;
out.chain = check.catalogProblems(chain, null).length > 0;
out.def = def;
process.stdout.write(JSON.stringify(out));
@@ -5,6 +5,7 @@
*/
const fs = require("fs");
const path = require("path");
const CATALOG = require("./helper_322_catalog_module.cjs"); // 구조물 표(322-1)
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
const ROOT = path.join(__dirname, "..", "..");
@@ -19,6 +20,7 @@ function loadTs(file) {
const box = { exports: {} };
cache.set(full, box.exports);
const localRequire = (request) => {
if (CATALOG.isCatalogRequest(request)) return CATALOG; // 구조물 표(본보기 · 323-7)
if (request.startsWith(".")) return loadTs(path.join(path.dirname(full), `${request}.ts`));
return require(request);
};
+6 -3
View File
@@ -3,6 +3,7 @@
* 정의는 진짜 `구조물정의.json` 을 읽어 메모리 사본만 고침(파일 쓰기 없음). */
const fs = require("fs");
const path = require("path");
const CATALOG = require("./helper_322_catalog_module.cjs"); // 구조물 표(322-1)
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
const ROOT = path.join(__dirname, "..", "..");
@@ -16,9 +17,11 @@ const load = (...parts) => {
}).outputText;
const box = (cache[file] = { exports: {} });
const near = (id) =>
id.startsWith(".")
? load(path.relative(ROOT, path.resolve(path.dirname(file), `${id}.ts`)))
: require(id);
CATALOG.isCatalogRequest(id) // 구조물 표(본보기 · 323-7)
? CATALOG
: id.startsWith(".")
? load(path.relative(ROOT, path.resolve(path.dirname(file), `${id}.ts`)))
: require(id);
new Function("exports", "module", "require", js)(box.exports, box, near);
return box.exports;
};
+2 -1
View File
@@ -128,7 +128,8 @@ def test_횡단_형상_단_수_단마다_형태_높이(out):
def test_배선():
spec = _read("B05_Profile", "B05_Profile_UI_Structures_Spec.ts")
assert "STRUCTURE_SPECS[type.type_id] ?? ADDON_SPECS[type.type_id]" in spec
# 명세 · 덧붙이 명세 = 본보기 키로(323-7 · 복사한 종류도 같은 상자)
assert "specFor(type.type_id) ?? ADDON_SPECS[baseType(type.type_id)]" in spec
walls = _read("common_util", "common_util_structure_walls.ts")
assert "파이썬 `tier_extras` 짝" in walls
engine = _read("B06_Section", "B06_Section_Engine_Structures_Wall.py")
+16 -21
View File
@@ -1,7 +1,8 @@
"""PLAN 322-0 ② — 「기타」 유령 길 막기: 종류 이름을 바꾼 뒤에도 옛 이름 측점을 구조물로 되옮기지 않음.
"""PLAN 322-0 ② → 323-7 — 「기타」 유령 길 막기: 종류 이름을 바꾼 뒤에도 옛 이름 측점을 안 되옮김.
정의 사본(임시 폴더 · 마스터 안 고침)에서 종류 이름을 바꾸고 앞 판을 `구조물정의_이력/판_N.json` 으로 둠 →
옛 이름 · 새 이름 측점 모두 이관 0건. 옛 꼴 자유 글(기성막이 · 대피로 폭 · 임의 이름)은 종전대로 옮김.
정의 사본(임시 폴더 · 마스터 안 고침)에서 종류 이름을 바꿈 → 지금 관 · 구조물이 심는 자리(측점
계획)의 측점은 옛 이름 · 새 이름 모두 이관 0건(323-7 · 자리로 가름 · 지난 판 이름 되짚기 걷음).
옛 꼴 자유 글(기성막이 · 대피로 폭 · 임의 이름)은 심는 자리가 아니면 종전대로 옮김.
"""
from __future__ import annotations
@@ -37,23 +38,25 @@ def renamed(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> dict:
return old
def _moved(label: str) -> int:
plan = migrate_irregular_stations([{"chainage_m": 120.0, "structure": label}])
def _moved(label: str, planted: tuple[float, ...] = ()) -> int:
plan = migrate_irregular_stations([{"chainage_m": 120.0, "structure": label}], planted)
return len(plan.structures) + len(plan.pipe_facilities)
def test_old_and_new_names_are_not_migrated(renamed: dict) -> None:
def test_old_and_new_names_at_planted_spots_are_not_migrated(renamed: dict) -> None:
# 323-7 — 자리로 가름(지금 관 · 구조물이 심는 자리) · 이름을 바꿔도 자리는 그대로
assert schema.structure_type_map()["box_culvert"].name == "박스암거" # 사본을 읽음
for type_id, name in RENAMED.items():
old_name = next(t["name"] for t in renamed["types"] if t["type_id"] == type_id)
assert _moved(old_name) == 0, old_name
assert _moved(name) == 0, name
assert _moved(old_name, (120.0,)) == 0, old_name
assert _moved(name, (120.0,)) == 0, name
assert _moved(name) == 0, name # 지금 판 이름은 자리 없이도
def test_every_type_name_of_any_version_is_blocked(renamed: dict) -> None:
names = {t["name"] for t in renamed["types"]} | set(RENAMED.values())
assert names <= schema.all_type_names()
assert [name for name in sorted(names) if _moved(name)] == []
def test_history_names_are_not_read(renamed: dict) -> None:
# 옛 이름 되짚기 걷음(323-7) — 자리를 못 받은 옛 이름 = 옛 자유 글처럼 「기타」
assert not hasattr(schema, "all_type_names")
assert _moved("노출형 횡단수로") == 1
def test_legacy_free_text_still_migrates(renamed: dict) -> None:
@@ -62,12 +65,4 @@ def test_legacy_free_text_still_migrates(renamed: dict) -> None:
assert [s.type_id for s in plan.structures] == ["refuge"]
plan = migrate_irregular_stations([{"chainage_m": 15.0, "structure": "돌망태 보강"}])
assert [(s.type_id, s.options["name"]) for s in plan.structures] == [("etc", "돌망태 보강")]
def test_master_history_names_are_read() -> None:
"""실제 마스터 이력(판_1 ~)의 이름도 모음 — 이력 파일은 읽기만."""
history = MASTER.parent / schema.HISTORY_DIRNAME
names = set()
for path in history.glob("판_*.json"):
names |= {t["name"] for t in json.loads(path.read_text(encoding="utf-8"))["types"]}
assert names <= schema.all_type_names()
assert _moved("돌망태 보강", (120.004,)) == 0 # 심는 자리면 글이 무엇이든 안 옮김
+106
View File
@@ -0,0 +1,106 @@
"""PLAN 323-7 종류 더하기 — 본보기 복사 한 종류가 일곱 갈래(목록 · 횡단도 · 카드이름 · 형상 · 형상칸 · 3D · 자리꼴)
를 본보기 그대로 그림 · 손코딩 동작 · 명세는 본보기 키로(`baseType`) · 검사는 화면 = 서버.
마스터는 읽기만 — 사본(헬퍼가 더한 정의)을 임시 파일로 두고 서버 쪽 읽기를 그리로 돌림.
TS 쪽 = `helper_323_7_new_type.cjs`(Node).
"""
from __future__ import annotations
import json
import subprocess
from pathlib import Path
import pytest
from B05_Profile import B05_Profile_Structures_Schema as schema
from B05_Profile.B05_Profile_Structures_Schema import StructureInstance
from B06_Section.B06_Section_Engine_Structures_Wall import wall_specs_from
from common_util import common_util_structure_catalog as catalog
from M02_MasterTemplete.StructOptions import M02_SO_Definition as definition
HERE = Path(__file__).parent
ROOT = HERE.parent.parent
pytestmark = pytest.mark.skipif(
not (ROOT / "config" / "node_modules").is_dir(), reason="프론트엔드 의존성 미설치"
)
@pytest.fixture(scope="module")
def ts() -> dict:
run = subprocess.run(
["node", str(HERE / "helper_323_7_new_type.cjs")],
capture_output=True,
text=True,
encoding="utf-8",
timeout=120,
check=True,
)
return json.loads(run.stdout)
@pytest.fixture()
def copied(ts: dict, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> dict:
path = tmp_path / "구조물정의.json"
path.write_text(json.dumps(ts["def"], ensure_ascii=False), encoding="utf-8")
monkeypatch.setattr(schema, "DEFINITION_PATH", str(path))
monkeypatch.setattr(catalog, "DEFINITION_PATH", str(path), raising=False)
return ts["def"]
def test_only_structure_originals_are_templates(ts: dict) -> None:
assert "pipe" not in ts["templates"] and "retaining_wall" in ts["templates"]
assert ts["pipeRefused"] is None
def test_seven_branches_follow_template(ts: dict) -> None:
for name, (source, copy) in ts["rows"].items():
assert copy == source, name
assert ts["base"] == ["retaining_wall", "retaining_wall"]
assert ts["names"] == ["옹벽 2", "콘크리트 포장 2"]
def test_new_type_draws_like_template(ts: dict) -> None:
wall = ts["keys"][0]
assert ts["plan"] == [[40, "옹벽 2"], [50, "옹벽 2"], [60, "옹벽 2"]]
assert ts["attached"] == [[wall, "옹벽 2"]] * 3
assert ts["tier"] == [
{"form": "콘크리트", "height_m": 1}
] # 옹벽 추가 단 = 콘크리트(본보기 동작)
assert ts["spec"] is True # 포장 명세 = 본보기 명세
def test_check_screen_equals_server(ts: dict, copied: dict) -> None:
master = json.loads(Path(definition.schema.DEFINITION_PATH).read_text(encoding="utf-8"))
assert ts["problems"] == []
real = json.loads(
(ROOT / "resources" / "master_template" / "구조물정의.json").read_text("utf-8")
)
assert definition.check(copied, real) == []
assert ts["badBase"] is True and ts["chain"] is True
bad = json.loads(json.dumps(copied))
bad["종류"][ts["keys"][0]]["본보기"] = "soil_guard"
assert any("본보기" in e for e in definition.check(bad, copied))
assert master["types"] # 사본을 읽음
def test_server_reads_copy_like_template(ts: dict, copied: dict) -> None:
wall = ts["keys"][0]
rows = catalog.resolve_catalog(copied)
keep = ("list", "station", "title", "shape", "shapeOpts", "model3d", "placement", "owner")
assert {k: rows[wall][k] for k in keep} == {k: rows["retaining_wall"][k] for k in keep}
assert rows[wall]["base"] == "retaining_wall"
item = StructureInstance.model_validate(
{
"structure_id": "n1",
"type_id": wall,
"placement": "interval",
"start_m": 40,
"chainage_m": 50,
"end_m": 60,
"options": {"height_m": 2, "tier_1_height": 1},
}
)
(spec,) = wall_specs_from([item])
assert spec["name"] == "옹벽 2" and spec["extras"][0]["form"] == "콘크리트"
@@ -110,9 +110,18 @@ def test_폼이_적는_칸이_모두_목록에_있다(merged) -> None:
"""폼에 칸을 늘리고 목록에 안 넣으면 여기서 잡힘."""
source = FORM_TS.read_text(encoding="utf-8")
body = source[source.index("readOptions() {") :]
branches = re.split(r'current === "(\w+)"', body)[1:]
assert branches[::2] == ["pipe", "box_culvert", "ford_pavement", "ford_bridge", "revetment"]
for facility, block in zip(branches[::2], branches[1::2]):
# 폼 갈래 = 구조물 표 형상(323-7 · 종류 키 대신) → 관 지점 종류
branches = re.split(r'shapeOf\(current\) === "(\w+)"', body)[1:]
kinds = {
"pipe_set": "pipe",
"box": "box_culvert",
"ford_pavement": "ford_pavement",
"ford": "ford_bridge",
"hidden_revet": "revetment",
}
assert branches[::2] == list(kinds)
for shape, block in zip(branches[::2], branches[1::2]):
facility = kinds[shape]
written = set(re.findall(r'putNumber\(options, "(\w+)"', block))
written |= set(re.findall(r"options\.(\w+) =", block))
missing = written - set(merged["keys"][facility])
@@ -92,8 +92,11 @@ def test_화면_고침을_그대로_받아_판_더하기_1_지난_판_길(copy)
assert res.status_code == 200, res.text
got = res.json()
assert got["판"] == doc["판"] + 1
for key in ("구조물군", "종류", "배치", "표시", "그룹", "규칙", "types"):
for key in ("구조물군", "종류", "배치", "표시", "그룹", "규칙"):
assert got[key] == sent[key], key
# 옛 칸(drawing_views)은 [저장] 때 빠짐(323-7 · 아무도 안 읽음) — 나머지는 그대로
retired = schema.without_retired
assert got["types"] == [retired(t) for t in sent["types"]]
assert list(got["구조물군"]) == list(sent["구조물군"]) # 순서 그대로
rows = admin.get(f"{URL}/history").json()
assert [r["판"] for r in rows] == [doc["판"]] and len(rows[0]["저장일"]) == 16