feat(511-A ⑧ 고정 브라우저 시험용 열기): fixed_browser = server 면 P7 직선화 · D4 발자국 단계 = 잠잠 뒤 서버 값(끊긴 차례 = 묶음이 버림 · 못 받으면 브라우저) · 끌기 도구 R2 손잡이 · R4 측점 맞춤 · R8 갈아끼움 · C7 반폭 맞춤 = 브라우저 값 + 놓은 뒤 서버 대조 · 둘레 시험 글 · 가짜 모듈 맞춤 · 화면 시험(묶음마다 /calc 답 = 그 페이지 TS) — 요구 320-4
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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],
|
||||
|
||||
@@ -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?.();
|
||||
|
||||
@@ -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<string, Call> = {
|
||||
"stations.carry_keyed": ((states: Record<string, Raw>, from, to) =>
|
||||
carryKeyedStates((name: KeyedState) => states[name] ?? {}, from, to)) as Call,
|
||||
"route.handle": dragHandleTo as Call,
|
||||
"route.station_fit": 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,
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
/* =============================================================================
|
||||
* B05_Profile/B05_Profile_Footprint_Server.ts
|
||||
* 계산 묶음 footprint 단계(D4) + 계산 자리 「서버」 길(PLAN 511-A) — 판 fixed_browser = server 면 잠잠 뒤 서버
|
||||
* (`footprint.plan` · 같은 `footprintStage` 전 구간)가 셈 · 끊긴 차례는 묶음이 버림 · 못 받으면 브라우저 셈.
|
||||
* 판 both = 지금 그대로(같은 틱 · 브라우저 셈).
|
||||
* ========================================================================== */
|
||||
|
||||
import type { StageContext } from "../A00_Common/design/logic/design_pipeline_types";
|
||||
import { calcOnServerNow } from "../A00_Common/design/design_calc_sites";
|
||||
import {
|
||||
calcChecked,
|
||||
calcOnServerOnly,
|
||||
trialRequest,
|
||||
} from "../A00_Common/design/design_calc_server";
|
||||
import { footprintStage } from "./logic/B05_Profile_Footprint_Stage";
|
||||
import type { FootprintLines } from "./logic/B05_Profile_PlanFootprint";
|
||||
|
||||
/** 단계가 읽는 몫만 — 측점(지반) · 측점 횡단 detail · 노선 선. */
|
||||
export function footprintInputs(ctx: StageContext<"footprint">): unknown {
|
||||
const byKey = ctx.out.cross?.byKey ?? {};
|
||||
const route = ctx.out.route;
|
||||
return {
|
||||
ground: { stations: ctx.out.ground?.stations ?? [] },
|
||||
cross: {
|
||||
byKey: Object.fromEntries(Object.entries(byKey).map(([k, v]) => [k, { detail: v.detail }])),
|
||||
},
|
||||
route: route ? { line: route.line, chainage: route.chainage, lengthM: route.lengthM } : null,
|
||||
};
|
||||
}
|
||||
|
||||
export function footprintStageSited(
|
||||
ctx: StageContext<"footprint">,
|
||||
): FootprintLines | Promise<FootprintLines> {
|
||||
if (!calcOnServerNow("fixed_browser"))
|
||||
return calcChecked("fixed_browser", "footprint.plan", footprintStage, [ctx]);
|
||||
const asked = trialRequest("footprint.plan", [footprintInputs(ctx)]);
|
||||
return calcOnServerOnly<FootprintLines>("fixed_browser", "D4", asked, ctx.signal).then(
|
||||
(lines) => lines ?? footprintStage(ctx),
|
||||
);
|
||||
}
|
||||
@@ -40,6 +40,12 @@ import { createRangeBandView } from "../A00_Common/design/design_profile_range_b
|
||||
import { arrowsBelongToProfile } from "../B06_Section/B06_Section_UI_Cross_Panel_Base";
|
||||
import { stepStructureStation } from "../common_util/common_util_structure_station";
|
||||
import { draftProjectId, useDesignStore } from "../A00_Common/design/design_store";
|
||||
import { calcOnServerNow } from "../A00_Common/design/design_calc_sites";
|
||||
import {
|
||||
calcChecked,
|
||||
calcOnServerOnly,
|
||||
trialRequest,
|
||||
} from "../A00_Common/design/design_calc_server";
|
||||
|
||||
/** 방향키 ▲▼ 한 번에 움직이는 계획고(m) — 좌우(구조물 측점)는 1 m 걸음(234-1 · `stepStructureStation`). */
|
||||
const KEY_STEP_M = 0.1;
|
||||
@@ -141,7 +147,22 @@ export function createPanelTools(ctx: PanelToolsContext): PanelTools {
|
||||
tools.handleRunPick(sub);
|
||||
return;
|
||||
}
|
||||
ctx.applyEdits(straightenBetween(base, ctx.edits(), fromM, toM, ctx.savedRadii()));
|
||||
// 계산 자리 fixed_browser = server 면 잠잠 뒤 서버 값으로 얹음(511-A)
|
||||
const args: Parameters<typeof straightenBetween> = [
|
||||
base,
|
||||
ctx.edits(),
|
||||
fromM,
|
||||
toM,
|
||||
ctx.savedRadii(),
|
||||
];
|
||||
if (calcOnServerNow("fixed_browser")) {
|
||||
const asked = trialRequest("profile.tools", args);
|
||||
void calcOnServerOnly<ReturnType<typeof straightenBetween>>(
|
||||
"fixed_browser",
|
||||
"P7",
|
||||
asked,
|
||||
).then((edits) => edits && ctx.applyEdits(edits));
|
||||
} else ctx.applyEdits(calcChecked("fixed_browser", "profile.tools", straightenBetween, args));
|
||||
tools.clear();
|
||||
},
|
||||
onShift: (runs, delta) => {
|
||||
|
||||
@@ -74,6 +74,7 @@ import type { DesignPageHost } from "../A00_Common/design/design_page";
|
||||
import * as draftLink from "./B05_Profile_UI_RouteEdit_Draft";
|
||||
import { bindStationSnap, paintStationSnap } from "./B05_Profile_UI_RouteEdit_SnapStep";
|
||||
import * as fit from "./logic/B05_Profile_RouteEdit_StationFit";
|
||||
import { calcChecked } from "../A00_Common/design/design_calc_server";
|
||||
import "./B05_Profile_UI_Style_RouteEdit.css";
|
||||
|
||||
/** 등고선 눈금을 고를 때 쓸 라벨 예산 — B04 지도 기본값(350)보다 **넉넉히** 둔다
|
||||
@@ -434,7 +435,13 @@ export async function mountRouteEditor(
|
||||
markEdited();
|
||||
const stop = fitting && before ? edits.floorStop(before, planned, nodeInfo, fitLimits()) : "";
|
||||
if (stop && before) return restoreSnapshot(before, stop, false);
|
||||
if (stationSnap && fitting) planned = fit.fitAfter(before, snapshotNow(), fitLimits());
|
||||
// 계산 자리 fixed_browser = server 면 잠잠 뒤 서버 대조만(511-A)
|
||||
if (stationSnap && fitting)
|
||||
planned = calcChecked("fixed_browser", "route.station_fit", fit.fitAfter, [
|
||||
before,
|
||||
snapshotNow(),
|
||||
fitLimits(),
|
||||
]);
|
||||
markEdited();
|
||||
writeRouteLocks(projectId, planned, apexLock); // 고정은 [취소] · 다시 열기에도 남는다(22-5).
|
||||
syncCurveBar();
|
||||
|
||||
@@ -21,6 +21,7 @@ import type { ContentBounds } from "../A00_Common/b_svg_zoom_pan";
|
||||
import type { CrossSection } from "./../B06_Section/B06_Section_Api_Fetch";
|
||||
import { fillSlopeLengths, toeOffsets } from "../B06_Section/logic/B06_Section_UI_Cross_Fit";
|
||||
import type { CrossPreviewResponse } from "./B05_Profile_Api_Replan";
|
||||
import { calcChecked } from "../A00_Common/design/design_calc_server";
|
||||
|
||||
/** 그림 가장자리 여백(px) — 위는 범례 한 줄, 아래는 눈금 안내 한 줄 몫이다. */
|
||||
export const CROSS_PLOT_PAD = { left: 10, right: 10, top: 20, bottom: 20 };
|
||||
@@ -113,7 +114,10 @@ export function buildCrossSvg(
|
||||
const ground = preview.samples
|
||||
.filter((sample) => sample.valid && sample.elevation_m !== null)
|
||||
.map((sample) => [Number(sample.offset_m), Number(sample.elevation_m)] as Point);
|
||||
const toes = preview.design ? toeOffsets(asSection(preview)) : { left: null, right: null };
|
||||
// 계산 자리 fixed_browser = server 면 잠잠 뒤 서버 대조만(511-A)
|
||||
const toes = preview.design
|
||||
? calcChecked("fixed_browser", "cross.fit", toeOffsets, [asSection(preview)])
|
||||
: { left: null, right: null };
|
||||
// 계획 횡단선은 사면 끝에서 끊는다 — 그 바깥은 원지반이고 지반선이 이미 그린다.
|
||||
const design = clipToToes(
|
||||
(preview.design?.design_line ?? []).map(
|
||||
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
type RouteEditPick,
|
||||
} from "./B05_Profile_UI_RouteEdit_Input";
|
||||
import { markShown } from "./B05_Profile_UI_RouteEdit_Marks";
|
||||
import { calcChecked } from "../A00_Common/design/design_calc_server";
|
||||
import {
|
||||
curveShortfalls,
|
||||
floorNote,
|
||||
@@ -188,7 +189,15 @@ export function bindRouteEditPointer(params: RouteEditPointerParams): RouteEditP
|
||||
const before = planned[node - 1];
|
||||
const after = planned[node + 1];
|
||||
if (!before || !after) return null;
|
||||
return curveDragTo(before, [curve.apex[0], curve.apex[1]], after, which, to);
|
||||
// 계산 자리 fixed_browser = server 면 놓은 뒤(잠잠) 서버 대조만(끌기 도구 · 511-A)
|
||||
const apex: Vertex = [curve.apex[0], curve.apex[1]];
|
||||
return calcChecked("fixed_browser", "route.handle", curveDragTo, [
|
||||
before,
|
||||
apex,
|
||||
after,
|
||||
which,
|
||||
to,
|
||||
]);
|
||||
}
|
||||
|
||||
let dragNode = -1;
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -58,6 +58,13 @@ const STUBS = {
|
||||
"../A00_Common/design/design_profile_free_view": {
|
||||
createFreeView: () => ({ mount: () => undefined, redraw: () => undefined }),
|
||||
},
|
||||
// 계산 자리 「서버」 길(511-A) — 판 both 그대로 · 이 흠과 무관
|
||||
"../A00_Common/design/design_calc_sites": { calcOnServerNow: () => false },
|
||||
"../A00_Common/design/design_calc_server": {
|
||||
calcChecked: (_key, _id, fn, args) => fn(...args),
|
||||
calcOnServerOnly: async () => undefined,
|
||||
trialRequest: () => ({}),
|
||||
},
|
||||
};
|
||||
|
||||
const { loadTs } = makeLoader({
|
||||
|
||||
@@ -27,7 +27,16 @@ const session = memoryStorage();
|
||||
globalThis.window = { sessionStorage: session, localStorage: memoryStorage() };
|
||||
globalThis.fetch = async () => ({ ok: false });
|
||||
|
||||
const STUBS = {};
|
||||
// 계산 자리 「서버」 길(511-A) — 판 both 그대로
|
||||
const CALC_STUBS = {
|
||||
design_calc_sites: { calcOnServerNow: () => false, calcProjectId: () => "" },
|
||||
design_calc_server: {
|
||||
calcChecked: (_key, _id, fn, args) => fn(...args),
|
||||
calcOnServerOnly: async () => undefined,
|
||||
trialRequest: () => ({}),
|
||||
},
|
||||
};
|
||||
const STUBS = { ...CALC_STUBS };
|
||||
const { load, loadTs } = makeLoader({
|
||||
root: ROOT,
|
||||
requireFor: (full) => (request) => {
|
||||
|
||||
@@ -51,6 +51,13 @@ const STUBS = {
|
||||
"../A00_Common/design/design_profile_free_view": {
|
||||
createFreeView: () => ({ mount: () => undefined, redraw: () => undefined }),
|
||||
},
|
||||
// 계산 자리 「서버」 길(511-A) — 판 both 그대로
|
||||
"../A00_Common/design/design_calc_sites": { calcOnServerNow: () => false },
|
||||
"../A00_Common/design/design_calc_server": {
|
||||
calcChecked: (_key, _id, fn, args) => fn(...args),
|
||||
calcOnServerOnly: async () => undefined,
|
||||
trialRequest: () => ({}),
|
||||
},
|
||||
};
|
||||
|
||||
const { loadTs } = makeLoader({
|
||||
|
||||
@@ -172,7 +172,12 @@ def test_배선():
|
||||
panel = _read("B05_Profile/B05_Profile_UI_Profile_Panel_Tools.ts")
|
||||
assert "tools.handleStationPick(station ?? chainageM)" in panel
|
||||
assert "straightSubRun(alignment, fromM, toM)" in panel
|
||||
assert "straightenBetween(base, ctx.edits(), fromM, toM, ctx.savedRadii())" in panel
|
||||
# 511-A — 인자 한 벌(계산 자리 길이 함수째 받음)
|
||||
args = (
|
||||
"Parameters<typeof straightenBetween> = [\n base,\n ctx.edits(),\n"
|
||||
" fromM,\n toM,\n ctx.savedRadii(),"
|
||||
)
|
||||
assert args in panel
|
||||
assert "autoRadii: ctx.savedRadii" in panel
|
||||
assert 'window.addEventListener("keydown", onEscape, true)' in panel
|
||||
assert "createRangeBandView(" in panel
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -151,7 +151,9 @@ def test_시스템_관리자_카드_창_G1_서버_길(browser):
|
||||
assert len(heads) == 8 and len(calcs) == 45
|
||||
assert heads[0]["boxes"] == [[True, False], [True, True]] # G1 묶음 머리 = 바꿀 수 있음
|
||||
editable = [row["name"] for row in calcs if not row["boxes"][0][1]]
|
||||
assert [name.split(" · ")[0] for name in editable] == ["G1", "D3"] # 줄도 둘만 켬
|
||||
opened = ["G1", "D3", "E1", "F7", "F8", "C4", "R9", "F10"] # 511-A ③ ④ 더
|
||||
opened += ["R2", "R4", "R8", "P7", "C7", "D4"] # ⑧ 시험용
|
||||
assert [name.split(" · ")[0] for name in editable] == opened
|
||||
assert all(row["name"].split(" · ")[1] for row in calcs) # 이름 = 등록부 label
|
||||
assert all(row["boxes"][1][1] for row in calcs) # 서버 정본 칸은 늘 잠금
|
||||
assert "지금 서버 0 줄" in dialog.inner_text()
|
||||
|
||||
@@ -132,7 +132,7 @@ def test_노선_반영_단추_없앰_배선():
|
||||
def test_넘김_알림은_결과_한_줄에_합침():
|
||||
"""346-3 덧 — [저장] 뒤 「노선 밖 고침」 두 줄 → 한 줄 · 넘김은 조용히 수만 · 기본은 알림 그대로."""
|
||||
keys = _read("B06_Section/B06_Section_Station_Keys.ts")
|
||||
assert "{ quiet = false }: { quiet?: boolean } = {}," in keys
|
||||
assert "{ quiet = false, applyTo = toRouteId }: { quiet?: boolean; applyTo?: number }" in keys
|
||||
assert "if (outside.size && !quiet)" in keys
|
||||
patch = _read("B06_Section/B06_Section_Route_Carry_Patch.ts")
|
||||
assert "outside: keyed.outside.size + chain.outside.size" in patch
|
||||
|
||||
@@ -48,6 +48,8 @@ def test_기준_횡단만_계산(r):
|
||||
def test_그리기는_횡단만_부름():
|
||||
mass = (ROOT / "B05_Profile/B05_Profile_UI_Profile_MassHaul.ts").read_text(encoding="utf-8")
|
||||
calls = re.findall(r"computeMassHaulSeries\(([^;]*?)\)", mass, re.S)
|
||||
# 511-A — 부름 인자 한 벌(계산 자리 길이 함수째 받음)
|
||||
calls += re.findall(r"Parameters<typeof computeMassHaulSeries> = \[([^;]*?)\];", mass, re.S)
|
||||
assert calls and all('["cross"]' in call for call in calls)
|
||||
|
||||
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
"""PLAN 511-A 화면 — 자기 포트 Playwright 머리 없는 브라우저 · 서버가 떠 있을 때만 · 검증_sub2.
|
||||
|
||||
- 판 기본값(both) = B05 계획고 ▲ · 교각점 끌기 동안 /calc 0 · 브라우저 셈 기록만
|
||||
- 묶음 하나씩(quantity · carry · fixed_browser) server → 같은 조작 → /calc 요청 · 답마다
|
||||
그 페이지 TS 로 다시 셈한 값 = 서버 값 · 그 묶음 id 만 감 → 판 기본값으로(파일 바이트 그대로)
|
||||
- 저장 안 함(세션 초안만 · 창 닫으면 버림) · 시험 브라우저 표식 시스템 로그 행만 지움
|
||||
- 시간 표 = `tmp/511a_times.json`(묶음마다 브라우저 ms · 왕복 ms · Node ms · 흉내 0 · 30 · 80 ms)
|
||||
|
||||
./venv/Scripts/python.exe -m pytest resources/tester/test_511_a_calc_sites_browser.py -q -s
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import statistics
|
||||
import sys
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
ROOT = HERE.parents[1]
|
||||
if str(HERE) not in sys.path:
|
||||
sys.path.insert(0, str(HERE))
|
||||
|
||||
from helper_verify_project import own_ports # noqa: E402
|
||||
from test_505_3_cross_card_screen import JS_NODES # noqa: E402
|
||||
|
||||
API, BASE = own_ports()
|
||||
BOARD = ROOT / "resources" / "master_template" / "calc_sites.json"
|
||||
PROJECT = "cb15892a-5788-4a26-9316-20a15421f68b" # 검증_sub2(OWNERS.md)
|
||||
MARK = "AisloTest-511-A"
|
||||
TIMES = ROOT / "tmp" / "511a_times.json"
|
||||
GROUP_IDS = {
|
||||
"quantity": {"structure.layouts", "cross.berm_spans", "masshaul.series"},
|
||||
"carry": {"stations.carry_keyed"},
|
||||
"fixed_browser": {"route.handle", "route.station_fit", "stations.swap", "profile.tools"}
|
||||
| {"cross.fit", "footprint.plan"},
|
||||
}
|
||||
|
||||
|
||||
def _started(base: str) -> str | None:
|
||||
try:
|
||||
with urllib.request.urlopen(f"{base}/api/health", timeout=3) as res:
|
||||
return json.loads(res.read()).get("started_at")
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
_UP = _started(BASE) if BASE else None
|
||||
pytestmark = pytest.mark.skipif(
|
||||
_UP is None or _UP != _started(API), reason="이 폴더 서버 없음 · vite 가 이 폴더 백엔드로 안 감"
|
||||
)
|
||||
|
||||
JS_GRADE = r"""async () => {
|
||||
const url = performance.getEntriesByType('resource').map((e) => e.name)
|
||||
.find((n) => /design_pipeline_page\.ts/.test(n));
|
||||
const m = await import(url);
|
||||
const pid = localStorage.getItem('frd_current_project_id');
|
||||
const pipe = m.pagePipeline(pid);
|
||||
const cur = pipe.input('alignment') ?? { station_offsets: {}, curve_radii: {} };
|
||||
const st = pipe.output('ground').stations;
|
||||
const key = st[Math.floor(st.length / 2)].chainageM.toFixed(3);
|
||||
m.pipelineGrade(pid, { ...cur, station_offsets: { ...cur.station_offsets,
|
||||
[key]: (cur.station_offsets?.[key] ?? 0) + 0.3 } });
|
||||
}"""
|
||||
|
||||
# 잡은 /calc 하나를 이 페이지의 같은 TS 로 다시 셈 → 같은지
|
||||
JS_MIRROR = r"""async (calls) => {
|
||||
const urls = performance.getEntriesByType('resource').map((e) => e.name);
|
||||
const mod = async (re) => {
|
||||
const u = urls.find((n) => re.test(n));
|
||||
if (!u) throw new Error('모듈 없음 ' + re);
|
||||
return import(u);
|
||||
};
|
||||
const plain = (v) => JSON.parse(JSON.stringify(v, (_k, x) =>
|
||||
x instanceof Map ? [...x.entries()] : x instanceof Set ? [...x] : x) ?? 'null');
|
||||
const where = {
|
||||
'structure.layouts': [/B06_Section_Structure_Layouts\.ts/, 'structureAreaRows'],
|
||||
'cross.berm_spans': [/common_util_berm_spans\.ts/, 'bermSpansFromStructures'],
|
||||
'masshaul.series': [/common_util_mass_haul\.ts/, 'computeMassHaulSeries'],
|
||||
'route.handle': [/B05_Profile_UI_RouteEdit_Curve\.ts/, 'dragHandleTo'],
|
||||
'route.station_fit': [/B05_Profile_RouteEdit_StationFit\.ts/, 'fitAfter'],
|
||||
'stations.swap': [/B06_Section_Station_Swap\.ts/, 'swapCrossSections'],
|
||||
'profile.tools': [/B05_Profile_UI_Profile_Straighten\.ts/, 'straightenBetween'],
|
||||
'cross.fit': [/B06_Section_UI_Cross_Fit\.ts/, 'toeOffsets'],
|
||||
'footprint.plan': [/B05_Profile_Footprint_Stage\.ts/, 'footprintStage'],
|
||||
'stations.carry_keyed': [/B06_Section_Route_Carry\.ts/, 'carryKeyedStates'],
|
||||
};
|
||||
const out = [];
|
||||
for (const { run, inputs, results } of calls) {
|
||||
for (const id of run) {
|
||||
const fn = (await mod(where[id][0]))[where[id][1]];
|
||||
const args = inputs.args?.[id];
|
||||
const local = id === 'structure.layouts' ? fn(inputs.detail.cross_sections)
|
||||
: id === 'cross.berm_spans' ? fn(inputs.berm.structures)
|
||||
: id === 'footprint.plan' ? fn({ out: args[0], range: null, prev: null })
|
||||
: id === 'stations.carry_keyed' ? fn((n) => args[0][n] ?? {}, args[1], args[2])
|
||||
: fn(...args);
|
||||
const theirs = id === 'structure.layouts' ? results[id].areas
|
||||
: id === 'cross.berm_spans' ? results[id].spans : results[id];
|
||||
out.push({ id, same: JSON.stringify(plain(local)) === JSON.stringify(theirs) });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}"""
|
||||
|
||||
|
||||
def _drop_test_logs() -> None:
|
||||
from config.config_db import close_db_pool, init_db_pool
|
||||
|
||||
async def run():
|
||||
pool = await init_db_pool()
|
||||
try:
|
||||
async with pool.acquire() as connection, connection.cursor() as cursor:
|
||||
await cursor.execute("DELETE FROM system_audit_logs WHERE user_agent = %s", (MARK,))
|
||||
await connection.commit()
|
||||
finally:
|
||||
await close_db_pool()
|
||||
|
||||
asyncio.run(run())
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def ctx():
|
||||
sync = pytest.importorskip("playwright.sync_api")
|
||||
committed = BOARD.read_bytes()
|
||||
cred = json.loads((ROOT / "tmp" / "aislo_cred.json").read_text(encoding="utf-8"))
|
||||
try:
|
||||
with sync.sync_playwright() as play:
|
||||
chrome = play.chromium.launch(channel="msedge", headless=True)
|
||||
context = chrome.new_context(viewport={"width": 1600, "height": 1000}, user_agent=MARK)
|
||||
login = {k: cred[k] for k in ("email", "password")}
|
||||
r = context.request.post(f"{BASE}/api/auth/login/request", data=login, timeout=60000)
|
||||
assert r.ok, f"로그인 실패 {r.status}" # 비밀번호는 안 찍음
|
||||
try:
|
||||
yield context
|
||||
finally:
|
||||
for key in GROUP_IDS:
|
||||
_put(context, key, "both")
|
||||
chrome.close()
|
||||
finally:
|
||||
BOARD.write_bytes(committed)
|
||||
_drop_test_logs()
|
||||
|
||||
|
||||
def _put(context, key: str, value: str) -> None:
|
||||
r = context.request.put(f"{BASE}/api/dashboard/admin/calc-sites", data={"sites": {key: value}})
|
||||
assert r.ok, r.status
|
||||
|
||||
|
||||
def _session(context, delay: int = 0):
|
||||
page = context.new_page()
|
||||
page.add_init_script(
|
||||
f"localStorage.setItem('frd_current_project_id', '{PROJECT}');"
|
||||
" performance.setResourceTimingBufferSize(20000);"
|
||||
)
|
||||
calls: list[dict] = []
|
||||
|
||||
def on_response(res):
|
||||
if res.url.endswith("/calc") and res.request.method == "POST":
|
||||
calls.append({**json.loads(res.request.post_data), "results": res.json()["results"]})
|
||||
|
||||
page.on("response", on_response)
|
||||
page.goto(f"{BASE}/#/b05-profile", wait_until="domcontentloaded")
|
||||
page.wait_for_function("() => !!window.__aisloPipeline", timeout=90000)
|
||||
page.wait_for_timeout(9000)
|
||||
seed = "{ calls: [], local: {}, delayMs: 0 }"
|
||||
page.evaluate(f"(d) => {{ (window.__aisloCalcServer ??= {seed}).delayMs = d; }}", delay)
|
||||
return page, calls
|
||||
|
||||
|
||||
def _act(page) -> dict:
|
||||
page.evaluate(JS_GRADE) # 계획고 ▲ 한 번(가운데 측점 +0.3 m)
|
||||
page.wait_for_timeout(3500)
|
||||
nodes = page.evaluate(JS_NODES)
|
||||
assert len(nodes) >= 3, "지도에서 교각점을 못 찾음"
|
||||
node = nodes[len(nodes) // 2]
|
||||
page.mouse.move(node["x"], node["y"])
|
||||
page.mouse.down()
|
||||
page.mouse.move(node["x"] + 14, node["y"] + 9, steps=10) # 교각점 끌기(노선 초안)
|
||||
page.mouse.up()
|
||||
page.wait_for_timeout(5000)
|
||||
return page.evaluate("() => window.__aisloCalcServer")
|
||||
|
||||
|
||||
def _median(values: list[float]) -> float | None:
|
||||
values = [v for v in values if v is not None]
|
||||
return round(statistics.median(values), 2) if values else None
|
||||
|
||||
|
||||
def test_기본값_서버_부름_0(ctx):
|
||||
page, calls = _session(ctx)
|
||||
log = _act(page)
|
||||
page.close()
|
||||
assert calls == [] and log["calls"] == []
|
||||
assert {"structure.layouts", "masshaul.series", "footprint.plan"} <= set(log["local"])
|
||||
assert {"route.station_fit", "stations.swap", "stations.carry_keyed"} <= set(log["local"])
|
||||
TIMES.write_text(json.dumps({"browser": log["local"]}, ensure_ascii=False), encoding="utf-8")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("group", list(GROUP_IDS))
|
||||
def test_묶음_서버_요청_값_같음_시간(ctx, group):
|
||||
_put(ctx, group, "server")
|
||||
table = json.loads(TIMES.read_text(encoding="utf-8")) if TIMES.exists() else {}
|
||||
try:
|
||||
for delay in (0, 30, 80):
|
||||
page, calls = _session(ctx, delay)
|
||||
log = _act(page)
|
||||
mirror = page.evaluate(JS_MIRROR, calls)
|
||||
page.close()
|
||||
sent = {id_ for call in calls for id_ in call["run"]}
|
||||
assert sent and sent <= GROUP_IDS[group], (group, sent)
|
||||
assert mirror and all(row["same"] for row in mirror), (group, mirror)
|
||||
assert all(c["same"] is not False for c in log["calls"]), log["calls"]
|
||||
rows = {}
|
||||
for call in log["calls"]:
|
||||
rows.setdefault(call["id"], []).append(call)
|
||||
table[f"{group}@{delay}"] = {
|
||||
id_: {
|
||||
"n": len(group_calls),
|
||||
"round_ms": _median([c["roundMs"] for c in group_calls]),
|
||||
"node_ms": _median([c["nodeMs"] for c in group_calls]),
|
||||
"wall_ms": _median([c["wallMs"] for c in group_calls]),
|
||||
"kb": round(_median([c["bytes"] for c in group_calls]) / 1024),
|
||||
}
|
||||
for id_, group_calls in rows.items()
|
||||
}
|
||||
print(group, delay, table[f"{group}@{delay}"])
|
||||
finally:
|
||||
_put(ctx, group, "both")
|
||||
TIMES.write_text(json.dumps(table, ensure_ascii=False, indent=1), encoding="utf-8")
|
||||
assert json.loads(BOARD.read_text("utf-8"))["sites"][group] == "both"
|
||||
@@ -77,7 +77,7 @@ def test_cross_refresh_lays_structure_areas_once():
|
||||
유토 뱃지가 갈렸다(실측 B05 −14,144.5 · B06 −12,533.2㎥)."""
|
||||
body = REFRESH[REFRESH.index("export async function refreshCrossDesigns") :]
|
||||
body = body[: body.index("\n}\n")]
|
||||
assert "applyStructureAreaRows(" in body and "structureAreaRows(" in body
|
||||
assert "applyStructureAreaRows(" in body and "structureAreaRows" in body # 511-A 함수째 넘김
|
||||
assert "applyStructureAreaRows(" not in B05_PREVIEW
|
||||
# 화면(횡단 보기 · 카드 그리기)은 면적을 안 고침 — 카드 안 그려도 면적 같음(387-4)
|
||||
assert "applyStructureAreaRows(" not in B06_PAGE
|
||||
|
||||
Reference in New Issue
Block a user