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:
2026-10-22 12:45:47 +09:00
co-authored by Claude Opus 5.5
parent 5df40ccc47
commit e71efdfeb5
13 changed files with 467 additions and 68 deletions
+74 -27
View File
@@ -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) {
+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")])
+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) => {
+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;
+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);
+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}
+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"
}
}
+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));
})();
+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));
+14 -7
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")
@@ -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
+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"],