diff --git a/A00_Common/design/design_follow_server.ts b/A00_Common/design/design_follow_server.ts new file mode 100644 index 000000000..b3c7b437f --- /dev/null +++ b/A00_Common/design/design_follow_server.ts @@ -0,0 +1,52 @@ +/* ============================================================================= + * A00_Common/design/design_follow_server.ts + * 계산 자리 follow 「서버」(PLAN 511-B B-5) — 끌기 도구 꼴: 조작 중 = 화면 셈(미리보기) · 잠잠 뒤 서버 Node + * route · follow 두 단계(`stage.follow` · until follow · 저장 정본 맥락은 창구가 프로젝트 id 로 붙임)를 묻고 + * 답이 오면 묶음을 노선부터 다시 돌림 → 같은 노선 초안이면 서버 값(곁 자료 다시 붙임)으로 바꿈. + * 판이 both 면 null(화면 셈 그대로). 열쇠 = 노선 초안 + 저장 노선 계획고 편집(follow 는 노선에만 기댐). + * ========================================================================== */ + +import { readState } from "../b_page_state"; +import { + withFollowSides, + type FollowSides, +} from "../../B05_Profile/logic/B05_Profile_Pipeline_Grade"; +import { calcOnServerNow } from "./design_calc_sites"; +import { afterQuiet, serverCalc } from "./design_calc_server"; +import type { FollowPatch, PipelineInputs } from "./logic/design_pipeline_types"; + +const ID = "stage.follow"; +let held: { key: string; patch: FollowPatch } | null = null; +let asked: string | null = null; + +/** 서버 follow 값 — 같은 열쇠 답이 있으면 그 값 · 없으면 null(화면 셈) + 잠잠 뒤 묻고 `redraw`. */ +export function followFromServer( + projectId: string, + inputs: Readonly, + before: unknown, + savedEdits: unknown, + redraw: () => void, +): FollowPatch | null { + if (!calcOnServerNow("follow") || !inputs.route) return null; + const key = JSON.stringify([inputs.route, savedEdits]); + if (held?.key === key) return held.patch; + if (asked === key) return null; + asked = key; + const { route, alignment, pipes, structures, plusStations } = inputs; + const stages = { + until: "follow", + input: { route, alignment, pipes, structures, plusStations }, + before_draft: before, + pipe_ids: readState("pipe-ids", projectId), + saved_edits: savedEdits, + }; + const body = JSON.stringify({ run: [ID], inputs: { stages } }); + void afterQuiet(ID, (signal) => serverCalc("follow", [ID], body, signal)).then((results) => { + if (asked === key) asked = null; + const got = results?.[ID] as ({ patch: FollowPatch } & FollowSides) | undefined; + if (!got) return; + held = { key, patch: withFollowSides(got.patch, got) }; + redraw(); + }); + return null; +} diff --git a/A00_Common/design/design_pipeline_page.ts b/A00_Common/design/design_pipeline_page.ts index 170bc3d1e..f5531df3f 100644 --- a/A00_Common/design/design_pipeline_page.ts +++ b/A00_Common/design/design_pipeline_page.ts @@ -29,6 +29,7 @@ export { pipelineWork } from "./logic/design_pipeline"; import { createStationsStage, snapOptions, stationDiff } from "./logic/design_pipeline_stations"; import { stationsOnServer } from "./design_stations_server"; import { routeOnServer } from "./design_route_server"; +import { followFromServer } from "./design_follow_server"; import type { ChangeSource, FollowPatch, @@ -303,8 +304,15 @@ function createPagePipeline( const { pipes, structures, plusStations } = inputsNow(); return { alignment: kept, pipes, structures, plusStations }; } + // 계산 자리 follow 「서버」(511-B B-5) — 끌기 중 화면 셈 · 잠잠 뒤 서버 값으로 바꿈 + const before = beforeDraft ?? facilitiesNow(); + const remote = followFromServer(projectId, ctx.inputs, before, savedEdits(), () => + pipe.refresh("route"), + ); const saved = { pipes: savedPipes, structures: savedStructures }; - return followStage({ ...ctx, inputs: placedFollowInputs(ctx, savedLine(), saved) }); + return ( + remote ?? followStage({ ...ctx, inputs: placedFollowInputs(ctx, savedLine(), saved) }) + ); }, stations: createStationsStage({ intervalM: options.stationIntervalM, diff --git a/A00_Common/design/logic/design_calc_node_stages.ts b/A00_Common/design/logic/design_calc_node_stages.ts index df21c873b..bec708668 100644 --- a/A00_Common/design/logic/design_calc_node_stages.ts +++ b/A00_Common/design/logic/design_calc_node_stages.ts @@ -20,12 +20,14 @@ import { readFileSync, statSync } from "node:fs"; import { bermSpansFromStructures, bermSpecAt } from "@util/common_util_berm_spans"; import type { FollowingStructure } from "@util/common_util_route_follow"; +import { useCatalogDefinition, type CatalogDefinition } from "@util/common_util_structure_catalog"; import type { PlanPipe, PlanStructure, PlanType } from "@util/common_util_structure_station"; import { parseDtmGrid, type DtmGrid } from "@util/common_util_surface_sampler"; import type { RoutePlanResponse } from "../../../B05_Profile/B05_Profile_Api_Replan"; import { createFollowStage, createPlanlineStage, + followSides, profileStage, } from "../../../B05_Profile/logic/B05_Profile_Pipeline_Grade"; import { shapeStatus } from "../../../B05_Profile/logic/B05_Profile_Pipeline_Detail"; @@ -40,14 +42,23 @@ import { type AlignmentEdits, } from "../../../B05_Profile/logic/B05_Profile_UI_Profile_Alignment"; import { readAlignment } from "../../../B05_Profile/logic/B05_Profile_UI_Profile_Data"; -import type { SectionDetailResponse } from "../../../B06_Section/B06_Section_Api_Fetch"; +import type { + LongitudinalSection, + SectionDetailResponse, +} from "../../../B06_Section/B06_Section_Api_Fetch"; import { createCrossStage } from "../../../B06_Section/logic/B06_Section_Cross_Stage"; import { pipeBook } from "../../../B06_Section/B06_Section_Route_Carry_Patch"; import { plantedBase } from "./design_planted_base"; -import { createDesignPipeline } from "./design_pipeline"; +import { AFTER_STAGES, BROWSER_STAGES, createDesignPipeline } from "./design_pipeline"; import { contextCross, placedFollowInputs } from "./design_pipeline_stage_inputs"; import { createStationsStage, snapOptions } from "./design_pipeline_stations"; -import type { PipelineInputs, StageName, Vertex } from "./design_pipeline_types"; +import type { + PipelineInputs, + PipelineStages, + StageContext, + StageName, + Vertex, +} from "./design_pipeline_types"; import { plain } from "./design_calc_node_trial"; export interface StagesInputs { @@ -67,6 +78,13 @@ export interface StagesInputs { interval_m?: number; grid?: { path: string; design: "las" | "sheet" } | null; input: Partial; + /** 구조물 정의(서버 창구가 붙임 · 화면 `useCatalogDefinition` 꼴) — 측점 · 카드 이름 표. */ + definition?: CatalogDefinition | null; + /** 저장 종단(계획고 편집 = `readAlignment(·).edits` · 세션 초안 `saved_edits` 없을 때 · 창구가 붙임). */ + longitudinal?: LongitudinalSection | null; + /** "follow" = route · follow 두 단계만(격자 · 상세 안 씀 · 계산 자리 follow 「서버」 · 511-B B-5) — + * stage.follow = { patch · plus · followed }(곁 자료 = 페이지가 다시 붙임). */ + until?: "follow"; }; } @@ -107,6 +125,12 @@ async function runStages( plusStations: stages.input.plusStations ?? null, }; const stored = detail && readAlignment(detail.longitudinal); + if (stages.definition) useCatalogDefinition(stages.definition); + // 페이지 `savedEdits` 짝 — 세션 초안 없으면 저장 종단 편집 · 없으면 빈 편집(창구가 longitudinal 을 붙인 때) + const storedEdits = () => + stages.longitudinal === undefined + ? null + : ((stages.longitudinal && readAlignment(stages.longitudinal)?.edits) ?? emptyEdits()); const book = pipeBook(stages.pipe_ids ?? null); // 따라감 기준(페이지 `beforeDraft ?? 가게`) — 초안 전 관 · 구조물(null = 저장분) const before = stages.before_draft ?? stages.input; @@ -116,7 +140,7 @@ async function runStages( saved: () => ({ pipes: before.pipes ?? savedPipes, structures: before.structures ?? savedStructures, - alignment: stages.saved_edits ?? inputs.alignment ?? emptyEdits(), + alignment: stages.saved_edits ?? storedEdits() ?? inputs.alignment ?? emptyEdits(), }), stationsAt: (route, patch, at) => buildStations( @@ -128,50 +152,50 @@ async function runStages( ).map((station) => station.chainageM), }); const crossStage = createCrossStage({ grid: () => grid }); - const pipe = createDesignPipeline({ - stages: { - route: createRouteStage({ - minRadiusM: () => plan.min_radius_m ?? 0, - savedLine: () => ({ line: savedLine(), curves: savedCurves() }), - }), - follow: (ctx) => - followStage({ - ...ctx, - inputs: placedFollowInputs(ctx, savedLine(), { - pipes: savedPipes, - structures: savedStructures, - }), + const table: PipelineStages = { + route: createRouteStage({ + minRadiusM: () => plan.min_radius_m ?? 0, + savedLine: () => ({ line: savedLine(), curves: savedCurves() }), + }), + follow: (ctx) => + followStage({ + ...ctx, + inputs: placedFollowInputs(ctx, savedLine(), { + pipes: savedPipes, + structures: savedStructures, }), - stations: createStationsStage({ - intervalM: stages.interval_m, - types: () => enabled, - saved: () => plan, }), - ground: createGroundStage({ - grid: () => grid, - remote: () => Promise.reject(new Error("DTM 격자 없음 — 서버 표본 길은 파이썬")), - }), - planline: createPlanlineStage({ - savedBase: (at) => - plantedBase({ base: null, detail }, at, types, (pipes) => - pipes.map((pipe) => ({ ...pipe, id: book.at(pipe.chainage_m) })), - ), - savedLine, - storedPvi: () => (stored ? toAlignmentBase(stored).basePvi : null), - }), - profile: profileStage, - cross: (ctx) => { - const status = shapeStatus( - ctx.inputs.pipes ?? savedPipes, - ctx.inputs.structures ?? savedStructures, - types, - ); - const list = Array.isArray(ctx.inputs.structures) ? ctx.inputs.structures : []; - const spans = bermSpansFromStructures(list); - return crossStage.stage(contextCross(ctx, status, (at) => bermSpecAt(spans, at))); - }, + stations: createStationsStage({ + intervalM: stages.interval_m, + types: () => enabled, + saved: () => plan, + }), + ground: createGroundStage({ + grid: () => grid, + remote: () => Promise.reject(new Error("DTM 격자 없음 — 서버 표본 길은 파이썬")), + }), + planline: createPlanlineStage({ + savedBase: (at) => + plantedBase({ base: null, detail }, at, types, (pipes) => + pipes.map((pipe) => ({ ...pipe, id: book.at(pipe.chainage_m) })), + ), + savedLine, + storedPvi: () => (stored ? toAlignmentBase(stored).basePvi : null), + }), + profile: profileStage, + cross: (ctx) => { + const status = shapeStatus( + ctx.inputs.pipes ?? savedPipes, + ctx.inputs.structures ?? savedStructures, + types, + ); + const list = Array.isArray(ctx.inputs.structures) ? ctx.inputs.structures : []; + const spans = bermSpansFromStructures(list); + return crossStage.stage(contextCross(ctx, status, (at) => bermSpecAt(spans, at))); }, - }); + }; + if (stages.until === "follow") return followOnly(table, inputs); + const pipe = createDesignPipeline({ stages: table }); try { const record = await pipe.start(inputs); await pipe.settled(); @@ -184,6 +208,29 @@ async function runStages( } } +/** route · follow 두 단계만 — 묶음 첫 바퀴와 같은 맥락(앞 출력 없음 = 저장분에서 · 페이지 「다시 열기」 꼴). */ +async function followOnly( + table: PipelineStages, + inputs: PipelineInputs, +): Promise> { + const none = Object.fromEntries( + [...BROWSER_STAGES, ...AFTER_STAGES].map((name) => [name, null]), + ) as StageContext<"route">["out"]; + const at = (out: StageContext["out"]): StageContext => ({ + generation: 1, + range: null, + signal: new AbortController().signal, + inputs, + out, + before: none, + prev: null, + }); + const route = await table.route!(at<"route">(none)); + const follow = await table.follow!(at<"follow">({ ...none, route })); + const out = { route: plain(route), follow: { patch: plain(follow), ...followSides(follow) } }; + return { stations: null, profile: null, cross: null, ...out } as Record; +} + /** id → 셈(입구 `RUNNERS` 에 얹음 · `available` 에는 안 넣음) — 바퀴 하나를 id 들이 나눠 씀. */ export function stageRunners(inputs: StagesInputs): Record Promise> { let run: Promise> | null = null; diff --git a/A00_Common/design/logic/design_pipeline.ts b/A00_Common/design/logic/design_pipeline.ts index ed7f04fff..1cd512706 100644 --- a/A00_Common/design/logic/design_pipeline.ts +++ b/A00_Common/design/logic/design_pipeline.ts @@ -83,6 +83,8 @@ export interface DesignPipeline { value: unknown, part?: RangePart | null, ): Promise | null; + /** 입력 그대로 그 출처부터 다시 셈 — 늦게 온 서버 값(계산 자리 follow 「서버」 · 511-B B-5). */ + refresh(source: ChangeSource): Promise | null; /** `name` = 기록 `work` 칸 이름(알림 받는 쪽 몫 ms · 349-0s). */ subscribe(fn: (event: PipelineEvent) => void, name?: string): () => void; output(name: K): PipelineOutputs[K] | null; @@ -353,6 +355,11 @@ export function createDesignPipeline(options: { stages: PipelineStages }): Desig : { sources: [source], fromM: 0, toM: Infinity, keys: [] }; return begin(range); }, + refresh(source) { + if (busy) return null; + inputVer[SOURCE_INPUT[source]] += 1; + return begin({ sources: [source], fromM: 0, toM: Infinity, keys: [] }); + }, subscribe(fn, name = "알림") { listeners.set(fn, name); return () => listeners.delete(fn); diff --git a/B05_Profile/logic/B05_Profile_Pipeline_Grade.ts b/B05_Profile/logic/B05_Profile_Pipeline_Grade.ts index c2e79a615..7d5c912f0 100644 --- a/B05_Profile/logic/B05_Profile_Pipeline_Grade.ts +++ b/B05_Profile/logic/B05_Profile_Pipeline_Grade.ts @@ -184,6 +184,24 @@ const plusAt = new WeakMap(); /** follow 출력마다 따라가기만 한 자리(id → 자리 누가거리들) — planline 이 427 세움에 씀(459). */ const followedAt = new WeakMap>(); +/** follow 출력 곁 자료(추가 측점 땅 자리 · 따라가기만 한 자리) — JSON 꼴(서버 follow · 511-B B-5). */ +export interface FollowSides { + plus: FollowingStructure[] | null; + followed: Array<[string, number[]]> | null; +} + +export function followSides(patch: object): FollowSides { + const followed = followedAt.get(patch); + return { plus: plusAt.get(patch) ?? null, followed: followed ? [...followed] : null }; +} + +/** 서버가 낸 follow 출력에 곁 자료를 다시 붙임 — 다음 걸음 · planline 이 화면 셈과 같은 값을 봄. */ +export function withFollowSides(patch: T, sides: FollowSides): T { + if (sides.plus) plusAt.set(patch, sides.plus); + if (sides.followed) followedAt.set(patch, new Map(sides.followed)); + return patch; +} + type Facility = FollowingStructure & { id?: string | null; structure_id?: string | null }; /** 땅 위 자리 — 좌표가 있으면 그대로(따라가기만 한 시설 = 정본 좌표 · 초안 둔 채 열기도) · 없으면 누가거리의 diff --git a/B06_Section/B06_Section_Router_Calc.py b/B06_Section/B06_Section_Router_Calc.py index 6ee07bea6..9644af226 100644 --- a/B06_Section/B06_Section_Router_Calc.py +++ b/B06_Section/B06_Section_Router_Calc.py @@ -5,7 +5,8 @@ 브라우저가 계산 자리 판(`calc_sites.json`)에서 묶음을 「서버」로 둔 때만 부름 (`design_calc_server.ts`). 권한 = 프로젝트 접근(`protected_with_company`) · 호출량 = `main.py` 미들웨어 · 본문 상한 `MAX_BODY`. -파일 경로 = 서버가 프로젝트 id 로 정함(그 storage 안 DTM 격자) · 입력에 경로 칸 = 400. +파일 경로 = 서버가 프로젝트 id 로 정함(그 storage 안 DTM 격자) · 입력에 경로 칸 = 400 · +follow 「서버」 맥락(저장 노선 · 관 · 구조물 …) = 서버가 모음(`B06_Section_Router_Calc_Context`). 실행 = 늘 떠 있는 Node 하나(`common_util_calc_runner.run_calc_served`) · 정본 저장 길은 이 창구를 안 씀. 단계(`stage.*`) · 화면 길(`cross.refresh`) = 서버가 DTM 격자 경로 · 구조물 정의를 채움 — @@ -20,6 +21,7 @@ from uuid import UUID from fastapi import APIRouter, Request from fastapi.responses import JSONResponse +from B06_Section.B06_Section_Router_Calc_Context import attach_follow_context from common_util.common_util_calc_runner import ( PAGE_IDS, SERVER_ONLY_IDS, @@ -97,6 +99,10 @@ async def run_calc_endpoint(project_id: UUID, request: Request) -> dict | JSONRe return _error(400, problem) if set(run) & (STAGE_IDS | PAGE_IDS): await _server_context(project_id, inputs) + # follow 「서버」 = 저장 정본 맥락은 서버가 모음(브라우저 정본 칸 버림 · 511-B B-5) + problem = await attach_follow_context(project_id, inputs) + if problem: + return _error(409, problem) if {"stations.list", "stations.plan"} & set(run) and isinstance(inputs.get("stations"), dict): # 심는 측점 · 카드 이름 표 = 서버 구조물 정의(서버 측점 생성과 같은 표 · 511-B B-2) from B05_Profile.B05_Profile_Engine_Sections_Node import structure_table diff --git a/B06_Section/B06_Section_Router_Calc_Context.py b/B06_Section/B06_Section_Router_Calc_Context.py new file mode 100644 index 000000000..543714a3d --- /dev/null +++ b/B06_Section/B06_Section_Router_Calc_Context.py @@ -0,0 +1,93 @@ +"""계산 창구 `/calc` 의 서버 맥락(PLAN 511-B B-5) — follow 「서버」(`stage.follow` · until +follow)에 붙이는 저장 정본을 프로젝트 id 로 서버가 모음. 브라우저가 보낸 정본 칸은 버림(경로 +막기와 같은 원칙). + + plan · types · definition · saved_pipes · saved_structures · saved_plus · interval_m · + longitudinal + = 화면이 받는 값과 같은 길(GET 노선 계획 · 구조물 종류 · 정의 · 관 지점 · 구조물 · 추가 측점 · + 측점 간격 기본 · 최신 노선 저장 종단) — 정본 쓰기 없음(관 지점 = 화면 GET 과 같은 파생 저장본만). +브라우저 몫 = `BROWSER_KEYS`(묶음 입력 · 초안 전 시설 · 관 세션 장부 · 세션 계획고 초안). +""" + +from __future__ import annotations + +import asyncio +import json +from pathlib import Path +from typing import Any +from uuid import UUID + +BROWSER_KEYS = frozenset({"input", "before_draft", "pipe_ids", "saved_edits", "until"}) +PLAN_KEYS = ("planned", "curves", "min_radius_m", "station_snap", "station_snap_step") + + +async def follow_context(project_id: UUID) -> dict[str, Any] | str: + """follow 두 단계 맥락 — 못 모으면 까닭 글.""" + from B04_PreProcess.B04_PreProcess_Router_Basins import get_pipe_points + from B05_Profile.B05_Profile_Engine_Sections_Core import SectionGenerationOptions + from B05_Profile.B05_Profile_Engine_Sections_Node import structure_table + from B05_Profile.B05_Profile_Plus_Stations import load_plus_stations + from B05_Profile.B05_Profile_Router_Replan import _project_paths, read_route_plan + from B05_Profile.B05_Profile_Structures_Router import read_structures + + paths = await _project_paths(project_id) + if paths is None: + return "프로젝트 저장 경로 없음" + root = paths[0] + plan, pipes, structures, (types, definition) = await asyncio.gather( + read_route_plan(project_id), + get_pipe_points(project_id), + read_structures(project_id), + asyncio.to_thread(structure_table), + ) + for name, got in (("노선", plan), ("관", pipes)): + if not isinstance(got, dict): + return f"{name} 정본을 못 읽음" + if not hasattr(structures, "model_dump"): + return "구조물 정본을 못 읽음" + return { + "plan": {key: plan.get(key) for key in PLAN_KEYS}, + "types": types, + "definition": definition, + "saved_pipes": [ + {k: v for k, v in pipe.items() if k != "lonlat"} for pipe in pipes["pipe_points"] + ], + "saved_structures": structures.model_dump(mode="json")["structures"], + "saved_plus": await asyncio.to_thread(load_plus_stations, root), + "interval_m": SectionGenerationOptions().station_interval_m, + "longitudinal": await _saved_longitudinal(project_id, root), + } + + +async def _saved_longitudinal(project_id: UUID, root: Path) -> dict[str, Any] | None: + """최신 노선의 저장 종단(화면 상세 `longitudinal`) — 없거나 못 읽으면 None.""" + from B05_Profile.B05_Profile_Repository import get_latest_route + from B06_Section.B06_Section_Repository import get_longitudinal_section + from config.config_db import get_db_pool + + async with get_db_pool().acquire() as connection: + route = await get_latest_route(connection, project_id) + row = route and await get_longitudinal_section(connection, project_id, int(route["id"])) + rel = (row or {}).get("longitudinal_file_path") + if not rel: + return None + path = (root / str(rel)).resolve() + if not path.is_relative_to(root.resolve()) or not path.is_file(): + return None + data = json.loads(await asyncio.to_thread(path.read_text, encoding="utf-8")) + return data if isinstance(data, dict) else None + + +async def attach_follow_context(project_id: UUID, inputs: dict[str, Any]) -> str | None: + """`inputs.stages`(until follow) — 브라우저 몫만 남기고 서버 맥락을 붙임 · 못 붙이면 까닭 글.""" + stages = inputs.get("stages") + if not isinstance(stages, dict) or stages.get("until") != "follow": + return None + context = await follow_context(project_id) + if isinstance(context, str): + return context + inputs["stages"] = { + **{k: v for k, v in stages.items() if k in BROWSER_KEYS}, + **context, + } + return None diff --git a/common_util/common_util_calc_sites.py b/common_util/common_util_calc_sites.py index ac5380289..a8dce730d 100644 --- a/common_util/common_util_calc_sites.py +++ b/common_util/common_util_calc_sites.py @@ -36,12 +36,13 @@ GROUPS: tuple[dict[str, Any], ...] = ( # 묶음 단계(511-B · 단계째 서버 Node `stage.*`) — 짝 모으기 B-3 ~ B-5 끝난 줄부터 엶 # 노선(511-B B-5 · 노선 저장 폴리라인 = Node `route.polyline` · 조작 중 「서버」 = 같은 id) {"key": "route", "nos": ["R1", "R3"], "switchable": True, "fixed": None, "why": None}, + # 따라감(511-B B-5 · 서버 정본 Node · 조작 중 「서버」 = 잠잠 뒤 창구 맥락 + route · follow 두 단계) { "key": "follow", "nos": ["P5", "P6", "F1", "F2"], - "switchable": False, - "fixed": "both", - "why": "pair_B5", + "switchable": True, + "fixed": None, + "why": None, }, # 측점(511-B B-2 · 서버 측점 생성 = Node `stations.sections` · 조작 중 「서버」 = `stations.list`) { diff --git a/resources/master_template/calc_sites.json b/resources/master_template/calc_sites.json index 9934379fa..10709416a 100644 --- a/resources/master_template/calc_sites.json +++ b/resources/master_template/calc_sites.json @@ -6,6 +6,7 @@ "quantity": "both", "carry": "both", "route": "both", + "follow": "both", "stations": "both", "profile": "both", "cross": "both", diff --git a/resources/tester/test_309_2_calc_sites.py b/resources/tester/test_309_2_calc_sites.py index 0c8f2dd94..da098f250 100644 --- a/resources/tester/test_309_2_calc_sites.py +++ b/resources/tester/test_309_2_calc_sites.py @@ -64,6 +64,7 @@ def test_묶음_여덟_줄_등록부_전부(): "quantity", "carry", "route", + "follow", "stations", "profile", # 511-B B-3 엶 "cross", # 511-B B-4 끝에 엶 @@ -162,7 +163,7 @@ def test_읽기_로그인_쓰기_시스템_관리자만(client): def test_잠금_모르는_줄_400_감사_한_줄(client): http, _role, board, log = client url = "/api/dashboard/admin/calc-sites" - for bad in ({"follow": "server"}, {"fixed_server": "both"}, {"nope": "server"}): + for bad in ({"form_default": "server"}, {"fixed_server": "both"}, {"nope": "server"}): assert http.put(url, json={"sites": bad}).status_code == 400, bad assert http.put(url, json={"sites": {"ground_sample": "browser"}}).status_code == 400 assert log == [] diff --git a/resources/tester/test_309_2_calc_sites_browser.py b/resources/tester/test_309_2_calc_sites_browser.py index 720ce9540..cf4e1cc5d 100644 --- a/resources/tester/test_309_2_calc_sites_browser.py +++ b/resources/tester/test_309_2_calc_sites_browser.py @@ -150,13 +150,13 @@ def test_시스템_관리자_카드_창_G1_서버_길(browser): calcs = [row for row in rows if not row["head"]] assert len(heads) == 13 and len(calcs) == 45 # 511-B 단계 다섯 줄 · F9 · 511-C haul whys = {row["name"].split(" · ")[0]: row["why"] for row in calcs} - for no, n in {"F2": 5}.items(): - assert whys[no] == f"짝 모으는 중 · B-{n}", no + assert not any(why.startswith("짝 모으는 중") for why in whys.values()) # 단계 줄 모두 엶 assert whys["C1"].startswith("끄면 횡단을 서버 Node") # 511-B B-4 엶 assert heads[0]["boxes"] == [[True, False], [True, True]] # G1 묶음 머리 = 바꿀 수 있음 editable = [row["name"] for row in calcs if not row["boxes"][0][1]] opened = ["G1", "D3", "E1", "F7", "F8", "C4", "R9", "F10"] # 511-A ③ ④ 더 - opened += ["R1", "R3", "R5", "R6", "R7", "F3"] # 511-B B-5 노선 · B-2 측점 + opened += ["R1", "R3", "P5", "P6", "F1", "F2"] # 511-B B-5 노선 · 따라감 + opened += ["R5", "R6", "R7", "F3"] # 511-B B-2 측점 opened += ["P1", "P2", "P3", "P4", "P8"] # 511-B B-3 종단 엶 opened += ["G2", "G4", "C1", "C2", "C3", "C5", "C6", "F4", "F5", "F6"] # 511-B B-4 opened += ["E2"] # 511-C 유토 배분 엶(기본 server = 체크 꺼짐) diff --git a/resources/tester/test_511_b1_calc_node_stages.py b/resources/tester/test_511_b1_calc_node_stages.py index 095f8acdc..39d9e8124 100644 --- a/resources/tester/test_511_b1_calc_node_stages.py +++ b/resources/tester/test_511_b1_calc_node_stages.py @@ -76,9 +76,7 @@ def test_단계_id_표_같음_등록부_밖(): def test_판_다섯_줄_잠금_기본값_그대로(): by_key = {g["key"]: g for g in calc_sites.GROUPS} - expected = { - "follow": (["P5", "P6", "F1", "F2"], "pair_B5"), - } + expected = {} for key, (nos, why) in expected.items(): group = by_key[key] assert group["nos"] == nos and group["why"] == why, key @@ -95,6 +93,7 @@ def test_판_다섯_줄_잠금_기본값_그대로(): "quantity", "carry", "route", # B-5 = 엶(511-B B-5) + "follow", # B-5 = 엶(511-B B-5) "stations", # B-2 끝 = 엶(511-B B-2) "profile", # B-3 엶(511-B · test_511_b3_profile_node) "cross", @@ -109,7 +108,7 @@ def test_판_다섯_줄_잠금_기본값_그대로(): ).stdout assert calc_sites.PATH.read_bytes().replace(b"\r\n", b"\n") == committed.replace(b"\r\n", b"\n") locale = (ROOT / "ui_template" / "ui_template_locale_m1.ts").read_text(encoding="utf-8") - for n in (4, 5): + for n in (4,): assert f'B01_CalcSites_Why_pair_B{n}: ["짝 모으는 중 · B-{n}"' in locale diff --git a/resources/tester/test_511_b5_route_node.py b/resources/tester/test_511_b5_route_node.py index 8d7b9f9c3..cbcc3b58a 100644 --- a/resources/tester/test_511_b5_route_node.py +++ b/resources/tester/test_511_b5_route_node.py @@ -16,6 +16,7 @@ from __future__ import annotations +import asyncio import json import sys from pathlib import Path @@ -319,3 +320,200 @@ def test_화면_서버_노선_화면_셈과_같음(screens, case): assert both["calls"] == [] # 저장 노선(초안 없음) = 서버 안 부름 · 초안 = server 판만 서버 노선 assert bool(server["calls"]) is (case == "draft") + + +# ── follow 「서버」 — 창구 맥락 · Node route · follow 두 단계 ───────────────────────── + + +def test_창구_follow_맥락_브라우저_정본칸_버림(monkeypatch): + from B06_Section import B06_Section_Router_Calc_Context as ctx + + async def context(_pid): + return {"plan": {"planned": "서버"}, "saved_pipes": ["서버"], "longitudinal": None} + + monkeypatch.setattr(ctx, "follow_context", context) + inputs = { + "stages": { + "until": "follow", + "input": {"route": 1}, + "saved_edits": None, + "plan": {"planned": "브라우저"}, + "saved_pipes": ["브라우저"], + "grid": {"path": "C:/x"}, + "detail": {"x": 1}, + } + } + assert asyncio.run(ctx.attach_follow_context("p", inputs)) is None + assert inputs["stages"] == { + "until": "follow", + "input": {"route": 1}, + "saved_edits": None, + "plan": {"planned": "서버"}, + "saved_pipes": ["서버"], + "longitudinal": None, + } + other = {"stages": {"plan": "브라우저"}} # until 없음 = 그대로(B-1 단계 길) + assert asyncio.run(ctx.attach_follow_context("p", other)) is None and other["stages"] == { + "plan": "브라우저" + } + + +def _golden_stages() -> dict: + from common_util.common_util_surface_sampler import dtm_grid_bytes + + def fixture(name): + return json.loads((FIXTURE / name).read_text(encoding="utf-8")) + + plan = fixture("plan.json") + nodes = [[n["x"], n["y"]] for n in plan["nodes"]] + at = len(nodes) // 2 + nodes[at] = [nodes[at][0] + 6, nodes[at][1] + 4] # 교각점 하나 옮긴 노선 초안 + draft = { + "nodes": nodes, + "curveOn": [n["radius_m"] is not None for n in plan["nodes"]], + "curveRadius": [None] * len(nodes), + "curveLock": [None] * len(nodes), + "curveArc": [None] * len(nodes), + "stationSnap": bool(plan.get("station_snap")), + "stationSnapStep": plan.get("station_snap_step"), + } + grid = ROOT / "tmp" / "511b5_grid.bin" + if not grid.is_file(): + grid.write_bytes( + dtm_grid_bytes(FIXTURE / "project/B04_PreProcess/models/dtm_classification_smooth.npz") + ) + return { + "plan": plan, + "detail": fixture("detail.json"), + "types": fixture("structure_types.json")["types"], + "saved_pipes": [ + {k: v for k, v in p.items() if k != "lonlat"} + for p in fixture("pipes.json")["pipe_points"] + ], + "saved_structures": fixture("structures.json")["structures"], + "interval_m": 20, + "grid": {"path": str(grid), "design": "las"}, + "input": {"route": draft, "plusStations": fixture("plus_stations.json")["chainages"]}, + } + + +def test_Node_follow_두_단계_묶음_첫_바퀴와_같음(): + from common_util import common_util_calc_runner as runner + + stages = _golden_stages() + full = runner.run_calc_served(["stage.route", "stage.follow"], {"stages": stages}) + only = runner.run_calc_served( + ["stage.route", "stage.follow"], {"stages": {**stages, "until": "follow", "grid": None}} + ) + assert full and only and "error" not in full and "error" not in only, (full, only) + assert only["results"]["stage.route"] == full["results"]["stage.route"] + got = only["results"]["stage.follow"] + assert got["patch"] == full["results"]["stage.follow"] + assert got["patch"]["pipes"] and got["followed"] # 관 따라감 · 따라가기만 한 자리 곁 자료 + + +# ── 화면 — follow 「서버」 끌기 여러 걸음 뒤 놓음 = 화면 셈(검증_sub3 · Playwright · 저장 안 함) ── + +JS_DRAG = r"""async (steps) => { + const urls = performance.getEntriesByType('resource').map((e) => e.name); + const mod = async (re) => import(urls.find((n) => re.test(n))); + const page = await mod(/design_pipeline_page\.ts/); + const pid = localStorage.getItem('frd_current_project_id'); + const plan = await (await mod(/B05_Profile_Api_Replan\.ts/)).fetchRoutePlan(pid); + const flat = (await mod(/B05_Profile_UI_RouteEdit_Edits\.ts/)) + .flattenServerPlan(plan.nodes, plan.curves ?? [], plan.node_edits); + const bridge = page.routeDraftBridge(pid, null); + const at = Math.floor(flat.planned.length / 2); + for (let k = 1; k <= steps; k += 1) { // 끌기 걸음 — 걸음마다 묶음이 돎(잠잠 안 기다림) + const nodes = flat.planned.map(([x, y]) => [x, y]); + nodes[at] = [nodes[at][0] + 2 * k, nodes[at][1] + 1.5 * k]; + bridge.edited({ + nodes, + curveOn: flat.curveOn.map((on) => on !== false), + curveRadius: flat.curveRadius.map((r) => r ?? null), + curveLock: flat.curveLock ?? nodes.map(() => null), + curveArc: flat.curveArc ?? nodes.map(() => null), + stationSnap: plan.station_snap ?? false, + stationSnapStep: plan.station_snap_step ?? null, + }); + await new Promise((r) => setTimeout(r, 60)); + } + await page.pagePipeline(pid).settled(); +}""" + +JS_FOLLOW = r"""async () => { + const urls = performance.getEntriesByType('resource').map((e) => e.name); + const page = await import(urls.find((n) => /design_pipeline_page\.ts/.test(n))); + const pid = localStorage.getItem('frd_current_project_id'); + const pipe = page.pagePipeline(pid); + await page.reconcilePagePipeline(pid); + await pipe.settled(); + const plain = (v) => JSON.parse(JSON.stringify(v ?? null)); + const calls = (window.__aisloCalcServer?.calls ?? []).filter((c) => c.id === 'stage.follow'); + const planline = pipe.output('planline'); + return { + follow: plain(pipe.output('follow')), + inputs: plain(['pipes', 'structures', 'plusStations', 'alignment'] + .map((k) => [k, pipe.input(k)])), + base_pvi: plain(planline?.basePvi), pvi: plain(pipe.output('profile')?.pvi), + calls: calls.map((c) => ({ round: c.roundMs, node: c.nodeMs, bytes: c.bytes })), + runs: (window.__aisloPipeline?.runs ?? []).slice(-1).map((r) => r.outcome), + }; +}""" + + +@pytest.fixture(scope="module") +def drags(): + from test_511_b1_calc_node_stages import _UP, BASE, _started + + if not BASE or _UP is None or _UP != _started(BASE.replace("5178", "8005")): + pytest.skip("이 폴더 서버 없음") + sync = pytest.importorskip("playwright.sync_api") + cred = json.loads((ROOT / "tmp" / "aislo_cred.json").read_text(encoding="utf-8")) + shots = {} + with sync.sync_playwright() as play: + chrome = play.chromium.launch(channel="msedge", headless=True) + try: + for mode in ("both", "server"): + context = chrome.new_context(viewport={"width": 1600, "height": 1000}) + login = {k: cred[k] for k in ("email", "password")} + r = context.request.post( + f"{BASE}/api/auth/login/request", data=login, timeout=60000 + ) + assert r.ok, f"로그인 실패 {r.status}" # 비밀번호는 안 찍음 + if mode == "server": # 판은 안 고침 — 이 창이 받는 판만 follow = server + + def board(route): + body = route.fetch().json() + body["sites"]["follow"] = "server" + route.fulfill(json=body) + + context.route("**/api/dashboard/calc-sites", board) + page = context.new_page() + page.add_init_script( + f"localStorage.setItem('frd_current_project_id', '{VERIFY}');" + " performance.setResourceTimingBufferSize(20000);" + ) + page.goto(f"{BASE}/#/b05-profile", wait_until="domcontentloaded") + page.wait_for_function("() => !!window.__aisloPipeline", timeout=90000) + page.wait_for_timeout(9000) + page.evaluate(JS_DRAG, 4) # 끌기 네 걸음(세션 초안 · 창과 함께 버림) + page.wait_for_timeout(4000) # 잠잠 400 ms · 서버 · 다시 돎 + shots[mode] = page.evaluate(JS_FOLLOW) + context.close() + finally: + chrome.close() + (ROOT / "tmp" / "511b5_drags.json").write_text(json.dumps(shots), encoding="utf-8") + return shots + + +def test_화면_follow_서버_끌기_뒤_화면_셈과_같음(drags): + both, server = drags["both"], drags["server"] + assert both["runs"] == server["runs"] == ["done"] + assert both["calls"] == [] and server["calls"] # server 판만 놓은 뒤 서버 follow + # 계획고 옮김 = 끌기 중 화면 셈이 입력에 옮겨 둔 값 그대로(서버 follow 는 저장분에서 새로 셈 · 옮김 + # 기준(앞 노선 · 앞 첫 계획선) 없음 → patch 에 alignment 안 냄) — 입력 · 종단은 같아야 함 + drop = lambda patch: {k: v for k, v in patch.items() if k != "alignment"} # noqa: E731 + assert drop(server["follow"]) == drop(both["follow"]) + for key in ("inputs", "base_pvi", "pvi"): + assert server[key] == both[key], key diff --git a/ui_template/ui_template_locale_m1.ts b/ui_template/ui_template_locale_m1.ts index 2edb0464d..76e3887dd 100644 --- a/ui_template/ui_template_locale_m1.ts +++ b/ui_template/ui_template_locale_m1.ts @@ -120,6 +120,10 @@ export const ui_locales_m1 = { "끄면 잠잠 뒤 서버 노선(같은 TS) · 노선 [저장] = 서버 Node", "Off = server route after a pause (same TS) · route [Save] = server Node", ], + B01_CalcSites_Why_follow: [ + "끄면 끌기 중 화면 셈 · 놓은 뒤 서버 따라감(같은 TS) · [저장] 따라감 = 서버 Node", + "Off = browser while dragging · server follow after release (same TS) · [Save] follow = server Node", + ], B01_CalcSites_Why_stations: [ "끄면 잠잠 뒤 서버 측점(같은 TS) · [저장] 측점 = 서버 Node · DTM 아닌 지표면은 파이썬", "Off = server stations after a pause (same TS) · [Save] stations = server Node · non-DTM surface = Python", @@ -133,7 +137,6 @@ export const ui_locales_m1 = { "끄면 횡단을 서버 Node 가 셈(잠잠 뒤 · 왕복) · DTM 아닌 지표면 = 브라우저", "Off = server Node calcs cross sections (after a pause · round trip) · non-DTM surface = browser", ], - B01_CalcSites_Why_pair_B5: ["짝 모으는 중 · B-5", "Merging pairs · B-5"], B01_CalcSites_Why_form_default: [ "폼 기본값 도구 — 입력 칸 기본값 · 서버 다시 안 셈(B04 · B08 은 파이썬)", "Form default tool — input default · server never recalcs (B04 · B08 = Python)",