From 5df40ccc477db66a10f276396ddb5cca474fc807 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 22 Oct 2026 12:37:08 +0900 Subject: [PATCH 1/4] =?UTF-8?q?feat(511-A=20=EA=B3=84=EC=82=B0=20=EC=9E=90?= =?UTF-8?q?=EB=A6=AC=20=EC=84=9C=EB=B2=84=20=EC=B0=BD=EA=B5=AC):=20POST=20?= =?UTF-8?q?/api/projects/{id}/calc=20=E2=80=94=20=ED=94=84=EB=A1=9C?= =?UTF-8?q?=EC=A0=9D=ED=8A=B8=20=EA=B6=8C=ED=95=9C=20=C2=B7=20=EB=B3=B8?= =?UTF-8?q?=EB=AC=B8=20=EC=83=81=ED=95=9C=208=20MB=20=C2=B7=20=EB=8A=98=20?= =?UTF-8?q?=EB=96=A0=20=EC=9E=88=EB=8A=94=20Node=20=ED=95=98=EB=82=98(desi?= =?UTF-8?q?gn=5Fcalc=5Fnode=20--serve=20=C2=B7=20=EC=A3=BD=EC=9D=8C=20?= =?UTF-8?q?=C2=B7=20=EB=AC=B6=EC=9D=8C=20=EB=B0=94=EB=80=9C=20=3D=20?= =?UTF-8?q?=EB=8B=A4=EC=8B=9C=20=EB=9D=84=EC=9B=80=20=C2=B7=201.4=20MB=203?= =?UTF-8?q?3=20ms=20=C2=B7=20=EC=83=88=20Node=20110~170=20ms)=20=C2=B7=20?= =?UTF-8?q?=EC=8B=9C=ED=97=98=20=EA=B8=B8=20id=20=EB=94=B0=EB=A1=9C(TRIAL?= =?UTF-8?q?=5FIDS)=20=C2=B7=20=EB=B8=8C=EB=9D=BC=EC=9A=B0=EC=A0=80=20?= =?UTF-8?q?=EA=B8=B8=20design=5Fcalc=5Fserver(=EC=9E=A0=EC=9E=A0=20400=20m?= =?UTF-8?q?s=20=C2=B7=20=EB=8A=A6=EC=9D=80=20=EB=8B=B5=20=EB=B2=84?= =?UTF-8?q?=EB=A6=BC=20=C2=B7=20=EB=8C=80=EC=A1=B0=20=C2=B7=20=EA=B8=B0?= =?UTF-8?q?=EB=A1=9D=20=5F=5FaisloCalcServer)=20=E2=80=94=20=EC=9A=94?= =?UTF-8?q?=EA=B5=AC=20320-4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- A00_Common/design/design_calc_server.ts | 189 ++++++++++++++++++ A00_Common/design/design_calc_sites.ts | 16 +- A00_Common/design/design_page.ts | 2 +- A00_Common/design/logic/design_calc_node.ts | 61 ++++-- .../design/logic/design_calc_node_trial.ts | 61 ++++++ B06_Section/B06_Section_Router_Calc.py | 59 ++++++ common_util/common_util_calc_runner.py | 97 ++++++++- main.py | 2 + resources/tester/test_309_2_calc_sites.py | 2 +- resources/tester/test_511_a_calc_server.py | 139 +++++++++++++ 10 files changed, 607 insertions(+), 21 deletions(-) create mode 100644 A00_Common/design/design_calc_server.ts create mode 100644 A00_Common/design/logic/design_calc_node_trial.ts create mode 100644 B06_Section/B06_Section_Router_Calc.py create mode 100644 resources/tester/test_511_a_calc_server.py diff --git a/A00_Common/design/design_calc_server.ts b/A00_Common/design/design_calc_server.ts new file mode 100644 index 000000000..3c9ec1639 --- /dev/null +++ b/A00_Common/design/design_calc_server.ts @@ -0,0 +1,189 @@ +/* ============================================================================= + * design_calc_server.ts + * 계산 자리 「서버」 길(PLAN 511-A) — 판(`design_calc_sites.ts`)이 묶음을 server 로 둔 때 조작 중 계산을 서버 + * 창구(`POST /projects/{id}/calc` · `B06_Section_Router_Calc.py`)가 같은 TS 로 셈. 판이 both 면 지금 그대로. + * serverCalc 한 번 묻기(signal 로 끊음 · 왕복 흉내 `delayMs` · 기록) + * afterQuiet 잠잠 뒤 — 같은 칸 새 부름이 앞 것을 끊음(앞 것 = undefined · 늦은 답 버림) + * calcChecked 동기 자리 — 브라우저 값 바로 · server 면 잠잠 뒤 같은 입력 서버 셈 → 대조 · 다르면 apply + * calcFromServer 그리기 자리 — server 면 서버 값만(입력 같은 답 · 없으면 앞 답) · 답 오면 redraw + * 기록 = `window.__aisloCalcServer`(calls · local = 브라우저 ms · delayMs) — 시간 표 · 화면 검증용. + * 조작 중 왕복 = 지침 5장 「네트워크 왕복 금지」의 시험용 예외(판 기본값 both). + * ========================================================================== */ + +import { API_BASE_URL } from "@config/config_frontend"; +import { calcOnServerNow, calcProjectId } from "./design_calc_sites"; +import { plain } from "./logic/design_calc_node_trial"; + +/** 편집이 멈춘 것으로 볼 시간(ms) — 배분 선반입과 같은 값(`B05_Profile_Api_HaulPlan.ts`). */ +export const QUIET_MS = 400; +const LOG_SIZE = 200; +const LOCAL_SIZE = 20; + +export interface CalcServerCall { + key: string; + id: string; + /** 브라우저 셈 ms(같이 셈한 자리만). */ + browserMs: number | null; + /** 보내기 → 답(흉내 지연 포함). */ + roundMs: number; + /** Node 셈 · 파이썬 ↔ Node(창구 답). */ + nodeMs: number | null; + wallMs: number | null; + bytes: number; + /** 브라우저 값 = 서버 값(대조 자리만 · 아니면 null). */ + same: boolean | null; + applied: boolean; +} + +interface CalcServerLog { + calls: CalcServerCall[]; + local: Record; + delayMs: number; +} + +const log = (): CalcServerLog => + ((globalThis as { __aisloCalcServer?: CalcServerLog }).__aisloCalcServer ??= { + calls: [], + local: {}, + delayMs: 0, + }); + +const now = (): number => performance.now(); +const r2 = (value: number): number => Math.round(value * 100) / 100; + +function wait(ms: number, signal?: AbortSignal): Promise { + return new Promise((resolve, reject) => { + if (signal?.aborted) return reject(new DOMException("끊김", "AbortError")); + const timer = setTimeout(resolve, ms); + signal?.addEventListener("abort", () => { + clearTimeout(timer); + reject(new DOMException("끊김", "AbortError")); + }); + }); +} + +const bodyOf = (id: string, args: unknown[]): string => + JSON.stringify({ run: [id], inputs: { args: { [id]: args } } }); + +function record(call: CalcServerCall): void { + const calls = log().calls; + calls.push(call); + if (calls.length > LOG_SIZE) calls.splice(0, calls.length - LOG_SIZE); +} + +/** 서버 계산 한 번 — `{id: 값}` · 실패 = throw · `body` = 이미 굳힌 본문(입력이 뒤에 바뀌어도 그때 값). */ +export async function serverCalc( + key: string, + run: string[], + inputs: Record | string, + signal?: AbortSignal, + extra: Partial = {}, +): Promise> { + const body = typeof inputs === "string" ? inputs : JSON.stringify({ run, inputs }); + const t0 = now(); + if (log().delayMs > 0) await wait(log().delayMs, signal); + const response = await fetch(`${API_BASE_URL}/projects/${calcProjectId()}/calc`, { + method: "POST", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body, + signal, + }); + const data = (await response.json()) as { + results?: Record; + ms?: number; + wall_ms?: number; + message?: string; + }; + if (!response.ok || !data.results) throw new Error(data.message ?? `HTTP ${response.status}`); + record({ + key, + id: run.join("+"), + browserMs: null, + roundMs: r2(now() - t0), + nodeMs: data.ms ?? null, + wallMs: data.wall_ms ?? null, + bytes: body.length, + same: null, + applied: true, + ...extra, + }); + return data.results; +} + +const quiet = new Map(); + +/** 잠잠 뒤 task — 같은 칸 새 부름 · outer 끊김 = 앞 것 끊음(undefined) · 실패도 undefined(경고 한 줄). */ +export async function afterQuiet( + slot: string, + task: (signal: AbortSignal) => Promise, + outer?: AbortSignal, +): Promise { + quiet.get(slot)?.abort(); + const controller = new AbortController(); + quiet.set(slot, controller); + outer?.addEventListener("abort", () => controller.abort()); + try { + await wait(QUIET_MS, controller.signal); + return await task(controller.signal); + } catch (error) { + if (!controller.signal.aborted) console.warn(`[calc-server] ${slot} 실패`, error); + return undefined; + } finally { + if (quiet.get(slot) === controller) quiet.delete(slot); + } +} + +/** 브라우저 셈 + 시간 기록(묶음 판 상관없이) · server 면 잠잠 뒤 서버 대조 · 다르면 apply(서버 값). */ +export function calcChecked( + key: string, + id: string, + fn: (...args: A) => R, + args: A, + apply?: (value: R) => void, +): R { + const t0 = now(); + const value = fn(...args); + const browserMs = r2(now() - t0); + const local = (log().local[id] ??= []); + local.push(browserMs); + if (local.length > LOCAL_SIZE) local.shift(); + if (!calcOnServerNow(key)) return value; + const body = bodyOf(id, args); + const mine = JSON.stringify(plain(value)); + const extra: Partial = { browserMs, same: null, applied: false }; + void afterQuiet(id, (signal) => serverCalc(key, [id], body, signal, extra)).then((results) => { + if (!results) return; + extra.same = JSON.stringify(results[id]) === mine; + if (!extra.same && apply) { + apply(results[id] as R); + extra.applied = true; + } + }); + return value; +} + +const held = new Map(); +const asked = new Map(); + +/** 그리기 자리 서버 값 — 같은 입력 답이면 fresh · 아니면 앞 답(없으면 undefined) + 잠잠 뒤 묻고 redraw. */ +export function calcFromServer( + key: string, + id: string, + args: unknown[], + redraw: () => void, +): { value: R | undefined; fresh: boolean } { + const body = bodyOf(id, args); + const hold = held.get(id); + if (hold?.body === body) return { value: hold.value as R, fresh: true }; + if (asked.get(id) !== body) { + asked.set(id, body); + void afterQuiet(id, (signal) => serverCalc(key, [id], body, signal)).then((results) => { + if (asked.get(id) === body) asked.delete(id); + if (!results) return; + held.set(id, { body, value: results[id] }); + redraw(); + }); + } + return { value: hold?.value as R | undefined, fresh: false }; +} diff --git a/A00_Common/design/design_calc_sites.ts b/A00_Common/design/design_calc_sites.ts index 7ecd81f8b..b1cd2c4dc 100644 --- a/A00_Common/design/design_calc_sites.ts +++ b/A00_Common/design/design_calc_sites.ts @@ -5,6 +5,7 @@ * 「계산 자리」 창(`B01_Dashboard_UI_CalcSites.ts`). 열린 페이지는 다음 열기부터 · 못 받으면 전부 both. * ground_sample(G1) server = ground 단계가 격자 대신 서버 표본(`B05_Profile_Api_TerrainGrid.ts`) * corridor_mesh(D3) server = 3D 미리보기 워커 안 띄움 · 서버 모형만(`B05_Profile_UI_Corridor_Worker_Client.ts`) + * quantity · carry · fixed_browser server = 조작 중 서버 창구(511-A · `design_calc_server.ts`) * ========================================================================== */ import { API_BASE_URL } from "@config/config_frontend"; @@ -43,11 +44,15 @@ export const saveCalcSites = (sites: Record): Promise> | null = null; +let loaded: Record = {}; +let project = ""; -/** 페이지 열 때 — 새로 받음(앞 페이지 판 버림). */ -export function openCalcSites(): Promise> { +/** 페이지 열 때 — 새로 받음(앞 페이지 판 버림) · 서버 창구가 쓸 프로젝트. */ +export function openCalcSites(projectId = project): Promise> { + project = projectId; + loaded = {}; opened = fetchCalcSites().then( - (board) => board.sites, + (board) => (loaded = board.sites), () => ({}), ); return opened; @@ -57,3 +62,8 @@ export function openCalcSites(): Promise> { export async function calcOnServer(key: string): Promise { return (await (opened ?? openCalcSites()))[key] === "server"; } + +/** 동기 자리용 — 받기 전 = both. */ +export const calcOnServerNow = (key: string): boolean => loaded[key] === "server"; + +export const calcProjectId = (): string => project; diff --git a/A00_Common/design/design_page.ts b/A00_Common/design/design_page.ts index 4f0515c18..77b29b225 100644 --- a/A00_Common/design/design_page.ts +++ b/A00_Common/design/design_page.ts @@ -120,7 +120,7 @@ export function renderDesignPage(root: HTMLElement): MountedPage { root.replaceChildren(page); const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY) ?? ""; pinDraftProject(projectId); // 연 프로젝트 고정(388-3) — 다른 탭이 바꿔도 이 탭 초안 칸 그대로 - void openCalcSites(); // 계산 자리 판(504-2) — 열 때 한 번 · 열린 동안 바뀐 판은 다음 열기부터 + void openCalcSites(projectId); // 계산 자리 판(504-2) — 열 때 한 번 · 열린 동안 바뀐 판은 다음 열기부터 usePipeIds(projectId || null); // 관 세션 id 장부 = 이 프로젝트(323-5 · 카드 · 알약 · 종단 · 3D 같은 id) // 관 길이 · 흙막이 개수 = 공용 캐시(페이지 노선) 한 벌 — 마지막 연 보기의 사본이 아님(416 · B05 = B06) · // 노선 초안 동안 = 계산 줄 새 노선 상세(다시 센 횡단 · 428) diff --git a/A00_Common/design/logic/design_calc_node.ts b/A00_Common/design/logic/design_calc_node.ts index 8c353b06a..de85bb8ab 100644 --- a/A00_Common/design/logic/design_calc_node.ts +++ b/A00_Common/design/logic/design_calc_node.ts @@ -5,6 +5,7 @@ * 브라우저가 쓰는 같은 TS 를 그대로 부름 — 계산을 두 벌로 짜지 않음. * * 실행: node <묶음> <입력.json> <출력.json> (package.json `build:design-calc`) + * node <묶음> --serve 늘 떠 있음 — 표준 입력 한 줄 = 요청 JSON · 표준 출력 한 줄 = 출력 JSON(511-A) * 입력 { run: [id…], inputs: { detail?: 종횡단 상세(API 꼴) · route_points?: 노선 폴리라인 · * context?: { earthwork_conversion · natural_spoil_min_ground_slope · * haul_equipment_limits · 채집석 · 구조물 잔토(B08) } · @@ -17,7 +18,8 @@ * 출력 { results: { [id]: 값 }, available: [id…] } * — 차례는 요청 차례가 아니라 기댐 차례(누가토량 앞에 구조물 면적을 상세에 얹음 · * server-calc 묶음과 같은 차례). - * 끝 코드: 0 성공 / 2 인자 오류 / 4 모르는 id + * — 시험 길 id(`design_calc_node_trial.ts`)도 돌지만 `available` 에는 없음. + * 끝 코드: 0 성공 / 2 인자 오류 / 4 모르는 id(--serve 는 그 줄 { error }) * ========================================================================== */ import { readFileSync, writeFileSync } from "node:fs"; @@ -40,10 +42,11 @@ import { } from "../../../B06_Section/B06_Section_Structure_Layouts"; import { carryRunners, type CarryInputs } from "./design_calc_node_carry"; import { followRunners, type FollowInputs } from "./design_calc_node_follow"; +import { TRIAL_IDS, trialRunners, type TrialInputs } from "./design_calc_node_trial"; type HaulOptions = NonNullable[2]>; -interface CalcInputs extends FollowInputs, CarryInputs { +interface CalcInputs extends FollowInputs, CarryInputs, TrialInputs { detail?: SectionDetailResponse; route_points?: RoutePoint[]; context?: Partial< @@ -141,6 +144,7 @@ function runDesignCalc(run: readonly string[], inputs: CalcInputs) { const RUNNERS: Record unknown> = { ...followRunners(inputs), // 측점 열쇠 · 따라감 · 꼭짓점 세움(472-4 ①) ...carryRunners(inputs), // 노선 바뀐 [저장] 다시 얹기 — 관 · 구조물 초안 · 횡단 고침(472-6 6-B) + ...trialRunners(inputs), // 계산 자리 「서버」 시험 길(511-A) // 구조물 측점 면적 · 관 길이를 상세에 얹음(자리에서) — 뒤 누가토량이 고친 면적 위에서 쌓임. "structure.layouts": () => { const areas = structureAreaRows(sections); @@ -226,21 +230,48 @@ function runDesignCalc(run: readonly string[], inputs: CalcInputs) { const unknown = run.filter((id) => !(id in RUNNERS)); const results: Record = {}; if (!unknown.length) for (const id of run) results[id] = need(id); - return { results, unknown, available: Object.keys(RUNNERS) }; + const available = Object.keys(RUNNERS).filter((id) => !TRIAL_IDS.includes(id)); + return { results, unknown, available }; +} + +type CalcRequest = { run?: string[]; inputs?: CalcInputs }; + +/** 늘 떠 있음(511-A) — 한 줄 받아 한 줄 냄 · 한 줄 실패는 그 줄 { error }(프로세스는 삶). */ +function serve(): void { + let buffer = ""; + process.stdin.setEncoding("utf8"); + process.stdin.on("data", (chunk: string) => { + buffer += chunk; + for (let at = buffer.indexOf("\n"); at >= 0; at = buffer.indexOf("\n")) { + const line = buffer.slice(0, at); + buffer = buffer.slice(at + 1); + let answer: unknown; + try { + const request = JSON.parse(line) as CalcRequest; + const t0 = performance.now(); + const { results, unknown } = runDesignCalc(request.run ?? [], request.inputs ?? {}); + const ms = Math.round((performance.now() - t0) * 100) / 100; + answer = unknown.length ? { error: `모르는 id: ${unknown.join(", ")}` } : { results, ms }; + } catch (error) { + answer = { error: error instanceof Error ? error.message : String(error) }; + } + process.stdout.write(`${JSON.stringify(answer)}\n`); + } + }); + process.stdin.on("end", () => process.exit(0)); } const [inputPath, outputPath] = process.argv.slice(2); -if (!inputPath || !outputPath) { - console.error("사용법: node <묶음> <입력.json> <출력.json>"); +if (inputPath === "--serve") serve(); +else if (!inputPath || !outputPath) { + console.error("사용법: node <묶음> <입력.json> <출력.json> | node <묶음> --serve"); process.exit(2); +} else { + const request = JSON.parse(readFileSync(inputPath, "utf8")) as CalcRequest; + const { results, unknown, available } = runDesignCalc(request.run ?? [], request.inputs ?? {}); + if (unknown.length) { + console.error(`모르는 id: ${unknown.join(", ")}`); + process.exit(4); + } + writeFileSync(outputPath, JSON.stringify({ results, available })); } -const request = JSON.parse(readFileSync(inputPath, "utf8")) as { - run?: string[]; - inputs?: CalcInputs; -}; -const { results, unknown, available } = runDesignCalc(request.run ?? [], request.inputs ?? {}); -if (unknown.length) { - console.error(`모르는 id: ${unknown.join(", ")}`); - process.exit(4); -} -writeFileSync(outputPath, JSON.stringify({ results, available })); diff --git a/A00_Common/design/logic/design_calc_node_trial.ts b/A00_Common/design/logic/design_calc_node_trial.ts new file mode 100644 index 000000000..b2f0f30ca --- /dev/null +++ b/A00_Common/design/logic/design_calc_node_trial.ts @@ -0,0 +1,61 @@ +/* ============================================================================= + * A00_Common/design/logic/design_calc_node_trial.ts + * 계산 자리 「서버」 시험 길(PLAN 511-A) — 조작 중 브라우저가 부르는 함수를 서버 Node 가 **같은 TS 로** 부름. + * 등록부 `node:` 실행기 밖(파이썬 `TRIAL_IDS` 와 같은 표) · 입력 = `inputs.args[id]`(그 함수 인자 그대로 · JSON). + * masshaul.series E1 유토곡선 그리기(`computeMassHaulSeries`) + * stations.carry_keyed R9 측점 열쇠 넘김(`carryKeyedStates` · 읽기 = 칸 이름 → 값 표) + * route.handle R2 · route.station_fit R4 · stations.swap R8 · profile.tools P7 · cross.fit C7 · footprint.plan D4 + * Map · Set 은 목록으로(JSON) — 브라우저 대조도 같은 꼴(`design_calc_server.ts` `plain`). + * ========================================================================== */ + +import { computeMassHaulSeries } from "@util/common_util_mass_haul"; +import { footprintStage } from "../../../B05_Profile/logic/B05_Profile_Footprint_Stage"; +import { fitCurveStations } from "../../../B05_Profile/logic/B05_Profile_RouteEdit_StationFit"; +import { straightenBetween } from "../../../B05_Profile/logic/B05_Profile_UI_Profile_Straighten"; +import { dragHandleTo } from "../../../B05_Profile/logic/B05_Profile_UI_RouteEdit_Curve"; +import { + carryKeyedStates, + type KeyedState, + type Raw, +} from "../../../B06_Section/logic/B06_Section_Route_Carry"; +import { swapCrossSections } from "../../../B06_Section/logic/B06_Section_Station_Swap"; +import { toeOffsets } from "../../../B06_Section/logic/B06_Section_UI_Cross_Fit"; +import type { StageContext } from "./design_pipeline_types"; + +export interface TrialInputs { + args?: Record; +} + +type Call = (...args: never[]) => unknown; + +/** Map · Set → 목록 — JSON 으로 오가는 꼴. */ +export function plain(value: unknown): unknown { + return JSON.parse( + JSON.stringify(value, (_key, item: unknown) => + item instanceof Map ? [...item.entries()] : item instanceof Set ? [...item] : item, + ), + ); +} + +const TRIAL: Record = { + "masshaul.series": computeMassHaulSeries as Call, + "stations.carry_keyed": ((states: Record, from, to) => + carryKeyedStates((name: KeyedState) => states[name] ?? {}, from, to)) as Call, + "route.handle": dragHandleTo as Call, + "route.station_fit": fitCurveStations as Call, + "stations.swap": swapCrossSections as Call, + "profile.tools": straightenBetween as Call, + "cross.fit": toeOffsets as Call, + // 발자국 단계 — 앞 결과 없이 전 구간(브라우저 범위 밖 재사용과 같은 값) + "footprint.plan": ((out: StageContext<"footprint">["out"]) => + footprintStage({ out, range: null, prev: null } as StageContext<"footprint">)) as Call, +}; + +export const TRIAL_IDS = Object.keys(TRIAL); + +/** id → 셈(입구 `RUNNERS` 에 얹음 · `available` 에는 안 넣음). */ +export function trialRunners(inputs: TrialInputs): Record unknown> { + return Object.fromEntries( + TRIAL_IDS.map((id) => [id, () => plain(TRIAL[id](...((inputs.args?.[id] ?? []) as never[])))]), + ); +} diff --git a/B06_Section/B06_Section_Router_Calc.py b/B06_Section/B06_Section_Router_Calc.py new file mode 100644 index 000000000..b7aa5fd68 --- /dev/null +++ b/B06_Section/B06_Section_Router_Calc.py @@ -0,0 +1,59 @@ +"""계산 자리 「서버」 창구(PLAN 511-A · 요구 320-4) — 조작 중 계산을 서버가 같은 TS 로. + + POST /api/projects/{id}/calc {run: [id…], inputs: {…}} → {results, ms, wall_ms} + +브라우저가 계산 자리 판(`calc_sites.json`)에서 묶음을 「서버」로 둔 때만 부름 +(`design_calc_server.ts`). +권한 = 프로젝트 접근(`protected_with_company`) · 호출량 = `main.py` 미들웨어 · 본문 상한 `MAX_BODY`. +실행 = 늘 떠 있는 Node 하나(`common_util_calc_runner.run_calc_served`) · +정본 저장 길은 이 창구를 안 씀. +""" + +import asyncio +import json +from uuid import UUID + +from fastapi import APIRouter, Request +from fastapi.responses import JSONResponse + +from common_util.common_util_calc_runner import run_calc_served + +router = APIRouter(prefix="/api/projects", tags=["B06 Calc Server"]) + +#: 본문 상한 — 종횡단 상세 한 벌(1.3~1.5 MB) 여유 · 넘으면 413. +MAX_BODY = 8 * 1024 * 1024 + + +def _error(status: int, message: str) -> JSONResponse: + return JSONResponse(status_code=status, content={"status": "error", "message": message}) + + +@router.post("/{project_id}/calc", response_model=None) +async def run_calc_endpoint(project_id: UUID, request: Request) -> dict | JSONResponse: + declared = request.headers.get("content-length") + if declared and declared.isdigit() and int(declared) > MAX_BODY: + return _error(413, "계산 입력이 너무 큼") + body = b"" + async for chunk in request.stream(): + body += chunk + if len(body) > MAX_BODY: + return _error(413, "계산 입력이 너무 큼") + try: + payload = json.loads(body) + except ValueError: + return _error(400, "JSON 아님") + run = payload.get("run") if isinstance(payload, dict) else None + inputs = payload.get("inputs", {}) if isinstance(payload, dict) else None + if not isinstance(run, list) or not all(isinstance(i, str) for i in run): + return _error(400, "run = id 목록") + if not isinstance(inputs, dict): + return _error(400, "inputs = 객체") + try: + answer = await asyncio.to_thread(run_calc_served, run, inputs) + except ValueError as exc: + return _error(400, str(exc)) + if answer is None: + return _error(503, "서버 계산을 못 돌림") + if "error" in answer: + return _error(422, str(answer["error"])) + return {"status": "success", "project_id": str(project_id), **answer} diff --git a/common_util/common_util_calc_runner.py b/common_util/common_util_calc_runner.py index 4c11b847a..e4450fc7f 100644 --- a/common_util/common_util_calc_runner.py +++ b/common_util/common_util_calc_runner.py @@ -4,15 +4,28 @@ (`build:design-calc`). 부름은 `common_util_node_bundle.run_bundle_json` 한 길 · 계산 없음. `NODE_IDS` = 등록부에서 실행기가 `node:` 인 줄(같은지 시험 `test_472_3_calc_runner.py`). 입력 꼴 = 입구 머리(`detail` · `route_points` · `context` · `excavation`). +`run_calc_served` = 계산 자리 「서버」 창구(511-A · `B06_Section_Router_Calc`) — +늘 떠 있는 Node 하나(`--serve` · 한 줄 요청 · 한 줄 답) · 죽음 · 묶음 다시 지음 = 다시 띄움 · +시험 길 `TRIAL_IDS` 도 받음. """ from __future__ import annotations +import atexit +import json +import subprocess +import threading +import time from collections.abc import Sequence from pathlib import Path from typing import Any -from common_util.common_util_node_bundle import run_bundle_json +from common_util.common_util_node_bundle import ( + build_bundle, + bundle_stale, + node_env, + run_bundle_json, +) ROOT = Path(__file__).resolve().parents[1] BUNDLE = ROOT / "config" / "design_calc_node" / "design_calc_node.js" @@ -33,6 +46,20 @@ NODE_IDS = frozenset( "stations.carry", } ) +# 시험 길(`design_calc_node_trial.ts` `TRIAL` 표와 같음) — 조작 중 브라우저 함수를 서버가 같은 TS 로 +TRIAL_IDS = frozenset( + { + "masshaul.series", + "stations.carry_keyed", + "route.handle", + "route.station_fit", + "stations.swap", + "profile.tools", + "cross.fit", + "footprint.plan", + } +) +SERVE_TIMEOUT_S = 60 def run_calc(ids: Sequence[str], inputs: dict[str, Any]) -> dict[str, Any] | None: @@ -42,3 +69,71 @@ def run_calc(ids: Sequence[str], inputs: dict[str, Any]) -> dict[str, Any] | Non raise ValueError(f"Node 실행기 아닌 id: {', '.join(unknown)}") output = run_bundle_json(BUNDLE, NPM_SCRIPT, {"run": list(ids), "inputs": inputs}) return output.get("results") if isinstance(output, dict) else None + + +class _Served: + """늘 떠 있는 Node 하나 — 한 번에 한 요청(잠금) · 답 없음 · 죽음 = 끄고 다음 부름에 다시.""" + + def __init__(self) -> None: + self.lock = threading.Lock() + self.proc: subprocess.Popen[str] | None = None + self.stamp = 0 + + def _start(self) -> subprocess.Popen[str]: + self.stop() + self.proc = subprocess.Popen( # noqa: S603 — 고정 실행 파일 · 입력은 표준 입력 한 줄 + ["node", str(BUNDLE), "--serve"], + cwd=str(ROOT), + env=node_env(), + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + text=True, + encoding="utf-8", + ) + self.stamp = BUNDLE.stat().st_mtime_ns + return self.proc + + def stop(self) -> None: + if self.proc and self.proc.poll() is None: + self.proc.kill() + self.proc = None + + def call(self, payload: dict[str, Any]) -> dict[str, Any] | None: + line = json.dumps(payload, default=float) + "\n" + with self.lock: + if bundle_stale(BUNDLE) and not build_bundle(NPM_SCRIPT): + return None + for _ in range(2): # 죽은 프로세스에 쓴 첫 부름 = 다시 띄워 한 번 더 + proc = self.proc + if not proc or proc.poll() is not None or BUNDLE.stat().st_mtime_ns != self.stamp: + proc = self._start() + timer = threading.Timer(SERVE_TIMEOUT_S, proc.kill) + timer.start() + try: + proc.stdin.write(line) # type: ignore[union-attr] + proc.stdin.flush() # type: ignore[union-attr] + answer = proc.stdout.readline() # type: ignore[union-attr] + except OSError: + answer = "" + finally: + timer.cancel() + if answer: + return json.loads(answer) + self.stop() + return None + + +_SERVED = _Served() +atexit.register(_SERVED.stop) + + +def run_calc_served(ids: Sequence[str], inputs: dict[str, Any]) -> dict[str, Any] | None: + """창구 길 — `{results, ms(Node 셈), wall_ms(파이썬 ↔ Node)}` · `{error}` · 못 돌림 None.""" + unknown = [item for item in ids if item not in NODE_IDS | TRIAL_IDS] + if unknown: + raise ValueError(f"서버 계산 id 아님: {', '.join(unknown)}") + t0 = time.perf_counter() + answer = _SERVED.call({"run": list(ids), "inputs": inputs}) + if answer is not None: + answer["wall_ms"] = round((time.perf_counter() - t0) * 1000, 2) + return answer diff --git a/main.py b/main.py index 3740dd697..d9042e54b 100644 --- a/main.py +++ b/main.py @@ -59,6 +59,7 @@ from B06_Section.B06_Section_Router_HaulPlan import ( router as b06_section_haul_plan_router, ) from B06_Section.B06_Section_Router_CrossTable import router as b06_section_cross_table_router +from B06_Section.B06_Section_Router_Calc import router as b06_calc_router from B07_DesignDetail.B07_DesignDetail_Router import router as b07_design_router from B07_DesignDetail.B07_DesignDetail_Router_Frame import router as b07_frame_router from B07_DesignDetail.B07_DesignDetail_Router_PlanKinds import router as b07_plan_kinds_router @@ -477,6 +478,7 @@ app.include_router(b06_design_save_router, dependencies=protected_with_company) app.include_router(b06_section_confirm_router, dependencies=protected_with_company) app.include_router(b06_section_haul_plan_router, dependencies=protected_with_company) app.include_router(b06_section_cross_table_router, dependencies=protected_with_company) +app.include_router(b06_calc_router, dependencies=protected_with_company) app.include_router(b07_design_router, dependencies=protected_with_company) app.include_router(b07_frame_router, dependencies=protected_with_company) app.include_router(b07_plan_kinds_router, dependencies=protected_with_company) diff --git a/resources/tester/test_309_2_calc_sites.py b/resources/tester/test_309_2_calc_sites.py index b835aaa8e..f1cfad168 100644 --- a/resources/tester/test_309_2_calc_sites.py +++ b/resources/tester/test_309_2_calc_sites.py @@ -237,7 +237,7 @@ def test_연결_자리_cross_격자_그대로(): assert body.index('if (await calcOnServer("corridor_mesh")) return null;') < body.index( "getWorker()" ) - assert "void openCalcSites();" in _text("A00_Common/design/design_page.ts") + assert "void openCalcSites(projectId);" in _text("A00_Common/design/design_page.ts") # 저장 길은 판을 안 읽음 for rel in ("A00_Common/design/design_save.ts", "A00_Common/design/design_save_api.ts"): assert "calc_sites" not in _text(rel) and "calcOnServer" not in _text(rel) diff --git a/resources/tester/test_511_a_calc_server.py b/resources/tester/test_511_a_calc_server.py new file mode 100644 index 000000000..3054674f7 --- /dev/null +++ b/resources/tester/test_511_a_calc_server.py @@ -0,0 +1,139 @@ +"""PLAN 511-A (요구 320-4) — 계산 자리 「서버」 창구 `POST /api/projects/{id}/calc` · 떠 있는 Node. + +못박는 것 + - 창구 = 프로젝트 권한(다른 회사 403 · 시스템 관리자 통과) · 본문 상한 413 · 모르는 id · 꼴 400. + - 늘 떠 있는 Node 답 = 파일 길(`run_calc`) 답 · 죽여도 다음 부름에 다시 띄움. + - 시험 길 id(`TRIAL_IDS`) = TS `design_calc_node_trial.ts` 표 · `available` 에 안 섞임. + - 공유 DB 안 씀(가짜 풀) · 실제 마스터 안 씀(고정 자료 golden_472). + + ./venv/Scripts/python.exe -m pytest resources/tester/test_511_a_calc_server.py -q +""" + +from __future__ import annotations + +import json +import re +import sys +from pathlib import Path + +import pytest +from fastapi import Depends, FastAPI +from fastapi.testclient import TestClient + +ROOT = Path(__file__).resolve().parents[2] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from B06_Section import B06_Section_Router_Calc as calc_router # noqa: E402 +from common_util import common_util_auth as auth # noqa: E402 +from common_util import common_util_calc_runner as runner # noqa: E402 + +FIXTURE = ROOT / "resources" / "tester" / "fixtures" / "golden_472" +TRIAL_TS = ROOT / "A00_Common" / "design" / "logic" / "design_calc_node_trial.ts" +PROJECT = "11111111-2222-3333-4444-555555555555" + +pytestmark = pytest.mark.skipif( + not (ROOT / "config" / "node_modules" / "vite").is_dir(), reason="프론트엔드 의존성 미설치" +) + + +def _fixture(name: str): + return json.loads((FIXTURE / name).read_text(encoding="utf-8")) + + +def _inputs() -> dict: + ctx = _fixture("context.json") + keys = ("earthwork_conversion", "haul_equipment_limits", "natural_spoil_min_ground_slope") + return {"detail": _fixture("detail.json"), "context": {k: ctx[k] for k in keys}} + + +class _Cursor: + async def execute(self, sql, args=None): + self.args = args + + async def fetchone(self): + return (1,) if self.args == (PROJECT,) else None # 회사 1 프로젝트 하나 + + async def __aenter__(self): + return self + + async def __aexit__(self, *exc): + return False + + +class _Conn(_Cursor): + def cursor(self): + return _Cursor() + + +class _Pool: + def acquire(self): + return _Conn() + + +@pytest.fixture +def client(monkeypatch): + monkeypatch.setattr(auth, "get_db_pool", lambda: _Pool()) + app = FastAPI() + app.include_router( + calc_router.router, + dependencies=[Depends(auth.verify_session), Depends(auth.require_project_access)], + ) + session = {"role": "USER", "company_id": 1, "user_id": 7} + app.dependency_overrides[auth.verify_session] = lambda: dict(session) + return TestClient(app), session + + +def test_main_프로젝트_권한으로_붙음(): + main = (ROOT / "main.py").read_text(encoding="utf-8") + assert "app.include_router(b06_calc_router, dependencies=protected_with_company)" in main + assert "note_api_call(session_id)" in main # 호출량 = 미들웨어(/api 전부) + + +def test_권한_다른_회사_403_관리자_통과(client): + http, session = client + body = {"run": ["cross.fit"], "inputs": {"args": {"cross.fit": [{}]}}} + ok = http.post(f"/api/projects/{PROJECT}/calc", json=body) + assert ok.status_code == 200, ok.text + assert ok.json()["results"] == {"cross.fit": {"left": None, "right": None}} + session["company_id"] = 2 + assert http.post(f"/api/projects/{PROJECT}/calc", json=body).status_code == 403 + missing = "00000000-0000-0000-0000-000000000000" + assert http.post(f"/api/projects/{missing}/calc", json=body).status_code == 404 + session["role"] = "SYSTEM_ADMIN" + assert http.post(f"/api/projects/{PROJECT}/calc", json=body).status_code == 200 + + +def test_본문_상한_꼴_모르는_id(client, monkeypatch): + http, _ = client + url = f"/api/projects/{PROJECT}/calc" + monkeypatch.setattr(calc_router, "MAX_BODY", 100) + big = {"run": ["cross.fit"], "inputs": {"pad": "x" * 200}} + assert http.post(url, json=big).status_code == 413 + monkeypatch.setattr(calc_router, "MAX_BODY", 8 * 1024 * 1024) + assert http.post(url, content=b"{nope").status_code == 400 + assert http.post(url, json={"run": "cross.fit"}).status_code == 400 + assert http.post(url, json={"run": ["cross.fit"], "inputs": []}).status_code == 400 + bad = http.post(url, json={"run": ["stations.list"], "inputs": {}}) + assert bad.status_code == 400 and "stations.list" in bad.json()["message"] + + +def test_떠_있는_Node_답_파일_길과_같음_죽어도_다시(): + ids = ["structure.layouts", "masshaul.cumulative", "haul.plan", "excavation.trench"] + want = runner.run_calc(ids, _inputs()) + got = runner.run_calc_served(ids, _inputs()) + assert got["results"] == want and want["masshaul.cumulative"]["final_cumulative_m3"] + assert got["ms"] >= 0 and got["wall_ms"] >= got["ms"] + runner._SERVED.proc.kill() + runner._SERVED.proc.wait() + again = runner.run_calc_served(["structure.layouts"], _inputs()) + assert again["results"]["structure.layouts"] == want["structure.layouts"] + + +def test_시험_길_id_TS_표와_같음_available_밖(): + src = TRIAL_TS.read_text(encoding="utf-8") + table = src.split("const TRIAL: Record = {")[1].split("\n};")[0] + assert set(re.findall(r'^ "([a-z_.]+)":', table, re.M)) == runner.TRIAL_IDS + out = runner.run_bundle_json(runner.BUNDLE, runner.NPM_SCRIPT, {"run": [], "inputs": {}}) + assert set(out["available"]) == runner.NODE_IDS + assert not runner.NODE_IDS & runner.TRIAL_IDS From e71efdfeb5679821b23530751e34fe33b0edf8ed Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 22 Oct 2026 12:45:47 +0900 Subject: [PATCH 2/4] =?UTF-8?q?feat(511-A=20=E2=91=A2=20=ED=86=A0=EB=9F=89?= =?UTF-8?q?=20=C2=B7=20=EA=B5=AC=EC=A1=B0=EB=AC=BC=20=EC=85=88=20=EC=97=B4?= =?UTF-8?q?=EA=B8=B0):=20=EA=B3=84=EC=82=B0=20=EC=9E=90=EB=A6=AC=20?= =?UTF-8?q?=ED=8C=90=20quantity=20=C2=B7=20carry=20=C2=B7=20fixed=5Fbrowse?= =?UTF-8?q?r=20=EB=B0=94=EA=BF=80=20=EC=88=98=20=EC=9E=88=EC=9D=8C(?= =?UTF-8?q?=EA=B8=B0=EB=B3=B8=20both=20=C2=B7=20=ED=8C=90=20=EC=A4=84=20?= =?UTF-8?q?=EC=B0=A8=EB=A1=80=20=EA=B3=A0=EC=A0=95)=20=C2=B7=20quantity=20?= =?UTF-8?q?=3D=20server=20=EB=A9=B4=20F7=20=EC=A0=80=EC=9E=A5=20=EB=85=B8?= =?UTF-8?q?=EC=84=A0=20=EB=A9=B4=EC=A0=81=20=C2=B7=20C4=20=EC=86=8C?= =?UTF-8?q?=EB=8B=A8=20=EC=82=AC=EB=B3=B8=20=3D=20=EC=9E=A0=EC=9E=A0=20?= =?UTF-8?q?=EB=92=A4=20=EC=84=9C=EB=B2=84=20=EA=B0=92=EB=A7=8C=20=C2=B7=20?= =?UTF-8?q?E1=20=EC=9C=A0=ED=86=A0=EA=B3=A1=EC=84=A0=20=3D=20=EC=84=9C?= =?UTF-8?q?=EB=B2=84=20=EA=B0=92=EC=9C=BC=EB=A1=9C=20=EB=8B=A4=EC=8B=9C=20?= =?UTF-8?q?=EA=B7=B8=EB=A6=BC(=EA=B7=B8=20=EC=82=AC=EC=9D=B4=20=EC=95=9E?= =?UTF-8?q?=20=EA=B3=A1=EC=84=A0=20=C2=B7=20=E3=80=8C=EC=84=9C=EB=B2=84=20?= =?UTF-8?q?=EC=85=88=20=EC=A4=91=E3=80=8D)=20=C2=B7=20F7=20=EC=B4=88?= =?UTF-8?q?=EC=95=88=20=EB=A9=B4=EC=A0=81=20=C2=B7=20C4=20=EC=B4=88?= =?UTF-8?q?=EC=95=88=20=EC=86=8C=EB=8B=A8=20=3D=20=EB=B8=8C=EB=9D=BC?= =?UTF-8?q?=EC=9A=B0=EC=A0=80=20=EA=B0=92=20+=20=EC=84=9C=EB=B2=84=20?= =?UTF-8?q?=EB=8C=80=EC=A1=B0(=EB=8B=A4=EB=A5=B4=EB=A9=B4=20=EC=96=B9?= =?UTF-8?q?=EC=9D=8C)=20=C2=B7=20F8=20=3D=20=EB=B8=8C=EB=9D=BC=EC=9A=B0?= =?UTF-8?q?=EC=A0=80=20=EB=B6=80=EB=A6=84=20=EC=97=86=EC=9D=8C=20=C2=B7=20?= =?UTF-8?q?=EA=B1=B0=EC=9A=B8=20=EC=8B=9C=ED=97=98(=EC=84=9C=EB=B2=84=20?= =?UTF-8?q?=EB=8B=B5=20=3D=20=EB=B8=8C=EB=9D=BC=EC=9A=B0=EC=A0=80=20TS=20?= =?UTF-8?q?=EB=8B=B5)=20=E2=80=94=20=EC=9A=94=EA=B5=AC=20320-4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- A00_Common/design/design_calc_server.ts | 101 +++++++++++++----- A00_Common/design/design_facility_lane.ts | 17 ++- A00_Common/design/design_shared.ts | 14 ++- .../B05_Profile_UI_Profile_MassHaul.ts | 46 +++++--- B06_Section/B06_Section_Cross_Refresh.ts | 21 +++- common_util/common_util_calc_sites.py | 19 ++-- resources/master_template/calc_sites.json | 5 +- resources/tester/helper_511_a_browser.cjs | 90 ++++++++++++++++ resources/tester/helper_511_a_mirror.cjs | 21 ++++ resources/tester/test_309_2_calc_sites.py | 21 ++-- resources/tester/test_511_a_calc_browser.py | 73 +++++++++++++ resources/tester/test_511_a_calc_mirror.py | 94 ++++++++++++++++ ui_template/ui_template_locale_m1.ts | 13 ++- 13 files changed, 467 insertions(+), 68 deletions(-) create mode 100644 resources/tester/helper_511_a_browser.cjs create mode 100644 resources/tester/helper_511_a_mirror.cjs create mode 100644 resources/tester/test_511_a_calc_browser.py create mode 100644 resources/tester/test_511_a_calc_mirror.py diff --git a/A00_Common/design/design_calc_server.ts b/A00_Common/design/design_calc_server.ts index 3c9ec1639..6f6373c50 100644 --- a/A00_Common/design/design_calc_server.ts +++ b/A00_Common/design/design_calc_server.ts @@ -5,6 +5,7 @@ * serverCalc 한 번 묻기(signal 로 끊음 · 왕복 흉내 `delayMs` · 기록) * afterQuiet 잠잠 뒤 — 같은 칸 새 부름이 앞 것을 끊음(앞 것 = undefined · 늦은 답 버림) * calcChecked 동기 자리 — 브라우저 값 바로 · server 면 잠잠 뒤 같은 입력 서버 셈 → 대조 · 다르면 apply + * calcOnServerOnly 비동기 자리 — server 면 서버 값만(잠잠 뒤 · 끊긴 부름 = undefined) * calcFromServer 그리기 자리 — server 면 서버 값만(입력 같은 답 · 없으면 앞 답) · 답 오면 redraw * 기록 = `window.__aisloCalcServer`(calls · local = 브라우저 ms · delayMs) — 시간 표 · 화면 검증용. * 조작 중 왕복 = 지침 5장 「네트워크 왕복 금지」의 시험용 예외(판 기본값 both). @@ -62,9 +63,6 @@ function wait(ms: number, signal?: AbortSignal): Promise { }); } -const bodyOf = (id: string, args: unknown[]): string => - JSON.stringify({ run: [id], inputs: { args: { [id]: args } } }); - function record(call: CalcServerCall): void { const calls = log().calls; calls.push(call); @@ -96,18 +94,21 @@ export async function serverCalc( message?: string; }; if (!response.ok || !data.results) throw new Error(data.message ?? `HTTP ${response.status}`); - record({ - key, - id: run.join("+"), - browserMs: null, - roundMs: r2(now() - t0), - nodeMs: data.ms ?? null, - wallMs: data.wall_ms ?? null, - bytes: body.length, - same: null, - applied: true, - ...extra, - }); + // extra 그 객체로 기록 — 대조 뒤 같음 · apply 를 부른 쪽이 그 자리에서 채움 + record( + Object.assign(extra, { + key, + id: run.join("+"), + browserMs: null, + roundMs: r2(now() - t0), + nodeMs: data.ms ?? null, + wallMs: data.wall_ms ?? null, + bytes: body.length, + same: null, + applied: true, + ...extra, + }), + ); return data.results; } @@ -134,13 +135,38 @@ export async function afterQuiet( } } -/** 브라우저 셈 + 시간 기록(묶음 판 상관없이) · server 면 잠잠 뒤 서버 대조 · 다르면 apply(서버 값). */ +/** 서버 묻는 꼴 — 없으면 시험 길(`inputs.args[id]` = 함수 인자 · 답 = `results[id]`). */ +export interface CalcRequest { + run: string[]; + inputs: Record; + pick: (results: Record) => unknown; +} + +/** F7 구조물 면적 · C4 소단 구간 — 등록부 `node:` 입력 그대로. */ +export const areasRequest = (rows: unknown[]): CalcRequest => ({ + run: ["structure.layouts"], + inputs: { detail: { cross_sections: rows } }, + pick: (results) => (results["structure.layouts"] as { areas: unknown }).areas, +}); +export const bermRequest = (structures: unknown[]): CalcRequest => ({ + run: ["cross.berm_spans"], + inputs: { berm: { structures, chainages: [] } }, + pick: (results) => (results["cross.berm_spans"] as { spans: unknown }).spans, +}); + +const trialRequest = (id: string, args: unknown[]): CalcRequest => ({ + run: [id], + inputs: { args: { [id]: args } }, + pick: (results) => results[id], +}); + +/** 브라우저 셈 + 시간 기록(판 상관없이) · server 면 잠잠 뒤 서버 대조 · 다르면 apply(서버 값). */ export function calcChecked( key: string, id: string, fn: (...args: A) => R, args: A, - apply?: (value: R) => void, + options: { apply?: (value: R) => void; request?: CalcRequest } = {}, ): R { const t0 = now(); const value = fn(...args); @@ -149,20 +175,40 @@ export function calcChecked( local.push(browserMs); if (local.length > LOCAL_SIZE) local.shift(); if (!calcOnServerNow(key)) return value; - const body = bodyOf(id, args); + const request = options.request ?? trialRequest(id, args); + const body = JSON.stringify({ run: request.run, inputs: request.inputs }); const mine = JSON.stringify(plain(value)); const extra: Partial = { browserMs, same: null, applied: false }; - void afterQuiet(id, (signal) => serverCalc(key, [id], body, signal, extra)).then((results) => { - if (!results) return; - extra.same = JSON.stringify(results[id]) === mine; - if (!extra.same && apply) { - apply(results[id] as R); - extra.applied = true; - } - }); + void afterQuiet(id, (signal) => serverCalc(key, request.run, body, signal, extra)).then( + (results) => { + if (!results) return; + const theirs = request.pick(results); + extra.same = JSON.stringify(theirs) === mine; + if (!extra.same && options.apply) { + options.apply(theirs as R); + extra.applied = true; + } + }, + ); return value; } +/** 서버 값만(비동기 자리) — 잠잠 뒤 묻고 고른 값 · 새 부름 · outer 끊김 · 실패 = undefined. */ +export async function calcOnServerOnly( + key: string, + slot: string, + request: CalcRequest, + outer?: AbortSignal, +): Promise { + const body = JSON.stringify({ run: request.run, inputs: request.inputs }); + const results = await afterQuiet( + slot, + (signal) => serverCalc(key, request.run, body, signal), + outer, + ); + return results ? (request.pick(results) as R) : undefined; +} + const held = new Map(); const asked = new Map(); @@ -173,7 +219,8 @@ export function calcFromServer( args: unknown[], redraw: () => void, ): { value: R | undefined; fresh: boolean } { - const body = bodyOf(id, args); + const request = trialRequest(id, args); + const body = JSON.stringify({ run: request.run, inputs: request.inputs }); const hold = held.get(id); if (hold?.body === body) return { value: hold.value as R, fresh: true }; if (asked.get(id) !== body) { diff --git a/A00_Common/design/design_facility_lane.ts b/A00_Common/design/design_facility_lane.ts index f9faa06d1..5c9bf6970 100644 --- a/A00_Common/design/design_facility_lane.ts +++ b/A00_Common/design/design_facility_lane.ts @@ -20,6 +20,8 @@ import { type BermSpan, } from "../../B06_Section/B06_Section_UI_Page_Persist"; import { pipeIdAt, placePipeIds, prunePipeIds, withPipeIds } from "./design_pipe_ids"; +import { calcOnServerNow } from "./design_calc_sites"; +import { bermRequest, calcChecked, calcOnServerOnly } from "./design_calc_server"; import { plannedIrregular } from "./design_structure_status"; import { planStations } from "../../common_util/common_util_structure_station"; import { followPipeReports } from "../../B05_Profile/logic/B05_Profile_Pipeline_Follow"; @@ -155,8 +157,21 @@ export function createFacilityLane( function syncBerm(): void { const routeId = pageRouteId(projectId); if (!structuresKnown || routeId === null) return; + // 계산 자리 quantity = server 면 잠잠 뒤 서버 값만(511-A) · 아니면 브라우저 셈 + if (calcOnServerNow("quantity")) { + void calcOnServerOnly("quantity", "C4.copy", bermRequest(structures)).then( + (next) => next && putBerm(routeId, next), + ); + return; + } + putBerm( + routeId, + calcChecked("quantity", "cross.berm_spans", bermSpansFromStructures, [structures]), + ); + } + + function putBerm(routeId: number, next: BermSpan[]): void { const copy = readState("berm", projectId, routeId); - const next = bermSpansFromStructures(structures); if (Array.isArray(copy) && JSON.stringify(copy) === JSON.stringify(next)) return; writeBermSpans(projectId, routeId, next); void Promise.all([import("./design_pipeline_cross"), import("./design_pipeline_page")]) diff --git a/A00_Common/design/design_shared.ts b/A00_Common/design/design_shared.ts index 2e538b2f0..904ec427d 100644 --- a/A00_Common/design/design_shared.ts +++ b/A00_Common/design/design_shared.ts @@ -59,6 +59,7 @@ import { type CrossRefreshInput, } from "../../B06_Section/B06_Section_Cross_Refresh"; import { bermSpansFromStructures, bermSpecAt } from "../../B06_Section/B06_Section_UI_Page_Persist"; +import { areasRequest, bermRequest, calcChecked } from "./design_calc_server"; import { registeredKeys, stationIntervalOf } from "../../B06_Section/B06_Section_Station_Keys"; import { detailCross } from "../../B05_Profile/logic/B05_Profile_Pipeline_Detail"; import type { CrossOutput, PipelineStation } from "./logic/design_pipeline_types"; @@ -254,7 +255,11 @@ function startPagePipeline(shared: DesignPageShared, profile: DesignProfile): vo onUnstable: () => showToast(L("Design_Pipeline_Unstable"), "warning"), // 초안 소단 = 지금 구조물(따라간 자리)의 소단 구간(365-5 · 저장 노선은 다시 셈 창구가 세션 사본으로) bermAt: (structures) => { - const spans = bermSpansFromStructures(Array.isArray(structures) ? structures : []); + const list = Array.isArray(structures) ? structures : []; + // 계산 자리 quantity = server 면 잠잠 뒤 서버 대조만(초안 미리보기 · 511-A) + const spans = calcChecked("quantity", "cross.berm_spans", bermSpansFromStructures, [list], { + request: bermRequest(list), + }); return (chainageM) => bermSpecAt(spans, chainageM); }, // 저장 노선 횡단 = 다시 셈 창구 한 벌(365-4) — 카드 · 유토곡선 · [저장] 패치가 보는 상세 설계를 늘 고치고 그 @@ -277,7 +282,12 @@ function startPagePipeline(shared: DesignPageShared, profile: DesignProfile): vo const routeId = routeIdOf(shared); if (routeId !== null) withDraftAdjusts(detail, projectId, routeId); const rows = detail.cross_sections; - applyStructureAreaRows(rows, structureAreaRows(rows)); + // 계산 자리 quantity = server 면 잠잠 뒤 서버 값 대조 · 다르면 얹고 다시 그림(511-A) + const areas = calcChecked("quantity", "structure.layouts", structureAreaRows, [rows], { + request: areasRequest(rows), + apply: (server) => (applyStructureAreaRows(rows, server), profile.redraw()), + }); + applyStructureAreaRows(rows, areas); }, // 초안 시작 — 초안 전 상태를 떠 둠 · 버리면 그대로(측점이 달라도 · 349-0o) · 상세가 없으면 묶음 측점으로 갈아끼움 holdDraft: (routeId, saved, seed) => { diff --git a/B05_Profile/B05_Profile_UI_Profile_MassHaul.ts b/B05_Profile/B05_Profile_UI_Profile_MassHaul.ts index 87aa9f811..0e69e5259 100644 --- a/B05_Profile/B05_Profile_UI_Profile_MassHaul.ts +++ b/B05_Profile/B05_Profile_UI_Profile_MassHaul.ts @@ -52,6 +52,8 @@ import { createPanelResizer } from "@ui/ui_template_resizer"; import "@util/common_util_mass_haul.css"; import "./B05_Profile_UI_Style_MassHaul.css"; import { frameOf } from "../common_util/common_util_view_window"; +import { calcOnServerNow } from "../A00_Common/design/design_calc_sites"; +import { calcChecked, calcFromServer } from "../A00_Common/design/design_calc_server"; /** 2차 패널 펼침 여부 — 세션 동안만 유지(패널 높이·접힘과 같은 수명). */ /* 펼침 상태·높이·범례는 화면 취향(①) — 키는 등록표(`b_page_state`)가 만든다. */ @@ -317,15 +319,30 @@ export function createRouteMassHaulPanel( function reportSummary(params: RouteMassHaulDrawParams): void { if (!onSummary) return; if (!context || params.pendingRecalc) return; - emitSummary( - computeMassHaulSeries( - params.longitudinal, - params.crossSections, - context.conversion, - context.naturalSpoilMinSlope, - ["cross"], - ), + const series = crossSeries(params, context).series; + if (series) emitSummary(series); + } + + /** 횡단 기준 누가토량 — 계산 자리 quantity = server 면 서버 값(잠잠 뒤 · 그 사이 앞 값 · 511-A). */ + function crossSeries( + params: RouteMassHaulDrawParams, + ctx: RouteMassHaulContext, + ): { series: MassHaulSeries[] | undefined; fresh: boolean } { + const args: Parameters = [ + params.longitudinal, + params.crossSections, + ctx.conversion, + ctx.naturalSpoilMinSlope, + ["cross"], + ]; + if (!calcOnServerNow("quantity")) { + const series = calcChecked("quantity", "masshaul.series", computeMassHaulSeries, args); + return { series, fresh: true }; + } + const got = calcFromServer("quantity", "masshaul.series", args, () => + draw(params), ); + return { series: got.value, fresh: got.fresh }; } /** 그리기 몫 기록(349-0s) — 묶음 마지막 차례 `work.유토곡선`. */ @@ -371,13 +388,12 @@ export function createRouteMassHaulPanel( // (354-2 · 계산 코드는 공용에 남김). // 결과를 아껴 두지 **않는다** — 횡단 설계가 제자리에서 갈리므로 객체 동일성으로는 // 바뀐 것을 못 잰다(2026-09-04 캐시를 넣었다가 곡선이 안 따라와 되돌림). - const series: MassHaulSeries[] = computeMassHaulSeries( - params.longitudinal, - params.crossSections, - context.conversion, - context.naturalSpoilMinSlope, - ["cross"], - ); + const { series, fresh } = crossSeries(params, context); + if (!fresh) { + pendingChip.textContent = "서버 셈 중…"; + pendingChip.hidden = !drawnOnce; + } + if (!series) return note("서버가 유토곡선을 셈하는 중입니다."); // 총괄값은 방금 낸 계열에서 꺼낸다 — 접힘 경로처럼 따로 적분하지 않는다. if (series !== lastSeries) emitSummary(series); lastSeries = series; diff --git a/B06_Section/B06_Section_Cross_Refresh.ts b/B06_Section/B06_Section_Cross_Refresh.ts index e7c0b586d..c8659360c 100644 --- a/B06_Section/B06_Section_Cross_Refresh.ts +++ b/B06_Section/B06_Section_Cross_Refresh.ts @@ -66,6 +66,12 @@ import { readAlignment, toDesignProfile } from "../B05_Profile/logic/B05_Profile import { useStationKeys } from "./B06_Section_Station_Keys"; import { hasStaleDesigns, staleKey } from "./B06_Section_UI_Section_Common"; import { applyStructureAreaRows, structureAreaRows } from "./B06_Section_Structure_Layouts"; +import { calcOnServerNow } from "../A00_Common/design/design_calc_sites"; +import { + areasRequest, + calcChecked, + calcOnServerOnly, +} from "../A00_Common/design/design_calc_server"; import { withPipeOptions } from "./B06_Section_Section_Adjusts"; import { extendUnclosed } from "../B05_Profile/logic/B05_Profile_Sections_Ground"; import { designTerrainGridNow } from "../B05_Profile/B05_Profile_Api_TerrainGrid"; @@ -173,10 +179,17 @@ export async function refreshCrossDesigns(input: CrossRefreshInput): Promise>( + "quantity", + "F7.saved", + areasRequest(input.detail.cross_sections), + ) + : calcChecked("quantity", "structure.layouts", structureAreaRows, [ + input.detail.cross_sections, + ]); + if (rows) applyStructureAreaRows(input.detail.cross_sections, rows); return sections .filter((section) => JSON.stringify(section.design) !== before.get(section)) .map((section) => section.chainage_m); diff --git a/common_util/common_util_calc_sites.py b/common_util/common_util_calc_sites.py index cc84fe57d..4deec5a27 100644 --- a/common_util/common_util_calc_sites.py +++ b/common_util/common_util_calc_sites.py @@ -1,7 +1,8 @@ """계산 자리 판(PLAN 504-2 · 요구 309-2) — 시스템 관리자 「계산 자리」 창이 읽고 쓰는 판 하나. 판 = `resources/master_template/calc_sites.json` `{version, sites: {묶음: "both" | "server"}}`. -both = 조작 중 브라우저 셈 + [저장] 서버 정본(지금 등록부 자리) · server = 조작 중에도 서버 길. +both = 조작 중 브라우저 셈 + [저장] 서버 정본(지금 등록부 자리) · server = 조작 중에도 서버 길 +(G1 · D3 = 504-2 · ③ 토량 · ④ 이월 · ⑧ 고정 브라우저(시험용 · fixed 는 정본 칸 표시) = 511-A 창구). 판이 없거나 깨지면 기본값(전부 both = 등록부 그대로). [저장] · [확정] 서버 정본 길은 판을 안 읽음. 묶음 8 줄 = 등록부(`design_calc_registry*.ts`) # 전부 · 같은지 시험 `test_309_2_calc_sites.py`. """ @@ -24,11 +25,11 @@ GROUPS: tuple[dict[str, Any], ...] = ( { "key": "quantity", "nos": ["E1", "F7", "F8", "C4"], - "switchable": False, - "fixed": "both", - "why": "later", + "switchable": True, + "fixed": None, + "why": "quantity", }, - {"key": "carry", "nos": ["R9", "F10"], "switchable": False, "fixed": "both", "why": "later"}, + {"key": "carry", "nos": ["R9", "F10"], "switchable": True, "fixed": None, "why": "carry"}, { "key": "stages", "nos": ["R1", "R3", "R5", "R6", "R7", "G2", "G4", "P1", "P2", "P3", "P4", "P5", "P6"] @@ -54,12 +55,14 @@ GROUPS: tuple[dict[str, Any], ...] = ( { "key": "fixed_browser", "nos": ["R2", "R4", "R8", "P7", "C7", "D4"], - "switchable": False, + "switchable": True, "fixed": "browser", "why": "browser", }, ) -SWITCHABLE = frozenset(g["key"] for g in GROUPS if g["switchable"]) +# 판 줄 차례 = 묶음 차례(파일 늘 같은 꼴) +ORDER = tuple(g["key"] for g in GROUPS if g["switchable"]) +SWITCHABLE = frozenset(ORDER) LOCKED = frozenset(g["key"] for g in GROUPS if not g["switchable"]) @@ -71,7 +74,7 @@ def read_sites() -> dict[str, Any]: raw = {} stored = raw.get("sites") if isinstance(raw, dict) else None stored = stored if isinstance(stored, dict) else {} - sites = {key: stored.get(key) if stored.get(key) in VALUES else "both" for key in SWITCHABLE} + sites = {k: stored.get(k) if stored.get(k) in VALUES else "both" for k in ORDER} version = raw.get("version") if isinstance(raw, dict) else None return {"version": version if isinstance(version, int) else 0, "sites": sites} diff --git a/resources/master_template/calc_sites.json b/resources/master_template/calc_sites.json index f88037ca8..c0ad2f7a9 100644 --- a/resources/master_template/calc_sites.json +++ b/resources/master_template/calc_sites.json @@ -2,6 +2,9 @@ "version": 1, "sites": { "ground_sample": "both", - "corridor_mesh": "both" + "corridor_mesh": "both", + "quantity": "both", + "carry": "both", + "fixed_browser": "both" } } diff --git a/resources/tester/helper_511_a_browser.cjs b/resources/tester/helper_511_a_browser.cjs new file mode 100644 index 000000000..6b8b71fb9 --- /dev/null +++ b/resources/tester/helper_511_a_browser.cjs @@ -0,0 +1,90 @@ +/* PLAN 511-A 브라우저 길 헬퍼 — `design_calc_server.ts` 를 그 자리에서 트랜스파일 · 판(`design_calc_sites`)과 + * fetch 를 가짜로 끼워 판 따라 길 고름 · 잠잠 · 끊음 · 대조 · 기록을 잼. 결과 = 표준출력 JSON 한 벌. + * 짝 = `test_511_a_calc_browser.py`. */ +const path = require("path"); +const { makeLoader } = require("./helper_ts_stub_loader.cjs"); + +const ROOT = path.join(__dirname, "..", ".."); +const board = {}; +const fetched = []; +let answer = (body) => ({ [body.run[0]]: body.inputs.args?.[body.run[0]]?.[0] ?? null }); +globalThis.fetch = async (url, init) => { + const body = JSON.parse(init.body); + fetched.push({ url, run: body.run, at: Date.now() }); + if (init.signal?.aborted) throw new DOMException("끊김", "AbortError"); + return { + ok: true, + status: 200, + json: async () => ({ results: answer(body), ms: 1, wall_ms: 2 }), + }; +}; +const plain = (value) => JSON.parse(JSON.stringify(value)); +const { loadTs } = makeLoader({ + root: ROOT, + stubs: { + design_calc_sites: { + calcOnServerNow: (key) => board[key] === "server", + calcProjectId: () => "p1", + }, + design_calc_node_trial: { plain }, + }, +}); +const mod = loadTs(path.join(ROOT, "A00_Common", "design", "design_calc_server.ts")); +const tick = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); +const echo = (value) => value; + +(async () => { + const out = {}; + // 1 판 both — 브라우저 값 · 서버 부름 0 · 브라우저 ms 기록 + out.both = mod.calcChecked("quantity", "cross.fit", echo, [5]); + await tick(500); + out.bothFetched = fetched.length; + out.local = globalThis.__aisloCalcServer.local["cross.fit"].length; + + // 2 판 server — 브라우저 값 바로 · 잠잠 뒤 한 번(빠른 둘째 부름이 첫째를 끊음) · 같음 + board.quantity = "server"; + let applied = 0; + mod.calcChecked("quantity", "cross.fit", echo, [6], { apply: () => (applied += 1) }); + out.server = mod.calcChecked("quantity", "cross.fit", echo, [7], { apply: () => (applied += 1) }); + out.beforeQuiet = fetched.length; + await tick(500); + out.afterQuiet = fetched.length; + const calls = globalThis.__aisloCalcServer.calls; + out.same = calls[calls.length - 1].same; + out.applied = applied; + + // 3 서버 값이 다름 — apply(서버 값) + answer = () => ({ "cross.fit": 99 }); + let got = null; + mod.calcChecked("quantity", "cross.fit", echo, [8], { apply: (v) => (got = v) }); + await tick(500); + out.diffApplied = got; + out.diffSame = calls[calls.length - 1].same; + + // 4 그리기 자리 — 처음 undefined · 답 오면 redraw · 같은 입력 = fresh + answer = (body) => ({ "masshaul.series": ["s", body.inputs.args["masshaul.series"][0]] }); + let redraws = 0; + const first = mod.calcFromServer("quantity", "masshaul.series", [1], () => (redraws += 1)); + await tick(500); + const second = mod.calcFromServer("quantity", "masshaul.series", [1], () => (redraws += 1)); + const third = mod.calcFromServer("quantity", "masshaul.series", [2], () => (redraws += 1)); + out.draw = { first, second, third, redraws }; + await tick(500); + + // 5 비동기 자리 — outer 끊음 = undefined · 부름 0 + const before = fetched.length; + const outer = new AbortController(); + const pending = mod.calcOnServerOnly("quantity", "slot", mod.areasRequest([]), outer.signal); + outer.abort(); + out.aborted = (await pending) === undefined && fetched.length === before; + + // 6 왕복 흉내 — delayMs 만큼 늦음 + globalThis.__aisloCalcServer.delayMs = 80; + answer = () => ({ "structure.layouts": { areas: [1] } }); + const t0 = Date.now(); + out.onlyValue = await mod.calcOnServerOnly("quantity", "slot2", mod.areasRequest([])); + out.delayed = Date.now() - t0 >= 400 + 80; + out.roundMs = calls[calls.length - 1].roundMs; + out.url = fetched[fetched.length - 1].url; + process.stdout.write(JSON.stringify(out)); +})(); diff --git a/resources/tester/helper_511_a_mirror.cjs b/resources/tester/helper_511_a_mirror.cjs new file mode 100644 index 000000000..a855da9fc --- /dev/null +++ b/resources/tester/helper_511_a_mirror.cjs @@ -0,0 +1,21 @@ +/* PLAN 511-A 거울 헬퍼 — 브라우저 자리가 부르는 TS 함수를 그 자리에서 트랜스파일해 Node 로 바로 부름. + * 입력 = argv[2] JSON `[{ mod: 뿌리 기준 경로, fn: export 이름, args: [...] }]` · 출력 = 표준출력 JSON 목록 + * (Map · Set → 목록 = 서버 `plain` 과 같은 꼴). 짝 = `test_511_a_calc_server.py`(서버 창구 답과 견줌). */ +const fs = require("fs"); +const path = require("path"); +const { makeLoader, memoryStorage } = require("./helper_ts_stub_loader.cjs"); + +const ROOT = path.join(__dirname, "..", ".."); +globalThis.window = { sessionStorage: memoryStorage(), localStorage: memoryStorage() }; +const { loadTs } = makeLoader({ root: ROOT }); + +const plain = (value) => + JSON.parse( + JSON.stringify(value, (_key, item) => + item instanceof Map ? [...item.entries()] : item instanceof Set ? [...item] : item, + ) ?? "null", + ); + +const job = JSON.parse(fs.readFileSync(process.argv[2], "utf8")); +const out = job.map(({ mod, fn, args }) => plain(loadTs(path.join(ROOT, mod))[fn](...args))); +process.stdout.write(JSON.stringify(out)); diff --git a/resources/tester/test_309_2_calc_sites.py b/resources/tester/test_309_2_calc_sites.py index f1cfad168..fdb1f62ce 100644 --- a/resources/tester/test_309_2_calc_sites.py +++ b/resources/tester/test_309_2_calc_sites.py @@ -58,27 +58,33 @@ def test_묶음_여덟_줄_등록부_전부(): nos = [no for group in calc_sites.GROUPS for no in group["nos"]] assert len(calc_sites.GROUPS) == 8 assert len(nos) == len(set(nos)) and set(nos) == set(sites) - assert calc_sites.SWITCHABLE == {"ground_sample", "corridor_mesh"} + assert calc_sites.SWITCHABLE == { + "ground_sample", + "corridor_mesh", + "quantity", + "carry", + "fixed_browser", + } # 511-A 셋 더 for group in calc_sites.GROUPS: got = {sites[no] for no in group["nos"]} - if group["switchable"] or group["fixed"] == "both": - assert got == {"browser_now"}, group["key"] + if group["fixed"] == "browser": + assert got == {"browser_only"}, group["key"] elif group["fixed"] == "server": assert got <= {"server_canon", "server_only"}, group["key"] else: - assert got == {"browser_only"}, group["key"] + assert got == {"browser_now"}, group["key"] by_key = {g["key"]: g["nos"] for g in calc_sites.GROUPS} assert by_key["ground_sample"] == ["G1"] and by_key["corridor_mesh"] == ["D3"] def test_커밋한_판_기본값_없음_깨짐(tmp_path, monkeypatch): committed = json.loads(calc_sites.PATH.read_text(encoding="utf-8")) - assert committed["sites"] == {"ground_sample": "both", "corridor_mesh": "both"} + assert committed["sites"] == dict.fromkeys(calc_sites.SWITCHABLE, "both") assert set(calc_sites.read_sites()["sites"].values()) == {"both"} monkeypatch.setattr(calc_sites, "PATH", tmp_path / "none.json") assert calc_sites.read_sites() == { "version": 0, - "sites": {"ground_sample": "both", "corridor_mesh": "both"}, + "sites": dict.fromkeys(calc_sites.SWITCHABLE, "both"), } broken = tmp_path / "broken.json" broken.write_text('{"sites": {"ground_sample": "server", "corridor_mesh": "x"', "utf-8") @@ -157,7 +163,8 @@ def test_잠금_모르는_줄_400_감사_한_줄(client): done = http.put(url, json={"sites": {"ground_sample": "server"}}) assert done.status_code == 200 and done.json()["changed"] == ["ground_sample"] saved = json.loads(board.read_text("utf-8")) - assert saved == {"version": 2, "sites": {"ground_sample": "server", "corridor_mesh": "both"}} + sites = {**dict.fromkeys(calc_sites.SWITCHABLE, "both"), "ground_sample": "server"} + assert saved == {"version": 2, "sites": sites} inserts = [args for sql, args in log if "system_audit_logs" in sql] assert len(inserts) == 1 assert inserts[0][:5] == (7, "CALC_SITE_CHANGE", "calc_site", None, "ground_sample=server") diff --git a/resources/tester/test_511_a_calc_browser.py b/resources/tester/test_511_a_calc_browser.py new file mode 100644 index 000000000..4c45b0e84 --- /dev/null +++ b/resources/tester/test_511_a_calc_browser.py @@ -0,0 +1,73 @@ +"""PLAN 511-A (요구 320-4) — 브라우저 「서버」 길(`design_calc_server.ts`) · 판 따라 길 · 자리. + +못박는 것 + - 판 both = 브라우저 값 · 서버 부름 0(넣어도 바뀌는 것 0) · server = 잠잠 400 ms 뒤 한 번 + (앞 부름 끊음). + - 대조 = 같으면 그대로 · 다르면 apply(서버 값) · 그리기 자리 = 답 오면 redraw · 같은 입력 = fresh. + - 끊긴 부름 = undefined · 왕복 흉내 delayMs · 창구 주소 = /projects/{id}/calc. + - 자리마다 판 열쇠 · [저장] · [확정] 길은 서버 길을 안 부름. + + ./venv/Scripts/python.exe -m pytest resources/tester/test_511_a_calc_browser.py -q +""" + +from __future__ import annotations + +import json +import shutil +import subprocess +from pathlib import Path + +import pytest + +HERE = Path(__file__).resolve().parent +ROOT = HERE.parents[1] + + +def _text(rel: str) -> str: + return (ROOT / rel).read_text(encoding="utf-8") + + +@pytest.mark.skipif(shutil.which("node") is None, reason="node 없음") +def test_판_따라_길_고름_잠잠_대조_기록(): + proc = subprocess.run( # noqa: S603 — 고정 실행 파일 + ["node", str(HERE / "helper_511_a_browser.cjs")], + cwd=ROOT, + capture_output=True, + text=True, + encoding="utf-8", + timeout=60, + ) + assert proc.returncode == 0, proc.stderr + out = json.loads(proc.stdout) + assert out["both"] == 5 and out["bothFetched"] == 0 and out["local"] == 1 + assert out["server"] == 7 and out["beforeQuiet"] == 0 and out["afterQuiet"] == 1 + assert out["same"] is True and out["applied"] == 0 + assert out["diffApplied"] == 99 and out["diffSame"] is False + draw = out["draw"] + assert draw["first"] == {"fresh": False} and draw["redraws"] == 1 + assert draw["second"] == {"value": ["s", 1], "fresh": True} + assert draw["third"] == {"value": ["s", 1], "fresh": False} # 새 입력 = 앞 답 + 다시 물음 + assert out["aborted"] is True + assert out["onlyValue"] == [1] and out["delayed"] and out["roundMs"] >= 80 + assert out["url"] == "http://fake/projects/p1/calc" # API_BASE_URL(가짜) + 창구 + + +def test_자리마다_판_열쇠_저장_길_안_부름(): + refresh = _text("B06_Section/B06_Section_Cross_Refresh.ts") + assert 'calcOnServerNow("quantity")' in refresh and '"F7.saved"' in refresh + shared = _text("A00_Common/design/design_shared.ts") + assert 'calcChecked("quantity", "structure.layouts"' in shared + assert 'calcChecked("quantity", "cross.berm_spans"' in shared + lane = _text("A00_Common/design/design_facility_lane.ts") + assert '"C4.copy", bermRequest(structures)' in lane + mass = _text("B05_Profile/B05_Profile_UI_Profile_MassHaul.ts") + assert 'calcFromServer("quantity", "masshaul.series"' in mass + assert "openCalcSites(projectId)" in _text("A00_Common/design/design_page.ts") + for rel in ( + "A00_Common/design/design_save.ts", + "A00_Common/design/design_save_api.ts", + "A00_Common/design/design_reset.ts", + ): + assert "design_calc_server" not in _text(rel), rel + page = _text("A00_Common/design/design_pipeline_page.ts") + assert len(page.splitlines()) <= 699 and "design_calc_server" not in page diff --git a/resources/tester/test_511_a_calc_mirror.py b/resources/tester/test_511_a_calc_mirror.py new file mode 100644 index 000000000..066fc5206 --- /dev/null +++ b/resources/tester/test_511_a_calc_mirror.py @@ -0,0 +1,94 @@ +"""PLAN 511-A (요구 320-4) — 묶음마다 서버 창구 답 = 브라우저 자리 TS 답(거울). + +브라우저 쪽 = `helper_511_a_mirror.cjs` 가 그 자리 함수를 바로 부름 · 서버 쪽 = 늘 떠 있는 Node +(`run_calc_served` · 창구와 같은 본문 꼴 — `design_calc_server.ts` `areasRequest` · `bermRequest` · +시험 길 args). +고정 자료 = golden_472 · 272 소단 사례 · 손 자료(공유 DB · 실제 마스터 안 씀). + + ./venv/Scripts/python.exe -m pytest resources/tester/test_511_a_calc_mirror.py -q +""" + +from __future__ import annotations + +import json +import shutil +import subprocess +import sys +from pathlib import Path + +import pytest + +HERE = Path(__file__).resolve().parent +ROOT = HERE.parents[1] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) +if str(HERE) not in sys.path: + sys.path.insert(0, str(HERE)) + +from common_util import common_util_calc_runner as runner # noqa: E402 + +FIXTURE = HERE / "fixtures" / "golden_472" + +pytestmark = pytest.mark.skipif( + shutil.which("node") is None or not (ROOT / "config" / "node_modules" / "vite").is_dir(), + reason="node · 프론트엔드 의존성 없음", +) + + +def _fixture(name: str): + return json.loads((FIXTURE / name).read_text(encoding="utf-8")) + + +def _browser(tmp_path: Path, calls: list[dict]) -> list: + job = tmp_path / "job.json" + job.write_text(json.dumps(calls, ensure_ascii=False), encoding="utf-8") + proc = subprocess.run( # noqa: S603 — 고정 실행 파일 + ["node", str(HERE / "helper_511_a_mirror.cjs"), str(job)], + cwd=ROOT, + capture_output=True, + text=True, + encoding="utf-8", + timeout=120, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +def _served(run: list[str], inputs: dict) -> dict: + answer = runner.run_calc_served(run, inputs) + assert answer and "results" in answer, answer + return answer["results"] + + +def _trial(id_: str, args: list): + return _served([id_], {"args": {id_: args}})[id_] + + +# ── ③ 토량 · 구조물 셈(E1 · F7 · C4 · F8 = 브라우저 부름 없음) ─────────────────────── + + +def test_quantity_F7_C4_E1_서버_브라우저_같음(tmp_path): + from test_272_berm_source import STRUCTURE_CASES + + detail = _fixture("detail.json") + sections = detail["cross_sections"] + conv = _fixture("context.json")["earthwork_conversion"] + long_ = detail["longitudinal"] + calls = [ + {"mod": "B06_Section/B06_Section_Structure_Layouts.ts", "fn": "structureAreaRows", + "args": [sections]}, + {"mod": "common_util/common_util_mass_haul.ts", "fn": "computeMassHaulSeries", + "args": [long_, sections, conv, None, ["cross"]]}, + ] + [ + {"mod": "common_util/common_util_berm_spans.ts", "fn": "bermSpansFromStructures", + "args": [case]} + for case in STRUCTURE_CASES + ] # fmt: skip + browser = _browser(tmp_path, calls) + areas = _served(["structure.layouts"], {"detail": {"cross_sections": sections}}) + assert areas["structure.layouts"]["areas"] == browser[0] and browser[0] + series = _trial("masshaul.series", [long_, sections, conv, None, ["cross"]]) + assert series == browser[1] and series[0]["result"]["final_cumulative_m3"] + for index, case in enumerate(STRUCTURE_CASES): + got = _served(["cross.berm_spans"], {"berm": {"structures": case, "chainages": []}}) + assert got["cross.berm_spans"]["spans"] == browser[2 + index], index diff --git a/ui_template/ui_template_locale_m1.ts b/ui_template/ui_template_locale_m1.ts index 8c93a946c..bc6109fff 100644 --- a/ui_template/ui_template_locale_m1.ts +++ b/ui_template/ui_template_locale_m1.ts @@ -106,7 +106,14 @@ export const ui_locales_m1 = { "끄면 미리보기 안 지음 · [저장] · [확정] 때 서버가 지은 모형만", "Off = no preview · only the model the server built on [Save] · [Confirm]", ], - B01_CalcSites_Why_later: ["서버 계산 창구 뒤(2단계)", "After the server calc endpoint (step 2)"], + B01_CalcSites_Why_quantity: [ + "끄면 서버 창구가 셈(조작 중 왕복 · 느림) · F8 = 브라우저 부름 없음(판만)", + "Off = server endpoint calcs (round trip while editing · slower) · F8 = no browser call", + ], + B01_CalcSites_Why_carry: [ + "끄면 잠잠 뒤 서버 셈 · F10 = 브라우저 부름 없음(판만)", + "Off = server calc after a pause · F10 = no browser call", + ], B01_CalcSites_Why_pair_later: [ "파이썬 짝 섞임 · 짝 모으기(472-4) 뒤", "Mixed Python pairs · after pair merge (472-4)", @@ -120,8 +127,8 @@ export const ui_locales_m1 = { "Server only (GIS · raster · value)", ], B01_CalcSites_Why_browser: [ - "입력 도구 · 그림 — 서버 셈 없음", - "Input tools · drawing — no server calc", + "시험용 — 끄면 서버로 돌림(느림) · P7 · D4 = 서버 값 · 끌기 도구(R2 · R4 · R8 · C7) = 대조만", + "Test only — off = server (slow) · P7 · D4 = server value · drag tools (R2 · R4 · R8 · C7) = compare only", ], B01_CalcSites_Saved: ["계산 자리 저장함", "Calc sites saved"], B01_CalcSites_Leave: ["저장 안 한 계산 자리가 있음", "You have unsaved calc sites"], From 57c36a9d150c52776f3c174e578648af6785875d Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 22 Oct 2026 12:48:15 +0900 Subject: [PATCH 3/4] =?UTF-8?q?feat(511-A=20=E2=91=A3=20=EC=9D=B4=EC=9B=94?= =?UTF-8?q?=20=EC=97=B4=EA=B8=B0):=20carry=20=3D=20server=20=EB=A9=B4=20R9?= =?UTF-8?q?=20=EC=B8=A1=EC=A0=90=20=EC=97=B4=EC=87=A0=20=EB=84=98=EA=B9=80?= =?UTF-8?q?=EC=9D=84=20=EC=9E=A0=EC=9E=A0=20=EB=92=A4=20=EC=84=9C=EB=B2=84?= =?UTF-8?q?(=EA=B0=99=EC=9D=80=20carryKeyedStates)=EA=B0=80=20=EC=85=88=20?= =?UTF-8?q?=E2=86=92=20=EB=B8=8C=EB=9D=BC=EC=9A=B0=EC=A0=80=20=EA=B0=92?= =?UTF-8?q?=EA=B3=BC=20=EB=8C=80=EC=A1=B0=20=C2=B7=20=EB=8B=A4=EB=A5=B4?= =?UTF-8?q?=EB=A9=B4=20=EA=B7=B8=20=EB=85=B8=EC=84=A0=20=EC=B9=B8=EC=9D=84?= =?UTF-8?q?=20=EC=84=9C=EB=B2=84=20=EA=B0=92=EC=9C=BC=EB=A1=9C=20=C2=B7=20?= =?UTF-8?q?F10=20=3D=20=EB=B8=8C=EB=9D=BC=EC=9A=B0=EC=A0=80=20=EB=B6=80?= =?UTF-8?q?=EB=A6=84=20=EC=97=86=EC=9D=8C(=ED=8C=90=EB=A7=8C)=20=C2=B7=20?= =?UTF-8?q?=EA=B1=B0=EC=9A=B8=20=EC=8B=9C=ED=97=98=20=EC=85=8B=20=E2=80=94?= =?UTF-8?q?=20=EC=9A=94=EA=B5=AC=20320-4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- B06_Section/B06_Section_Station_Keys.ts | 23 +++++++++---- resources/tester/helper_511_a_carry.ts | 10 ++++++ resources/tester/test_511_a_calc_browser.py | 6 ++++ resources/tester/test_511_a_calc_mirror.py | 36 +++++++++++++++++++++ 4 files changed, 69 insertions(+), 6 deletions(-) create mode 100644 resources/tester/helper_511_a_carry.ts diff --git a/B06_Section/B06_Section_Station_Keys.ts b/B06_Section/B06_Section_Station_Keys.ts index 532cef832..78b91350d 100644 --- a/B06_Section/B06_Section_Station_Keys.ts +++ b/B06_Section/B06_Section_Station_Keys.ts @@ -22,6 +22,7 @@ import { renameChainageKeys } from "./logic/B06_Section_Station_Swap"; import { afterAnySwap } from "./B06_Section_Swap_Bus"; import { pipeIdAt, pipeIdNear } from "../A00_Common/design/design_pipe_ids"; import { showToast } from "@ui/ui_template_elements"; +import { calcChecked } from "../A00_Common/design/design_calc_server"; import { chainageKey, looseChainage, type StationKeyRole } from "@util/common_util_station_key"; import { inferStationInterval } from "@util/common_util_svg"; import type { CrossSection, SectionDetailResponse } from "./B06_Section_Api_Fetch"; @@ -253,12 +254,22 @@ export function carryStationDrafts( projectId: string, fromRouteId: number, toRouteId: number, - { quiet = false }: { quiet?: boolean } = {}, + { quiet = false, applyTo = toRouteId }: { quiet?: boolean; applyTo?: number } = {}, ): number { - const { states, outside } = carryKeyedStates( - (name) => readRaw(name, projectId, fromRouteId), - lists.get(scopeOf(projectId, fromRouteId)), - lists.get(scopeOf(projectId, toRouteId)), + // 계산 자리 carry = server 면 잠잠 뒤 같은 입력 서버 셈 → 다르면 그 칸을 서버 값으로(applyTo · 511-A) + const read = Object.fromEntries( + KEYED_STATES.map((name) => [name, readRaw(name, projectId, fromRouteId)]), + ) as Record; + const { states, outside } = calcChecked( + "carry", + "stations.carry_keyed", + (from: typeof read, before?: Keyed[], after?: Keyed[]) => + carryKeyedStates((name) => from[name], before, after), + [read, lists.get(scopeOf(projectId, fromRouteId)), lists.get(scopeOf(projectId, toRouteId))], + { + apply: (server) => + KEYED_STATES.forEach((name) => writeState(name, server.states[name], projectId, applyTo)), + }, ); for (const name of KEYED_STATES) writeState(name, states[name], projectId, toRouteId); if (outside.size && !quiet) @@ -275,7 +286,7 @@ const NEXT_ROUTE = -1; */ export function carryStationKeys(projectId: string, routeId: number, next: Keyed[]): number { lists.set(scopeOf(projectId, NEXT_ROUTE), next); - const outside = carryStationDrafts(projectId, routeId, NEXT_ROUTE); + const outside = carryStationDrafts(projectId, routeId, NEXT_ROUTE, { applyTo: routeId }); for (const name of KEYED_STATES) { writeState(name, readRaw(name, projectId, NEXT_ROUTE), projectId, routeId); writeState(name, null, projectId, NEXT_ROUTE); diff --git a/resources/tester/helper_511_a_carry.ts b/resources/tester/helper_511_a_carry.ts new file mode 100644 index 000000000..d052fae2e --- /dev/null +++ b/resources/tester/helper_511_a_carry.ts @@ -0,0 +1,10 @@ +/* PLAN 511-A 거울 — R9 브라우저 자리(`carryStationDrafts`)가 부르는 꼴 그대로(읽기 = 칸 이름 → 값 표). */ +import { + carryKeyedStates, + type Keyed, + type KeyedState, + type Raw, +} from "../../B06_Section/logic/B06_Section_Route_Carry"; + +export const carryWith = (from: Record, before?: Keyed[], after?: Keyed[]) => + carryKeyedStates((name) => from[name], before, after); diff --git a/resources/tester/test_511_a_calc_browser.py b/resources/tester/test_511_a_calc_browser.py index 4c45b0e84..0519b588f 100644 --- a/resources/tester/test_511_a_calc_browser.py +++ b/resources/tester/test_511_a_calc_browser.py @@ -71,3 +71,9 @@ def test_자리마다_판_열쇠_저장_길_안_부름(): assert "design_calc_server" not in _text(rel), rel page = _text("A00_Common/design/design_pipeline_page.ts") assert len(page.splitlines()) <= 699 and "design_calc_server" not in page + + +def test_이월_R9_자리_판_열쇠(): + keys = _text("B06_Section/B06_Section_Station_Keys.ts") + assert '"carry",\n "stations.carry_keyed",' in keys + assert "carryStationDrafts(projectId, routeId, NEXT_ROUTE, { applyTo: routeId })" in keys diff --git a/resources/tester/test_511_a_calc_mirror.py b/resources/tester/test_511_a_calc_mirror.py index 066fc5206..7391e1498 100644 --- a/resources/tester/test_511_a_calc_mirror.py +++ b/resources/tester/test_511_a_calc_mirror.py @@ -92,3 +92,39 @@ def test_quantity_F7_C4_E1_서버_브라우저_같음(tmp_path): for index, case in enumerate(STRUCTURE_CASES): got = _served(["cross.berm_spans"], {"berm": {"structures": case, "chainages": []}}) assert got["cross.berm_spans"]["spans"] == browser[2 + index], index + + +# ── ④ 이월 · 다시 얹기(R9 · F10 = 브라우저 부름 없음) ────────────────────────────────── + + +def test_carry_R9_서버_브라우저_같음(tmp_path): + seat = {"x": 10.0, "y": 5.0, "left": [0.0, 1.0]} + states = { + "crossdesign": {"grid:1": {"ditch_type": "U"}, "20.00": {"paved": True}}, + "shoulderext": {"grid:2": 0.5, "@at": {"grid:2": 40.0}, "@seat": {"grid:2": seat}}, + "bermdepth": {"at:55.00": 1.0}, + "rockb": {"30.00": -2.5}, + "cutslope": {}, + } + before = [ + {"key": "grid:1", "chainageM": 20.0, "seat": seat}, + {"key": "grid:2", "chainageM": 40.0}, + {"key": "end:EP", "chainageM": 60.0}, + ] + after = [ + {"key": "grid:1", "chainageM": 22.0}, + {"key": "grid:2", "chainageM": 41.0, "seat": seat}, + {"key": "end:EP", "chainageM": 58.0}, + ] + empty = dict.fromkeys(states, {}) # 브라우저 읽기 = 빈 칸도 {}(readRaw) + cases = [[states, before, after], [states, before, None], [empty, None, after]] + browser = _browser( + tmp_path, + [ + {"mod": "resources/tester/helper_511_a_carry.ts", "fn": "carryWith", "args": case} + for case in cases + ], + ) + for index, case in enumerate(cases): + assert _trial("stations.carry_keyed", case) == browser[index], index + assert browser[0]["states"]["crossdesign"] # 넘김이 실제로 일어남 From eff9d7f5d02e55c4bd9bca7ff29e7a31d3a639dc Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 22 Oct 2026 13:09:00 +0900 Subject: [PATCH 4/4] =?UTF-8?q?feat(511-A=20=E2=91=A7=20=EA=B3=A0=EC=A0=95?= =?UTF-8?q?=20=EB=B8=8C=EB=9D=BC=EC=9A=B0=EC=A0=80=20=EC=8B=9C=ED=97=98?= =?UTF-8?q?=EC=9A=A9=20=EC=97=B4=EA=B8=B0):=20fixed=5Fbrowser=20=3D=20serv?= =?UTF-8?q?er=20=EB=A9=B4=20P7=20=EC=A7=81=EC=84=A0=ED=99=94=20=C2=B7=20D4?= =?UTF-8?q?=20=EB=B0=9C=EC=9E=90=EA=B5=AD=20=EB=8B=A8=EA=B3=84=20=3D=20?= =?UTF-8?q?=EC=9E=A0=EC=9E=A0=20=EB=92=A4=20=EC=84=9C=EB=B2=84=20=EA=B0=92?= =?UTF-8?q?(=EB=81=8A=EA=B8=B4=20=EC=B0=A8=EB=A1=80=20=3D=20=EB=AC=B6?= =?UTF-8?q?=EC=9D=8C=EC=9D=B4=20=EB=B2=84=EB=A6=BC=20=C2=B7=20=EB=AA=BB=20?= =?UTF-8?q?=EB=B0=9B=EC=9C=BC=EB=A9=B4=20=EB=B8=8C=EB=9D=BC=EC=9A=B0?= =?UTF-8?q?=EC=A0=80)=20=C2=B7=20=EB=81=8C=EA=B8=B0=20=EB=8F=84=EA=B5=AC?= =?UTF-8?q?=20R2=20=EC=86=90=EC=9E=A1=EC=9D=B4=20=C2=B7=20R4=20=EC=B8=A1?= =?UTF-8?q?=EC=A0=90=20=EB=A7=9E=EC=B6=A4=20=C2=B7=20R8=20=EA=B0=88?= =?UTF-8?q?=EC=95=84=EB=81=BC=EC=9B=80=20=C2=B7=20C7=20=EB=B0=98=ED=8F=AD?= =?UTF-8?q?=20=EB=A7=9E=EC=B6=A4=20=3D=20=EB=B8=8C=EB=9D=BC=EC=9A=B0?= =?UTF-8?q?=EC=A0=80=20=EA=B0=92=20+=20=EB=86=93=EC=9D=80=20=EB=92=A4=20?= =?UTF-8?q?=EC=84=9C=EB=B2=84=20=EB=8C=80=EC=A1=B0=20=C2=B7=20=EB=91=98?= =?UTF-8?q?=EB=A0=88=20=EC=8B=9C=ED=97=98=20=EA=B8=80=20=C2=B7=20=EA=B0=80?= =?UTF-8?q?=EC=A7=9C=20=EB=AA=A8=EB=93=88=20=EB=A7=9E=EC=B6=A4=20=C2=B7=20?= =?UTF-8?q?=ED=99=94=EB=A9=B4=20=EC=8B=9C=ED=97=98(=EB=AC=B6=EC=9D=8C?= =?UTF-8?q?=EB=A7=88=EB=8B=A4=20/calc=20=EB=8B=B5=20=3D=20=EA=B7=B8=20?= =?UTF-8?q?=ED=8E=98=EC=9D=B4=EC=A7=80=20TS)=20=E2=80=94=20=EC=9A=94?= =?UTF-8?q?=EA=B5=AC=20320-4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- A00_Common/design/design_calc_server.ts | 2 +- A00_Common/design/design_pipeline_page.ts | 4 +- .../design/logic/design_calc_node_trial.ts | 4 +- B05_Profile/B05_Profile_Footprint_Server.ts | 40 +++ .../B05_Profile_UI_Profile_Panel_Tools.ts | 23 +- B05_Profile/B05_Profile_UI_RouteEdit.ts | 9 +- .../B05_Profile_UI_RouteEdit_Cross_Draw.ts | 6 +- .../B05_Profile_UI_RouteEdit_Pointer.ts | 11 +- B06_Section/B06_Section_Section_Store.ts | 6 +- resources/tester/helper_219_1_arrow_owner.cjs | 7 + .../tester/helper_346_1_pipeline_page.cjs | 11 +- .../tester/helper_472_d_b05_pipe_undo.cjs | 7 + resources/tester/test_271_straighten_pick.py | 7 +- resources/tester/test_280_4_fit.py | 7 +- .../tester/test_309_2_calc_sites_browser.py | 4 +- .../tester/test_346_3_route_save_screen.py | 2 +- .../tester/test_354_2_masshaul_cross_only.py | 2 + resources/tester/test_366_3_station_fit.py | 7 +- resources/tester/test_511_a_calc_browser.py | 17 +- resources/tester/test_511_a_calc_mirror.py | 83 +++++++ .../tester/test_511_a_calc_sites_browser.py | 235 ++++++++++++++++++ .../tester/test_masshaul_single_source.py | 2 +- 22 files changed, 477 insertions(+), 19 deletions(-) create mode 100644 B05_Profile/B05_Profile_Footprint_Server.ts create mode 100644 resources/tester/test_511_a_calc_sites_browser.py diff --git a/A00_Common/design/design_calc_server.ts b/A00_Common/design/design_calc_server.ts index 6f6373c50..c0fc466a2 100644 --- a/A00_Common/design/design_calc_server.ts +++ b/A00_Common/design/design_calc_server.ts @@ -154,7 +154,7 @@ export const bermRequest = (structures: unknown[]): CalcRequest => ({ pick: (results) => (results["cross.berm_spans"] as { spans: unknown }).spans, }); -const trialRequest = (id: string, args: unknown[]): CalcRequest => ({ +export const trialRequest = (id: string, args: unknown[]): CalcRequest => ({ run: [id], inputs: { args: { [id]: args } }, pick: (results) => results[id], diff --git a/A00_Common/design/design_pipeline_page.ts b/A00_Common/design/design_pipeline_page.ts index cb2021ab2..ac82d8e1a 100644 --- a/A00_Common/design/design_pipeline_page.ts +++ b/A00_Common/design/design_pipeline_page.ts @@ -100,7 +100,7 @@ import { buildStations, SECTION_STATION_INTERVAL_M, } from "../../B05_Profile/logic/B05_Profile_Sections_Stations"; -import { footprintStage } from "../../B05_Profile/logic/B05_Profile_Footprint_Stage"; +import { footprintStageSited } from "../../B05_Profile/B05_Profile_Footprint_Server"; import type { PlanPipe, PlanStructure, @@ -357,7 +357,7 @@ function createPagePipeline( return out; }); }, - footprint: footprintStage, + footprint: footprintStageSited, // 계산 자리 판(511-A) drainage: link.side?.pipelineStage, corridor: (ctx) => { if (ctx.inputs.route) options.onCorridorStale?.(); diff --git a/A00_Common/design/logic/design_calc_node_trial.ts b/A00_Common/design/logic/design_calc_node_trial.ts index b2f0f30ca..d22ead5fa 100644 --- a/A00_Common/design/logic/design_calc_node_trial.ts +++ b/A00_Common/design/logic/design_calc_node_trial.ts @@ -10,7 +10,7 @@ import { computeMassHaulSeries } from "@util/common_util_mass_haul"; import { footprintStage } from "../../../B05_Profile/logic/B05_Profile_Footprint_Stage"; -import { fitCurveStations } from "../../../B05_Profile/logic/B05_Profile_RouteEdit_StationFit"; +import { fitAfter } from "../../../B05_Profile/logic/B05_Profile_RouteEdit_StationFit"; import { straightenBetween } from "../../../B05_Profile/logic/B05_Profile_UI_Profile_Straighten"; import { dragHandleTo } from "../../../B05_Profile/logic/B05_Profile_UI_RouteEdit_Curve"; import { @@ -42,7 +42,7 @@ const TRIAL: Record = { "stations.carry_keyed": ((states: Record, from, to) => carryKeyedStates((name: KeyedState) => states[name] ?? {}, from, to)) as Call, "route.handle": dragHandleTo as Call, - "route.station_fit": fitCurveStations as Call, + "route.station_fit": fitAfter as Call, // 편집 한 걸음 뒤 맞춤(fitCurveStations 를 부름) "stations.swap": swapCrossSections as Call, "profile.tools": straightenBetween as Call, "cross.fit": toeOffsets as Call, diff --git a/B05_Profile/B05_Profile_Footprint_Server.ts b/B05_Profile/B05_Profile_Footprint_Server.ts new file mode 100644 index 000000000..831a8676a --- /dev/null +++ b/B05_Profile/B05_Profile_Footprint_Server.ts @@ -0,0 +1,40 @@ +/* ============================================================================= + * B05_Profile/B05_Profile_Footprint_Server.ts + * 계산 묶음 footprint 단계(D4) + 계산 자리 「서버」 길(PLAN 511-A) — 판 fixed_browser = server 면 잠잠 뒤 서버 + * (`footprint.plan` · 같은 `footprintStage` 전 구간)가 셈 · 끊긴 차례는 묶음이 버림 · 못 받으면 브라우저 셈. + * 판 both = 지금 그대로(같은 틱 · 브라우저 셈). + * ========================================================================== */ + +import type { StageContext } from "../A00_Common/design/logic/design_pipeline_types"; +import { calcOnServerNow } from "../A00_Common/design/design_calc_sites"; +import { + calcChecked, + calcOnServerOnly, + trialRequest, +} from "../A00_Common/design/design_calc_server"; +import { footprintStage } from "./logic/B05_Profile_Footprint_Stage"; +import type { FootprintLines } from "./logic/B05_Profile_PlanFootprint"; + +/** 단계가 읽는 몫만 — 측점(지반) · 측점 횡단 detail · 노선 선. */ +export function footprintInputs(ctx: StageContext<"footprint">): unknown { + const byKey = ctx.out.cross?.byKey ?? {}; + const route = ctx.out.route; + return { + ground: { stations: ctx.out.ground?.stations ?? [] }, + cross: { + byKey: Object.fromEntries(Object.entries(byKey).map(([k, v]) => [k, { detail: v.detail }])), + }, + route: route ? { line: route.line, chainage: route.chainage, lengthM: route.lengthM } : null, + }; +} + +export function footprintStageSited( + ctx: StageContext<"footprint">, +): FootprintLines | Promise { + if (!calcOnServerNow("fixed_browser")) + return calcChecked("fixed_browser", "footprint.plan", footprintStage, [ctx]); + const asked = trialRequest("footprint.plan", [footprintInputs(ctx)]); + return calcOnServerOnly("fixed_browser", "D4", asked, ctx.signal).then( + (lines) => lines ?? footprintStage(ctx), + ); +} diff --git a/B05_Profile/B05_Profile_UI_Profile_Panel_Tools.ts b/B05_Profile/B05_Profile_UI_Profile_Panel_Tools.ts index fdcc2d9ec..428d5e7b8 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Panel_Tools.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Panel_Tools.ts @@ -40,6 +40,12 @@ import { createRangeBandView } from "../A00_Common/design/design_profile_range_b import { arrowsBelongToProfile } from "../B06_Section/B06_Section_UI_Cross_Panel_Base"; import { stepStructureStation } from "../common_util/common_util_structure_station"; import { draftProjectId, useDesignStore } from "../A00_Common/design/design_store"; +import { calcOnServerNow } from "../A00_Common/design/design_calc_sites"; +import { + calcChecked, + calcOnServerOnly, + trialRequest, +} from "../A00_Common/design/design_calc_server"; /** 방향키 ▲▼ 한 번에 움직이는 계획고(m) — 좌우(구조물 측점)는 1 m 걸음(234-1 · `stepStructureStation`). */ const KEY_STEP_M = 0.1; @@ -141,7 +147,22 @@ export function createPanelTools(ctx: PanelToolsContext): PanelTools { tools.handleRunPick(sub); return; } - ctx.applyEdits(straightenBetween(base, ctx.edits(), fromM, toM, ctx.savedRadii())); + // 계산 자리 fixed_browser = server 면 잠잠 뒤 서버 값으로 얹음(511-A) + const args: Parameters = [ + base, + ctx.edits(), + fromM, + toM, + ctx.savedRadii(), + ]; + if (calcOnServerNow("fixed_browser")) { + const asked = trialRequest("profile.tools", args); + void calcOnServerOnly>( + "fixed_browser", + "P7", + asked, + ).then((edits) => edits && ctx.applyEdits(edits)); + } else ctx.applyEdits(calcChecked("fixed_browser", "profile.tools", straightenBetween, args)); tools.clear(); }, onShift: (runs, delta) => { diff --git a/B05_Profile/B05_Profile_UI_RouteEdit.ts b/B05_Profile/B05_Profile_UI_RouteEdit.ts index 86ad60483..328e5c691 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit.ts @@ -74,6 +74,7 @@ import type { DesignPageHost } from "../A00_Common/design/design_page"; import * as draftLink from "./B05_Profile_UI_RouteEdit_Draft"; import { bindStationSnap, paintStationSnap } from "./B05_Profile_UI_RouteEdit_SnapStep"; import * as fit from "./logic/B05_Profile_RouteEdit_StationFit"; +import { calcChecked } from "../A00_Common/design/design_calc_server"; import "./B05_Profile_UI_Style_RouteEdit.css"; /** 등고선 눈금을 고를 때 쓸 라벨 예산 — B04 지도 기본값(350)보다 **넉넉히** 둔다 @@ -434,7 +435,13 @@ export async function mountRouteEditor( markEdited(); const stop = fitting && before ? edits.floorStop(before, planned, nodeInfo, fitLimits()) : ""; if (stop && before) return restoreSnapshot(before, stop, false); - if (stationSnap && fitting) planned = fit.fitAfter(before, snapshotNow(), fitLimits()); + // 계산 자리 fixed_browser = server 면 잠잠 뒤 서버 대조만(511-A) + if (stationSnap && fitting) + planned = calcChecked("fixed_browser", "route.station_fit", fit.fitAfter, [ + before, + snapshotNow(), + fitLimits(), + ]); markEdited(); writeRouteLocks(projectId, planned, apexLock); // 고정은 [취소] · 다시 열기에도 남는다(22-5). syncCurveBar(); diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Cross_Draw.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Cross_Draw.ts index a0c2a7fc1..daacecf8d 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Cross_Draw.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Cross_Draw.ts @@ -21,6 +21,7 @@ import type { ContentBounds } from "../A00_Common/b_svg_zoom_pan"; import type { CrossSection } from "./../B06_Section/B06_Section_Api_Fetch"; import { fillSlopeLengths, toeOffsets } from "../B06_Section/logic/B06_Section_UI_Cross_Fit"; import type { CrossPreviewResponse } from "./B05_Profile_Api_Replan"; +import { calcChecked } from "../A00_Common/design/design_calc_server"; /** 그림 가장자리 여백(px) — 위는 범례 한 줄, 아래는 눈금 안내 한 줄 몫이다. */ export const CROSS_PLOT_PAD = { left: 10, right: 10, top: 20, bottom: 20 }; @@ -113,7 +114,10 @@ export function buildCrossSvg( const ground = preview.samples .filter((sample) => sample.valid && sample.elevation_m !== null) .map((sample) => [Number(sample.offset_m), Number(sample.elevation_m)] as Point); - const toes = preview.design ? toeOffsets(asSection(preview)) : { left: null, right: null }; + // 계산 자리 fixed_browser = server 면 잠잠 뒤 서버 대조만(511-A) + const toes = preview.design + ? calcChecked("fixed_browser", "cross.fit", toeOffsets, [asSection(preview)]) + : { left: null, right: null }; // 계획 횡단선은 사면 끝에서 끊는다 — 그 바깥은 원지반이고 지반선이 이미 그린다. const design = clipToToes( (preview.design?.design_line ?? []).map( diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Pointer.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Pointer.ts index 40edb8fbd..dae66c034 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Pointer.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Pointer.ts @@ -33,6 +33,7 @@ import { type RouteEditPick, } from "./B05_Profile_UI_RouteEdit_Input"; import { markShown } from "./B05_Profile_UI_RouteEdit_Marks"; +import { calcChecked } from "../A00_Common/design/design_calc_server"; import { curveShortfalls, floorNote, @@ -188,7 +189,15 @@ export function bindRouteEditPointer(params: RouteEditPointerParams): RouteEditP const before = planned[node - 1]; const after = planned[node + 1]; if (!before || !after) return null; - return curveDragTo(before, [curve.apex[0], curve.apex[1]], after, which, to); + // 계산 자리 fixed_browser = server 면 놓은 뒤(잠잠) 서버 대조만(끌기 도구 · 511-A) + const apex: Vertex = [curve.apex[0], curve.apex[1]]; + return calcChecked("fixed_browser", "route.handle", curveDragTo, [ + before, + apex, + after, + which, + to, + ]); } let dragNode = -1; diff --git a/B06_Section/B06_Section_Section_Store.ts b/B06_Section/B06_Section_Section_Store.ts index 4fdce6dd3..548bc6fd1 100644 --- a/B06_Section/B06_Section_Section_Store.ts +++ b/B06_Section/B06_Section_Section_Store.ts @@ -66,6 +66,7 @@ import { import { sameStationList, swapCrossSections } from "./logic/B06_Section_Station_Swap"; import { mergeCrossPatches } from "./logic/B06_Section_UI_Page_Patches"; import { emitSwap, type DetailListener } from "./B06_Section_Swap_Bus"; +import { calcChecked } from "../A00_Common/design/design_calc_server"; export { onAnySwap } from "./B06_Section_Swap_Bus"; @@ -264,14 +265,15 @@ export async function swapSectionStations( const interval = stationIntervalOf(current); const oldKeys = registeredKeys(projectId, routeId, current.cross_sections, interval); if (sameStationList(current.cross_sections, oldKeys, stations)) return null; - const swap = swapCrossSections( + // 계산 자리 fixed_browser = server 면 잠잠 뒤 서버 대조만(511-A) + const swap = calcChecked("fixed_browser", "stations.swap", swapCrossSections, [ current, stations, oldKeys, interval, useDesignStore(projectId, null).get("routeDraft") !== null, savedDesigns(projectId, routeId) ?? [], - ); // 초안 없으면 정본 표본(467) + ]); // 초안 없으면 정본 표본(467) // 설계는 사본 — 면적 얹기 · 다시 셈이 앞 상세(초안 전 상세 등)의 설계를 제자리로 안 고침(349-0o) for (const section of swap.detail.cross_sections) if (section.design) section.design = { ...section.design }; diff --git a/resources/tester/helper_219_1_arrow_owner.cjs b/resources/tester/helper_219_1_arrow_owner.cjs index 7a8431b8f..731741bb7 100644 --- a/resources/tester/helper_219_1_arrow_owner.cjs +++ b/resources/tester/helper_219_1_arrow_owner.cjs @@ -58,6 +58,13 @@ const STUBS = { "../A00_Common/design/design_profile_free_view": { createFreeView: () => ({ mount: () => undefined, redraw: () => undefined }), }, + // 계산 자리 「서버」 길(511-A) — 판 both 그대로 · 이 흠과 무관 + "../A00_Common/design/design_calc_sites": { calcOnServerNow: () => false }, + "../A00_Common/design/design_calc_server": { + calcChecked: (_key, _id, fn, args) => fn(...args), + calcOnServerOnly: async () => undefined, + trialRequest: () => ({}), + }, }; const { loadTs } = makeLoader({ diff --git a/resources/tester/helper_346_1_pipeline_page.cjs b/resources/tester/helper_346_1_pipeline_page.cjs index 84e9ea937..2a4465501 100644 --- a/resources/tester/helper_346_1_pipeline_page.cjs +++ b/resources/tester/helper_346_1_pipeline_page.cjs @@ -27,7 +27,16 @@ const session = memoryStorage(); globalThis.window = { sessionStorage: session, localStorage: memoryStorage() }; globalThis.fetch = async () => ({ ok: false }); -const STUBS = {}; +// 계산 자리 「서버」 길(511-A) — 판 both 그대로 +const CALC_STUBS = { + design_calc_sites: { calcOnServerNow: () => false, calcProjectId: () => "" }, + design_calc_server: { + calcChecked: (_key, _id, fn, args) => fn(...args), + calcOnServerOnly: async () => undefined, + trialRequest: () => ({}), + }, +}; +const STUBS = { ...CALC_STUBS }; const { load, loadTs } = makeLoader({ root: ROOT, requireFor: (full) => (request) => { diff --git a/resources/tester/helper_472_d_b05_pipe_undo.cjs b/resources/tester/helper_472_d_b05_pipe_undo.cjs index 01d041c54..a08a66131 100644 --- a/resources/tester/helper_472_d_b05_pipe_undo.cjs +++ b/resources/tester/helper_472_d_b05_pipe_undo.cjs @@ -51,6 +51,13 @@ const STUBS = { "../A00_Common/design/design_profile_free_view": { createFreeView: () => ({ mount: () => undefined, redraw: () => undefined }), }, + // 계산 자리 「서버」 길(511-A) — 판 both 그대로 + "../A00_Common/design/design_calc_sites": { calcOnServerNow: () => false }, + "../A00_Common/design/design_calc_server": { + calcChecked: (_key, _id, fn, args) => fn(...args), + calcOnServerOnly: async () => undefined, + trialRequest: () => ({}), + }, }; const { loadTs } = makeLoader({ diff --git a/resources/tester/test_271_straighten_pick.py b/resources/tester/test_271_straighten_pick.py index fc75e60e5..e5fbab5fd 100644 --- a/resources/tester/test_271_straighten_pick.py +++ b/resources/tester/test_271_straighten_pick.py @@ -172,7 +172,12 @@ def test_배선(): panel = _read("B05_Profile/B05_Profile_UI_Profile_Panel_Tools.ts") assert "tools.handleStationPick(station ?? chainageM)" in panel assert "straightSubRun(alignment, fromM, toM)" in panel - assert "straightenBetween(base, ctx.edits(), fromM, toM, ctx.savedRadii())" in panel + # 511-A — 인자 한 벌(계산 자리 길이 함수째 받음) + args = ( + "Parameters = [\n base,\n ctx.edits(),\n" + " fromM,\n toM,\n ctx.savedRadii()," + ) + assert args in panel assert "autoRadii: ctx.savedRadii" in panel assert 'window.addEventListener("keydown", onEscape, true)' in panel assert "createRangeBandView(" in panel diff --git a/resources/tester/test_280_4_fit.py b/resources/tester/test_280_4_fit.py index 1c2f821cd..25b652fa6 100644 --- a/resources/tester/test_280_4_fit.py +++ b/resources/tester/test_280_4_fit.py @@ -77,7 +77,12 @@ def test_끄는_중_미리보기_놓을_때_같음(got) -> None: def test_편집기_이음() -> None: edit = (B05 / "B05_Profile_UI_RouteEdit.ts").read_text(encoding="utf-8") - assert "fit.fitAfter(before, snapshotNow(), fitLimits())" in edit + # 511-A 계산 자리 대조(같은 인자) + call = ( + '"route.station_fit", fit.fitAfter, [\n' + " before,\n snapshotNow(),\n fitLimits()," + ) + assert call in edit assert "fit.fitDrag(snapshotNow(), base.planned, index, fitLimits(), base)" in edit assert "stationM: stationIntervalM" in edit assert len(edit.splitlines()) <= 695 diff --git a/resources/tester/test_309_2_calc_sites_browser.py b/resources/tester/test_309_2_calc_sites_browser.py index fce9ca2c9..607436f38 100644 --- a/resources/tester/test_309_2_calc_sites_browser.py +++ b/resources/tester/test_309_2_calc_sites_browser.py @@ -151,7 +151,9 @@ def test_시스템_관리자_카드_창_G1_서버_길(browser): assert len(heads) == 8 and len(calcs) == 45 assert heads[0]["boxes"] == [[True, False], [True, True]] # G1 묶음 머리 = 바꿀 수 있음 editable = [row["name"] for row in calcs if not row["boxes"][0][1]] - assert [name.split(" · ")[0] for name in editable] == ["G1", "D3"] # 줄도 둘만 켬 + opened = ["G1", "D3", "E1", "F7", "F8", "C4", "R9", "F10"] # 511-A ③ ④ 더 + opened += ["R2", "R4", "R8", "P7", "C7", "D4"] # ⑧ 시험용 + assert [name.split(" · ")[0] for name in editable] == opened assert all(row["name"].split(" · ")[1] for row in calcs) # 이름 = 등록부 label assert all(row["boxes"][1][1] for row in calcs) # 서버 정본 칸은 늘 잠금 assert "지금 서버 0 줄" in dialog.inner_text() diff --git a/resources/tester/test_346_3_route_save_screen.py b/resources/tester/test_346_3_route_save_screen.py index c265c56df..7c6957578 100644 --- a/resources/tester/test_346_3_route_save_screen.py +++ b/resources/tester/test_346_3_route_save_screen.py @@ -132,7 +132,7 @@ def test_노선_반영_단추_없앰_배선(): def test_넘김_알림은_결과_한_줄에_합침(): """346-3 덧 — [저장] 뒤 「노선 밖 고침」 두 줄 → 한 줄 · 넘김은 조용히 수만 · 기본은 알림 그대로.""" keys = _read("B06_Section/B06_Section_Station_Keys.ts") - assert "{ quiet = false }: { quiet?: boolean } = {}," in keys + assert "{ quiet = false, applyTo = toRouteId }: { quiet?: boolean; applyTo?: number }" in keys assert "if (outside.size && !quiet)" in keys patch = _read("B06_Section/B06_Section_Route_Carry_Patch.ts") assert "outside: keyed.outside.size + chain.outside.size" in patch diff --git a/resources/tester/test_354_2_masshaul_cross_only.py b/resources/tester/test_354_2_masshaul_cross_only.py index 3f178e535..bbb79185d 100644 --- a/resources/tester/test_354_2_masshaul_cross_only.py +++ b/resources/tester/test_354_2_masshaul_cross_only.py @@ -48,6 +48,8 @@ def test_기준_횡단만_계산(r): def test_그리기는_횡단만_부름(): mass = (ROOT / "B05_Profile/B05_Profile_UI_Profile_MassHaul.ts").read_text(encoding="utf-8") calls = re.findall(r"computeMassHaulSeries\(([^;]*?)\)", mass, re.S) + # 511-A — 부름 인자 한 벌(계산 자리 길이 함수째 받음) + calls += re.findall(r"Parameters = \[([^;]*?)\];", mass, re.S) assert calls and all('["cross"]' in call for call in calls) diff --git a/resources/tester/test_366_3_station_fit.py b/resources/tester/test_366_3_station_fit.py index 0154535bc..2c05b11f6 100644 --- a/resources/tester/test_366_3_station_fit.py +++ b/resources/tester/test_366_3_station_fit.py @@ -89,7 +89,12 @@ def test_시작_자리는_고친_꺾임점_앞(got) -> None: def test_편집기_이음() -> None: edit = (B05 / "B05_Profile_UI_RouteEdit.ts").read_text("utf-8") - assert "fit.fitAfter(before, snapshotNow(), fitLimits())" in edit # 280-4 + # 280-4 · 511-A 계산 자리 대조(같은 인자) + call = ( + '"route.station_fit", fit.fitAfter, [\n' + " before,\n snapshotNow(),\n fitLimits()," + ) + assert call in edit # 되돌리기 · 추천 [적용] · 초안 되살림이 지나는 restoreSnapshot 은 안 맞춤 at = edit.index("function restoreSnapshot(") assert "applyEdit(message, record, false);" in edit[at : at + 700] diff --git a/resources/tester/test_511_a_calc_browser.py b/resources/tester/test_511_a_calc_browser.py index 0519b588f..965c8f299 100644 --- a/resources/tester/test_511_a_calc_browser.py +++ b/resources/tester/test_511_a_calc_browser.py @@ -70,10 +70,25 @@ def test_자리마다_판_열쇠_저장_길_안_부름(): ): assert "design_calc_server" not in _text(rel), rel page = _text("A00_Common/design/design_pipeline_page.ts") - assert len(page.splitlines()) <= 699 and "design_calc_server" not in page + assert len(page.splitlines()) <= 699 and "design_calc_server" not in page # 연결 = 다른 파일 def test_이월_R9_자리_판_열쇠(): keys = _text("B06_Section/B06_Section_Station_Keys.ts") assert '"carry",\n "stations.carry_keyed",' in keys assert "carryStationDrafts(projectId, routeId, NEXT_ROUTE, { applyTo: routeId })" in keys + + +def test_고정_브라우저_자리_판_열쇠(): + sites = { + "B05_Profile/B05_Profile_UI_RouteEdit_Pointer.ts": '"fixed_browser", "route.handle"', + "B05_Profile/B05_Profile_UI_RouteEdit.ts": '"fixed_browser", "route.station_fit"', + "B06_Section/B06_Section_Section_Store.ts": '"fixed_browser", "stations.swap"', + "B05_Profile/B05_Profile_UI_Profile_Panel_Tools.ts": '"fixed_browser",\n "P7",', + "B05_Profile/B05_Profile_UI_RouteEdit_Cross_Draw.ts": '"fixed_browser", "cross.fit"', + "B05_Profile/B05_Profile_Footprint_Server.ts": '"fixed_browser", "D4"', + } + for rel, needle in sites.items(): + assert needle in _text(rel), rel + page = _text("A00_Common/design/design_pipeline_page.ts") + assert "footprint: footprintStageSited," in page diff --git a/resources/tester/test_511_a_calc_mirror.py b/resources/tester/test_511_a_calc_mirror.py index 7391e1498..70177c631 100644 --- a/resources/tester/test_511_a_calc_mirror.py +++ b/resources/tester/test_511_a_calc_mirror.py @@ -128,3 +128,86 @@ def test_carry_R9_서버_브라우저_같음(tmp_path): for index, case in enumerate(cases): assert _trial("stations.carry_keyed", case) == browser[index], index assert browser[0]["states"]["crossdesign"] # 넘김이 실제로 일어남 + + +# ── ⑧ 고정 브라우저(시험용 · R2 R4 C7 대조 · P7 서버 값 · R8 · D4 = 화면 검증) ────────────────── + +_ROUTE = [[0, 0], [93, 7], [171, 61], [262, 48], [337, 112], [431, 101], [503, 160]] +_LIMITS = {"minRadiusM": 30, "limitRadiusM": 12, "limitArcM": 0, "stationM": 20, "intervalM": 10} + + +def _columns(planned): + n = len(planned) + return { + "planned": [list(p) for p in planned], + "curveOn": [True] * n, + "curveRadius": [None] * n, + "curveLock": [None] * n, + "curveArc": [None] * n, + "apexLock": [i in (0, n - 1) for i in range(n)], + } + + +def _profile_base(): + import math + + stations = [i * 20 for i in range(16)] + ground = [round(100 + 0.03 * s + 0.6 * math.sin(s / 13), 6) for s in stations] + policy = { + "station_interval_m": 20, "curve_radius_ratio": 1, "curve_tangent_max_ratio": 0.5, + "curve_skip_legal_exception": False, "default_curve_length_m": 20, + "default_curve_radius_m": 300, "balance_tolerance_percent": 10, "edit_step_m": 0.1, + "grade_violation_policy": "warn", "max_grade_pct": 14, "curve_skip_delta_pct": 0, + "paved": True, "min_vertical_radius_m": 100, "min_curve_length_m": 20, + } # fmt: skip + return { + "policy": policy, + "onlyExplicitCurves": False, + "basePvi": [{"chainage_m": s, "elevation_m": z} for s, z in zip(stations, ground)], + "chainage": stations, + "ground": ground, + "stations": [{"station_id": f"S{s}", "chainage_m": s} for s in stations], + } + + +def test_fixed_browser_R2_R4_P7_C7_서버_브라우저_같음(tmp_path): + pushed = [list(p) for p in _ROUTE] + pushed[2] = [174.0, 58.0] + base = _profile_base() + empty = {"station_offsets": {}, "curve_radii": {}} + saved = {"40.000": 5330, "200.000": 1084} + sections = _fixture("detail.json")["cross_sections"][:12] + cases = { + "route.handle": [ + [[0, 0], [100, 0], [100, 100], "end", [105, 60]], + [[0, 0], [100, 0], [100, 100], "start", [60, -4]], + [[0, 0], [100, 0], [200, 0], "end", [150, 0]], # 직선 = null + ], + "route.station_fit": [ + [_columns(_ROUTE), _columns(pushed), _LIMITS], + [None, _columns(_ROUTE), {**_LIMITS, "intervalM": 20}], + ], + "profile.tools": [ + [base, empty, 40, 200, {}], + [base, {"station_offsets": {}, "curve_radii": dict(saved)}, 40, 200, saved], + [base, empty, 0, 300, {}], + ], + "cross.fit": [[section] for section in sections], + } + b05 = "B05_Profile/logic/B05_Profile_" + where = { + "route.handle": (f"{b05}UI_RouteEdit_Curve.ts", "dragHandleTo"), + "route.station_fit": (f"{b05}RouteEdit_StationFit.ts", "fitAfter"), + "profile.tools": (f"{b05}UI_Profile_Straighten.ts", "straightenBetween"), + "cross.fit": ("B06_Section/logic/B06_Section_UI_Cross_Fit.ts", "toeOffsets"), + } + flat = [(id_, args) for id_, rows in cases.items() for args in rows] + browser = _browser( + tmp_path, + [{"mod": where[id_][0], "fn": where[id_][1], "args": args} for id_, args in flat], + ) + for (id_, args), want in zip(flat, browser): + assert _trial(id_, args) == want, id_ + assert browser[0] and browser[2] is None # 직선 = null + toes = [want for (id_, _), want in zip(flat, browser) if id_ == "cross.fit"] + assert any(toe["left"] is not None for toe in toes) diff --git a/resources/tester/test_511_a_calc_sites_browser.py b/resources/tester/test_511_a_calc_sites_browser.py new file mode 100644 index 000000000..5d8cad785 --- /dev/null +++ b/resources/tester/test_511_a_calc_sites_browser.py @@ -0,0 +1,235 @@ +"""PLAN 511-A 화면 — 자기 포트 Playwright 머리 없는 브라우저 · 서버가 떠 있을 때만 · 검증_sub2. + +- 판 기본값(both) = B05 계획고 ▲ · 교각점 끌기 동안 /calc 0 · 브라우저 셈 기록만 +- 묶음 하나씩(quantity · carry · fixed_browser) server → 같은 조작 → /calc 요청 · 답마다 + 그 페이지 TS 로 다시 셈한 값 = 서버 값 · 그 묶음 id 만 감 → 판 기본값으로(파일 바이트 그대로) +- 저장 안 함(세션 초안만 · 창 닫으면 버림) · 시험 브라우저 표식 시스템 로그 행만 지움 +- 시간 표 = `tmp/511a_times.json`(묶음마다 브라우저 ms · 왕복 ms · Node ms · 흉내 0 · 30 · 80 ms) + + ./venv/Scripts/python.exe -m pytest resources/tester/test_511_a_calc_sites_browser.py -q -s +""" + +from __future__ import annotations + +import asyncio +import json +import statistics +import sys +import urllib.request +from pathlib import Path + +import pytest + +HERE = Path(__file__).resolve().parent +ROOT = HERE.parents[1] +if str(HERE) not in sys.path: + sys.path.insert(0, str(HERE)) + +from helper_verify_project import own_ports # noqa: E402 +from test_505_3_cross_card_screen import JS_NODES # noqa: E402 + +API, BASE = own_ports() +BOARD = ROOT / "resources" / "master_template" / "calc_sites.json" +PROJECT = "cb15892a-5788-4a26-9316-20a15421f68b" # 검증_sub2(OWNERS.md) +MARK = "AisloTest-511-A" +TIMES = ROOT / "tmp" / "511a_times.json" +GROUP_IDS = { + "quantity": {"structure.layouts", "cross.berm_spans", "masshaul.series"}, + "carry": {"stations.carry_keyed"}, + "fixed_browser": {"route.handle", "route.station_fit", "stations.swap", "profile.tools"} + | {"cross.fit", "footprint.plan"}, +} + + +def _started(base: str) -> str | None: + try: + with urllib.request.urlopen(f"{base}/api/health", timeout=3) as res: + return json.loads(res.read()).get("started_at") + except Exception: + return None + + +_UP = _started(BASE) if BASE else None +pytestmark = pytest.mark.skipif( + _UP is None or _UP != _started(API), reason="이 폴더 서버 없음 · vite 가 이 폴더 백엔드로 안 감" +) + +JS_GRADE = r"""async () => { + const url = performance.getEntriesByType('resource').map((e) => e.name) + .find((n) => /design_pipeline_page\.ts/.test(n)); + const m = await import(url); + const pid = localStorage.getItem('frd_current_project_id'); + const pipe = m.pagePipeline(pid); + const cur = pipe.input('alignment') ?? { station_offsets: {}, curve_radii: {} }; + const st = pipe.output('ground').stations; + const key = st[Math.floor(st.length / 2)].chainageM.toFixed(3); + m.pipelineGrade(pid, { ...cur, station_offsets: { ...cur.station_offsets, + [key]: (cur.station_offsets?.[key] ?? 0) + 0.3 } }); +}""" + +# 잡은 /calc 하나를 이 페이지의 같은 TS 로 다시 셈 → 같은지 +JS_MIRROR = r"""async (calls) => { + const urls = performance.getEntriesByType('resource').map((e) => e.name); + const mod = async (re) => { + const u = urls.find((n) => re.test(n)); + if (!u) throw new Error('모듈 없음 ' + re); + return import(u); + }; + const plain = (v) => JSON.parse(JSON.stringify(v, (_k, x) => + x instanceof Map ? [...x.entries()] : x instanceof Set ? [...x] : x) ?? 'null'); + const where = { + 'structure.layouts': [/B06_Section_Structure_Layouts\.ts/, 'structureAreaRows'], + 'cross.berm_spans': [/common_util_berm_spans\.ts/, 'bermSpansFromStructures'], + 'masshaul.series': [/common_util_mass_haul\.ts/, 'computeMassHaulSeries'], + 'route.handle': [/B05_Profile_UI_RouteEdit_Curve\.ts/, 'dragHandleTo'], + 'route.station_fit': [/B05_Profile_RouteEdit_StationFit\.ts/, 'fitAfter'], + 'stations.swap': [/B06_Section_Station_Swap\.ts/, 'swapCrossSections'], + 'profile.tools': [/B05_Profile_UI_Profile_Straighten\.ts/, 'straightenBetween'], + 'cross.fit': [/B06_Section_UI_Cross_Fit\.ts/, 'toeOffsets'], + 'footprint.plan': [/B05_Profile_Footprint_Stage\.ts/, 'footprintStage'], + 'stations.carry_keyed': [/B06_Section_Route_Carry\.ts/, 'carryKeyedStates'], + }; + const out = []; + for (const { run, inputs, results } of calls) { + for (const id of run) { + const fn = (await mod(where[id][0]))[where[id][1]]; + const args = inputs.args?.[id]; + const local = id === 'structure.layouts' ? fn(inputs.detail.cross_sections) + : id === 'cross.berm_spans' ? fn(inputs.berm.structures) + : id === 'footprint.plan' ? fn({ out: args[0], range: null, prev: null }) + : id === 'stations.carry_keyed' ? fn((n) => args[0][n] ?? {}, args[1], args[2]) + : fn(...args); + const theirs = id === 'structure.layouts' ? results[id].areas + : id === 'cross.berm_spans' ? results[id].spans : results[id]; + out.push({ id, same: JSON.stringify(plain(local)) === JSON.stringify(theirs) }); + } + } + return out; +}""" + + +def _drop_test_logs() -> None: + from config.config_db import close_db_pool, init_db_pool + + async def run(): + pool = await init_db_pool() + try: + async with pool.acquire() as connection, connection.cursor() as cursor: + await cursor.execute("DELETE FROM system_audit_logs WHERE user_agent = %s", (MARK,)) + await connection.commit() + finally: + await close_db_pool() + + asyncio.run(run()) + + +@pytest.fixture(scope="module") +def ctx(): + sync = pytest.importorskip("playwright.sync_api") + committed = BOARD.read_bytes() + cred = json.loads((ROOT / "tmp" / "aislo_cred.json").read_text(encoding="utf-8")) + try: + with sync.sync_playwright() as play: + chrome = play.chromium.launch(channel="msedge", headless=True) + context = chrome.new_context(viewport={"width": 1600, "height": 1000}, user_agent=MARK) + 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}" # 비밀번호는 안 찍음 + try: + yield context + finally: + for key in GROUP_IDS: + _put(context, key, "both") + chrome.close() + finally: + BOARD.write_bytes(committed) + _drop_test_logs() + + +def _put(context, key: str, value: str) -> None: + r = context.request.put(f"{BASE}/api/dashboard/admin/calc-sites", data={"sites": {key: value}}) + assert r.ok, r.status + + +def _session(context, delay: int = 0): + page = context.new_page() + page.add_init_script( + f"localStorage.setItem('frd_current_project_id', '{PROJECT}');" + " performance.setResourceTimingBufferSize(20000);" + ) + calls: list[dict] = [] + + def on_response(res): + if res.url.endswith("/calc") and res.request.method == "POST": + calls.append({**json.loads(res.request.post_data), "results": res.json()["results"]}) + + page.on("response", on_response) + page.goto(f"{BASE}/#/b05-profile", wait_until="domcontentloaded") + page.wait_for_function("() => !!window.__aisloPipeline", timeout=90000) + page.wait_for_timeout(9000) + seed = "{ calls: [], local: {}, delayMs: 0 }" + page.evaluate(f"(d) => {{ (window.__aisloCalcServer ??= {seed}).delayMs = d; }}", delay) + return page, calls + + +def _act(page) -> dict: + page.evaluate(JS_GRADE) # 계획고 ▲ 한 번(가운데 측점 +0.3 m) + page.wait_for_timeout(3500) + nodes = page.evaluate(JS_NODES) + assert len(nodes) >= 3, "지도에서 교각점을 못 찾음" + node = nodes[len(nodes) // 2] + page.mouse.move(node["x"], node["y"]) + page.mouse.down() + page.mouse.move(node["x"] + 14, node["y"] + 9, steps=10) # 교각점 끌기(노선 초안) + page.mouse.up() + page.wait_for_timeout(5000) + return page.evaluate("() => window.__aisloCalcServer") + + +def _median(values: list[float]) -> float | None: + values = [v for v in values if v is not None] + return round(statistics.median(values), 2) if values else None + + +def test_기본값_서버_부름_0(ctx): + page, calls = _session(ctx) + log = _act(page) + page.close() + assert calls == [] and log["calls"] == [] + assert {"structure.layouts", "masshaul.series", "footprint.plan"} <= set(log["local"]) + assert {"route.station_fit", "stations.swap", "stations.carry_keyed"} <= set(log["local"]) + TIMES.write_text(json.dumps({"browser": log["local"]}, ensure_ascii=False), encoding="utf-8") + + +@pytest.mark.parametrize("group", list(GROUP_IDS)) +def test_묶음_서버_요청_값_같음_시간(ctx, group): + _put(ctx, group, "server") + table = json.loads(TIMES.read_text(encoding="utf-8")) if TIMES.exists() else {} + try: + for delay in (0, 30, 80): + page, calls = _session(ctx, delay) + log = _act(page) + mirror = page.evaluate(JS_MIRROR, calls) + page.close() + sent = {id_ for call in calls for id_ in call["run"]} + assert sent and sent <= GROUP_IDS[group], (group, sent) + assert mirror and all(row["same"] for row in mirror), (group, mirror) + assert all(c["same"] is not False for c in log["calls"]), log["calls"] + rows = {} + for call in log["calls"]: + rows.setdefault(call["id"], []).append(call) + table[f"{group}@{delay}"] = { + id_: { + "n": len(group_calls), + "round_ms": _median([c["roundMs"] for c in group_calls]), + "node_ms": _median([c["nodeMs"] for c in group_calls]), + "wall_ms": _median([c["wallMs"] for c in group_calls]), + "kb": round(_median([c["bytes"] for c in group_calls]) / 1024), + } + for id_, group_calls in rows.items() + } + print(group, delay, table[f"{group}@{delay}"]) + finally: + _put(ctx, group, "both") + TIMES.write_text(json.dumps(table, ensure_ascii=False, indent=1), encoding="utf-8") + assert json.loads(BOARD.read_text("utf-8"))["sites"][group] == "both" diff --git a/resources/tester/test_masshaul_single_source.py b/resources/tester/test_masshaul_single_source.py index 2c4af4fe5..4ecb605d0 100644 --- a/resources/tester/test_masshaul_single_source.py +++ b/resources/tester/test_masshaul_single_source.py @@ -77,7 +77,7 @@ def test_cross_refresh_lays_structure_areas_once(): 유토 뱃지가 갈렸다(실측 B05 −14,144.5 · B06 −12,533.2㎥).""" body = REFRESH[REFRESH.index("export async function refreshCrossDesigns") :] body = body[: body.index("\n}\n")] - assert "applyStructureAreaRows(" in body and "structureAreaRows(" in body + assert "applyStructureAreaRows(" in body and "structureAreaRows" in body # 511-A 함수째 넘김 assert "applyStructureAreaRows(" not in B05_PREVIEW # 화면(횡단 보기 · 카드 그리기)은 면적을 안 고침 — 카드 안 그려도 면적 같음(387-4) assert "applyStructureAreaRows(" not in B06_PAGE