diff --git a/A00_Common/design/design_view_cross.ts b/A00_Common/design/design_view_cross.ts index 1f4239ca2..83b10d48e 100644 --- a/A00_Common/design/design_view_cross.ts +++ b/A00_Common/design/design_view_cross.ts @@ -37,6 +37,7 @@ import { } from "../../B06_Section/B06_Section_UI_Page_Persist"; import { readStructurePick } from "../../B05_Profile/B05_Profile_UI_Structure_Pick_Session"; import { NO_SELECTION, PIPE_MATCH_M, selectDesign } from "./design_selection"; +import { paintMapPick } from "./design_pick_profile"; import { readPendingPipes } from "../../B05_Profile/B05_Profile_Api_Pipes_Draft"; import { applyStructurePick } from "../../B06_Section/B06_Section_UI_Page_Structure_Pick"; import { bindCrossPick } from "../../B06_Section/B06_Section_UI_Page_Pick_Sync"; @@ -505,6 +506,7 @@ export async function mountCrossView( appliedPick = null; restoreStructurePick(); }, + paintMap: (selection) => shared.profile?.side && paintMapPick(shared.profile.side, selection), }); /** 고름을 다 풂(54-38) — 강조 모두 끄고 넘김 되얹기 표시 · 선택 칸 비움(풀기 한 길 · 233-2). */ function releaseCross(): void { diff --git a/B06_Section/B06_Section_UI_Page_Pick_Sync.ts b/B06_Section/B06_Section_UI_Page_Pick_Sync.ts index 239649672..d3b87e236 100644 --- a/B06_Section/B06_Section_UI_Page_Pick_Sync.ts +++ b/B06_Section/B06_Section_UI_Page_Pick_Sync.ts @@ -8,9 +8,11 @@ * 횡단 보기 안 누름(카드 · 목록 · 알약 · 형상)은 그 자리에서 그리고 값을 적으므로(from "b06") 되받지 않음. * 풀기(Esc · 카드 다시 누름 · 목록 다시 누름 · 보기 오감)는 `release` 한 길 — 카드 · 폼 · 범위 테두리 · * 형상까지 모두 끔(카드만 풀고 `--span` 이 남던 구멍 · a4). + * 한 벌 배수유역도(관 마커 · 유역 · 측점 마름모)는 낸 쪽과 무관하게 늘 값대로 칠함 — 횡단 보기 안 + * 누름(카드 · 목록)도 지도에 이어짐(270 · 종단 보기 `paintMapPick` 과 같은 규칙). * ========================================================================== */ -import { subscribeSelection } from "../A00_Common/design/design_selection"; +import { subscribeSelection, type DesignSelection } from "../A00_Common/design/design_selection"; import { bindEscapeRelease } from "../A00_Common/design/design_pick_escape"; import type { CrossSection } from "./B06_Section_Api_Fetch"; import type { StationControls } from "./B06_Section_UI_Page_Station_Controls"; @@ -27,6 +29,8 @@ export interface CrossPickDeps { }; /** 지금 값으로 카드 · 형상 · 폼을 다시 세움(넘김 적용 길). */ reapply: () => void; + /** 배수유역도를 값으로 칠함(알림 없이 · 270). */ + paintMap?: (selection: DesignSelection | null) => void; } let releasing = false; @@ -65,7 +69,8 @@ function releaseAll(deps: CrossPickDeps): void { } /** 횡단 보기를 고름 한 칸에 묶음 — 보일 때 · 다른 곳이 바꾼 값만 받음 · 보기를 떠나면 끊음. - * `release` = 강조 모두 끔(값은 부르는 쪽이 비움). */ + * `release` = 강조 모두 끔(값은 부르는 쪽이 비움). 지도는 보일 때 모든 값을 칠함 — 보기 전환 때는 + * 종단 보기가 같은 값으로 칠해 둠 · 재계산 뒤는 횡단 보기 종단도 알림(`onPipesChanged`)이 다시 얹음. */ export function bindCrossPick( projectId: string | null, host: { visible: () => boolean; onDispose: (fn: () => void) => void }, @@ -73,7 +78,9 @@ export function bindCrossPick( ): { release: () => void } { host.onDispose( subscribeSelection(projectId, (selection, from) => { - if (!host.visible() || from === "b06") return; + if (!host.visible()) return; + deps.paintMap?.(selection); + if (from === "b06") return; if (selection.chainageM === null) releaseCrossHighlights(deps); else deps.reapply(); }), diff --git a/B06_Section/B06_Section_UI_Section_View_Profile.ts b/B06_Section/B06_Section_UI_Section_View_Profile.ts index 3cac3c62d..d1f0a1767 100644 --- a/B06_Section/B06_Section_UI_Section_View_Profile.ts +++ b/B06_Section/B06_Section_UI_Section_View_Profile.ts @@ -15,7 +15,14 @@ import { type DesignPageShared, } from "../A00_Common/design/design_shared"; import { readState } from "../A00_Common/b_page_state"; -import { coversChainage, PIPE_MATCH_M } from "../A00_Common/design/design_selection"; +import { + coversChainage, + NO_SELECTION, + PIPE_MATCH_M, + readSelection, + selectDesign, +} from "../A00_Common/design/design_selection"; +import { paintMapPick } from "../A00_Common/design/design_pick_profile"; import { fetchLatestRoute, type RouteLatestResponse } from "../B05_Profile/B05_Profile_Api_Fetch"; import type { CrossSection, @@ -158,6 +165,15 @@ export function createSectionProfile( return stationId; }; + /** 배수유역도에서 고른 관 자리 → 고름 한 칸(null = 풂) — 횡단 보기 구독이 카드 · 폼 · 알약 · 표 열을 + * 그 값으로 세움(종단 보기 `pickFromMap` 과 같은 몫 · 270). */ + const pickFromMap = (chainageM: number | null): void => + selectDesign( + shared.projectId, + chainageM === null ? NO_SELECTION : { chainageM, structureId: null }, + { from: "map" }, + ); + const syncMarks = (): void => { if (!profile) return; profile.setStructureTypes([...types]); @@ -196,6 +212,13 @@ export function createSectionProfile( onStructureTypeAdd: (chainage, typeId) => host.edit()?.addStructureType(chainage, typeId), onIrregularSelect: (station) => host.selectCard(nearestCard(station.chainage_m)?.station_id ?? null), + onBasinSelected: (chainageM) => pickFromMap(chainageM), + onPipeSelected: (chainageM) => pickFromMap(chainageM), + // 배수 재계산이 지운 지도 강조를 값대로 다시 얹음(종단 보기 restorePick 과 같은 몫 · 270) + onPipesChanged: () => { + if (shared.view() === "cross" && shared.profile?.side) + paintMapPick(shared.profile.side, readSelection(shared.projectId)); + }, // 알약을 누르면 그 자리에 가장 가까운 측점 카드를 고른다 — 목록 클릭과 같은 규칙. // 구조물 알약은 그 구조물을 폼에 올린다 — 카드가 측점으로 풀면 같은 측점 관이 먼저다(52-15). onStructureSelect: (structureId) => { diff --git a/resources/tester/helper_270_1_cross_map.cjs b/resources/tester/helper_270_1_cross_map.cjs new file mode 100644 index 000000000..9252a7b2a --- /dev/null +++ b/resources/tester/helper_270_1_cross_map.cjs @@ -0,0 +1,118 @@ +/* 270-1 횡단 보기 배수유역도 = 고름 한 칸에서 파생 — 진짜 TS 를 Node 로 돌림. + * `bindCrossPick`(B06_Section_UI_Page_Pick_Sync) + 진짜 `paintMapPick`(design_pick_profile) + 지도 흉내. + * 표준출력 JSON 한 벌 · 짝 = test_270_1_cross_map.py. + */ +const fs = require("fs"); +const path = require("path"); +const ROOT = path.join(__dirname, "..", ".."); +const NM = path.join(ROOT, "config", "node_modules"); +const ts = require(path.join(NM, "typescript")); + +function loadTs(file, stubs = {}) { + const js = ts.transpileModule(fs.readFileSync(path.join(ROOT, file), "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, + }).outputText; + const box = { exports: {} }; + const localRequire = (request) => { + if (stubs[request]) return stubs[request]; + throw new Error(`stub 없음: ${request}`); + }; + new Function("exports", "module", "require", js)(box.exports, box, localRequire); + return box.exports; +} + +globalThis.window = { addEventListener() {}, removeEventListener() {} }; +globalThis.document = { addEventListener() {}, removeEventListener() {} }; + +const store = new Map(); +const DSel = loadTs("A00_Common/design/design_selection.ts", { + "../b_page_state": { + readState: (key, pid) => store.get(`${key}:${pid}`) ?? null, + writeState: (key, value, pid) => store.set(`${key}:${pid}`, value), + }, +}); +const ESC = loadTs("A00_Common/design/design_pick_escape.ts", { "./design_selection": DSel }); +const IRR = { + irregularStationId: (id) => `irregular:${id}`, + isPipeStation: (s) => s.origin === "pipe", +}; +const SY = loadTs("B05_Profile/B05_Profile_UI_Selection.ts", { + "../A00_Common/design/design_selection": DSel, + "./B05_Profile_UI_IrregularStations": IRR, +}); +const PP = loadTs("A00_Common/design/design_pick_profile.ts", { + "./design_selection": DSel, + "./design_pick_escape": ESC, + "../../B05_Profile/B05_Profile_UI_Selection": SY, + "../../B05_Profile/B05_Profile_UI_IrregularStations": IRR, +}); +const SYNC = loadTs("B06_Section/B06_Section_UI_Page_Pick_Sync.ts", { + "../A00_Common/design/design_selection": DSel, + "../A00_Common/design/design_pick_escape": ESC, +}); + +const PID = "p270"; +/* 지도 흉내 — 관 셋(95.38 · 195.3 · 292.22) · 고른 관 = 찾는 폭 안 관(배수유역도 `selectPipeAtChainage` 꼴) */ +const PIPES = [95.38, 195.3, 292.22]; +const map = { + calls: [], + pipe: null, + station: null, + selectPipeAtChainage(at, notify = true) { + this.calls.push(["pipe", at, notify]); + this.pipe = + at === null ? null : (PIPES.find((c) => Math.abs(c - at) < DSel.PIPE_MATCH_M) ?? null); + }, + markStation(at) { + this.calls.push(["mark", at]); + this.station = at; + }, +}; +let visible = true; +let reapplied = 0; +const disposers = []; +const host = { visible: () => visible, onDispose: (fn) => disposers.push(fn) }; +const deps = { + sections: () => [], + controls: { + revetOffset: { highlightFor: () => false, selectedFor: () => false, select() {} }, + ford: { selectedFor: () => false, select() {} }, + box: { selectedFor: () => false, select() {} }, + }, + releaseForm() {}, + view: { focusStation() {}, refreshCards() {}, selectMark() {} }, + reapply: () => reapplied++, + paintMap: (selection) => PP.paintMapPick(map, selection), +}; +const pick = SYNC.bindCrossPick(PID, host, deps); +const snap = () => ({ pipe: map.pipe, station: map.station, reapplied, calls: map.calls.length }); +const out = {}; + +// ① 횡단 보기 안 누름(from b06) — 지도는 칠하고 넘김 적용은 안 함 +DSel.selectDesign(PID, { chainageM: 95.38, structureId: null }, { from: "b06" }); +out.b06Pipe = { ...snap(), notify: map.calls[0]?.[2] }; +// ② 일반 측점 카드(관 없음) — 옛 관 강조가 풀리고 마름모만 +DSel.selectDesign(PID, { chainageM: 80, structureId: null }, { from: "b06" }); +out.b06Card = snap(); +// ③ 지도에서 고름(from map) — 지도 칠함 + 넘김 적용(카드 · 폼 · 알약 · 표 열) +DSel.selectDesign(PID, { chainageM: 292.22, structureId: null }, { from: "map" }); +out.fromMap = snap(); +// ④ 노면공(범위 · 기준 30) — 관 없음 · 넘김 적용 +DSel.selectDesign(PID, { chainageM: 30, structureId: "pave" }, { from: "b05" }); +out.pave = snap(); +// ⑤ 풀기(from b06) — 지도 풂 +DSel.selectDesign(PID, DSel.NO_SELECTION, { from: "b06" }); +out.release = snap(); +// ⑥ 숨은 보기 — 안 칠함 · 안 적용 +visible = false; +const before = map.calls.length; +DSel.selectDesign(PID, { chainageM: 195.3, structureId: null }, { from: "map" }); +out.hidden = { calls: map.calls.length - before, reapplied }; +// ⑦ 다시 보임 뒤 다른 곳(종단 보기)이 바꾼 값 = 칠함 + 넘김 적용 +visible = true; +DSel.selectDesign(PID, { chainageM: 95.38, structureId: null }, { from: "b05" }); +out.show = snap(); +pick.release(); // 풀기 길은 그대로(값은 부르는 쪽이 비움) +out.releaseKeepsMap = map.pipe; + +process.stdout.write(JSON.stringify(out)); diff --git a/resources/tester/test_270_1_cross_map.py b/resources/tester/test_270_1_cross_map.py new file mode 100644 index 000000000..c503d72e2 --- /dev/null +++ b/resources/tester/test_270_1_cross_map.py @@ -0,0 +1,82 @@ +# -*- coding: utf-8 -*- +"""270-1 횡단 보기 배수유역도 = 고름 한 칸에서 파생(요구 165-4). + +까닭: 한 벌 배수유역도를 종단 보기 구독(`bindProfilePick`)만 칠해(보일 때만) 횡단 보기에서 고른 관 · +세월교의 유역 · 관 마커가 안 켜지고 종단 보기에서 칠한 옛 강조가 남음 · 지도에서 유역을 눌러도 횡단 보기 +알림(`onBasinSelected` · `onPipeSelected`)이 없어 카드 · 표 열 · 목록이 안 따라옴. +고침: 횡단 보기 구독이 낸 쪽과 무관하게 지도를 값으로 칠함(`paintMapPick` · 알림 없이) · 지도 고름 = 고름 한 칸 +(from map → 넘김 적용 길) · 배수 재계산 · 보기 전환 뒤 다시 얹음. +진짜 TS 를 `helper_270_1_cross_map.cjs` 가 Node 로 돌린다. +""" + +from __future__ import annotations + +import json +import os +import subprocess + +import pytest + +HERE = os.path.dirname(os.path.abspath(__file__)) +ROOT = os.path.abspath(os.path.join(HERE, "..", "..")) + +pytestmark = pytest.mark.skipif( + not os.path.isdir(os.path.join(ROOT, "config", "node_modules")), + reason="프론트엔드 의존성 미설치", +) + + +def _read(*parts: str) -> str: + with open(os.path.join(ROOT, *parts), encoding="utf-8") as handle: + return handle.read() + + +@pytest.fixture(scope="module") +def out() -> dict: + proc = subprocess.run( # noqa: S603 — 고정 실행 파일 + ["node", os.path.join(HERE, "helper_270_1_cross_map.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): + # 목록 · 카드로 관을 고르면(from b06) 지도 관 · 마름모가 켜지고 넘김 적용은 안 돎 · 알림 없이 칠함 + assert out["b06Pipe"]["pipe"] == 95.38 and out["b06Pipe"]["station"] == 95.38 + assert out["b06Pipe"]["reapplied"] == 0 and out["b06Pipe"]["notify"] is False + + +def test_관_없는_카드는_옛_관_강조_풂(out): + assert out["b06Card"]["pipe"] is None and out["b06Card"]["station"] == 80 + + +def test_지도_고름은_넘김_적용(out): + # 지도(from map)에서 고르면 카드 · 폼 · 알약 · 표 열을 세우는 넘김 적용이 한 번 돎 + assert out["fromMap"]["pipe"] == 292.22 and out["fromMap"]["reapplied"] == 1 + + +def test_노면공은_관_강조_없음(out): + # 노면공(범위) 고름 = 관 없음 → 유역 · 관 마커 끔(노면공 범위가 관을 덮어 켜지 않음) + assert out["pave"]["pipe"] is None and out["pave"]["station"] == 30 + + +def test_풀기_숨은_보기_다시_보임(out): + assert out["release"]["pipe"] is None and out["release"]["station"] is None + assert out["hidden"] == {"calls": 0, "reapplied": 2} # 숨은 횡단 보기는 종단 보기 몫 + assert out["show"]["pipe"] == 95.38 and out["show"]["reapplied"] == 3 + assert out["releaseKeepsMap"] == 95.38 # release 는 횡단 강조만 — 지도는 값이 비면 구독이 풂 + + +def test_횡단_보기_이음(): + view = _read("B06_Section", "B06_Section_UI_Section_View_Profile.ts") + assert "onBasinSelected: (chainageM) => pickFromMap(chainageM)" in view + assert "onPipeSelected: (chainageM) => pickFromMap(chainageM)" in view + assert '{ from: "map" }' in view and "NO_SELECTION" in view + # 배수 재계산 뒤 다시 얹음 — 횡단 보기가 보일 때만 + assert 'shared.view() === "cross"' in view and "paintMapPick(shared.profile.side" in view + cross = _read("A00_Common", "design", "design_view_cross.ts") + assert "paintMap: (selection) =>" in cross and "paintMapPick(shared.profile.side" in cross