From 4c017c3bd90694e3c1fd2f3936ef322784a88a0c Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 17 Oct 2026 11:56:26 +0900 Subject: [PATCH] =?UTF-8?q?feat(365-1):=20=EB=AC=B6=EC=9D=8C=20=EC=A3=BC?= =?UTF-8?q?=EC=9D=B8=20=3D=20=ED=8E=98=EC=9D=B4=EC=A7=80=20=C2=B7=20?= =?UTF-8?q?=EC=B4=88=EC=95=88=20=EB=8F=99=EC=95=88=20=EC=84=B8=EC=85=98=20?= =?UTF-8?q?=EC=83=81=EC=84=B8=20=EC=95=88=20=EB=8D=AE=EC=9D=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 계산 묶음을 종단도 한 벌(sharedProfile)이 세움 — B06 바로 열기 · 새로고침도 노선 초안 셈 · 종단 보기는 다리만 받음(알림은 pipeline 바인딩) - 초안 둔 채 열기 = 측점을 가게에 냄 · 초안 전 저장 상세를 열 때 맡김(버리면 saved) - 초안 동안 측점 갈아끼움 = 메모리만(세션 section-detail = 저장 노선 그대로) - 시험 test_365_page_pipeline(초안 둔 채 열기 같은 상세 · 버림 saved · 세션 안 덮음) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TteK8mfYZetk5P3bEZkyVZ --- A00_Common/design/design_pipeline_page.ts | 9 +- A00_Common/design/design_shared.ts | 41 +++ A00_Common/design/design_view_profile.ts | 39 +-- B06_Section/B06_Section_Section_Store.ts | 5 +- resources/tester/helper_365_page_pipeline.cjs | 274 ++++++++++++++++++ resources/tester/helper_365_swap.mjs | 109 +++++++ resources/tester/helper_365_swap_entry.ts | 7 + resources/tester/test_365_page_pipeline.py | 144 +++++++++ 8 files changed, 593 insertions(+), 35 deletions(-) create mode 100644 resources/tester/helper_365_page_pipeline.cjs create mode 100644 resources/tester/helper_365_swap.mjs create mode 100644 resources/tester/helper_365_swap_entry.ts create mode 100644 resources/tester/test_365_page_pipeline.py diff --git a/A00_Common/design/design_pipeline_page.ts b/A00_Common/design/design_pipeline_page.ts index 3ec3d51f0..49a4f9d9d 100644 --- a/A00_Common/design/design_pipeline_page.ts +++ b/A00_Common/design/design_pipeline_page.ts @@ -513,10 +513,13 @@ function createPagePipeline( if (disposed || !ready || !savedSource().base) return; canonChanged(); started = true; - held = null; + const inputs = inputsNow(); + // 초안 둔 채 열기(새로고침 · B06 바로 열기 · 365-1) — 초안 전 = 지금 저장 상세(세션 그대로) · 측점은 가게에 냄 + held = inputs.route ? (held ?? holdNow()) : null; dropping = null; - void pipe.start(inputsNow()); - openGeneration = quietGeneration = pipe.generation; + void pipe.start(inputs); + openGeneration = pipe.generation; + quietGeneration = inputs.route ? -1 : pipe.generation; }; /** 저장 노선(그린 선 · 곡선표 · 기본 반경) · 그 측점 열쇠 — [저장] 뒤 다시 받음(`restart`). */ diff --git a/A00_Common/design/design_shared.ts b/A00_Common/design/design_shared.ts index 803c7c158..1ca8333de 100644 --- a/A00_Common/design/design_shared.ts +++ b/A00_Common/design/design_shared.ts @@ -29,6 +29,17 @@ import { saveDesign, type DesignSaveStep } from "./design_save"; import { markDesignClean } from "./design_leave"; import { resetDesign } from "./design_reset"; import type { DesignView } from "./design_dock"; +import { routeDraftBridge, type PagePipelineOptions } from "./design_pipeline_page"; +import { SECTION_STATION_INTERVAL_M } from "../../B05_Profile/logic/B05_Profile_Sections_Stations"; +import { + applyStructureAreaRows, + structureAreaRows, +} from "../../B06_Section/B06_Section_Structure_Layouts"; +import { + holdSectionDetail, + restoreSectionDetail, + swapSectionStations, +} from "../../B06_Section/B06_Section_Section_Store"; function L(key: keyof typeof ui_locales): string { return ui_locales[key][currentLanguageIndex]; @@ -62,6 +73,8 @@ export interface DesignViewBinding { onReset?: () => void; /** 다른 보기가 고친 관 옵션을 이 보기 캐시(횡단 스펙)에 얹는다. */ onPipeOptions?: (chainageM: number, patch: Record) => void; + /** 계산 묶음 알림 — 묶음은 페이지가 세움(365-1) · 보기는 알림만 받음. */ + pipeline?: Pick; } /** 한 벌 자리 — 페이지(`design_page`)가 만들어 두 보기에 같은 객체로 넘긴다. */ @@ -182,9 +195,37 @@ export function sharedProfile(shared: DesignPageShared): DesignProfile { attachProfilePopout(profile, shared.profileSlot, popoutButton); attachSubPanelPopouts(profile); shared.profile = profile; + if (shared.projectId) startPagePipeline(shared, profile); return profile; } +/** 계산 묶음 한 벌(365-1) — 종단도와 함께 페이지가 세움 · B05 · B06 어느 쪽으로 들어와도(새로고침도) 노선 + * 초안을 셈. 편집기는 `routeDraftBridge` 로 이 다리를 받아 씀 · 보기 몫 알림은 `pipeline` 바인딩으로. */ +function startPagePipeline(shared: DesignPageShared, profile: DesignProfile): void { + const projectId = shared.projectId; + const each = (fn: (hooks: NonNullable) => void): void => + Object.values(shared.bindings).forEach((binding) => binding?.pipeline && fn(binding.pipeline)); + routeDraftBridge(projectId, profile, { + stationIntervalM: SECTION_STATION_INTERVAL_M, + onCorridorStale: () => each((hooks) => hooks.onCorridorStale?.()), + onFacilitiesFollowed: (far) => each((hooks) => hooks.onFacilitiesFollowed?.(far)), + structureAreas: ({ cross_sections: rows }) => + applyStructureAreaRows(rows, structureAreaRows(rows)), + // 초안 시작 — 초안 전 상태를 떠 둠 · 버리면 그대로(측점이 달라도 · 349-0o) · 상세가 없으면 묶음 측점으로 갈아끼움 + holdDraft: (routeId, saved) => { + const hold = saved && holdSectionDetail(projectId, routeId, saved); + return async (stations) => { + if (!hold) { + await swapSectionStations(projectId, routeId, stations); + return "no-saved"; + } + const back = await restoreSectionDetail(projectId, routeId, hold, stations); + return !back ? "no-cache" : back.same ? "saved" : "saved-diff"; + }; + }, + }); +} + /** 「구조물 배치」 폼 한 벌 — 알림은 지금 보기로, 상세 옵션은 횡단 보기에서만 그린다. */ export function sharedStructures(shared: DesignPageShared): StructuresSection { if (shared.structures) return shared.structures; diff --git a/A00_Common/design/design_view_profile.ts b/A00_Common/design/design_view_profile.ts index b64c50514..a581b1445 100644 --- a/A00_Common/design/design_view_profile.ts +++ b/A00_Common/design/design_view_profile.ts @@ -48,16 +48,7 @@ import { rememberStandardDefaults, } from "../../B06_Section/B06_Section_UI_Standard_Panel"; import { fetchSectionContext } from "../../B06_Section/B06_Section_Api_Fetch"; -import { - applyStructureAreaRows, - structureAreaRows, -} from "../../B06_Section/B06_Section_Structure_Layouts"; -import { - holdSectionDetail, - invalidateSectionDetail, - restoreSectionDetail, - swapSectionStations, -} from "../../B06_Section/B06_Section_Section_Store"; +import { invalidateSectionDetail } from "../../B06_Section/B06_Section_Section_Store"; import { onStaleStructureTypes, structureAnchorM, @@ -414,6 +405,11 @@ export async function mountProfileView( bindDesignView(shared, "profile", { save: profileSaveParts(actionContext), onReset: () => uphillOverrides.clear(), + // 계산 묶음(페이지 몫 · 365-1) 알림 중 종단 보기 몫 — 3D 「낡음」 · 관 · 구조물 따라감 + pipeline: { + onCorridorStale: () => sections.setCorridorPending(true), + onFacilitiesFollowed: followFacilities, + }, }); // 탭이 다시 보일 때 정의 판이 바뀌었으면 종류 · 패널 다시 읽음(322-0) @@ -528,27 +524,8 @@ export async function mountProfileView( smooth: latestResponse.surface_params.smooth, tools: [view3d.modal.button], recommend: panel.recommend, - // 계산 묶음(346-1) — 노선을 고칠 때마다 종단이 따라옴(미리보기 · [저장]이 정본). - draft: routeDraftBridge(activeProjectId, profilePanel, { - stationIntervalM: panel.values().stationInterval ?? undefined, - onCorridorStale: () => sections.setCorridorPending(true), - onFacilitiesFollowed: followFacilities, - structureAreas: ({ cross_sections: rows }) => - applyStructureAreaRows(rows, structureAreaRows(rows)), - // 초안 시작 — 초안 전 상태를 떠 둠 · 버리면 그대로(측점이 달라도 · 349-0o) · 상세가 없으면(초안 둔 채 - // 새로고침) 묶음 측점으로 갈아끼움 - holdDraft: (routeId, saved) => { - const hold = saved && holdSectionDetail(activeProjectId, routeId, saved); - return async (stations) => { - if (!hold) { - await swapSectionStations(activeProjectId, routeId, stations); - return "no-saved"; - } - const back = await restoreSectionDetail(activeProjectId, routeId, hold, stations); - return !back ? "no-cache" : back.same ? "saved" : "saved-diff"; - }; - }, - }), + // 계산 묶음(346-1) — 페이지가 세운 다리(365-1) · 노선을 고칠 때마다 종단이 따라옴(미리보기 · [저장]이 정본). + draft: routeDraftBridge(activeProjectId, profilePanel), }); // ③ 종단면·횡단 자료 — 하단 패널을 3D보다 먼저 채운다. diff --git a/B06_Section/B06_Section_Section_Store.ts b/B06_Section/B06_Section_Section_Store.ts index b6bedd8c1..f826e1720 100644 --- a/B06_Section/B06_Section_Section_Store.ts +++ b/B06_Section/B06_Section_Section_Store.ts @@ -19,6 +19,7 @@ import { clearState, readState, writeState } from "../A00_Common/b_page_state"; import { PIPE_MATCH_M } from "../A00_Common/design/design_selection"; +import { useDesignStore } from "../A00_Common/design/design_store"; import { setGeneralStations } from "../common_util/common_util_structure_station"; import { forgetDraftStations, @@ -157,7 +158,9 @@ export async function swapSectionStations( if (swap.moved) carryStationKeys(projectId, routeId, nextKeys); else useStationKeyList(projectId, routeId, nextKeys); cache.set(key, swap.detail); - writeState("section-detail", swap.detail, projectId, routeId); + // 노선 초안 동안 = 메모리만(365-1) — 세션 상세는 저장 노선 그대로(새로고침 · B06 바로 열기 = 묶음이 초안 다시 셈) + if (!useDesignStore(projectId, null).get("routeDraft")) + writeState("section-detail", swap.detail, projectId, routeId); await withDraftShapes(swap.detail, projectId); // 새 측점에도 구조물 그림 자료(323-4) withStructureAreas(swap.detail); if (cache.get(key) !== swap.detail) return null; // 그 사이 다른 갈아끼움 · 새로 받음 diff --git a/resources/tester/helper_365_page_pipeline.cjs b/resources/tester/helper_365_page_pipeline.cjs new file mode 100644 index 000000000..ae558e96a --- /dev/null +++ b/resources/tester/helper_365_page_pipeline.cjs @@ -0,0 +1,274 @@ +/* PLAN 365 단위검증 헬퍼 — 묶음 주인 = 페이지(초안 둔 채 열기 · B06 바로 열기 · 새로고침). 진짜 TS + * (`design_pipeline_page` · 단계 파일 전부)를 그 자리에서 트랜스파일해 Node 로 돌림 · 서버 창구만 가짜 + * (틀 = `helper_346_2_pipeline_cross_page.cjs`). 입력 argv[2] JSON · argv[3] 격자 이진 · 결과 = 표준출력 JSON. + * 짝 = `test_365_page_pipeline.py`. */ +const fs = require("fs"); +const path = require("path"); +const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); +const CATALOG = require("./helper_322_catalog_module.cjs"); + +const ROOT = path.join(__dirname, "..", ".."); +const job = JSON.parse(fs.readFileSync(process.argv[2], "utf8")); + +function memoryStorage() { + const map = new Map(); + return { + getItem: (key) => (map.has(key) ? map.get(key) : null), + setItem: (key, value) => map.set(key, String(value)), + removeItem: (key) => map.delete(key), + key: (index) => [...map.keys()][index] ?? null, + get length() { + return map.size; + }, + }; +} +globalThis.window = { + sessionStorage: memoryStorage(), + localStorage: memoryStorage(), + setTimeout, + clearTimeout, +}; + +// 흙 배분 서버 — 늦게 답함 · 끊기면 버림 +const haul = { calls: 0, aborted: 0, answered: 0 }; +globalThis.fetch = (url, init) => + new Promise((resolve, reject) => { + haul.calls += 1; + const timer = setTimeout(() => { + haul.answered += 1; + resolve({ ok: true, json: async () => ({ haul_plan: { blocks: [], url } }) }); + }, job.haulDelayMs); + init.signal.addEventListener("abort", () => { + clearTimeout(timer); + haul.aborted += 1; + reject(new Error("aborted")); + }); + }); + +const STUBS = {}; +const cache = new Map(); +function loadTs(full) { + 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) => { + if (CATALOG.isCatalogRequest(request)) return CATALOG; + const key = Object.keys(STUBS).find((name) => request.endsWith(name)); + if (key) return STUBS[key]; + if (request.startsWith("@config/")) return { API_BASE_URL: "http://fake" }; + if (request.startsWith("@util/")) + return loadTs(path.join(ROOT, "common_util", `${request.slice(6)}.ts`)); + if (request.startsWith(".")) return loadTs(path.join(path.dirname(full), `${request}.ts`)); + return require(request); + }; + new Function("exports", "module", "require", js)(box.exports, box, localRequire); + return box.exports; +} +const load = (...parts) => loadTs(path.join(ROOT, ...parts)); + +const sampler = load("common_util", "common_util_surface_sampler.ts"); +const bytes = fs.readFileSync(process.argv[3]); +const grid = sampler.parseDtmGrid( + bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.length), +); +const local = { grid, design: "las" }; + +STUBS["B05_Profile_Api_Replan"] = { + analysisTerrain: () => "las", + fetchRoutePlan: async () => ({ + planned: job.line, + curves: [], + min_radius_m: job.minRadius, + station_snap: false, + }), +}; +STUBS["B05_Profile_Api_Structures"] = { + fetchStructures: async () => ({ structures: [] }), + fetchStructureTypes: async () => job.types, +}; +STUBS["B05_Profile_Api_TerrainGrid"] = { + loadDesignTerrainGrid: async () => local, + groundStageDeps: () => ({ grid: () => local, remote: async () => [] }), +}; +STUBS["B04_PreProcess_Api_Fetch"] = { + fetchDetailPipePoints: async () => ({ + pipe_points: job.pipes.map((pipe) => ({ ...pipe, lonlat: [0, 0] })), + }), +}; +STUBS["B06_Section_UI_Standard_Panel"] = { + effectiveStandardCross: () => job.standard, + readRockBoundaryDefault: () => -0.5, +}; + +const pipeMod = load("A00_Common", "design", "logic", "design_pipeline.ts"); +const pageMod = load("A00_Common", "design", "design_pipeline_page.ts"); +const stationsMod = load("A00_Common", "design", "logic", "design_pipeline_stations.ts"); +const routeGround = load("B05_Profile", "logic", "B05_Profile_Pipeline_RouteGround.ts"); +const grade = load("B05_Profile", "logic", "B05_Profile_Pipeline_Grade.ts"); +const gradeGround = load("B05_Profile", "logic", "B05_Profile_Grade_Ground.ts"); +const detailMod = load("B05_Profile", "logic", "B05_Profile_Pipeline_Detail.ts"); +const alignment = load("B05_Profile", "logic", "B05_Profile_UI_Profile_Alignment.ts"); +const editMod = load("B05_Profile", "B05_Profile_UI_Profile_Edit.ts"); +const page = load("A00_Common", "b_page_state.ts"); + +const tick = (ms = 0) => new Promise((resolve) => setTimeout(resolve, ms)); +const runs = () => globalThis.__aisloPipeline?.runs ?? []; +async function settle(count) { + for (let i = 0; i < 400; i += 1) { + const last = runs()[runs().length - 1]; + if (runs().length >= count && last && last.outcome !== "running") return last; + await tick(5); + } + throw new Error("묶음이 끝나지 않음"); +} +const n = job.nodes.length; +const draftOf = (nodes) => ({ + nodes, + curveOn: Array(n).fill(true), + curveRadius: Array(n).fill(null), + curveLock: Array(n).fill(null), + curveArc: Array(n).fill(null), + stationSnap: false, +}); +const shift = (index, dx, dy) => + job.nodes.map((node, i) => (i === index ? [node[0] + dx, node[1] + dy] : node)); + +/** 저장 기준 — 저장 노선 지반 첫 계획선(관 측점 포함 · 서버 정책 그대로). */ +async function savedBaseOf() { + const seed = pipeMod.createDesignPipeline({ + stages: { + route: routeGround.createRouteStage({ + minRadiusM: () => job.minRadius, + savedLine: () => ({ line: job.line, curves: [] }), + }), + stations: stationsMod.createStationsStage({ + intervalM: job.interval, + types: () => job.types, + }), + ground: routeGround.createGroundStage({ grid: () => local, remote: async () => [] }), + }, + }); + await seed.start({ route: null, alignment: null, cross: null, pipes: job.pipes, structures: [] }); + const ground = seed.output("ground"); + seed.dispose(); + const samples = ground.longitudinal.chainageM.map((c, i) => ({ + chainage_m: c, + elevation_m: ground.longitudinal.z[i], + })); + const profile = gradeGround.groundProfile(samples); + return { + policy: job.policy, + onlyExplicitCurves: false, + basePvi: gradeGround.groundFollowingBasePvi({ + samples, + stations: ground.stations.map((s) => s.chainageM), + policy: job.policy, + }), + chainage: profile.chainage, + ground: profile.ground, + stations: ground.stations.map((s) => ({ + station_id: grade.stationIdOf(s.chainageM), + chainage_m: s.chainageM, + })), + }; +} + +const store = load("A00_Common", "design", "design_store.ts"); +const r3 = (value) => (typeof value === "number" ? Math.round(value * 1000) / 1000 : null); + +/** 묶음 새 노선 상세(카드가 그리는 것) — 측점 · 계획고 · 절 · 성토 면적. */ +function shownRows(P) { + const shown = pageMod.pipelineDetail(P); + return ( + shown && + shown.cross_sections.map((s) => [ + r3(s.chainage_m), + r3(s.design?.design_elevation_m), + r3(s.design?.cut_area_m2), + r3(s.design?.fill_area_m2), + ]) + ); +} + +async function run() { + const P = "proj-365"; + window.localStorage.setItem("frd_current_project_id", P); // 지금 프로젝트(노선 0 칸 자리) + const savedBase = await savedBaseOf(); + const shop = store.useDesignStore(P, null); + const holds = []; + const link = () => ({ + pipelineSource: () => ({ + base: savedBase, + edits: alignment.emptyEdits(), + detail: job.detail, + routeId: 7, + }), + adoptPipeline: () => undefined, + redraw: () => undefined, + }); + const options = { + stationIntervalM: job.interval, + holdDraft: (_routeId, saved) => { + holds.push(saved === job.detail ? "saved" : saved ? "other" : "null"); + return async () => "saved"; + }, + }; + const open = (label) => { + const bridge = pageMod.routeDraftBridge(P, link(), options); + pageMod.pipelineEarthwork(P, { conversion: job.conversion }); + return { bridge, label }; + }; + const stationList = () => (shop.get("stations") ?? []).map((s) => r3(s.chainageM)); + + // ① B05 길 — 저장 노선으로 열고 노선 꼭짓점 옮김(초안) + const first = open("b05"); + await settle(runs().length + 1); + first.bridge.edited(draftOf(shift(1, 8, -6))); + await settle(runs().length + 1); + await tick(20); + const b05 = { rows: shownRows(P), stations: stationList(), holds: [...holds] }; + + // ② 떠남(새로고침 · 묶음 메모리 버림) — 세션(가게) 초안은 남음 · 가게 측점은 비워 다시 내는지 봄 + pageMod.disposePagePipeline(P); + const kept = shop.get("routeDraft") !== null; + shop.set("stations", null); + holds.length = 0; + + // ③ B06 바로 열기 — 페이지가 묶음을 세움(편집기 없음) · 초안 그대로 셈 + const again = open("b06"); + const reopenRun = await settle(runs().length + 1); + await tick(20); + const b06 = { + rows: shownRows(P), + stations: stationList(), + holds: [...holds], + outcome: reopenRun.outcome, + stages: Object.keys(reopenRun.stages), + }; + + // ④ 초안 버림 — 열 때 맡긴 초안 전 상세로 되돌림(저장 정본) + again.bridge.edited(null); + const dropRun = await settle(runs().length + 1); + await tick(20); + const dropped = { + detail: pageMod.pipelineDetail(P), + restoreWhy: dropRun.restoreWhy ?? null, + routeDraft: shop.get("routeDraft"), + }; + pageMod.disposePagePipeline(P); + page.clearDrafts(P); + return { kept, b05, b06, dropped }; +} + +run() + .then((out) => { + process.stdout.write(JSON.stringify(out)); + process.exit(0); + }) + .catch((error) => { + process.stderr.write(String(error && error.stack ? error.stack : error)); + process.exit(1); + }); diff --git a/resources/tester/helper_365_swap.mjs b/resources/tester/helper_365_swap.mjs new file mode 100644 index 000000000..bdac7b8c1 --- /dev/null +++ b/resources/tester/helper_365_swap.mjs @@ -0,0 +1,109 @@ +/* 365-1 헬퍼 — 노선 초안 동안 측점 갈아끼움은 캐시(메모리)만 · 세션 상세 = 저장 노선 그대로. + * node helper_365_swap.mjs <묶음.mjs> + * vite ssr 로 묶은 진짜 모듈(helper_365_swap_entry.ts) · 표준출력 JSON 한 벌. 짝 = test_365_page_pipeline.py. + */ +import { pathToFileURL } from "node:url"; + +const store = () => { + const m = new Map(); + return { + getItem: (k) => (m.has(k) ? m.get(k) : null), + setItem: (k, v) => m.set(k, String(v)), + removeItem: (k) => m.delete(k), + key: (i) => [...m.keys()][i] ?? null, + get length() { + return m.size; + }, + clear: () => m.clear(), + }; +}; +globalThis.window = globalThis; +globalThis.sessionStorage = store(); +globalThis.localStorage = store(); +const any = new Proxy(function () {}, { + get: (_t, k) => (k === Symbol.toPrimitive ? () => 0 : any), + apply: () => any, + construct: () => any, +}); +globalThis.document = any; +globalThis.requestAnimationFrame = (run) => setTimeout(run, 0); + +const m = await import(pathToFileURL(process.argv[2]).href); +const P = "p365"; +const R = 9; +localStorage.setItem("frd_current_project_id", P); + +const section = (chainage_m, kind) => ({ + station_id: `station_${String(Math.round(chainage_m * 1000)).padStart(12, "0")}`, + chainage_m, + label: "", + kind, + center_z: 100, + azimuth_deg: 90, + center_x: chainage_m, + center_y: 0, + frame: { left_xy: [0, 1] }, + samples: [{ offset_m: 0, elevation_m: 100, valid: true }], + design: { design_elevation_m: 101 }, +}); +const saved = { + longitudinal: { + length_m: 60, + stations: [], + profile_alignment: { policy: { station_interval_m: 20 } }, + }, + cross_sections: [ + section(0, "bp"), + section(20, "regular"), + section(40, "regular"), + section(60, "ep"), + ], +}; +const station = (key, chainageM, kind) => ({ + key, + chainageM, + x: chainageM, + y: 0, + azimuthDeg: 90, + left: [0, 1], + kind, + id: null, + ground: { centerZ: 99, left: [{ offsetM: 0.5, z: 99.1 }], right: [{ offsetM: 0.5, z: 98.9 }] }, + planRadiusM: null, + curveOuterSide: null, + curveWideningM: null, +}); +const list = (...at) => + at.map((c, i) => + station( + i === 0 ? "end:BP" : i === at.length - 1 ? "end:EP" : `grid:${c}`, + c, + i === 0 || i === at.length - 1 ? "end" : "grid", + ), + ); +const chainages = (detail) => detail?.cross_sections.map((s) => s.chainage_m) ?? null; +const shop = m.useDesignStore(P, null); + +m.writeState("section-detail", saved, P, R); +await m.loadSectionDetail(P, R); // 세션 한 벌 → 캐시 +const out = {}; + +// 노선 초안 동안 — 측점 하나 늘어난 목록 +shop.set("routeDraft", { nodes: [], rev: 1 }); +const draft = await m.swapSectionStations(P, R, list(0, 20, 30, 40, 60)); +out.draft = { + swapped: draft !== null, + cache: chainages(await m.loadSectionDetail(P, R)), + session: chainages(m.readState("section-detail", P, R)), +}; + +// 초안 없음(저장 노선) — 갈아끼움이 세션까지 +shop.set("routeDraft", null); +const plain = await m.swapSectionStations(P, R, list(0, 20, 50, 60)); +out.saved = { + swapped: plain !== null, + cache: chainages(await m.loadSectionDetail(P, R)), + session: chainages(m.readState("section-detail", P, R)), +}; +process.stdout.write(JSON.stringify(out)); +process.exit(0); diff --git a/resources/tester/helper_365_swap_entry.ts b/resources/tester/helper_365_swap_entry.ts new file mode 100644 index 000000000..7881d8068 --- /dev/null +++ b/resources/tester/helper_365_swap_entry.ts @@ -0,0 +1,7 @@ +/* 365-1 시험 묶음 진입점 — 초안 동안 측점 갈아끼움 = 메모리만(진짜 모듈 · vite ssr). */ +export { + loadSectionDetail, + swapSectionStations, +} from "../../B06_Section/B06_Section_Section_Store"; +export { useDesignStore } from "../../A00_Common/design/design_store"; +export { readState, writeState } from "../../A00_Common/b_page_state"; diff --git a/resources/tester/test_365_page_pipeline.py b/resources/tester/test_365_page_pipeline.py new file mode 100644 index 000000000..fa8c67afc --- /dev/null +++ b/resources/tester/test_365_page_pipeline.py @@ -0,0 +1,144 @@ +"""PLAN 365 sub7 — 평면 > 종단 <> 횡단 한 방향 · 묶음 주인 = 페이지 · 캐시 · 고리. + +못박는 것(365-1) + - 묶음은 페이지(종단도 한 벌 `sharedProfile`)가 세움 — 종단 보기는 다리만 받음(옵션 안 넘김). + - 노선 초안 둔 채 다시 열기(새로고침 · B06 바로 열기 · 편집기 없음) = B05 길과 같은 초안 상세 + (측점 · 계획고 · 면적) · 측점을 가게에 다시 냄 · 초안 전 = 저장 상세(열 때 맡김) → 버리면 saved. + - 초안 동안 측점 갈아끼움은 메모리만 — 세션 상세(저장 노선)를 새 측점 + 옛 설계로 덮지 않음. + +짝: `helper_365_page_pipeline.cjs`(진짜 TS · 서버 창구만 가짜 · 틀 = 346-2 시험) · +`helper_365_swap_entry.ts` + `helper_365_swap.mjs`(진짜 상세 가게 · vite ssr). +""" + +from __future__ import annotations + +import json +import shutil +import subprocess +import sys +import tempfile +from pathlib import Path + +import pytest + +HERE = Path(__file__).resolve().parent +ROOT = HERE.parents[1] +if str(HERE) not in sys.path: + sys.path.insert(0, str(HERE)) + +import test_346_2_pipeline_cross_page as base # noqa: E402 + +from common_util.common_util_surface_sampler import DtmGridSampler, dtm_grid_bytes # noqa: E402 + +pytestmark = base.pytestmark + + +def _job(folder: Path) -> tuple[Path, Path]: + npz, nodes = base._fake(folder) + line = base._line(nodes) + sampler = DtmGridSampler.from_npz(npz) + pipes = base._pipes(line) + saved = base._sections(line, sampler, pipes) + options = base.resolve_grade_options("trunk", terrain_type="normal", paved=False, stored=None) + policy = base.design_ground_following_profile(saved["longitudinal"], options)[0]["policy"] + job = { + "nodes": nodes, + "line": line, + "minRadius": base.MIN_RADIUS_M, + "policy": policy, + "types": base._types(), + "interval": base.INTERVAL_M, + "pipes": pipes, + "standard": base.STANDARD_CROSS_SECTION, + "conversion": base.ONES, + "detail": base._saved_detail(saved), + "haulDelayMs": 10, + } + path = folder / "job.json" + path.write_text(json.dumps(job), encoding="utf-8") + grid = folder / "grid.bin" + grid.write_bytes(dtm_grid_bytes(npz)) + return path, grid + + +@pytest.fixture(scope="module") +def r() -> dict: + with tempfile.TemporaryDirectory() as folder: + path, grid = _job(Path(folder)) + proc = subprocess.run( # noqa: S603 — 고정 실행 파일 + ["node", str(HERE / "helper_365_page_pipeline.cjs"), str(path), str(grid)], + capture_output=True, + text=True, + encoding="utf-8", + timeout=180, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +def test_365_1_묶음_주인_페이지(): + shared = (ROOT / "A00_Common/design/design_shared.ts").read_text(encoding="utf-8") + view = (ROOT / "A00_Common/design/design_view_profile.ts").read_text(encoding="utf-8") + assert "routeDraftBridge(projectId, profile, {" in shared # 종단도 한 벌과 함께 세움 + assert "draft: routeDraftBridge(activeProjectId, profilePanel)," in view # 다리만 받음 + assert "holdDraft" not in view and "holdDraft" in shared + + +def test_365_1_초안_둔_채_열기_같은_초안(r): + b05, b06 = r["b05"], r["b06"] + assert r["kept"] # 떠나도 세션 초안 남음 + assert b05["rows"] and b06["rows"] == b05["rows"] # 측점 · 계획고 · 면적 같음 + assert b06["outcome"] == "done" and "cross" in b06["stages"] + assert b06["stations"] == b05["stations"] == [row[0] for row in b05["rows"]] # 측점 다시 냄 + assert b05["holds"] == ["saved"] and b06["holds"] == ["saved"] # 초안 전 = 저장 상세 + + +def test_365_1_초안_버림_저장_정본(r): + d = r["dropped"] + assert d["detail"] is None and d["routeDraft"] is None + assert d["restoreWhy"] == "saved" + + +@pytest.fixture(scope="module") +def swap(tmp_path_factory) -> dict: + work = tmp_path_factory.mktemp("b365") + build = subprocess.run( # noqa: S603 — 고정 실행 파일 + [ + "node", + str(ROOT / "config" / "node_modules" / "vite" / "bin" / "vite.js"), + "build", + "--configLoader", + "runner", + "--ssr", + "../resources/tester/helper_365_swap_entry.ts", + "--outDir", + str(work), + "--logLevel", + "error", + ], + cwd=ROOT, + capture_output=True, + text=True, + encoding="utf-8", + timeout=300, + ) + assert build.returncode == 0, build.stderr + bundle = work / "bundle.mjs" + shutil.copy(work / "helper_365_swap_entry.js", bundle) + proc = subprocess.run( # noqa: S603 — 고정 실행 파일 + ["node", str(HERE / "helper_365_swap.mjs"), str(bundle)], + capture_output=True, + text=True, + encoding="utf-8", + timeout=120, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +def test_365_1_초안_동안_세션_상세_안_덮음(swap): + d = swap["draft"] + assert d["swapped"] and d["cache"] == [0, 20, 30, 40, 60] # 메모리 = 초안 측점 + assert d["session"] == [0, 20, 40, 60] # 세션 = 저장 노선 그대로 + s = swap["saved"] + assert s["swapped"] and s["session"] == s["cache"] == [0, 20, 50, 60] # 초안 없으면 세션도