Files
Aislo/resources/tester/helper_b05_structures_form_summary.cjs
T
eomsangdonandClaude Sonnet 5.5 71fac35dab feat(B05·B06): 구조물 좌측 패널 「집계 항목」 줄 · 옵션 보이기 · [수정] 옵션 보존 · 목록 지정 색(21-4)
- 폼 옵션 아래 「집계 항목」 줄(이름 · 미지정 · [고르기] · [해제]) — 구조물 · 관 계열 하위 폼 · B05 · B06 한 코드
- 옵션 `visible:false` 는 폼에서 숨김(정의 · 옛 값 그대로) · [수정]은 폼이 그린 칸만 갈아 끼우고 안 그린 칸(상세 · 숨긴 칸) 보존 — 옵션 덮어쓰기 흠 고침
- 목록 줄 `.is-assigned` 색 · 툴팁 항목 이름 · 캐시는 지금 초안 기록 칸(structures · pipes) 안 summary_item
- 미지정이면 칸을 안 실음 · 관은 서버 응답에 칸이 없으면 같은 측점에서 화면이 이어받음
- 항목 고르기 모달은 21-5 가 오기 전까지 같은 이름의 임시 함수 · Structures_Panel 700줄 넘어 옵션 그리기를 Panel_Options 로 나눔

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TULoa94ZFL26KU6ZqVpjkF
2026-09-29 12:19:31 +09:00

284 lines
12 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() {}
}
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();