feat(구조물표): 323-6 바꿈 한 길 — 바뀜 신호 하나(구조물 초안도 설계 가게 · watchDrafts) · 종류 바꿈 표 규칙(swapsInPlace · E · F 점형 ↔ 구간형 = 지우고 같은 기준 측점에 새로) · 조용한 되돌림 없앰(알림) · 그리기가 조정값 안 만듦 · replayDraftPipeOptions 걷음

- 구조물 초안 쓰기 = useDesignStore().set("structures")(칸 그대로) → 관 초안과 같은 구독 · 횡단 보기 watchDrafts(구조물 = 늘 · 관 = 자리 · 종류 · 구간 · 잇기가 바뀔 때만 · 옵션만은 폼 길이 그 카드만 · 카드 줌 · 팬 지킴) · 옛 되부름 onStructuresChanged 걷음
- 종류 바꿈 = 표(catalog) swapsInPlace — 정본 다름 · 구조물 자리꼴 다름(E · F) = 지우고 같은 기준 측점에 새로(구간 = 기본 길이) · 같으면 종류 키만 · 막히면 옛 기록 · 옛 종류 폼 + 알림(전엔 [수정] 조기 반환 → 조용히 되돌림 · 드롭다운만 새 종류)
- syncApplied = 사용자가 조작한 세션이 있을 때만 깎음(카드 그리기가 자동 배치를 조정값으로 굳혀 [저장]으로 내보내던 것)
- replayDraftPipeOptions 걷음 — 그림 자료는 형상 얹기(초안 현황) · 다단 조정값은 조정 세션이 정본
- 시험: test_323_6_change(신호 · 거르기 · 표 규칙 · 그리기 · 걷음) · test_319 점형 ↔ 구간형 · 막힘 알림 · 되얹기 시험 = 형상 얹기로 · 옛 기대 글 고침

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ji4nuyLVHcxrXWHTdb5nRD
This commit is contained in:
2026-10-14 18:21:41 +09:00
co-authored by Claude Opus 5.5
parent cb4bab7bf2
commit 529baca3ff
16 changed files with 352 additions and 96 deletions
+64 -4
View File
@@ -95,10 +95,14 @@ const out = {};
],
{
"./B05_Profile_UI_Structures_Define": { asRoad: (record) => record },
"@ui/ui_template_elements": { showConfirmDialog: async () => true, showToast: () => {} },
"@ui/ui_template_elements": {
showConfirmDialog: async () => true,
showToast: (text, kind) => toasts.push([text, kind ?? null]),
},
"./B06_Section_Section_Drafts": { forgetPreviewAdjusts: () => {} },
},
);
const toasts = [];
const { bindStructuresEvents } = load(B05("B05_Profile_UI_Structures_Panel_Events"));
const typeMap = new Map(TYPES.map((type) => [type.type_id, type]));
const el = () => ({
@@ -109,8 +113,18 @@ const out = {};
},
});
const fields = () => ({ station: el(), remainder: el(), wrap: { hidden: false } });
const run = ({ structures = [], pipes = [], editing = null, pipeAt = null, next }) => {
// commitOk = [수정] 반영이 받아 줌(패널은 기록을 새 것으로 갈아 끼움) · false = 막힘(필수 칸 등 · 323-6 알림)
const run = ({
structures = [],
pipes = [],
editing = null,
pipeAt = null,
next,
commitOk = true,
}) => {
toasts.length = 0;
const calls = {
loaded: [],
removed: [],
addAt: [],
emit: 0,
@@ -164,14 +178,19 @@ const out = {};
syncPlacementFields: () => {},
renderOptionFields: () => {},
syncFacilityForm: () => {},
liveCommit: () => (calls.live += 1),
liveCommit: () => {
calls.live += 1;
const at = structures.findIndex((s) => s.structure_id === editingId);
if (commitOk && at >= 0) structures[at] = { ...structures[at] };
},
loadForm: (target) => calls.loaded.push(target?.type_id ?? null),
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) };
return { ...calls, left: structures.map((s) => s.type_id), toasts: [...toasts] };
};
const store = window.sessionStorage;
const kindreset = () => JSON.parse(store.getItem("aislo:draft:kindreset:P") ?? "null");
@@ -219,6 +238,47 @@ const out = {};
next: "open_ditch",
});
out.to_blank = run({ pipes: [{ chainage_m: 200, facility: "pipe" }], pipeAt: 200, next: null });
// 323-6 — E · F 점형 ↔ 구간형 = 지우고 같은 기준 측점에 새로(구간 = 기본 길이는 `addAt`) · 같은 자리꼴 = 제자리
out.point_to_interval = run({
structures: [{ structure_id: "m1", type_id: "mirror", placement: "point", chainage_m: 140 }],
editing: "m1",
next: "guardrail",
});
out.interval_to_point = run({
structures: [
{
structure_id: "g1",
type_id: "guardrail",
placement: "interval",
start_m: 100,
chainage_m: 105,
end_m: 120,
},
],
editing: "g1",
next: "mirror",
});
out.interval_same = run({
structures: [
{
structure_id: "r1",
type_id: "refuge",
placement: "interval",
start_m: 100,
chainage_m: 105,
end_m: 120,
},
],
editing: "r1",
next: "work_yard",
});
// 막힘 = 옛 기록 · 옛 종류 폼으로 되돌리고 알림(조용한 되돌림 없앰)
out.blocked = run({
structures: [{ structure_id: "b1", type_id: "mirror", placement: "point", chainage_m: 30 }],
editing: "b1",
next: "curb",
commitOk: false,
});
}
// ── 2. 일반 측점 자리(grid) — 종류 바꿈 = 새로 뜬 횡단 · 다 빠짐 = 시설 뺀 사본(319-2) ──────
+82
View File
@@ -0,0 +1,82 @@
/* PLAN 323-6 바꿈 한 길 헬퍼 — 진짜 `B05_Profile_Api_Structures.ts`(구조물 초안) · `design_store.ts`(바뀜 신호)를
* 그 자리에서 트랜스파일해 Node 로 돌림(세션만 흉내) · 결과 = 표준출력 JSON. 짝 = `test_323_6_change.py`.
*/
const fs = require("fs");
const path = require("path");
const ROOT = path.join(__dirname, "..", "..");
const ts = require(path.join(ROOT, "config", "node_modules", "typescript"));
const CATALOG = require("./helper_322_catalog_module.cjs");
const memory = new Map();
const store = {
getItem: (k) => (memory.has(k) ? memory.get(k) : null),
setItem: (k, v) => memory.set(k, String(v)),
removeItem: (k) => memory.delete(k),
key: (i) => [...memory.keys()][i] ?? null,
get length() {
return memory.size;
},
};
globalThis.window = { sessionStorage: store, localStorage: store, addEventListener: () => {} };
globalThis.sessionStorage = store;
globalThis.localStorage = store;
const cache = new Map();
function 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 (CATALOG.isCatalogRequest(spec)) return CATALOG;
if (spec.startsWith("@ui/")) return new Proxy({}, { get: () => () => {} });
if (spec.startsWith("@config/")) return load(path.join(ROOT, "config", `${spec.slice(8)}.ts`));
if (spec.startsWith(".")) return load(path.join(path.dirname(full), `${spec}.ts`));
return require(spec);
};
new Function("exports", "module", "require", js)(box.exports, box, need);
return box.exports;
}
const api = load(path.join(ROOT, "B05_Profile", "B05_Profile_Api_Structures.ts"));
const { useDesignStore } = load(path.join(ROOT, "A00_Common", "design", "design_store.ts"));
const heard = { structures: [], pipes: [] };
const shop = useDesignStore("P", null);
for (const key of ["structures", "pipes"]) shop.subscribe(key, (value) => heard[key].push(value));
const wall = { structure_id: "w1", type_id: "retaining_wall", chainage_m: 10 };
api.writePendingStructures("P", [wall]);
api.writePendingStructures("P", null);
api.writePendingStructures("P", [wall]);
// 바뀜 신호 거르기 — 구조물 = 늘 · 관 = 자리 · 종류 · 구간 · 잇기가 바뀔 때만(옵션만 = 폼 길이 그 카드)
const status = load(path.join(ROOT, "A00_Common", "design", "design_structure_status.ts"));
let fired = 0;
const off = status.watchDrafts("Q", () => (fired += 1));
const shopQ = useDesignStore("Q", null);
shopQ.set("pipes", [{ chainage_m: 10, source: "user", options: { a: 1 } }]);
shopQ.set("pipes", [{ chainage_m: 10, source: "user", options: { a: 2 } }]);
shopQ.set("pipes", [{ chainage_m: 12, source: "user", options: { a: 2 } }]);
shopQ.set("pipes", [{ chainage_m: 12, source: "user", facility: "box_culvert" }]);
shopQ.set("structures", []);
off();
shopQ.set("structures", null);
process.stdout.write(
JSON.stringify({
watch: fired,
heard: heard.structures.map((v) => (v ? v.length : null)),
pipesHeard: heard.pipes.length,
read: api.readPendingStructures("P"),
key: memory.has("aislo:draft:structures:P"),
swaps: [
"mirror>curb",
"mirror>guardrail",
"refuge>work_yard",
"pipe>box_culvert",
"pipe>revetment",
"cross_underdrain>pipe",
].map((pair) => [pair, CATALOG.swapsInPlace(...pair.split(">"))]),
}),
);
@@ -1,5 +1,6 @@
/* 관 초안 옵션 되얹기 단위검증 헬퍼(PLAN 24-7a) — TS 를 그 자리에서 트랜스파일해 Node 로 돌린다.
* 새로고침 뒤 캐시 상세(저장분)에 저장 전에 고친 관 옵션(월류 폭 · 관경)이 다시 얹히는지 본다.
/* 관 초안 옵션 되얹기 단위검증 헬퍼(PLAN 24-7a → 323-6) — TS 를 그 자리에서 트랜스파일해 Node 로 돌린다.
* 새로고침 뒤 캐시 상세(저장분)에 저장 전에 고친 관 옵션(월류 폭 · 관경)이 다시 얹히는지 본다 — 옛 되얹기
* (`replayDraftPipeOptions`)는 걷고 형상 얹기 한 벌(`attachShapes` · 초안 현황)이 맡음(323-6).
* 결과는 표준출력에 JSON 한 벌. 화면 그릇은 쓰지 않는다.
*
* ⚠ 확장자가 `.cjs` 인 까닭은 `helper_b05_structures_form_summary.cjs` 머리와 같다.
@@ -47,50 +48,45 @@ function loadTs(file) {
return box.exports;
}
const options = loadTs(path.join(ROOT, "B06_Section", "B06_Section_UI_Page_Pipe_Options.ts"));
const DEF = JSON.parse(
fs.readFileSync(path.join(ROOT, "resources", "master_template", "구조물정의.json"), "utf8"),
);
const TYPES = DEF.types.map((t) => ({ managed_by: null, enabled: true, ...t }));
const attach = loadTs(path.join(ROOT, "common_util", "common_util_structure_attach.ts"));
// 캐시 상세(저장분) — 세월교(월류 폭 10) · 배수관(관경 1000) · 옹벽 없음
// 캐시 상세(저장분) — 세월교(월류 폭 10) · 배수관(관경 1000) · 조정창 정본(기슭막이 조정값)
const adjust = { outlet: { x: 0.4, d: null, h: 2.0, m: null } };
const detail = {
cross_sections: [
{
chainage_m: 85.05,
culvert: { pipe_kind: "파형강관", diameter_m: 1.0, inlet: {}, outlet: {} },
design: { revet_adjust: adjust },
},
{
chainage_m: 173.09,
ford: { span_m: 10, diameter_m: 1.0, pipe_count: 1, wing_in: {}, wing_out: {} },
},
{ chainage_m: 258.12 },
],
};
const sent = [];
const ctx = {
detail: () => detail,
ford: {
setPipe: (chainageM, patch) => {
sent.push({ chainageM, patch });
if (patch.ford_width_m) detail.cross_sections[1].ford.span_m = patch.ford_width_m;
},
setWing: () => {},
},
box: {},
revetOffset: { update: () => sent.push({ revet: true }) },
overrideOptions: () => {},
refreshCard: () => {},
};
// 초안: 세월교 월류 폭 3.5 · 배수관 관경 1200 · 기슭막이 높이(조정창 정본이라 되살리지 않음)
options.replayDraftPipeOptions(ctx, [
{ chainage_m: 85.05, options: { pipe_diameter_mm: 1200, outlet_revet_height_m: 2.5 } },
{ chainage_m: 173.09, facility: "ford_bridge", options: { ford_width_m: 3.5 } },
{ chainage_m: 258.12, options: {} },
]);
options.replayDraftPipeOptions(ctx, null);
// 초안: 세월교 월류 폭 3.5 · 배수관 관경 1200 · 기슭막이 높이(조정창 정본이라 조정값은 안 건드림)
attach.attachShapes(
detail.cross_sections,
[
{ chainage_m: 85.05, options: { pipe_diameter_mm: 1200, outlet_revet_height_m: 2.5 } },
{ chainage_m: 173.09, facility: "ford_bridge", options: { ford_width_m: 3.5 } },
],
[],
TYPES,
);
process.stdout.write(
JSON.stringify({
fordSpan: detail.cross_sections[1].ford.span_m,
culvertDiameter: detail.cross_sections[0].culvert.diameter_m,
fordCalls: sent.filter((s) => s.chainageM === 173.09).length,
revetTouched: sent.some((s) => s.revet === true),
fordAt: detail.cross_sections.filter((s) => s.ford).map((s) => s.chainage_m),
revetTouched: detail.cross_sections[0].design.revet_adjust !== adjust,
}),
);
+6 -4
View File
@@ -65,8 +65,7 @@ def test_B06_추가_순간_기다림_적고_초안_씀():
body.index("pipeFacilities.push({"),
body.index("if (waitsFordHeight(attributes.facility, attributes.options))"),
body.index("fordWaitsOf(deps.projectId).add(at);"),
body.index("persistPipes();"),
body.index("deps.onStructuresChanged?.();"),
body.index("persistPipes();"), # 초안 씀 = 카드 다시(바뀜 신호 하나 · 323-6)
]
assert order == sorted(order)
@@ -145,8 +144,11 @@ def test_실패하면_토스트_한_줄(out):
def test_추가_뒤_새_측점_횡단_미리_뜸():
body = _body(B06, " onPipeAdd: (chainageM, attributes) => {", " onPipeUpdate:")
assert "deps.onStructuresChanged?.();" in body
assert "onStructuresChanged: () => void refreshDetailForStructures()," in CROSS
assert "persistPipes();" in body # 초안 씀 → 구독(`watchDrafts`)이 카드 다시(323-6)
# 카드 다시 = 바뀜 신호 하나(323-6 · 관 · 구조물 초안 구독 · 옛 되부름 갈음)
assert (
"watchDrafts(projectId, () => host.visible() && void refreshDetailForStructures())" in CROSS
)
drafts = _read("B06_Section", "B06_Section_Section_Drafts.ts")
stations = _body(
drafts, "export async function withDraftStations(", "parked.set(detail, stale);"
+1 -1
View File
@@ -83,7 +83,7 @@ def test_B05_B06_같은_목록_각자_관빼기():
assert "onPipeFacilityRemove: (chainage) => profilePanel.drainage.removePipe(chainage)," in b05
# 공용 확인창 · 저장 길(초안 → [저장])은 안 고침
events = _read("B05_Profile", "B05_Profile_UI_Structures_Panel_Events.ts")
assert 'import { showConfirmDialog } from "@ui/ui_template_elements";' in events
assert 'import { showConfirmDialog, showToast } from "@ui/ui_template_elements";' in events
assert "ctx.emit();" in events
+16
View File
@@ -75,6 +75,22 @@ def test_same_owner_switch_unchanged(result: dict) -> None:
assert result["to_blank"]["reset"] == 1 and result["to_blank"]["addAt"] == []
def test_point_interval_switch_replaces_at_same_anchor(result: dict) -> None:
# 323-6 — 표 규칙(`swapsInPlace`) · 구간 = 기본 길이(`addAt`)
up = result["point_to_interval"]
assert up["left"] == [] and up["addAt"] == [[140, "guardrail"]] and up["live"] == 0
down = result["interval_to_point"]
assert down["left"] == [] and down["addAt"] == [[105, "mirror"]]
same = result["interval_same"]
assert same["addAt"] == [] and same["live"] == 1 and same["left"] == ["work_yard"]
def test_blocked_switch_restores_with_notice(result: dict) -> None:
blocked = result["blocked"]
assert blocked["left"] == ["mirror"] and blocked["loaded"] == ["mirror"]
assert len(blocked["toasts"]) == 1 and blocked["toasts"][0][1] == "error"
def test_grid_station_kind_change_uses_bare_copy(result: dict) -> None:
# 일반 측점 자리 = 측점이 남음 — 저장분에서 구조물만 뺀 사본에 새 종류(지형 쪽 고친 값 그대로)
grid = result["grid"]
+79
View File
@@ -0,0 +1,79 @@
"""PLAN 323-6 바꿈 한 길 — 바뀜 신호 하나 · 종류 바꿈 표 규칙 · 그리기가 조정값을 안 만듦 · 옛 되얹기 걷음.
- 구조물 초안도 설계 가게(`useDesignStore`)로 씀 = 관 초안과 같은 신호 · 칸은 그대로(`structures`) ·
횡단 보기가 두 초안을 구독해 카드 · 그림을 다시(옛 되부름 `onStructuresChanged` 갈음).
- 종류 바꿈 = 정본 같고 구조물은 자리꼴도 같아야 제자리(`swapsInPlace`) · 아니면 지우고 같은 기준 측점에 새로
(흐름 = test_319_kind_swap).
- 카드 그리기의 적용값 되받기는 사용자가 조작한 세션이 있을 때만(자동 배치를 조정값으로 안 굳힘).
TS 쪽 = `helper_323_6_change.cjs`(Node).
"""
from __future__ import annotations
import json
import subprocess
from pathlib import Path
import pytest
HERE = Path(__file__).parent
ROOT = HERE.parent.parent
pytestmark = pytest.mark.skipif(
not (ROOT / "config" / "node_modules").is_dir(), reason="프론트엔드 의존성 미설치"
)
@pytest.fixture(scope="module")
def out() -> dict:
run = subprocess.run(
["node", str(HERE / "helper_323_6_change.cjs")],
capture_output=True,
text=True,
encoding="utf-8",
timeout=120,
check=True,
)
return json.loads(run.stdout)
def _src(*parts: str) -> str:
return (ROOT.joinpath(*parts)).read_text(encoding="utf-8")
def test_structure_draft_signals_through_store(out: dict) -> None:
assert out["heard"] == [1, None, 1] and out["pipesHeard"] == 0
assert out["read"] == [{"structure_id": "w1", "type_id": "retaining_wall", "chainage_m": 10}]
assert out["key"] is True # 칸 그대로
cross = _src("A00_Common", "design", "design_view_cross.ts")
assert "watchDrafts(projectId," in cross and "onStructuresChanged" not in cross
panel = _src("B06_Section", "B06_Section_UI_Page_Structures_Panel.ts")
assert "onStructuresChanged" not in panel # 옛 되부름 걷음
def test_change_signal_filters_option_only_pipe_edits(out: dict) -> None:
# 관 놓기 · 옵션만 · 옮기기 · 종류 바꿈 · 구조물 · 끊은 뒤 = 1 · 0 · 1 · 1 · 1 · 0
assert out["watch"] == 4
def test_kind_swap_rule_from_table(out: dict) -> None:
assert dict(out["swaps"]) == {
"mirror>curb": True,
"mirror>guardrail": False,
"refuge>work_yard": True,
"pipe>box_culvert": True,
"pipe>revetment": True,
"cross_underdrain>pipe": False,
}
def test_drawing_does_not_create_adjusts() -> None:
controls = _src("B06_Section", "B06_Section_UI_Page_Station_Controls.ts")
body = controls.split("syncApplied: (chainageM, role, applied) => {", 1)[1].split("},", 1)[0]
assert "if (!stored || sameAdjust(stored, applied)) return;" in body
def test_old_replay_removed() -> None:
assert "replayDraftPipeOptions" not in _src(
"B06_Section", "B06_Section_UI_Page_Pipe_Options.ts"
)
@@ -5,6 +5,7 @@
저장값(월류 폭 10 · 관경 1000)으로 돌아가 있었다. 초안(관 목록 세션 초안)이 정본이므로
횡단 보기가 서며 초안 관 옵션을 캐시에 다시 얹는다.
• 되얹기 — 세월교 월류 폭 3.5 · 배수관 관경 1200 이 캐시 스펙에 실림 · 조정창 정본(기슭막이 높이 · 형태)은 안 건드림
(323-6 — 옛 `replayDraftPipeOptions` 걷음 · 형상 얹기 한 벌이 초안 현황으로 다시 셈)
• 횡단 보기가 먼저 선 뒤 종단 보기가 서도 종단 보기가 배수유역도의 관 목록을 먼저 쥠(빈 목록으로 시작해 관을 지우지 않음)
화면 그릇 없이 Node 헬퍼가 TS 를 트랜스파일해 돌린다(`helper_b06_draft_pipe_replay.cjs`).
"""
@@ -33,12 +34,15 @@ def test_draft_pipe_options_are_replayed_onto_the_cache():
out = json.loads(proc.stdout)
assert out["fordSpan"] == 3.5 # 월류 폭 초안이 캐시 스펙에 실림(저장분 10 아님)
assert out["culvertDiameter"] == 1.2 # 배수관 관경 초안 1200
assert out["fordCalls"] == 1 # 옵션 없는 관은 건드리지 않고 세월교는 한 번만
assert out["fordAt"] == [173.09] # 세월교는 제 측점에만
assert out["revetTouched"] is False # 기슭막이 높이 · 형태는 조정창이 정본
# 배선 — 횡단 보기가 서며 초안을 읽어 되얹고, 종단 보기는 마지막 관 목록을 먼저 싣는다.
# 배선 — 옛 되얹기는 걷음(323-6) · 초안 현황으로 상세를 읽을 때마다 형상 얹기(`withDraftShapes`) ·
# 종단 보기는 마지막 관 목록을 먼저 싣는다.
cross = _read("A00_Common", "design", "design_view_cross.ts")
assert "replayDraftPipeOptions(pipeOptionsContext, readPendingPipes(projectId))" in cross
assert "replayDraftPipeOptions" not in cross
store = _read("B06_Section", "B06_Section_Section_Store.ts")
assert "await withDraftShapes(detail, projectId);" in store
shared = _read("A00_Common", "design", "design_shared.ts")
assert "shared.lastPipes = pipes" in shared
profile = _read("A00_Common", "design", "design_view_profile.ts")