From eff9d7f5d02e55c4bd9bca7ff29e7a31d3a639dc Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 22 Oct 2026 13:09:00 +0900 Subject: [PATCH] =?UTF-8?q?feat(511-A=20=E2=91=A7=20=EA=B3=A0=EC=A0=95=20?= =?UTF-8?q?=EB=B8=8C=EB=9D=BC=EC=9A=B0=EC=A0=80=20=EC=8B=9C=ED=97=98?= =?UTF-8?q?=EC=9A=A9=20=EC=97=B4=EA=B8=B0):=20fixed=5Fbrowser=20=3D=20serv?= =?UTF-8?q?er=20=EB=A9=B4=20P7=20=EC=A7=81=EC=84=A0=ED=99=94=20=C2=B7=20D4?= =?UTF-8?q?=20=EB=B0=9C=EC=9E=90=EA=B5=AD=20=EB=8B=A8=EA=B3=84=20=3D=20?= =?UTF-8?q?=EC=9E=A0=EC=9E=A0=20=EB=92=A4=20=EC=84=9C=EB=B2=84=20=EA=B0=92?= =?UTF-8?q?(=EB=81=8A=EA=B8=B4=20=EC=B0=A8=EB=A1=80=20=3D=20=EB=AC=B6?= =?UTF-8?q?=EC=9D=8C=EC=9D=B4=20=EB=B2=84=EB=A6=BC=20=C2=B7=20=EB=AA=BB=20?= =?UTF-8?q?=EB=B0=9B=EC=9C=BC=EB=A9=B4=20=EB=B8=8C=EB=9D=BC=EC=9A=B0?= =?UTF-8?q?=EC=A0=80)=20=C2=B7=20=EB=81=8C=EA=B8=B0=20=EB=8F=84=EA=B5=AC?= =?UTF-8?q?=20R2=20=EC=86=90=EC=9E=A1=EC=9D=B4=20=C2=B7=20R4=20=EC=B8=A1?= =?UTF-8?q?=EC=A0=90=20=EB=A7=9E=EC=B6=A4=20=C2=B7=20R8=20=EA=B0=88?= =?UTF-8?q?=EC=95=84=EB=81=BC=EC=9B=80=20=C2=B7=20C7=20=EB=B0=98=ED=8F=AD?= =?UTF-8?q?=20=EB=A7=9E=EC=B6=A4=20=3D=20=EB=B8=8C=EB=9D=BC=EC=9A=B0?= =?UTF-8?q?=EC=A0=80=20=EA=B0=92=20+=20=EB=86=93=EC=9D=80=20=EB=92=A4=20?= =?UTF-8?q?=EC=84=9C=EB=B2=84=20=EB=8C=80=EC=A1=B0=20=C2=B7=20=EB=91=98?= =?UTF-8?q?=EB=A0=88=20=EC=8B=9C=ED=97=98=20=EA=B8=80=20=C2=B7=20=EA=B0=80?= =?UTF-8?q?=EC=A7=9C=20=EB=AA=A8=EB=93=88=20=EB=A7=9E=EC=B6=A4=20=C2=B7=20?= =?UTF-8?q?=ED=99=94=EB=A9=B4=20=EC=8B=9C=ED=97=98(=EB=AC=B6=EC=9D=8C?= =?UTF-8?q?=EB=A7=88=EB=8B=A4=20/calc=20=EB=8B=B5=20=3D=20=EA=B7=B8=20?= =?UTF-8?q?=ED=8E=98=EC=9D=B4=EC=A7=80=20TS)=20=E2=80=94=20=EC=9A=94?= =?UTF-8?q?=EA=B5=AC=20320-4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- A00_Common/design/design_calc_server.ts | 2 +- A00_Common/design/design_pipeline_page.ts | 4 +- .../design/logic/design_calc_node_trial.ts | 4 +- B05_Profile/B05_Profile_Footprint_Server.ts | 40 +++ .../B05_Profile_UI_Profile_Panel_Tools.ts | 23 +- B05_Profile/B05_Profile_UI_RouteEdit.ts | 9 +- .../B05_Profile_UI_RouteEdit_Cross_Draw.ts | 6 +- .../B05_Profile_UI_RouteEdit_Pointer.ts | 11 +- B06_Section/B06_Section_Section_Store.ts | 6 +- resources/tester/helper_219_1_arrow_owner.cjs | 7 + .../tester/helper_346_1_pipeline_page.cjs | 11 +- .../tester/helper_472_d_b05_pipe_undo.cjs | 7 + resources/tester/test_271_straighten_pick.py | 7 +- resources/tester/test_280_4_fit.py | 7 +- .../tester/test_309_2_calc_sites_browser.py | 4 +- .../tester/test_346_3_route_save_screen.py | 2 +- .../tester/test_354_2_masshaul_cross_only.py | 2 + resources/tester/test_366_3_station_fit.py | 7 +- resources/tester/test_511_a_calc_browser.py | 17 +- resources/tester/test_511_a_calc_mirror.py | 83 +++++++ .../tester/test_511_a_calc_sites_browser.py | 235 ++++++++++++++++++ .../tester/test_masshaul_single_source.py | 2 +- 22 files changed, 477 insertions(+), 19 deletions(-) create mode 100644 B05_Profile/B05_Profile_Footprint_Server.ts create mode 100644 resources/tester/test_511_a_calc_sites_browser.py diff --git a/A00_Common/design/design_calc_server.ts b/A00_Common/design/design_calc_server.ts index 6f6373c50..c0fc466a2 100644 --- a/A00_Common/design/design_calc_server.ts +++ b/A00_Common/design/design_calc_server.ts @@ -154,7 +154,7 @@ export const bermRequest = (structures: unknown[]): CalcRequest => ({ pick: (results) => (results["cross.berm_spans"] as { spans: unknown }).spans, }); -const trialRequest = (id: string, args: unknown[]): CalcRequest => ({ +export const trialRequest = (id: string, args: unknown[]): CalcRequest => ({ run: [id], inputs: { args: { [id]: args } }, pick: (results) => results[id], diff --git a/A00_Common/design/design_pipeline_page.ts b/A00_Common/design/design_pipeline_page.ts index cb2021ab2..ac82d8e1a 100644 --- a/A00_Common/design/design_pipeline_page.ts +++ b/A00_Common/design/design_pipeline_page.ts @@ -100,7 +100,7 @@ import { buildStations, SECTION_STATION_INTERVAL_M, } from "../../B05_Profile/logic/B05_Profile_Sections_Stations"; -import { footprintStage } from "../../B05_Profile/logic/B05_Profile_Footprint_Stage"; +import { footprintStageSited } from "../../B05_Profile/B05_Profile_Footprint_Server"; import type { PlanPipe, PlanStructure, @@ -357,7 +357,7 @@ function createPagePipeline( return out; }); }, - footprint: footprintStage, + footprint: footprintStageSited, // 계산 자리 판(511-A) drainage: link.side?.pipelineStage, corridor: (ctx) => { if (ctx.inputs.route) options.onCorridorStale?.(); diff --git a/A00_Common/design/logic/design_calc_node_trial.ts b/A00_Common/design/logic/design_calc_node_trial.ts index b2f0f30ca..d22ead5fa 100644 --- a/A00_Common/design/logic/design_calc_node_trial.ts +++ b/A00_Common/design/logic/design_calc_node_trial.ts @@ -10,7 +10,7 @@ import { computeMassHaulSeries } from "@util/common_util_mass_haul"; import { footprintStage } from "../../../B05_Profile/logic/B05_Profile_Footprint_Stage"; -import { fitCurveStations } from "../../../B05_Profile/logic/B05_Profile_RouteEdit_StationFit"; +import { fitAfter } from "../../../B05_Profile/logic/B05_Profile_RouteEdit_StationFit"; import { straightenBetween } from "../../../B05_Profile/logic/B05_Profile_UI_Profile_Straighten"; import { dragHandleTo } from "../../../B05_Profile/logic/B05_Profile_UI_RouteEdit_Curve"; import { @@ -42,7 +42,7 @@ const TRIAL: Record = { "stations.carry_keyed": ((states: Record, from, to) => carryKeyedStates((name: KeyedState) => states[name] ?? {}, from, to)) as Call, "route.handle": dragHandleTo as Call, - "route.station_fit": fitCurveStations as Call, + "route.station_fit": fitAfter as Call, // 편집 한 걸음 뒤 맞춤(fitCurveStations 를 부름) "stations.swap": swapCrossSections as Call, "profile.tools": straightenBetween as Call, "cross.fit": toeOffsets as Call, diff --git a/B05_Profile/B05_Profile_Footprint_Server.ts b/B05_Profile/B05_Profile_Footprint_Server.ts new file mode 100644 index 000000000..831a8676a --- /dev/null +++ b/B05_Profile/B05_Profile_Footprint_Server.ts @@ -0,0 +1,40 @@ +/* ============================================================================= + * B05_Profile/B05_Profile_Footprint_Server.ts + * 계산 묶음 footprint 단계(D4) + 계산 자리 「서버」 길(PLAN 511-A) — 판 fixed_browser = server 면 잠잠 뒤 서버 + * (`footprint.plan` · 같은 `footprintStage` 전 구간)가 셈 · 끊긴 차례는 묶음이 버림 · 못 받으면 브라우저 셈. + * 판 both = 지금 그대로(같은 틱 · 브라우저 셈). + * ========================================================================== */ + +import type { StageContext } from "../A00_Common/design/logic/design_pipeline_types"; +import { calcOnServerNow } from "../A00_Common/design/design_calc_sites"; +import { + calcChecked, + calcOnServerOnly, + trialRequest, +} from "../A00_Common/design/design_calc_server"; +import { footprintStage } from "./logic/B05_Profile_Footprint_Stage"; +import type { FootprintLines } from "./logic/B05_Profile_PlanFootprint"; + +/** 단계가 읽는 몫만 — 측점(지반) · 측점 횡단 detail · 노선 선. */ +export function footprintInputs(ctx: StageContext<"footprint">): unknown { + const byKey = ctx.out.cross?.byKey ?? {}; + const route = ctx.out.route; + return { + ground: { stations: ctx.out.ground?.stations ?? [] }, + cross: { + byKey: Object.fromEntries(Object.entries(byKey).map(([k, v]) => [k, { detail: v.detail }])), + }, + route: route ? { line: route.line, chainage: route.chainage, lengthM: route.lengthM } : null, + }; +} + +export function footprintStageSited( + ctx: StageContext<"footprint">, +): FootprintLines | Promise { + if (!calcOnServerNow("fixed_browser")) + return calcChecked("fixed_browser", "footprint.plan", footprintStage, [ctx]); + const asked = trialRequest("footprint.plan", [footprintInputs(ctx)]); + return calcOnServerOnly("fixed_browser", "D4", asked, ctx.signal).then( + (lines) => lines ?? footprintStage(ctx), + ); +} diff --git a/B05_Profile/B05_Profile_UI_Profile_Panel_Tools.ts b/B05_Profile/B05_Profile_UI_Profile_Panel_Tools.ts index fdcc2d9ec..428d5e7b8 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Panel_Tools.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Panel_Tools.ts @@ -40,6 +40,12 @@ import { createRangeBandView } from "../A00_Common/design/design_profile_range_b import { arrowsBelongToProfile } from "../B06_Section/B06_Section_UI_Cross_Panel_Base"; import { stepStructureStation } from "../common_util/common_util_structure_station"; import { draftProjectId, useDesignStore } from "../A00_Common/design/design_store"; +import { calcOnServerNow } from "../A00_Common/design/design_calc_sites"; +import { + calcChecked, + calcOnServerOnly, + trialRequest, +} from "../A00_Common/design/design_calc_server"; /** 방향키 ▲▼ 한 번에 움직이는 계획고(m) — 좌우(구조물 측점)는 1 m 걸음(234-1 · `stepStructureStation`). */ const KEY_STEP_M = 0.1; @@ -141,7 +147,22 @@ export function createPanelTools(ctx: PanelToolsContext): PanelTools { tools.handleRunPick(sub); return; } - ctx.applyEdits(straightenBetween(base, ctx.edits(), fromM, toM, ctx.savedRadii())); + // 계산 자리 fixed_browser = server 면 잠잠 뒤 서버 값으로 얹음(511-A) + const args: Parameters = [ + base, + ctx.edits(), + fromM, + toM, + ctx.savedRadii(), + ]; + if (calcOnServerNow("fixed_browser")) { + const asked = trialRequest("profile.tools", args); + void calcOnServerOnly>( + "fixed_browser", + "P7", + asked, + ).then((edits) => edits && ctx.applyEdits(edits)); + } else ctx.applyEdits(calcChecked("fixed_browser", "profile.tools", straightenBetween, args)); tools.clear(); }, onShift: (runs, delta) => { diff --git a/B05_Profile/B05_Profile_UI_RouteEdit.ts b/B05_Profile/B05_Profile_UI_RouteEdit.ts index 86ad60483..328e5c691 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit.ts @@ -74,6 +74,7 @@ import type { DesignPageHost } from "../A00_Common/design/design_page"; import * as draftLink from "./B05_Profile_UI_RouteEdit_Draft"; import { bindStationSnap, paintStationSnap } from "./B05_Profile_UI_RouteEdit_SnapStep"; import * as fit from "./logic/B05_Profile_RouteEdit_StationFit"; +import { calcChecked } from "../A00_Common/design/design_calc_server"; import "./B05_Profile_UI_Style_RouteEdit.css"; /** 등고선 눈금을 고를 때 쓸 라벨 예산 — B04 지도 기본값(350)보다 **넉넉히** 둔다 @@ -434,7 +435,13 @@ export async function mountRouteEditor( markEdited(); const stop = fitting && before ? edits.floorStop(before, planned, nodeInfo, fitLimits()) : ""; if (stop && before) return restoreSnapshot(before, stop, false); - if (stationSnap && fitting) planned = fit.fitAfter(before, snapshotNow(), fitLimits()); + // 계산 자리 fixed_browser = server 면 잠잠 뒤 서버 대조만(511-A) + if (stationSnap && fitting) + planned = calcChecked("fixed_browser", "route.station_fit", fit.fitAfter, [ + before, + snapshotNow(), + fitLimits(), + ]); markEdited(); writeRouteLocks(projectId, planned, apexLock); // 고정은 [취소] · 다시 열기에도 남는다(22-5). syncCurveBar(); diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Cross_Draw.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Cross_Draw.ts index a0c2a7fc1..daacecf8d 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Cross_Draw.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Cross_Draw.ts @@ -21,6 +21,7 @@ import type { ContentBounds } from "../A00_Common/b_svg_zoom_pan"; import type { CrossSection } from "./../B06_Section/B06_Section_Api_Fetch"; import { fillSlopeLengths, toeOffsets } from "../B06_Section/logic/B06_Section_UI_Cross_Fit"; import type { CrossPreviewResponse } from "./B05_Profile_Api_Replan"; +import { calcChecked } from "../A00_Common/design/design_calc_server"; /** 그림 가장자리 여백(px) — 위는 범례 한 줄, 아래는 눈금 안내 한 줄 몫이다. */ export const CROSS_PLOT_PAD = { left: 10, right: 10, top: 20, bottom: 20 }; @@ -113,7 +114,10 @@ export function buildCrossSvg( const ground = preview.samples .filter((sample) => sample.valid && sample.elevation_m !== null) .map((sample) => [Number(sample.offset_m), Number(sample.elevation_m)] as Point); - const toes = preview.design ? toeOffsets(asSection(preview)) : { left: null, right: null }; + // 계산 자리 fixed_browser = server 면 잠잠 뒤 서버 대조만(511-A) + const toes = preview.design + ? calcChecked("fixed_browser", "cross.fit", toeOffsets, [asSection(preview)]) + : { left: null, right: null }; // 계획 횡단선은 사면 끝에서 끊는다 — 그 바깥은 원지반이고 지반선이 이미 그린다. const design = clipToToes( (preview.design?.design_line ?? []).map( diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Pointer.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Pointer.ts index 40edb8fbd..dae66c034 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Pointer.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Pointer.ts @@ -33,6 +33,7 @@ import { type RouteEditPick, } from "./B05_Profile_UI_RouteEdit_Input"; import { markShown } from "./B05_Profile_UI_RouteEdit_Marks"; +import { calcChecked } from "../A00_Common/design/design_calc_server"; import { curveShortfalls, floorNote, @@ -188,7 +189,15 @@ export function bindRouteEditPointer(params: RouteEditPointerParams): RouteEditP const before = planned[node - 1]; const after = planned[node + 1]; if (!before || !after) return null; - return curveDragTo(before, [curve.apex[0], curve.apex[1]], after, which, to); + // 계산 자리 fixed_browser = server 면 놓은 뒤(잠잠) 서버 대조만(끌기 도구 · 511-A) + const apex: Vertex = [curve.apex[0], curve.apex[1]]; + return calcChecked("fixed_browser", "route.handle", curveDragTo, [ + before, + apex, + after, + which, + to, + ]); } let dragNode = -1; diff --git a/B06_Section/B06_Section_Section_Store.ts b/B06_Section/B06_Section_Section_Store.ts index 4fdce6dd3..548bc6fd1 100644 --- a/B06_Section/B06_Section_Section_Store.ts +++ b/B06_Section/B06_Section_Section_Store.ts @@ -66,6 +66,7 @@ import { import { sameStationList, swapCrossSections } from "./logic/B06_Section_Station_Swap"; import { mergeCrossPatches } from "./logic/B06_Section_UI_Page_Patches"; import { emitSwap, type DetailListener } from "./B06_Section_Swap_Bus"; +import { calcChecked } from "../A00_Common/design/design_calc_server"; export { onAnySwap } from "./B06_Section_Swap_Bus"; @@ -264,14 +265,15 @@ export async function swapSectionStations( const interval = stationIntervalOf(current); const oldKeys = registeredKeys(projectId, routeId, current.cross_sections, interval); if (sameStationList(current.cross_sections, oldKeys, stations)) return null; - const swap = swapCrossSections( + // 계산 자리 fixed_browser = server 면 잠잠 뒤 서버 대조만(511-A) + const swap = calcChecked("fixed_browser", "stations.swap", swapCrossSections, [ current, stations, oldKeys, interval, useDesignStore(projectId, null).get("routeDraft") !== null, savedDesigns(projectId, routeId) ?? [], - ); // 초안 없으면 정본 표본(467) + ]); // 초안 없으면 정본 표본(467) // 설계는 사본 — 면적 얹기 · 다시 셈이 앞 상세(초안 전 상세 등)의 설계를 제자리로 안 고침(349-0o) for (const section of swap.detail.cross_sections) if (section.design) section.design = { ...section.design }; diff --git a/resources/tester/helper_219_1_arrow_owner.cjs b/resources/tester/helper_219_1_arrow_owner.cjs index 7a8431b8f..731741bb7 100644 --- a/resources/tester/helper_219_1_arrow_owner.cjs +++ b/resources/tester/helper_219_1_arrow_owner.cjs @@ -58,6 +58,13 @@ const STUBS = { "../A00_Common/design/design_profile_free_view": { createFreeView: () => ({ mount: () => undefined, redraw: () => undefined }), }, + // 계산 자리 「서버」 길(511-A) — 판 both 그대로 · 이 흠과 무관 + "../A00_Common/design/design_calc_sites": { calcOnServerNow: () => false }, + "../A00_Common/design/design_calc_server": { + calcChecked: (_key, _id, fn, args) => fn(...args), + calcOnServerOnly: async () => undefined, + trialRequest: () => ({}), + }, }; const { loadTs } = makeLoader({ diff --git a/resources/tester/helper_346_1_pipeline_page.cjs b/resources/tester/helper_346_1_pipeline_page.cjs index 84e9ea937..2a4465501 100644 --- a/resources/tester/helper_346_1_pipeline_page.cjs +++ b/resources/tester/helper_346_1_pipeline_page.cjs @@ -27,7 +27,16 @@ const session = memoryStorage(); globalThis.window = { sessionStorage: session, localStorage: memoryStorage() }; globalThis.fetch = async () => ({ ok: false }); -const STUBS = {}; +// 계산 자리 「서버」 길(511-A) — 판 both 그대로 +const CALC_STUBS = { + design_calc_sites: { calcOnServerNow: () => false, calcProjectId: () => "" }, + design_calc_server: { + calcChecked: (_key, _id, fn, args) => fn(...args), + calcOnServerOnly: async () => undefined, + trialRequest: () => ({}), + }, +}; +const STUBS = { ...CALC_STUBS }; const { load, loadTs } = makeLoader({ root: ROOT, requireFor: (full) => (request) => { diff --git a/resources/tester/helper_472_d_b05_pipe_undo.cjs b/resources/tester/helper_472_d_b05_pipe_undo.cjs index 01d041c54..a08a66131 100644 --- a/resources/tester/helper_472_d_b05_pipe_undo.cjs +++ b/resources/tester/helper_472_d_b05_pipe_undo.cjs @@ -51,6 +51,13 @@ const STUBS = { "../A00_Common/design/design_profile_free_view": { createFreeView: () => ({ mount: () => undefined, redraw: () => undefined }), }, + // 계산 자리 「서버」 길(511-A) — 판 both 그대로 + "../A00_Common/design/design_calc_sites": { calcOnServerNow: () => false }, + "../A00_Common/design/design_calc_server": { + calcChecked: (_key, _id, fn, args) => fn(...args), + calcOnServerOnly: async () => undefined, + trialRequest: () => ({}), + }, }; const { loadTs } = makeLoader({ diff --git a/resources/tester/test_271_straighten_pick.py b/resources/tester/test_271_straighten_pick.py index fc75e60e5..e5fbab5fd 100644 --- a/resources/tester/test_271_straighten_pick.py +++ b/resources/tester/test_271_straighten_pick.py @@ -172,7 +172,12 @@ def test_배선(): panel = _read("B05_Profile/B05_Profile_UI_Profile_Panel_Tools.ts") assert "tools.handleStationPick(station ?? chainageM)" in panel assert "straightSubRun(alignment, fromM, toM)" in panel - assert "straightenBetween(base, ctx.edits(), fromM, toM, ctx.savedRadii())" in panel + # 511-A — 인자 한 벌(계산 자리 길이 함수째 받음) + args = ( + "Parameters = [\n base,\n ctx.edits(),\n" + " fromM,\n toM,\n ctx.savedRadii()," + ) + assert args in panel assert "autoRadii: ctx.savedRadii" in panel assert 'window.addEventListener("keydown", onEscape, true)' in panel assert "createRangeBandView(" in panel diff --git a/resources/tester/test_280_4_fit.py b/resources/tester/test_280_4_fit.py index 1c2f821cd..25b652fa6 100644 --- a/resources/tester/test_280_4_fit.py +++ b/resources/tester/test_280_4_fit.py @@ -77,7 +77,12 @@ def test_끄는_중_미리보기_놓을_때_같음(got) -> None: def test_편집기_이음() -> None: edit = (B05 / "B05_Profile_UI_RouteEdit.ts").read_text(encoding="utf-8") - assert "fit.fitAfter(before, snapshotNow(), fitLimits())" in edit + # 511-A 계산 자리 대조(같은 인자) + call = ( + '"route.station_fit", fit.fitAfter, [\n' + " before,\n snapshotNow(),\n fitLimits()," + ) + assert call in edit assert "fit.fitDrag(snapshotNow(), base.planned, index, fitLimits(), base)" in edit assert "stationM: stationIntervalM" in edit assert len(edit.splitlines()) <= 695 diff --git a/resources/tester/test_309_2_calc_sites_browser.py b/resources/tester/test_309_2_calc_sites_browser.py index fce9ca2c9..607436f38 100644 --- a/resources/tester/test_309_2_calc_sites_browser.py +++ b/resources/tester/test_309_2_calc_sites_browser.py @@ -151,7 +151,9 @@ def test_시스템_관리자_카드_창_G1_서버_길(browser): assert len(heads) == 8 and len(calcs) == 45 assert heads[0]["boxes"] == [[True, False], [True, True]] # G1 묶음 머리 = 바꿀 수 있음 editable = [row["name"] for row in calcs if not row["boxes"][0][1]] - assert [name.split(" · ")[0] for name in editable] == ["G1", "D3"] # 줄도 둘만 켬 + opened = ["G1", "D3", "E1", "F7", "F8", "C4", "R9", "F10"] # 511-A ③ ④ 더 + opened += ["R2", "R4", "R8", "P7", "C7", "D4"] # ⑧ 시험용 + assert [name.split(" · ")[0] for name in editable] == opened assert all(row["name"].split(" · ")[1] for row in calcs) # 이름 = 등록부 label assert all(row["boxes"][1][1] for row in calcs) # 서버 정본 칸은 늘 잠금 assert "지금 서버 0 줄" in dialog.inner_text() diff --git a/resources/tester/test_346_3_route_save_screen.py b/resources/tester/test_346_3_route_save_screen.py index c265c56df..7c6957578 100644 --- a/resources/tester/test_346_3_route_save_screen.py +++ b/resources/tester/test_346_3_route_save_screen.py @@ -132,7 +132,7 @@ def test_노선_반영_단추_없앰_배선(): def test_넘김_알림은_결과_한_줄에_합침(): """346-3 덧 — [저장] 뒤 「노선 밖 고침」 두 줄 → 한 줄 · 넘김은 조용히 수만 · 기본은 알림 그대로.""" keys = _read("B06_Section/B06_Section_Station_Keys.ts") - assert "{ quiet = false }: { quiet?: boolean } = {}," in keys + assert "{ quiet = false, applyTo = toRouteId }: { quiet?: boolean; applyTo?: number }" in keys assert "if (outside.size && !quiet)" in keys patch = _read("B06_Section/B06_Section_Route_Carry_Patch.ts") assert "outside: keyed.outside.size + chain.outside.size" in patch diff --git a/resources/tester/test_354_2_masshaul_cross_only.py b/resources/tester/test_354_2_masshaul_cross_only.py index 3f178e535..bbb79185d 100644 --- a/resources/tester/test_354_2_masshaul_cross_only.py +++ b/resources/tester/test_354_2_masshaul_cross_only.py @@ -48,6 +48,8 @@ def test_기준_횡단만_계산(r): def test_그리기는_횡단만_부름(): mass = (ROOT / "B05_Profile/B05_Profile_UI_Profile_MassHaul.ts").read_text(encoding="utf-8") calls = re.findall(r"computeMassHaulSeries\(([^;]*?)\)", mass, re.S) + # 511-A — 부름 인자 한 벌(계산 자리 길이 함수째 받음) + calls += re.findall(r"Parameters = \[([^;]*?)\];", mass, re.S) assert calls and all('["cross"]' in call for call in calls) diff --git a/resources/tester/test_366_3_station_fit.py b/resources/tester/test_366_3_station_fit.py index 0154535bc..2c05b11f6 100644 --- a/resources/tester/test_366_3_station_fit.py +++ b/resources/tester/test_366_3_station_fit.py @@ -89,7 +89,12 @@ def test_시작_자리는_고친_꺾임점_앞(got) -> None: def test_편집기_이음() -> None: edit = (B05 / "B05_Profile_UI_RouteEdit.ts").read_text("utf-8") - assert "fit.fitAfter(before, snapshotNow(), fitLimits())" in edit # 280-4 + # 280-4 · 511-A 계산 자리 대조(같은 인자) + call = ( + '"route.station_fit", fit.fitAfter, [\n' + " before,\n snapshotNow(),\n fitLimits()," + ) + assert call in edit # 되돌리기 · 추천 [적용] · 초안 되살림이 지나는 restoreSnapshot 은 안 맞춤 at = edit.index("function restoreSnapshot(") assert "applyEdit(message, record, false);" in edit[at : at + 700] diff --git a/resources/tester/test_511_a_calc_browser.py b/resources/tester/test_511_a_calc_browser.py index 0519b588f..965c8f299 100644 --- a/resources/tester/test_511_a_calc_browser.py +++ b/resources/tester/test_511_a_calc_browser.py @@ -70,10 +70,25 @@ def test_자리마다_판_열쇠_저장_길_안_부름(): ): assert "design_calc_server" not in _text(rel), rel page = _text("A00_Common/design/design_pipeline_page.ts") - assert len(page.splitlines()) <= 699 and "design_calc_server" not in page + assert len(page.splitlines()) <= 699 and "design_calc_server" not in page # 연결 = 다른 파일 def test_이월_R9_자리_판_열쇠(): keys = _text("B06_Section/B06_Section_Station_Keys.ts") assert '"carry",\n "stations.carry_keyed",' in keys assert "carryStationDrafts(projectId, routeId, NEXT_ROUTE, { applyTo: routeId })" in keys + + +def test_고정_브라우저_자리_판_열쇠(): + sites = { + "B05_Profile/B05_Profile_UI_RouteEdit_Pointer.ts": '"fixed_browser", "route.handle"', + "B05_Profile/B05_Profile_UI_RouteEdit.ts": '"fixed_browser", "route.station_fit"', + "B06_Section/B06_Section_Section_Store.ts": '"fixed_browser", "stations.swap"', + "B05_Profile/B05_Profile_UI_Profile_Panel_Tools.ts": '"fixed_browser",\n "P7",', + "B05_Profile/B05_Profile_UI_RouteEdit_Cross_Draw.ts": '"fixed_browser", "cross.fit"', + "B05_Profile/B05_Profile_Footprint_Server.ts": '"fixed_browser", "D4"', + } + for rel, needle in sites.items(): + assert needle in _text(rel), rel + page = _text("A00_Common/design/design_pipeline_page.ts") + assert "footprint: footprintStageSited," in page diff --git a/resources/tester/test_511_a_calc_mirror.py b/resources/tester/test_511_a_calc_mirror.py index 7391e1498..70177c631 100644 --- a/resources/tester/test_511_a_calc_mirror.py +++ b/resources/tester/test_511_a_calc_mirror.py @@ -128,3 +128,86 @@ def test_carry_R9_서버_브라우저_같음(tmp_path): for index, case in enumerate(cases): assert _trial("stations.carry_keyed", case) == browser[index], index assert browser[0]["states"]["crossdesign"] # 넘김이 실제로 일어남 + + +# ── ⑧ 고정 브라우저(시험용 · R2 R4 C7 대조 · P7 서버 값 · R8 · D4 = 화면 검증) ────────────────── + +_ROUTE = [[0, 0], [93, 7], [171, 61], [262, 48], [337, 112], [431, 101], [503, 160]] +_LIMITS = {"minRadiusM": 30, "limitRadiusM": 12, "limitArcM": 0, "stationM": 20, "intervalM": 10} + + +def _columns(planned): + n = len(planned) + return { + "planned": [list(p) for p in planned], + "curveOn": [True] * n, + "curveRadius": [None] * n, + "curveLock": [None] * n, + "curveArc": [None] * n, + "apexLock": [i in (0, n - 1) for i in range(n)], + } + + +def _profile_base(): + import math + + stations = [i * 20 for i in range(16)] + ground = [round(100 + 0.03 * s + 0.6 * math.sin(s / 13), 6) for s in stations] + policy = { + "station_interval_m": 20, "curve_radius_ratio": 1, "curve_tangent_max_ratio": 0.5, + "curve_skip_legal_exception": False, "default_curve_length_m": 20, + "default_curve_radius_m": 300, "balance_tolerance_percent": 10, "edit_step_m": 0.1, + "grade_violation_policy": "warn", "max_grade_pct": 14, "curve_skip_delta_pct": 0, + "paved": True, "min_vertical_radius_m": 100, "min_curve_length_m": 20, + } # fmt: skip + return { + "policy": policy, + "onlyExplicitCurves": False, + "basePvi": [{"chainage_m": s, "elevation_m": z} for s, z in zip(stations, ground)], + "chainage": stations, + "ground": ground, + "stations": [{"station_id": f"S{s}", "chainage_m": s} for s in stations], + } + + +def test_fixed_browser_R2_R4_P7_C7_서버_브라우저_같음(tmp_path): + pushed = [list(p) for p in _ROUTE] + pushed[2] = [174.0, 58.0] + base = _profile_base() + empty = {"station_offsets": {}, "curve_radii": {}} + saved = {"40.000": 5330, "200.000": 1084} + sections = _fixture("detail.json")["cross_sections"][:12] + cases = { + "route.handle": [ + [[0, 0], [100, 0], [100, 100], "end", [105, 60]], + [[0, 0], [100, 0], [100, 100], "start", [60, -4]], + [[0, 0], [100, 0], [200, 0], "end", [150, 0]], # 직선 = null + ], + "route.station_fit": [ + [_columns(_ROUTE), _columns(pushed), _LIMITS], + [None, _columns(_ROUTE), {**_LIMITS, "intervalM": 20}], + ], + "profile.tools": [ + [base, empty, 40, 200, {}], + [base, {"station_offsets": {}, "curve_radii": dict(saved)}, 40, 200, saved], + [base, empty, 0, 300, {}], + ], + "cross.fit": [[section] for section in sections], + } + b05 = "B05_Profile/logic/B05_Profile_" + where = { + "route.handle": (f"{b05}UI_RouteEdit_Curve.ts", "dragHandleTo"), + "route.station_fit": (f"{b05}RouteEdit_StationFit.ts", "fitAfter"), + "profile.tools": (f"{b05}UI_Profile_Straighten.ts", "straightenBetween"), + "cross.fit": ("B06_Section/logic/B06_Section_UI_Cross_Fit.ts", "toeOffsets"), + } + flat = [(id_, args) for id_, rows in cases.items() for args in rows] + browser = _browser( + tmp_path, + [{"mod": where[id_][0], "fn": where[id_][1], "args": args} for id_, args in flat], + ) + for (id_, args), want in zip(flat, browser): + assert _trial(id_, args) == want, id_ + assert browser[0] and browser[2] is None # 직선 = null + toes = [want for (id_, _), want in zip(flat, browser) if id_ == "cross.fit"] + assert any(toe["left"] is not None for toe in toes) diff --git a/resources/tester/test_511_a_calc_sites_browser.py b/resources/tester/test_511_a_calc_sites_browser.py new file mode 100644 index 000000000..5d8cad785 --- /dev/null +++ b/resources/tester/test_511_a_calc_sites_browser.py @@ -0,0 +1,235 @@ +"""PLAN 511-A 화면 — 자기 포트 Playwright 머리 없는 브라우저 · 서버가 떠 있을 때만 · 검증_sub2. + +- 판 기본값(both) = B05 계획고 ▲ · 교각점 끌기 동안 /calc 0 · 브라우저 셈 기록만 +- 묶음 하나씩(quantity · carry · fixed_browser) server → 같은 조작 → /calc 요청 · 답마다 + 그 페이지 TS 로 다시 셈한 값 = 서버 값 · 그 묶음 id 만 감 → 판 기본값으로(파일 바이트 그대로) +- 저장 안 함(세션 초안만 · 창 닫으면 버림) · 시험 브라우저 표식 시스템 로그 행만 지움 +- 시간 표 = `tmp/511a_times.json`(묶음마다 브라우저 ms · 왕복 ms · Node ms · 흉내 0 · 30 · 80 ms) + + ./venv/Scripts/python.exe -m pytest resources/tester/test_511_a_calc_sites_browser.py -q -s +""" + +from __future__ import annotations + +import asyncio +import json +import statistics +import sys +import urllib.request +from pathlib import Path + +import pytest + +HERE = Path(__file__).resolve().parent +ROOT = HERE.parents[1] +if str(HERE) not in sys.path: + sys.path.insert(0, str(HERE)) + +from helper_verify_project import own_ports # noqa: E402 +from test_505_3_cross_card_screen import JS_NODES # noqa: E402 + +API, BASE = own_ports() +BOARD = ROOT / "resources" / "master_template" / "calc_sites.json" +PROJECT = "cb15892a-5788-4a26-9316-20a15421f68b" # 검증_sub2(OWNERS.md) +MARK = "AisloTest-511-A" +TIMES = ROOT / "tmp" / "511a_times.json" +GROUP_IDS = { + "quantity": {"structure.layouts", "cross.berm_spans", "masshaul.series"}, + "carry": {"stations.carry_keyed"}, + "fixed_browser": {"route.handle", "route.station_fit", "stations.swap", "profile.tools"} + | {"cross.fit", "footprint.plan"}, +} + + +def _started(base: str) -> str | None: + try: + with urllib.request.urlopen(f"{base}/api/health", timeout=3) as res: + return json.loads(res.read()).get("started_at") + except Exception: + return None + + +_UP = _started(BASE) if BASE else None +pytestmark = pytest.mark.skipif( + _UP is None or _UP != _started(API), reason="이 폴더 서버 없음 · vite 가 이 폴더 백엔드로 안 감" +) + +JS_GRADE = r"""async () => { + const url = performance.getEntriesByType('resource').map((e) => e.name) + .find((n) => /design_pipeline_page\.ts/.test(n)); + const m = await import(url); + const pid = localStorage.getItem('frd_current_project_id'); + const pipe = m.pagePipeline(pid); + const cur = pipe.input('alignment') ?? { station_offsets: {}, curve_radii: {} }; + const st = pipe.output('ground').stations; + const key = st[Math.floor(st.length / 2)].chainageM.toFixed(3); + m.pipelineGrade(pid, { ...cur, station_offsets: { ...cur.station_offsets, + [key]: (cur.station_offsets?.[key] ?? 0) + 0.3 } }); +}""" + +# 잡은 /calc 하나를 이 페이지의 같은 TS 로 다시 셈 → 같은지 +JS_MIRROR = r"""async (calls) => { + const urls = performance.getEntriesByType('resource').map((e) => e.name); + const mod = async (re) => { + const u = urls.find((n) => re.test(n)); + if (!u) throw new Error('모듈 없음 ' + re); + return import(u); + }; + const plain = (v) => JSON.parse(JSON.stringify(v, (_k, x) => + x instanceof Map ? [...x.entries()] : x instanceof Set ? [...x] : x) ?? 'null'); + const where = { + 'structure.layouts': [/B06_Section_Structure_Layouts\.ts/, 'structureAreaRows'], + 'cross.berm_spans': [/common_util_berm_spans\.ts/, 'bermSpansFromStructures'], + 'masshaul.series': [/common_util_mass_haul\.ts/, 'computeMassHaulSeries'], + 'route.handle': [/B05_Profile_UI_RouteEdit_Curve\.ts/, 'dragHandleTo'], + 'route.station_fit': [/B05_Profile_RouteEdit_StationFit\.ts/, 'fitAfter'], + 'stations.swap': [/B06_Section_Station_Swap\.ts/, 'swapCrossSections'], + 'profile.tools': [/B05_Profile_UI_Profile_Straighten\.ts/, 'straightenBetween'], + 'cross.fit': [/B06_Section_UI_Cross_Fit\.ts/, 'toeOffsets'], + 'footprint.plan': [/B05_Profile_Footprint_Stage\.ts/, 'footprintStage'], + 'stations.carry_keyed': [/B06_Section_Route_Carry\.ts/, 'carryKeyedStates'], + }; + const out = []; + for (const { run, inputs, results } of calls) { + for (const id of run) { + const fn = (await mod(where[id][0]))[where[id][1]]; + const args = inputs.args?.[id]; + const local = id === 'structure.layouts' ? fn(inputs.detail.cross_sections) + : id === 'cross.berm_spans' ? fn(inputs.berm.structures) + : id === 'footprint.plan' ? fn({ out: args[0], range: null, prev: null }) + : id === 'stations.carry_keyed' ? fn((n) => args[0][n] ?? {}, args[1], args[2]) + : fn(...args); + const theirs = id === 'structure.layouts' ? results[id].areas + : id === 'cross.berm_spans' ? results[id].spans : results[id]; + out.push({ id, same: JSON.stringify(plain(local)) === JSON.stringify(theirs) }); + } + } + return out; +}""" + + +def _drop_test_logs() -> None: + from config.config_db import close_db_pool, init_db_pool + + async def run(): + pool = await init_db_pool() + try: + async with pool.acquire() as connection, connection.cursor() as cursor: + await cursor.execute("DELETE FROM system_audit_logs WHERE user_agent = %s", (MARK,)) + await connection.commit() + finally: + await close_db_pool() + + asyncio.run(run()) + + +@pytest.fixture(scope="module") +def ctx(): + sync = pytest.importorskip("playwright.sync_api") + committed = BOARD.read_bytes() + cred = json.loads((ROOT / "tmp" / "aislo_cred.json").read_text(encoding="utf-8")) + try: + with sync.sync_playwright() as play: + chrome = play.chromium.launch(channel="msedge", headless=True) + context = chrome.new_context(viewport={"width": 1600, "height": 1000}, user_agent=MARK) + login = {k: cred[k] for k in ("email", "password")} + r = context.request.post(f"{BASE}/api/auth/login/request", data=login, timeout=60000) + assert r.ok, f"로그인 실패 {r.status}" # 비밀번호는 안 찍음 + try: + yield context + finally: + for key in GROUP_IDS: + _put(context, key, "both") + chrome.close() + finally: + BOARD.write_bytes(committed) + _drop_test_logs() + + +def _put(context, key: str, value: str) -> None: + r = context.request.put(f"{BASE}/api/dashboard/admin/calc-sites", data={"sites": {key: value}}) + assert r.ok, r.status + + +def _session(context, delay: int = 0): + page = context.new_page() + page.add_init_script( + f"localStorage.setItem('frd_current_project_id', '{PROJECT}');" + " performance.setResourceTimingBufferSize(20000);" + ) + calls: list[dict] = [] + + def on_response(res): + if res.url.endswith("/calc") and res.request.method == "POST": + calls.append({**json.loads(res.request.post_data), "results": res.json()["results"]}) + + page.on("response", on_response) + page.goto(f"{BASE}/#/b05-profile", wait_until="domcontentloaded") + page.wait_for_function("() => !!window.__aisloPipeline", timeout=90000) + page.wait_for_timeout(9000) + seed = "{ calls: [], local: {}, delayMs: 0 }" + page.evaluate(f"(d) => {{ (window.__aisloCalcServer ??= {seed}).delayMs = d; }}", delay) + return page, calls + + +def _act(page) -> dict: + page.evaluate(JS_GRADE) # 계획고 ▲ 한 번(가운데 측점 +0.3 m) + page.wait_for_timeout(3500) + nodes = page.evaluate(JS_NODES) + assert len(nodes) >= 3, "지도에서 교각점을 못 찾음" + node = nodes[len(nodes) // 2] + page.mouse.move(node["x"], node["y"]) + page.mouse.down() + page.mouse.move(node["x"] + 14, node["y"] + 9, steps=10) # 교각점 끌기(노선 초안) + page.mouse.up() + page.wait_for_timeout(5000) + return page.evaluate("() => window.__aisloCalcServer") + + +def _median(values: list[float]) -> float | None: + values = [v for v in values if v is not None] + return round(statistics.median(values), 2) if values else None + + +def test_기본값_서버_부름_0(ctx): + page, calls = _session(ctx) + log = _act(page) + page.close() + assert calls == [] and log["calls"] == [] + assert {"structure.layouts", "masshaul.series", "footprint.plan"} <= set(log["local"]) + assert {"route.station_fit", "stations.swap", "stations.carry_keyed"} <= set(log["local"]) + TIMES.write_text(json.dumps({"browser": log["local"]}, ensure_ascii=False), encoding="utf-8") + + +@pytest.mark.parametrize("group", list(GROUP_IDS)) +def test_묶음_서버_요청_값_같음_시간(ctx, group): + _put(ctx, group, "server") + table = json.loads(TIMES.read_text(encoding="utf-8")) if TIMES.exists() else {} + try: + for delay in (0, 30, 80): + page, calls = _session(ctx, delay) + log = _act(page) + mirror = page.evaluate(JS_MIRROR, calls) + page.close() + sent = {id_ for call in calls for id_ in call["run"]} + assert sent and sent <= GROUP_IDS[group], (group, sent) + assert mirror and all(row["same"] for row in mirror), (group, mirror) + assert all(c["same"] is not False for c in log["calls"]), log["calls"] + rows = {} + for call in log["calls"]: + rows.setdefault(call["id"], []).append(call) + table[f"{group}@{delay}"] = { + id_: { + "n": len(group_calls), + "round_ms": _median([c["roundMs"] for c in group_calls]), + "node_ms": _median([c["nodeMs"] for c in group_calls]), + "wall_ms": _median([c["wallMs"] for c in group_calls]), + "kb": round(_median([c["bytes"] for c in group_calls]) / 1024), + } + for id_, group_calls in rows.items() + } + print(group, delay, table[f"{group}@{delay}"]) + finally: + _put(ctx, group, "both") + TIMES.write_text(json.dumps(table, ensure_ascii=False, indent=1), encoding="utf-8") + assert json.loads(BOARD.read_text("utf-8"))["sites"][group] == "both" diff --git a/resources/tester/test_masshaul_single_source.py b/resources/tester/test_masshaul_single_source.py index 2c4af4fe5..4ecb605d0 100644 --- a/resources/tester/test_masshaul_single_source.py +++ b/resources/tester/test_masshaul_single_source.py @@ -77,7 +77,7 @@ def test_cross_refresh_lays_structure_areas_once(): 유토 뱃지가 갈렸다(실측 B05 −14,144.5 · B06 −12,533.2㎥).""" body = REFRESH[REFRESH.index("export async function refreshCrossDesigns") :] body = body[: body.index("\n}\n")] - assert "applyStructureAreaRows(" in body and "structureAreaRows(" in body + assert "applyStructureAreaRows(" in body and "structureAreaRows" in body # 511-A 함수째 넘김 assert "applyStructureAreaRows(" not in B05_PREVIEW # 화면(횡단 보기 · 카드 그리기)은 면적을 안 고침 — 카드 안 그려도 면적 같음(387-4) assert "applyStructureAreaRows(" not in B06_PAGE