fix(b05·b06): 정본 다른 종류(관 ↔ 맹암거 · 개거 등)로 바꿈 = 옛 것 지우고 같은 측점에 새 종류 · 일반 측점 자리 종류 바꿈 · 지움 = 구조물만 뺀 사본에 새 종류 · 중심고 상자에 겹친 Y 눈금 숨기지 않음(상자 위 · 안 비치는 바탕) (319 · 요구 217)

- 공용 구조물 패널 종류 바꿈: 관 지점 ↔ 구조물이면 새로 놓기 폼으로 빠지던 것 → 옛 것 지움 → 우클릭 추가 길(addAt)로 같은 측점 · 옛 측점 조정값 버림(318-4)
- 횡단 초안 맞춤: 일반 측점 자리(grid)는 종류가 바뀌어도 저장분(옛 관 그림)이 남던 것 → 구조물만 뺀 사본(지형 쪽 고친 값 그대로) + 새 종류
- 시험: test_319_kind_swap 8 · test_318 중심고 겹침 기대값 바꿈

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C6BNBFXFndbqYxu6sZuknK
This commit is contained in:
2026-10-14 06:52:02 +09:00
co-authored by Claude Opus 5.5
parent 6d914b7647
commit 2641c15e29
9 changed files with 494 additions and 17 deletions
@@ -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<void> => 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;
}
@@ -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(
+23 -5
View File
@@ -103,7 +103,21 @@ async function previewOf(
return chainages.flatMap((at) => previews.get(key(at)) ?? []);
}
/** 미리 뜬 횡단에 카드 이름 · 관 그림 — 서버가 [저장] 때 붙이는 것과 같은 규칙. */
/** 일반 측점 자리(grid · 272)의 「구조물 없는 꼴」(217) — 저장분에서 시설 · 이름만 뺀 사본(한 벌을 다시 씀 ·
* 지형 쪽 고친 값은 그 측점 것 그대로) · 새 종류는 그 위에 얹음(`decorate`) · 측점은 남으므로 숨기지 않음. */
const bares = new WeakMap<CrossSection, CrossSection>();
function bareOf(section: CrossSection): CrossSection {
let copy = bares.get(section);
if (!copy) {
copy = { ...section, preview: true };
const record = copy as unknown as Record<string, unknown>;
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; // 더 새 맞춤이 돎 — 그쪽이 얹음
+2 -2
View File
@@ -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,
+2 -1
View File
@@ -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);
@@ -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,
};
+319
View File
@@ -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);
});
+5 -1
View File
@@ -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"]
+115
View File
@@ -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