diff --git a/A00_Common/design/design_calc_server.ts b/A00_Common/design/design_calc_server.ts index 3c9ec1639..6f6373c50 100644 --- a/A00_Common/design/design_calc_server.ts +++ b/A00_Common/design/design_calc_server.ts @@ -5,6 +5,7 @@ * serverCalc 한 번 묻기(signal 로 끊음 · 왕복 흉내 `delayMs` · 기록) * afterQuiet 잠잠 뒤 — 같은 칸 새 부름이 앞 것을 끊음(앞 것 = undefined · 늦은 답 버림) * calcChecked 동기 자리 — 브라우저 값 바로 · server 면 잠잠 뒤 같은 입력 서버 셈 → 대조 · 다르면 apply + * calcOnServerOnly 비동기 자리 — server 면 서버 값만(잠잠 뒤 · 끊긴 부름 = undefined) * calcFromServer 그리기 자리 — server 면 서버 값만(입력 같은 답 · 없으면 앞 답) · 답 오면 redraw * 기록 = `window.__aisloCalcServer`(calls · local = 브라우저 ms · delayMs) — 시간 표 · 화면 검증용. * 조작 중 왕복 = 지침 5장 「네트워크 왕복 금지」의 시험용 예외(판 기본값 both). @@ -62,9 +63,6 @@ function wait(ms: number, signal?: AbortSignal): Promise { }); } -const bodyOf = (id: string, args: unknown[]): string => - JSON.stringify({ run: [id], inputs: { args: { [id]: args } } }); - function record(call: CalcServerCall): void { const calls = log().calls; calls.push(call); @@ -96,18 +94,21 @@ export async function serverCalc( message?: string; }; if (!response.ok || !data.results) throw new Error(data.message ?? `HTTP ${response.status}`); - record({ - key, - id: run.join("+"), - browserMs: null, - roundMs: r2(now() - t0), - nodeMs: data.ms ?? null, - wallMs: data.wall_ms ?? null, - bytes: body.length, - same: null, - applied: true, - ...extra, - }); + // extra 그 객체로 기록 — 대조 뒤 같음 · apply 를 부른 쪽이 그 자리에서 채움 + record( + Object.assign(extra, { + key, + id: run.join("+"), + browserMs: null, + roundMs: r2(now() - t0), + nodeMs: data.ms ?? null, + wallMs: data.wall_ms ?? null, + bytes: body.length, + same: null, + applied: true, + ...extra, + }), + ); return data.results; } @@ -134,13 +135,38 @@ export async function afterQuiet( } } -/** 브라우저 셈 + 시간 기록(묶음 판 상관없이) · server 면 잠잠 뒤 서버 대조 · 다르면 apply(서버 값). */ +/** 서버 묻는 꼴 — 없으면 시험 길(`inputs.args[id]` = 함수 인자 · 답 = `results[id]`). */ +export interface CalcRequest { + run: string[]; + inputs: Record; + pick: (results: Record) => unknown; +} + +/** F7 구조물 면적 · C4 소단 구간 — 등록부 `node:` 입력 그대로. */ +export const areasRequest = (rows: unknown[]): CalcRequest => ({ + run: ["structure.layouts"], + inputs: { detail: { cross_sections: rows } }, + pick: (results) => (results["structure.layouts"] as { areas: unknown }).areas, +}); +export const bermRequest = (structures: unknown[]): CalcRequest => ({ + run: ["cross.berm_spans"], + inputs: { berm: { structures, chainages: [] } }, + pick: (results) => (results["cross.berm_spans"] as { spans: unknown }).spans, +}); + +const trialRequest = (id: string, args: unknown[]): CalcRequest => ({ + run: [id], + inputs: { args: { [id]: args } }, + pick: (results) => results[id], +}); + +/** 브라우저 셈 + 시간 기록(판 상관없이) · server 면 잠잠 뒤 서버 대조 · 다르면 apply(서버 값). */ export function calcChecked( key: string, id: string, fn: (...args: A) => R, args: A, - apply?: (value: R) => void, + options: { apply?: (value: R) => void; request?: CalcRequest } = {}, ): R { const t0 = now(); const value = fn(...args); @@ -149,20 +175,40 @@ export function calcChecked( local.push(browserMs); if (local.length > LOCAL_SIZE) local.shift(); if (!calcOnServerNow(key)) return value; - const body = bodyOf(id, args); + const request = options.request ?? trialRequest(id, args); + const body = JSON.stringify({ run: request.run, inputs: request.inputs }); const mine = JSON.stringify(plain(value)); const extra: Partial = { browserMs, same: null, applied: false }; - void afterQuiet(id, (signal) => serverCalc(key, [id], body, signal, extra)).then((results) => { - if (!results) return; - extra.same = JSON.stringify(results[id]) === mine; - if (!extra.same && apply) { - apply(results[id] as R); - extra.applied = true; - } - }); + void afterQuiet(id, (signal) => serverCalc(key, request.run, body, signal, extra)).then( + (results) => { + if (!results) return; + const theirs = request.pick(results); + extra.same = JSON.stringify(theirs) === mine; + if (!extra.same && options.apply) { + options.apply(theirs as R); + extra.applied = true; + } + }, + ); return value; } +/** 서버 값만(비동기 자리) — 잠잠 뒤 묻고 고른 값 · 새 부름 · outer 끊김 · 실패 = undefined. */ +export async function calcOnServerOnly( + key: string, + slot: string, + request: CalcRequest, + outer?: AbortSignal, +): Promise { + const body = JSON.stringify({ run: request.run, inputs: request.inputs }); + const results = await afterQuiet( + slot, + (signal) => serverCalc(key, request.run, body, signal), + outer, + ); + return results ? (request.pick(results) as R) : undefined; +} + const held = new Map(); const asked = new Map(); @@ -173,7 +219,8 @@ export function calcFromServer( args: unknown[], redraw: () => void, ): { value: R | undefined; fresh: boolean } { - const body = bodyOf(id, args); + const request = trialRequest(id, args); + const body = JSON.stringify({ run: request.run, inputs: request.inputs }); const hold = held.get(id); if (hold?.body === body) return { value: hold.value as R, fresh: true }; if (asked.get(id) !== body) { diff --git a/A00_Common/design/design_facility_lane.ts b/A00_Common/design/design_facility_lane.ts index f9faa06d1..5c9bf6970 100644 --- a/A00_Common/design/design_facility_lane.ts +++ b/A00_Common/design/design_facility_lane.ts @@ -20,6 +20,8 @@ import { type BermSpan, } from "../../B06_Section/B06_Section_UI_Page_Persist"; import { pipeIdAt, placePipeIds, prunePipeIds, withPipeIds } from "./design_pipe_ids"; +import { calcOnServerNow } from "./design_calc_sites"; +import { bermRequest, calcChecked, calcOnServerOnly } from "./design_calc_server"; import { plannedIrregular } from "./design_structure_status"; import { planStations } from "../../common_util/common_util_structure_station"; import { followPipeReports } from "../../B05_Profile/logic/B05_Profile_Pipeline_Follow"; @@ -155,8 +157,21 @@ export function createFacilityLane( function syncBerm(): void { const routeId = pageRouteId(projectId); if (!structuresKnown || routeId === null) return; + // 계산 자리 quantity = server 면 잠잠 뒤 서버 값만(511-A) · 아니면 브라우저 셈 + if (calcOnServerNow("quantity")) { + void calcOnServerOnly("quantity", "C4.copy", bermRequest(structures)).then( + (next) => next && putBerm(routeId, next), + ); + return; + } + putBerm( + routeId, + calcChecked("quantity", "cross.berm_spans", bermSpansFromStructures, [structures]), + ); + } + + function putBerm(routeId: number, next: BermSpan[]): void { const copy = readState("berm", projectId, routeId); - const next = bermSpansFromStructures(structures); if (Array.isArray(copy) && JSON.stringify(copy) === JSON.stringify(next)) return; writeBermSpans(projectId, routeId, next); void Promise.all([import("./design_pipeline_cross"), import("./design_pipeline_page")]) diff --git a/A00_Common/design/design_shared.ts b/A00_Common/design/design_shared.ts index 2e538b2f0..904ec427d 100644 --- a/A00_Common/design/design_shared.ts +++ b/A00_Common/design/design_shared.ts @@ -59,6 +59,7 @@ import { type CrossRefreshInput, } from "../../B06_Section/B06_Section_Cross_Refresh"; import { bermSpansFromStructures, bermSpecAt } from "../../B06_Section/B06_Section_UI_Page_Persist"; +import { areasRequest, bermRequest, calcChecked } from "./design_calc_server"; import { registeredKeys, stationIntervalOf } from "../../B06_Section/B06_Section_Station_Keys"; import { detailCross } from "../../B05_Profile/logic/B05_Profile_Pipeline_Detail"; import type { CrossOutput, PipelineStation } from "./logic/design_pipeline_types"; @@ -254,7 +255,11 @@ function startPagePipeline(shared: DesignPageShared, profile: DesignProfile): vo onUnstable: () => showToast(L("Design_Pipeline_Unstable"), "warning"), // 초안 소단 = 지금 구조물(따라간 자리)의 소단 구간(365-5 · 저장 노선은 다시 셈 창구가 세션 사본으로) bermAt: (structures) => { - const spans = bermSpansFromStructures(Array.isArray(structures) ? structures : []); + const list = Array.isArray(structures) ? structures : []; + // 계산 자리 quantity = server 면 잠잠 뒤 서버 대조만(초안 미리보기 · 511-A) + const spans = calcChecked("quantity", "cross.berm_spans", bermSpansFromStructures, [list], { + request: bermRequest(list), + }); return (chainageM) => bermSpecAt(spans, chainageM); }, // 저장 노선 횡단 = 다시 셈 창구 한 벌(365-4) — 카드 · 유토곡선 · [저장] 패치가 보는 상세 설계를 늘 고치고 그 @@ -277,7 +282,12 @@ function startPagePipeline(shared: DesignPageShared, profile: DesignProfile): vo const routeId = routeIdOf(shared); if (routeId !== null) withDraftAdjusts(detail, projectId, routeId); const rows = detail.cross_sections; - applyStructureAreaRows(rows, structureAreaRows(rows)); + // 계산 자리 quantity = server 면 잠잠 뒤 서버 값 대조 · 다르면 얹고 다시 그림(511-A) + const areas = calcChecked("quantity", "structure.layouts", structureAreaRows, [rows], { + request: areasRequest(rows), + apply: (server) => (applyStructureAreaRows(rows, server), profile.redraw()), + }); + applyStructureAreaRows(rows, areas); }, // 초안 시작 — 초안 전 상태를 떠 둠 · 버리면 그대로(측점이 달라도 · 349-0o) · 상세가 없으면 묶음 측점으로 갈아끼움 holdDraft: (routeId, saved, seed) => { diff --git a/B05_Profile/B05_Profile_UI_Profile_MassHaul.ts b/B05_Profile/B05_Profile_UI_Profile_MassHaul.ts index 87aa9f811..0e69e5259 100644 --- a/B05_Profile/B05_Profile_UI_Profile_MassHaul.ts +++ b/B05_Profile/B05_Profile_UI_Profile_MassHaul.ts @@ -52,6 +52,8 @@ import { createPanelResizer } from "@ui/ui_template_resizer"; import "@util/common_util_mass_haul.css"; import "./B05_Profile_UI_Style_MassHaul.css"; import { frameOf } from "../common_util/common_util_view_window"; +import { calcOnServerNow } from "../A00_Common/design/design_calc_sites"; +import { calcChecked, calcFromServer } from "../A00_Common/design/design_calc_server"; /** 2차 패널 펼침 여부 — 세션 동안만 유지(패널 높이·접힘과 같은 수명). */ /* 펼침 상태·높이·범례는 화면 취향(①) — 키는 등록표(`b_page_state`)가 만든다. */ @@ -317,15 +319,30 @@ export function createRouteMassHaulPanel( function reportSummary(params: RouteMassHaulDrawParams): void { if (!onSummary) return; if (!context || params.pendingRecalc) return; - emitSummary( - computeMassHaulSeries( - params.longitudinal, - params.crossSections, - context.conversion, - context.naturalSpoilMinSlope, - ["cross"], - ), + const series = crossSeries(params, context).series; + if (series) emitSummary(series); + } + + /** 횡단 기준 누가토량 — 계산 자리 quantity = server 면 서버 값(잠잠 뒤 · 그 사이 앞 값 · 511-A). */ + function crossSeries( + params: RouteMassHaulDrawParams, + ctx: RouteMassHaulContext, + ): { series: MassHaulSeries[] | undefined; fresh: boolean } { + const args: Parameters = [ + params.longitudinal, + params.crossSections, + ctx.conversion, + ctx.naturalSpoilMinSlope, + ["cross"], + ]; + if (!calcOnServerNow("quantity")) { + const series = calcChecked("quantity", "masshaul.series", computeMassHaulSeries, args); + return { series, fresh: true }; + } + const got = calcFromServer("quantity", "masshaul.series", args, () => + draw(params), ); + return { series: got.value, fresh: got.fresh }; } /** 그리기 몫 기록(349-0s) — 묶음 마지막 차례 `work.유토곡선`. */ @@ -371,13 +388,12 @@ export function createRouteMassHaulPanel( // (354-2 · 계산 코드는 공용에 남김). // 결과를 아껴 두지 **않는다** — 횡단 설계가 제자리에서 갈리므로 객체 동일성으로는 // 바뀐 것을 못 잰다(2026-09-04 캐시를 넣었다가 곡선이 안 따라와 되돌림). - const series: MassHaulSeries[] = computeMassHaulSeries( - params.longitudinal, - params.crossSections, - context.conversion, - context.naturalSpoilMinSlope, - ["cross"], - ); + const { series, fresh } = crossSeries(params, context); + if (!fresh) { + pendingChip.textContent = "서버 셈 중…"; + pendingChip.hidden = !drawnOnce; + } + if (!series) return note("서버가 유토곡선을 셈하는 중입니다."); // 총괄값은 방금 낸 계열에서 꺼낸다 — 접힘 경로처럼 따로 적분하지 않는다. if (series !== lastSeries) emitSummary(series); lastSeries = series; diff --git a/B06_Section/B06_Section_Cross_Refresh.ts b/B06_Section/B06_Section_Cross_Refresh.ts index e7c0b586d..c8659360c 100644 --- a/B06_Section/B06_Section_Cross_Refresh.ts +++ b/B06_Section/B06_Section_Cross_Refresh.ts @@ -66,6 +66,12 @@ import { readAlignment, toDesignProfile } from "../B05_Profile/logic/B05_Profile import { useStationKeys } from "./B06_Section_Station_Keys"; import { hasStaleDesigns, staleKey } from "./B06_Section_UI_Section_Common"; import { applyStructureAreaRows, structureAreaRows } from "./B06_Section_Structure_Layouts"; +import { calcOnServerNow } from "../A00_Common/design/design_calc_sites"; +import { + areasRequest, + calcChecked, + calcOnServerOnly, +} from "../A00_Common/design/design_calc_server"; import { withPipeOptions } from "./B06_Section_Section_Adjusts"; import { extendUnclosed } from "../B05_Profile/logic/B05_Profile_Sections_Ground"; import { designTerrainGridNow } from "../B05_Profile/B05_Profile_Api_TerrainGrid"; @@ -173,10 +179,17 @@ export async function refreshCrossDesigns(input: CrossRefreshInput): Promise>( + "quantity", + "F7.saved", + areasRequest(input.detail.cross_sections), + ) + : calcChecked("quantity", "structure.layouts", structureAreaRows, [ + input.detail.cross_sections, + ]); + if (rows) applyStructureAreaRows(input.detail.cross_sections, rows); return sections .filter((section) => JSON.stringify(section.design) !== before.get(section)) .map((section) => section.chainage_m); diff --git a/common_util/common_util_calc_sites.py b/common_util/common_util_calc_sites.py index cc84fe57d..4deec5a27 100644 --- a/common_util/common_util_calc_sites.py +++ b/common_util/common_util_calc_sites.py @@ -1,7 +1,8 @@ """계산 자리 판(PLAN 504-2 · 요구 309-2) — 시스템 관리자 「계산 자리」 창이 읽고 쓰는 판 하나. 판 = `resources/master_template/calc_sites.json` `{version, sites: {묶음: "both" | "server"}}`. -both = 조작 중 브라우저 셈 + [저장] 서버 정본(지금 등록부 자리) · server = 조작 중에도 서버 길. +both = 조작 중 브라우저 셈 + [저장] 서버 정본(지금 등록부 자리) · server = 조작 중에도 서버 길 +(G1 · D3 = 504-2 · ③ 토량 · ④ 이월 · ⑧ 고정 브라우저(시험용 · fixed 는 정본 칸 표시) = 511-A 창구). 판이 없거나 깨지면 기본값(전부 both = 등록부 그대로). [저장] · [확정] 서버 정본 길은 판을 안 읽음. 묶음 8 줄 = 등록부(`design_calc_registry*.ts`) # 전부 · 같은지 시험 `test_309_2_calc_sites.py`. """ @@ -24,11 +25,11 @@ GROUPS: tuple[dict[str, Any], ...] = ( { "key": "quantity", "nos": ["E1", "F7", "F8", "C4"], - "switchable": False, - "fixed": "both", - "why": "later", + "switchable": True, + "fixed": None, + "why": "quantity", }, - {"key": "carry", "nos": ["R9", "F10"], "switchable": False, "fixed": "both", "why": "later"}, + {"key": "carry", "nos": ["R9", "F10"], "switchable": True, "fixed": None, "why": "carry"}, { "key": "stages", "nos": ["R1", "R3", "R5", "R6", "R7", "G2", "G4", "P1", "P2", "P3", "P4", "P5", "P6"] @@ -54,12 +55,14 @@ GROUPS: tuple[dict[str, Any], ...] = ( { "key": "fixed_browser", "nos": ["R2", "R4", "R8", "P7", "C7", "D4"], - "switchable": False, + "switchable": True, "fixed": "browser", "why": "browser", }, ) -SWITCHABLE = frozenset(g["key"] for g in GROUPS if g["switchable"]) +# 판 줄 차례 = 묶음 차례(파일 늘 같은 꼴) +ORDER = tuple(g["key"] for g in GROUPS if g["switchable"]) +SWITCHABLE = frozenset(ORDER) LOCKED = frozenset(g["key"] for g in GROUPS if not g["switchable"]) @@ -71,7 +74,7 @@ def read_sites() -> dict[str, Any]: raw = {} stored = raw.get("sites") if isinstance(raw, dict) else None stored = stored if isinstance(stored, dict) else {} - sites = {key: stored.get(key) if stored.get(key) in VALUES else "both" for key in SWITCHABLE} + sites = {k: stored.get(k) if stored.get(k) in VALUES else "both" for k in ORDER} version = raw.get("version") if isinstance(raw, dict) else None return {"version": version if isinstance(version, int) else 0, "sites": sites} diff --git a/resources/master_template/calc_sites.json b/resources/master_template/calc_sites.json index f88037ca8..c0ad2f7a9 100644 --- a/resources/master_template/calc_sites.json +++ b/resources/master_template/calc_sites.json @@ -2,6 +2,9 @@ "version": 1, "sites": { "ground_sample": "both", - "corridor_mesh": "both" + "corridor_mesh": "both", + "quantity": "both", + "carry": "both", + "fixed_browser": "both" } } diff --git a/resources/tester/helper_511_a_browser.cjs b/resources/tester/helper_511_a_browser.cjs new file mode 100644 index 000000000..6b8b71fb9 --- /dev/null +++ b/resources/tester/helper_511_a_browser.cjs @@ -0,0 +1,90 @@ +/* PLAN 511-A 브라우저 길 헬퍼 — `design_calc_server.ts` 를 그 자리에서 트랜스파일 · 판(`design_calc_sites`)과 + * fetch 를 가짜로 끼워 판 따라 길 고름 · 잠잠 · 끊음 · 대조 · 기록을 잼. 결과 = 표준출력 JSON 한 벌. + * 짝 = `test_511_a_calc_browser.py`. */ +const path = require("path"); +const { makeLoader } = require("./helper_ts_stub_loader.cjs"); + +const ROOT = path.join(__dirname, "..", ".."); +const board = {}; +const fetched = []; +let answer = (body) => ({ [body.run[0]]: body.inputs.args?.[body.run[0]]?.[0] ?? null }); +globalThis.fetch = async (url, init) => { + const body = JSON.parse(init.body); + fetched.push({ url, run: body.run, at: Date.now() }); + if (init.signal?.aborted) throw new DOMException("끊김", "AbortError"); + return { + ok: true, + status: 200, + json: async () => ({ results: answer(body), ms: 1, wall_ms: 2 }), + }; +}; +const plain = (value) => JSON.parse(JSON.stringify(value)); +const { loadTs } = makeLoader({ + root: ROOT, + stubs: { + design_calc_sites: { + calcOnServerNow: (key) => board[key] === "server", + calcProjectId: () => "p1", + }, + design_calc_node_trial: { plain }, + }, +}); +const mod = loadTs(path.join(ROOT, "A00_Common", "design", "design_calc_server.ts")); +const tick = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); +const echo = (value) => value; + +(async () => { + const out = {}; + // 1 판 both — 브라우저 값 · 서버 부름 0 · 브라우저 ms 기록 + out.both = mod.calcChecked("quantity", "cross.fit", echo, [5]); + await tick(500); + out.bothFetched = fetched.length; + out.local = globalThis.__aisloCalcServer.local["cross.fit"].length; + + // 2 판 server — 브라우저 값 바로 · 잠잠 뒤 한 번(빠른 둘째 부름이 첫째를 끊음) · 같음 + board.quantity = "server"; + let applied = 0; + mod.calcChecked("quantity", "cross.fit", echo, [6], { apply: () => (applied += 1) }); + out.server = mod.calcChecked("quantity", "cross.fit", echo, [7], { apply: () => (applied += 1) }); + out.beforeQuiet = fetched.length; + await tick(500); + out.afterQuiet = fetched.length; + const calls = globalThis.__aisloCalcServer.calls; + out.same = calls[calls.length - 1].same; + out.applied = applied; + + // 3 서버 값이 다름 — apply(서버 값) + answer = () => ({ "cross.fit": 99 }); + let got = null; + mod.calcChecked("quantity", "cross.fit", echo, [8], { apply: (v) => (got = v) }); + await tick(500); + out.diffApplied = got; + out.diffSame = calls[calls.length - 1].same; + + // 4 그리기 자리 — 처음 undefined · 답 오면 redraw · 같은 입력 = fresh + answer = (body) => ({ "masshaul.series": ["s", body.inputs.args["masshaul.series"][0]] }); + let redraws = 0; + const first = mod.calcFromServer("quantity", "masshaul.series", [1], () => (redraws += 1)); + await tick(500); + const second = mod.calcFromServer("quantity", "masshaul.series", [1], () => (redraws += 1)); + const third = mod.calcFromServer("quantity", "masshaul.series", [2], () => (redraws += 1)); + out.draw = { first, second, third, redraws }; + await tick(500); + + // 5 비동기 자리 — outer 끊음 = undefined · 부름 0 + const before = fetched.length; + const outer = new AbortController(); + const pending = mod.calcOnServerOnly("quantity", "slot", mod.areasRequest([]), outer.signal); + outer.abort(); + out.aborted = (await pending) === undefined && fetched.length === before; + + // 6 왕복 흉내 — delayMs 만큼 늦음 + globalThis.__aisloCalcServer.delayMs = 80; + answer = () => ({ "structure.layouts": { areas: [1] } }); + const t0 = Date.now(); + out.onlyValue = await mod.calcOnServerOnly("quantity", "slot2", mod.areasRequest([])); + out.delayed = Date.now() - t0 >= 400 + 80; + out.roundMs = calls[calls.length - 1].roundMs; + out.url = fetched[fetched.length - 1].url; + process.stdout.write(JSON.stringify(out)); +})(); diff --git a/resources/tester/helper_511_a_mirror.cjs b/resources/tester/helper_511_a_mirror.cjs new file mode 100644 index 000000000..a855da9fc --- /dev/null +++ b/resources/tester/helper_511_a_mirror.cjs @@ -0,0 +1,21 @@ +/* PLAN 511-A 거울 헬퍼 — 브라우저 자리가 부르는 TS 함수를 그 자리에서 트랜스파일해 Node 로 바로 부름. + * 입력 = argv[2] JSON `[{ mod: 뿌리 기준 경로, fn: export 이름, args: [...] }]` · 출력 = 표준출력 JSON 목록 + * (Map · Set → 목록 = 서버 `plain` 과 같은 꼴). 짝 = `test_511_a_calc_server.py`(서버 창구 답과 견줌). */ +const fs = require("fs"); +const path = require("path"); +const { makeLoader, memoryStorage } = require("./helper_ts_stub_loader.cjs"); + +const ROOT = path.join(__dirname, "..", ".."); +globalThis.window = { sessionStorage: memoryStorage(), localStorage: memoryStorage() }; +const { loadTs } = makeLoader({ root: ROOT }); + +const plain = (value) => + JSON.parse( + JSON.stringify(value, (_key, item) => + item instanceof Map ? [...item.entries()] : item instanceof Set ? [...item] : item, + ) ?? "null", + ); + +const job = JSON.parse(fs.readFileSync(process.argv[2], "utf8")); +const out = job.map(({ mod, fn, args }) => plain(loadTs(path.join(ROOT, mod))[fn](...args))); +process.stdout.write(JSON.stringify(out)); diff --git a/resources/tester/test_309_2_calc_sites.py b/resources/tester/test_309_2_calc_sites.py index f1cfad168..fdb1f62ce 100644 --- a/resources/tester/test_309_2_calc_sites.py +++ b/resources/tester/test_309_2_calc_sites.py @@ -58,27 +58,33 @@ def test_묶음_여덟_줄_등록부_전부(): nos = [no for group in calc_sites.GROUPS for no in group["nos"]] assert len(calc_sites.GROUPS) == 8 assert len(nos) == len(set(nos)) and set(nos) == set(sites) - assert calc_sites.SWITCHABLE == {"ground_sample", "corridor_mesh"} + assert calc_sites.SWITCHABLE == { + "ground_sample", + "corridor_mesh", + "quantity", + "carry", + "fixed_browser", + } # 511-A 셋 더 for group in calc_sites.GROUPS: got = {sites[no] for no in group["nos"]} - if group["switchable"] or group["fixed"] == "both": - assert got == {"browser_now"}, group["key"] + if group["fixed"] == "browser": + assert got == {"browser_only"}, group["key"] elif group["fixed"] == "server": assert got <= {"server_canon", "server_only"}, group["key"] else: - assert got == {"browser_only"}, group["key"] + assert got == {"browser_now"}, group["key"] by_key = {g["key"]: g["nos"] for g in calc_sites.GROUPS} assert by_key["ground_sample"] == ["G1"] and by_key["corridor_mesh"] == ["D3"] def test_커밋한_판_기본값_없음_깨짐(tmp_path, monkeypatch): committed = json.loads(calc_sites.PATH.read_text(encoding="utf-8")) - assert committed["sites"] == {"ground_sample": "both", "corridor_mesh": "both"} + assert committed["sites"] == dict.fromkeys(calc_sites.SWITCHABLE, "both") assert set(calc_sites.read_sites()["sites"].values()) == {"both"} monkeypatch.setattr(calc_sites, "PATH", tmp_path / "none.json") assert calc_sites.read_sites() == { "version": 0, - "sites": {"ground_sample": "both", "corridor_mesh": "both"}, + "sites": dict.fromkeys(calc_sites.SWITCHABLE, "both"), } broken = tmp_path / "broken.json" broken.write_text('{"sites": {"ground_sample": "server", "corridor_mesh": "x"', "utf-8") @@ -157,7 +163,8 @@ def test_잠금_모르는_줄_400_감사_한_줄(client): done = http.put(url, json={"sites": {"ground_sample": "server"}}) assert done.status_code == 200 and done.json()["changed"] == ["ground_sample"] saved = json.loads(board.read_text("utf-8")) - assert saved == {"version": 2, "sites": {"ground_sample": "server", "corridor_mesh": "both"}} + sites = {**dict.fromkeys(calc_sites.SWITCHABLE, "both"), "ground_sample": "server"} + assert saved == {"version": 2, "sites": sites} inserts = [args for sql, args in log if "system_audit_logs" in sql] assert len(inserts) == 1 assert inserts[0][:5] == (7, "CALC_SITE_CHANGE", "calc_site", None, "ground_sample=server") diff --git a/resources/tester/test_511_a_calc_browser.py b/resources/tester/test_511_a_calc_browser.py new file mode 100644 index 000000000..4c45b0e84 --- /dev/null +++ b/resources/tester/test_511_a_calc_browser.py @@ -0,0 +1,73 @@ +"""PLAN 511-A (요구 320-4) — 브라우저 「서버」 길(`design_calc_server.ts`) · 판 따라 길 · 자리. + +못박는 것 + - 판 both = 브라우저 값 · 서버 부름 0(넣어도 바뀌는 것 0) · server = 잠잠 400 ms 뒤 한 번 + (앞 부름 끊음). + - 대조 = 같으면 그대로 · 다르면 apply(서버 값) · 그리기 자리 = 답 오면 redraw · 같은 입력 = fresh. + - 끊긴 부름 = undefined · 왕복 흉내 delayMs · 창구 주소 = /projects/{id}/calc. + - 자리마다 판 열쇠 · [저장] · [확정] 길은 서버 길을 안 부름. + + ./venv/Scripts/python.exe -m pytest resources/tester/test_511_a_calc_browser.py -q +""" + +from __future__ import annotations + +import json +import shutil +import subprocess +from pathlib import Path + +import pytest + +HERE = Path(__file__).resolve().parent +ROOT = HERE.parents[1] + + +def _text(rel: str) -> str: + return (ROOT / rel).read_text(encoding="utf-8") + + +@pytest.mark.skipif(shutil.which("node") is None, reason="node 없음") +def test_판_따라_길_고름_잠잠_대조_기록(): + proc = subprocess.run( # noqa: S603 — 고정 실행 파일 + ["node", str(HERE / "helper_511_a_browser.cjs")], + cwd=ROOT, + capture_output=True, + text=True, + encoding="utf-8", + timeout=60, + ) + assert proc.returncode == 0, proc.stderr + out = json.loads(proc.stdout) + assert out["both"] == 5 and out["bothFetched"] == 0 and out["local"] == 1 + assert out["server"] == 7 and out["beforeQuiet"] == 0 and out["afterQuiet"] == 1 + assert out["same"] is True and out["applied"] == 0 + assert out["diffApplied"] == 99 and out["diffSame"] is False + draw = out["draw"] + assert draw["first"] == {"fresh": False} and draw["redraws"] == 1 + assert draw["second"] == {"value": ["s", 1], "fresh": True} + assert draw["third"] == {"value": ["s", 1], "fresh": False} # 새 입력 = 앞 답 + 다시 물음 + assert out["aborted"] is True + assert out["onlyValue"] == [1] and out["delayed"] and out["roundMs"] >= 80 + assert out["url"] == "http://fake/projects/p1/calc" # API_BASE_URL(가짜) + 창구 + + +def test_자리마다_판_열쇠_저장_길_안_부름(): + refresh = _text("B06_Section/B06_Section_Cross_Refresh.ts") + assert 'calcOnServerNow("quantity")' in refresh and '"F7.saved"' in refresh + shared = _text("A00_Common/design/design_shared.ts") + assert 'calcChecked("quantity", "structure.layouts"' in shared + assert 'calcChecked("quantity", "cross.berm_spans"' in shared + lane = _text("A00_Common/design/design_facility_lane.ts") + assert '"C4.copy", bermRequest(structures)' in lane + mass = _text("B05_Profile/B05_Profile_UI_Profile_MassHaul.ts") + assert 'calcFromServer("quantity", "masshaul.series"' in mass + assert "openCalcSites(projectId)" in _text("A00_Common/design/design_page.ts") + for rel in ( + "A00_Common/design/design_save.ts", + "A00_Common/design/design_save_api.ts", + "A00_Common/design/design_reset.ts", + ): + assert "design_calc_server" not in _text(rel), rel + page = _text("A00_Common/design/design_pipeline_page.ts") + assert len(page.splitlines()) <= 699 and "design_calc_server" not in page diff --git a/resources/tester/test_511_a_calc_mirror.py b/resources/tester/test_511_a_calc_mirror.py new file mode 100644 index 000000000..066fc5206 --- /dev/null +++ b/resources/tester/test_511_a_calc_mirror.py @@ -0,0 +1,94 @@ +"""PLAN 511-A (요구 320-4) — 묶음마다 서버 창구 답 = 브라우저 자리 TS 답(거울). + +브라우저 쪽 = `helper_511_a_mirror.cjs` 가 그 자리 함수를 바로 부름 · 서버 쪽 = 늘 떠 있는 Node +(`run_calc_served` · 창구와 같은 본문 꼴 — `design_calc_server.ts` `areasRequest` · `bermRequest` · +시험 길 args). +고정 자료 = golden_472 · 272 소단 사례 · 손 자료(공유 DB · 실제 마스터 안 씀). + + ./venv/Scripts/python.exe -m pytest resources/tester/test_511_a_calc_mirror.py -q +""" + +from __future__ import annotations + +import json +import shutil +import subprocess +import sys +from pathlib import Path + +import pytest + +HERE = Path(__file__).resolve().parent +ROOT = HERE.parents[1] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) +if str(HERE) not in sys.path: + sys.path.insert(0, str(HERE)) + +from common_util import common_util_calc_runner as runner # noqa: E402 + +FIXTURE = HERE / "fixtures" / "golden_472" + +pytestmark = pytest.mark.skipif( + shutil.which("node") is None or not (ROOT / "config" / "node_modules" / "vite").is_dir(), + reason="node · 프론트엔드 의존성 없음", +) + + +def _fixture(name: str): + return json.loads((FIXTURE / name).read_text(encoding="utf-8")) + + +def _browser(tmp_path: Path, calls: list[dict]) -> list: + job = tmp_path / "job.json" + job.write_text(json.dumps(calls, ensure_ascii=False), encoding="utf-8") + proc = subprocess.run( # noqa: S603 — 고정 실행 파일 + ["node", str(HERE / "helper_511_a_mirror.cjs"), str(job)], + cwd=ROOT, + capture_output=True, + text=True, + encoding="utf-8", + timeout=120, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +def _served(run: list[str], inputs: dict) -> dict: + answer = runner.run_calc_served(run, inputs) + assert answer and "results" in answer, answer + return answer["results"] + + +def _trial(id_: str, args: list): + return _served([id_], {"args": {id_: args}})[id_] + + +# ── ③ 토량 · 구조물 셈(E1 · F7 · C4 · F8 = 브라우저 부름 없음) ─────────────────────── + + +def test_quantity_F7_C4_E1_서버_브라우저_같음(tmp_path): + from test_272_berm_source import STRUCTURE_CASES + + detail = _fixture("detail.json") + sections = detail["cross_sections"] + conv = _fixture("context.json")["earthwork_conversion"] + long_ = detail["longitudinal"] + calls = [ + {"mod": "B06_Section/B06_Section_Structure_Layouts.ts", "fn": "structureAreaRows", + "args": [sections]}, + {"mod": "common_util/common_util_mass_haul.ts", "fn": "computeMassHaulSeries", + "args": [long_, sections, conv, None, ["cross"]]}, + ] + [ + {"mod": "common_util/common_util_berm_spans.ts", "fn": "bermSpansFromStructures", + "args": [case]} + for case in STRUCTURE_CASES + ] # fmt: skip + browser = _browser(tmp_path, calls) + areas = _served(["structure.layouts"], {"detail": {"cross_sections": sections}}) + assert areas["structure.layouts"]["areas"] == browser[0] and browser[0] + series = _trial("masshaul.series", [long_, sections, conv, None, ["cross"]]) + assert series == browser[1] and series[0]["result"]["final_cumulative_m3"] + for index, case in enumerate(STRUCTURE_CASES): + got = _served(["cross.berm_spans"], {"berm": {"structures": case, "chainages": []}}) + assert got["cross.berm_spans"]["spans"] == browser[2 + index], index diff --git a/ui_template/ui_template_locale_m1.ts b/ui_template/ui_template_locale_m1.ts index 8c93a946c..bc6109fff 100644 --- a/ui_template/ui_template_locale_m1.ts +++ b/ui_template/ui_template_locale_m1.ts @@ -106,7 +106,14 @@ export const ui_locales_m1 = { "끄면 미리보기 안 지음 · [저장] · [확정] 때 서버가 지은 모형만", "Off = no preview · only the model the server built on [Save] · [Confirm]", ], - B01_CalcSites_Why_later: ["서버 계산 창구 뒤(2단계)", "After the server calc endpoint (step 2)"], + B01_CalcSites_Why_quantity: [ + "끄면 서버 창구가 셈(조작 중 왕복 · 느림) · F8 = 브라우저 부름 없음(판만)", + "Off = server endpoint calcs (round trip while editing · slower) · F8 = no browser call", + ], + B01_CalcSites_Why_carry: [ + "끄면 잠잠 뒤 서버 셈 · F10 = 브라우저 부름 없음(판만)", + "Off = server calc after a pause · F10 = no browser call", + ], B01_CalcSites_Why_pair_later: [ "파이썬 짝 섞임 · 짝 모으기(472-4) 뒤", "Mixed Python pairs · after pair merge (472-4)", @@ -120,8 +127,8 @@ export const ui_locales_m1 = { "Server only (GIS · raster · value)", ], B01_CalcSites_Why_browser: [ - "입력 도구 · 그림 — 서버 셈 없음", - "Input tools · drawing — no server calc", + "시험용 — 끄면 서버로 돌림(느림) · P7 · D4 = 서버 값 · 끌기 도구(R2 · R4 · R8 · C7) = 대조만", + "Test only — off = server (slow) · P7 · D4 = server value · drag tools (R2 · R4 · R8 · C7) = compare only", ], B01_CalcSites_Saved: ["계산 자리 저장함", "Calc sites saved"], B01_CalcSites_Leave: ["저장 안 한 계산 자리가 있음", "You have unsaved calc sites"],