feat(M02): 구조물 옵션 정의 = 명세 이름 고치기 칸(명세 있는 종류 · 52-11)

- 명세가 있는 종류(STRUCTURE_SPECS)는 컨테이너 제목 › 항목 이름 › 값 이름 나무 · 이름만 고침 · 키 · 필수/선택 · 꼴은 읽기만
- 필수 컨테이너 제목 = 정의 그룹 이름 자리 · 나머지 = 종류.이름 표(B05 · B06 그리기와 같은 차례)
- 미리보기 = B05 · B06 명세 폼에 고친 이름 · 빈 이름은 저장 막음 · [저장] = 새 판 + 이력(48장 길 그대로)
- 옛 편집기(옵션 · 배치 · 그룹 · 규칙 · 기준 측점)는 명세 종류에서 숨김 · 명세 없는 종류는 그대로
- 시험: test_m02_so_def_spec(정의 사본) · ui_checks/52-11_정의_이름고치기

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UoSr2sqvYtBf1dtQdus88s
This commit is contained in:
2026-10-05 23:42:11 +09:00
co-authored by Claude Opus 5.5
parent 9f3a00041d
commit 103a207cee
9 changed files with 501 additions and 15 deletions
@@ -0,0 +1,64 @@
/* M02 구조물 명세 이름 고치기(`M02_SO_Def_Spec_Model.ts` · PLAN 52-11) 단위검증 헬퍼 —
* TS 를 그 자리에서 트랜스파일해 Node 로 돌리고 결과를 JSON 으로 냄(pytest 가 판정).
* 정의는 진짜 `구조물정의.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 load = (...parts) => {
const js = ts.transpileModule(fs.readFileSync(path.join(ROOT, ...parts), "utf8"), {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
}).outputText;
const box = { exports: {} };
new Function("exports", "module", "require", js)(box.exports, box, require);
return box.exports;
};
const m = load("M02_MasterTemplete", "StructOptions", "M02_SO_Def_Spec_Model.ts");
const { PIPE_SPEC } = load("A00_Common", "design", "design_structure_pipe.ts");
const real = () =>
JSON.parse(
fs.readFileSync(path.join(ROOT, "resources", "master_template", "구조물정의.json"), "utf8"),
);
const out = {};
const def = real();
const rows = m.specNameRows(def, PIPE_SPEC);
out.levels = [0, 1, 2].map((lv) => rows.filter((r) => r.level === lv).length);
out.unique = new Set(rows.map((r) => r.nameKey)).size === rows.length;
out.pipeTitle = rows.find((r) => r.nameKey === "pipe");
out.optional = rows.find((r) => r.nameKey === "soil_add");
out.basinForm = rows.find((r) => r.nameKey === "inlet.form=I");
// 이름 고침 — 그룹 제목 · 항목 · 값 · 선택 컨테이너
const work = real();
const pick = (k) => m.specNameRows(work, PIPE_SPEC).find((r) => r.nameKey === k);
m.setSpecName(work, "pipe", pick("pipe"), "관 본체");
m.setSpecName(work, "pipe", pick("pipe.diameter"), "관 지름");
m.setSpecName(work, "pipe", pick("pipe.diameter=800"), "Ø800");
m.setSpecName(work, "pipe", pick("soil_add"), "흙막이 더");
out.groupName = work.그룹.pipe.find((g) => g.id === "grv6lny").이름;
out.names = ["pipe", "pipe.diameter", "pipe.diameter=800", "soil_add"].map(
(k) => work.종류.pipe.이름[k] ?? null,
);
out.reread = ["pipe", "pipe.diameter", "pipe.diameter=800", "soil_add"].map((k) => pick(k).label);
// 구조는 그대로 — types · 동작 · 다른 종류 · 이름 표 키 수(새 키 없음)
const before = real();
out.sameTypes = JSON.stringify(work.types) === JSON.stringify(before.types);
out.sameKeys =
Object.keys(work.종류.pipe.이름).length === Object.keys(before.종류.pipe.이름).length;
// 이름 표가 빈 종류 — 명세 글로 읽고 고치면 이름 표가 생김
const bare = real();
delete bare.종류.pipe.이름;
const bareRows = m.specNameRows(bare, PIPE_SPEC);
out.bareLabel = bareRows.find((r) => r.nameKey === "inlet.structure=기슭막이").label;
m.setSpecName(
bare,
"pipe",
bareRows.find((r) => r.nameKey === "inlet"),
"유입",
);
out.bareGroup = bare.그룹.pipe.find((g) => g.id === "gnbf9m9").이름;
process.stdout.write(JSON.stringify(out));
+58
View File
@@ -0,0 +1,58 @@
"""M02 구조물 명세 이름 고치기(`M02_SO_Def_Spec_Model.ts` · PLAN 52-11) 단위검증.
Node 헬퍼(`helper_m02_so_def_spec.cjs`)가 TS 를 트랜스파일해 진짜 정의 사본(메모리)으로 돌리고
pytest 는 그 결과(JSON)를 판정.
못박는 것
- 배관 명세 나무: 컨테이너 5 · 항목 · 값 줄 · 이름 키 겹침 없음 · 필수/선택 · 꼴 표시.
- 필수 컨테이너 제목은 정의 그룹 이름에 씀 · 항목 · 값 · 선택 컨테이너는 이름 표에 씀.
- 이름만 바뀜 — types(옵션 · 키) 그대로 · 이름 표에 없는 키를 새로 만들지 않음.
- 이름 표가 빈 종류는 명세 글로 읽음.
"""
from __future__ import annotations
import json
import subprocess
from pathlib import Path
import pytest
HERE = Path(__file__).resolve().parent
@pytest.fixture(scope="module")
def r() -> dict:
proc = subprocess.run(
["node", str(HERE / "helper_m02_so_def_spec.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) -> None:
assert r["levels"][0] == 5
assert r["levels"][1] > 5 and r["levels"][2] > 10
assert r["unique"] is True
assert r["pipeTitle"]["tag"] == "required" and r["pipeTitle"]["group"] == "grv6lny"
assert r["optional"]["tag"] == "optional" and r["optional"]["group"] is None
assert r["basinForm"]["label"] == "I형" and r["basinForm"]["key"] == "I"
def test_이름_고침_자리(r) -> None:
assert r["groupName"] == "관 본체"
assert r["names"] == [None, "관 지름", "Ø800", "흙막이 더"]
assert r["reread"] == ["관 본체", "관 지름", "Ø800", "흙막이 더"]
def test_구조는_그대로(r) -> None:
assert r["sameTypes"] is True and r["sameKeys"] is True
def test_이름_표_없는_종류(r) -> None:
assert r["bareLabel"] == "흙막이"
assert r["bareGroup"] == "유입"
@@ -0,0 +1,155 @@
/* 52-11 — M02 「구조물 옵션 정의」 = 구조물 명세 이름 고치기(명세 있는 종류) · B05 가 그 이름으로 그림
* 부르는 법: 단계마다 한 번씩 — 앞에 `window.__m5211 = "단계";` 를 붙여
* M02 마스터 층(#/m02-master-template?root=master) · 시스템 관리자:
* orca eval --expression "window.__m5211='m02'; $(cat 이 파일)"
* B05 종단설계(#/b05-profile) · 목록에 「배관 1」:
* orca eval --expression "window.__m5211='b05'; $(cat 이 파일)"
* 쓰는 것: 없음 — M02 는 화면 사본만 고치고 [저장] 안 누름 · 되돌린 뒤 판 그대로를 GET 으로 봄 ·
* B05 는 받아 둔 종류 객체(메모리)만 고침 · 끝나면 orca reload 로 사본을 버림
* 기대 수치(m02):
* - spec = 배관 나무 줄(컨테이너 5) · old = 옛 편집기 0(옵션 · 배치 · 그룹 · 규칙 · 기준 측점) · other = 명세 없는 종류는 옛 편집기
* - 이름 칸만 고침: inputs = 줄 수 · 키 · 꼴은 글(입력 칸 아님)
* - 컨테이너 제목 · 항목 이름 · 값 이름 고침 → 미리보기 legend · 칸 이름 · 선택지 글 따라감 · [저장] 켜짐
* - 빈 이름 → issues 「명세 이름」 · [저장] 꺼짐 · 되돌리면 이름 그대로 · 판 그대로
* 기대 수치(b05): 사본 이름(컨테이너 · 항목 · 값)이 배관 폼에 그대로 → named.* = true
*/
(async () => {
const MODE = window.__m5211 || "m02";
const W = (ms) => new Promise((r) => setTimeout(r, ms));
const $ = (s, root = document) => root.querySelector(s);
const $$ = (s, root = document) => [...root.querySelectorAll(s)];
const until = async (fn, ms = 10000) => {
for (let t = 0; t < ms; t += 150) {
const v = fn();
if (v) return v;
await W(150);
}
return null;
};
const put = (box, value) => {
box.value = value;
box.dispatchEvent(new Event("input", { bubbles: true }));
box.dispatchEvent(new Event("change", { bubbles: true }));
};
const NEW = { pipe: "관 본체X", "pipe.diameter": "관 지름X", "pipe.diameter=800": "Ø800X" };
const out = { mode: MODE };
if (MODE === "m02") {
const getDef = async () =>
(await fetch("/api/m02/structure-definition", { credentials: "include" })).json();
const first = await getDef();
out.판 = first.판;
const row = await until(() => $("[data-options-row]"));
if (!row) return JSON.stringify({ error: "정의 줄 없음" });
row.click();
if (!(await until(() => $$("[data-sod-kind]").length)))
return JSON.stringify({ error: "종류 목록 없음" });
// 명세 없는 종류 — 옛 편집기 그대로
$('[data-sod-kind="retaining_wall"]').click();
await W(200);
out.other = { base: !!$("[data-sod-base]"), spec: !!$("[data-sod-spec-block]") };
// 배관 — 명세 이름 나무
$('[data-sod-kind="pipe"]').click();
await W(200);
const block = $("[data-sod-spec-block]");
const detail = $("[data-sod-detail]");
const rows = $$("[data-sod-spec]", block);
out.spec = {
rows: rows.length,
containers: $$(".is-level-0", block).length,
inputs: $$("input, select, textarea", block).filter(
(n) => !n.closest("[data-sod-spec-preview]"),
).length,
nameInputs: $$("[data-sod-spec-name]", block).length,
keysReadOnly: rows.every((r) => r.querySelector("small.m02-opts__key")),
};
out.old = {
base: !!$("[data-sod-base]", detail),
blocks: detail.children.length, // 머리 칸 + 명세 칸 = 2
};
const save = $("[data-sod-save]");
out.saveBefore = save.disabled;
const box = (k) => $(`[data-sod-spec="${k}"] [data-sod-spec-name]`);
const orig = Object.fromEntries(Object.keys(NEW).map((k) => [k, box(k).value]));
for (const [k, v] of Object.entries(NEW)) {
put(box(k), v);
await W(150);
}
const preview = $("[data-sod-spec-preview]");
const pipeBox = $('[data-spec-container="pipe"]', preview);
out.preview = {
title: pipeBox.querySelector("legend").textContent.includes(NEW.pipe),
item: pipeBox
.querySelector('[data-spec-item="pipe.diameter"]')
.textContent.includes(NEW["pipe.diameter"]),
choice: [...pipeBox.querySelectorAll('[data-spec-item="pipe.diameter"] option')].some(
(o) => o.textContent === NEW["pipe.diameter=800"],
),
};
out.saveAfterEdit = $("[data-sod-save]").disabled;
// 빈 이름 → 저장 막힘
put(box("pipe.diameter"), "");
await W(150);
out.blank = {
issue: $("[data-sod-issues]").textContent.includes("명세 이름"),
save: $("[data-sod-save]").disabled,
};
// 되돌림
for (const [k, v] of Object.entries(orig)) {
put(box(k), v);
await W(150);
}
out.restored = Object.entries(orig).every(([k, v]) => box(k).value === v);
out.판After = (await getDef()).판;
out.ok =
out.other.base &&
!out.other.spec &&
out.spec.containers === 5 &&
out.spec.inputs === out.spec.rows &&
out.spec.nameInputs === out.spec.rows &&
out.spec.keysReadOnly &&
!out.old.base &&
out.old.blocks === 2 &&
out.preview.title &&
out.preview.item &&
out.preview.choice &&
!out.saveAfterEdit &&
out.blank.issue &&
out.blank.save &&
out.restored &&
out.판After === out.판;
} else {
const sec = $(".b05-structure-section");
if (!sec) return JSON.stringify({ error: "구조물 배치 칸 없음" });
const url = performance
.getEntriesByType("resource")
.map((e) => e.name)
.filter((n) => n.includes("/B05_Profile/B05_Profile_Api_Structures.ts"))
.pop();
const api = await import(url);
const pipe = (await api.fetchStructureTypes(true)).find((t) => t.type_id === "pipe");
pipe.spec_names = { ...(pipe.spec_names ?? {}), ...NEW };
await until(() => $$(".b05-route__irregular-list li").length);
const li = $$(".b05-route__irregular-list li").find((l) =>
/배관 1$/.test(l.textContent.trim()),
);
const spec = () => $(".b05-structure__spec", sec);
(li.querySelector("button") ?? li).click();
await W(800);
if (!(spec() && spec().offsetParent !== null)) (li.querySelector("button") ?? li).click();
await until(() => spec() && spec().offsetParent !== null);
const pipeBox = $('[data-spec-container="pipe"]', spec());
out.named = {
title: pipeBox.querySelector("legend").textContent.includes(NEW.pipe),
item: pipeBox
.querySelector('[data-spec-item="pipe.diameter"]')
.textContent.includes(NEW["pipe.diameter"]),
choice: [...pipeBox.querySelectorAll('[data-spec-item="pipe.diameter"] option')].some(
(o) => o.textContent === NEW["pipe.diameter=800"],
),
};
(li.querySelector("button") ?? li).click(); // 재선택 = 해제
out.ok = Object.values(out.named).every(Boolean);
}
return JSON.stringify(out);
})();