Merge remote-tracking branch 'origin/sub_laptop_2' into sub_laptop_3

This commit is contained in:
2026-10-22 13:09:50 +09:00
41 changed files with 1589 additions and 83 deletions
+236
View File
@@ -0,0 +1,236 @@
/* =============================================================================
* 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
* calcOnServerOnly 비동기 자리 — server 면 서버 값만(잠잠 뒤 · 끊긴 부름 = undefined)
* 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<string, number[]>;
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<void> {
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"));
});
});
}
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, unknown> | string,
signal?: AbortSignal,
extra: Partial<CalcServerCall> = {},
): Promise<Record<string, unknown>> {
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<string, unknown>;
ms?: number;
wall_ms?: number;
message?: string;
};
if (!response.ok || !data.results) throw new Error(data.message ?? `HTTP ${response.status}`);
// 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;
}
const quiet = new Map<string, AbortController>();
/** 잠잠 뒤 task — 같은 칸 새 부름 · outer 끊김 = 앞 것 끊음(undefined) · 실패도 undefined(경고 한 줄). */
export async function afterQuiet<T>(
slot: string,
task: (signal: AbortSignal) => Promise<T>,
outer?: AbortSignal,
): Promise<T | undefined> {
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);
}
}
/** 서버 묻는 꼴 — 없으면 시험 길(`inputs.args[id]` = 함수 인자 · 답 = `results[id]`). */
export interface CalcRequest {
run: string[];
inputs: Record<string, unknown>;
pick: (results: Record<string, unknown>) => 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,
});
export const trialRequest = (id: string, args: unknown[]): CalcRequest => ({
run: [id],
inputs: { args: { [id]: args } },
pick: (results) => results[id],
});
/** 브라우저 셈 + 시간 기록(판 상관없이) · server 면 잠잠 뒤 서버 대조 · 다르면 apply(서버 값). */
export function calcChecked<A extends unknown[], R>(
key: string,
id: string,
fn: (...args: A) => R,
args: A,
options: { apply?: (value: R) => void; request?: CalcRequest } = {},
): 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 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<CalcServerCall> = { browserMs, same: null, applied: false };
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<R>(
key: string,
slot: string,
request: CalcRequest,
outer?: AbortSignal,
): Promise<R | undefined> {
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<string, { body: string; value: unknown }>();
const asked = new Map<string, string>();
/** 그리기 자리 서버 값 — 같은 입력 답이면 fresh · 아니면 앞 답(없으면 undefined) + 잠잠 뒤 묻고 redraw. */
export function calcFromServer<R>(
key: string,
id: string,
args: unknown[],
redraw: () => void,
): { value: R | undefined; fresh: boolean } {
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) {
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 };
}
+13 -3
View File
@@ -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<string, CalcSiteValue>): Promise<Cal
request({ method: "PUT", body: JSON.stringify({ sites }) });
let opened: Promise<Record<string, CalcSiteValue>> | null = null;
let loaded: Record<string, CalcSiteValue> = {};
let project = "";
/** 페이지 열 때 — 새로 받음(앞 페이지 판 버림). */
export function openCalcSites(): Promise<Record<string, CalcSiteValue>> {
/** 페이지 열 때 — 새로 받음(앞 페이지 판 버림) · 서버 창구가 쓸 프로젝트. */
export function openCalcSites(projectId = project): Promise<Record<string, CalcSiteValue>> {
project = projectId;
loaded = {};
opened = fetchCalcSites().then(
(board) => board.sites,
(board) => (loaded = board.sites),
() => ({}),
);
return opened;
@@ -57,3 +62,8 @@ export function openCalcSites(): Promise<Record<string, CalcSiteValue>> {
export async function calcOnServer(key: string): Promise<boolean> {
return (await (opened ?? openCalcSites()))[key] === "server";
}
/** 동기 자리용 — 받기 전 = both. */
export const calcOnServerNow = (key: string): boolean => loaded[key] === "server";
export const calcProjectId = (): string => project;
+16 -1
View File
@@ -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<BermSpan[]>("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<BermSpan[]>("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")])
+1 -1
View File
@@ -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)
+2 -2
View File
@@ -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?.();
+12 -2
View File
@@ -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) => {
+46 -15
View File
@@ -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<Parameters<typeof computeHaulPlan>[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<string, () => 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<string, unknown> = {};
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 }));
@@ -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 { 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 {
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<string, unknown[]>;
}
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<string, Call> = {
"masshaul.series": computeMassHaulSeries as Call,
"stations.carry_keyed": ((states: Record<string, Raw>, from, to) =>
carryKeyedStates((name: KeyedState) => states[name] ?? {}, from, to)) as Call,
"route.handle": dragHandleTo as Call,
"route.station_fit": fitAfter as Call, // 편집 한 걸음 뒤 맞춤(fitCurveStations 를 부름)
"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<string, () => unknown> {
return Object.fromEntries(
TRIAL_IDS.map((id) => [id, () => plain(TRIAL[id](...((inputs.args?.[id] ?? []) as never[])))]),
);
}
@@ -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<FootprintLines> {
if (!calcOnServerNow("fixed_browser"))
return calcChecked("fixed_browser", "footprint.plan", footprintStage, [ctx]);
const asked = trialRequest("footprint.plan", [footprintInputs(ctx)]);
return calcOnServerOnly<FootprintLines>("fixed_browser", "D4", asked, ctx.signal).then(
(lines) => lines ?? footprintStage(ctx),
);
}
+31 -15
View File
@@ -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<typeof computeMassHaulSeries> = [
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<MassHaulSeries[]>("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;
@@ -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<typeof straightenBetween> = [
base,
ctx.edits(),
fromM,
toM,
ctx.savedRadii(),
];
if (calcOnServerNow("fixed_browser")) {
const asked = trialRequest("profile.tools", args);
void calcOnServerOnly<ReturnType<typeof straightenBetween>>(
"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) => {
+8 -1
View File
@@ -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();
@@ -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(
@@ -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;
+17 -4
View File
@@ -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<num
if (!refreshLocally(input)) await refreshFromServer(input);
// 구조물이 선 측점은 폐회로 면적이 정본이다 — **이 자리 한 곳**에서 늘 다시 얹는다(387-4 · 카드 그리기는
// 안 얹음 · 설계가 그대로여도 조정창 4축 · 구조물 제원이 바뀌면 면적이 바뀜 · 6 ms/67 측점).
applyStructureAreaRows(
input.detail.cross_sections,
structureAreaRows(input.detail.cross_sections),
);
// 계산 자리 quantity = server 면 잠잠 뒤 서버 값만 · 밀린 부름은 안 얹음(새 부름이 얹음 · 511-A)
const rows = calcOnServerNow("quantity")
? await calcOnServerOnly<ReturnType<typeof structureAreaRows>>(
"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);
+59
View File
@@ -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}
+4 -2
View File
@@ -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 };
+17 -6
View File
@@ -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<KeyedState, Raw>;
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);
+96 -1
View File
@@ -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
+11 -8
View File
@@ -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}
+2
View File
@@ -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)
+4 -1
View File
@@ -2,6 +2,9 @@
"version": 1,
"sites": {
"ground_sample": "both",
"corridor_mesh": "both"
"corridor_mesh": "both",
"quantity": "both",
"carry": "both",
"fixed_browser": "both"
}
}
@@ -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({
@@ -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) => {
@@ -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({
+90
View File
@@ -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));
})();
+10
View File
@@ -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<KeyedState, Raw>, before?: Keyed[], after?: Keyed[]) =>
carryKeyedStates((name) => from[name], before, after);
+21
View File
@@ -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));
+6 -1
View File
@@ -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<typeof straightenBetween> = [\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
+6 -1
View File
@@ -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
+15 -8
View File
@@ -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")
@@ -237,7 +244,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)
@@ -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()
@@ -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
@@ -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<typeof computeMassHaulSeries> = \[([^;]*?)\];", mass, re.S)
assert calls and all('["cross"]' in call for call in calls)
+6 -1
View File
@@ -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]
@@ -0,0 +1,94 @@
"""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<MassHaulSeries[]>("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 # 연결 = 다른 파일
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
+213
View File
@@ -0,0 +1,213 @@
"""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
# ── ④ 이월 · 다시 얹기(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"] # 넘김이 실제로 일어남
# ── ⑧ 고정 브라우저(시험용 · 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)
+139
View File
@@ -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<string, Call> = {")[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
@@ -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"
@@ -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
+10 -3
View File
@@ -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"],