diff --git a/A00_Common/design/design_shared.ts b/A00_Common/design/design_shared.ts index 77815407..3a428158 100644 --- a/A00_Common/design/design_shared.ts +++ b/A00_Common/design/design_shared.ts @@ -75,6 +75,8 @@ export interface DesignPageShared { panel: DesignPanel | null; profile: DesignProfile | null; structures: StructuresSection | null; + /** 배수유역도가 마지막으로 알린 관 목록 — 뒤늦게 서는 보기가 처음부터 같은 관 목록을 쥔다(24-7a). */ + lastPipes?: Parameters>[0]; /** 횡단 보기가 서기 전에 종단 보기가 고친 관 옵션 — 횡단 보기가 서면 캐시에 얹는다. */ pipeOptionQueue: Array<[number, Record]>; /** 접힌 좌측 패널을 편다(바깥에서 고른 것이 폼에 실릴 때). */ @@ -152,7 +154,10 @@ export function sharedProfile(shared: DesignPageShared): DesignProfile { onSelectStation: (stationId) => bound(shared).profile?.onSelectStation(stationId), onResetAll: () => bound(shared).profile?.onResetAll?.(), callbacks: { - onPipesChanged: toAll(shared, "onPipesChanged"), + onPipesChanged: (pipes) => { + shared.lastPipes = pipes; + toAll(shared, "onPipesChanged")(pipes); + }, onCrossDesignsUpdated: toAll(shared, "onCrossDesignsUpdated"), onStructureMove: toView(shared, "onStructureMove"), onStructureRemove: toView(shared, "onStructureRemove"), diff --git a/A00_Common/design/design_view_cross.ts b/A00_Common/design/design_view_cross.ts index 6d10d713..28fd8330 100644 --- a/A00_Common/design/design_view_cross.ts +++ b/A00_Common/design/design_view_cross.ts @@ -38,11 +38,13 @@ import { import { maxToeFitHalfWidth } from "../../B06_Section/B06_Section_UI_Cross_Fit"; import { readStructurePick } from "../../B05_Profile/B05_Profile_UI_Structure_Pick_Session"; import { NO_SELECTION, PIPE_MATCH_M, selectDesign } from "./design_selection"; +import { readPendingPipes } from "../../B05_Profile/B05_Profile_Api_Pipes_Draft"; import { applyStructurePick } from "../../B06_Section/B06_Section_UI_Page_Structure_Pick"; import { createSectionView } from "../../B06_Section/B06_Section_UI_Section_View"; import { revetWallSpec } from "../../B06_Section/B06_Section_UI_Cross_Culvert_Const"; import { applyPipeOptionsToCache, + replayDraftPipeOptions, type PipeOptionsContext, } from "../../B06_Section/B06_Section_UI_Page_Pipe_Options"; import { @@ -581,6 +583,8 @@ export async function mountCrossView( bindPipeOptions(shared, (chainageM, patch) => applyPipeOptionsToCache(pipeOptionsContext, chainageM, patch), ); + // 새로고침 뒤 캐시 상세는 저장분 — 저장 전에 고친 관 옵션을 초안에서 다시 얹는다(24-7a). + replayDraftPipeOptions(pipeOptionsContext, readPendingPipes(projectId)); renderSectionDetail(); // 측점이 없는 구조물 알림 — 관을 나중에 놓으면 그 측점이 안 생겨 수량에서 조용히 빠진다 // (계획서 3-14 ㉯). 만드는 것은 사용자가 누를 때만. diff --git a/A00_Common/design/design_view_profile.ts b/A00_Common/design/design_view_profile.ts index 4753c777..8ca8ae22 100644 --- a/A00_Common/design/design_view_profile.ts +++ b/A00_Common/design/design_view_profile.ts @@ -114,7 +114,7 @@ export async function mountProfileView( from: "b05", }); /** 배수유역도가 마지막으로 알린 관 목록 — 숨어 있다 다시 보일 때 폼 · 3D 에 다시 싣는다. */ - let lastPipes: PipeReport | null = null; + let lastPipes: PipeReport | null = shared.lastPipes ?? null; const applyPipes = (pipes: PipeReport): void => { // 횡단배수는 계획선을 그 지점에 물리는 시설이라 그래프 세로 점선·계획고 틸팅 // 버튼이 필요하다(2026-08-17 사용자 지시). 알약 레인은 종류·선택을 맡는다. @@ -616,6 +616,9 @@ export async function mountProfileView( // ③ 종단면·횡단 자료 — 하단 패널을 3D보다 먼저 채운다. if (latestResponse.route) await restoreSections(latestResponse.route.id); + // 횡단 보기가 먼저 서서 배수유역도가 관 목록을 이미 알렸다면 그것을 먼저 싣는다 — 빈 목록으로 + // 시작하면 아래 구조물 로딩이 관 대조로 관을 지우고 초안 옵션이 자동 배치로 되돌아간다(24-7a). + if (lastPipes) applyPipes(lastPipes); // 구조물 타입 레지스트리·정본 — 노선이 없어도 목록은 보여 준다(추가는 노선 이후). await bridge.load(); advanceLoading(""); diff --git a/B06_Section/B06_Section_UI_Page_Pipe_Options.ts b/B06_Section/B06_Section_UI_Page_Pipe_Options.ts index 32312a4e..aa03b600 100644 --- a/B06_Section/B06_Section_UI_Page_Pipe_Options.ts +++ b/B06_Section/B06_Section_UI_Page_Pipe_Options.ts @@ -8,6 +8,8 @@ import { pipeDiameterM } from "@util/common_util_culvert_sets"; import { showToast } from "@ui/ui_template_elements"; +import type { DetailPipeInput } from "../B04_PreProcess/B04_PreProcess_Api_Fetch"; +import { PIPE_MATCH_M } from "../A00_Common/design/design_selection"; import type { SectionDetailResponse } from "./B06_Section_Api_Fetch"; import { applyBoxFormOptions, applyFordFormOptions } from "./B06_Section_UI_Page_Ford_Controls"; import { revetWallSpec } from "./B06_Section_UI_Cross_Culvert_Const"; @@ -39,7 +41,7 @@ export function applyPipeOptionsToCache( ): void { const owner = ctx .detail() - ?.cross_sections.find((section) => Math.abs(section.chainage_m - chainageM) < 0.51); + ?.cross_sections.find((section) => Math.abs(section.chainage_m - chainageM) < PIPE_MATCH_M); if (!owner) return; // 세월교는 스펙 자리(`section.ford`)가 배수관과 달라 조정창 제어기로 보낸다 — // 로직은 그쪽 것을 쓰고 프론트만 폼 UI다(2026-08-30 사용자 확정). @@ -133,3 +135,22 @@ export function applyPipeOptionsToCache( } } } + +/** 조정창(기슭막이 조정값)이 정본인 칸 — 초안에서 되살리지 않는다. */ +const ADJUST_OWNED = /_revet_(height_m|form)$/; + +/** + * 관 초안 옵션을 **횡단 캐시에 다시 얹는다**(24-7a). 새로고침 뒤 캐시 상세는 저장분이라 + * 저장 전에 고친 월류 폭·관경 등이 폼·카드에서 옛 값으로 돌아간다 — 초안이 관 옵션의 정본이다. + */ +export function replayDraftPipeOptions( + ctx: PipeOptionsContext, + pipes: ReadonlyArray | null, +): void { + for (const pipe of pipes ?? []) { + const patch = Object.fromEntries( + Object.entries(pipe.options ?? {}).filter(([key]) => !ADJUST_OWNED.test(key)), + ); + if (Object.keys(patch).length) applyPipeOptionsToCache(ctx, pipe.chainage_m, patch); + } +} diff --git a/resources/tester/helper_b06_draft_pipe_replay.cjs b/resources/tester/helper_b06_draft_pipe_replay.cjs new file mode 100644 index 00000000..a8bd8f1e --- /dev/null +++ b/resources/tester/helper_b06_draft_pipe_replay.cjs @@ -0,0 +1,94 @@ +/* 관 초안 옵션 되얹기 단위검증 헬퍼(PLAN 24-7a) — TS 를 그 자리에서 트랜스파일해 Node 로 돌린다. + * 새로고침 뒤 캐시 상세(저장분)에 저장 전에 고친 관 옵션(월류 폭 · 관경)이 다시 얹히는지 본다. + * 결과는 표준출력에 JSON 한 벌. 화면 그릇은 쓰지 않는다. + * + * ⚠ 확장자가 `.cjs` 인 까닭은 `helper_b05_structures_form_summary.cjs` 머리와 같다. + */ +const fs = require("fs"); +const path = require("path"); +const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); + +const ROOT = path.join(__dirname, "..", ".."); +const cache = new Map(); + +const session = new Map(); +const fakeStorage = { + getItem: (k) => session.get(k) ?? null, + setItem: (k, v) => session.set(k, String(v)), + removeItem: (k) => session.delete(k), +}; +globalThis.window = { sessionStorage: fakeStorage, localStorage: fakeStorage }; +globalThis.sessionStorage = fakeStorage; +globalThis.localStorage = fakeStorage; + +function loadTs(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 localRequire = (request) => { + // 화면 공용 부품(@ui/…)은 이 시험이 안 쓰는 것이라 아무 일 안 하는 함수로 막아 둠 + if (request.startsWith("@ui/")) return new Proxy({}, { get: () => () => {} }); + if (request.startsWith("@util/")) + return loadTs(path.join(ROOT, "common_util", `${request.slice(6)}.ts`)); + if (request.startsWith("@config/")) + return loadTs(path.join(ROOT, "config", `${request.slice(8)}.ts`)); + return request.startsWith(".") + ? loadTs(path.join(path.dirname(full), `${request}.ts`)) + : require(request); + }; + new Function("exports", "module", "require", js)(box.exports, box, localRequire); + cache.set(full, box.exports); + return box.exports; +} + +const options = loadTs(path.join(ROOT, "B06_Section", "B06_Section_UI_Page_Pipe_Options.ts")); + +// 캐시 상세(저장분) — 세월교(월류 폭 10) · 배수관(관경 1000) · 옹벽 없음 +const detail = { + cross_sections: [ + { + chainage_m: 85.05, + culvert: { pipe_kind: "파형강관", diameter_m: 1.0, inlet: {}, outlet: {} }, + }, + { + chainage_m: 173.09, + ford: { span_m: 10, diameter_m: 1.0, pipe_count: 1, wing_in: {}, wing_out: {} }, + }, + ], +}; +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); + +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), + }), +); diff --git a/resources/tester/test_b06_draft_pipe_replay.py b/resources/tester/test_b06_draft_pipe_replay.py new file mode 100644 index 00000000..34919528 --- /dev/null +++ b/resources/tester/test_b06_draft_pipe_replay.py @@ -0,0 +1,47 @@ +# -*- coding: utf-8 -*- +"""저장 전 관 옵션 초안 되얹기 회귀검증 — PLAN 24-7a (2026-09-29). + +새로고침하면 횡단 캐시 상세는 저장분이라, 저장 전에 고친 월류 폭 · 관경이 폼 · 카드에서 +저장값(월류 폭 10 · 관경 1000)으로 돌아가 있었다. 초안(관 목록 세션 초안)이 정본이므로 +횡단 보기가 서며 초안 관 옵션을 캐시에 다시 얹는다. +• 되얹기 — 세월교 월류 폭 3.5 · 배수관 관경 1200 이 캐시 스펙에 실림 · 조정창 정본(기슭막이 높이 · 형태)은 안 건드림 +• 횡단 보기가 먼저 선 뒤 종단 보기가 서도 종단 보기가 배수유역도의 관 목록을 먼저 쥠(빈 목록으로 시작해 관을 지우지 않음) +화면 그릇 없이 Node 헬퍼가 TS 를 트랜스파일해 돌린다(`helper_b06_draft_pipe_replay.cjs`). +""" + +import json +import os +import subprocess + +HERE = os.path.dirname(os.path.abspath(__file__)) +ROOT = os.path.abspath(os.path.join(HERE, "..", "..")) + + +def _read(*parts: str) -> str: + with open(os.path.join(ROOT, *parts), encoding="utf-8") as handle: + return handle.read() + + +def test_draft_pipe_options_are_replayed_onto_the_cache(): + proc = subprocess.run( + ["node", os.path.join(HERE, "helper_b06_draft_pipe_replay.cjs")], + capture_output=True, + text=True, + timeout=60, + ) + assert proc.returncode == 0, proc.stderr + out = json.loads(proc.stdout) + assert out["fordSpan"] == 3.5 # 월류 폭 초안이 캐시 스펙에 실림(저장분 10 아님) + assert out["culvertDiameter"] == 1.2 # 배수관 관경 초안 1200 + assert out["fordCalls"] == 1 # 옵션 없는 관은 건드리지 않고 세월교는 한 번만 + assert out["revetTouched"] is False # 기슭막이 높이 · 형태는 조정창이 정본 + + # 배선 — 횡단 보기가 서며 초안을 읽어 되얹고, 종단 보기는 마지막 관 목록을 먼저 싣는다. + cross = _read("A00_Common", "design", "design_view_cross.ts") + assert "replayDraftPipeOptions(pipeOptionsContext, readPendingPipes(projectId))" in cross + shared = _read("A00_Common", "design", "design_shared.ts") + assert "shared.lastPipes = pipes" in shared + profile = _read("A00_Common", "design", "design_view_profile.ts") + assert profile.index("if (lastPipes) applyPipes(lastPipes)") < profile.index( + "await bridge.load();\n advanceLoading" + )