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:
2026-10-22 13:09:00 +09:00
co-authored by Claude Opus 5.5
parent 57c36a9d15
commit eff9d7f5d0
22 changed files with 477 additions and 19 deletions
+1 -1
View File
@@ -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],
+2 -2
View File
@@ -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) => {
+8 -1
View File
@@ -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;
+4 -2
View File
@@ -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({
+6 -1
View File
@@ -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
+6 -1
View File
@@ -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)
+6 -1
View File
@@ -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]
+16 -1
View File
@@ -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