Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JywHxQuWPk6UYXTebyrBoU
191 lines
7.1 KiB
JavaScript
191 lines
7.1 KiB
JavaScript
/* PLAN 190-1 단위검증 헬퍼 — B06 집수정 판정면 = 부재 외곽 가상 사각형(세월교 측벽 꼴).
|
|
* 화면 TS 를 그 자리에서 트랜스파일해 Node 로 돌림 · 결과 = 표준출력 JSON 한 벌.
|
|
* 입력 = `fixtures/b07_cross_overlay_sections.json` 배관 측점 + design.inlet_structure 강제(U · L · I).
|
|
* 짝 = `test_190_1_basin_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();
|
|
const STUBS = {
|
|
"@ui/ui_template_toast": { showToast: () => undefined },
|
|
"@ui/ui_template_elements": { showToast: () => undefined },
|
|
};
|
|
|
|
// ── SVG 그릇 흉내 — 오버레이가 쓰는 만큼만 ─────────────────────────────────
|
|
class FakeNode {
|
|
constructor(tag) {
|
|
this.tagName = String(tag).toUpperCase();
|
|
this.children = [];
|
|
this.attrs = {};
|
|
this.listeners = {};
|
|
this.textContent = "";
|
|
this._classes = new Set();
|
|
const self = this;
|
|
this.classList = {
|
|
add: (...names) => names.forEach((n) => self._classes.add(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),
|
|
};
|
|
}
|
|
append(...kids) {
|
|
kids.forEach((k) => this.children.push(k));
|
|
}
|
|
appendChild(kid) {
|
|
this.children.push(kid);
|
|
return kid;
|
|
}
|
|
setAttribute(name, value) {
|
|
this.attrs[name] = String(value);
|
|
if (name === "class") this._classes = new Set(String(value).split(/\s+/).filter(Boolean));
|
|
}
|
|
getAttribute(name) {
|
|
return this.attrs[name] ?? null;
|
|
}
|
|
addEventListener(type, fn) {
|
|
(this.listeners[type] ??= []).push(fn);
|
|
}
|
|
click() {
|
|
let stopped = false;
|
|
const event = { stopPropagation: () => (stopped = true), preventDefault: () => {} };
|
|
(this.listeners.click ?? []).forEach((fn) => fn(event));
|
|
return stopped;
|
|
}
|
|
}
|
|
globalThis.document = {
|
|
createElement: (tag) => new FakeNode(tag),
|
|
createElementNS: (_ns, tag) => new FakeNode(tag),
|
|
};
|
|
|
|
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 { outlineBox } = loadTs(B06("B06_Section_UI_Cross_Culvert_Types"));
|
|
|
|
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 BASIN_TIP = "유입 집수정 선택 — 조정창에서 구조물 형식 선택";
|
|
const X = (o) => -o * 10;
|
|
const Y = (e) => -e * 10;
|
|
const pointsOf = (node) => node.attrs.points.split(" ").map((pair) => pair.split(",").map(Number));
|
|
|
|
function basinCase(shape) {
|
|
const target = { ...pipeSection, design: { ...pipeSection.design, inlet_structure: shape } };
|
|
const layout = computeStoredLayouts(target, sections).culvert;
|
|
const layer = new FakeNode("g");
|
|
const picked = [];
|
|
const setter = appendCulvertOverlay(layer, layout, X, Y, (key) => picked.push(key));
|
|
const hits = layer.children.filter((n) => n.classList.contains("b06-chart__culvert-revet-hit"));
|
|
const basinHits = hits.filter((n) => n.children[0]?.textContent === BASIN_TIP);
|
|
const all = layout.basin.parts.flatMap((part) => part.points);
|
|
// 기대 외곽(화면 좌표) — 부재 전 꼭짓점의 최소 · 최대
|
|
const xs = all.map((p) => X(p.offset));
|
|
const ys = all.map((p) => Y(p.elevation));
|
|
const expect = {
|
|
minX: Math.min(...xs),
|
|
maxX: Math.max(...xs),
|
|
minY: Math.min(...ys),
|
|
maxY: Math.max(...ys),
|
|
};
|
|
const got = basinHits.length ? pointsOf(basinHits[0]) : [];
|
|
const gotBox = got.length
|
|
? {
|
|
minX: Math.min(...got.map(([px]) => px)),
|
|
maxX: Math.max(...got.map(([px]) => px)),
|
|
minY: Math.min(...got.map(([, py]) => py)),
|
|
maxY: Math.max(...got.map(([, py]) => py)),
|
|
}
|
|
: null;
|
|
const stopped = basinHits.length ? basinHits[0].click() : false;
|
|
// 그림 모양은 그대로 — 집수정 부재 폴리곤 = 부재 점열 그대로 · 고르면 부재 강조
|
|
const drawn = layer.children.filter((n) => n.classList.contains("b06-chart__culvert-basin"));
|
|
const drawnSame = drawn.every(
|
|
(n, i) =>
|
|
n.attrs.points ===
|
|
layout.basin.parts[i].points.map((p) => `${X(p.offset)},${Y(p.elevation)}`).join(" "),
|
|
);
|
|
setter("inlet");
|
|
const activeOnInlet = drawn.every((n) => n.classList.contains("is-active"));
|
|
return {
|
|
shape: layout.basin.shape,
|
|
parts: layout.basin.parts.length,
|
|
basinHits: basinHits.length,
|
|
corners: got.length,
|
|
expect,
|
|
got: gotBox,
|
|
picked,
|
|
stopped,
|
|
lastHitIsBasin: hits[hits.length - 1] === basinHits[0],
|
|
drawnParts: drawn.length,
|
|
drawnSame,
|
|
activeOnInlet,
|
|
};
|
|
}
|
|
|
|
const out = { U: basinCase("U"), L: basinCase("L"), I: basinCase("I") };
|
|
// 세월교 측벽과 같은 함수 — ㄴ 점열의 외곽
|
|
const ell = [
|
|
{ offset: 0, elevation: 0 },
|
|
{ offset: 0.3, elevation: 0 },
|
|
{ offset: 0.3, elevation: 2 },
|
|
{ offset: 1.5, elevation: 2 },
|
|
{ offset: 1.5, elevation: 2.3 },
|
|
{ offset: 0, elevation: 2.3 },
|
|
];
|
|
out.outline = outlineBox(ell);
|
|
// 판정면 없으면(onSelect 없음 · B07) 집수정 판정면도 없음
|
|
{
|
|
const target = { ...pipeSection, design: { ...pipeSection.design, inlet_structure: "U" } };
|
|
const layout = computeStoredLayouts(target, sections).culvert;
|
|
const layer = new FakeNode("g");
|
|
appendCulvertOverlay(layer, layout, X, Y);
|
|
out.noSelectHits = layer.children.filter((n) =>
|
|
n.classList.contains("b06-chart__culvert-revet-hit"),
|
|
).length;
|
|
}
|
|
|
|
process.stdout.write(JSON.stringify(out));
|