Merge remote-tracking branches 'origin/sub_desktop_1', 'origin/sub_laptop_2', 'origin/sub_laptop_4' and 'origin/sub_laptop_7' into main_laptop_1
This commit is contained in:
@@ -160,7 +160,7 @@
|
||||
},
|
||||
{
|
||||
"key": "inlet_revet_form",
|
||||
"label": "유입 기슭막이 형태",
|
||||
"label": "유입 흙막이 형태",
|
||||
"input": "select",
|
||||
"choices": [
|
||||
"돌쌓기(찰)",
|
||||
@@ -176,7 +176,7 @@
|
||||
},
|
||||
{
|
||||
"key": "inlet_revet_length_m",
|
||||
"label": "유입 기슭막이 길이",
|
||||
"label": "유입 흙막이 길이",
|
||||
"input": "number",
|
||||
"unit": "m",
|
||||
"default": 10,
|
||||
@@ -184,7 +184,7 @@
|
||||
},
|
||||
{
|
||||
"key": "inlet_revet_before_m",
|
||||
"label": "유입 기슭막이 기준측점 전",
|
||||
"label": "유입 흙막이 기준측점 전",
|
||||
"input": "number",
|
||||
"unit": "m",
|
||||
"default": 5,
|
||||
@@ -193,7 +193,7 @@
|
||||
},
|
||||
{
|
||||
"key": "inlet_revet_after_m",
|
||||
"label": "유입 기슭막이 기준측점 후",
|
||||
"label": "유입 흙막이 기준측점 후",
|
||||
"input": "number",
|
||||
"unit": "m",
|
||||
"default": 5,
|
||||
@@ -202,7 +202,7 @@
|
||||
},
|
||||
{
|
||||
"key": "inlet_revet_height_m",
|
||||
"label": "유입 기슭막이 높이",
|
||||
"label": "유입 흙막이 높이",
|
||||
"input": "number",
|
||||
"unit": "m",
|
||||
"default": 1.5,
|
||||
@@ -223,7 +223,7 @@
|
||||
},
|
||||
{
|
||||
"key": "outlet_revet_form",
|
||||
"label": "유출 기슭막이 형태",
|
||||
"label": "유출 흙막이 형태",
|
||||
"input": "select",
|
||||
"choices": [
|
||||
"돌쌓기(찰)",
|
||||
@@ -240,7 +240,7 @@
|
||||
},
|
||||
{
|
||||
"key": "outlet_revet_length_m",
|
||||
"label": "유출 기슭막이 길이",
|
||||
"label": "유출 흙막이 길이",
|
||||
"input": "number",
|
||||
"unit": "m",
|
||||
"default": 10,
|
||||
@@ -248,7 +248,7 @@
|
||||
},
|
||||
{
|
||||
"key": "outlet_revet_before_m",
|
||||
"label": "유출 기슭막이 기준측점 전",
|
||||
"label": "유출 흙막이 기준측점 전",
|
||||
"input": "number",
|
||||
"unit": "m",
|
||||
"default": 5,
|
||||
@@ -257,7 +257,7 @@
|
||||
},
|
||||
{
|
||||
"key": "outlet_revet_after_m",
|
||||
"label": "유출 기슭막이 기준측점 후",
|
||||
"label": "유출 흙막이 기준측점 후",
|
||||
"input": "number",
|
||||
"unit": "m",
|
||||
"default": 5,
|
||||
@@ -266,7 +266,7 @@
|
||||
},
|
||||
{
|
||||
"key": "outlet_revet_height_m",
|
||||
"label": "유출 기슭막이 높이",
|
||||
"label": "유출 흙막이 높이",
|
||||
"input": "number",
|
||||
"unit": "m",
|
||||
"default": 1.5,
|
||||
@@ -277,7 +277,7 @@
|
||||
},
|
||||
{
|
||||
"key": "revet_foundation",
|
||||
"label": "기슭막이 기초",
|
||||
"label": "흙막이 기초",
|
||||
"input": "select",
|
||||
"choices": [
|
||||
"기초유",
|
||||
@@ -317,7 +317,7 @@
|
||||
},
|
||||
{
|
||||
"key": "revet_foundation",
|
||||
"label": "기슭막이 기초",
|
||||
"label": "흙막이 기초",
|
||||
"input": "select",
|
||||
"choices": [
|
||||
"기초유",
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
/* 107 세월교 헬퍼 — 화면 TS 를 그 자리에서 트랜스파일해 Node 로 돌림.
|
||||
* 입력 = 73-4 픽스처(세월교 173.09 · 박스 352.14) · 결과 = 표준출력 JSON 한 벌. 짝 = `test_107_ford_free.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 },
|
||||
};
|
||||
|
||||
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);
|
||||
const out = {};
|
||||
|
||||
// 107-1 — 세월교 · 박스를 고르면 같은 측점 벽 고름을 풂(푸는 것은 안 부름)
|
||||
const { createBodyControls } = loadTs(B06("B06_Section_UI_Page_Ford_Controls.ts"));
|
||||
const released = [];
|
||||
const bodies = createBodyControls(
|
||||
{
|
||||
sectionAt: () => undefined,
|
||||
patchCachedDesign: () => undefined,
|
||||
refreshCard: () => undefined,
|
||||
queuePipeOptions: () => undefined,
|
||||
round1: (v) => Math.round(v * 10) / 10,
|
||||
clampMove: (v) => v,
|
||||
releaseWall: (chainageM) => released.push(chainageM),
|
||||
},
|
||||
() => null,
|
||||
);
|
||||
bodies.ford.control.select(173.09, "inlet");
|
||||
bodies.ford.control.select(173.09, null);
|
||||
bodies.box.control.select(352.14, "left");
|
||||
out.released = released;
|
||||
|
||||
// 107-2 — 원지반을 바꾼 사본: 세월교 바닥 = 노면 − (관경 + 토피) · 박스 본체 · 끝 · 날개 · 흙막이 자리 그대로
|
||||
const { computeStoredLayouts } = loadTs(B06("B06_Section_Structure_Layouts.ts"));
|
||||
const fixture = JSON.parse(
|
||||
fs.readFileSync(path.join(__dirname, "fixtures", "b06_73_4_body_guard_sections.json"), "utf8"),
|
||||
);
|
||||
const sections = fixture.cross_sections;
|
||||
const at = (c) => sections.find((x) => Math.abs(x.chainage_m - c) < 0.6);
|
||||
const clone = (v) => JSON.parse(JSON.stringify(v));
|
||||
const r3 = (v) => Math.round(v * 1000) / 1000;
|
||||
const pts = (list) => (list ?? []).map((p) => [r3(p.offset), r3(p.elevation)]);
|
||||
/** 원지반 사본 — [from, to] 안 샘플을 dz 만큼 옮김. */
|
||||
const shiftGround = (x, dz, from = -Infinity, to = Infinity) => {
|
||||
for (const p of x.samples)
|
||||
if (p.offset_m >= from && p.offset_m <= to && p.elevation_m != null) p.elevation_m += dz;
|
||||
};
|
||||
const GROUNDS = {
|
||||
base: () => undefined,
|
||||
bedDown2: (x) => shiftGround(x, -2, -2, 2),
|
||||
allUp05: (x) => shiftGround(x, 0.5),
|
||||
allDown1: (x) => shiftGround(x, -1),
|
||||
};
|
||||
const walls = (culvert) =>
|
||||
(culvert?.walls ?? []).filter((w) => w.role !== "extra").map((w) => pts(w.points));
|
||||
function fordShape(base, edit, ground) {
|
||||
const x = clone(base);
|
||||
edit(x);
|
||||
ground(x);
|
||||
const { ford, culvert } = computeStoredLayouts(
|
||||
x,
|
||||
sections.map((y) => (y === base ? x : y)),
|
||||
);
|
||||
return {
|
||||
sides: ford.sides.map((side) => ({
|
||||
role: side.role,
|
||||
parts: side.parts.map((part) => pts(part.points)),
|
||||
slabTop: r3(side.slabTopElevation),
|
||||
// 노면(벽 상단) − 바닥판 상면 = 관경 + 최소 토피
|
||||
depth: r3(side.top.elevation - side.slabTopElevation),
|
||||
})),
|
||||
pipeCover: r3(ford.ford.diameter_m + ford.ford.min_cover_m),
|
||||
slab: pts(ford.slabBridge),
|
||||
pipe: pts(ford.pipe.outer),
|
||||
walls: walls(culvert),
|
||||
};
|
||||
}
|
||||
function boxShape(base, edit, ground) {
|
||||
const x = clone(base);
|
||||
edit(x);
|
||||
ground(x);
|
||||
const { box, culvert } = computeStoredLayouts(
|
||||
x,
|
||||
sections.map((y) => (y === base ? x : y)),
|
||||
);
|
||||
return {
|
||||
top: pts(box.topSlab),
|
||||
bottom: pts(box.bottomSlab),
|
||||
barrel: pts(box.barrel),
|
||||
sides: box.sides.map((side) => ({
|
||||
role: side.role,
|
||||
offset: r3(side.offset),
|
||||
top: r3(side.topElevation),
|
||||
bottom: r3(side.bottomElevation),
|
||||
apron: pts(side.apron),
|
||||
})),
|
||||
walls: walls(culvert),
|
||||
};
|
||||
}
|
||||
const FORD = at(173.087);
|
||||
const BOX = at(352.14);
|
||||
const keep = () => undefined;
|
||||
const inletWing = (x) => {
|
||||
x.culvert.inlet = { role: "inlet", structure: "" };
|
||||
};
|
||||
// 날개벽 박스 — 흙막이 없이 양쪽 날개벽(에이프런 1.414 m)
|
||||
const noGuard = (x) => {
|
||||
delete x.culvert;
|
||||
for (const wing of [x.box.wing_in, x.box.wing_out])
|
||||
Object.assign(wing, { installed: true, slab_extend_m: 1.414 });
|
||||
};
|
||||
const run = (shape, base, edit) =>
|
||||
Object.fromEntries(Object.entries(GROUNDS).map(([k, g]) => [k, shape(base, edit, g)]));
|
||||
out.ford = { guards: run(fordShape, FORD, keep), inletWing: run(fordShape, FORD, inletWing) };
|
||||
out.box = { guards: run(boxShape, BOX, keep), bare: run(boxShape, BOX, noGuard) };
|
||||
|
||||
// 107-3 — 유입 날개벽 끝이 묻히면 수평 여유(흙막이 수평공간과 같은 칸 · 기본 1 m) 뒤 절토
|
||||
function wingCut(berm) {
|
||||
const x = clone(FORD);
|
||||
delete x.culvert;
|
||||
for (const wing of [x.ford.wing_in, x.ford.wing_out])
|
||||
Object.assign(wing, { installed: true, slab_extend_m: 1.414 });
|
||||
x.ford.wing_in.berm_m = berm;
|
||||
shiftGround(x, 3); // 바닥판 끝을 원지반 안으로
|
||||
const { ford } = computeStoredLayouts(
|
||||
x,
|
||||
sections.map((y) => (y === FORD ? x : y)),
|
||||
);
|
||||
return Object.fromEntries(ford.sides.map((side) => [side.role, pts(side.cutLine)]));
|
||||
}
|
||||
out.wingCut = { auto: wingCut(null), two: wingCut(2), zero: wingCut(0) };
|
||||
|
||||
process.stdout.write(JSON.stringify(out));
|
||||
@@ -0,0 +1,302 @@
|
||||
/* PLAN 108 단위검증 헬퍼 — 조정창 끌기 두 축 · 걸음 맞춤 · 미리 보기 = 결과 · BOX 끝 판정면.
|
||||
* TS 를 그 자리에서 트랜스파일해 Node 로 돌린다. 결과는 표준출력에 JSON 한 벌.
|
||||
* 끌기 손잡이(`bindDragHandle`)는 가로채서 창이 넘긴 `snap` 을 그대로 부른다.
|
||||
*
|
||||
* ⚠ 확장자가 `.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();
|
||||
/** 가로챌 모듈 — 아래에서 채운다(창 모듈을 부르기 전에). */
|
||||
let STUBS = {};
|
||||
|
||||
// ── 화면 그릇 흉내 — 조정창이 쓰는 만큼만 ─────────────────────────────────────
|
||||
class FakeNode {
|
||||
constructor(tag) {
|
||||
this.tagName = tag.toUpperCase();
|
||||
this.children = [];
|
||||
this.dataset = {};
|
||||
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),
|
||||
};
|
||||
}
|
||||
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);
|
||||
}
|
||||
get textContent() {
|
||||
return this._text + this.children.map((c) => c.textContent).join("");
|
||||
}
|
||||
append(...kids) {
|
||||
kids.forEach((k) => this.children.push(k));
|
||||
}
|
||||
setAttribute(name, value) {
|
||||
this[name] = value;
|
||||
}
|
||||
addEventListener() {}
|
||||
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;
|
||||
}
|
||||
}
|
||||
globalThis.document = {
|
||||
createElement: (tag) => new FakeNode(tag),
|
||||
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);
|
||||
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];
|
||||
if (request.startsWith("@util/"))
|
||||
return loadTs(path.join(ROOT, "common_util", `${request.slice(6)}.ts`));
|
||||
return loadTs(path.join(path.dirname(full), `${request}.ts`));
|
||||
};
|
||||
new Function("exports", "module", "require", js)(box.exports, box, localRequire);
|
||||
return box.exports;
|
||||
}
|
||||
|
||||
const B06 = (name) => path.join(ROOT, "B06_Section", `${name}.ts`);
|
||||
const base = loadTs(B06("B06_Section_UI_Cross_Panel_Base"));
|
||||
/** 창이 넘긴 끌기 deps — 마지막 것이 지금 창. */
|
||||
const drags = [];
|
||||
STUBS = {
|
||||
"./B06_Section_UI_Section_Common": { L: (key) => key },
|
||||
"@ui/ui_template_elements": { showToast: () => {} },
|
||||
"./B06_Section_UI_Cross_Panel_Base": {
|
||||
...base,
|
||||
bindDragHandle: (root, deps) => {
|
||||
drags.push(deps);
|
||||
return { attach: () => {}, detach: () => {} };
|
||||
},
|
||||
},
|
||||
};
|
||||
const { buildStructurePanel } = loadTs(B06("B06_Section_UI_Cross_Structure_Panel"));
|
||||
const { buildBoxPanel } = loadTs(B06("B06_Section_UI_Cross_Box_Panel"));
|
||||
const { buildFordPanel } = loadTs(B06("B06_Section_UI_Cross_Ford_Panel"));
|
||||
const { structurePanelDeps } = loadTs(B06("B06_Section_UI_Cross_View_Structure"));
|
||||
const { placePipeWall } = loadTs(B06("B06_Section_UI_Cross_Culvert_Solve"));
|
||||
const { boxHitBands } = loadTs(B06("B06_Section_UI_Cross_Box"));
|
||||
|
||||
const r3 = (v) => Math.round(v * 1000) / 1000;
|
||||
const snapOut = (s) => (s ? { leftM: r3(s.leftM), downM: r3(s.downM), text: s.text } : null);
|
||||
|
||||
// ── 벽 · 집수정 창 — 실제 배선(structurePanelDeps)으로 ─────────────────────────
|
||||
function wallCase({ key, structure = "revet", outward, adjust, appliedD = 0, basin, guard }) {
|
||||
const updates = [];
|
||||
const ctx = {
|
||||
section: { chainage_m: 100 },
|
||||
adjustChainage: () => 100,
|
||||
isLinked: false,
|
||||
revetOffset: { update: (at, role, patch) => updates.push({ role, ...patch }) },
|
||||
inletStructure: {
|
||||
valueFor: () => structure,
|
||||
adjustFor: () => basin ?? { innerWidthM: 1, innerHeightM: 1.2, lateralM: 0, slopeM: 0 },
|
||||
updateAdjust: (at, next) => updates.push({ basin: next }),
|
||||
},
|
||||
adjustOf: () => adjust,
|
||||
outwardOf: () => outward,
|
||||
heightOfWall: () => 2,
|
||||
drawnWallKeys: () => [key],
|
||||
formOfWall: () => "돌쌓기(메)",
|
||||
appliedD: () => new Map([[key, appliedD]]),
|
||||
inletIsBasin: () => structure !== "revet",
|
||||
hiddenPipe: () => false,
|
||||
bodyGuard: () => !!guard,
|
||||
fordGuard: () => false,
|
||||
inletOptions: () => ({ revetAllowed: true, basinLUAllowed: true }),
|
||||
extraState: () => ({ canAdd: true, count: 1 }),
|
||||
basinExtraState: () => ({ canAdd: false, count: 0 }),
|
||||
activeRevet: () => key,
|
||||
setActiveRevet: () => {},
|
||||
toggleRevet: () => {},
|
||||
scrollKey: "t",
|
||||
};
|
||||
const panel = buildStructurePanel(structurePanelDeps(ctx));
|
||||
panel.show(key);
|
||||
const drag = drags[drags.length - 1];
|
||||
return {
|
||||
panel,
|
||||
snap: (leftM, downM) => {
|
||||
const s = drag.snap(leftM, downM);
|
||||
if (s?.text) s.apply();
|
||||
return { snap: snapOut(s), update: updates.pop() ?? null };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** 배관 기준벽 자리(`placePipeWall`) — 미리 보기 = 결과 판정용. 화면 왼쪽 = offset +. */
|
||||
const wallAt = (outward, adjust) =>
|
||||
placePipeWall({
|
||||
autoOffset: 5 * outward,
|
||||
outward,
|
||||
height: 2,
|
||||
baseElevation0: 98,
|
||||
edgeElevation: 100,
|
||||
invertCap: 99,
|
||||
adjust: { x: adjust.x, d: adjust.d, h: null, m: null },
|
||||
defaultD: 0,
|
||||
});
|
||||
const shiftOf = (outward, from, to) => {
|
||||
const a = wallAt(outward, from);
|
||||
const b = wallAt(outward, to);
|
||||
return { leftM: r3(b.anchorOffset - a.anchorOffset), downM: r3(a.topElevation - b.topElevation) };
|
||||
};
|
||||
|
||||
const out = { wall: {}, box: {}, ford: {} };
|
||||
for (const outward of [1, -1]) {
|
||||
const from = { x: 0, d: 2, h: null, m: null };
|
||||
const { snap, panel } = wallCase({ key: "outlet", outward, adjust: from });
|
||||
// 대각 끌기 — 화면 왼쪽 3.3 · 아래 1.7 (outward = 1 기준), 반대편은 화면 좌우만 뒤집음
|
||||
const got = snap(3.3 * outward, 1.7);
|
||||
out.wall[`diag${outward}`] = {
|
||||
...got,
|
||||
result: got.update ? shiftOf(outward, from, got.update) : null,
|
||||
overlayHead: panel.root.children[0].children.map((c) => c.className),
|
||||
};
|
||||
}
|
||||
// 한계 — 사면 위로 넘김 · 안쪽으로 넘김 → 가장 가까운 허용 자리(d 0 · x 0)
|
||||
out.wall.limit = wallCase({
|
||||
key: "outlet",
|
||||
outward: 1,
|
||||
adjust: { x: 0.5, d: 0.5, h: null, m: null },
|
||||
}).snap(-5, -3);
|
||||
// 흙막이(세월교 · BOX) — 본체 끝이 0점이라 안쪽(음수 x)도 됨 · ±10 m 끝
|
||||
out.wall.guard = wallCase({
|
||||
key: "outlet",
|
||||
outward: 1,
|
||||
adjust: { x: 0, d: 0, h: null, m: null },
|
||||
guard: true,
|
||||
}).snap(-14, 0);
|
||||
// 다단 벽 — ▲▼ 순수 수직 · 0.1 걸음 · 선반 x ≥ 1.2·d 가 따라 나감
|
||||
out.wall.extra = wallCase({
|
||||
key: "extra0",
|
||||
outward: 1,
|
||||
adjust: { x: 0, d: 0, h: null, m: null },
|
||||
}).snap(0.04, 1.0);
|
||||
// 집수정 — 1:1.2 사면 · 0.1 걸음 · 좌우 · 사면 ≥ 0
|
||||
out.wall.basin = wallCase({
|
||||
key: "inlet",
|
||||
structure: "L",
|
||||
outward: -1,
|
||||
adjust: { x: 0, d: null, h: null, m: null },
|
||||
basin: { innerWidthM: 1, innerHeightM: 1.2, lateralM: 0.3, slopeM: 0 },
|
||||
}).snap(-0.86, 0.25);
|
||||
out.wall.basinNone = wallCase({
|
||||
key: "inlet",
|
||||
structure: "L",
|
||||
outward: -1,
|
||||
adjust: { x: 0, d: null, h: null, m: null },
|
||||
}).snap(0.02, -0.01);
|
||||
|
||||
// ── BOX 창 — 길이(화면 좌 +) · 표고 ───────────────────────────────────────────
|
||||
function boxCase(role, adjust, leftM, downM) {
|
||||
const moves = [];
|
||||
const panel = buildBoxPanel({
|
||||
adjustFor: () => adjust,
|
||||
nudgeLength: () => {},
|
||||
nudgeRise: () => {},
|
||||
move: (r, screen, rise) => moves.push({ role: r, screen: r3(screen), rise: r3(rise) }),
|
||||
reset: () => {},
|
||||
wingRoleFor: () => "inlet",
|
||||
bodyLengthM: () => 5,
|
||||
slopeRatio: () => null,
|
||||
close: () => {},
|
||||
});
|
||||
panel.show(role);
|
||||
const s = drags[drags.length - 1].snap(leftM, downM);
|
||||
if (s?.text) s.apply();
|
||||
return {
|
||||
snap: snapOut(s),
|
||||
move: moves[0] ?? null,
|
||||
value: panel.root.children[1].children.map((c) => c.className || "line"),
|
||||
};
|
||||
}
|
||||
out.box.left = boxCase("left", { lengthM: 0, riseM: 0 }, 0.47, 0.33);
|
||||
out.box.right = boxCase("right", { lengthM: 1, riseM: 0 }, -0.47, -0.21);
|
||||
out.box.shorten = boxCase("left", { lengthM: 0.2, riseM: 9.9 }, -0.9, -0.5);
|
||||
|
||||
// ── 세월교 창 — 좌우 + 1:1.2 사면 · 0.1 걸음 ─────────────────────────────────────
|
||||
function fordCase(outward, adjust, leftM, downM) {
|
||||
const moves = [];
|
||||
const panel = buildFordPanel({
|
||||
adjustFor: () => adjust,
|
||||
heightFor: () => 1.5,
|
||||
nudge: () => {},
|
||||
nudgeSlope: () => {},
|
||||
outwardOf: () => outward,
|
||||
move: (r, screen, slope) => moves.push({ role: r, screen: r3(screen), slope: r3(slope) }),
|
||||
reset: () => {},
|
||||
slabLengthM: () => 3.2,
|
||||
close: () => {},
|
||||
});
|
||||
panel.show("inlet");
|
||||
const s = drags[drags.length - 1].snap(leftM, downM);
|
||||
if (s?.text) s.apply();
|
||||
return {
|
||||
snap: snapOut(s),
|
||||
move: moves[0] ?? null,
|
||||
value: panel.root.children[1].children.map((c) => c.className || "line"),
|
||||
};
|
||||
}
|
||||
out.ford = {
|
||||
diag: fordCase(1, { heightM: null, lateralM: 0, slopeM: 0 }, 0.53, 0.27),
|
||||
inward: fordCase(-1, { heightM: null, lateralM: 0.2, slopeM: 0 }, 0.9, 0),
|
||||
};
|
||||
|
||||
// ── BOX 판정면 — 좌측 끝 날개벽(에이프런 4 → 6) · 우측 끝 날개벽 없음 ───────────
|
||||
const layout = {
|
||||
sides: [
|
||||
{ role: "left", offset: 4, apron: [{ offset: 4 }, { offset: 6 }] },
|
||||
{ role: "right", offset: -4, apron: [] },
|
||||
],
|
||||
topSlab: [{ elevation: 99 }, { elevation: 99 }],
|
||||
bottomSlab: [{ elevation: 96.5 }, { elevation: 96.5 }],
|
||||
};
|
||||
out.hit = Object.fromEntries(
|
||||
boxHitBands(layout).map(([role, points]) => [
|
||||
role,
|
||||
{
|
||||
offsets: [...new Set(points.map((p) => r3(p.offset)))].sort((a, b) => a - b),
|
||||
elevations: [...new Set(points.map((p) => p.elevation))].sort((a, b) => a - b),
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
process.stdout.write(JSON.stringify(out));
|
||||
@@ -107,6 +107,28 @@ const out = {
|
||||
}),
|
||||
};
|
||||
|
||||
// 107-1 — 유입 날개벽(측벽) 좌우 · 상하를 옮겨도 유출 흙막이 자리 그대로(관 1 m 맞춤 = 기본 자리 기준)
|
||||
const outletWallAt = (inlet) => {
|
||||
const section = clone(FORD);
|
||||
section.culvert.inlet = { role: "inlet", structure: "" };
|
||||
section.design.ford_adjust = { inlet: { heightM: null, ...inlet } };
|
||||
const { ford, culvert } = computeStoredLayouts(
|
||||
section,
|
||||
sections.map((s) => (s === FORD ? section : s)),
|
||||
);
|
||||
const wall = culvert.walls.find((w) => w.role === "outlet");
|
||||
const [right, left] = ford.pipe.outer.slice(2);
|
||||
return {
|
||||
back: r3(wall.bottomBack.offset),
|
||||
label: ford.label.text,
|
||||
lengthM: r3(Math.hypot(left.offset - right.offset, left.elevation - right.elevation)),
|
||||
};
|
||||
};
|
||||
out.inletMoves = [0, 0.1, 0.2, 0.3, 0.5, 1.0].map((lateralM) =>
|
||||
outletWallAt({ lateralM, slopeM: 0 }),
|
||||
);
|
||||
out.inletSlopes = [0.1, 0.5].map((slopeM) => outletWallAt({ lateralM: 0, slopeM }));
|
||||
|
||||
// 박스 352.14 — 흙막이를 바깥으로 밀면 본체 끝이 배면까지 따라감 · 안 밀면 그대로
|
||||
const BOX = sections.find((s) => Math.abs(s.chainage_m - 352.14) < 0.6);
|
||||
function boxFit(edit) {
|
||||
|
||||
@@ -68,7 +68,7 @@ const layoutsOf = (s, base) =>
|
||||
sections.map((x) => (x === base ? s : x)),
|
||||
);
|
||||
|
||||
// ① 91-2 세월교 바닥 — 자동은 하상을 따라감 · 손 값(측벽 높이)은 원지반을 바꿔도 그대로 · 흙막이 기본 높이도
|
||||
// ① 91-2 · 107-2 세월교 바닥 — 기본 · 손 값(측벽 높이)은 원지반을 바꿔도 그대로 · 흙막이 기본 높이도
|
||||
const FORD = at(173.087);
|
||||
function fordFloor(edit) {
|
||||
const s = clone(FORD);
|
||||
|
||||
@@ -186,7 +186,6 @@ for (const [label, key, kind] of [
|
||||
const fordDeps = {
|
||||
adjustFor: () => ({ heightM: null, lateralM: 0, slopeM: 0 }),
|
||||
heightFor: () => 1.5,
|
||||
nudgeHeight: () => {},
|
||||
nudge: () => {},
|
||||
nudgeSlope: () => {},
|
||||
reset: () => {},
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
/* M02 일위대가 로직 자재(`M02_Labor_LogicMats_Model.ts` `releaseTied`) 헬퍼 —
|
||||
* 그 자리에서 트랜스파일해 Node 로 돌림 · stdin = {row, before, mats} · stdout = 고친 row JSON. */
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
|
||||
|
||||
const file = path.join(__dirname, "..", "..", "M02_MasterTemplete", "M02_Labor_LogicMats_Model.ts");
|
||||
const js = ts.transpileModule(fs.readFileSync(file, "utf8"), {
|
||||
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
|
||||
}).outputText;
|
||||
const box = { exports: {} };
|
||||
new Function("exports", "module", "require", js)(box.exports, box, require);
|
||||
|
||||
const input = JSON.parse(fs.readFileSync(0, "utf8"));
|
||||
process.stdout.write(JSON.stringify(box.exports.releaseTied(input.row, input.before, input.mats)));
|
||||
@@ -0,0 +1,210 @@
|
||||
/* M02 일위대가 줄 통째 끌기(`M02_MasterTemplete_Labor_RowDrag.ts` · 102-2) 단위검증 헬퍼 —
|
||||
* 트랜스파일해 Node 로 돌림 · 작은 가짜 DOM(줄 높이 20px · 셀렉터는 그 파일이 쓰는 꼴만) · JSON 으로 냄. */
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
|
||||
|
||||
const file = path.join(
|
||||
__dirname,
|
||||
"..",
|
||||
"..",
|
||||
"M02_MasterTemplete",
|
||||
"M02_MasterTemplete_Labor_RowDrag.ts",
|
||||
);
|
||||
const js = ts.transpileModule(fs.readFileSync(file, "utf8"), {
|
||||
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
|
||||
}).outputText;
|
||||
|
||||
/* ---- 가짜 DOM ---- */
|
||||
const one = (node, sel) => {
|
||||
const m = /^(\w+)?(?:\[([\w-]+)\])?$/.exec(sel.trim());
|
||||
if (!m) throw new Error(`셀렉터 모름: ${sel}`);
|
||||
return (!m[1] || node.tag === m[1]) && (!m[2] || m[2] in node.attrs);
|
||||
};
|
||||
const matches = (node, sel) =>
|
||||
sel.split(",").some((part) => {
|
||||
const [up, me] = part.includes(">") ? part.split(">") : [null, part];
|
||||
return one(node, me) && (!up || (node.parent && one(node.parent, up)));
|
||||
});
|
||||
class Node {
|
||||
constructor(tag, attrs = {}, parent = null) {
|
||||
this.tag = tag;
|
||||
this.attrs = attrs;
|
||||
this.parent = parent;
|
||||
this.kids = [];
|
||||
this.on = {};
|
||||
this.style = {};
|
||||
const set = new Set();
|
||||
this.classList = {
|
||||
add: (...c) => c.forEach((x) => set.add(x)),
|
||||
remove: (...c) => c.forEach((x) => set.delete(x)),
|
||||
contains: (c) => set.has(c),
|
||||
list: () => [...set],
|
||||
};
|
||||
if (parent) parent.kids.push(this);
|
||||
}
|
||||
matches(sel) {
|
||||
return matches(this, sel);
|
||||
}
|
||||
closest(sel) {
|
||||
for (let n = this; n; n = n.parent) if (n.matches(sel)) return n;
|
||||
return null;
|
||||
}
|
||||
get previousElementSibling() {
|
||||
const k = this.parent.kids;
|
||||
return k[k.indexOf(this) - 1] ?? null;
|
||||
}
|
||||
get nextElementSibling() {
|
||||
const k = this.parent.kids;
|
||||
return k[k.indexOf(this) + 1] ?? null;
|
||||
}
|
||||
querySelectorAll(sel) {
|
||||
const out = [];
|
||||
const walk = (n) => n.kids.forEach((c) => (c.matches(sel) && out.push(c), walk(c)));
|
||||
walk(this);
|
||||
return out;
|
||||
}
|
||||
addEventListener(type, fn) {
|
||||
(this.on[type] ??= []).push(fn);
|
||||
}
|
||||
}
|
||||
|
||||
/* 표 — 줄 0 · 1(옵션 값 줄 · 하위 줄 둘 = 열린 줄) · 2 · 3 */
|
||||
const root = new Node("div");
|
||||
const tbody = new Node("tbody", {}, new Node("table", {}, root));
|
||||
const tr = (attrs) => new Node("tr", attrs, tbody);
|
||||
const rows = [
|
||||
tr({ "data-labor-logic": "A" }),
|
||||
tr({ "data-labor-logic": "B" }),
|
||||
tr({ "data-labor-opt": "B" }),
|
||||
tr({ "data-labor-sub": "" }),
|
||||
tr({ "data-labor-sub": "" }),
|
||||
tr({ "data-labor-logic": "C" }),
|
||||
tr({ "data-labor-logic": "D" }),
|
||||
];
|
||||
const cell = (r, tag = "td", attrs = {}) => new Node(tag, attrs, rows[r]);
|
||||
const td = rows.map((_, r) => cell(r));
|
||||
const btn = new Node("button", {}, cell(1));
|
||||
const input = new Node("input", { "data-labor-alias": "" }, cell(1));
|
||||
const qty = new Node("span", {}, new Node("button", { "data-labor-qty-open": "" }, cell(1)));
|
||||
const grip = new Node("span", { "data-labor-grip": "0" }, td[0]);
|
||||
const Y = (r) => r * 20 + 10;
|
||||
|
||||
let capture = null;
|
||||
root.hasPointerCapture = (id) => capture === id;
|
||||
root.setPointerCapture = (id) => (capture = id);
|
||||
root.releasePointerCapture = () => (capture = null);
|
||||
|
||||
const winClick = [];
|
||||
let frames = [];
|
||||
global.window = {
|
||||
addEventListener: (t, fn) => winClick.push(fn),
|
||||
removeEventListener: (t, fn) => {
|
||||
const i = winClick.indexOf(fn);
|
||||
if (i >= 0) winClick.splice(i, 1);
|
||||
},
|
||||
getSelection: () => ({ removeAllRanges() {} }),
|
||||
};
|
||||
global.requestAnimationFrame = (fn) => frames.push(fn);
|
||||
global.document = { elementFromPoint: (x, y) => td[Math.floor(y / 20)] ?? null };
|
||||
|
||||
const box = { exports: {} };
|
||||
new Function("exports", "module", "require", js)(box.exports, box, require);
|
||||
const m = box.exports;
|
||||
|
||||
/* ---- 부르기 ---- */
|
||||
const moves = [];
|
||||
m.attachRowDrag(root, (from, to) => moves.push([from, to]));
|
||||
const fire = (type, e) => root.on[type]?.forEach((fn) => fn({ preventDefault() {}, ...e }));
|
||||
const down = (target, y) =>
|
||||
fire("pointerdown", { button: 0, pointerId: 1, clientX: 50, clientY: y, target });
|
||||
const mv = (y, dx = 0) =>
|
||||
fire("pointermove", { pointerId: 1, buttons: 1, clientX: 50 + dx, clientY: y });
|
||||
/** 누름(click) — 창 캡처 삼킴을 먼저 거침 · 남으면 줄 열고 닫기 */
|
||||
const click = () => {
|
||||
let stopped = false;
|
||||
[...winClick].forEach((fn) =>
|
||||
fn({ stopPropagation: () => (stopped = true), preventDefault() {} }),
|
||||
);
|
||||
winClick.length = 0; // once
|
||||
return !stopped;
|
||||
};
|
||||
const frame = () => {
|
||||
const f = frames;
|
||||
frames = [];
|
||||
f.forEach((fn) => fn());
|
||||
};
|
||||
const classes = () => rows.map((r) => r.classList.list().sort().join(" "));
|
||||
|
||||
const out = {};
|
||||
out.threshold = [
|
||||
[5, 0],
|
||||
[6, 0],
|
||||
[0, -6],
|
||||
[5, 5],
|
||||
[-7, 2],
|
||||
].map(([dx, dy]) => m.pastThreshold(dx, dy));
|
||||
|
||||
// 1) 5px 만 움직임 = 누름 — 옮김 없음 · 누름 지나감(열고 닫기)
|
||||
down(td[0], Y(0));
|
||||
mv(Y(0) + 5);
|
||||
fire("pointerup", {});
|
||||
out.small = { moves: moves.length, clickPasses: click(), capture };
|
||||
|
||||
// 2) 줄 A(0) → 줄 C(차례 2) 로 끌기 — 끄는 동안 표시 · 다시 그림 미룸 · 놓으면 move(0,2) · 누름 삼킴
|
||||
let drawn = 0;
|
||||
const host = {};
|
||||
down(td[0], Y(0));
|
||||
mv(Y(0) + 7);
|
||||
m.afterRowDrag(host, () => drawn++);
|
||||
m.afterRowDrag(host, () => drawn++); // 마지막 것만
|
||||
mv(Y(5));
|
||||
out.during = { drawn, capture, classes: classes(), select: root.style.userSelect };
|
||||
fire("pointerup", {});
|
||||
out.drop = { moves: moves.splice(0), drawn, clickPasses: click(), classes: classes() };
|
||||
frame();
|
||||
out.afterFrame = { listeners: winClick.length };
|
||||
|
||||
// 3) 열린 줄 B 의 하위 줄에서 시작 → 위 줄 A 로 = move(1,0) · 표시 = A 위 선 · B 무리 흐림
|
||||
down(td[4], Y(4));
|
||||
mv(Y(0));
|
||||
out.fromSub = { classes: classes() };
|
||||
fire("pointerup", {});
|
||||
out.fromSub.moves = moves.splice(0);
|
||||
click();
|
||||
frame();
|
||||
|
||||
// 4) 줄 A → 열린 줄 B(아래로) — 표시 = B 무리 맨 아래(하위 줄 둘째) 선
|
||||
down(td[0], Y(0));
|
||||
mv(Y(2));
|
||||
out.downMark = classes();
|
||||
fire("pointerup", {});
|
||||
out.downMark.push(JSON.stringify(moves.splice(0)));
|
||||
click();
|
||||
frame();
|
||||
|
||||
// 5) 단추 · 입력칸 · 수량 여는 자리에서는 시작 안 함
|
||||
for (const [name, t] of [
|
||||
["button", btn],
|
||||
["input", input],
|
||||
["qty", qty],
|
||||
]) {
|
||||
down(t, Y(1));
|
||||
mv(Y(6));
|
||||
fire("pointerup", {});
|
||||
out[`block_${name}`] = { moves: moves.splice(0).length, capture, clickPasses: click() };
|
||||
}
|
||||
|
||||
// 6) 손잡이도 같은 길 · 제자리에 놓음 = 옮김 없음 · 누름은 삼킴(열림 그대로)
|
||||
down(grip, Y(0));
|
||||
mv(Y(0) + 8);
|
||||
fire("pointerup", {});
|
||||
out.gripSelf = { moves: moves.splice(0).length, clickPasses: click() };
|
||||
frame();
|
||||
|
||||
// 7) 끄는 중이 아니면 바로 그림
|
||||
let now = 0;
|
||||
m.afterRowDrag(host, () => now++);
|
||||
out.idleDraw = now;
|
||||
|
||||
process.stdout.write(JSON.stringify(out));
|
||||
@@ -0,0 +1,68 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""PLAN 106 — 구간형 구조물 범위 강조 · B05/B06 화면 글 「흙막이」.
|
||||
|
||||
• 106-1 구조물을 고르면 시작~종료 안 횡단 카드 전부에 `--span` 클래스 · 카드를 다시 만들어도 유지
|
||||
• 106-2 로케일 · 툴팁 · 정의 label 에 「기슭막이」 없음(독립=D군 · 사방 근거 글 빼고) · 저장 값 · choices 그대로
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
ROOT = os.path.abspath(os.path.join(HERE, "..", ".."))
|
||||
|
||||
|
||||
def _read(*parts: str) -> str:
|
||||
with open(os.path.join(ROOT, *parts), encoding="utf-8") as handle:
|
||||
return handle.read()
|
||||
|
||||
|
||||
VIEW = _read("B06_Section", "B06_Section_UI_Section_View.ts")
|
||||
|
||||
|
||||
def test_범위_강조_클래스는_고름_목록갱신_카드재생성_세_길에서_칠한다():
|
||||
assert VIEW.count("b06-cross-card--span") == 2 # syncSpanHighlights · buildCrossCard
|
||||
assert re.search(r"selectMark: \(structureId\) => \{\s+spanMarkId = structureId;", VIEW)
|
||||
assert re.search(r"spanMarks = structures;\s+syncSpanHighlights\(\);", VIEW)
|
||||
assert 'card.classList.toggle("b06-cross-card--span", inSpan(section))' in VIEW
|
||||
|
||||
|
||||
def test_범위_판정은_시작_종료가_다른_구간형만():
|
||||
assert "start == null || end == null || start === end" in VIEW
|
||||
|
||||
|
||||
def test_범위_강조_CSS는_선택_강조를_덮지_않는다():
|
||||
css = _read("B06_Section", "B06_Section_UI_Style_Cross.css")
|
||||
assert ".b06-cross-card--span:not(.b06-cross-card--selected)" in css
|
||||
|
||||
|
||||
def test_로케일에_기슭막이는_독립_한_곳뿐():
|
||||
locale = _read("ui_template", "ui_template_locale_b2.ts")
|
||||
korean = [
|
||||
line
|
||||
for line in locale.splitlines()
|
||||
if "기슭막이" in line and not line.strip().startswith(("//", "/*"))
|
||||
]
|
||||
assert len(korean) == 1 and "B06_Cross_Revet_Own" in korean[0]
|
||||
|
||||
|
||||
def test_정의_label은_흙막이_choices_값은_그대로():
|
||||
definition = json.loads(_read("resources", "master_template", "구조물정의.json"))
|
||||
labels, choices = [], []
|
||||
|
||||
def walk(node):
|
||||
if isinstance(node, dict):
|
||||
if isinstance(node.get("label"), str):
|
||||
labels.append(node["label"])
|
||||
if node.get("key") in ("inlet_type", "outlet_type"):
|
||||
choices.append(node["choices"])
|
||||
for value in node.values():
|
||||
walk(value)
|
||||
elif isinstance(node, list):
|
||||
for value in node:
|
||||
walk(value)
|
||||
|
||||
walk(definition)
|
||||
assert not [label for label in labels if "기슭막이" in label and label != "기슭막이"]
|
||||
assert ["기슭막이", "집수정"] in choices and ["기슭막이"] in choices
|
||||
@@ -0,0 +1,92 @@
|
||||
"""107 세월교 — 유입 · 유출 따로 · 원지반 제약 삭제 · 유입 날개벽 수평 여유.
|
||||
|
||||
화면 TS 를 그 자리에서 돌린 값(`helper_107_ford_free.cjs`) · 픽스처 = 73-4 세월교 173.09 · 박스 352.14.
|
||||
"""
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
if str(ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
HELPER = Path(__file__).parent / "helper_107_ford_free.cjs"
|
||||
NODE_MODULES = ROOT / "config" / "node_modules"
|
||||
|
||||
pytestmark = pytest.mark.skipif(not NODE_MODULES.is_dir(), reason="프론트엔드 의존성 미설치")
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def out() -> dict:
|
||||
done = subprocess.run( # noqa: S603 — 고정 실행 파일
|
||||
["node", str(HELPER)], cwd=str(ROOT), capture_output=True, text=True, encoding="utf-8"
|
||||
)
|
||||
assert done.returncode == 0, done.stderr
|
||||
return json.loads(done.stdout)
|
||||
|
||||
|
||||
def _read(rel: str) -> str:
|
||||
return (ROOT / rel).read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_세월교_박스를_고르면_같은_측점_벽_고름을_풂(out: dict) -> None:
|
||||
assert out["released"] == [173.09, 352.14]
|
||||
|
||||
|
||||
def test_벽을_고르면_세월교_박스_고름을_풂() -> None:
|
||||
station = _read("B06_Section/B06_Section_UI_Page_Station_Controls.ts")
|
||||
assert (
|
||||
"if (key && ford.control.selectedFor(chainageM)) ford.control.select(chainageM, null);"
|
||||
in station
|
||||
)
|
||||
assert (
|
||||
"if (key && box.control.selectedFor(chainageM)) box.control.select(chainageM, null);"
|
||||
in station
|
||||
)
|
||||
view = _read("B06_Section/B06_Section_UI_Cross_View.ts")
|
||||
# 카드 안 — 벽을 고르면 본체 창을 닫고 · 본체를 고르면 벽 창을 닫음
|
||||
assert "bodies.clearSelection(); // 세월교 · 박스 고름을 풂" in view
|
||||
assert "if (activeRevet !== null) toggleRevet(activeRevet);" in view
|
||||
|
||||
|
||||
@pytest.mark.parametrize("variant", ["guards", "inletWing"])
|
||||
def test_세월교_원지반을_바꿔도_바닥_그대로(out: dict, variant: str) -> None:
|
||||
"""107-2 — 바닥 = 노면 − (관경 + 최소 토피) · 하상 −2 m · 전체 +0.5 / −1 m 사본도 같은 모양."""
|
||||
runs = out["ford"][variant]
|
||||
for name, shape in runs.items():
|
||||
assert shape == runs["base"], name
|
||||
base = runs["base"]
|
||||
assert all(side["depth"] == pytest.approx(base["pipeCover"]) for side in base["sides"])
|
||||
|
||||
|
||||
@pytest.mark.parametrize("variant", ["guards", "bare"])
|
||||
def test_박스_원지반을_바꿔도_본체_끝_날개_흙막이_그대로(out: dict, variant: str) -> None:
|
||||
runs = out["box"][variant]
|
||||
for name, shape in runs.items():
|
||||
assert shape == runs["base"], name
|
||||
if variant == "bare":
|
||||
assert all(side["apron"] for side in runs["base"]["sides"])
|
||||
else:
|
||||
assert len(runs["base"]["walls"]) == 2
|
||||
|
||||
|
||||
def test_유입_날개벽_끝이_묻히면_수평_여유_뒤_절토(out: dict) -> None:
|
||||
"""107-3 — 흙막이 수평공간과 같은 칸(빈 값 = 1 m) · 유출 날개벽은 수평 구간 없음."""
|
||||
cuts = out["wingCut"]
|
||||
for case, berm in (("auto", 1.0), ("two", 2.0)):
|
||||
(x0, z0), (x1, z1) = cuts[case]["inlet"][:2]
|
||||
assert z1 == pytest.approx(z0) and abs(x1 - x0) == pytest.approx(berm), case
|
||||
(x0, z0), (x1, z1) = cuts["zero"]["inlet"][:2]
|
||||
assert z1 > z0 + 0.1 # 0 = 바로 절토
|
||||
assert cuts["auto"]["outlet"][1][1] > cuts["auto"]["outlet"][0][1] + 0.1
|
||||
|
||||
|
||||
def test_날개벽_수평_여유_칸은_흙막이_수평공간과_같은_키() -> None:
|
||||
ford = _read("A00_Common/design/design_structure_ford.ts")
|
||||
assert '...bodyGuardItems(id, "본체 끝 높이(자동)", true),' in ford
|
||||
guard = _read("A00_Common/design/design_structure_guard.ts")
|
||||
assert "map: wingBerm ? { 흙막이: branch, 날개벽: branch } : { 흙막이: branch }," in guard
|
||||
@@ -0,0 +1,124 @@
|
||||
"""PLAN 108 — 횡단도 안 조정창 끌기 두 축 · 패널 걸음 맞춤 · 미리 보기 = 결과 · BOX 끝 판정면.
|
||||
|
||||
짝: `B06_Section/B06_Section_UI_Cross_Panel_Base.ts`(`snapAxisDrag` · `bindDragHandle`) ·
|
||||
`_Cross_Structure_Panel.ts` · `_Cross_View_Structure.ts`(벽 · 집수정 축 · 한계) · `_Cross_Box_Panel.ts` ·
|
||||
`_Cross_Box.ts`(`boxHitBands`). 실제 TS 를 `helper_108_drag_box_hit.cjs` 가 Node 로 돌린다.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
|
||||
import pytest
|
||||
|
||||
HERE = os.path.dirname(__file__)
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def out() -> dict:
|
||||
proc = subprocess.run( # noqa: S603 — 고정 실행 파일
|
||||
["node", os.path.join(HERE, "helper_108_drag_box_hit.cjs")],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
timeout=60,
|
||||
)
|
||||
assert proc.returncode == 0, proc.stderr
|
||||
return json.loads(proc.stdout)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("side", ["diag1", "diag-1"])
|
||||
def test_대각_끌기는_두_축이_함께_걸음_배수로(out, side) -> None:
|
||||
"""배관 기준벽(1 m 걸음) — 사면 d 두 칸 + 수평 x 한 칸이 한 번에 · 값은 걸음 배수."""
|
||||
case = out["wall"][side]
|
||||
assert case["update"] == {"role": "outlet", "x": 1, "d": 4}
|
||||
assert "◀" in case["snap"]["text"] or "▶" in case["snap"]["text"]
|
||||
assert "▼ 2.0m" in case["snap"]["text"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("side", ["diag1", "diag-1"])
|
||||
def test_미리보기_자리는_놓은_뒤_자리와_같다(out, side) -> None:
|
||||
"""미리 보기 이동 = `placePipeWall` 로 다시 푼 벽 자리 이동(화면 왼쪽 · 아래)."""
|
||||
case = out["wall"][side]
|
||||
assert case["snap"]["leftM"] == case["result"]["leftM"]
|
||||
assert case["snap"]["downM"] == case["result"]["downM"]
|
||||
|
||||
|
||||
def test_한계면_가장_가까운_허용_자리(out) -> None:
|
||||
"""사면 위로 · 안쪽으로 넘겨 끌면 d 0 · x 0 에서 멈춤 — 원래 자리로 돌아가지 않음."""
|
||||
case = out["wall"]["limit"]
|
||||
assert case["update"] == {"role": "outlet", "x": 0, "d": 0}
|
||||
assert case["snap"] == {"leftM": -1, "downM": -0.417, "text": "▶ 0.5m · ▲ 0.5m"}
|
||||
|
||||
|
||||
def test_흙막이는_안쪽도_되고_10m_에서_멈춘다(out) -> None:
|
||||
assert out["wall"]["guard"]["update"] == {"role": "outlet", "x": -10, "d": 0}
|
||||
assert out["wall"]["guard"]["snap"]["leftM"] == -10
|
||||
|
||||
|
||||
def test_다단_벽은_순수_수직_선반이_따라_나감(out) -> None:
|
||||
"""다단 ▲▼ = 수직 하강 · x ≥ 1.2·d(선반) — 미리 보기도 그 자리."""
|
||||
case = out["wall"]["extra"]
|
||||
assert case["update"] == {"role": "extra0", "x": 1.2, "d": 1}
|
||||
assert case["snap"]["leftM"] == 1.2 and case["snap"]["downM"] == 1
|
||||
|
||||
|
||||
def test_집수정은_사면_축_0_1_걸음(out) -> None:
|
||||
case = out["wall"]["basin"]
|
||||
assert case["update"]["basin"]["lateralM"] == 0.9
|
||||
assert case["update"]["basin"]["slopeM"] == 0.3
|
||||
assert case["snap"] == {"leftM": -0.9, "downM": 0.25, "text": "▶ 0.6m · ▼ 0.3m"}
|
||||
|
||||
|
||||
def test_걸음_반도_안_끌면_반영_없음(out) -> None:
|
||||
assert out["wall"]["basinNone"] == {
|
||||
"snap": {"leftM": 0, "downM": 0, "text": ""},
|
||||
"update": None,
|
||||
}
|
||||
|
||||
|
||||
def test_BOX_끌기는_길이_표고_함께(out) -> None:
|
||||
assert out["box"]["left"]["move"] == {"role": "left", "screen": 0.5, "rise": -0.3}
|
||||
assert out["box"]["left"]["snap"]["leftM"] == 0.5
|
||||
assert out["box"]["right"]["move"] == {"role": "right", "screen": -0.5, "rise": 0.2}
|
||||
|
||||
|
||||
def test_BOX_한계_길이_0_표고_10(out) -> None:
|
||||
"""안으로 줄이면 길이 0 에서 · 표고는 +10 m 에서 멈춤."""
|
||||
assert out["box"]["shorten"]["move"] == {"role": "left", "screen": -0.2, "rise": 0.1}
|
||||
|
||||
|
||||
def test_세월교_끌기는_좌우_사면_함께(out) -> None:
|
||||
"""사면 d 0.3 · 좌우 0.2(0.1 걸음) — 미리 보기 = 좌 0.2 + 0.3 · 아래 0.3/1.2."""
|
||||
case = out["ford"]["diag"]
|
||||
assert case["move"] == {"role": "inlet", "screen": 0.2, "slope": 0.3}
|
||||
assert case["snap"] == {"leftM": 0.5, "downM": 0.25, "text": "◀ 0.2m · ▼ 0.3m"}
|
||||
|
||||
|
||||
def test_세월교_노견_안쪽은_0_에서_멈춤(out) -> None:
|
||||
"""좌우 0.2 에서 안쪽으로 0.9 끌어도 0 까지만(가장 가까운 허용 자리)."""
|
||||
case = out["ford"]["inward"]
|
||||
assert case["move"] == {"role": "inlet", "screen": 0.2, "slope": 0}
|
||||
assert case["snap"]["leftM"] == 0.2
|
||||
|
||||
|
||||
def test_조정창은_제목과_단추만(out) -> None:
|
||||
"""머리줄 = 제목 · 닫기(집수정 ↺ 없음) · BOX 오버레이 읽기 줄 = 끄는 중 미리 보기 하나."""
|
||||
assert out["wall"]["diag1"]["overlayHead"] == [
|
||||
"b06-structure-panel__title",
|
||||
"b06-structure-panel__btn b06-structure-panel__close",
|
||||
]
|
||||
assert out["box"]["left"]["value"] == ["b06-structure-panel__drag"]
|
||||
assert out["ford"]["diag"]["value"] == ["b06-structure-panel__drag"]
|
||||
|
||||
|
||||
def test_BOX_끝_판정면은_본체로_안_넘어감(out) -> None:
|
||||
"""본체 = 두 구체 끝 사이 · 끝 = 구체 끝 ~ 에이프런 바깥 · 날개벽 없는 끝 = 바깥 0.3 m 띠."""
|
||||
hit = out["hit"]
|
||||
assert hit["body"]["offsets"] == [-4, 4]
|
||||
assert hit["left"]["offsets"] == [4, 6]
|
||||
assert hit["right"]["offsets"] == [-4.3, -4]
|
||||
for role in ("body", "left", "right"):
|
||||
assert hit[role]["elevations"] == [96.5, 99]
|
||||
@@ -0,0 +1,131 @@
|
||||
"""110 — B08 수리 판정 하상경사 ← B06 횡단 경사.
|
||||
|
||||
관 = 관 바닥 두 끝 경사(`CulvertLayout.pipe.slopePct`) · 박스 = 박스 두 끝 원지반 경사(본체는 늘 수평) ·
|
||||
관 길이와 같은 길(`STRUCTURE_ROW_KEYS` → 서버 Node → `_AREA_KEYS` → 정본) · B08 은 연결값(linked)으로
|
||||
실음(저장된 측점 null 이 덮지 않게) · 0 % 이하는 빈칸(손 입력) · 세월교 · 물넘이는 장 기본값 그대로.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
if str(ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from B06_Section.B06_Section_Schema import CrossSectionPatch # noqa: E402
|
||||
from B08_Quantity.B08_Quantity_Engine_Hydro import compute # noqa: E402
|
||||
from B08_Quantity.B08_Quantity_Hydro_Defaults import ( # noqa: E402
|
||||
DEFAULTS,
|
||||
SHEET_DEFAULTS,
|
||||
station_rows,
|
||||
)
|
||||
from common_util.common_util_drainage_pipes import PipePoint # noqa: E402
|
||||
|
||||
FIXTURES = Path(__file__).parent / "fixtures"
|
||||
NODE_MODULES = ROOT / "config" / "node_modules"
|
||||
needs_node = pytest.mark.skipif(not NODE_MODULES.is_dir(), reason="프론트엔드 의존성 미설치")
|
||||
|
||||
BASINS = [
|
||||
{"kind": "detail_basin", "chainage_m": c, "area_m2": 10717.0, "flow_length_m": 163.9,
|
||||
"relief_m": 120.0, "design_flow_m3s": 1.0, "tc_minutes": 5.0}
|
||||
for c in (85.05, 173.09, 300.0, 400.0, 500.0)
|
||||
] # fmt: skip
|
||||
POINTS = [
|
||||
PipePoint(chainage_m=85.052, options={"pipe_diameter_mm": "1000"}),
|
||||
PipePoint(chainage_m=173.09, facility="ford_bridge", options={"ford_width_m": 10}),
|
||||
PipePoint(chainage_m=300.004, facility="box_culvert",
|
||||
options={"body_width_m": 2.0, "body_height_m": 1.5}),
|
||||
PipePoint(chainage_m=400.0, options={"pipe_diameter_mm": 800}),
|
||||
PipePoint(chainage_m=500.0, options={"pipe_diameter_mm": 800}),
|
||||
] # fmt: skip
|
||||
DESIGNS = [
|
||||
# 관 자리 85.052 ↔ 횡단 85.0(스냅 · 0.5 m 안)
|
||||
{"chainage_m": 85.0, "design": {"pipe_slope_pct": 5.0, "pipe_length_m": 9}},
|
||||
# 세월교에 경사 키가 있어도 안 이음(장 기본값)
|
||||
{"chainage_m": 173.09, "design": {"pipe_slope_pct": 4.0, "box_ground_slope_pct": 4.0}},
|
||||
{"chainage_m": 300.0, "design": {"box_ground_slope_pct": 2.0}},
|
||||
{"chainage_m": 400.0, "design": {"pipe_slope_pct": 0.0}}, # 수평 관 → 빈칸
|
||||
{"chainage_m": 501.0, "design": {"pipe_slope_pct": 3.0}}, # 1 m 떨어짐 → 짝 없음
|
||||
]
|
||||
|
||||
|
||||
def _rows() -> dict[float, dict]:
|
||||
return {r["chainage_m"]: r for r in station_rows(POINTS, BASINS, DESIGNS)}
|
||||
|
||||
|
||||
def test_관_경사가_하상경사_칸으로() -> None:
|
||||
assert _rows()[85.052]["linked"]["slope_denominator"] == 20.0 # 5 % → 1/20
|
||||
|
||||
|
||||
def test_박스는_원지반_경사() -> None:
|
||||
assert _rows()[300.004]["linked"]["slope_denominator"] == 50.0 # 2 % → 1/50
|
||||
|
||||
|
||||
def test_0_퍼센트_이하와_짝_없음은_빈칸() -> None:
|
||||
rows = _rows()
|
||||
assert "slope_denominator" not in rows[400.0]["linked"]
|
||||
assert "slope_denominator" not in rows[500.0]["linked"]
|
||||
assert DEFAULTS["slope_denominator"] is None # 손 입력 자리
|
||||
|
||||
|
||||
def test_세월교는_장_기본값_그대로() -> None:
|
||||
assert "slope_denominator" not in _rows()[173.09]["linked"]
|
||||
assert SHEET_DEFAULTS["ford"]["slope_denominator"] > 0
|
||||
|
||||
|
||||
def test_연결값으로_판정이_나옴() -> None:
|
||||
row = _rows()[85.052]
|
||||
out = compute({**DEFAULTS, **row["linked"], "r24_mm": 231.56, "sheet": "pipe"})
|
||||
assert out["ok"] in (True, False)
|
||||
assert out["slope_bed"] == pytest.approx(0.05)
|
||||
|
||||
|
||||
def test_저장된_측점_null_이_연결값을_덮지_않음() -> None:
|
||||
"""화면은 저장본 · 초안의 null 을 연결값 칸에서 건너뜀 — 연결값으로 실어야 저장된 측점도 바뀜."""
|
||||
ui = (ROOT / "B08_Quantity" / "B08_Quantity_UI_Hydro.ts").read_text(encoding="utf-8")
|
||||
assert "v != null || !(k in linkedOf(station))" in ui
|
||||
assert "...station.linked," in ui
|
||||
|
||||
|
||||
def test_패치가_받는다() -> None:
|
||||
patch = CrossSectionPatch(chainage_m=1, pipe_slope_pct=0.0, box_ground_slope_pct=-3.5)
|
||||
assert (patch.pipe_slope_pct, patch.box_ground_slope_pct) == (0.0, -3.5)
|
||||
|
||||
|
||||
def _server_rows(sections: list[dict]) -> dict[float, dict]:
|
||||
from B06_Section.B06_Section_Server_Calc_Prebuild import _NPM_SCRIPT, BUNDLE
|
||||
from common_util.common_util_node_bundle import run_bundle_json
|
||||
|
||||
output = run_bundle_json(BUNDLE, _NPM_SCRIPT, {"detail": {"cross_sections": sections}})
|
||||
assert output, "서버 Node 번들 실행 실패"
|
||||
return {round(r["chainage_m"], 3): r for r in output["areas"]}
|
||||
|
||||
|
||||
@needs_node
|
||||
def test_서버가_관_경사를_관_길이와_같이_냄() -> None:
|
||||
sections = json.loads((FIXTURES / "b07_cross_overlay_sections.json").read_text("utf-8"))
|
||||
row = _server_rows(sections["cross_sections"])[85.052]
|
||||
assert row["pipe_length_m"] > 0
|
||||
# 관 바닥 두 끝 경사 — 유입이 유출보다 낮게 그려지지 않음(역경사는 수평으로 클램프)
|
||||
assert row["pipe_slope_pct"] >= 0
|
||||
|
||||
|
||||
@needs_node
|
||||
def test_서버가_박스_원지반_경사를_주인_측점에만() -> None:
|
||||
data = json.loads((FIXTURES / "b06_73_4_body_guard_sections.json").read_text("utf-8"))
|
||||
sections = data["cross_sections"]
|
||||
box_at = next(s for s in sections if s.get("box"))
|
||||
# 옆 측점에 같은 박스가 걸쳐도(폭 안 연동) 주인 측점에만 실림
|
||||
neighbour = next(s for s in sections if s["chainage_m"] == 340)
|
||||
neighbour["box"] = copy.deepcopy(box_at["box"])
|
||||
rows = _server_rows(sections)
|
||||
assert isinstance(rows[352.14]["box_ground_slope_pct"], float)
|
||||
assert "box_ground_slope_pct" not in rows[340]
|
||||
# 세월교 · 박스 흙막이는 관이 아님 — 관 경사 안 실음
|
||||
assert all("pipe_slope_pct" not in r for r in rows.values())
|
||||
@@ -56,6 +56,9 @@ def test_유입_유출은_구조와_흙막이_칸만(out):
|
||||
for spec in ("ford", "box"):
|
||||
for side in ("inlet", "outlet"):
|
||||
want = GUARD if side == "inlet" else GUARD[:-1]
|
||||
if spec == "ford" and side == "inlet":
|
||||
# 세월교 유입 날개벽도 같은 칸(수평 여유 · 107-3)
|
||||
want = [*GUARD[:-1], ["berm", ["흙막이", "날개벽"], "1"]]
|
||||
assert out[spec][side] == want, (spec, side)
|
||||
assert [i[0] for i in out[spec]["soil_add"]] == [
|
||||
"form",
|
||||
@@ -164,12 +167,9 @@ def test_집계표_세월교_박스_흙막이_상자는_유출_단_수만큼():
|
||||
assert groups(type_id, "", None) == []
|
||||
|
||||
|
||||
def test_B06_세월교_박스_칸은_측벽_높이_줄만_받음():
|
||||
def test_B06_세월교_박스_칸은_받는_줄_없음():
|
||||
"""107-3 — 측벽 높이 줄 지움."""
|
||||
dock = (ROOT / "B06_Section/B06_Section_UI_Adjust_Dock.ts").read_text(encoding="utf-8")
|
||||
assert (
|
||||
"slot === sideSlots?.wingInlet || slot === sideSlots?.wingOutlet) return WALL_HEIGHT_ROW"
|
||||
in dock
|
||||
)
|
||||
assert 'WALL_HEIGHT_ROW = ".b06-structure-panel__struct--wall-height"' in dock
|
||||
assert "slot === sideSlots?.wingInlet || slot === sideSlots?.wingOutlet) return null" in dock
|
||||
lists = (ROOT / "B05_Profile/B05_Profile_UI_Structures_List.ts").read_text(encoding="utf-8")
|
||||
assert "return isNone(c, state[c.id][0])" in lists
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"""
|
||||
|
||||
import json
|
||||
import math
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
@@ -74,3 +75,11 @@ def test_박스도_흙막이를_밀면_본체가_따라감(fit: dict) -> None:
|
||||
assert fit["boxInletPlus1"]["bodyLengthM"] == pytest.approx(
|
||||
fit["boxAuto"]["bodyLengthM"] + 1, abs=1e-3
|
||||
)
|
||||
|
||||
|
||||
def test_유입_측벽을_옮겨도_유출_흙막이_자리_그대로(fit: dict) -> None:
|
||||
"""107-1 — 관 1 m 맞춤은 기본 자리 기준 · 사용자 이동이 반대쪽 흙막이를 안 밈 · L 은 올림."""
|
||||
backs = {case["back"] for case in fit["inletMoves"] + fit["inletSlopes"]}
|
||||
assert len(backs) == 1, fit["inletMoves"]
|
||||
for case in fit["inletMoves"] + fit["inletSlopes"]:
|
||||
assert case["label"].endswith(f"L{math.ceil(case['lengthM'] - 1e-6)}m")
|
||||
|
||||
@@ -29,9 +29,9 @@ def fit() -> dict:
|
||||
return json.loads(done.stdout)
|
||||
|
||||
|
||||
def test_세월교_바닥_자동은_하상을_따라감(fit: dict) -> None:
|
||||
for before, after in zip(fit["fordAuto"], fit["fordAutoBedDown"]):
|
||||
assert after["slabTop"] < before["slabTop"] - 1.0
|
||||
def test_세월교_바닥_기본도_원지반을_바꿔도_그대로(fit: dict) -> None:
|
||||
"""107-2 — 기본 바닥 = 노면 − (관경 + 토피) · 하상을 안 따라감(옛 91-2 「자동은 하상」 뒤집음)."""
|
||||
assert fit["fordAuto"] == fit["fordAutoBedDown"]
|
||||
|
||||
|
||||
def test_세월교_바닥_손_값은_원지반을_바꿔도_그대로(fit: dict) -> None:
|
||||
@@ -41,9 +41,11 @@ def test_세월교_바닥_손_값은_원지반을_바꿔도_그대로(fit: dict)
|
||||
assert side["wallBottom"] == pytest.approx(side["slabTop"] - 0.3, abs=1e-3)
|
||||
|
||||
|
||||
def test_세월교_박스_칸은_측벽_높이_줄을_받음() -> None:
|
||||
def test_세월교_박스_칸은_측벽_높이_줄_없음() -> None:
|
||||
"""107-3 — 「측벽 높이(자동)」 줄 지움(유입 · 유출)."""
|
||||
dock = (ROOT / "B06_Section/B06_Section_UI_Adjust_Dock.ts").read_text(encoding="utf-8")
|
||||
assert "sideSlots?.wingOutlet) return WALL_HEIGHT_ROW" in dock
|
||||
assert "sideSlots?.wingOutlet) return null" in dock
|
||||
assert "wall-height" not in dock
|
||||
|
||||
|
||||
def test_다단_자동_자리는_원지반과_무관_사면_1m(fit: dict) -> None:
|
||||
|
||||
@@ -68,6 +68,7 @@ def test_측점마다_연결값() -> None:
|
||||
|
||||
|
||||
def test_잇지_않는_칸() -> None:
|
||||
# 하상경사는 B06 횡단 경사가 있을 때만 이음(110 · test_110_b08_bed_slope) — 설계 없이는 빈칸
|
||||
for row in station_rows(POINTS, BASINS):
|
||||
assert not {"slope_denominator", "pipe_kind", "n"} & set(row["linked"])
|
||||
|
||||
|
||||
@@ -41,7 +41,7 @@ def test_폼과_같은_값은_사본_줄로_만들지_않는다(out) -> None:
|
||||
assert out["dock"]["outlet"] == ["options", "readout", "tier"]
|
||||
# 집수정 내공 줄은 화면에서 숨김(54-40 · 값 · 그림 그대로)
|
||||
assert out["dock"]["basin"] == ["options", "readout"]
|
||||
assert out["dock"]["ford"] == ["readout", "wall-height"]
|
||||
assert out["dock"]["ford"] == ["readout"] # 측벽 높이 줄 지움(107-3)
|
||||
assert out["dock"]["box"] == ["readout"]
|
||||
|
||||
|
||||
@@ -86,7 +86,6 @@ def test_사본_줄은_모두_폼_칸으로_옮겨_붙는다() -> None:
|
||||
"tier",
|
||||
"options",
|
||||
"basin-size",
|
||||
"wall-height",
|
||||
"form",
|
||||
"height",
|
||||
"span",
|
||||
|
||||
@@ -207,6 +207,9 @@ def test_ford_box_guard_attached_as_culvert() -> None:
|
||||
assert "culvert" not in sections[100.0]
|
||||
# 유입만 수평공간(89-4) · 도수로/돌붙임 두께 cm → m(90-2)
|
||||
assert ford["inlet"]["revet_berm_m"] == 2 and "revet_berm_m" not in ford["outlet"]
|
||||
# 유입 날개벽 수평 여유 = 같은 칸(107-3) · 안 세운 날개벽엔 안 실림
|
||||
wings = sections[60.0]["ford"]
|
||||
assert wings["wing_in"]["berm_m"] == 2 and "berm_m" not in wings["wing_out"]
|
||||
assert sections[60.0]["ford"]["chute"] == {"structure": "도수로", "thickness_m": 0.3}
|
||||
assert box["chute"] == {"structure": "돌붙임", "thickness_m": 0.45}
|
||||
assert "chute" not in sections[40.0]["culvert"]
|
||||
|
||||
@@ -40,6 +40,9 @@ _COMPUTED_FIELDS = {
|
||||
# 다리**(`STRUCTURE_ROW_KEYS` → `B06_Section_Server_Calc_Node` →
|
||||
# `recompute_server_side`)로 서버가 다시 내므로 재계산에서 안 사라진다.
|
||||
"pipe_length_m",
|
||||
# B08 하상경사 원천(110) — 관 경사 · 박스 원지반 경사(%) · 관 길이와 같은 다리.
|
||||
"pipe_slope_pct",
|
||||
"box_ground_slope_pct",
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -52,7 +52,7 @@ def test_면적을_못_내는_측점에서도_길이는_낸다():
|
||||
"""설계선이 모자라 폐회로 면적을 못 내는 자리에도 **관은 서 있다** — 수량은 그 길이가 필요하다."""
|
||||
source = _LAYOUTS.read_text(encoding="utf-8")
|
||||
assert "return pipeRow;" in source, "면적 실패 시 null 로 떨어지면 관 길이까지 사라진다"
|
||||
assert "layouts.culvert?.pipe?.lengthM" in source
|
||||
assert "layouts.culvert?.pipe" in source and "pipe.lengthM" in source
|
||||
|
||||
|
||||
def test_이름이_한_벌이다():
|
||||
@@ -99,8 +99,8 @@ def test_소유_측점에만_싣는다():
|
||||
**하나만 고르는 것**은 그대로라 두 번 세지 않는다.
|
||||
"""
|
||||
layouts = _LAYOUTS.read_text(encoding="utf-8")
|
||||
assert "pipeOwnerChainage" in layouts, "소유 측점을 안 가리고 있다"
|
||||
assert "section.culvert?.chainage_m" in layouts, "관이 놓인 자리를 안 읽고 있다"
|
||||
assert "ownerChainage" in layouts, "소유 측점을 안 가리고 있다"
|
||||
assert "isOwner(section.culvert.chainage_m)" in layouts, "관이 놓인 자리를 안 읽고 있다"
|
||||
py = (PROJECT_ROOT / "B06_Section" / "B06_Section_Engine_Culvert.py").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
① 필지마다 상자 하나 · 빈 값은 빈 줄 · 쪼개진 필지는 큰 조각에 지시선
|
||||
② 구역이 한 필지 안 — 경계 없이 어느 필지인지 한 번
|
||||
③ 상자 · 지시선 끝이 노선 버퍼와 안 겹침 · 상자끼리 안 겹침 · 도면에서도 노선과 안 겹침
|
||||
④ 상자 밑 선(등고선 · 계류 · 필지 · 읍면동 · 시군구)은 잘려 상자 안 선 0 (109-4)
|
||||
"""
|
||||
|
||||
import json
|
||||
@@ -11,6 +12,7 @@ from pathlib import Path
|
||||
from shapely.geometry import LineString, Point
|
||||
from shapely.geometry import box as box_polygon
|
||||
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_IpTable import clip_lines
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_MassHaul import balloon_size
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Plan import MM
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_PlanKinds import (
|
||||
@@ -18,9 +20,11 @@ from B07_DesignDetail.B07_DesignDetail_Engine_Cad_PlanKinds import (
|
||||
layer_id,
|
||||
)
|
||||
from B07_DesignDetail.B07_DesignDetail_Router_Support_Landuse import (
|
||||
LABEL_CLIPPED,
|
||||
PARCEL_FONT,
|
||||
ROUTE_CLEAR_M,
|
||||
clip_parcels,
|
||||
label_rects,
|
||||
place_labels,
|
||||
)
|
||||
|
||||
@@ -161,3 +165,44 @@ def _polyline(entity: dict) -> LineString:
|
||||
c["shapeData"]["endPoint"] for c in children
|
||||
]
|
||||
return LineString([(p["x"], p["y"]) for p in points])
|
||||
|
||||
|
||||
def test_lines_under_label_boxes_are_cut(tmp_path: Path) -> None:
|
||||
features = [
|
||||
(
|
||||
{
|
||||
"pnu": f"P{i}{j}",
|
||||
"jibun": f"{i}{j}-1",
|
||||
"jimok": "임야",
|
||||
"parea": 9999,
|
||||
"owner_nm": "홍길동",
|
||||
},
|
||||
_polygon(_square(i * 100.0, j * 100.0, 100.0)),
|
||||
)
|
||||
for i in range(4)
|
||||
for j in range(3)
|
||||
]
|
||||
parcels = clip_parcels(_geojson(tmp_path, features), "EPSG:4326", BOX)
|
||||
route = [(0.0, 140.0), (130.0, 160.0), (260.0, 120.0), (400.0, 170.0)]
|
||||
items = place_labels(parcels, route, BOX)
|
||||
rects = label_rects(items)
|
||||
assert PARCEL_FONT == 3.6 and rects == [_rect(item) for item in items] # 그리는 상자와 같음
|
||||
# 도곽 전체를 촘촘히 가로지르는 배경 선 — 상자마다 반드시 지나감
|
||||
across = [[[0.0, y], [400.0, y]] for y in range(1, 300, 2)]
|
||||
pieces = {
|
||||
"contour": {"lines": [list(line) for line in across]},
|
||||
"stream": {"lines": [[[x, 0.0], [x, 300.0]] for x in range(1, 400, 2)]},
|
||||
"parcel": {"lines": [line for parcel in parcels for line in parcel["lines"]]},
|
||||
"emd": {"lines": [list(line) for line in across]},
|
||||
"sgg": {"lines": [list(line) for line in across]},
|
||||
"centerline": {"lines": [[list(p) for p in route]]},
|
||||
}
|
||||
clip_lines(pieces, rects, LABEL_CLIPPED)
|
||||
inner = [box_polygon(*rect).buffer(-1e-3) for rect in rects]
|
||||
for piece in LABEL_CLIPPED:
|
||||
for points in pieces[piece]["lines"]:
|
||||
line = LineString(points)
|
||||
assert all(line.intersection(area).length < 1e-6 for area in inner), piece
|
||||
# 상자 밖은 남음 · 노선은 그대로
|
||||
assert sum(LineString(p).length for p in pieces["contour"]["lines"]) > 0.5 * 150 * 400
|
||||
assert pieces["centerline"]["lines"] == [[list(p) for p in route]]
|
||||
|
||||
@@ -8,6 +8,8 @@
|
||||
옛 종류 파일의 slope_outline 펼침 · 종류 업데이트(기본 넷 포함) · 확정본 풀림 · 배수시설 기호
|
||||
⑥ IP 표(94장) — 예시 검산 · 도-분-초 올림 · 장마다 그 장 IP 한 번 · 표 밑 선 자름 · 층
|
||||
⑦ 용지도 종류(93장) — 기본 종류 · 옛 종류 폴더에 한 번 더함(지우면 안 살아남) · 용지 조각 층
|
||||
⑧ 배수유역도 종류(109장) — 양식 watershed · 한 장 목록 · 지우기 · 다시 만들기 · IP 표 두 줄 차례 ·
|
||||
용지도 상자 안 선 0
|
||||
"""
|
||||
|
||||
import json
|
||||
@@ -19,6 +21,7 @@ import pytest
|
||||
|
||||
from B06_Section.B06_Section_Engine_Design import compute_cross_design
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_IpTable import (
|
||||
_place,
|
||||
curve_values,
|
||||
dms,
|
||||
ip_blocks,
|
||||
@@ -38,6 +41,7 @@ from B07_DesignDetail.B07_DesignDetail_Router_Support_Plan import (
|
||||
add_kind,
|
||||
delete_kind,
|
||||
list_kinds,
|
||||
plan_kind_items,
|
||||
read_marks,
|
||||
slope_records,
|
||||
update_kind,
|
||||
@@ -139,6 +143,7 @@ def test_kinds_add_delete_persist(tmp_path: Path) -> None:
|
||||
"대피소",
|
||||
"라이다",
|
||||
"용지도",
|
||||
"배수유역도",
|
||||
]
|
||||
added = add_kind(root, "내 평면도", ["centerline", "station", "nope"])
|
||||
assert added["pieces"] == ["centerline", "station"]
|
||||
@@ -148,14 +153,28 @@ def test_kinds_add_delete_persist(tmp_path: Path) -> None:
|
||||
add_kind(root, "빈 것", [])
|
||||
delete_kind(root, "terrain") # 기본 넷도 지움
|
||||
again = list_kinds(root) # 다시 읽어 같음
|
||||
assert [k["name"] for k in again] == ["노선배치도", "대피소", "라이다", "용지도", "내 평면도"]
|
||||
assert [k["name"] for k in again] == [
|
||||
"노선배치도",
|
||||
"대피소",
|
||||
"라이다",
|
||||
"용지도",
|
||||
"배수유역도",
|
||||
"내 평면도",
|
||||
]
|
||||
assert again[-1]["pieces"] == ["centerline", "station"]
|
||||
with pytest.raises(FileNotFoundError):
|
||||
delete_kind(root, "terrain")
|
||||
for kind in list(again):
|
||||
delete_kind(root, kind["id"])
|
||||
assert list_kinds(root) == [] # 다 지우면 비어 있음(기본 넷이 되살아나지 않음)
|
||||
assert {k["id"] for k in DEFAULT_KINDS} == {"terrain", "route", "layout", "lidar", "landuse"}
|
||||
assert {k["id"] for k in DEFAULT_KINDS} == {
|
||||
"terrain",
|
||||
"route",
|
||||
"layout",
|
||||
"lidar",
|
||||
"landuse",
|
||||
"watershed",
|
||||
}
|
||||
|
||||
|
||||
def test_refuge_marks_same_spot(tmp_path: Path) -> None:
|
||||
@@ -202,7 +221,7 @@ def test_default_kinds_keep_old_picture_and_legacy_file(tmp_path: Path) -> None:
|
||||
outline = {"road_edge", "fill_outline", "cut_outline"}
|
||||
# 옛 slope_outline 자리를 나눈 셋이 그대로 채움 · 배수시설은 옛 그림에 없음(용지도는 따로)
|
||||
for kind in DEFAULT_KINDS:
|
||||
if kind["id"] != "landuse":
|
||||
if kind["id"] not in ("landuse", "watershed"):
|
||||
assert outline <= set(kind["pieces"]) and "drainage" not in kind["pieces"]
|
||||
folder = _kinds_dir(root)
|
||||
folder.mkdir(parents=True)
|
||||
@@ -227,6 +246,7 @@ def test_update_kind_default_and_release_confirmed(tmp_path: Path) -> None:
|
||||
"id": "terrain",
|
||||
"name": "지형2",
|
||||
"pieces": ["contour", "centerline"],
|
||||
"layout": "plan",
|
||||
}
|
||||
assert [k["id"] for k in list_kinds(root)][0] == "terrain" # 차례 그대로
|
||||
drawings = _read_manifest(root)["drawings"]
|
||||
@@ -315,9 +335,9 @@ def test_landuse_kind_added_once_to_old_folder(tmp_path: Path) -> None:
|
||||
json.dumps({"name": kind["name"], "pieces": kind["pieces"], "order": order}), "utf-8"
|
||||
)
|
||||
kinds = list_kinds(root)
|
||||
assert [k["id"] for k in kinds][-1] == "landuse"
|
||||
assert kinds[-1]["pieces"] == ["contour", "centerline", "parcel", "parcel_info", "emd", "sgg"]
|
||||
assert len(list_kinds(root)) == 5 # 두 번 읽어도 한 번
|
||||
assert [k["id"] for k in kinds][-2:] == ["landuse", "watershed"] # 늘어난 기본 종류 둘
|
||||
assert kinds[-2]["pieces"] == ["contour", "centerline", "parcel", "parcel_info", "emd", "sgg"]
|
||||
assert len(list_kinds(root)) == 6 # 두 번 읽어도 한 번
|
||||
delete_kind(root, "landuse")
|
||||
assert "landuse" not in [k["id"] for k in list_kinds(root)] # 지운 것은 안 살아남
|
||||
|
||||
@@ -344,3 +364,79 @@ def test_landuse_pieces_become_layers() -> None:
|
||||
if e.get("layerId") == layer_id("parcel_info") and e["type"] == "Text"
|
||||
]
|
||||
assert labels == ["1-1", "임야", "국"]
|
||||
|
||||
|
||||
def test_watershed_kind_layout_one_sheet(tmp_path: Path) -> None:
|
||||
"""배수유역도 = 기본 종류 · 양식 watershed · 조각 없음 · 장이 여럿이어도 목록은 한 장."""
|
||||
root = tmp_path / "project"
|
||||
watershed = next(k for k in list_kinds(root) if k["id"] == "watershed")
|
||||
assert watershed == {
|
||||
"id": "watershed",
|
||||
"name": "배수유역도",
|
||||
"pieces": [],
|
||||
"layout": "watershed",
|
||||
}
|
||||
# 측점 0 ~ 2,000 m — 1/1,200 평면도는 여러 장 · 유역도는 한 장
|
||||
stations = [
|
||||
{"chainage_m": float(c), "center_x": 0.0, "center_y": float(c)} for c in range(0, 2001, 20)
|
||||
]
|
||||
items = plan_kind_items(root, {"stations": stations})
|
||||
terrain = [item for item in items if item["plan_kind"] == "terrain"]
|
||||
mine = [item for item in items if item["plan_kind"] == "watershed"]
|
||||
assert len(terrain) > 1
|
||||
assert mine == [
|
||||
{
|
||||
"id": "plan_watershed",
|
||||
"label": "배수유역도",
|
||||
"plan_kind": "watershed",
|
||||
"plan_kind_name": "배수유역도",
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def test_watershed_kind_delete_and_add_again(tmp_path: Path) -> None:
|
||||
"""× 로 지우면 안 살아남 · 「새 종류」 양식 유역도로 다시 만듦 · 업데이트는 양식 그대로."""
|
||||
root = tmp_path / "project"
|
||||
delete_kind(root, "watershed")
|
||||
assert "watershed" not in [k["id"] for k in list_kinds(root)]
|
||||
assert "watershed" not in [k["id"] for k in list_kinds(root)] # 다시 읽어도 안 살아남
|
||||
added = add_kind(root, "배수유역도", ["contour"], "watershed") # 조각은 버림
|
||||
assert added["layout"] == "watershed" and added["pieces"] == []
|
||||
again = next(k for k in list_kinds(root) if k["id"] == added["id"])
|
||||
assert again == added
|
||||
assert any(
|
||||
item["id"] == f"plan_{added['id']}" for item in plan_kind_items(root, {"stations": []})
|
||||
)
|
||||
update_kind(root, added["id"], "유역도2", []) # 유역도 양식은 조각 없이 업데이트
|
||||
assert next(k for k in list_kinds(root) if k["id"] == added["id"])["layout"] == "watershed"
|
||||
with pytest.raises(ValueError):
|
||||
add_kind(root, "이상한 양식", ["contour"], "nope")
|
||||
|
||||
|
||||
def test_watershed_kind_added_once_to_folder_with_landuse(tmp_path: Path) -> None:
|
||||
"""용지도 표시가 있는 옛 폴더 — 배수유역도만 한 번 더해짐 · 지우면 안 살아남."""
|
||||
root = tmp_path / "project"
|
||||
folder = _kinds_dir(root)
|
||||
folder.mkdir(parents=True)
|
||||
for order, kind in enumerate(DEFAULT_KINDS[:5]):
|
||||
(folder / f"{kind['id']}.json").write_text(
|
||||
json.dumps({"name": kind["name"], "pieces": kind["pieces"], "order": order}), "utf-8"
|
||||
)
|
||||
(folder / "_landuse_added").touch()
|
||||
assert [k["id"] for k in list_kinds(root)][-1] == "watershed"
|
||||
delete_kind(root, "watershed")
|
||||
assert [k["id"] for k in list_kinds(root)][-1] == "landuse"
|
||||
|
||||
|
||||
def test_ip_table_two_lines_left_to_right_top_to_bottom() -> None:
|
||||
"""한 줄 셋까지 들어가는 폭 · 묶음 넷 — 첫 줄(1 · 2 · 3) 맨 위 왼 → 오 · 넷째는 아랫줄 왼쪽."""
|
||||
blocks = [{"name": str(i), "ia": 1.0} for i in range(1, 5)]
|
||||
block_w = 7.0 + 6.0 + 23.0
|
||||
_place(blocks, 2 * 5.0 + 3 * block_w + 1.0)
|
||||
origins = [b["origin"] for b in blocks]
|
||||
tops = [o[1] for o in origins]
|
||||
assert tops[0] == tops[1] == tops[2] > tops[3] # 첫 줄이 위
|
||||
assert origins[0][0] < origins[1][0] < origins[2][0] # 왼 → 오
|
||||
assert origins[3][0] == origins[0][0] # 다음 줄은 다시 왼쪽
|
||||
assert tops[3] == 5.0 + 8 * 5.0 # 마지막 줄이 도곽 왼쪽 아래 기준(옛 한 줄 자리와 같음)
|
||||
assert tops[0] - tops[3] == 8 * 5.0 + 3.0 # 줄 간격 그대로
|
||||
|
||||
@@ -118,6 +118,39 @@ def test_일위대가_합계_줄_번호_칸(tmp_path: Path, monkeypatch: pytest.
|
||||
assert got["d"]["일위대가번"] == "SU-000004" and got["d"]["도번"] == ""
|
||||
|
||||
|
||||
def test_일위대가_합계_상태_넷(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
|
||||
"""101-1 — 합계 칸 색: 붉음(기준 안 나온 돈 줄 · 단가 없는 자재) > 노랑(수량 없는 줄 · 자재) >
|
||||
초록(모두 반영) · 돈 아닌 로직 줄은 판정에서 뺌 · 담은 것 없음 = 상태 없음."""
|
||||
from M02_MasterTemplete import M02_MasterTemplete_Store as store
|
||||
|
||||
monkeypatch.setattr(store, "FOLDER", tmp_path)
|
||||
(tmp_path / "labor").mkdir()
|
||||
money = {"기준": {"계": 100}, "까닭": None}
|
||||
docs = {
|
||||
"ok": {"줄": [{**money, "수량": 2}, {"결과": 3, "기준": None, "까닭": None}], "자재": [
|
||||
{"기준단가": 5, "수량": 1}
|
||||
]},
|
||||
"short": {"줄": [{**money, "수량": 2}, {**money, "수량": None}], "자재": []},
|
||||
"error": {"줄": [{**money, "수량": None}, {"기준": None, "까닭": "식 오류"}], "자재": [
|
||||
{"기준단가": None, "까닭": "단가 없음", "수량": None},
|
||||
{"기준단가": 5, "수량": None},
|
||||
]},
|
||||
} # fmt: skip
|
||||
for name, calc in docs.items():
|
||||
doc = {"담은로직": ["x"], "계산값": {**calc, "반영합": {"계": 7}, "기준합": {"계": 9}}}
|
||||
(tmp_path / "labor" / f"{name}.json").write_text(json.dumps(doc), encoding="utf-8")
|
||||
empty = {"담은로직": [], "계산값": {"줄": [], "반영합": {"계": 7}}}
|
||||
(tmp_path / "labor" / "empty.json").write_text(json.dumps(empty), encoding="utf-8")
|
||||
got = store.structure_numbers()
|
||||
assert (got["ok"]["합계상태"], got["ok"]["합계까닭"]) == ("ok", "모두 반영")
|
||||
assert (got["short"]["합계상태"], got["short"]["합계까닭"]) == ("short", "수량 없는 줄 1")
|
||||
assert (got["error"]["합계상태"], got["error"]["합계까닭"]) == (
|
||||
"error",
|
||||
"기준 단가 없음 2 · 수량 없는 줄 2",
|
||||
)
|
||||
assert "합계상태" not in got["empty"]
|
||||
|
||||
|
||||
def test_일위대가_합계_줄_표_부품과_연결():
|
||||
sheet = _text(SHEET / "ui_template_sheet.ts")
|
||||
assert "laborSumLabel: opts.laborSumLabel," in sheet
|
||||
@@ -131,6 +164,12 @@ def test_일위대가_합계_줄_표_부품과_연결():
|
||||
assert "state.combo && state.laborSumLabel" in render and "masterRow(KEY_SUM)" in render
|
||||
main = _text(M02 / "M02_MasterTemplete_UI_Main.ts")
|
||||
assert "laborSumLabel: (colId: string) => laborSumText(struct.numbers()[colId])" in main
|
||||
assert "laborSumState: (colId: string) => laborSumState(struct.numbers()[colId])" in main
|
||||
assert "(기준)" not in _text(M02 / "M02_MasterTemplete_Structure.ts") # 101-1 글 지움
|
||||
assert "is-sum-${sum.state}" in render and "td.is-sum-error" in _text(
|
||||
SHEET / "ui_template_sheet.css"
|
||||
)
|
||||
assert "markSum(c)" in sheet # 101-2 머리 고르면 합계 칸도 같이
|
||||
assert "onNumbers: () => sheet?.recalc()" in main # 카드 [저장] 뒤 다시 읽기
|
||||
struct = _text(M02 / "M02_MasterTemplete_Structure.ts")
|
||||
assert "fetchStructureNumbers().then(" in struct and "opts.onNumbers?.()" in struct
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
"""일위대가 로직 자재(PLAN 103-1) — 담은 로직 맨 위 층 재료 줄 · 고름 · 합 이중 계산 없음.
|
||||
|
||||
- 로직 줄 계산값 `재료` = 호표 재료 줄(차례 · 품목 · 단가 · 금액 · 걸림) · 재료 없는 로직은 빈 목록
|
||||
- 고름 `{호표 차례: MT키}` → 그 줄 품목 · 단가 · 로직 재료비가 바뀜(엔진 `choosing`) ·
|
||||
고름 없으면 지금 값
|
||||
- 합 · 알약에 로직 자재 금액을 다시 안 더함 · 저장 뒤 고름 남음 · 틀린 고름 400
|
||||
- 사용자 자재와 같은 품목 = [저장] 400 · 입력에 걸린 고름은 입력이 바뀌면 풂(화면 짝 · Node)
|
||||
- 프로젝트 사본 씨앗 = 문서 고름
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
|
||||
import pytest
|
||||
|
||||
from M01_MasterData import M01_MasterData_Store as m01
|
||||
from M02_MasterTemplete import M02_Item_Snapshot as snap
|
||||
from M02_MasterTemplete import M02_Labor_Doc as labor
|
||||
from M02_MasterTemplete import M02_Labor_Ref as refs
|
||||
from M02_MasterTemplete import M02_MasterTemplete_Store as store
|
||||
from resources.tester.test_m02_structure import write_sample_table
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
NET = "GC000337" # 낙석방지망 — 재료 줄 3 · 4 · 5(철망 MT006697 · 결속선 MT000194 · 에폭시)
|
||||
BORING = "GC000431" # 보링 천공 — 재료 줄 4~8 조건에 {규격} · 9 = MT000008(입력 안 걸림)
|
||||
BARE = "GC000994" # 재료 줄 없는 돈 로직
|
||||
WIRE = "MT000194"
|
||||
NET_ITEM = "MT006697"
|
||||
|
||||
|
||||
def _whole():
|
||||
return m01.loaded()[1]
|
||||
|
||||
|
||||
def _doc(**extra):
|
||||
return {"열": "rv", "담은로직": [], "자재": [], **extra}
|
||||
|
||||
|
||||
def test_로직_자재는_맨_위_층_재료_줄_재료_없는_로직은_빈_목록():
|
||||
got = labor.run_line(_whole(), NET, {})
|
||||
assert [(m["차례"], m["품목"]["키"]) for m in got["재료"]] == [
|
||||
(3, NET_ITEM),
|
||||
(4, WIRE),
|
||||
(5, "MT007705"),
|
||||
]
|
||||
assert all(m["고름"] is False and m["걸림"] == [] for m in got["재료"])
|
||||
assert labor.run_line(_whole(), BARE, {})["재료"] == []
|
||||
|
||||
|
||||
def test_조건에_든_입력은_걸림():
|
||||
mats = {m["차례"]: m for m in labor.run_line(_whole(), BORING, {})["재료"]}
|
||||
assert all(mats[n]["걸림"] == ["규격"] for n in range(4, 9))
|
||||
assert mats[9]["걸림"] == []
|
||||
|
||||
|
||||
def test_고름이_단가와_로직_재료비를_바꿈_고름_없으면_지금_값():
|
||||
plain = labor.run_line(_whole(), NET, {})
|
||||
again = labor.run_line(_whole(), NET, {}, None, None)
|
||||
assert again["기준"] == plain["기준"] # 고름 없음 = 지금 길 그대로(회귀)
|
||||
picked = labor.run_line(_whole(), NET, {}, None, {"3": WIRE})
|
||||
row = picked["재료"][0]
|
||||
assert (row["품목"]["키"], row["고름"]) == (WIRE, True)
|
||||
assert row["단가"] == picked["재료"][1]["단가"] != plain["재료"][0]["단가"]
|
||||
gap = (plain["재료"][0]["단가"] - row["단가"]) * row["수량"]
|
||||
assert plain["기준"]["재료비"] - picked["기준"]["재료비"] == pytest.approx(gap)
|
||||
assert picked["기준"]["노무비"] == plain["기준"]["노무비"]
|
||||
|
||||
|
||||
def test_합_알약에_로직_자재_금액_다시_안_더함():
|
||||
doc = labor.clean(_doc(담은로직=[{"로직": NET, "수량": {"손": 2}}]))
|
||||
got = labor.calc(doc, {})
|
||||
line = got["줄"][0]
|
||||
assert line["재료"] and got["자재"] == []
|
||||
assert got["기준합"]["계"] == pytest.approx(line["기준"]["계"])
|
||||
assert got["반영합"]["계"] == pytest.approx(line["기준"]["계"] * 2)
|
||||
assert not [p for p in labor.pills(doc) if p["키"].startswith(labor.MATERIAL_HEAD)]
|
||||
|
||||
|
||||
def test_고름_정리_틀리면_400():
|
||||
got = labor.clean(_doc(담은로직=[{"로직": NET, "고름": {3: WIRE}}, {"로직": BARE, "고름": {}}]))
|
||||
assert got["담은로직"][0]["고름"] == {"3": WIRE} and "고름" not in got["담은로직"][1]
|
||||
for bad in ({"0": WIRE}, {"3": "MT999999"}, {"3": "LB000001"}, {"가": WIRE}, ["3"]):
|
||||
with pytest.raises(store.StoreError) as e:
|
||||
labor.clean(_doc(담은로직=[{"로직": NET, "고름": bad}]))
|
||||
assert e.value.status == 400
|
||||
|
||||
|
||||
def test_사용자_자재가_로직_자재와_같으면_저장_400():
|
||||
with pytest.raises(store.StoreError) as e:
|
||||
labor.server(_doc(담은로직=[{"로직": NET}], 자재=[{"코드": WIRE}]))
|
||||
assert e.value.status == 400 and WIRE in str(e.value.detail)
|
||||
# 고른 품목도 같음 — 결속선 줄을 다른 품목으로 바꾸면 사용자 자재로 담을 수 있음
|
||||
with pytest.raises(store.StoreError):
|
||||
labor.server(
|
||||
_doc(담은로직=[{"로직": NET, "고름": {"3": "MT000008"}}], 자재=[{"코드": "MT000008"}])
|
||||
)
|
||||
ok = labor.server(
|
||||
_doc(담은로직=[{"로직": NET, "고름": {"4": NET_ITEM}}], 자재=[{"코드": WIRE}])
|
||||
)
|
||||
assert ok["자재"][0]["코드"] == WIRE
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def system(tmp_path, monkeypatch):
|
||||
write_sample_table(tmp_path)
|
||||
monkeypatch.setattr(store, "FOLDER", tmp_path)
|
||||
store.create_structure("rv")
|
||||
return tmp_path
|
||||
|
||||
|
||||
def test_저장_뒤_고름_남고_계산값에_반영(system):
|
||||
have = store.read("labor", "rv")
|
||||
sent = {**have["문서"], "담은로직": [{"로직": NET, "고름": {"3": WIRE}, "수량": None}]}
|
||||
store.write("labor", "rv", have["판"], sent)
|
||||
doc = store.read("labor", "rv")["문서"]
|
||||
assert doc["담은로직"][0]["고름"] == {"3": WIRE}
|
||||
assert doc["계산값"]["줄"][0]["재료"][0]["품목"]["키"] == WIRE
|
||||
|
||||
|
||||
def test_단가승격도_고름을_따름_없으면_지금_값():
|
||||
expand = refs.Expand(_whole())
|
||||
plain = expand._promote({"담은로직": [{"로직": NET}], "배": 1})
|
||||
picked = expand._promote({"담은로직": [{"로직": NET, "고름": {"3": WIRE}}], "배": 1})
|
||||
assert plain["기준"] == labor.run_line(_whole(), NET, {})["기준"]
|
||||
assert picked["기준"]["재료비"] < plain["기준"]["재료비"]
|
||||
|
||||
|
||||
def test_사본_씨앗은_문서_고름():
|
||||
labor_file = {
|
||||
"줄": {
|
||||
"담은로직": [
|
||||
{"로직": NET, "고름": {"3": WIRE}},
|
||||
{"로직": BARE},
|
||||
{"참조": {"층": "system", "주인": None, "키": "SP-000001"}, "로직": ""},
|
||||
]
|
||||
}
|
||||
}
|
||||
assert snap.seed_picks(labor_file) == {NET: {"3": WIRE}}
|
||||
assert snap.seed_picks(None) == {}
|
||||
|
||||
|
||||
def _release(row: dict, before: dict, mats: list) -> dict:
|
||||
proc = subprocess.run(
|
||||
["node", os.path.join(HERE, "helper_m02_labor_logic_mats.cjs")],
|
||||
input=json.dumps({"row": row, "before": before, "mats": mats}, ensure_ascii=False),
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
check=True,
|
||||
)
|
||||
return json.loads(proc.stdout)
|
||||
|
||||
|
||||
def test_입력이_바뀌면_걸린_고름만_풂_화면_짝():
|
||||
mats = labor._json(labor.run_line(_whole(), BORING, {})["재료"])
|
||||
picks = {"4": "MT007895", "9": WIRE}
|
||||
row = {"로직": BORING, "입력값": {"규격": "NX"}, "고름": picks}
|
||||
assert _release(row, {"규격": "NX"}, mats)["고름"] == picks # 그대로
|
||||
assert _release(row, {"규격": "BX"}, mats)["고름"] == {"9": WIRE} # 규격에 걸린 줄만
|
||||
row = {**row, "고름": {"4": "MT007895"}}
|
||||
assert "고름" not in _release(row, {}, mats) # 다 풀리면 칸째 지움
|
||||
@@ -0,0 +1,85 @@
|
||||
"""102 — M02 일위대가 상세 줄 배치 · 줄 통째 끌기 · 구분 열 품셈 글.
|
||||
|
||||
끌기(102-2) = Node 헬퍼(`helper_m02_labor_rowdrag.cjs`)가 `M02_MasterTemplete_Labor_RowDrag.ts` 를 가짜 DOM 에서 돌림 ·
|
||||
6px 문턱 · 끈 뒤 누름 삼킴 · 열린 줄 하위 줄에서 시작 · 단추 · 입력칸 · 수량 칸 막음 · 끄는 동안 다시 그림 미룸.
|
||||
배치(102-1) · 품셈 글(102-3) = 소스 꼴.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
M02 = Path(HERE).parents[1] / "M02_MasterTemplete"
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def r():
|
||||
proc = subprocess.run(
|
||||
["node", os.path.join(HERE, "helper_m02_labor_rowdrag.cjs")],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
timeout=60,
|
||||
)
|
||||
assert proc.returncode == 0, proc.stderr
|
||||
return json.loads(proc.stdout)
|
||||
|
||||
|
||||
def test_문턱_6px(r):
|
||||
assert r["threshold"] == [False, True, True, False, True]
|
||||
|
||||
|
||||
def test_작게_움직이면_누름(r):
|
||||
assert r["small"] == {"moves": 0, "clickPasses": True, "capture": None}
|
||||
|
||||
|
||||
def test_끌어_놓으면_순서만_누름_삼킴(r):
|
||||
assert r["during"]["drawn"] == 0 # 끄는 동안 다시 그리지 않음
|
||||
assert r["during"]["capture"] == 1 and r["during"]["select"] == "none"
|
||||
assert r["during"]["classes"][0] == "is-dragging"
|
||||
assert r["during"]["classes"][5] == "is-dragunder"
|
||||
assert r["drop"]["moves"] == [[0, 2]]
|
||||
assert r["drop"]["drawn"] == 1 # 끝난 뒤 마지막 것 한 번
|
||||
assert r["drop"]["clickPasses"] is False # 열림 · 닫힘 그대로
|
||||
assert set(r["drop"]["classes"]) == {""}
|
||||
assert r["afterFrame"]["listeners"] == 0
|
||||
assert r["idleDraw"] == 1
|
||||
|
||||
|
||||
def test_열린_줄_하위_줄째(r):
|
||||
assert r["fromSub"]["moves"] == [[1, 0]]
|
||||
assert r["fromSub"]["classes"][:5] == ["is-dragover"] + ["is-dragging"] * 4
|
||||
assert r["downMark"][4] == "is-dragunder" and r["downMark"][-1] == "[[0,1]]"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name", ["button", "input", "qty"])
|
||||
def test_단추_입력칸에선_시작_안_함(r, name):
|
||||
assert r[f"block_{name}"] == {"moves": 0, "capture": None, "clickPasses": True}
|
||||
|
||||
|
||||
def test_손잡이_제자리(r):
|
||||
assert r["gripSelf"] == {"moves": 0, "clickPasses": False}
|
||||
|
||||
|
||||
def test_배치_손잡이_차례칸_앞_단추칸():
|
||||
rows = (M02 / "M02_MasterTemplete_Labor_Rows.ts").read_text(encoding="utf-8")
|
||||
assert "children: move ? [grip(), String(i + 1)] : [String(i + 1)]," in rows
|
||||
assert 'draggable: "true"' not in rows and "dragFrom" not in rows
|
||||
assert "if (move) attachRowDrag(out, move);" in rows
|
||||
css = (M02 / "M02_MasterTemplete_Labor.css").read_text(encoding="utf-8")
|
||||
order = css[css.index(".m02-labrow__order {") :]
|
||||
assert "flex-direction: column;" in order[: order.index("}")]
|
||||
assert ".m02-labrow__tools > .ui-btn-group {\n align-items: stretch;" in css
|
||||
detail = (M02 / "M02_MasterTemplete_Labor_Detail.ts").read_text(encoding="utf-8")
|
||||
assert "afterRowDrag(logicHost, () =>" in detail
|
||||
|
||||
|
||||
def test_구분열_품셈_글():
|
||||
rows = (M02 / "M02_MasterTemplete_Labor_Rows.ts").read_text(encoding="utf-8")
|
||||
assert "const BOOK = /^(건설|산림)품셈/;" in rows
|
||||
assert 'const book = row.참조 ? undefined : BOOK.exec(logic?.출처 ?? "")?.[0];' in rows
|
||||
assert 'className: "m02-labrow__book"' in rows
|
||||
@@ -74,7 +74,7 @@ def test_sub2_가_부르는_이름과_모양():
|
||||
), name
|
||||
assert "fetchStructureNumbers = (): Promise<Record<string, StructureNumbers>>" in body
|
||||
assert re.search(
|
||||
r"interface StructureNumbers \{\s*도번: string;\s*면적번: string;\s*/\*\* 일위대가[^*]*\*/\s*일위대가번: string;(\s*/\*\*[^*]*\*/)?\s*일위대가합계\?: number;\s*합계기준\?: boolean;\s*\}",
|
||||
r"interface StructureNumbers \{\s*도번: string;\s*면적번: string;\s*/\*\* 일위대가[^*]*\*/\s*일위대가번: string;(\s*/\*\*[^*]*\*/)?\s*일위대가합계\?: number;\s*합계기준\?: boolean;(\s*/\*\*[^*]*\*/)?(\s*합계\w+\?: [^;]+;)*\s*\}",
|
||||
body,
|
||||
)
|
||||
assert re.search(r'export type Kind = [^;]*"area" \| "labor"', body) # 재료산출 없앰
|
||||
@@ -93,7 +93,7 @@ def test_집계표만_컨테이너를_붙임():
|
||||
# 구조물 도면 저장은 `도면` · `더한장`(82-2) 칸만 갈아 끼움 — 도번 · 산출근거는 읽은 그대로
|
||||
# 맨 위 연결표는 도면 표에 맞춤(41-3 `toDrawing.withLinks`)
|
||||
assert re.search(
|
||||
r'toDrawing\.withLinks\(\{\s*\.\.\.\(loaded as StructureDoc\),\s*'
|
||||
r"toDrawing\.withLinks\(\{\s*\.\.\.\(loaded as StructureDoc\),\s*"
|
||||
r'\.\.\.\(edited as Pick<StructureDoc, "도면" \| "더한장">\),\s*\}\)',
|
||||
body,
|
||||
)
|
||||
|
||||
@@ -120,7 +120,7 @@
|
||||
out.splitAfter = ctl("inlet", "after").value;
|
||||
await set("inlet", "after", "4");
|
||||
out.splitLength = ctl("inlet", "length").value;
|
||||
// 흙막이 = 유출 「추가 기슭막이(단)」 수만큼(54-1) — [추가] · [빼기] 없음 · 도수로/돌붙임 하나
|
||||
// 흙막이 = 유출 「추가 흙막이(단)」 수만큼(54-1) — [추가] · [빼기] 없음 · 도수로/돌붙임 하나
|
||||
out.soilAddButtons = spec().querySelectorAll('[data-spec-add="soil_add"]').length;
|
||||
out.soilRemoveButtons = spec().querySelectorAll('[data-spec-remove="soil_add"]').length;
|
||||
spec().querySelector('[data-spec-add="chute_add"]').click();
|
||||
|
||||
@@ -239,9 +239,9 @@
|
||||
sec()
|
||||
.querySelectorAll("button")
|
||||
.forEach((b) => flat(b) === "리셋" && b.click());
|
||||
// 다단 벽(추가 기슭막이 N단) — 관 유출 벽에 한 단 더함(세션만 · 단이 설 자리가 있는 관까지 차례로)
|
||||
// 다단 벽(추가 흙막이 N단) — 관 유출 벽에 한 단 더함(세션만 · 단이 설 자리가 있는 관까지 차례로)
|
||||
out.tier = null;
|
||||
for (const card of cards().filter((c) => /유출 기슭막이 선택/.test(c.innerHTML))) {
|
||||
for (const card of cards().filter((c) => /유출 흙막이 선택/.test(c.innerHTML))) {
|
||||
if (/추가\(성토부\)/.test(card.innerHTML)) {
|
||||
out.tier = card.id;
|
||||
break;
|
||||
@@ -306,7 +306,7 @@
|
||||
}
|
||||
// ── 다. 빈 곳 — 구조물 카드 · 빈 카드 · 다시 누름 = 카드 풂 ──
|
||||
await clearCard();
|
||||
const pipeCard = targets.find((c) => /유입 집수정|유출 기슭막이/.test(c.innerHTML));
|
||||
const pipeCard = targets.find((c) => /유입 집수정|유출 흙막이/.test(c.innerHTML));
|
||||
if (pipeCard) {
|
||||
click(pipeCard.querySelector("svg"));
|
||||
await w(1400);
|
||||
@@ -423,12 +423,12 @@
|
||||
judge(`값 되돌림: ${key}`, noSpan(back) === noSpan(before), state());
|
||||
};
|
||||
// 형태 칸은 구조를 고른 관에서만 섬 — 그 칸이 서는 관을 찾음
|
||||
for (const c of targets.filter((c) => /유출 기슭막이 선택/.test(c.innerHTML))) {
|
||||
for (const c of targets.filter((c) => /유출 흙막이 선택/.test(c.innerHTML))) {
|
||||
await clearCard();
|
||||
click(uniqueHits(document.getElementById(c.id))[0]);
|
||||
await w(1400);
|
||||
if (!ctlOf("outlet.form")) continue;
|
||||
await bodyCase(c, "outlet.form", "콘크리트", (t) => /유출 기슭막이 콘크리트/.test(t));
|
||||
await bodyCase(c, "outlet.form", "콘크리트", (t) => /유출 흙막이 콘크리트/.test(t));
|
||||
break;
|
||||
}
|
||||
const fordCard = targets.find((c) => /련/.test(titles(c).join("|")));
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
/* 54-10 — B06 다단 흙막이 ↔ 배관 「흙막이」 컨테이너
|
||||
* 시작 화면: B06(#/b06-section) · 검증 프로젝트(sub5_검증) · 목록에 다단이 서는 관(기본 「배관 2」 12+18)
|
||||
* 부르는 법: orca eval --expression "window.__m5410={mode:'단계', n:0, item:'', value:''}; $(cat 이 파일)"
|
||||
* look → 흙막이 컨테이너 수 · 켜진 상자 · 옛 「추가 기슭막이」 칸 · 다단 벽 그림(형태 · 높이)
|
||||
* look → 흙막이 컨테이너 수 · 켜진 상자 · 옛 「추가 흙막이」 칸 · 다단 벽 그림(형태 · 높이)
|
||||
* wall n → 횡단도 다단 n 번째(0 부터) 벽을 누름 → look
|
||||
* set n item value → 흙막이 n+1 컨테이너 item(form · height · length · before · after) 값 바꿈 → look
|
||||
* tier+ / tier- → 유출 「추가 기슭막이(단)」 + / -
|
||||
* tier+ / tier- → 유출 「추가 흙막이(단)」 + / -
|
||||
* 기대: wall n 뒤 켜진 상자 = n · 옛 칸 0 · set form → 벽 형태 · set height → 벽 H(한도 안)
|
||||
*/
|
||||
(async () => {
|
||||
@@ -71,7 +71,7 @@
|
||||
),
|
||||
),
|
||||
extraGroupShown: [...document.querySelectorAll("legend")].filter(
|
||||
(l) => visible(l) && l.textContent.startsWith("추가 기슭막이"),
|
||||
(l) => visible(l) && l.textContent.startsWith("추가 흙막이"),
|
||||
).length,
|
||||
tier: tierRow()?.querySelector(".b06-structure-panel__hval")?.textContent ?? null,
|
||||
walls: extraShapes(),
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* 부르는 법: orca eval --expression "window.__m5411={mode:'단계'}; $(cat 이 파일)"
|
||||
* look → 단 수 · 보이는 흙막이 상자 · 파일의 흙막이 값 번호 · 집계표 줄 수 · 미지정 알림
|
||||
* prep → 보이는 흙막이 상자마다 구조물 조합 잇기 · 마지막 상자 형태 = form → [저장] → look
|
||||
* tier+ / tier- → 유출 「추가 기슭막이(단)」 + / - → [저장] → look
|
||||
* tier+ / tier- → 유출 「추가 흙막이(단)」 + / - → [저장] → look
|
||||
* 기대: 단 수 2 → 1 에 상자 2 → 1 · 집계 줄(또는 미지정 건) 1 줄어듦 · 파일 값 그대로 ·
|
||||
* 다시 2 에 줄 · 상자 값 되살아남
|
||||
*/
|
||||
|
||||
@@ -108,10 +108,10 @@
|
||||
["inlet", "유입"],
|
||||
["outlet", "유출"],
|
||||
]) {
|
||||
const wall = pic.walls.find((t) => t.startsWith(`${role} 기슭막이`));
|
||||
const wall = pic.walls.find((t) => t.startsWith(`${role} 흙막이`));
|
||||
if (side === "inlet" && basin) continue;
|
||||
const f = head0(fields[`${side}.form`]);
|
||||
if (!wall || flat(wall) !== flat(`${role} 기슭막이 ${f}`))
|
||||
if (!wall || flat(wall) !== flat(`${role} 흙막이 ${f}`))
|
||||
bad(`${head}: ${side} 형태 ${f} ↔ ${wall}`);
|
||||
}
|
||||
const d = Math.round((set?.culvert?.diameter_m ?? 0) * 1000);
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
/* 54-1 — B05 · B06 좌측 패널 배관: 「선택」 글 · 지난 정의 값 숨김 · 관 길이 보여 주는 칸(정수) ·
|
||||
* 유입 집수정 = 길이 · 전 · 후 숨김 · 유출 읽기 줄(관길이 · 좌우 자동 자리) 없음 · [흙막이 추가] 없음 ·
|
||||
* 흙막이 컨테이너 수 = 유출 「추가 기슭막이(단)」 N
|
||||
* 흙막이 컨테이너 수 = 유출 「추가 흙막이(단)」 N
|
||||
* 시작 화면: B05(#/b05-profile) 또는 B06(#/b06-section) · 검증 프로젝트(sub5_검증) · 목록에 「배관 1」(4+5)
|
||||
* 부르는 법: orca eval --expression "window.__m541='단계'; $(cat 이 파일)"
|
||||
* look → 지금 화면 배관 폼 수치(아무것도 안 바꿈)
|
||||
* basin → 유입 구조 집수정 · 흙막이 번갈아 보이는 칸(되돌림 · 저장 안 함)
|
||||
* tier+ / tier- → B06 유출 벽을 골라 「추가 기슭막이(단)」 + / - 한 번 · 흙막이 컨테이너 수
|
||||
* tier+ / tier- → B06 유출 벽을 골라 「추가 흙막이(단)」 + / - 한 번 · 흙막이 컨테이너 수
|
||||
* 기대: pick 글 전부 「선택」 · 고르기 0 · stale 보임 0 · 관 길이 input 0 · 값 정수 ·
|
||||
* 집수정 칸 = structure · form · height · 흙막이 칸 = + length · before · after ·
|
||||
* soil_add 추가 단추 0 · 유출 읽기 줄 0 · soil_add 수 = 단 수
|
||||
@@ -86,7 +86,7 @@
|
||||
outletText: box("outlet")?.textContent.includes("자동 자리"),
|
||||
tier: tierRow()?.querySelector(".b06-structure-panel__hval")?.textContent ?? null,
|
||||
extraGroupShown: [...document.querySelectorAll("legend")].filter(
|
||||
(l) => visible(l) && l.textContent.startsWith("추가 기슭막이"),
|
||||
(l) => visible(l) && l.textContent.startsWith("추가 흙막이"),
|
||||
).length,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -120,7 +120,7 @@
|
||||
for (const form of forms) {
|
||||
await set(side, "form", form);
|
||||
const poly = [...card().querySelectorAll(".b06-chart__culvert-revet")].find((p) =>
|
||||
(p.querySelector("title")?.textContent ?? "").startsWith(`${word} 기슭막이`),
|
||||
(p.querySelector("title")?.textContent ?? "").startsWith(`${word} 흙막이`),
|
||||
);
|
||||
const hatch = poly.nextElementSibling?.nextElementSibling;
|
||||
rows.push({
|
||||
|
||||
Reference in New Issue
Block a user