From 2641c15e29fc8cbf6d65e812a6563275abc28c6e Mon Sep 17 00:00:00 2001 From: umsangdon Date: Wed, 14 Oct 2026 06:52:02 +0900 Subject: [PATCH] =?UTF-8?q?fix(b05=C2=B7b06):=20=EC=A0=95=EB=B3=B8=20?= =?UTF-8?q?=EB=8B=A4=EB=A5=B8=20=EC=A2=85=EB=A5=98(=EA=B4=80=20=E2=86=94?= =?UTF-8?q?=20=EB=A7=B9=EC=95=94=EA=B1=B0=20=C2=B7=20=EA=B0=9C=EA=B1=B0=20?= =?UTF-8?q?=EB=93=B1)=EB=A1=9C=20=EB=B0=94=EA=BF=88=20=3D=20=EC=98=9B=20?= =?UTF-8?q?=EA=B2=83=20=EC=A7=80=EC=9A=B0=EA=B3=A0=20=EA=B0=99=EC=9D=80=20?= =?UTF-8?q?=EC=B8=A1=EC=A0=90=EC=97=90=20=EC=83=88=20=EC=A2=85=EB=A5=98=20?= =?UTF-8?q?=C2=B7=20=EC=9D=BC=EB=B0=98=20=EC=B8=A1=EC=A0=90=20=EC=9E=90?= =?UTF-8?q?=EB=A6=AC=20=EC=A2=85=EB=A5=98=20=EB=B0=94=EA=BF=88=20=C2=B7=20?= =?UTF-8?q?=EC=A7=80=EC=9B=80=20=3D=20=EA=B5=AC=EC=A1=B0=EB=AC=BC=EB=A7=8C?= =?UTF-8?q?=20=EB=BA=80=20=EC=82=AC=EB=B3=B8=EC=97=90=20=EC=83=88=20?= =?UTF-8?q?=EC=A2=85=EB=A5=98=20=C2=B7=20=EC=A4=91=EC=8B=AC=EA=B3=A0=20?= =?UTF-8?q?=EC=83=81=EC=9E=90=EC=97=90=20=EA=B2=B9=EC=B9=9C=20Y=20?= =?UTF-8?q?=EB=88=88=EA=B8=88=20=EC=88=A8=EA=B8=B0=EC=A7=80=20=EC=95=8A?= =?UTF-8?q?=EC=9D=8C(=EC=83=81=EC=9E=90=20=EC=9C=84=20=C2=B7=20=EC=95=88?= =?UTF-8?q?=20=EB=B9=84=EC=B9=98=EB=8A=94=20=EB=B0=94=ED=83=95)=20(319=20?= =?UTF-8?q?=C2=B7=20=EC=9A=94=EA=B5=AC=20217)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 공용 구조물 패널 종류 바꿈: 관 지점 ↔ 구조물이면 새로 놓기 폼으로 빠지던 것 → 옛 것 지움 → 우클릭 추가 길(addAt)로 같은 측점 · 옛 측점 조정값 버림(318-4) - 횡단 초안 맞춤: 일반 측점 자리(grid)는 종류가 바뀌어도 저장분(옛 관 그림)이 남던 것 → 구조물만 뺀 사본(지형 쪽 고친 값 그대로) + 새 종류 - 시험: test_319_kind_swap 8 · test_318 중심고 겹침 기대값 바꿈 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01C6BNBFXFndbqYxu6sZuknK --- .../B05_Profile_UI_Structures_Panel.ts | 8 +- .../B05_Profile_UI_Structures_Panel_Events.ts | 27 +- B06_Section/B06_Section_Section_Drafts.ts | 28 +- B06_Section/B06_Section_UI_Cross_Axes.ts | 4 +- B06_Section/B06_Section_UI_Style_Cross.css | 3 +- resources/tester/helper_318_cross_card.cjs | 1 + resources/tester/helper_319_kind_swap.cjs | 319 ++++++++++++++++++ resources/tester/test_318_cross_card.py | 6 +- resources/tester/test_319_kind_swap.py | 115 +++++++ 9 files changed, 494 insertions(+), 17 deletions(-) create mode 100644 resources/tester/helper_319_kind_swap.cjs create mode 100644 resources/tester/test_319_kind_swap.py diff --git a/B05_Profile/B05_Profile_UI_Structures_Panel.ts b/B05_Profile/B05_Profile_UI_Structures_Panel.ts index 6c28e06a5..223e6b6fb 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Panel.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Panel.ts @@ -465,9 +465,7 @@ export function createStructuresSection( const loadPipeForm = (pipe: PipeFacilityItem): void => loadPipeFormInto(panelContext, pipe); const emit = (): void => emitFrom(panelContext); const commit = (live = false): Promise => commitInto(panelContext, live); - - /* 입력 배선은 700줄 제한으로 `_Structures_Panel_Events` 로 옮겼다 — 처리 함수는 여기 그대로다. */ - /* 저장·배선이 함께 보는 패널 컨텍스트 — 가변 상태의 주인은 그대로 패널이다. */ + /* 입력 배선(700줄 제한 · `_Structures_Panel_Events`)과 저장이 함께 보는 컨텍스트 — 가변 상태 주인 = 패널. */ const panelContext: StructuresEventContext = { groupSelect, typeSelect, @@ -493,6 +491,7 @@ export function createStructuresSection( commitPosition: () => commitPosition(), liveCommit: () => liveCommit(), loadForm, + addAt: (at, typeId) => api.addAt(at, typeId), // 정본 다른 종류로 바꿈 = 같은 측점 새로 놓기(217) cancelTempPipe: () => cancelTempPipe(), syncTypeOptions: (keepTypeId) => syncTypeOptions(keepTypeId), syncPlacementFields: () => syncPlacementFields(), @@ -543,7 +542,7 @@ export function createStructuresSection( syncButtons(); renderList(); - return { + const api: StructuresSection = { root, facility: { ...facilityOptions, recount: () => (facilityOptions.recount(), renderList()) }, // 단 수 → 목록 램프(54-15) listRoot: list, @@ -697,4 +696,5 @@ export function createStructuresSection( return true; }, }; + return api; } diff --git a/B05_Profile/B05_Profile_UI_Structures_Panel_Events.ts b/B05_Profile/B05_Profile_UI_Structures_Panel_Events.ts index 392223cf9..77aebce06 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Panel_Events.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Panel_Events.ts @@ -8,7 +8,11 @@ * 처리 함수(commit·loadForm 등)는 그대로 패널 소유다 — 여기서는 이어 붙이기만 한다. * ========================================================================== */ -import type { StructureInstance, StructureType } from "./B05_Profile_Api_Structures"; +import { + structureAnchorM, + type StructureInstance, + type StructureType, +} from "./B05_Profile_Api_Structures"; import type { StructuresCommitContext } from "./B05_Profile_UI_Structures_Panel_Commit"; import { asRoad } from "./B05_Profile_UI_Structures_Define"; import { readState, writeState } from "../A00_Common/b_page_state"; @@ -34,6 +38,8 @@ export interface StructuresEventContext extends StructuresCommitContext { commitPosition: () => void; liveCommit: () => void; loadForm: (target: StructureInstance | null) => void; + /** 우클릭 추가와 같은 길 — 그 측점에 그 종류를 놓고 고름(패널 `addAt`). */ + addAt: (chainageM: number, typeId: string) => void; emit: () => void; optionRow: HTMLElement; } @@ -82,9 +88,22 @@ export function bindStructuresEvents(ctx: StructuresEventContext): { ? null : ctx.pipeFacilities().find((entry) => Math.abs(entry.chainage_m - pipe) < PIPE_MATCH_M); const prev = ctx.typeMap().get(record ? asRoad(record).type_id : (pipeItem?.facility ?? "")); - // 정본이 다른 종류(관 지점 ↔ 구조물)로 · 빈칸으로 = 새로 놓기 - if (!next || !prev || !!next.managed_by !== !!prev.managed_by) - return startNew(ctx.groupSelect.value, next?.type_id); + if (!next || !prev) return startNew(ctx.groupSelect.value, next?.type_id); // 빈칸 = 새로 놓기 + // 정본이 다른 종류(관 지점 ↔ 구조물 — 맹암거 · 개거 등)로 = 옛 것을 지워 「없음」을 거친 뒤 같은 + // 측점에 새 종류를 놓고 고름(우클릭 추가 길 · 새 종류 기본값 · 요구 217) · 옛 조정값은 318-4 대로 버림 + if (!!next.managed_by !== !!prev.managed_by) { + const at = record ? structureAnchorM(record) : pipeItem!.chainage_m; + if (record) { + ctx.structures.splice(index, 1); + ctx.setEditingId(null); + ctx.emit(); + } else { + ctx.callbacks.onPipeRemove(at); + ctx.setSelectedPipeChainage(null); + } + forgetStationAdjusts(ctx.projectId, at, prev.type_id, next.type_id); + return ctx.addAt(at, next.type_id); + } const key = record ? `s:${id}` : `p:${pipe}`; // 구조물은 정본 기록(안 보이는 상세 칸까지) · 관은 아직 옛 종류로 그려진 폼에서 읽음 stashTypeValues( diff --git a/B06_Section/B06_Section_Section_Drafts.ts b/B06_Section/B06_Section_Section_Drafts.ts index 52854a601..6f43f57e4 100644 --- a/B06_Section/B06_Section_Section_Drafts.ts +++ b/B06_Section/B06_Section_Section_Drafts.ts @@ -103,7 +103,21 @@ async function previewOf( return chainages.flatMap((at) => previews.get(key(at)) ?? []); } -/** 미리 뜬 횡단에 카드 이름 · 관 그림 — 서버가 [저장] 때 붙이는 것과 같은 규칙. */ +/** 일반 측점 자리(grid · 272)의 「구조물 없는 꼴」(217) — 저장분에서 시설 · 이름만 뺀 사본(한 벌을 다시 씀 · + * 지형 쪽 고친 값은 그 측점 것 그대로) · 새 종류는 그 위에 얹음(`decorate`) · 측점은 남으므로 숨기지 않음. */ +const bares = new WeakMap(); +function bareOf(section: CrossSection): CrossSection { + let copy = bares.get(section); + if (!copy) { + copy = { ...section, preview: true }; + const record = copy as unknown as Record; + for (const key of ["culvert", "ford", "box", "ford_pavement", "structure"]) delete record[key]; + bares.set(section, copy); + } + return copy; +} + +/** 미리 뜬 횡단 · 일반 측점 자리 사본에 카드 이름 · 관 그림 — 서버가 [저장] 때 붙이는 것과 같은 규칙. */ function decorate( fresh: CrossSection[], pipes: Pipes, @@ -138,6 +152,7 @@ function decorate( section.preview = true; const hit = labels.find(([at]) => near(at, section.chainage_m)); if (hit) section.structure = hit[1]; + else delete section.structure; // 구조물이 다 빠진 일반 측점 자리(217) } const own = pipes.filter((pipe) => fresh.some((section) => near(section.chainage_m, pipe.chainage_m)), @@ -206,19 +221,22 @@ export async function withDraftStations( const was = kindsAt(oldPipes, oldStructures, section.chainage_m); return was !== "" && was !== kindsAt(pipes, structures, section.chainage_m); }; + // 일반 측점 자리(grid · 272)는 측점이 남음 — 종류가 바뀌거나 빠지면 구조물 없는 사본에 새 종류(217) const isStale = (section: CrossSection): boolean => section.kind === "irregular" && - !section.grid && - (planted.every((at) => !near(at, section.chainage_m)) || replaced(section)); + (section.grid + ? replaced(section) + : planted.every((at) => !near(at, section.chainage_m)) || replaced(section)); stale = all.filter(isStale); - live = all.filter((section) => !isStale(section)); + const bare = stale.filter((section) => section.grid).map(bareOf); + live = [...all.filter((section) => !isStale(section)), ...bare]; const missing = planted.filter( (at, index) => planted.findIndex((other) => near(other, at)) === index && live.every((section) => !near(section.chainage_m, at)), ); fresh = missing.length ? await previewOf(projectId, routeId, missing).catch(() => []) : []; - if (fresh.length) decorate(fresh, pipes, structures, types); + if (fresh.length || bare.length) decorate([...fresh, ...bare], pipes, structures, types); } } if (turns.get(detail) !== turn) return; // 더 새 맞춤이 돎 — 그쪽이 얹음 diff --git a/B06_Section/B06_Section_UI_Cross_Axes.ts b/B06_Section/B06_Section_UI_Cross_Axes.ts index 72b8c20cf..2bb553ade 100644 --- a/B06_Section/B06_Section_UI_Cross_Axes.ts +++ b/B06_Section/B06_Section_UI_Cross_Axes.ts @@ -117,7 +117,8 @@ export function createCrossAxes( const yDecimals = Math.max(0, Math.ceil(-Math.log10(yStep) - 1e-9)); const screenYOf = (raw: number) => ty + scale * y(options.elevationMid + (raw - options.elevationMid) * options.exaggeration); - // 중심고 상자 자리 — 보이는 범위 안일 때만 · 그 높이 눈금 글자는 상자에 양보 + // 중심고 상자 자리 — 보이는 범위 안일 때만 · 겹친 눈금 글도 그림(상자를 맨 뒤에 붙여 위에 · + // 바탕이 안 비쳐 가림 · 319-1) const centerZ = options.centerZ; const centerY = typeof centerZ === "number" && centerZ >= rawBottom && centerZ <= rawTop @@ -134,7 +135,6 @@ export function createCrossAxes( class: "b06-chart__grid", }), ); - if (centerY !== null && Math.abs(screenY - centerY) < CENTER_BOX_H) continue; layer.append( svgText(raw.toFixed(yDecimals), { x: plotLeft - Y_LABEL_GAP_PX, diff --git a/B06_Section/B06_Section_UI_Style_Cross.css b/B06_Section/B06_Section_UI_Style_Cross.css index 2b23f1c65..a141ae442 100644 --- a/B06_Section/B06_Section_UI_Style_Cross.css +++ b/B06_Section/B06_Section_UI_Style_Cross.css @@ -395,7 +395,8 @@ html.is-bm-route .b06-section__notice-bar { font-size: 10px; } -/* 중심고 Y축 라벨(318-2) — 눈금과 같은 10px · 둥근 상자 · 테마 강조색 · 축선 앞에서 멈춤 */ +/* 중심고 Y축 라벨(318-2) — 눈금과 같은 10px · 둥근 상자 · 테마 강조색 · 축선 앞에서 멈춤 · + 바탕 = 불투명 섞음(겹친 눈금 글을 위에서 가림 · 319-1) */ .b06-chart__center-box { fill: color-mix(in srgb, var(--color-accent) 14%, var(--color-surface-raised)); stroke: var(--color-accent); diff --git a/resources/tester/helper_318_cross_card.cjs b/resources/tester/helper_318_cross_card.cjs index 06008298a..054576849 100644 --- a/resources/tester/helper_318_cross_card.cjs +++ b/resources/tester/helper_318_cross_card.cjs @@ -277,6 +277,7 @@ const out = {}; tick_x: ticks.length ? Number(ticks[0].attrs.x) : null, tick_near_center: ticks.filter((t) => Math.abs(Number(t.attrs.y) - 3 - centerY) < 14).length, tick_count: ticks.length, + on_top: layer.children.slice(-2).map((node) => node.className), // 319-1 상자 · 글 = 맨 뒤(위) hidden_when_out_of_view: draw(800).querySelector(".b06-chart__center-box") === null, absent_without_center: draw(null).querySelector(".b06-chart__center-box") === null, }; diff --git a/resources/tester/helper_319_kind_swap.cjs b/resources/tester/helper_319_kind_swap.cjs new file mode 100644 index 000000000..b26796131 --- /dev/null +++ b/resources/tester/helper_319_kind_swap.cjs @@ -0,0 +1,319 @@ +/* PLAN 319 단위검증 헬퍼 — 정본이 다른 종류로 바꿈(관 지점 ↔ 구조물 · 맹암거 · 개거) = 옛 것 지우고 같은 + * 측점에 새 종류(패널 입력 배선) · 일반 측점 자리(grid) 종류 바꿈 · 지움 = 구조물만 뺀 사본에 새 종류(횡단 초안 맞춤). + * 진짜 TS 를 그 자리에서 트랜스파일해 Node 로 돌림(서버 · 무거운 화면 모듈은 흉내) · 결과 = 표준출력 JSON 한 벌. + * 짝 = `test_319_kind_swap.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 B05 = (name) => path.join(ROOT, "B05_Profile", `${name}.ts`); +const B06 = (name) => path.join(ROOT, "B06_Section", `${name}.ts`); +const TYPES = JSON.parse( + fs.readFileSync(path.join(ROOT, "resources", "master_template", "구조물정의.json"), "utf8"), +).types; + +class FakeStorage { + constructor() { + this.map = new Map(); + } + get length() { + return this.map.size; + } + key(index) { + return [...this.map.keys()][index] ?? null; + } + getItem(key) { + return this.map.has(key) ? this.map.get(key) : null; + } + setItem(key, value) { + this.map.set(key, String(value)); + } + removeItem(key) { + this.map.delete(key); + } +} +globalThis.window = { + sessionStorage: new FakeStorage(), + localStorage: new FakeStorage(), + addEventListener: () => {}, + removeEventListener: () => {}, + dispatchEvent: () => true, + requestAnimationFrame: (fn) => fn(), + setTimeout, + clearTimeout, +}; +globalThis.sessionStorage = window.sessionStorage; +globalThis.localStorage = window.localStorage; +globalThis.requestAnimationFrame = (fn) => fn(); +globalThis.document = { activeElement: null, addEventListener: () => {} }; + +const anything = () => + new Proxy({}, { get: (_t, key) => (key === "__esModule" ? false : () => undefined) }); + +/** `real` 에 든 파일만 진짜로 읽고 그 밖 상대 경로는 흉내 · `stubs` 는 지정자 그대로 갈음. */ +function makeLoader(real, stubs = {}) { + const cache = new Map(); + const realSet = new Set(real.map((file) => path.resolve(file))); + const load = (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 need = (spec) => { + if (spec in stubs) return stubs[spec]; + let target = null; + if (spec.startsWith("@util/")) target = path.join(ROOT, "common_util", `${spec.slice(6)}.ts`); + else if (spec.startsWith(".")) target = path.join(path.dirname(full), `${spec}.ts`); + if (target && realSet.has(path.resolve(target))) return load(target); + return anything(); + }; + new Function("exports", "module", "require", js)(box.exports, box, need); + return box.exports; + }; + return load; +} + +const out = {}; + +// ── 1. 패널 종류 바꿈(319-2) — 정본 다른 종류 = 지우고 같은 측점 addAt ───────────── +{ + const load = makeLoader( + [ + B05("B05_Profile_UI_Structures_Panel_Events"), + B05("B05_Profile_Api_Structures"), + B06("B06_Section_Api_Culvert_Options"), + path.join(ROOT, "A00_Common", "b_page_state.ts"), + path.join(ROOT, "A00_Common", "design", "design_selection.ts"), + path.join(ROOT, "common_util", "common_util_summary_item.ts"), + ], + { + "./B05_Profile_UI_Structures_Define": { asRoad: (record) => record }, + "@ui/ui_template_elements": { showConfirmDialog: async () => true, showToast: () => {} }, + "./B06_Section_Section_Drafts": { forgetPreviewAdjusts: () => {} }, + }, + ); + const { bindStructuresEvents } = load(B05("B05_Profile_UI_Structures_Panel_Events")); + const typeMap = new Map(TYPES.map((type) => [type.type_id, type])); + const el = () => ({ + value: "", + listeners: {}, + addEventListener(t, fn) { + this.listeners[t] = fn; + }, + }); + const fields = () => ({ station: el(), remainder: el(), wrap: { hidden: false } }); + const run = ({ structures = [], pipes = [], editing = null, pipeAt = null, next }) => { + const calls = { + removed: [], + addAt: [], + emit: 0, + live: 0, + reset: 0, + editingSet: [], + pipeSet: [], + }; + let editingId = editing; + let selectedPipe = pipeAt; + const ctx = { + groupSelect: el(), + typeSelect: el(), + primary: el(), + removeButton: el(), + resetButton: el(), + positionRow: el(), + memoField: el(), + optionRow: el(), + optionInputs: [], + anchorFields: fields(), + startFields: fields(), + endFields: fields(), + facilityOptions: { root: el(), readOptions: () => ({}) }, + groupBoxes: { shapes: () => ({}), get: () => ({}), set: () => {}, setShapes: () => {} }, + structures, + projectId: "P", + callbacks: { + onPipeRemove: (at) => calls.removed.push(at), + onListRelease: () => {}, + }, + pipeFacilities: () => pipes, + types: () => TYPES, + typeMap: () => typeMap, + currentType: () => (next ? typeMap.get(next) : null), + editingId: () => editingId, + setEditingId: (value) => { + editingId = value; + calls.editingSet.push(value); + }, + selectedPipeChainage: () => selectedPipe, + setSelectedPipeChainage: (value) => { + selectedPipe = value; + calls.pipeSet.push(value); + }, + summary: () => null, + setSummary: () => {}, + cancelTempPipe: () => {}, + resetForm: () => (calls.reset += 1), + syncTypeOptions: () => {}, + syncPlacementFields: () => {}, + renderOptionFields: () => {}, + syncFacilityForm: () => {}, + liveCommit: () => (calls.live += 1), + emit: () => (calls.emit += 1), + addAt: (at, typeId) => calls.addAt.push([at, typeId]), + interval: () => 20, + }; + bindStructuresEvents(ctx); + ctx.typeSelect.listeners.change(); + return { ...calls, left: structures.map((s) => s.type_id) }; + }; + const store = window.sessionStorage; + const kindreset = () => JSON.parse(store.getItem("aislo:draft:kindreset:P") ?? "null"); + store.setItem( + "aislo:draft:culvertopt:P:1", + JSON.stringify({ "80.00": { a: 1 }, "300.00": { b: 2 } }), + ); + out.pipe_to_underdrain = { + ...run({ pipes: [{ chainage_m: 80, facility: "pipe" }], pipeAt: 80, next: "cross_underdrain" }), + kindreset: kindreset(), + culvertopt: JSON.parse(store.getItem("aislo:draft:culvertopt:P:1")), + }; + // 새로 놓인 맹암거(같은 측점) → 처음 종류(배관)로 되돌림 = 비울 자리 기록이 빠짐(318-4 규칙) + out.underdrain_back_to_pipe = { + ...run({ + structures: [ + { structure_id: "n1", type_id: "cross_underdrain", placement: "point", chainage_m: 80 }, + ], + editing: "n1", + next: "pipe", + }), + kindreset: kindreset(), + }; + out.open_ditch_to_box = { + ...run({ + structures: [ + { structure_id: "s1", type_id: "sign", placement: "point", chainage_m: 10 }, + { structure_id: "s9", type_id: "open_ditch", placement: "point", chainage_m: 120 }, + ], + editing: "s9", + next: "box_culvert", + }), + kindreset: kindreset(), + }; + out.pipe_to_box = run({ + pipes: [{ chainage_m: 200, facility: "pipe" }], + pipeAt: 200, + next: "box_culvert", + }); + out.underdrain_to_open_ditch = run({ + structures: [ + { structure_id: "s2", type_id: "cross_underdrain", placement: "point", chainage_m: 60 }, + ], + editing: "s2", + next: "open_ditch", + }); + out.to_blank = run({ pipes: [{ chainage_m: 200, facility: "pipe" }], pipeAt: 200, next: null }); +} + +// ── 2. 일반 측점 자리(grid) — 종류 바꿈 = 새로 뜬 횡단 · 다 빠짐 = 시설 뺀 사본(319-2) ────── +const state = { draftPipes: null, draftStructures: null, savedPipes: [], savedStructures: [] }; +(async () => { + const load = makeLoader( + [ + B06("B06_Section_Section_Drafts"), + path.join(ROOT, "common_util", "common_util_culvert_sets.ts"), + path.join(ROOT, "common_util", "common_util_structure_station.ts"), + ], + { + "../B04_PreProcess/B04_PreProcess_Api_Fetch": { + fetchDetailPipePoints: async () => ({ pipe_points: state.savedPipes }), + }, + "../B05_Profile/B05_Profile_Api_Structures": { + fetchStructures: async () => ({ structures: state.savedStructures }), + fetchStructureTypes: async () => TYPES, + readPendingStructures: () => state.draftStructures, + }, + "../B05_Profile/B05_Profile_Api_Pipes_Draft": { readPendingPipes: () => state.draftPipes }, + "./B06_Section_Api_Fetch": { + previewStructureSections: async (_p, chainages) => + chainages.map((at) => ({ chainage_m: at, kind: "irregular", design: { fresh: true } })), + }, + }, + ); + const { withDraftStations, forgetDraftStations } = load(B06("B06_Section_Section_Drafts")); + const D800 = { pipe_kind: "파형강관", pipe_diameter_mm: "800" }; + const detailWith = (spec) => ({ + cross_sections: [ + { chainage_m: 20, kind: "regular", design: {} }, + { chainage_m: 40, kind: "irregular", grid: true, design: { keep: 1 }, ...spec }, + { chainage_m: 60, kind: "regular", design: {} }, + ], + }); + const at40 = (detail) => { + const section = detail.cross_sections.find((s) => Math.abs(s.chainage_m - 40) < 0.01); + return { + count: detail.cross_sections.length, + keys: Object.keys(section).filter((k) => + ["culvert", "box", "ford", "ford_pavement"].includes(k), + ), + structure: section.structure ?? null, + preview: Boolean(section.preview), + fresh: Boolean(section.design?.fresh), + keep: section.design?.keep ?? null, + }; + }; + const grid = {}; + // 저장분 = 격자 40 위 배관 + state.savedPipes = [{ chainage_m: 40, facility: "pipe", options: D800 }]; + state.savedStructures = []; + let detail = detailWith({ structure: "파형강관 D800", culvert: { chainage_m: 40 } }); + state.draftPipes = []; + state.draftStructures = [ + { + structure_id: "n1", + type_id: "cross_underdrain", + placement: "point", + chainage_m: 40, + options: {}, + }, + ]; + await withDraftStations(detail, "P", 1); + grid.pipe_to_underdrain = at40(detail); + state.draftStructures = []; + await withDraftStations(detail, "P", 1); + grid.pipe_deleted = at40(detail); + state.draftPipes = [{ chainage_m: 40, facility: "pipe", options: D800 }]; + state.draftStructures = null; + await withDraftStations(detail, "P", 1); + grid.pipe_back = at40(detail); + state.draftPipes = [{ chainage_m: 40, facility: "box_culvert" }]; + await withDraftStations(detail, "P", 1); + grid.pipe_to_box = at40(detail); + // 저장분 = 격자 40 위 맹암거 → 배관 + forgetDraftStations("P"); + state.savedPipes = []; + state.savedStructures = [ + { + structure_id: "s1", + type_id: "cross_underdrain", + placement: "point", + chainage_m: 40, + options: {}, + }, + ]; + detail = detailWith({ structure: "맹암거", underdrain: { chainage_m: 40 } }); + state.draftPipes = [{ chainage_m: 40, facility: "pipe", options: D800 }]; + state.draftStructures = []; + await withDraftStations(detail, "P", 1); + grid.underdrain_to_pipe = at40(detail); + forgetDraftStations("P"); + out.grid = grid; + process.stdout.write(JSON.stringify(out)); +})().catch((error) => { + process.stderr.write(String(error && error.stack ? error.stack : error)); + process.exit(1); +}); diff --git a/resources/tester/test_318_cross_card.py b/resources/tester/test_318_cross_card.py index 61e759fe6..456589481 100644 --- a/resources/tester/test_318_cross_card.py +++ b/resources/tester/test_318_cross_card.py @@ -78,7 +78,11 @@ def test_center_tag_on_y_axis(result: dict) -> None: assert box["x"] > 0 assert abs(box["y"] + box["height"] / 2 - center["center_y"]) < 0.01 # 중심고 높이에 assert center["text_x"] == center["tick_x"] # 글 오른끝 = 다른 눈금 자리 - assert center["tick_near_center"] == 0 # 겹치는 눈금 글은 비켜 줌 + assert center["tick_near_center"] >= 1 # 겹친 눈금 글도 그대로(319-1) + assert center["on_top"] == [ # 상자 · 글을 맨 뒤에 붙여 위에서 가림(319-1) + "b06-chart__center-box", + "b06-chart__tick b06-chart__center-text", + ] assert center["tick_count"] > 3 assert center["hidden_when_out_of_view"] assert center["absent_without_center"] diff --git a/resources/tester/test_319_kind_swap.py b/resources/tester/test_319_kind_swap.py new file mode 100644 index 000000000..903fd7334 --- /dev/null +++ b/resources/tester/test_319_kind_swap.py @@ -0,0 +1,115 @@ +"""PLAN 319 — 횡단배수 종류 바꿈이 맹암거 · 개거처럼 정본이 다른 종류에서도 먹음(요구 217). + +- 패널: 관 지점 ↔ 구조물로 바꿈 = 옛 것 지움(「없음」) → 같은 측점에 새 종류(우클릭 추가 길 `addAt`) + · 그 측점 옛 조정값 버림 · 처음 종류로 되돌리면 비울 자리 기록이 빠짐 · 같은 정본끼리는 종전 그대로 +- 횡단 초안 맞춤: 일반 측점 자리(grid)도 종류가 바뀌거나 빠지면 구조물만 뺀 사본(측점 남음 · 고친 값 + 그대로)에 새 종류 · 저장분 종류로 되돌리면 저장분 그대로 + +실제 TS 를 `helper_319_kind_swap.cjs` 가 Node 로 돌린다(서버 부르는 곳 흉내). +""" + +from __future__ import annotations + +import json +import os +import subprocess + +import pytest + +HERE = os.path.dirname(__file__) +ROOT = os.path.join(HERE, "..", "..") + +pytestmark = pytest.mark.skipif( + not os.path.isdir(os.path.join(ROOT, "config", "node_modules")), + reason="프론트엔드 의존성 미설치", +) + + +@pytest.fixture(scope="module") +def result() -> dict: + out = subprocess.run( + ["node", os.path.join(HERE, "helper_319_kind_swap.cjs")], + capture_output=True, + text=True, + encoding="utf-8", + timeout=120, + check=True, + ) + return json.loads(out.stdout) + + +def _src(*parts: str) -> str: + with open(os.path.join(ROOT, *parts), encoding="utf-8") as handle: + return handle.read() + + +def test_pipe_to_underdrain_removes_pipe_and_places_at_same_station(result: dict) -> None: + swap = result["pipe_to_underdrain"] + assert swap["removed"] == [80] + assert swap["pipeSet"] == [None] + assert swap["addAt"] == [[80, "cross_underdrain"]] + assert swap["reset"] == 0 and swap["live"] == 0 # 새로 놓기 폼으로 빠지지 않음 + assert swap["kindreset"] == {"80.00": "pipe"} # [저장] 때 그 측점 옛 조정값 비움(318-4) + assert swap["culvertopt"] == {"300.00": {"b": 2}} # 그 측점 세션 조정값만 버림 + + +def test_back_to_first_kind_drops_reset_mark(result: dict) -> None: + back = result["underdrain_back_to_pipe"] + assert back["left"] == [] and back["emit"] == 1 and back["editingSet"] == [None] + assert back["addAt"] == [[80, "pipe"]] + assert back["kindreset"] is None + + +def test_structure_to_pipe_kind(result: dict) -> None: + swap = result["open_ditch_to_box"] + assert swap["left"] == ["sign"] # 고른 개거만 빠짐 + assert swap["removed"] == [] + assert swap["addAt"] == [[120, "box_culvert"]] + assert swap["kindreset"]["120.00"] == "open_ditch" + + +def test_same_owner_switch_unchanged(result: dict) -> None: + for case in ("pipe_to_box", "underdrain_to_open_ditch"): + same = result[case] + assert same["addAt"] == [] and same["removed"] == [] and same["live"] == 1 + assert result["to_blank"]["reset"] == 1 and result["to_blank"]["addAt"] == [] + + +def test_grid_station_kind_change_uses_bare_copy(result: dict) -> None: + # 일반 측점 자리 = 측점이 남음 — 저장분에서 구조물만 뺀 사본에 새 종류(지형 쪽 고친 값 그대로) + grid = result["grid"] + assert grid["pipe_to_underdrain"] == { + "count": 3, + "keys": [], + "structure": "맹암거", + "preview": True, + "fresh": False, + "keep": 1, + } + assert grid["pipe_to_box"]["keys"] == ["box"] and grid["pipe_to_box"]["keep"] == 1 + assert grid["underdrain_to_pipe"]["keys"] == ["culvert"] + assert grid["underdrain_to_pipe"]["structure"] == "파형강관 D800" + assert grid["underdrain_to_pipe"]["keep"] == 1 + + +def test_grid_station_emptied_keeps_station_without_facility(result: dict) -> None: + assert result["grid"]["pipe_deleted"] == { + "count": 3, + "keys": [], + "structure": None, + "preview": True, + "fresh": False, + "keep": 1, + } + + +def test_grid_station_back_to_saved_kind_restores_saved(result: dict) -> None: + back = result["grid"]["pipe_back"] + assert back["keys"] == ["culvert"] and not back["preview"] and back["keep"] == 1 + + +def test_panel_wires_add_at() -> None: + panel = _src("B05_Profile", "B05_Profile_UI_Structures_Panel.ts") + assert "addAt: (at, typeId) => api.addAt(at, typeId)" in panel + assert "return api;" in panel + assert len(panel.splitlines()) <= 700