merge(511-B B-5 follow 합치기): origin/sub_laptop_3(follow 줄 엶 · /calc follow 서버 맥락) 받음 — 공유 파일 충돌 풂
- /calc 창구 차례 = 격자 경로 막기 → 단계 · 화면 길 서버 맥락(B-4) → follow 저장 정본 맥락(B-5 · stages 다시 짬) - 까닭 글 = cross 그대로 · pair_B5 걷음(follow 엶) - 시험 511_b1 · 309_2 · 309_2_browser = 잠긴 단계 줄 없음 · 잠금 예시 = form_default · 화면 열린 줄에 P5 P6 F1 F2 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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<PipelineInputs>,
|
||||
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;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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<PipelineInputs>;
|
||||
/** 구조물 정의(서버 창구가 붙임 · 화면 `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<Record<StageName, unknown>> {
|
||||
const none = Object.fromEntries(
|
||||
[...BROWSER_STAGES, ...AFTER_STAGES].map((name) => [name, null]),
|
||||
) as StageContext<"route">["out"];
|
||||
const at = <K extends StageName>(out: StageContext<K>["out"]): StageContext<K> => ({
|
||||
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<StageName, unknown>;
|
||||
}
|
||||
|
||||
/** id → 셈(입구 `RUNNERS` 에 얹음 · `available` 에는 안 넣음) — 바퀴 하나를 id 들이 나눠 씀. */
|
||||
export function stageRunners(inputs: StagesInputs): Record<string, () => Promise<unknown>> {
|
||||
let run: Promise<Record<StageName, unknown>> | null = null;
|
||||
|
||||
@@ -83,6 +83,8 @@ export interface DesignPipeline {
|
||||
value: unknown,
|
||||
part?: RangePart | null,
|
||||
): Promise<PipelineRunRecord> | null;
|
||||
/** 입력 그대로 그 출처부터 다시 셈 — 늦게 온 서버 값(계산 자리 follow 「서버」 · 511-B B-5). */
|
||||
refresh(source: ChangeSource): Promise<PipelineRunRecord> | null;
|
||||
/** `name` = 기록 `work` 칸 이름(알림 받는 쪽 몫 ms · 349-0s). */
|
||||
subscribe(fn: (event: PipelineEvent) => void, name?: string): () => void;
|
||||
output<K extends StageName>(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);
|
||||
|
||||
@@ -184,6 +184,24 @@ const plusAt = new WeakMap<object, FollowingStructure[]>();
|
||||
/** follow 출력마다 따라가기만 한 자리(id → 자리 누가거리들) — planline 이 427 세움에 씀(459). */
|
||||
const followedAt = new WeakMap<object, ReadonlyMap<string, number[]>>();
|
||||
|
||||
/** 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<T extends object>(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 };
|
||||
|
||||
/** 땅 위 자리 — 좌표가 있으면 그대로(따라가기만 한 시설 = 정본 좌표 · 초안 둔 채 열기도) · 없으면 누가거리의
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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`)
|
||||
{
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
"quantity": "both",
|
||||
"carry": "both",
|
||||
"route": "both",
|
||||
"follow": "both",
|
||||
"stations": "both",
|
||||
"profile": "both",
|
||||
"cross": "both",
|
||||
|
||||
@@ -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 == []
|
||||
|
||||
@@ -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 = 체크 꺼짐)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)",
|
||||
|
||||
Reference in New Issue
Block a user