- 시설 폼 칸에 옵션 키 표식(data-option-keys) · 끈 키의 칸은 가림 · 칸이 다 가려진 줄은 줄째 - 키가 여럿인 칸(본체 규격 · 유입구 구조)은 키가 다 꺼졌을 때만 가림 · 독립 기슭막이는 유입 · 유출 채널 키로 넓힘 - 숨긴 칸은 칸 값(제안값 채우기 포함)이 아니라 폼이 받은 옛 값을 저장에 그대로 실음 · 이 폼에는 필수 검사가 없음 - 가리기 코드는 새 파일(_Facility_Hidden)로 분리해 Facility.ts 700줄 아래 유지 - 시험 3개 추가 Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TULoa94ZFL26KU6ZqVpjkF
479 lines
17 KiB
JavaScript
479 lines
17 KiB
JavaScript
/* 구조물 폼 「집계 항목」 단위검증 헬퍼(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() {}
|
|
// 시험에 필요한 두 가지 선택자만 — `[data-option-keys]` · `.클래스`
|
|
querySelectorAll(selector) {
|
|
const match = selector.startsWith("[")
|
|
? (n) => n.dataset && n.dataset.optionKeys !== undefined
|
|
: (n) => n.classList.contains(selector.slice(1));
|
|
const found = [];
|
|
const walk = (node) =>
|
|
node.children.forEach((kid) => {
|
|
if (kid.classList && match(kid)) found.push(kid);
|
|
walk(kid);
|
|
});
|
|
walk(this);
|
|
return found;
|
|
}
|
|
}
|
|
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: () => () => {} });
|
|
if (request.startsWith(".") && request.endsWith(".json")) {
|
|
return JSON.parse(fs.readFileSync(path.join(path.dirname(full), request), "utf8"));
|
|
}
|
|
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 { createHiddenOptions } = B05("B05_Profile_UI_Drainage_Facility_Hidden");
|
|
const { labeled } = B05("B05_Profile_UI_Drainage_Facility_Fields");
|
|
|
|
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,
|
|
requiredEmpty = false,
|
|
summaryOnly = false,
|
|
}) {
|
|
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: requiredEmpty,
|
|
input: { focus() {} },
|
|
read: () => value,
|
|
isEmpty: () => requiredEmpty,
|
|
})),
|
|
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),
|
|
};
|
|
if (summaryOnly) {
|
|
const done = commitModule.commitSummaryOnly(ctx);
|
|
return { done, record: changed ? changed[0] : null };
|
|
}
|
|
await commitModule.commit(ctx, true);
|
|
return changed ? changed[0] : null;
|
|
}
|
|
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,
|
|
});
|
|
|
|
// 시설 폼 끈 옵션 가리기(PLAN 21-10 흠 8)
|
|
{
|
|
const tagged = labeled("관종", new FakeNode("select"), "pipe_kind");
|
|
const multi = labeled("구조", new FakeNode("select"), ["inlet_type", "inlet_structure"]);
|
|
const plain = labeled("메모", new FakeNode("select"));
|
|
out.labelTag = [tagged.dataset.optionKeys, multi.dataset.optionKeys, plain.dataset.optionKeys];
|
|
const root = new FakeNode("div");
|
|
const rowA = new FakeNode("div");
|
|
rowA.className = "b05-structure__grid";
|
|
const kind = labeled("관종", new FakeNode("select"), "pipe_kind");
|
|
const dia = labeled("관경", new FakeNode("select"), "pipe_diameter_mm");
|
|
rowA.append(kind, dia);
|
|
const rowB = new FakeNode("div");
|
|
rowB.className = "b05-structure__grid";
|
|
const inlet = labeled("구조", new FakeNode("select"), ["inlet_type", "inlet_structure"]);
|
|
const free = labeled("메모", new FakeNode("select")); // 표식 없는 칸은 안 가림
|
|
rowB.append(inlet, free);
|
|
root.append(rowA, rowB);
|
|
const hiddenOf = () => ({
|
|
kind: kind.classList.contains("is-option-hidden"),
|
|
dia: dia.classList.contains("is-option-hidden"),
|
|
rowA: rowA.classList.contains("is-option-hidden"),
|
|
inlet: inlet.classList.contains("is-option-hidden"),
|
|
free: free.classList.contains("is-option-hidden"),
|
|
rowB: rowB.classList.contains("is-option-hidden"),
|
|
});
|
|
const hidden = createHiddenOptions(root);
|
|
hidden.set("pipe", ["pipe_kind"]);
|
|
out.hideOne = hiddenOf();
|
|
hidden.set("pipe", ["pipe_kind", "pipe_diameter_mm"]);
|
|
out.hideRow = hiddenOf(); // 줄의 칸이 다 가려지면 줄째
|
|
hidden.set("pipe", ["inlet_structure"]);
|
|
out.hideInletPartial = hiddenOf(); // 키가 여럿인 칸은 하나만 꺼지면 그대로
|
|
hidden.set("pipe", ["inlet_type"]);
|
|
out.hideInlet = hiddenOf(); // 유입구 「구조」는 inlet_type 을 끄면 함께 가려짐(구조 키 포함)
|
|
hidden.set("pipe", []);
|
|
out.showAll = hiddenOf(); // [모두 보임] 뒤 되돌아옴
|
|
// 숨긴 칸은 폼이 받은 옛 값 그대로 — 칸에서 읽은 값(제안값 채우기 포함)이 아니라
|
|
hidden.remember({ pipe_kind: "흄관", pipe_diameter_mm: 1000, other: 1 });
|
|
hidden.set("pipe", ["pipe_kind", "inlet_type"]);
|
|
const read = { pipe_kind: "VR관", pipe_diameter_mm: 800, inlet_type: "제안", other: 2 };
|
|
hidden.restore(read);
|
|
out.restored = read;
|
|
hidden.remember({}); // 옛 값이 없던 숨긴 칸은 값이 안 실림
|
|
const read2 = { pipe_kind: "VR관", pipe_diameter_mm: 800 };
|
|
hidden.restore(read2);
|
|
out.restoredEmpty = read2;
|
|
out.revetKeys = [...merge.hiddenFormKeys("revetment", ["form", "height_m", "side"])].sort();
|
|
out.pipeKeys = [...merge.hiddenFormKeys("pipe", ["inlet_type"])].sort();
|
|
out.otherKeys = [...merge.hiddenFormKeys("box_culvert", ["form"])].sort();
|
|
}
|
|
|
|
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;
|
|
// 집계 항목만 바뀐 때(PLAN 21-10 흠 2) — 필수 옵션이 비어 폼 검증에 걸려도 기록에 바로 반영
|
|
const blocked = await saveWith({
|
|
previous: prev(),
|
|
summary: B,
|
|
formOptions: { H: 3 },
|
|
requiredEmpty: true,
|
|
});
|
|
out.formBlocks = blocked === null; // 종전 흐름(commit)은 필수 옵션이 비면 아무것도 안 함
|
|
const only = await saveWith({
|
|
previous: prev(),
|
|
summary: B,
|
|
formOptions: { H: 3 },
|
|
requiredEmpty: true,
|
|
summaryOnly: true,
|
|
});
|
|
out.summaryOnlyAssign = {
|
|
done: only.done,
|
|
key: only.record?.summary_item?.key,
|
|
options: only.record?.options,
|
|
};
|
|
const onlyClear = await saveWith({
|
|
previous: prev(),
|
|
summary: null,
|
|
formOptions: { H: 3 },
|
|
requiredEmpty: true,
|
|
summaryOnly: true,
|
|
});
|
|
out.summaryOnlyClear = { done: onlyClear.done, hasKey: "summary_item" in onlyClear.record };
|
|
const onlyNew = await saveWith({
|
|
previous: null,
|
|
summary: B,
|
|
formOptions: { H: 3 },
|
|
summaryOnly: true,
|
|
});
|
|
out.summaryOnlyNew = onlyNew.done; // 새 항목은 [추가] 몫 — 반영 안 함
|
|
process.stdout.write(JSON.stringify(out));
|
|
}
|
|
main();
|