feat(M02): 48-2 구조물 옵션 정의 편집 화면 — 군 · 종류 · 옵션 · 선택지 · 배치 격자 · 그룹 형상 · 규칙 · 판 이력
- 옛 「옵션 보이기 · 그룹」 화면을 정의 편집 화면으로 넓힘(시스템 관리자 고쳐 씀 · 다른 역할 읽기만) - 자료 손질 = M02_SO_Def_Model.ts(정의 틀 48-1 꼴) · 시험 test_m02_so_def_model.py Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016PSXsvyDxjNmufteN5bqmt
This commit is contained in:
@@ -0,0 +1,112 @@
|
||||
/* M02 구조물 정의 편집 화면 자료 손질(`M02_SO_Def_Model.ts` · PLAN 48-2) 단위검증 헬퍼 —
|
||||
* TS 를 그 자리에서 트랜스파일해 Node 로 돌리고 결과를 JSON 으로 냄(pytest 가 판정). */
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
|
||||
|
||||
const source = fs.readFileSync(
|
||||
path.join(__dirname, "..", "..", "M02_MasterTemplete", "StructOptions", "M02_SO_Def_Model.ts"),
|
||||
"utf8",
|
||||
);
|
||||
const js = ts.transpileModule(source, {
|
||||
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
|
||||
}).outputText;
|
||||
const box = { exports: {} };
|
||||
new Function("exports", "module", "require", js)(box.exports, box, require);
|
||||
const m = box.exports;
|
||||
|
||||
const sel = (key, choices) => ({ key, label: key, input: "select", choices: [...choices] });
|
||||
const type = () => ({
|
||||
type_id: "k1",
|
||||
group: "A",
|
||||
name: "배수관",
|
||||
options: [sel("a", ["x", "y"]), sel("b", ["p", "q"]), { key: "c", label: "c", input: "number" }],
|
||||
});
|
||||
const defOf = (over = {}) => ({
|
||||
판: 3,
|
||||
구조물군: { A: "A군", B: "B군" },
|
||||
동작: {},
|
||||
types: [
|
||||
type(),
|
||||
{ ...type(), type_id: "k2", name: "둘째" },
|
||||
{ ...type(), type_id: "k3", group: "B", name: "셋째" },
|
||||
],
|
||||
종류: {},
|
||||
표시: {},
|
||||
그룹: {},
|
||||
배치: {},
|
||||
규칙: [],
|
||||
...over,
|
||||
});
|
||||
const cells = (t, spots) => Object.fromEntries(m.placeAll(t.options, spots));
|
||||
|
||||
const out = {};
|
||||
// 자리 안 정한 옵션 = 차례대로 채움 · 두 칸이면 셋째는 둘째 줄
|
||||
out.auto = cells(type());
|
||||
// 정한 자리 먼저 · 남은 것은 빈 칸으로 · 너비는 칸 수를 안 넘음
|
||||
out.fixed = cells(type(), { c: { 줄: 1, 칸: 1, 너비: 5 } });
|
||||
// 안 씀은 격자에서 빠짐
|
||||
const off = type();
|
||||
off.options[0].enabled = false;
|
||||
out.off = cells(off);
|
||||
// 겹침
|
||||
out.overlap = m
|
||||
.overlaps(
|
||||
m.placeAll(type().options, { a: { 줄: 1, 칸: 1, 너비: 2 }, b: { 줄: 1, 칸: 2, 너비: 1 } }),
|
||||
)
|
||||
.sort();
|
||||
// 규칙 — 맞는 줄 · 없는 값 · 자기 자신 · 없는 하위 선택지 · 그러면 비었음 · 고르기 아닌 상위
|
||||
const rd = defOf();
|
||||
const R = (만약, 그러면) => ({ 종류: "k1", 만약, 그러면 });
|
||||
out.rules = [
|
||||
R({ 옵션: "a", 값들: ["x"] }, { 옵션: "b", 선택지: ["p"], 기본값: "p" }),
|
||||
R({ 옵션: "a", 값들: ["zz"] }, { 옵션: "b", 보이기: false }),
|
||||
R({ 옵션: "a", 값들: ["x"] }, { 옵션: "a", 보이기: false }),
|
||||
R({ 옵션: "a", 값들: ["y"] }, { 옵션: "b", 선택지: ["nope"] }),
|
||||
R({ 옵션: "a", 값들: ["x"] }, { 옵션: "b" }),
|
||||
R({ 옵션: "c", 값들: ["3"] }, { 옵션: "b", 보이기: false }),
|
||||
R({ 옵션: "a", 값들: ["x"] }, { 옵션: "b", 선택지: ["p"], 기본값: "q" }),
|
||||
].map((r) => m.ruleBad(rd, r));
|
||||
// 이름 — 빈 이름 · 겹침
|
||||
out.names = m.badNames(["가", " ", "가"]);
|
||||
// 저장 전 검사 — 깨끗한 정의는 빈 목록 · 겹침 · 이름 틀림 · 빈 선택지는 까닭
|
||||
out.clean = m.problems(defOf());
|
||||
const broken = defOf({
|
||||
배치: { k1: { a: { 줄: 1, 칸: 1, 너비: 2 }, b: { 줄: 1, 칸: 2, 너비: 1 } } },
|
||||
});
|
||||
broken.types[0].options[1].label = "a";
|
||||
broken.types[1].options[0].choices = [];
|
||||
out.broken = m.problems(broken);
|
||||
// 선택지 이름 바꿈 · 뺌 — 기본값 · 규칙 같이
|
||||
const rn = defOf({
|
||||
규칙: [R({ 옵션: "a", 값들: ["x", "y"] }, { 옵션: "b", 선택지: ["p"], 기본값: "p" })],
|
||||
});
|
||||
const a = rn.types[0].options[0];
|
||||
const b = rn.types[0].options[1];
|
||||
a.default = "x";
|
||||
m.renameChoice(rn, "k1", a, "x", "엑스");
|
||||
m.renameChoice(rn, "k1", b, "p", "피");
|
||||
out.rename = [
|
||||
a.choices,
|
||||
a.default,
|
||||
rn.규칙[0].만약.값들,
|
||||
rn.규칙[0].그러면.선택지,
|
||||
rn.규칙[0].그러면.기본값,
|
||||
];
|
||||
m.dropChoice(rn, "k1", b, "피");
|
||||
out.drop = [b.choices, rn.규칙[0].그러면.선택지, "기본값" in rn.규칙[0].그러면];
|
||||
// 순서 — 구조물군 키 차례 · 종류는 같은 군 안에서만
|
||||
const od = defOf();
|
||||
out.family = [m.stepFamily(od, "A", -1), m.stepFamily(od, "A", 1), Object.keys(od.구조물군)];
|
||||
out.type = [m.stepType(od, "k2", 1), m.stepType(od, "k1", 1), od.types.map((t) => t.type_id)];
|
||||
out.base = [
|
||||
m.baseShown(defOf(), "k1"),
|
||||
m.baseShown(defOf({ 종류: { k1: { 기준측점: false } } }), "k1"),
|
||||
];
|
||||
// 목록 순서 바꿈 · 끝은 그대로
|
||||
const list = [1, 2, 3];
|
||||
out.step = [m.step(list, 0, -1), m.step(list, 0, 1), list];
|
||||
// 새 키 — 머리글 · 안 겹침 · 영문 소문자 숫자
|
||||
const k = m.newKey("opt_", ["opt_aaaaaa"]);
|
||||
out.newKey = [/^opt_[0-9a-z]{4,}$/.test(k), k !== "opt_aaaaaa"];
|
||||
process.stdout.write(JSON.stringify(out));
|
||||
@@ -0,0 +1,87 @@
|
||||
"""M02 구조물 정의 편집 화면 자료 손질(`M02_SO_Def_Model.ts` · PLAN 48-2) 단위검증.
|
||||
|
||||
프론트에 JS 테스트 러너가 없어 Node 헬퍼(`helper_m02_so_def_model.cjs`)가 TS 를 트랜스파일해 돌리고
|
||||
pytest 는 그 결과(JSON)를 판정.
|
||||
|
||||
못박는 것
|
||||
- 배치 격자: 안 정한 옵션은 차례대로 · 정한 자리 먼저 · 너비 ≤ 칸 수 · 안 씀 빠짐 · 겹침 찾음.
|
||||
- 규칙: 없는 값 · 자기 자신 · 없는 하위 선택지 · 그러면 비었음 · 좁힌 선택지 밖 기본값은 틀린 줄
|
||||
(서버 `M02_SO_Definition._rule_errors` 와 같은 뜻) · 고르기 아닌 상위는 값 검사 안 함.
|
||||
- 저장 전 검사: 깨끗한 정의 = 빈 목록 · 이름 겹침 · 배치 겹침 · 빈 선택지 = 까닭 글.
|
||||
- 선택지 이름 바꿈 · 뺌은 그 옵션 기본값 · 같은 종류 규칙도 같이 고침.
|
||||
- 순서: 구조물군 = 키 차례 · 종류 = 같은 군 안에서만.
|
||||
"""
|
||||
|
||||
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_model.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["auto"] == {
|
||||
"a": {"줄": 1, "칸": 1, "너비": 1},
|
||||
"b": {"줄": 1, "칸": 2, "너비": 1},
|
||||
"c": {"줄": 2, "칸": 1, "너비": 1},
|
||||
}
|
||||
|
||||
|
||||
def test_배치_정한_자리_먼저_너비는_칸수까지(r) -> None:
|
||||
assert r["fixed"]["c"] == {"줄": 1, "칸": 1, "너비": 2}
|
||||
assert r["fixed"]["a"] == {"줄": 2, "칸": 1, "너비": 1}
|
||||
assert r["fixed"]["b"] == {"줄": 2, "칸": 2, "너비": 1}
|
||||
|
||||
|
||||
def test_안씀은_격자에서_빠짐(r) -> None:
|
||||
assert set(r["off"]) == {"b", "c"}
|
||||
assert r["off"]["b"] == {"줄": 1, "칸": 1, "너비": 1}
|
||||
|
||||
|
||||
def test_겹침(r) -> None:
|
||||
assert r["overlap"] == ["a", "b"]
|
||||
|
||||
|
||||
def test_규칙_틀린_줄(r) -> None:
|
||||
assert r["rules"] == [False, True, True, True, True, False, True]
|
||||
|
||||
|
||||
def test_이름_검사(r) -> None:
|
||||
assert r["names"] == ["(빈 이름)", "가"]
|
||||
|
||||
|
||||
def test_저장_전_검사(r) -> None:
|
||||
assert r["clean"] == []
|
||||
assert any("옵션 이름" in t for t in r["broken"])
|
||||
assert any("배치 겹침" in t for t in r["broken"])
|
||||
assert any("선택지가 있어야" in t for t in r["broken"])
|
||||
|
||||
|
||||
def test_선택지_이름_바꿈_뺌은_기본값_규칙도_같이(r) -> None:
|
||||
assert r["rename"] == [["엑스", "y"], "엑스", ["엑스", "y"], ["피"], "피"]
|
||||
assert r["drop"] == [["q"], [], False]
|
||||
|
||||
|
||||
def test_순서_기준측점_새키(r) -> None:
|
||||
assert r["family"] == [False, True, ["B", "A"]]
|
||||
assert r["type"] == [False, True, ["k2", "k1", "k3"]]
|
||||
assert r["base"] == [True, False]
|
||||
assert r["step"] == [False, True, [2, 1, 3]]
|
||||
assert r["newKey"] == [True, True]
|
||||
Reference in New Issue
Block a user