Merge remote-tracking branch 'origin/sub_laptop_3' into sub_laptop_1
충돌 둘(Structures_List · Panel_Types)은 양쪽을 모두 살려 풂 — 측점 글은 공용 stationLabel · 집계 항목 읽기 · isB05Option · SummaryItem 을 한 벌로, 쓰지 않게 된 isB05Option 가져오기는 뺌.
This commit is contained in:
@@ -0,0 +1,283 @@
|
||||
/* 구조물 폼 「집계 항목」 단위검증 헬퍼(PLAN 21-4) — TS 를 그 자리에서 트랜스파일해 Node 로 돌린다.
|
||||
* 화면 그릇(DOM)은 시험에 필요한 만큼만 흉내낸다. 결과는 표준출력에 JSON 한 벌.
|
||||
*
|
||||
* ⚠ 확장자가 `.cjs` 인 까닭은 `helper_b05_curve_radius_floor.cjs` 머리와 같다.
|
||||
*/
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
|
||||
|
||||
const ROOT = path.join(__dirname, "..", "..");
|
||||
const cache = new Map();
|
||||
|
||||
// ── 화면 그릇 흉내 — 요소 · 클래스 · 이벤트 · 글자만 ─────────────────────────
|
||||
class FakeNode {
|
||||
constructor(tag) {
|
||||
this.tagName = tag;
|
||||
this.children = [];
|
||||
this.dataset = {};
|
||||
this.style = {};
|
||||
this.listeners = {};
|
||||
this._text = "";
|
||||
this._classes = new Set();
|
||||
this.title = "";
|
||||
this.hidden = false;
|
||||
this.disabled = false;
|
||||
const self = this;
|
||||
this.classList = {
|
||||
add: (...names) => names.forEach((n) => self._classes.add(n)),
|
||||
remove: (...names) => names.forEach((n) => self._classes.delete(n)),
|
||||
toggle: (name, force) => {
|
||||
const on = force === undefined ? !self._classes.has(name) : !!force;
|
||||
on ? self._classes.add(name) : self._classes.delete(name);
|
||||
return on;
|
||||
},
|
||||
contains: (name) => self._classes.has(name),
|
||||
};
|
||||
}
|
||||
set className(value) {
|
||||
this._classes = new Set(String(value).split(/\s+/).filter(Boolean));
|
||||
}
|
||||
get className() {
|
||||
return [...this._classes].join(" ");
|
||||
}
|
||||
set textContent(value) {
|
||||
this._text = String(value);
|
||||
this.children = [];
|
||||
}
|
||||
get textContent() {
|
||||
return this._text + this.children.map((c) => c.textContent).join("");
|
||||
}
|
||||
append(...kids) {
|
||||
kids.forEach((k) => this.children.push(typeof k === "string" ? Object.assign(new FakeNode("#text"), { _text: k }) : k));
|
||||
}
|
||||
replaceChildren(...kids) {
|
||||
this.children = [];
|
||||
this.append(...kids);
|
||||
}
|
||||
addEventListener(type, handler) {
|
||||
(this.listeners[type] ||= []).push(handler);
|
||||
}
|
||||
click() {
|
||||
(this.listeners.click || []).forEach((h) => h({ target: this }));
|
||||
}
|
||||
scrollIntoView() {}
|
||||
}
|
||||
globalThis.document = { createElement: (tag) => new FakeNode(tag) };
|
||||
globalThis.Option = function Option(text, value) {
|
||||
const node = new FakeNode("option");
|
||||
node._text = String(text);
|
||||
node.value = value;
|
||||
return node;
|
||||
};
|
||||
globalThis.requestAnimationFrame = (fn) => fn();
|
||||
const session = new Map();
|
||||
globalThis.window = { sessionStorage: { getItem: (k) => session.get(k) ?? null, setItem: (k, v) => session.set(k, String(v)), removeItem: (k) => session.delete(k) } };
|
||||
globalThis.sessionStorage = globalThis.window.sessionStorage;
|
||||
|
||||
const STUBS = {
|
||||
"@config/config_frontend": { API_BASE_URL: "/api", API_TIMEOUT_MS: 1000 },
|
||||
};
|
||||
|
||||
function loadTs(file) {
|
||||
const full = path.resolve(file);
|
||||
if (cache.has(full)) return cache.get(full);
|
||||
const js = ts.transpileModule(fs.readFileSync(full, "utf8"), {
|
||||
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
|
||||
}).outputText;
|
||||
const box = { exports: {} };
|
||||
cache.set(full, box.exports);
|
||||
const localRequire = (request) => {
|
||||
if (STUBS[request]) return STUBS[request];
|
||||
// 화면 공용 부품(@ui/…) — 이 시험이 안 쓰는 것이라 뭘 불러도 아무 일 안 하는 함수로 막아 둠
|
||||
if (request.startsWith("@ui/")) return new Proxy({}, { get: () => () => {} });
|
||||
return request.startsWith(".")
|
||||
? loadTs(path.join(path.dirname(full), `${request}.ts`))
|
||||
: require(request);
|
||||
};
|
||||
new Function("exports", "module", "require", js)(box.exports, box, localRequire);
|
||||
cache.set(full, box.exports);
|
||||
return box.exports;
|
||||
}
|
||||
|
||||
const B05 = (name) => loadTs(path.join(ROOT, "B05_Profile", `${name}.ts`));
|
||||
const item = loadTs(path.join(ROOT, "common_util", "common_util_summary_item.ts"));
|
||||
const logic = B05("B05_Profile_UI_Structures_Summary");
|
||||
const merge = B05("B05_Profile_UI_Drainage_Facility_Merge");
|
||||
const { buildSummaryRow } = B05("B05_Profile_UI_Structures_Summary_Row");
|
||||
const { renderStructureList } = B05("B05_Profile_UI_Structures_List");
|
||||
const { renderOptionFields } = B05("B05_Profile_UI_Structures_Panel_Options");
|
||||
const commitModule = B05("B05_Profile_UI_Structures_Panel_Commit");
|
||||
|
||||
const out = {};
|
||||
const A = { layer: "personal", owner: "삼성/홍길동", key: "SS000007", name: "돌기슭막이 메쌓기 1.2m" };
|
||||
const B = { layer: "system", owner: null, key: "SS000004", name: "옹벽 콘크리트" };
|
||||
|
||||
// ── 순수 규칙 ───────────────────────────────────────────────────────────
|
||||
out.visible = [logic.isOptionVisible({}), logic.isOptionVisible({ visible: true }), logic.isOptionVisible({ visible: false })];
|
||||
const before = { type_id: "wall", options: { H: 2, stone_kind: "화강암", back_length_m: 0.6, memo_only: "x" } };
|
||||
out.mergeSame = logic.mergeStructureOptions(before, "wall", ["H", "stone_kind"], { H: 3 });
|
||||
out.mergeCleared = logic.mergeStructureOptions(before, "wall", ["H", "stone_kind"], {});
|
||||
out.mergeTypeChange = logic.mergeStructureOptions(before, "fence", ["H"], { H: 1 });
|
||||
out.mergeNoPrev = logic.mergeStructureOptions(null, "wall", ["H"], { H: 9 });
|
||||
out.mergePrevNoOptions = logic.mergeStructureOptions({ type_id: "wall" }, "wall", ["H"], { H: 9 });
|
||||
|
||||
out.isItem = [item.isSummaryItem(A), item.isSummaryItem({ layer: "x", owner: null, key: "K", name: "n" }), item.isSummaryItem(null), item.isSummaryItem({ ...A, key: "" })];
|
||||
out.readItem = [item.readSummaryItem(A)?.key ?? null, item.readSummaryItem("nope")];
|
||||
out.same = [item.sameSummaryItem(A, { ...A, name: "이름만 바뀜" }), item.sameSummaryItem(A, B), item.sameSummaryItem(null, null), item.sameSummaryItem(A, null)];
|
||||
out.label = [item.summaryLabel(null), item.summaryLabel(A)];
|
||||
out.tip = [item.summaryTooltip(null), item.summaryTooltip(A)];
|
||||
|
||||
// 관 계열 merge — 집계 항목 이어받기 · 지우기
|
||||
const pipePrev = { facility: "pipe", options: { pipe_kind: "흄관", extra: 1 }, summary_item: A };
|
||||
out.pipeKeep = merge.mergeFacilityOptions(pipePrev, { facility: "pipe", options: { pipe_kind: "VR관" } }).summary_item ?? null;
|
||||
out.pipeClear = merge.mergeFacilityOptions(pipePrev, { facility: "pipe", options: {}, summary_item: null }).summary_item;
|
||||
out.pipeSet = merge.mergeFacilityOptions(pipePrev, { facility: "pipe", options: {}, summary_item: B }).summary_item.key;
|
||||
|
||||
// 집계 항목 줄
|
||||
const row = buildSummaryRow();
|
||||
const clicks = [];
|
||||
row.bind({ pick: () => clicks.push("pick"), clear: () => clicks.push("clear") });
|
||||
const [labelNode, nameNode, pickBtn, clearBtn] = row.root.children;
|
||||
out.rowEmpty = { name: nameNode.textContent, assigned: nameNode.classList.contains("is-assigned"), clearDisabled: clearBtn.disabled, get: row.get(), label: labelNode.textContent, pick: pickBtn.textContent, clear: clearBtn.textContent };
|
||||
row.set(A);
|
||||
out.rowSet = { name: nameNode.textContent, assigned: nameNode.classList.contains("is-assigned"), clearDisabled: clearBtn.disabled, title: nameNode.title, key: row.get().key, nameShowsKey: nameNode.textContent.includes("SS000007") };
|
||||
pickBtn.click();
|
||||
clearBtn.click();
|
||||
out.clicks = clicks;
|
||||
row.set(null);
|
||||
out.rowCleared = { name: nameNode.textContent, clearDisabled: clearBtn.disabled };
|
||||
|
||||
// 목록 줄 — 지정 색 · 툴팁
|
||||
const list = new FakeNode("ul");
|
||||
const types = new Map([["wall", { name: "옹벽" }], ["fence", { name: "울타리" }], ["pipe", { name: "배수관" }]]);
|
||||
renderStructureList({
|
||||
list,
|
||||
structures: [
|
||||
{ structure_id: "s1", type_id: "wall", placement: "point", chainage_m: 100, options: {}, summary_item: A },
|
||||
{ structure_id: "s2", type_id: "fence", placement: "point", chainage_m: 200, options: {} },
|
||||
],
|
||||
pipeFacilities: [
|
||||
{ chainage_m: 300, facility: "pipe", summary_item: B },
|
||||
{ chainage_m: 400, facility: "pipe" },
|
||||
],
|
||||
typeMap: types,
|
||||
intervalM: 20,
|
||||
editingId: null,
|
||||
selectedPipeChainage: null,
|
||||
onSelectStructure() {},
|
||||
onSelectPipe() {},
|
||||
});
|
||||
out.list = list.children.map((li) => ({ assigned: li.classList.contains("is-assigned"), title: li.title }));
|
||||
|
||||
// 옵션 칸 그리기 — visible:false 숨김 · 상세(detail) 는 includeDetail 일 때만 · 정의는 그대로
|
||||
const opt = (key, extra = {}) => ({ key, label: key, input: "number", choices: [], unit: null, default: null, ...extra });
|
||||
const wallType = {
|
||||
type_id: "wall",
|
||||
options: [
|
||||
opt("height_m"),
|
||||
opt("length_m", { visible: false }),
|
||||
opt("stone_kind", { phase: "detail", input: "select", choices: ["a", "b"] }),
|
||||
opt("back_len_cm", { phase: "detail", visible: false }),
|
||||
],
|
||||
};
|
||||
const draw = (includeDetail) => {
|
||||
const optionInputs = [];
|
||||
const optionRow = new FakeNode("div");
|
||||
renderOptionFields(
|
||||
{
|
||||
optionInputs,
|
||||
optionRow,
|
||||
includeDetail,
|
||||
currentType: () => wallType,
|
||||
facilityFormKind: () => null,
|
||||
liveCommit() {},
|
||||
syncOptionLock() {},
|
||||
syncRangeDisplay() {},
|
||||
syncSplitInputs() {},
|
||||
},
|
||||
{ height_m: 2 },
|
||||
);
|
||||
return { keys: optionInputs.map((e) => e.key), hidden: optionRow.hidden };
|
||||
};
|
||||
out.drawB05 = draw(false); // B05 — 상세 칸 없음 + visible:false 숨김
|
||||
out.drawB06 = draw(true); // B06 — 상세 칸까지, 단 visible:false 는 여기서도 숨김
|
||||
out.definitionKept = wallType.options.map((o) => o.key); // 정의는 그대로
|
||||
|
||||
// [수정]/[추가] 저장 — 폼 값이 정본 기록으로 나가는 자리(Commit.commit)를 fake 폼으로 돌림
|
||||
const saveType = { type_id: "wall", placement: "point", managed_by: null, options: [] };
|
||||
async function saveWith({ previous, summary, formOptions }) {
|
||||
const structures = previous ? [previous] : [];
|
||||
let summaryValue = summary;
|
||||
let changed = null;
|
||||
const ctx = {
|
||||
callbacks: { onChange: (list) => (changed = list) },
|
||||
structures,
|
||||
groupSelect: {},
|
||||
memoField: { value: "" },
|
||||
anchorFields: { read: () => 10, station: { focus() {} } },
|
||||
startFields: {},
|
||||
endFields: {},
|
||||
facilityOptions: {},
|
||||
optionInputs: Object.entries(formOptions).map(([key, value]) => ({
|
||||
key,
|
||||
required: false,
|
||||
input: {},
|
||||
read: () => value,
|
||||
isEmpty: () => false,
|
||||
})),
|
||||
pipeFacilities: () => [],
|
||||
interval: () => 20,
|
||||
typeMap: () => new Map(),
|
||||
currentType: () => saveType,
|
||||
facilityFormKind: () => null,
|
||||
hasComputedRange: () => false,
|
||||
readLengthM: () => null,
|
||||
readBeforeM: () => 0,
|
||||
cancelTempPipe() {},
|
||||
renderList() {},
|
||||
renderOptionFields() {},
|
||||
syncButtons() {},
|
||||
syncFacilityForm() {},
|
||||
syncPlacementFields() {},
|
||||
syncTypeOptions() {},
|
||||
editingId: () => previous?.structure_id ?? null,
|
||||
setEditingId() {},
|
||||
selectedPipeChainage: () => null,
|
||||
setSelectedPipeChainage() {},
|
||||
positionConfirmed: () => true,
|
||||
setPositionConfirmed() {},
|
||||
tempPipeChainage: () => null,
|
||||
setTempPipeChainage() {},
|
||||
summary: () => summaryValue,
|
||||
setSummary: (value) => (summaryValue = value),
|
||||
};
|
||||
await commitModule.commit(ctx, true);
|
||||
return changed[0];
|
||||
}
|
||||
const prev = () => ({
|
||||
structure_id: "s1",
|
||||
type_id: "wall",
|
||||
placement: "point",
|
||||
chainage_m: 10,
|
||||
options: { H: 2, hidden_cost_key: "keep", detail_only: 9 },
|
||||
memo: "",
|
||||
placement_source: "manual",
|
||||
status: "draft",
|
||||
revision: 0,
|
||||
geometry: null,
|
||||
summary_item: A,
|
||||
});
|
||||
|
||||
async function main() {
|
||||
const fresh = await saveWith({ previous: null, summary: null, formOptions: { H: 3 } });
|
||||
out.commitNew = { hasKey: "summary_item" in fresh, options: fresh.options };
|
||||
const cleared = await saveWith({ previous: prev(), summary: null, formOptions: { H: 3 } });
|
||||
out.commitCleared = { hasKey: "summary_item" in cleared, options: cleared.options };
|
||||
const kept = await saveWith({ previous: prev(), summary: A, formOptions: { H: 3 } });
|
||||
out.commitKept = { key: kept.summary_item?.key, options: kept.options };
|
||||
const swapped = await saveWith({ previous: prev(), summary: B, formOptions: { H: 3 } });
|
||||
out.commitSwapped = swapped.summary_item?.key;
|
||||
process.stdout.write(JSON.stringify(out));
|
||||
}
|
||||
main();
|
||||
@@ -0,0 +1,114 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""B05/B06 좌측 패널 「집계 항목」 회귀검증 — PLAN 21-4 (2026-09-29).
|
||||
|
||||
폼 옵션 보이기(`visible`) · [수정] 옵션 보존(안 그린 칸 · 상세 칸) · 집계 항목 줄(이름 ·
|
||||
고르기 · 해제) · 관 계열 merge · 목록 줄 색(`.is-assigned`) + 툴팁을 본다.
|
||||
화면 그릇은 Node 헬퍼가 흉내낸다(`helper_b05_structures_form_summary.cjs` —
|
||||
TS 를 그 자리에서 트랜스파일).
|
||||
실제 화면 조작은 ORCA(자체 확인)로 따로 본다.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
|
||||
import pytest
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
A_KEY = "SS000007"
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def out():
|
||||
proc = subprocess.run(
|
||||
["node", os.path.join(HERE, "helper_b05_structures_form_summary.cjs")],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=60,
|
||||
)
|
||||
assert proc.returncode == 0, proc.stderr
|
||||
return json.loads(proc.stdout)
|
||||
|
||||
|
||||
def test_visible_false_만_숨김(out):
|
||||
assert out["visible"] == [True, True, False]
|
||||
|
||||
|
||||
def test_visible_false_옵션은_폼에_안_그림_정의는_그대로(out):
|
||||
assert out["drawB05"] == {
|
||||
"keys": ["height_m"],
|
||||
"hidden": False,
|
||||
} # 길이 숨김 · 상세는 B05 에 없음
|
||||
assert out["drawB06"]["keys"] == ["height_m", "stone_kind"] # B06 은 상세까지 · 숨김은 여기도
|
||||
assert out["definitionKept"] == ["height_m", "length_m", "stone_kind", "back_len_cm"]
|
||||
|
||||
|
||||
def test_수정은_안_그린_칸을_보존(out):
|
||||
# 폼이 그린 H · stone_kind 는 갈아 끼우고, 안 그린 상세 칸 · 숨긴 칸은 옛 값 그대로
|
||||
assert out["mergeSame"] == {"back_length_m": 0.6, "memo_only": "x", "H": 3}
|
||||
|
||||
|
||||
def test_그린_칸을_비우면_지워짐(out):
|
||||
assert out["mergeCleared"] == {"back_length_m": 0.6, "memo_only": "x"}
|
||||
|
||||
|
||||
def test_종류를_바꾸면_통째로_새_값(out):
|
||||
assert out["mergeTypeChange"] == {"H": 1}
|
||||
assert out["mergeNoPrev"] == {"H": 9}
|
||||
assert out["mergePrevNoOptions"] == {"H": 9}
|
||||
|
||||
|
||||
def test_참조_모양_검사(out):
|
||||
assert out["isItem"] == [True, False, False, False]
|
||||
assert out["readItem"] == [A_KEY, None]
|
||||
assert out["same"] == [True, False, True, False] # 이름만 바뀌면 같음
|
||||
|
||||
|
||||
def test_키는_앞에_안_내세움_툴팁에만(out):
|
||||
assert out["label"] == ["미지정", "돌기슭막이 메쌓기 1.2m"]
|
||||
assert A_KEY not in out["label"][1]
|
||||
assert A_KEY in out["tip"][1] and "개인용" in out["tip"][1]
|
||||
assert out["rowSet"]["nameShowsKey"] is False
|
||||
|
||||
|
||||
def test_집계_항목_줄(out):
|
||||
empty = out["rowEmpty"]
|
||||
assert (empty["name"], empty["assigned"], empty["clearDisabled"], empty["get"]) == (
|
||||
"미지정",
|
||||
False,
|
||||
True,
|
||||
None,
|
||||
)
|
||||
assert (empty["label"], empty["pick"], empty["clear"]) == ("집계 항목", "고르기", "해제")
|
||||
row = out["rowSet"]
|
||||
assert row["assigned"] is True and row["clearDisabled"] is False
|
||||
assert row["name"] == "돌기슭막이 메쌓기 1.2m" and A_KEY in row["title"]
|
||||
assert out["clicks"] == ["pick", "clear"]
|
||||
assert out["rowCleared"] == {"name": "미지정", "clearDisabled": True}
|
||||
|
||||
|
||||
def test_관_계열_merge_가_집계_항목을_잇고_지움(out):
|
||||
assert out["pipeKeep"]["key"] == A_KEY # 다음 값에 칸이 없으면 옛 지정을 이음
|
||||
assert out["pipeClear"] is None # 폼이 null 을 보내면 해제
|
||||
assert out["pipeSet"] == "SS000004" # 다른 항목으로 바꿈
|
||||
|
||||
|
||||
def test_목록_줄_색과_툴팁(out):
|
||||
rows = out["list"] # 측점 순: 구조물(지정) · 구조물(미지정) · 관(지정) · 관(미지정)
|
||||
assert [r["assigned"] for r in rows] == [True, False, True, False]
|
||||
assert A_KEY in rows[0]["title"] and "SS000004" in rows[2]["title"]
|
||||
assert rows[1]["title"] == "" and rows[3]["title"] == "" # 미지정은 지금 그대로
|
||||
|
||||
|
||||
def test_저장_기록에_집계_항목이_실리고_해제하면_칸이_빠짐(out):
|
||||
# 새 구조물(미지정) — 칸을 아예 안 실음(서버가 모르는 칸을 거절하는 동안에도 저장이 안 깨짐)
|
||||
assert out["commitNew"] == {"hasKey": False, "options": {"H": 3}}
|
||||
# 지정돼 있던 것을 해제 — 옛 지정이 살아오지 않음 · 안 그린 칸은 보존
|
||||
assert out["commitCleared"]["hasKey"] is False
|
||||
assert out["commitCleared"]["options"] == {"hidden_cost_key": "keep", "detail_only": 9, "H": 3}
|
||||
# 지정 유지 · 바꿈
|
||||
assert out["commitKept"] == {
|
||||
"key": A_KEY,
|
||||
"options": {"hidden_cost_key": "keep", "detail_only": 9, "H": 3},
|
||||
}
|
||||
assert out["commitSwapped"] == "SS000004"
|
||||
@@ -19,6 +19,8 @@ if str(PROJECT_ROOT) not in sys.path:
|
||||
from B05_Profile.B05_Profile_Structures_Schema import load_structure_types # noqa: E402
|
||||
|
||||
_PANEL = PROJECT_ROOT / "B05_Profile" / "B05_Profile_UI_Structures_Panel.ts"
|
||||
# 옵션 칸 그리기는 700줄 제한으로 이 조각으로 옮겨 감(2026-09-29 · PLAN 21-4)
|
||||
_PANEL_OPTIONS = PROJECT_ROOT / "B05_Profile" / "B05_Profile_UI_Structures_Panel_Options.ts"
|
||||
_B06 = PROJECT_ROOT / "B06_Section" / "B06_Section_UI_Page_Structures_Panel.ts"
|
||||
_B05 = PROJECT_ROOT / "B05_Profile" / "B05_Profile_UI_Panel.ts"
|
||||
|
||||
@@ -38,7 +40,7 @@ def test_B05_는_종전대로_유무_종류_위치만():
|
||||
|
||||
def test_기본값_없는_필수_항목은_빈_칸으로_연다():
|
||||
"""첫 항목을 슬쩍 고르면 **근거 없는 값**(뒷길이 25㎝ 같은)이 수량·단가로 흘러간다."""
|
||||
panel = _PANEL.read_text(encoding="utf-8")
|
||||
panel = _PANEL.read_text(encoding="utf-8") + _PANEL_OPTIONS.read_text(encoding="utf-8")
|
||||
fields = (PROJECT_ROOT / "B05_Profile" / "B05_Profile_UI_Structures_Fields.ts").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user