feat(511-A ③ 토량 · 구조물 셈 열기): 계산 자리 판 quantity · carry · fixed_browser 바꿀 수 있음(기본 both · 판 줄 차례 고정) · quantity = server 면 F7 저장 노선 면적 · C4 소단 사본 = 잠잠 뒤 서버 값만 · E1 유토곡선 = 서버 값으로 다시 그림(그 사이 앞 곡선 · 「서버 셈 중」) · F7 초안 면적 · C4 초안 소단 = 브라우저 값 + 서버 대조(다르면 얹음) · F8 = 브라우저 부름 없음 · 거울 시험(서버 답 = 브라우저 TS 답) — 요구 320-4
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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<void> {
|
||||
});
|
||||
}
|
||||
|
||||
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<T>(
|
||||
}
|
||||
}
|
||||
|
||||
/** 브라우저 셈 + 시간 기록(묶음 판 상관없이) · server 면 잠잠 뒤 서버 대조 · 다르면 apply(서버 값). */
|
||||
/** 서버 묻는 꼴 — 없으면 시험 길(`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,
|
||||
});
|
||||
|
||||
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,
|
||||
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<A extends unknown[], R>(
|
||||
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<CalcServerCall> = { 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<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>();
|
||||
|
||||
@@ -173,7 +219,8 @@ export function calcFromServer<R>(
|
||||
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) {
|
||||
|
||||
@@ -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")])
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,6 +2,9 @@
|
||||
"version": 1,
|
||||
"sites": {
|
||||
"ground_sample": "both",
|
||||
"corridor_mesh": "both"
|
||||
"corridor_mesh": "both",
|
||||
"quantity": "both",
|
||||
"carry": "both",
|
||||
"fixed_browser": "both"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
})();
|
||||
@@ -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));
|
||||
@@ -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")
|
||||
|
||||
@@ -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<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
|
||||
@@ -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
|
||||
@@ -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"],
|
||||
|
||||
Reference in New Issue
Block a user