- 관 판정면(벽 판정면 아래 — 겹친 자리는 벽) · 누르면 관 두 겹 is-active - 고름 전용 키 RevetPick(RevetKey | "pipe") — 조정 함수는 RevetKey 그대로 - 조정창 없음(show "pipe" = 숨김 · side "pipe") · 좌측 fieldset[data-spec-container="pipe"] 강조 + 스크롤 - 좌측 「관」 상자 칸 고침 = 관 고름 · B05 3D pipe 키 → B06 관 고름 - 107-1 벽 · 본체 동시 고름 금지 그대로 · 관 숨긴 카드(링크 · 세월교 · 박스)엔 판정면 없음 - 시험 test_118_pipe_pick(helper_118_pipe_pick.cjs) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013rJwJMVL4EE31y77TB11AP
310 lines
10 KiB
JavaScript
310 lines
10 KiB
JavaScript
/* PLAN 118-1 단위검증 헬퍼 — B06 배관 횡단도 관 고름 · 강조 · 조정창 숨김 · 3D pipe 키.
|
|
* 화면 TS 를 그 자리에서 트랜스파일해 Node 로 돌림 · 결과 = 표준출력 JSON 한 벌.
|
|
* 입력 = `fixtures/b07_cross_overlay_sections.json` 배관 측점 85.052(관 Φ1.0). 짝 = `test_118_pipe_pick.py`.
|
|
*/
|
|
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();
|
|
let STUBS = {
|
|
"@ui/ui_template_toast": { showToast: () => undefined },
|
|
"@ui/ui_template_elements": { showToast: () => undefined },
|
|
};
|
|
|
|
// ── 화면 그릇 흉내 — 오버레이 · 조정창이 쓰는 만큼만 ─────────────────────────
|
|
class FakeNode {
|
|
constructor(tag) {
|
|
this.tagName = String(tag).toUpperCase();
|
|
this.children = [];
|
|
this.attrs = {};
|
|
this.dataset = {};
|
|
this.listeners = {};
|
|
this._text = "";
|
|
this._classes = new Set();
|
|
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),
|
|
};
|
|
this.style = { setProperty: () => {} };
|
|
}
|
|
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(k));
|
|
}
|
|
appendChild(kid) {
|
|
this.children.push(kid);
|
|
return kid;
|
|
}
|
|
replaceChildren(...kids) {
|
|
this.children = kids;
|
|
}
|
|
setAttribute(name, value) {
|
|
this.attrs[name] = String(value);
|
|
if (name === "class") this.className = value;
|
|
}
|
|
getAttribute(name) {
|
|
return name === "class" ? this.className : (this.attrs[name] ?? null);
|
|
}
|
|
addEventListener(type, fn) {
|
|
(this.listeners[type] ??= []).push(fn);
|
|
}
|
|
removeEventListener() {}
|
|
click() {
|
|
let stopped = false;
|
|
const event = { stopPropagation: () => (stopped = true), preventDefault: () => {} };
|
|
(this.listeners.click ?? []).forEach((fn) => fn(event));
|
|
return stopped;
|
|
}
|
|
querySelectorAll(selector) {
|
|
const found = [];
|
|
const walk = (node) =>
|
|
node.children.forEach((kid) => {
|
|
if (kid.classList && kid.classList.contains(selector.slice(1))) found.push(kid);
|
|
walk(kid);
|
|
});
|
|
walk(this);
|
|
return found;
|
|
}
|
|
querySelector(selector) {
|
|
return this.querySelectorAll(selector)[0] ?? null;
|
|
}
|
|
getClientRects() {
|
|
return [];
|
|
}
|
|
}
|
|
globalThis.document = {
|
|
createElement: (tag) => new FakeNode(tag),
|
|
createElementNS: (_ns, tag) => new FakeNode(tag),
|
|
createComment: () => new FakeNode("#comment"),
|
|
addEventListener: () => {},
|
|
removeEventListener: () => {},
|
|
};
|
|
globalThis.window = { addEventListener: () => {}, removeEventListener: () => {} };
|
|
globalThis.requestAnimationFrame = (fn) => fn();
|
|
|
|
function loadTs(file) {
|
|
const full = path.resolve(file);
|
|
if (cache.has(full)) return cache.get(full);
|
|
if (full.endsWith(".json")) {
|
|
const data = JSON.parse(fs.readFileSync(full, "utf8"));
|
|
cache.set(full, data);
|
|
return data;
|
|
}
|
|
const js = ts.transpileModule(fs.readFileSync(full, "utf8"), {
|
|
compilerOptions: {
|
|
module: ts.ModuleKind.CommonJS,
|
|
target: ts.ScriptTarget.ES2020,
|
|
esModuleInterop: true,
|
|
},
|
|
}).outputText;
|
|
const box = { exports: {} };
|
|
cache.set(full, box.exports);
|
|
const localRequire = (request) => {
|
|
if (STUBS[request]) return STUBS[request];
|
|
const alias = { "@util/": "common_util", "@ui/": "ui_template", "@config/": "config" };
|
|
for (const [prefix, dir] of Object.entries(alias))
|
|
if (request.startsWith(prefix))
|
|
return loadTs(resolve(path.join(ROOT, dir, request.slice(prefix.length))));
|
|
return loadTs(resolve(path.join(path.dirname(full), request)));
|
|
};
|
|
new Function("exports", "module", "require", js)(box.exports, box, localRequire);
|
|
cache.set(full, box.exports);
|
|
return box.exports;
|
|
}
|
|
const resolve = (base) =>
|
|
[base, `${base}.ts`, path.join(base, "index.ts")].find(
|
|
(p) => fs.existsSync(p) && fs.statSync(p).isFile(),
|
|
) ?? `${base}.ts`;
|
|
|
|
const B06 = (f) => path.join(ROOT, "B06_Section", `${f}.ts`);
|
|
const { computeStoredLayouts } = loadTs(B06("B06_Section_Structure_Layouts"));
|
|
const { appendCulvertOverlay } = loadTs(B06("B06_Section_UI_Cross_Culvert"));
|
|
const { wallStandsAt } = loadTs(B06("B06_Section_UI_Cross_Culvert_Wire"));
|
|
const { applyStructurePick } = loadTs(B06("B06_Section_UI_Page_Structure_Pick"));
|
|
|
|
const fixture = JSON.parse(
|
|
fs.readFileSync(path.join(__dirname, "fixtures", "b07_cross_overlay_sections.json"), "utf8"),
|
|
);
|
|
const sections = fixture.cross_sections;
|
|
const pipeSection = sections.find((s) => s.culvert && !s.culvert.hidden_pipe);
|
|
const layout = computeStoredLayouts(pipeSection, sections).culvert;
|
|
const out = {};
|
|
|
|
// ① 오버레이 — 판정면 차례 · 관 누름 · 강조
|
|
function overlay(hidePipe) {
|
|
const layer = new FakeNode("g");
|
|
const picked = [];
|
|
const setter = appendCulvertOverlay(
|
|
layer,
|
|
layout,
|
|
(o) => -o * 10,
|
|
(e) => -e * 10,
|
|
(key) => picked.push(key),
|
|
hidePipe,
|
|
);
|
|
const hits = layer.children.filter((n) => n.classList.contains("b06-chart__culvert-revet-hit"));
|
|
const pipes = layer.children.filter((n) => n.classList.contains("b06-chart__culvert-pipe"));
|
|
const walls = layer.children.filter((n) => n.classList.contains("b06-chart__culvert-revet"));
|
|
return { layer, picked, setter, hits, pipes, walls };
|
|
}
|
|
const drawn = overlay(false);
|
|
const firstHit = drawn.hits[0];
|
|
const stopped = firstHit.click();
|
|
const active = () => ({
|
|
pipes: drawn.pipes.map((p) => p.classList.contains("is-active")),
|
|
walls: drawn.walls.map((w) => w.classList.contains("is-active")),
|
|
});
|
|
drawn.setter("pipe");
|
|
const onPipe = active();
|
|
drawn.setter("outlet");
|
|
const onOutlet = active();
|
|
drawn.setter(null);
|
|
const onNone = active();
|
|
// 판정면 차례 — 관 판정면이 벽 판정면들보다 먼저(아래) 얹힘
|
|
const order = drawn.layer.children
|
|
.filter((n) => n.classList.contains("b06-chart__culvert-revet-hit"))
|
|
.map((n) => (n === firstHit ? "pipe" : "wall"));
|
|
// 관 판정면 = 외경 겹 꼭짓점과 같음
|
|
const outer = drawn.pipes.find((p) => p.classList.contains("b06-chart__culvert-pipe--outer"));
|
|
out.overlay = {
|
|
pipeCount: drawn.pipes.length,
|
|
wallCount: drawn.walls.length,
|
|
hitOrder: order,
|
|
firstPicked: drawn.picked,
|
|
stopped,
|
|
hitMatchesOuter: firstHit.attrs.points === outer.attrs.points,
|
|
onPipe,
|
|
onOutlet,
|
|
onNone,
|
|
};
|
|
// 관을 숨긴 카드(링크 · 독립 흙막이 · 세월교 · 박스) — 관 판정면 없음
|
|
const hidden = overlay(true);
|
|
hidden.hits.forEach((h) => h.click());
|
|
out.hiddenPipe = { pipes: hidden.pipes.length, picked: hidden.picked };
|
|
|
|
// ② 연동 카드 — 관은 주인 측점 카드에만
|
|
const neighbour = { ...pipeSection, chainage_m: pipeSection.chainage_m + 2 };
|
|
out.stands = {
|
|
self: wallStandsAt(pipeSection, pipeSection, "pipe"),
|
|
neighbour: wallStandsAt(pipeSection, neighbour, "pipe"),
|
|
outletNeighbour: wallStandsAt(pipeSection, neighbour, "outlet"),
|
|
};
|
|
|
|
// ③ B05 3D 관 고름(pipe 키) → B06 벽 고름 길로
|
|
function pick(key, extra = {}) {
|
|
const calls = [];
|
|
const target = { ...pipeSection, ...extra };
|
|
applyStructurePick(
|
|
{ at: target.chainage_m, key },
|
|
{ cross_sections: [target] },
|
|
{
|
|
focusStation: (id) => calls.push(["focus", id]),
|
|
refreshCard: (c) => calls.push(["refresh", c]),
|
|
revetSelect: (c, k) => calls.push(["revet", k]),
|
|
fordSelect: (c, k) => calls.push(["ford", k]),
|
|
boxSelect: (c, k) => calls.push(["box", k]),
|
|
},
|
|
);
|
|
return calls.filter(([kind]) => kind !== "focus" && kind !== "refresh");
|
|
}
|
|
out.pick = {
|
|
pipe: pick("pipe"),
|
|
basin: pick("basin"),
|
|
fordPipe: pick("pipe", { ford: {} }),
|
|
};
|
|
|
|
// ④ 조정창 — show("pipe") = 숨김 · side "pipe" · 조정 함수 안 불림
|
|
const base = loadTs(B06("B06_Section_UI_Cross_Panel_Base"));
|
|
STUBS = {
|
|
...STUBS,
|
|
"./B06_Section_UI_Section_Common": { L: (key) => key },
|
|
"./B06_Section_UI_Cross_Panel_Base": {
|
|
...base,
|
|
bindDragHandle: () => ({ attach: () => {}, detach: () => {} }),
|
|
},
|
|
};
|
|
const { buildStructurePanel } = loadTs(B06("B06_Section_UI_Cross_Structure_Panel"));
|
|
const touched = [];
|
|
const spy = (name, value) => (key) => {
|
|
touched.push([name, key]);
|
|
return value;
|
|
};
|
|
const panelDeps = {
|
|
scrollKey: "t118",
|
|
adjustFor: spy("adjustFor", { x: 0, d: null, h: null, m: null }),
|
|
nudge: spy("nudge"),
|
|
nudgeSlope: spy("nudgeSlope"),
|
|
nudgeHeight: spy("nudgeHeight"),
|
|
heightFor: spy("heightFor", 2),
|
|
formFor: spy("formFor", "돌쌓기(메)"),
|
|
setForm: spy("setForm"),
|
|
reset: spy("reset"),
|
|
close: () => {},
|
|
structureFor: () => "revet",
|
|
basinAdjustFor: () => ({ innerWidthM: 1, innerHeightM: 1.2, lateralM: 0, slopeM: 0 }),
|
|
updateBasin: () => {},
|
|
nudgeBasinLateral: () => {},
|
|
resetBasin: () => {},
|
|
canNudge: spy("canNudge", true),
|
|
fineMove: spy("fineMove", false),
|
|
dragAxes: spy("dragAxes", null),
|
|
extraState: () => ({ canAdd: false, count: 0 }),
|
|
isLinkedCard: () => false,
|
|
basinExtraState: () => ({ canAdd: false, count: 0 }),
|
|
hiddenPipe: () => false,
|
|
bodyGuard: () => false,
|
|
setExtraCount: () => {},
|
|
setBasinExtraCount: () => {},
|
|
equalizeExtras: () => {},
|
|
spanFor: spy("spanFor", null),
|
|
setSpan: () => {},
|
|
linkState: () => null,
|
|
setLinked: () => {},
|
|
followGrade: () => true,
|
|
setFollowGrade: () => {},
|
|
};
|
|
const panel = buildStructurePanel(panelDeps);
|
|
panel.show("outlet");
|
|
const outletState = {
|
|
hidden: panel.root.classList.contains("is-hidden"),
|
|
side: panel.root.dataset.side,
|
|
};
|
|
touched.length = 0;
|
|
panel.show("pipe");
|
|
out.panel = {
|
|
outlet: outletState,
|
|
pipe: {
|
|
hidden: panel.root.classList.contains("is-hidden"),
|
|
side: panel.root.dataset.side,
|
|
touched: touched.map(([name]) => name),
|
|
},
|
|
};
|
|
panel.show(null);
|
|
out.panel.none = {
|
|
hidden: panel.root.classList.contains("is-hidden"),
|
|
side: panel.root.dataset.side,
|
|
};
|
|
|
|
process.stdout.write(JSON.stringify(out));
|