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:
2026-10-22 16:02:25 +09:00
co-authored by Claude Opus 5.5
14 changed files with 493 additions and 59 deletions
+52
View File
@@ -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;
}
+9 -1
View File
@@ -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 };
/** 땅 위 자리 — 좌표가 있으면 그대로(따라가기만 한 시설 = 정본 좌표 · 초안 둔 채 열기도) · 없으면 누가거리의
+7 -1
View File
@@ -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
+4 -3
View File
@@ -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",
+2 -1
View File
@@ -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
+198
View File
@@ -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
+4 -1
View File
@@ -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)",