diff --git a/B06_Section/B06_Section_UI_Cross_Culvert_Wire.ts b/B06_Section/B06_Section_UI_Cross_Culvert_Wire.ts index 810f0396f..7a3dbac79 100644 --- a/B06_Section/B06_Section_UI_Cross_Culvert_Wire.ts +++ b/B06_Section/B06_Section_UI_Cross_Culvert_Wire.ts @@ -41,7 +41,8 @@ export interface RevetOffsetControl { * (2026-08-30 사용자: 한 기슭막이는 그 연장 안 모든 횡단도에서 같이 강조된다). * 조정창은 고른 카드 하나에만 뜨므로 `selectedFor`와 따로 둔다. */ highlightFor: (section: CrossSection) => RevetPick | null; - select: (chainageM: number, key: RevetPick | null) => void; + /** `ownerM` = 폼에 올릴 시설 자리를 누른 쪽이 앎(물넘이 콘크리트 · 294-7) — 없으면 카드로 셈. */ + select: (chainageM: number, key: RevetPick | null, ownerM?: number) => void; /** 선택이 바뀌면 화면이 연동 카드 강조를 다시 칠한다 — 목록 쪽이 꽂는다. */ onSelectionChange?: () => void; /** 기하가 실제로 적용한 조작값을 되받아 담는다(한계에 걸린 요청값을 잘라 낸다). */ diff --git a/B06_Section/B06_Section_UI_Cross_Shape_Pick.ts b/B06_Section/B06_Section_UI_Cross_Shape_Pick.ts index d4e7694ec..1aa025e3f 100644 --- a/B06_Section/B06_Section_UI_Cross_Shape_Pick.ts +++ b/B06_Section/B06_Section_UI_Cross_Shape_Pick.ts @@ -8,6 +8,8 @@ * 구조물 판정 클래스(`b06-chart__culvert-revet-hit`)를 안 씀(보라 판정이 그 앞에 꽂혀 형상 위로 옴). * ② 누름만 고름 — 눌렀다 3 px 넘게 움직이면(끌기) 고름 아님. * ③ 노면공 포장 고름과 안 섞임 — 노면공 범위 표식(`data-pave-type`) 없는 형상만 · 누름은 번지지 않음. + * ④ 얇은 형상(물넘이 콘크리트 · 기본 배율 4 px)은 판정면을 아래쪽으로 `SHAPE_MIN_PX` 두께까지 늘림 — + * 노면선 보라 판정 띠(굵기 10 · 위아래 5) 바깥 아래를 누르면 형상(294-7). * ========================================================================== */ const SVG_NS = "http://www.w3.org/2000/svg"; @@ -15,6 +17,46 @@ const SVG_NS = "http://www.w3.org/2000/svg"; export const SHAPE_PICK_EVENT = "b06-structure-shape-pick"; /** 끌기로 볼 움직임(px) — 이보다 크면 고름 아님. */ export const SHAPE_DRAG_PX = 3; +/** 형상 판정면 최소 두께(그림 px) — 보라 판정 띠 아래 반(5)보다 넉넉히(294-7). */ +export const SHAPE_MIN_PX = 12; + +type XY = [number, number]; +const parse = (points: string): XY[] => + points + .trim() + .split(/\s+/) + .map((pair) => pair.split(",").map(Number) as XY) + .filter((p) => p.length === 2 && p.every(Number.isFinite)); +const insideOf = (ring: XY[], [x, y]: XY): boolean => + ring.reduce((inn, [ax, ay], i) => { + const [bx, by] = ring[(i + 1) % ring.length]; + return ay > y !== by > y && x < ax + ((y - ay) * (bx - ax)) / (by - ay) ? !inn : inn; + }, false); + +/** 얇은 형상 → 판정면 꼭짓점 — 바로 아래가 형상 밖인 꼭짓점(아랫변)만 내려 두께를 `minPx` 로. + * 두께 = 넓이 ÷ 가로 폭(기운 노면도 세로 두께로 잼). */ +export function thickenDown(points: string, minPx = SHAPE_MIN_PX): string { + const ring = parse(points); + if (ring.length < 3) return points; + const xs = ring.map(([x]) => x); + const area = Math.abs( + ring.reduce((sum, [x0, y0], i) => { + const [x1, y1] = ring[(i + 1) % ring.length]; + return sum + x0 * y1 - x1 * y0; + }, 0) / 2, + ); + const drop = minPx - area / Math.max(Math.max(...xs) - Math.min(...xs), 1e-9); + if (drop <= 0) return points; + const midX = ring.reduce((sum, [x]) => sum + x, 0) / ring.length; + const e = 1e-3; + return ring + .map(([x, y]): XY => { + const probe: XY = [x + (midX > x ? e : midX < x ? -e : 0), y + e]; + return insideOf(ring, probe) ? [x, y] : [x, y + drop]; + }) + .map((p) => p.join(",")) + .join(" "); +} /** 판정면을 꽂고 강조 setter 를 돌려줌 — 형상이 없으면 null. */ export function attachShapePick( @@ -32,7 +74,7 @@ export function attachShapePick( for (const shape of onPick ? shapes : []) { const id = shape.getAttribute("data-structure-id") ?? ""; const hit = document.createElementNS(SVG_NS, "polygon"); - hit.setAttribute("points", shape.getAttribute("points") ?? ""); + hit.setAttribute("points", thickenDown(shape.getAttribute("points") ?? "")); hit.setAttribute("class", "b06-chart__shape-hit"); let down: { x: number; y: number } | null = null; hit.addEventListener("pointerdown", (event) => { @@ -51,11 +93,14 @@ export function attachShapePick( return (active) => shapes.forEach((shape) => shape.classList.toggle("is-active", active)); } -/** 형상 누름 창구(287-5 · 288-2) — 관 지점(`pipe-…` · 물넘이포장) = 그 카드 시설 고름 · 그 밖(맹암거) = id 를 - * 실은 알림을 위로 띄움(구조물 목록이 받음). */ -export function shapePickRoute(from: Element, pickPipe: () => void): (structureId: string) => void { +/** 형상 누름 창구(287-5 · 288-2) — 관 지점(`pipe-…` · 물넘이포장) = 그 물넘이 누가거리로 고름(카드의 다른 관 · + * 연동 관 아님 · 294-7) · 그 밖(맹암거) = id 를 실은 알림을 위로 띄움(구조물 목록이 받음). */ +export function shapePickRoute( + from: Element, + pickPipe: (chainageM: number) => void, +): (structureId: string) => void { return (structureId) => { - if (structureId.startsWith("pipe-")) return pickPipe(); + if (structureId.startsWith("pipe-")) return pickPipe(Number(structureId.slice(5))); from.dispatchEvent(new CustomEvent(SHAPE_PICK_EVENT, { bubbles: true, detail: structureId })); }; } diff --git a/B06_Section/B06_Section_UI_Cross_View.ts b/B06_Section/B06_Section_UI_Cross_View.ts index 814b26d2b..7fa0738f2 100644 --- a/B06_Section/B06_Section_UI_Cross_View.ts +++ b/B06_Section/B06_Section_UI_Cross_View.ts @@ -232,13 +232,14 @@ export function createCrossSectionCard( }, selectCard: () => onSelect(section.station_id), }); - const toggleRevet = (key: RevetPick): void => { + const toggleRevet = (key: RevetPick, ownerM?: number): void => { const wasSelected = isSelected; activeRevet = activeRevet === key ? null : key; showRevetControl(activeRevet !== null); // 강조는 **연동된 카드 전부**라 제어기를 거친다(제어기가 목록에 다시 칠하기를 // 알린다 — 2026-08-30 사용자). 배선이 없어도 이 카드만은 즉시 맞춘다. - revetOffset?.select(section.chainage_m, activeRevet); + // `ownerM` = 폼에 올릴 시설 자리(물넘이 콘크리트 = 그 물넘이 · 294-7) + revetOffset?.select(section.chainage_m, activeRevet, ownerM); card.applyRevetHighlight?.(revetOffset?.highlightFor(section) ?? activeRevet); if (activeRevet !== null && activeArea !== null) { activeArea = null; @@ -587,7 +588,7 @@ export function createCrossSectionCard( // 물넘이포장 · 맹암거 형상 고름(287-5 · 288-2) — 다른 시설과 같은 창구 · 보라 손잡이 아래 · 누름만 const setShapeActive = attachShapePick( plotLayer, - revetOffset && shapePickRoute(svg, () => toggleRevet("pipe")), + revetOffset && shapePickRoute(svg, (at) => toggleRevet("pipe", at)), ); if (setShapeActive) { const baseSetActive = setRevetActive; diff --git a/B06_Section/B06_Section_UI_Page_Structures_Selection.ts b/B06_Section/B06_Section_UI_Page_Structures_Selection.ts index 20771b942..4dc6f64dd 100644 --- a/B06_Section/B06_Section_UI_Page_Structures_Selection.ts +++ b/B06_Section/B06_Section_UI_Page_Structures_Selection.ts @@ -45,10 +45,13 @@ export function wireStructureSelection( /** 폼에 올린 시설로 보기를 옮김 — 목록에서 고른 길(54-47). */ reveal?: (chainageM: number) => void, ): { syncInletStructure: () => void } { - const ownerChainageOf = (chainageM: number): number => { + const ownerChainageOf = (chainageM: number, key?: RevetPick): number => { const sectionAt = detail()?.cross_sections.find( (entry) => Math.abs(entry.chainage_m - chainageM) < 0.01, ); + // 자기 관 없는 카드의 「관」 = 물넘이 콘크리트 — 걸친 연동 관보다 앞(294-7 · 되살림 길도) + const fordM = sectionAt?.ford_pavement?.chainage_m; + if (key === "pipe" && fordM !== undefined && !sectionAt?.culvert) return fordM; // 물넘이포장 범위 옆 카드 = 그 물넘이 자리(287-5 · 형상 누름이 그 시설을 엶) return ( (sectionAt && @@ -75,14 +78,15 @@ export function wireStructureSelection( }; const origRevet = stationControls.revetOffset.select; - stationControls.revetOffset.select = (chainageM, key) => { + stationControls.revetOffset.select = (chainageM, key, ownerM) => { // 다른 카드 부재를 고르는 중에 옛 카드가 제 선택을 푸는 것 — 폼 · 선택 칸은 새 것 몫(53-1) const nested = wallSelecting; // 푸는 것은 고름이 아님 — 표시를 켜면 뒤이은 카드 고름이 선택 칸에 안 적힘(53-1) if (key) markWallSelecting(); origRevet(chainageM, key); // 도수로 띠는 자기 측점 시설 — 이웃 관 연장 안이어도 그 관 폼을 안 엶(152-4) - const owner = key === "chute" ? chainageM : ownerChainageOf(chainageM); + const owner = + ownerM ?? (key === "chute" ? chainageM : ownerChainageOf(chainageM, key ?? undefined)); if (key) remember?.(owner, key); if (key) panel.showAtChainage(owner); else if (!nested) { diff --git a/resources/tester/helper_294_7_ford_slab_pick.cjs b/resources/tester/helper_294_7_ford_slab_pick.cjs new file mode 100644 index 000000000..9319d1ad1 --- /dev/null +++ b/resources/tester/helper_294_7_ford_slab_pick.cjs @@ -0,0 +1,198 @@ +/* PLAN 294-7 · 295-4 단위검증 헬퍼 — 물넘이 콘크리트 누름(얇은 판정면 · 자기 물넘이 고름) · + * 보라 선 첫 누름이 남은 부재 고름을 안 되살림. 화면 TS 를 그 자리에서 트랜스파일해 Node 로 돌림. + * 짝 = `test_294_7_ford_slab_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 B06 = path.join(ROOT, "B06_Section"); + +class FakeStorage { + getItem(key) { + return Object.prototype.hasOwnProperty.call(this, key) ? this[key] : null; + } + setItem(key, value) { + this[key] = String(value); + } + removeItem(key) { + delete this[key]; + } +} +globalThis.window = { sessionStorage: new FakeStorage(), localStorage: new FakeStorage() }; +globalThis.document = { addEventListener: () => {}, removeEventListener: () => {} }; +globalThis.requestAnimationFrame = (fn) => fn(); + +const STUBS = { + "./B06_Section_UI_Adjust_Dock": { refreshAdjustSlots: () => {} }, + "./B06_Section_UI_Cross_Design_Pick": { setBermPickHost: () => {} }, + "./B06_Section_UI_Page_Persist": { BERM_TYPE_ID: "berm" }, +}; +const resolve = (base) => (fs.existsSync(`${base}.ts`) ? `${base}.ts` : base); +function loadTs(full) { + const js = ts.transpileModule(fs.readFileSync(full, "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, + }).outputText; + const box = { exports: {} }; + const localRequire = (request) => { + if (STUBS[request]) return STUBS[request]; + if (request.endsWith(".css")) return {}; + return loadTs(resolve(path.join(path.dirname(full), request))); + }; + new Function("exports", "module", "require", js)(box.exports, box, localRequire); + return box.exports; +} +const SP = loadTs(path.join(B06, "B06_Section_UI_Cross_Shape_Pick.ts")); +const PO = loadTs(path.join(B06, "B06_Section_UI_Cross_Pick_Order.ts")); +const SEL = loadTs(path.join(B06, "B06_Section_UI_Page_Structures_Selection.ts")); +const DS = loadTs(path.join(ROOT, "A00_Common", "design", "design_selection.ts")); +const out = {}; + +/* ── ① 얇은 콘크리트 판정면 — 기본 배율 4 px · 노면선 y=100 ─────────────── */ +const slab = "100,100 200,96 200,100 100,104"; // 기운 노면 · 두께 4 +const thick = SP.thickenDown(slab); +const pts = (s) => s.split(" ").map((p) => p.split(",").map(Number)); +const inside = (p, ring) => { + let inn = false; + for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) { + const [ax, ay] = ring[i]; + const [bx, by] = ring[j]; + if (ay > p[1] !== by > p[1] && p[0] < ((bx - ax) * (p[1] - ay)) / (by - ay) + ax) inn = !inn; + } + return inn; +}; +const gap = (p, [a, b]) => { + const d = [b[0] - a[0], b[1] - a[1]]; + const t = Math.max( + 0, + Math.min(1, ((p[0] - a[0]) * d[0] + (p[1] - a[1]) * d[1]) / (d[0] ** 2 + d[1] ** 2)), + ); + return Math.hypot(p[0] - a[0] - t * d[0], p[1] - a[1] - t * d[1]); +}; +// 카드 층 흉내 — 보라 노면선 판정(굵기 10 = 위아래 5) · 형상 판정면 · 중심 십자 없음 +const node = (name, classes, points) => ({ + name, + classList: { contains: (c) => classes.includes(c) }, + getAttribute: (a) => (a === "points" ? points : null), +}); +function stacked(shapePoints) { + const kids = []; + const layer = { + children: kids, + append: (...nodes) => + nodes.forEach((n) => { + const at = kids.indexOf(n); + if (at >= 0) kids.splice(at, 1); + kids.push(n); + }), + addEventListener: () => {}, + }; + layer.append( + node("road", ["b06-chart__design-hit"], "100,100 200,96"), + node("slab", ["b06-chart__shape-hit"], shapePoints), + ); + PO.stackPickTargets(layer); + return kids; +} +const topAt = (p, kids) => + [...kids] + .reverse() + .find((n) => + n.classList.contains("b06-chart__design-hit") + ? gap(p, pts(n.getAttribute("points"))) <= 5 + : inside(p, pts(n.getAttribute("points"))), + )?.name ?? null; +const now = stacked(thick); +const old = stacked(slab); +out.slab = { + thick, + bottomAt150: (pts(thick)[2][1] + pts(thick)[3][1]) / 2, // 아랫변 가운데 + justBelow: topAt([150, 106], now), // 노면선 6 px 아래 + justBelowOld: topAt([150, 106], old), + onLine: topAt([150, 99], now), // 노면선 위 = 보라 선(끌기 · 185) + insideOld: topAt([150, 101], old), // 옛 판정면 안쪽도 보라 띠에 덮임 + order: now.map((n) => n.name), + tallUnchanged: SP.thickenDown("0,0 30,0 30,20 0,20") === "0,0 30,0 30,20 0,20", +}; + +/* ── ② 형상 누름 길 — 물넘이 id = 그 누가거리 · 맹암거 = 알림 ─────────────── */ +const routed = []; +const from = { dispatchEvent: (event) => routed.push(["event", event.detail]) }; +globalThis.CustomEvent = class { + constructor(type, init) { + this.type = type; + this.detail = init?.detail; + } +}; +const route = SP.shapePickRoute(from, (at) => routed.push(["pipe", at])); +route("pipe-90.00"); +route("ud-1"); +out.routed = routed; + +/* ── ③ 폼에 올릴 시설(주인 측점) — 연동 관 95 m 가 걸친 물넘이 90 m 카드 ──── */ +const sections = [ + { chainage_m: 80, station_id: "s80", culvert: { c: 80 }, ford_pavement: { chainage_m: 90 } }, + { chainage_m: 90, station_id: "s90", ford_pavement: { chainage_m: 90 } }, + { chainage_m: 95, station_id: "s95", culvert: { c: 95 } }, + { chainage_m: 110, station_id: "s110", culvert: { c: 110 } }, +]; +const log = []; +const stationControls = { + revetOffset: { select: (at, key) => log.push(["select", at, key]), highlightFor: () => null }, + ford: { select: () => {} }, + box: { select: () => {} }, + structureSpan: { + // 연동 관 95 m 의 연장(유출 흙막이 10 m)이 90 m 카드까지 옴 + ownerOf: (s) => + s.culvert ? s : s.chainage_m >= 85 && s.chainage_m <= 105 ? sections[2] : null, + }, + inletStructure: { set: () => {}, valueFor: () => "auto" }, +}; +const panel = { + root: { addEventListener: () => {}, querySelectorAll: () => [], contains: () => false }, + facility: { onInletStructureChange: () => {}, setInletStructure: () => {} }, + currentChainage: () => null, + showAtChainage: (at) => log.push(["form", at]), + releasePipe: () => log.push(["release"]), + getStructures: () => [], + selectStructure: () => {}, +}; +SEL.wireStructureSelection( + stationControls, + () => ({ cross_sections: sections }), + panel, + (at, key) => log.push(["remember", at, key]), +); +const run = (...args) => { + log.length = 0; + stationControls.revetOffset.select(...args); + return log.filter(([kind]) => kind !== "select"); +}; +out.owner = { + slabOwnCard: run(90, "pipe"), // 형상 누름 · 되살림 = 그 물넘이 90(옛 = 연동 관 95) + slabHint: run(90, "pipe", 90), + slabLinkedCard: run(80, "pipe", 90), // 자기 관 있는 이웃 카드의 연동 콘크리트 = 물넘이 + pipeBody: run(80, "pipe"), // 관 몸통 = 자기 관 그대로 + chute: run(90, "chute"), +}; + +/* ── ④ 보라 선 첫 누름(295-4) — 남은 부재키(도수로 띠)를 카드 고름이 지키지 않게 ── */ +const P = "p1"; +const seed = () => { + DS.selectDesign(P, { chainageM: null, structureId: null }); + DS.selectDesign(P, { chainageM: 110, structureId: null }, { partKey: "chute" }); +}; +seed(); +DS.selectDesign(P, { chainageM: 110, structureId: null }, { from: "b06" }); // 옛 길 = 카드 고름만 +const oldKey = DS.readSelection(P)?.partKey ?? null; +seed(); +DS.selectDesign(P, { chainageM: 110, structureId: null }, { partKey: "", from: "b06" }); // 선 첫 누름 = 풂 먼저 +DS.selectDesign(P, { chainageM: 110, structureId: null }, { from: "b06" }); // 그다음 카드 고름 +out.firstLine = { oldKey, newKey: DS.readSelection(P)?.partKey ?? null }; + +// 다시 누름 = 풂 — 고름 표시(마이크로태스크)가 풀린 뒤 +queueMicrotask(() => { + out.owner.release = run(90, null, 90); + process.stdout.write(JSON.stringify(out)); +}); diff --git a/resources/tester/test_118_pipe_pick.py b/resources/tester/test_118_pipe_pick.py index 42c96a478..8a5254b20 100644 --- a/resources/tester/test_118_pipe_pick.py +++ b/resources/tester/test_118_pipe_pick.py @@ -112,5 +112,5 @@ def test_벽_본체_동시_고름_금지_그대로() -> None: in station ) view = _src("B06_Section/B06_Section_UI_Cross_View.ts") - assert "const toggleRevet = (key: RevetPick): void => {" in view + assert "const toggleRevet = (key: RevetPick, ownerM?: number): void => {" in view assert "if (activeRevet !== null) toggleRevet(activeRevet);" in view diff --git a/resources/tester/test_152_4_chute_pick.py b/resources/tester/test_152_4_chute_pick.py index 36bf47d36..a2deaddaa 100644 --- a/resources/tester/test_152_4_chute_pick.py +++ b/resources/tester/test_152_4_chute_pick.py @@ -61,6 +61,9 @@ def test_좌측_상자_강조_배선() -> None: ) selection = _src("B06_Section/B06_Section_UI_Page_Structures_Selection.ts") assert 'if (id === "chute_add" || id === "chute") return "chute";' in selection - assert 'const owner = key === "chute" ? chainageM : ownerChainageOf(chainageM);' in selection + assert ( + 'ownerM ?? (key === "chute" ? chainageM : ownerChainageOf(chainageM, key ?? undefined))' + in selection + ) css = _src("B06_Section/B06_Section_UI_Style_Cross_Areas.css") assert ".b06-chart__culvert-pitching.is-active {" in css diff --git a/resources/tester/test_287_ford_pavement_followup.py b/resources/tester/test_287_ford_pavement_followup.py index 789408e07..80a7a70c8 100644 --- a/resources/tester/test_287_ford_pavement_followup.py +++ b/resources/tester/test_287_ford_pavement_followup.py @@ -120,7 +120,7 @@ def test_형상_강조와_표식(ts): def test_보라_손잡이는_계획고만_형상_누름은_고름만(): view = _read("B06_Section", "B06_Section_UI_Cross_View.ts") # 형상 누름 = 시설 고름 창구만(설계 · 계획고를 안 건드림) · 보라 선 고름보다 먼저 꽂음(손잡이가 위) - assert 'shapePickRoute(svg, () => toggleRevet("pipe"))' in view + assert 'shapePickRoute(svg, (at) => toggleRevet("pipe", at))' in view assert view.index("attachShapePick(") < view.index("attachDesignLinePick({") pick = _read("B06_Section", "B06_Section_UI_Cross_Shape_Pick.ts") assert "onDesignChange" not in pick and "design_elevation" not in pick diff --git a/resources/tester/test_288_underdrain_cross.py b/resources/tester/test_288_underdrain_cross.py index 915a09620..51107f608 100644 --- a/resources/tester/test_288_underdrain_cross.py +++ b/resources/tester/test_288_underdrain_cross.py @@ -152,4 +152,4 @@ def test_맹암거_형상_누름은_그_구조물_고름(ts): assert "grid.addEventListener(SHAPE_PICK_EVENT, (event) => {" in view assert "if (id) pavementSelectListener?.(id);" in view # = structuresPanel.selectStructure(id) cross = (ROOT / "B06_Section" / "B06_Section_UI_Cross_View.ts").read_text(encoding="utf-8") - assert 'revetOffset && shapePickRoute(svg, () => toggleRevet("pipe")),' in cross + assert 'revetOffset && shapePickRoute(svg, (at) => toggleRevet("pipe", at)),' in cross diff --git a/resources/tester/test_294_1_chute_remove_pick.py b/resources/tester/test_294_1_chute_remove_pick.py index 558242756..d13c77ab5 100644 --- a/resources/tester/test_294_1_chute_remove_pick.py +++ b/resources/tester/test_294_1_chute_remove_pick.py @@ -64,5 +64,5 @@ def test_배관이면_관_상자_그대로(out): def test_형상_고름_길_그대로(): view = (ROOT / "B06_Section" / "B06_Section_UI_Cross_View.ts").read_text(encoding="utf-8") # 물넘이 콘크리트 · 맹암거 형상 누름 = 관 지점 고름 키 "pipe"(287-5) · 다시 누름 = 풂(toggle) - assert 'shapePickRoute(svg, () => toggleRevet("pipe"))' in view + assert 'shapePickRoute(svg, (at) => toggleRevet("pipe", at))' in view assert 'setShapeActive(key === "pipe");' in view diff --git a/resources/tester/test_294_7_ford_slab_pick.py b/resources/tester/test_294_7_ford_slab_pick.py new file mode 100644 index 000000000..3c043e626 --- /dev/null +++ b/resources/tester/test_294_7_ford_slab_pick.py @@ -0,0 +1,86 @@ +"""PLAN 294-7 — 물넘이 콘크리트 누름 고침(브레인 검증 297). + +- 기본 배율 콘크리트(약 4 px) = 보라 노면선 판정 띠(위아래 5 px)에 덮였음 → 형상 판정면을 아래쪽으로 + 최소 두께(12 px)까지 늘림 · 노면선 바로 아래 누름 = 콘크리트 · 노면선 위는 보라 선 그대로(185 끌기) +- 콘크리트 누름 = 그 물넘이(자기 id 누가거리) 고름 — 카드에 걸친 연동 관(95 m) · 자기 다른 관으로 안 감 · + 자기 관 없는 카드의 「관」 되살림도 물넘이 · 관 몸통 누름은 자기 관 그대로 · 다시 누름 = 풂 +- 맹암거 형상 = 알림(목록이 id 로 고름) 그대로 + +실제 TS 를 `helper_294_7_ford_slab_pick.cjs` 가 Node 로 돌린다. +""" + +from __future__ import annotations + +import json +import os +import subprocess + +import pytest + +HERE = os.path.dirname(__file__) +ROOT = os.path.join(HERE, "..", "..") +B06 = os.path.join(ROOT, "B06_Section") + +pytestmark = pytest.mark.skipif( + not os.path.isdir(os.path.join(ROOT, "config", "node_modules")), + reason="프론트엔드 의존성 미설치", +) + + +def _read(name: str) -> str: + with open(os.path.join(B06, name), encoding="utf-8") as f: + return f.read().replace(chr(13), "") + + +@pytest.fixture(scope="module") +def out() -> dict: + proc = subprocess.run( # noqa: S603 — 고정 실행 파일 + ["node", os.path.join(HERE, "helper_294_7_ford_slab_pick.cjs")], + capture_output=True, + text=True, + encoding="utf-8", + timeout=60, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +def test_기본_배율_노면선_바로_아래_누름은_콘크리트(out): + s = out["slab"] + assert s["thick"] == "100,100 200,96 200,108 100,112" # 아랫변만 8 내림(평균 두께 4 → 12) + assert s["bottomAt150"] == 110 + assert s["justBelowOld"] is None and s["justBelow"] == "slab" # 노면선 6 px 아래 + assert s["insideOld"] == "road" # 옛 판정면은 통째로 보라 띠 안 + assert s["onLine"] == "road" # 노면선 위 = 보라 선 그대로(끌기 · 185) + assert s["order"] == ["slab", "road"] + assert s["tallUnchanged"] is True # 두꺼운 형상(맹암거 등)은 그대로 + + +def test_형상_누름_길(out): + assert out["routed"] == [["pipe", 90], ["event", "ud-1"]] + + +def test_연동_관_있는_카드에서도_자기_물넘이(out): + o = out["owner"] + assert o["slabOwnCard"] == [["remember", 90, "pipe"], ["form", 90]] # 옛 = 연동 관 95 + assert o["slabHint"] == [["remember", 90, "pipe"], ["form", 90]] + assert o["slabLinkedCard"] == [["remember", 90, "pipe"], ["form", 90]] + assert o["pipeBody"] == [["remember", 80, "pipe"], ["form", 80]] + assert o["chute"] == [["remember", 90, "chute"], ["form", 90]] + + +def test_다시_누름은_풂(out): + assert out["owner"]["release"] == [["remember", 90, ""], ["release"]] + + +def test_배선_경사각도_상자(): + view = _read("B06_Section_UI_Cross_View.ts") + assert 'shapePickRoute(svg, (at) => toggleRevet("pipe", at))' in view + assert "revetOffset?.select(section.chainage_m, activeRevet, ownerM);" in view + assert 'setShapeActive(key === "pipe");' in view + assert len(view.splitlines()) <= 700 + shape = _read("B06_Section_UI_Cross_Shape_Pick.ts") + assert 'thickenDown(shape.getAttribute("points") ?? "")' in shape + # 폼에 물넘이(관 상자 없음) = 「경사각도」 상자(294-2 그대로) + dock = _read("B06_Section_UI_Adjust_Dock.ts") + assert 'side === "pipe" && (!own || own.closest("[hidden]")) ? visibleBox(SLOPE_BOX)' in dock