From 60acb5ba79b1366436ae298a730592c4219aa098 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 18 Oct 2026 23:51:49 +0900 Subject: [PATCH] =?UTF-8?q?fix(463=20B05=20B06=20=ED=95=9C=20=ED=8E=98?= =?UTF-8?q?=EC=9D=B4=EC=A7=80):=20=ED=8E=98=EC=9D=B4=EC=A7=80=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95=20=ED=95=9C=20=EC=B9=B8=20=C2=B7=20=EA=B0=95=EC=A0=9C?= =?UTF-8?q?=20=EB=B0=9B=EA=B8=B0=20=C2=B7=20=EC=B8=A1=EC=A0=90=20=EB=A7=8C?= =?UTF-8?q?=EB=93=A4=EA=B8=B0=20=EB=92=A4=20=EC=83=81=EC=84=B8=20=EC=95=8C?= =?UTF-8?q?=EB=A6=BC=20=C2=B7=20[=EC=A0=80=EC=9E=A5]=20=EC=83=81=EB=8B=A8?= =?UTF-8?q?=EC=B8=A1=20=EC=B0=A8=EB=A1=80=20=EA=B0=99=EC=9D=80=20=EB=B3=B8?= =?UTF-8?q?=EB=AC=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - (B) 종단도 기준(기울기 상한 · 최소고 강제 · 시작 측점) = 세션 초안 ?? 저장 노선 설정 — 두 보기가 그린 뒤 applyPageSettings 하나 · B06 이 받은 노선은 캐시에 - (C) 상세 강제 다시 받기 = 듣는 쪽 한 번(숨은 종단 보기 옛 객체 안 쥠) · [측점 만들기] 뒤 캐시 비우고 강제로 받음 - (D) [저장] 상단측 차례 = flushRouteParts 한 벌(노선 캐시 지표 조건 + 확정 지표면 id · 비정규 빈 목록) — 종단 보기를 열든 안 열든 같은 요청 - 시험 test_271_detail_notify · test_271_save_same · test_271_profile_settings · 옛 저장 시험 가짜 창구에 flushRouteParts Co-Authored-By: Claude Opus 5.5 --- A00_Common/design/design_save.ts | 7 +- .../design/design_view_profile_sections.ts | 8 +- B05_Profile/B05_Profile_Api_Fetch.ts | 33 ++- B05_Profile/B05_Profile_UI_Page_Actions.ts | 30 +-- B05_Profile/B05_Profile_UI_Page_Latest.ts | 23 ++ B05_Profile/B05_Profile_UI_Panel_Fields.ts | 7 + B05_Profile/B05_Profile_UI_Panel_Settings.ts | 5 +- B06_Section/B06_Section_Section_Store.ts | 6 +- .../B06_Section_UI_Missing_Stations.ts | 8 + .../B06_Section_UI_Section_View_Profile.ts | 29 +-- resources/tester/helper_271_detail_notify.cjs | 218 ++++++++++++++++++ .../tester/helper_271_profile_settings.cjs | 118 ++++++++++ resources/tester/helper_271_save_same.cjs | 142 ++++++++++++ resources/tester/helper_323_9_save_redraw.cjs | 3 + .../tester/helper_349_0u_save_rebase.cjs | 1 + resources/tester/helper_449_save_pipes.cjs | 1 + resources/tester/helper_design_store_save.cjs | 1 + resources/tester/test_176_3_no_uphill_lamp.py | 6 +- resources/tester/test_271_detail_notify.py | 55 +++++ resources/tester/test_271_profile_settings.py | 91 ++++++++ resources/tester/test_271_save_same.py | 84 +++++++ resources/tester/test_443_save_stops.py | 6 +- resources/tester/test_b06_save_channels.py | 2 +- 23 files changed, 822 insertions(+), 62 deletions(-) create mode 100644 resources/tester/helper_271_detail_notify.cjs create mode 100644 resources/tester/helper_271_profile_settings.cjs create mode 100644 resources/tester/helper_271_save_same.cjs create mode 100644 resources/tester/test_271_detail_notify.py create mode 100644 resources/tester/test_271_profile_settings.py create mode 100644 resources/tester/test_271_save_same.py diff --git a/A00_Common/design/design_save.ts b/A00_Common/design/design_save.ts index 5e39a4669..d03985c1f 100644 --- a/A00_Common/design/design_save.ts +++ b/A00_Common/design/design_save.ts @@ -37,10 +37,7 @@ import { writePendingStructures, } from "../../B05_Profile/B05_Profile_Api_Structures"; import { flushPlusStations, outsideNotice } from "../../B05_Profile/B05_Profile_Api_PlusStations"; -import { - flushUphillOverrides, - saveProfileAlignment, -} from "../../B05_Profile/B05_Profile_Api_Fetch"; +import { flushRouteParts, saveProfileAlignment } from "../../B05_Profile/B05_Profile_Api_Fetch"; import { markAlignmentSaved, pendingAlignment, @@ -190,7 +187,7 @@ function defaultStep( case "culvertOptions": return () => flushCulvertOptions(projectId, stateKey("culvertopt", projectId, fromRouteId)); case "uphill": - return () => flushUphillOverrides(projectId); + return () => flushRouteParts(projectId); // 종단 보기 몫과 같은 본문(463) } } diff --git a/A00_Common/design/design_view_profile_sections.ts b/A00_Common/design/design_view_profile_sections.ts index c7a7208d2..884223b1f 100644 --- a/A00_Common/design/design_view_profile_sections.ts +++ b/A00_Common/design/design_view_profile_sections.ts @@ -24,7 +24,7 @@ import { migrateLegacyStations } from "../../B05_Profile/B05_Profile_Api_Structu import { loadCorridorIfFresh } from "../../B05_Profile/B05_Profile_UI_Corridor"; import { stationLinesOf } from "../../B05_Profile/B05_Profile_UI_Page_Stations"; import { restorePoints, type RoadWidths } from "../../B05_Profile/B05_Profile_UI_Page_Helpers"; -import { panelValuesOf } from "../../B05_Profile/B05_Profile_UI_Page_Latest"; +import { applyPageSettings, panelValuesOf } from "../../B05_Profile/B05_Profile_UI_Page_Latest"; /** 종단 보기 본체 · 조각이 함께 보는 값 한 벌. */ export interface ProfileViewState { @@ -85,10 +85,8 @@ export function createProfileSections(deps: ProfileSectionsDeps) { state.detail = detail; profilePanel.render(detail, panel.values().stationInterval ?? undefined, routeId); // 저장된 계획선의 정책은 만들 당시 기준이라 지금 설정(임도 종류·설계속도·지형)과 - // 다를 수 있다 — 그릴 때마다 현재 기준으로 맞춘다(2026-08-19). - profilePanel.setGradeLimit(panel.gradeLimitPct()); - profilePanel.setEnforceMinCover(panel.values().enforcePipeClearance); - profilePanel.setStationDisplay(panel.stationDisplayOffset()); + // 다를 수 있다 — 그릴 때마다 페이지 설정 한 칸(세션 초안 ?? 저장 설정 · 횡단 보기와 같은 길 · 463)으로 맞춘다. + applyPageSettings(profilePanel, projectId, state.latest ?? undefined); profilePanel.setIrregularStations(deps.bridge().irregularStations()); renderStationLines(detail); // 예상형상 코리도 — 저장본이 지금 정본과 맞을 때만 올린다. 낡았으면 [3D 업데이트] 대기 diff --git a/B05_Profile/B05_Profile_Api_Fetch.ts b/B05_Profile/B05_Profile_Api_Fetch.ts index cabaf6440..bd32c576d 100644 --- a/B05_Profile/B05_Profile_Api_Fetch.ts +++ b/B05_Profile/B05_Profile_Api_Fetch.ts @@ -12,6 +12,7 @@ * ========================================================================== */ import { clearState, readState, writeState } from "../A00_Common/b_page_state"; +import { fetchConfirmedSurface } from "../B04_PreProcess/B04_PreProcess_Api_Fetch"; import { API_ANALYSIS_TIMEOUT_MS, API_BASE_URL, API_TIMEOUT_MS } from "@config/config_frontend"; @@ -299,14 +300,38 @@ export async function confirmRoute( * B06 에서 저장하면 이 값이 세션에만 남아 확정 뒤 옛 측구 방향이 그대로 쓰였다 * (2026-09-06 대응표 조사). 비어 있으면 요청을 내지 않는다. */ export async function flushUphillOverrides(projectId: string): Promise { - const stored = readState>("uphill", projectId); - const overrides = Object.entries(stored ?? {}) - .filter(([, side]) => side === "left" || side === "right") - .map(([chainage, side]) => ({ chainage_m: Number(chainage), side })); + const overrides = uphillOverridesOf(projectId); if (!overrides.length) return; await saveRouteParts(projectId, { uphill_overrides: overrides }); } +/** 세션 상단측(측구 방향) 변경분 — 요청 본문 모양. */ +function uphillOverridesOf( + projectId: string, +): NonNullable { + const stored = readState>("uphill", projectId); + return Object.entries(stored ?? {}) + .filter(([, side]) => side === "left" || side === "right") + .map(([chainage, side]) => ({ chainage_m: Number(chainage), side })); +} + +/** [저장] 상단측 차례 본문 한 벌(463 · 요구 271-5) — 노선 캐시 지표 조건 + 확정 지표면 id(서버가 비정규 측점 + * 횡단을 맞춤) · 비정규 목록 = 빈 목록(서버가 정본 관 · 구조물에서 셈) · 상단측 = 세션. 종단 보기를 열었든 + * 안 열었든 같은 요청(기본 창구 · 종단 보기 몫 둘 다 이것). */ +export async function flushRouteParts(projectId: string): Promise { + const latest = + readRouteLatestCache(projectId) ?? (await fetchLatestRoute(projectId).catch(() => null)); + const surface = await fetchConfirmedSurface(projectId).catch(() => null); + await saveRouteParts(projectId, { + filter_key: latest?.surface_params.source_filter, + method: latest?.surface_params.method, + smooth: latest?.surface_params.smooth, + surface_model_id: surface?.model_id ?? undefined, + irregular_stations: [], + uphill_overrides: uphillOverridesOf(projectId), + }); +} + /** [저장] 차례의 확정(단계 전이 없음) — 하위 차례가 실패하면 던짐(443 · 저장 한 줄기가 그 차례에서 멈춤 · * 세션 그대로). 다 되면 보낸 상단측 세션을 비움 — 안 비우면 매 [저장]이 옛 값을 다시 보냄. */ export async function saveRouteParts(projectId: string, body: RouteConfirmRequest): Promise { diff --git a/B05_Profile/B05_Profile_UI_Page_Actions.ts b/B05_Profile/B05_Profile_UI_Page_Actions.ts index deb03576f..fab601dbe 100644 --- a/B05_Profile/B05_Profile_UI_Page_Actions.ts +++ b/B05_Profile/B05_Profile_UI_Page_Actions.ts @@ -7,8 +7,7 @@ * 조작은 [저장]·[확정]에서만 정본으로 나간다). [초기화]는 한 벌(`design_reset`)이 한다. * ========================================================================== */ -import { saveRouteParts, type RouteLatestResponse } from "./B05_Profile_Api_Fetch"; -import { loadUphillOverrides } from "./B05_Profile_UI_Page_Helpers"; +import { flushRouteParts, type RouteLatestResponse } from "./B05_Profile_Api_Fetch"; import type { DesignViewSave } from "../A00_Common/design/design_shared"; import { pageRouteId } from "../A00_Common/design/design_page_route"; import type { createRoutePanel } from "./B05_Profile_UI_Panel"; @@ -46,31 +45,10 @@ export function profileSaveParts(ctx: PageActionContext): DesignViewSave { // 구조물 — 저장 뒤 서버 목록(새 식별자)으로 화면을 맞춘다. 실패는 초안을 남기고 던져 // 저장 한 줄기가 이 차례에서 멈춘다(443). structures: () => ctx.bridge().saveStructuresIfDirty(), - // 상단측 + 비정규 측점 — 데이터로는 확정 저장하되, 단계 완료 전이는 하지 않는다. - // 하위 실패 = 던짐 · 성공 = 상단측 세션 비움(443). 상단측은 세션에서 읽음 — 노선 바꾼 [저장]이 - // 기본 창구로 세션을 비웠으면 메모리 값을 다시 보내지 않게. + // 상단측 + 비정규 측점 횡단 맞춤 — 기본 창구와 같은 본문(463 · 종단 보기를 안 열어도 같은 요청). + // 하위 실패 = 던짐 · 성공 = 상단측 세션 비움(443) · 메모리 값도 비움(다시 안 보냄). uphill: async () => { - const latest = ctx.latest(); - await saveRouteParts(projectId, { - filter_key: latest?.surface_params.source_filter, - method: latest?.surface_params.method, - smooth: latest?.surface_params.smooth, - surface_model_id: ctx.confirmedSurface()?.model_id ?? undefined, - irregular_stations: ctx - .bridge() - .irregularStations() - .map((station) => ({ - chainage_m: station.chainage_m, - structure: station.structure, - })), - // 상단측(측구 방향) 사용자 변경분 — 종단 정본에 병합되어 B06이 그대로 소비한다. - uphill_overrides: [...loadUphillOverrides(projectId).entries()].map( - ([chainage, side]) => ({ - chainage_m: Number(chainage), - side, - }), - ), - }); + await flushRouteParts(projectId); ctx.uphillOverrides.clear(); }, }, diff --git a/B05_Profile/B05_Profile_UI_Page_Latest.ts b/B05_Profile/B05_Profile_UI_Page_Latest.ts index 6a5b0ebec..0e2f6c9dc 100644 --- a/B05_Profile/B05_Profile_UI_Page_Latest.ts +++ b/B05_Profile/B05_Profile_UI_Page_Latest.ts @@ -12,6 +12,8 @@ import { } from "./B05_Profile_Api_Fetch"; import { panelValuesOfDraft, readPageSettings } from "./B05_Profile_Api_PageSettings"; import type { RoutePanelValues } from "./B05_Profile_UI_Panel"; +import { SPEED_CHOICES, gradeLimitOf } from "./B05_Profile_UI_Panel_Fields"; +import type { DesignProfile } from "../A00_Common/design/design_profile"; /** * 확정 이력이 있으면 매 진입마다 DB(latest) 조회 대신 브라우저 세션 캐시를 우선 사용해 @@ -86,3 +88,24 @@ export function panelValuesOf(next: RouteLatestResponse): Partial, + projectId: string, + latest: RouteLatestResponse | null = readRouteLatestCache(projectId), +): void { + const draft = readPageSettings(projectId); + const values = latest ? panelValuesOf(latest) : draft ? panelValuesOfDraft(draft) : null; + if (!values) return; // 모름 — 종단도 그대로(서버 경고 기준) + const allowed = SPEED_CHOICES[values.gradeClass ?? "trunk"] ?? [20]; + const speed = allowed.includes(values.designSpeed ?? 20) + ? (values.designSpeed ?? 20) + : allowed[0]; + profile.setGradeLimit( + gradeLimitOf(speed, values.terrainType ?? "special", values.maxGradePct ?? null), + ); + profile.setEnforceMinCover(values.enforcePipeClearance === true); + profile.setStationDisplay(values.stationDisplay ?? { station: 0, cumulative: 0 }); +} diff --git a/B05_Profile/B05_Profile_UI_Panel_Fields.ts b/B05_Profile/B05_Profile_UI_Panel_Fields.ts index 550298ce1..9fabc1037 100644 --- a/B05_Profile/B05_Profile_UI_Panel_Fields.ts +++ b/B05_Profile/B05_Profile_UI_Panel_Fields.ts @@ -63,6 +63,13 @@ export const PROFILE_CRITERIA: Record< 20: { grade: { normal: 9, special: 14 }, radius: 100 }, }; +/** 적용 종단기울기 상한(%) — 넣은 값(> 0)이면 그 값 · 비웠으면 설계속도 · 지형 법정 기준(좌측 패널 · 페이지 설정 한 칸 463). */ +export const gradeLimitOf = ( + speed: RoutePanelValues["designSpeed"], + terrain: RoutePanelValues["terrainType"], + typed: number | null, +): number => (typed != null && typed > 0 ? typed : PROFILE_CRITERIA[speed].grade[terrain]); + /** 임도 종류별 표시 이름 — 프로젝트 등록(B02)과 같은 말을 쓴다(2026-08-19 확정). */ export const ROAD_TYPE_LABELS: Record = { trunk: "간선임도", diff --git a/B05_Profile/B05_Profile_UI_Panel_Settings.ts b/B05_Profile/B05_Profile_UI_Panel_Settings.ts index 017784cfb..d66adf1f8 100644 --- a/B05_Profile/B05_Profile_UI_Panel_Settings.ts +++ b/B05_Profile/B05_Profile_UI_Panel_Settings.ts @@ -12,6 +12,7 @@ import { createSelectField } from "@ui/ui_template_elements"; import { L, PROFILE_CRITERIA, + gradeLimitOf, ROAD_TYPE_LABELS, SPEED_CHOICES, button, @@ -187,10 +188,8 @@ export function createSettingsPart(callbacks: SettingsPartCallbacks) { } function gradeLimitPct(): number { - const criteria = PROFILE_CRITERIA[currentSpeed()]; const terrain = terrainType.value as RoutePanelValues["terrainType"]; - const typed = parseOptional(maxGradePct); - return typed != null && typed > 0 ? typed : criteria.grade[terrain]; + return gradeLimitOf(currentSpeed(), terrain, parseOptional(maxGradePct)); } /** 임도 종류에 맞춰 설계속도 선택지를 갈아 끼운다 — 작업임도는 20만 쓸 수 있다. */ diff --git a/B06_Section/B06_Section_Section_Store.ts b/B06_Section/B06_Section_Section_Store.ts index 7fd208459..f3d835590 100644 --- a/B06_Section/B06_Section_Section_Store.ts +++ b/B06_Section/B06_Section_Section_Store.ts @@ -137,7 +137,11 @@ export async function loadSectionDetail( cache.set(key, detail); writeState("section-detail", detail, projectId, routeId); keepCanon(projectId, routeId, detail); - return withDraftWalls(detail, projectId, routeId); + const ready = await withDraftWalls(detail, projectId, routeId); + // 강제로 받음(넓힘 재생성 · 측점 만들기) = 갈아끼움 — 옛 객체를 쥔 보기(숨은 종단 보기 등)가 새것으로(463) + if (options?.force && cache.get(key) === detail) + listeners.get(key)?.forEach((fn) => fn(ready, new Map())); + return ready; }) .finally(() => { pending.delete(key); diff --git a/B06_Section/B06_Section_UI_Missing_Stations.ts b/B06_Section/B06_Section_UI_Missing_Stations.ts index 03868c228..1aaacac25 100644 --- a/B06_Section/B06_Section_UI_Missing_Stations.ts +++ b/B06_Section/B06_Section_UI_Missing_Stations.ts @@ -19,6 +19,8 @@ import { createButton, showToast } from "@ui/ui_template_elements"; import { attachHoverInfo } from "@ui/ui_template_hover_info"; import { useDesignStore } from "../A00_Common/design/design_store"; import { savingDesign } from "../A00_Common/design/design_save"; +import { pageRouteId } from "../A00_Common/design/design_page_route"; +import { invalidateSectionDetail, loadSectionDetail } from "./B06_Section_Section_Store"; interface MissingStation { chainage_m: number; @@ -88,6 +90,12 @@ export async function mountMissingStationNotice( const result = await call(projectId, "POST"); showToast(`측점 ${result.created ?? 0}개를 만들었습니다.`, "success"); box.remove(); + // 서버가 측점을 세움 = 옛 상세 — 캐시 비우고 강제로 받아 듣는 쪽(종단 보기)까지 새것(463) + const routeId = pageRouteId(projectId); + if (routeId !== null) { + invalidateSectionDetail(projectId, routeId); + await loadSectionDetail(projectId, routeId, { force: true }); + } await onCreated(); } catch (error) { const detail = error instanceof Error ? ` ${error.message}` : ""; diff --git a/B06_Section/B06_Section_UI_Section_View_Profile.ts b/B06_Section/B06_Section_UI_Section_View_Profile.ts index 52d3ea729..b1a9bf0b2 100644 --- a/B06_Section/B06_Section_UI_Section_View_Profile.ts +++ b/B06_Section/B06_Section_UI_Section_View_Profile.ts @@ -15,7 +15,6 @@ import { sharedProfile, type DesignPageShared, } from "../A00_Common/design/design_shared"; -import { readState } from "../A00_Common/b_page_state"; import { coversChainage, NO_SELECTION, @@ -26,7 +25,12 @@ import { import { paintMapPick } from "../A00_Common/design/design_pick_profile"; import { ESCAPE_FROM } from "../A00_Common/design/design_pick_escape"; import { plantsStation } from "../B05_Profile/B05_Profile_UI_Profile_Structures"; -import { fetchLatestRoute, type RouteLatestResponse } from "../B05_Profile/B05_Profile_Api_Fetch"; +import { + fetchLatestRoute, + readRouteLatestCache, + writeRouteLatestCache, +} from "../B05_Profile/B05_Profile_Api_Fetch"; +import { applyPageSettings } from "../B05_Profile/B05_Profile_UI_Page_Latest"; import type { CrossSection, EarthworkConversion, @@ -79,24 +83,20 @@ export interface SectionProfile { /** * 배수유역도에 노선 평면선을 싣는다 — 종단 보기가 두고 간 최신 노선 캐시를 먼저 쓰고, - * 없으면 한 번 읽는다(읽기만 · 계산 없음). + * 없으면 한 번 읽어 캐시에 둔다(페이지 설정 한 칸이 읽음 · 463). */ function loadRoute(projectId: string, profile: DesignProfile): void { - // 최소 흙덮기 강제 여부 — 종단 보기를 거치지 않고 바로 들어왔으면 저장된 노선 설정값. - const enforceKnown = readState("profile-enforce-min-cover", projectId) !== null; - const apply = (latest: RouteLatestResponse): void => { - profile.setRoutePolyline(latest.route_points ?? []); - if (!enforceKnown) { - profile.setEnforceMinCover(latest.route_params?.enforce_pipe_clearance === true); - } - }; - const cached = readState("latest", projectId); + const cached = readRouteLatestCache(projectId); if (cached?.route_points?.length) { - apply(cached); + profile.setRoutePolyline(cached.route_points); return; } void fetchLatestRoute(projectId) - .then(apply) + .then((latest) => { + writeRouteLatestCache(projectId, latest); + profile.setRoutePolyline(latest.route_points ?? []); + applyPageSettings(profile, projectId, latest); // 그린 뒤 왔으면 지금 얹음 + }) .catch(() => { /* 노선을 못 읽으면 판이 「노선 필요」 안내를 그대로 보인다. */ }); @@ -242,6 +242,7 @@ export function createSectionProfile( input.stationInterval, Number.isFinite(routeId) ? routeId : undefined, ); + applyPageSettings(profile, id); // 페이지 설정 한 칸 — B05 를 안 열었어도 같은 기준(463) if (input.conversion) { profile.setEarthworkContext({ conversion: input.conversion, diff --git a/resources/tester/helper_271_detail_notify.cjs b/resources/tester/helper_271_detail_notify.cjs new file mode 100644 index 000000000..53a1174b2 --- /dev/null +++ b/resources/tester/helper_271_detail_notify.cjs @@ -0,0 +1,218 @@ +/* 463 B05 · B06 한 페이지 상세 알림(요구 271-5 · 분석 4장 (C)) — 진짜 상세 가게(`B06_Section_Section_Store`) · + * 진짜 [측점 만들기] 줄(`B06_Section_UI_Missing_Stations`) · 진짜 페이지 노선 칸. 서버 · 그림 · DOM 은 가짜. + * R1 — B05 가 쥔 상세(구독) · B06 넓힘 재생성 = 강제 다시 받기 → 듣는 쪽 한 번 · 새 객체 + * R2 — [측점 만들기] 뒤 = 캐시 비우고 강제로 받음 → 듣는 쪽 한 번 · 다시 그림(onCreated)이 새 상세를 읽음 + * node helper_271_detail_notify.cjs (짝 = `test_271_detail_notify.py`) + */ +const fs = require("fs"); +const path = require("path"); +const ROOT = path.join(__dirname, "..", ".."); +const ts = require(path.join(ROOT, "config", "node_modules", "typescript")); +const CATALOG = require(path.join(__dirname, "helper_322_catalog_module.cjs")); + +const P = "P463"; + +function memoryStorage() { + const map = new Map(); + return { + getItem: (k) => (map.has(k) ? map.get(k) : null), + setItem: (k, v) => map.set(k, String(v)), + removeItem: (k) => map.delete(k), + key: (i) => [...map.keys()][i] ?? null, + get length() { + return map.size; + }, + }; +} +globalThis.window = { + sessionStorage: memoryStorage(), + localStorage: memoryStorage(), + setTimeout, + clearTimeout, +}; + +/** 가짜 DOM — [측점 만들기] 줄이 쓰는 만큼(요소 · 단추 누름). */ +function fakeElement() { + const handlers = {}; + return { + className: "", + textContent: "", + disabled: false, + isConnected: true, + children: [], + append(...nodes) { + this.children.push(...nodes); + }, + prepend(node) { + this.children.unshift(node); + }, + remove() {}, + querySelector: () => null, + addEventListener: (name, fn) => (handlers[name] = fn), + fire: (name) => handlers[name](), + }; +} +globalThis.document = { createElement: () => fakeElement() }; +const buttons = []; + +const anyModule = (extra = {}) => + new Proxy(extra, { get: (t, key) => (key in t ? t[key] : () => undefined) }); + +/** 가짜 서버 — 노선 3 상세 · 받을 때마다 새 객체 · 측점 수는 `server.stations`. */ +const server = { stations: [0, 20, 40], fetches: 0, created: 0 }; +const detailOf = () => ({ + longitudinal: { stations: [], design_profiles: [] }, + cross_sections: server.stations.map((chainage_m) => ({ + station_id: `s${chainage_m}`, + chainage_m, + samples: [], + design: {}, + })), +}); +globalThis.fetch = async (url, init) => { + const method = init?.method ?? "GET"; + if (method === "POST") { + server.stations = [0, 20, 33.5, 40]; // 서버가 관 자리 측점을 세움 + server.created += 1; + return { ok: true, status: 200, json: async () => ({ created: 1 }) }; + } + const missing = server.created ? [] : [{ chainage_m: 33.5, label: "배수관" }]; + return { ok: true, status: 200, json: async () => ({ missing, can_create: true }) }; +}; + +const STUBS = { + "design_pipeline_page.ts": { + reconcilePagePipeline: async () => 0, + pipelineOwnsCross: () => false, + pagePipeline: () => null, + pipelineCross: () => undefined, + pipelineDetail: () => null, + }, + "design_pipeline_cross.ts": { forceCross: () => undefined }, + "B06_Section_Section_Drafts.ts": { + forgetDraftStations: () => undefined, + supersedeDraftStations: () => undefined, + withDraftShapes: async () => undefined, + withDraftStations: async () => undefined, + }, + "B05_Profile_Api_PlusStations.ts": { + loadSavedPlusStations: async () => undefined, + plusStationsOf: () => [], + }, + "B05_Profile_Api_Pipes_Draft.ts": { readPendingPipes: () => null }, + "design_page_data.ts": { pagePipes: async () => ({ pipe_points: [] }) }, + "B06_Section_Api_Fetch.ts": anyModule({ + fetchSectionDetail: async () => { + server.fetches += 1; + return detailOf(); + }, + }), + "B06_Section_Api_Culvert_Options.ts": anyModule({ STRUCTURE_ADJUST_KEYS: [] }), + "B06_Section_Swap_Bus.ts": anyModule(), + "B06_Section_Station_Keys.ts": anyModule({ KEYED_STATES: [] }), + "B06_Section_UI_Page_Persist.ts": anyModule(), + "B06_Section_UI_Standard_Panel.ts": anyModule({ readRockBoundaryDefault: () => null }), + "B06_Section_Cross_Design_Session.ts": anyModule(), + "common_util_cross_design.ts": anyModule(), + "B06_Section_Structure_Layouts.ts": anyModule({ computeStoredLayouts: () => null }), + "B06_Section_UI_Cross_Culvert_Wire.ts": anyModule(), + "B06_Section_UI_Cross_Ford_Geom.ts": anyModule(), + "B06_Section_UI_Cross_Box_Geom.ts": anyModule(), + "B06_Section_UI_Cross_View.ts": anyModule(), + "B06_Section_UI_Section_Common.ts": anyModule({ designElevationAt: () => undefined }), + "B06_Section_Display_Width.ts": anyModule({ displayHalfWidth: () => 10 }), + "B06_Section_UI_Style_Cross_Controls.css": {}, + "B06_Section_UI_Cross_Culvert_Basin.ts": anyModule(), + "B06_Section_UI_Cross_Culvert_Const.ts": anyModule(), + "B06_Section_UI_IrregularStations.ts": anyModule({ PIPE_DISPLAY_NAME: {} }), + "B05_Profile_UI_IrregularStations.ts": anyModule({ PIPE_DISPLAY_NAME: {} }), + "config_frontend.ts": anyModule({ PIPE_TYPES: [], API_BASE_URL: "/api" }), + "ui_template_elements.ts": anyModule({ + createButton: () => { + const button = fakeElement(); + buttons.push(button); + return button; + }, + showToast: () => undefined, + }), + "ui_template_hover_info.ts": anyModule(), + "design_store.ts": { useDesignStore: () => ({ subscribe: () => () => undefined }) }, + "design_save.ts": { savingDesign: () => false }, +}; + +const cache = new Map(); +const resolve = (base) => + [base, `${base}.ts`, path.join(base, "index.ts")].find( + (p) => fs.existsSync(p) && fs.statSync(p).isFile(), + ) ?? `${base}.ts`; +function loadTs(full) { + const stub = STUBS[path.basename(full)]; + if (stub) return stub; + if (cache.has(full)) return cache.get(full); + const js = ts.transpileModule(fs.readFileSync(full, "utf8"), { + compilerOptions: { + module: ts.ModuleKind.CommonJS, + target: ts.ScriptTarget.ES2020, + esModuleInterop: true, + }, + }).outputText; + const box = { exports: {} }; + cache.set(full, box.exports); + const near = (request) => { + if (CATALOG.isCatalogRequest(request)) return CATALOG; + const alias = { "@util/": "common_util", "@ui/": "ui_template", "@config/": "config" }; + for (const [prefix, dir] of Object.entries(alias)) + if (request.startsWith(prefix)) + return loadTs(resolve(path.join(ROOT, dir, request.slice(prefix.length)))); + return loadTs(resolve(path.join(path.dirname(full), request))); + }; + new Function("exports", "module", "require", js)(box.exports, box, near); + return box.exports; +} + +const store = loadTs(path.join(ROOT, "B06_Section", "B06_Section_Section_Store.ts")); +const missing = loadTs(path.join(ROOT, "B06_Section", "B06_Section_UI_Missing_Stations.ts")); +const route = loadTs(path.join(ROOT, "A00_Common", "design", "design_page_route.ts")); +const tick = () => new Promise((next) => setTimeout(next, 0)); + +(async () => { + const out = {}; + route.setPageRouteId(P, 3); + + // R1 — B05 이 쥔 상세 · B06 넓힘 재생성 = 강제 다시 받기 + const held = await store.loadSectionDetail(P, 3); + const heard = []; + const stop = store.subscribeSectionDetail(P, 3, (next) => heard.push(next)); + const fresh = await store.loadSectionDetail(P, 3, { force: true }); + out.r1 = { + newObject: fresh !== held, + listenerCalls: heard.length, + listenerGotFresh: heard[0] === fresh, + cacheIsFresh: store.peekSectionDetail(P, 3) === fresh, + }; + + // R2 — [측점 만들기] 뒤 = 캐시 비우고 강제로 받음 · 듣는 쪽 한 번 · 다시 그림이 새 상세 + heard.length = 0; + const before = server.fetches; + const redrawn = []; + const host = fakeElement(); + await missing.mountMissingStationNotice(host, P, async () => { + const seen = await store.loadSectionDetail(P, 3); // 다시 그림 자리(횡단 보기 refreshDetailForStructures) + redrawn.push(seen.cross_sections.map((section) => section.chainage_m)); + }); + const button = buttons.at(-1); + await button.fire("click"); + await tick(); + out.r2 = { + posted: server.created, + fetchesAfter: server.fetches - before, + listenerCalls: heard.length, + listenerStations: heard[0]?.cross_sections.map((section) => section.chainage_m) ?? null, + redrawn, + }; + stop(); + console.log(JSON.stringify(out)); +})().catch((error) => { + console.error(error); + process.exit(1); +}); diff --git a/resources/tester/helper_271_profile_settings.cjs b/resources/tester/helper_271_profile_settings.cjs new file mode 100644 index 000000000..b395b017a --- /dev/null +++ b/resources/tester/helper_271_profile_settings.cjs @@ -0,0 +1,118 @@ +/* 463 B05 · B06 한 페이지 페이지 설정 한 칸(요구 271-5 · 분석 4장 (B)) — 진짜 `applyPageSettings` · 페이지 설정 초안 · + * 기준표 · 세션 칸. 종단도는 가짜(받은 것 기록). 노선 캐시 · 초안 갈래마다 종단도가 받는 기준을 JSON 한 줄로. + * node helper_271_profile_settings.cjs (짝 = `test_271_profile_settings.py`) + */ +const fs = require("fs"); +const path = require("path"); +const ROOT = path.join(__dirname, "..", ".."); +const ts = require(path.join(ROOT, "config", "node_modules", "typescript")); + +function memoryStorage() { + const map = new Map(); + return { + getItem: (k) => (map.has(k) ? map.get(k) : null), + setItem: (k, v) => map.set(k, String(v)), + removeItem: (k) => map.delete(k), + key: (i) => [...map.keys()][i] ?? null, + get length() { + return map.size; + }, + }; +} +globalThis.window = { sessionStorage: memoryStorage(), localStorage: memoryStorage() }; + +const REAL = new Set( + [ + "A00_Common/b_page_state.ts", + "B05_Profile/B05_Profile_Api_Fetch.ts", + "B05_Profile/B05_Profile_Api_PageSettings.ts", + "B05_Profile/B05_Profile_UI_Page_Latest.ts", + "B05_Profile/B05_Profile_UI_Panel_Fields.ts", + ].map((rel) => path.join(ROOT, rel)), +); +const anything = () => + new Proxy({}, { get: (_target, key) => (key === "__esModule" ? false : () => undefined) }); +const cache = new Map(); +function load(file) { + if (cache.has(file)) return cache.get(file); + const box = { exports: {} }; + cache.set(file, box.exports); + const js = ts.transpileModule(fs.readFileSync(file, "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, + }).outputText; + const req = (request) => { + if (request === "@ui/ui_template_locale") return { ui_locales: {}, currentLanguageIndex: 0 }; + if (!request.startsWith(".")) return anything(); + const target = path.resolve(path.dirname(file), request); + const full = fs.existsSync(`${target}.ts`) ? `${target}.ts` : target; + return REAL.has(full) ? load(full) : anything(); + }; + new Function("exports", "module", "require", js)(box.exports, box, req); + return box.exports; +} + +const latestApi = load(path.join(ROOT, "B05_Profile", "B05_Profile_UI_Page_Latest.ts")); +const settings = load(path.join(ROOT, "B05_Profile", "B05_Profile_Api_PageSettings.ts")); +const state = load(path.join(ROOT, "A00_Common", "b_page_state.ts")); +const P = "P463"; + +/** 가짜 종단도 — 받은 기준을 적음. */ +function apply(latest) { + const got = {}; + latestApi.applyPageSettings( + { + setGradeLimit: (pct) => (got.grade = pct), + setEnforceMinCover: (on) => (got.enforce = on), + setStationDisplay: (next) => (got.display = next), + }, + P, + latest, + ); + return got; +} +const latestOf = (params) => ({ project_id: P, surface_params: {}, route_params: params }); + +const out = {}; +window.sessionStorage = memoryStorage(); +out.nothing = apply(undefined); // 노선 캐시 · 초안 없음 = 종단도 그대로 +state.writeState( + "latest", + latestOf({ + options: { grade_class: "trunk", design_speed_kph: 30, terrain_type: "normal" }, + max_grade_pct: null, + enforce_pipe_clearance: true, + station_display: { station: 3, cumulative: 60 }, + }), + P, +); +out.saved = apply(undefined); // 캐시 읽음 +settings.writePageSettings(P, { + design_speed_kph: 30, + terrain_type: "normal", + max_grade_pct: 12, + min_vertical_radius_m: null, + min_tangent_length_m: null, + enforce_pipe_clearance: false, + station_display: { station: 5, cumulative: 100 }, +}); +out.draft = apply(undefined); // 초안이 이김 +out.work = apply( + latestOf({ options: { grade_class: "work", design_speed_kph: 30, terrain_type: "normal" } }), +); // 받은 노선 · 초안 그대로 이김(작업임도 30 → 20 · 넣은 12 그대로) +state.clearState("pagesettings", P); +out.workSaved = apply( + latestOf({ options: { grade_class: "work", design_speed_kph: 30, terrain_type: "normal" } }), +); +out.defaults = apply(latestOf({})); // 빈 설정 = 좌측 패널 기본(20 · 특수 · 강제 해제 · 0) +state.clearState("latest", P); +settings.writePageSettings(P, { + design_speed_kph: 40, + terrain_type: "special", + max_grade_pct: null, + min_vertical_radius_m: null, + min_tangent_length_m: null, + enforce_pipe_clearance: true, + station_display: { station: 0, cumulative: 0 }, +}); +out.draftOnly = apply(undefined); +console.log(JSON.stringify(out)); diff --git a/resources/tester/helper_271_save_same.cjs b/resources/tester/helper_271_save_same.cjs new file mode 100644 index 000000000..8bb6c7f65 --- /dev/null +++ b/resources/tester/helper_271_save_same.cjs @@ -0,0 +1,142 @@ +/* 463 B05 · B06 한 페이지 [저장] 같은 본문(요구 271-5 · 분석 4장 (D)) — 진짜 저장 한 줄기(`design_save`) · + * 종단 보기 몫(`B05_Profile_UI_Page_Actions.profileSaveParts`) · B05 · B04 서버 창구 · 세션 칸. fetch 는 가짜(요청 줄 기록) · + * 그 밖 차례 창구 · 화면은 가짜. 종단 보기를 안 연 [저장](기본 창구) · 연 뒤 [저장]의 요청 줄을 견줌. + * node helper_271_save_same.cjs (짝 = `test_271_save_same.py`) + */ +const fs = require("fs"); +const path = require("path"); +const ROOT = path.join(__dirname, "..", ".."); +const ts = require(path.join(ROOT, "config", "node_modules", "typescript")); + +function memoryStorage() { + const map = new Map(); + return { + getItem: (k) => (map.has(k) ? map.get(k) : null), + setItem: (k, v) => map.set(k, String(v)), + removeItem: (k) => map.delete(k), + key: (i) => [...map.keys()][i] ?? null, + get length() { + return map.size; + }, + }; +} +globalThis.window = { + sessionStorage: memoryStorage(), + localStorage: memoryStorage(), + setTimeout, + clearTimeout, +}; + +/** 진짜로 읽는 파일 — 그 밖은 가짜(부르면 undefined). */ +const REAL = new Set( + [ + "A00_Common/design/design_save.ts", + "A00_Common/design/design_page_route.ts", + "A00_Common/b_page_state.ts", + "B05_Profile/B05_Profile_Api_Fetch.ts", + "B05_Profile/B05_Profile_UI_Page_Actions.ts", + "B04_PreProcess/B04_PreProcess_Api_Fetch.ts", + ].map((rel) => path.join(ROOT, rel)), +); +const CONFIG = { API_BASE_URL: "/api", API_TIMEOUT_MS: 1000, API_ANALYSIS_TIMEOUT_MS: 1000 }; +const anything = () => + new Proxy({}, { get: (_target, key) => (key === "__esModule" ? false : () => undefined) }); + +const cache = new Map(); +function load(file) { + if (cache.has(file)) return cache.get(file); + const box = { exports: {} }; + cache.set(file, box.exports); + const js = ts.transpileModule(fs.readFileSync(file, "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, + }).outputText; + const req = (request) => { + if (request === "@config/config_frontend") return CONFIG; + if (!request.startsWith(".")) return anything(); + const target = path.resolve(path.dirname(file), request); + const full = fs.existsSync(`${target}.ts`) ? `${target}.ts` : target; + return REAL.has(full) ? load(full) : anything(); + }; + new Function("exports", "module", "require", js)(box.exports, box, req); + return box.exports; +} + +const save = load(path.join(ROOT, "A00_Common", "design", "design_save.ts")); +const actions = load(path.join(ROOT, "B05_Profile", "B05_Profile_UI_Page_Actions.ts")); +const state = load(path.join(ROOT, "A00_Common", "b_page_state.ts")); +const route = load(path.join(ROOT, "A00_Common", "design", "design_page_route.ts")); + +const P = "P463"; +const LATEST = { + project_id: P, + route: { id: 3 }, + route_points: [ + { x: 0, y: 0 }, + { x: 100, y: 0 }, + ], + surface_params: { source_filter: "csf", method: "tin", smooth: true, contour_interval_m: 1 }, + route_params: {}, +}; + +let lines = []; +globalThis.fetch = async (url, init) => { + const method = init?.method ?? "GET"; + lines.push(`${method} ${url}${init?.body ? ` ${init.body}` : ""}`); + let reply = {}; + if (url.includes("/surface/confirmed")) reply = { model_id: 7 }; + else if (url.includes("/route/latest")) reply = LATEST; + else if (url.includes("/route/confirm")) reply = { failed_parts: [] }; + return { ok: true, status: 200, json: async () => reply }; +}; + +/** 세션을 같은 출발점으로 — 노선 캐시(있으면) · 상단측 한 곳. */ +function seed(withLatest) { + window.sessionStorage = memoryStorage(); + if (withLatest) state.writeState("latest", LATEST, P); + state.writeState("uphill", { "40.000": "right" }, P); +} + +async function run(steps) { + lines = []; + const failed = []; + const ok = await save.saveDesign({ + projectId: P, + routeId: () => 3, + steps, + cross: async () => undefined, + failed: (error) => failed.push(String(error?.message ?? error)), + }); + return { ok, failed, lines, uphillAfter: state.readState("uphill", P) }; +} + +(async () => { + route.setPageRouteId(P, 3); + const out = {}; + + // ① 종단 보기를 안 연 [저장] = 기본 창구 + seed(true); + out.closed = await run(undefined); + + // ② 종단 보기를 연 뒤 [저장] = 종단 보기 몫(상단측 차례) — 구조물 몫은 PUT 없음(가짜) + seed(true); + const memory = new Map([["40.000", "right"]]); + const parts = actions.profileSaveParts({ + projectId: P, + latest: () => LATEST, + confirmedSurface: () => ({ model_id: 7 }), + routeReady: () => true, + bridge: () => ({ saveStructuresIfDirty: async () => undefined }), + uphillOverrides: memory, + }); + out.open = await run({ uphill: parts.steps.uphill }); + out.openMemoryAfter = memory.size; + + // ③ 새로고침해 B06 로 바로 — 노선 캐시 없음 = 서버에서 한 번 읽고 같은 본문 + seed(false); + out.noCache = await run(undefined); + + console.log(JSON.stringify(out)); +})().catch((error) => { + console.error(error); + process.exit(1); +}); diff --git a/resources/tester/helper_323_9_save_redraw.cjs b/resources/tester/helper_323_9_save_redraw.cjs index c81b851fe..6ebc39496 100644 --- a/resources/tester/helper_323_9_save_redraw.cjs +++ b/resources/tester/helper_323_9_save_redraw.cjs @@ -72,6 +72,9 @@ function loadTs(file) { STUBS["@util/common_util_station_key"] = loadTs( path.join(ROOT, "common_util", "common_util_station_key.ts"), ); +// 상단측 차례 본문(463 · 지표 조건 · 확정 지표면 GET · 확정 POST) — 이 시험은 횡단 뒤처리만 봄 +const B05_FETCH = path.join(ROOT, "B05_Profile", "B05_Profile_Api_Fetch.ts"); +STUBS[B05_FETCH] = { ...loadTs(B05_FETCH), flushRouteParts: async () => undefined }; const { saveDesign, savingDesign } = loadTs( path.join(ROOT, "A00_Common", "design", "design_save.ts"), ); diff --git a/resources/tester/helper_349_0u_save_rebase.cjs b/resources/tester/helper_349_0u_save_rebase.cjs index 16c9fa553..4d62cbb58 100644 --- a/resources/tester/helper_349_0u_save_rebase.cjs +++ b/resources/tester/helper_349_0u_save_rebase.cjs @@ -77,6 +77,7 @@ STUBS["B04_PreProcess_Api_Fetch"] = { STUBS["B05_Profile_Api_Structures"] = { flushPendingStructures: async () => undefined }; STUBS["B05_Profile_Api_Fetch"] = { flushUphillOverrides: async () => undefined, + flushRouteParts: async () => undefined, // 상단측 차례(463) saveProfileAlignment: async () => ({}), }; STUBS["B05_Profile_UI_Profile_Edit"] = { diff --git a/resources/tester/helper_449_save_pipes.cjs b/resources/tester/helper_449_save_pipes.cjs index bfcc6ef05..b18541aa5 100644 --- a/resources/tester/helper_449_save_pipes.cjs +++ b/resources/tester/helper_449_save_pipes.cjs @@ -76,6 +76,7 @@ STUBS["B05_Profile_Api_Structures"] = { STUBS["B05_Profile_Api_PlusStations"] = { flushPlusStations: async () => undefined }; STUBS["B05_Profile_Api_Fetch"] = { flushUphillOverrides: async () => undefined, + flushRouteParts: async () => undefined, // 상단측 차례(463) saveProfileAlignment: async (_projectId, routeId, draft) => { calls.push(["ALIGN", routeId, draft]); return {}; diff --git a/resources/tester/helper_design_store_save.cjs b/resources/tester/helper_design_store_save.cjs index 55a103860..2aca10efc 100644 --- a/resources/tester/helper_design_store_save.cjs +++ b/resources/tester/helper_design_store_save.cjs @@ -74,6 +74,7 @@ const STUBS = { }, "../../B05_Profile/B05_Profile_Api_Fetch": { flushUphillOverrides: () => record("uphill")(), + flushRouteParts: () => record("uphill")(), // 상단측 차례 = 종단 보기 몫과 같은 본문(463) saveProfileAlignment: async (_p, routeId, edits) => calls.push(`alignment:${routeId}:${JSON.stringify(edits.station_offsets)}`), }, diff --git a/resources/tester/test_176_3_no_uphill_lamp.py b/resources/tester/test_176_3_no_uphill_lamp.py index 3f1b7a61c..daae0a2e6 100644 --- a/resources/tester/test_176_3_no_uphill_lamp.py +++ b/resources/tester/test_176_3_no_uphill_lamp.py @@ -30,7 +30,11 @@ def test_save_path_and_merge_kept(): # 저장 길(세션 → 저장 몸통)과 B06 이 부르는 종단 병합 함수는 그대로 page = _read("A00_Common", "design", "design_view_profile.ts") assert "persistUphillOverrides" in page - assert "uphill_overrides" in _read("B05_Profile", "B05_Profile_UI_Page_Actions.ts") + # 상단측 본문 = 종단 보기 몫 · 기본 창구 한 함수(463) + assert "flushRouteParts(projectId)" in _read("B05_Profile", "B05_Profile_UI_Page_Actions.ts") + assert "uphill_overrides: uphillOverridesOf(projectId)," in _read( + "B05_Profile", "B05_Profile_Api_Fetch.ts" + ) assert "def _merge_uphill_overrides_into_longitudinal" in _read( "B05_Profile", "B05_Profile_Router_Confirm.py" ) diff --git a/resources/tester/test_271_detail_notify.py b/resources/tester/test_271_detail_notify.py new file mode 100644 index 000000000..b537572ce --- /dev/null +++ b/resources/tester/test_271_detail_notify.py @@ -0,0 +1,55 @@ +"""463 B05 · B06 한 페이지 — 상세 갈아끼움 알림(요구 271-5 · 분석 4장 (C)). + +① R1 — 강제 다시 받기(넓힘 재생성) = 듣는 쪽(B05 종단 보기) 한 번 · 새 상세 + · 옛 코드 = 0 번(B05 옛 객체 쥠) +② R2 — [측점 만들기] 뒤 = 캐시 비우고 강제로 받음 · 듣는 쪽 한 번 · 다시 그림이 새 측점 + · 옛 코드 = 옛 캐시 +TS 셈은 `helper_271_detail_notify.cjs`. + + ./venv/Scripts/python.exe -m pytest resources/tester/test_271_detail_notify.py -q +""" + +import json +import subprocess +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +HELPER = Path(__file__).with_name("helper_271_detail_notify.cjs") + +pytestmark = pytest.mark.skipif( + not (ROOT / "config" / "node_modules" / "typescript").is_dir(), + reason="프론트엔드 의존성 미설치", +) + + +@pytest.fixture(scope="module") +def got() -> dict: + run = subprocess.run( # noqa: S603 — 고정 실행 파일 + ["node", str(HELPER)], + capture_output=True, + text=True, + encoding="utf-8", + timeout=120, + ) + assert run.returncode == 0, run.stderr + return json.loads(run.stdout) + + +def test_R1_강제_다시_받기는_듣는_쪽_한_번(got): + assert got["r1"] == { + "newObject": True, + "listenerCalls": 1, + "listenerGotFresh": True, + "cacheIsFresh": True, + } + + +def test_R2_측점_만들기_뒤_새_상세(got): + r2 = got["r2"] + assert r2["posted"] == 1 + assert r2["fetchesAfter"] == 1 # 강제로 한 번 · 다시 그림은 그 캐시 + assert r2["listenerCalls"] == 1 + assert r2["listenerStations"] == [0, 20, 33.5, 40] + assert r2["redrawn"] == [[0, 20, 33.5, 40]] diff --git a/resources/tester/test_271_profile_settings.py b/resources/tester/test_271_profile_settings.py new file mode 100644 index 000000000..784eb3c77 --- /dev/null +++ b/resources/tester/test_271_profile_settings.py @@ -0,0 +1,91 @@ +"""463 B05 · B06 한 페이지 — 페이지 설정 한 칸(요구 271-5 · 분석 4장 (B)). + +① 종단도 기준(기울기 상한 · 최소고 강제 · 시작 측점) = 세션 초안(448) ?? 저장 노선 설정 + · 빈 칸 = 좌측 패널 기본(설계속도 20 · 특수지형 · 강제 해제 · 0) · 작업임도는 20 만 + · 둘 다 없으면 종단도 그대로 +② 이음 — 횡단 보기 · 종단 보기 둘 다 종단도를 그린 뒤 같은 함수 + · 옛 코드 = B05 좌측 패널만 얹어 B06 새로고침에서 라벨 오프셋 · 기울기 빨강 · 강제가 + B05 열기 전까지 다름 · B06 이 받은 노선은 캐시에 둠 +③ 좌측 패널 상한 = 같은 기준 함수 +TS 셈은 `helper_271_profile_settings.cjs`. + + ./venv/Scripts/python.exe -m pytest resources/tester/test_271_profile_settings.py -q +""" + +import json +import subprocess +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +HELPER = Path(__file__).with_name("helper_271_profile_settings.cjs") + +pytestmark = pytest.mark.skipif( + not (ROOT / "config" / "node_modules" / "typescript").is_dir(), + reason="프론트엔드 의존성 미설치", +) + + +def src(*parts: str) -> str: + return ROOT.joinpath(*parts).read_text(encoding="utf-8") + + +@pytest.fixture(scope="module") +def got() -> dict: + run = subprocess.run( # noqa: S603 — 고정 실행 파일 + ["node", str(HELPER)], + capture_output=True, + text=True, + encoding="utf-8", + timeout=120, + ) + assert run.returncode == 0, run.stderr + return json.loads(run.stdout) + + +def test_초안이_저장_설정을_이김(got): + assert got["nothing"] == {} + assert got["saved"] == { + "grade": 8, + "enforce": True, + "display": {"station": 3, "cumulative": 60}, + } + assert got["draft"] == { + "grade": 12, + "enforce": False, + "display": {"station": 5, "cumulative": 100}, + } + assert got["work"] == got["draft"] + assert got["draftOnly"] == { + "grade": 10, + "enforce": True, + "display": {"station": 0, "cumulative": 0}, + } + + +def test_빈_칸은_좌측_패널_기본(got): + zero = {"station": 0, "cumulative": 0} + assert got["workSaved"] == {"grade": 9, "enforce": False, "display": zero} # 작업임도 30 → 20 + assert got["defaults"] == {"grade": 14, "enforce": False, "display": zero} # 20 · 특수 + + +def test_두_보기가_같은_함수로_얹음(): + cross = src("B06_Section", "B06_Section_UI_Section_View_Profile.ts") + render = cross[cross.index(" profile.render(") :] + assert render.index("applyPageSettings(profile, id);") < render.index("lane.paint();") + loader = cross[cross.index("function loadRoute(") :] + loader = loader[: loader.index("\n}\n")] + assert "writeRouteLatestCache(projectId, latest);" in loader + assert "applyPageSettings(profile, projectId, latest);" in loader + assert "enforceKnown" not in cross + sections = src("A00_Common", "design", "design_view_profile_sections.ts") + body = sections[sections.index(" function renderSections(") :] + body = body[: body.index("\n }\n")] + assert "applyPageSettings(profilePanel, projectId, state.latest ?? undefined);" in body + assert "panel.gradeLimitPct()" not in body + + +def test_좌측_패널_상한도_같은_기준(): + part = src("B05_Profile", "B05_Profile_UI_Panel_Settings.ts") + assert "return gradeLimitOf(currentSpeed(), terrain, parseOptional(maxGradePct));" in part diff --git a/resources/tester/test_271_save_same.py b/resources/tester/test_271_save_same.py new file mode 100644 index 000000000..592c07851 --- /dev/null +++ b/resources/tester/test_271_save_same.py @@ -0,0 +1,84 @@ +"""463 B05 · B06 한 페이지 — [저장] 같은 본문(요구 271-5 · 분석 4장 (D)). + +① 종단 보기를 안 연 [저장](기본 창구) · 연 뒤 [저장](종단 보기 몫) = 요청 줄 같음 + — 옛 코드 = 기본 창구가 상단측만 보내 서버 확정 요청에 지표 조건 · 확정 지표면 id 가 빠짐 + (비정규 측점 횡단 맞춤 안 돎) +② 노선 캐시 없음(새로고침해 B06 로 바로) = 서버에서 한 번 읽고 같은 본문 +③ 서버 — 그 본문이면 확정이 비정규 측점 횡단을 맞춤(`can_regenerate`) +TS 셈은 `helper_271_save_same.cjs`. + + ./venv/Scripts/python.exe -m pytest resources/tester/test_271_save_same.py -q +""" + +import json +import subprocess +from pathlib import Path + +import pytest + +from B05_Profile.B05_Profile_Schema import RouteConfirmRequest + +ROOT = Path(__file__).resolve().parents[2] +HELPER = Path(__file__).with_name("helper_271_save_same.cjs") + +pytestmark = pytest.mark.skipif( + not (ROOT / "config" / "node_modules" / "typescript").is_dir(), + reason="프론트엔드 의존성 미설치", +) + + +@pytest.fixture(scope="module") +def got() -> dict: + run = subprocess.run( # noqa: S603 — 고정 실행 파일 + ["node", str(HELPER)], + capture_output=True, + text=True, + encoding="utf-8", + timeout=120, + ) + assert run.returncode == 0, run.stderr + return json.loads(run.stdout) + + +def _confirm_body(lines: list[str]) -> dict: + posts = [line for line in lines if line.startswith("POST ") and "/route/confirm" in line] + assert len(posts) == 1, lines + return json.loads(posts[0].split(" ", 2)[2]) + + +def test_종단_보기를_열든_안_열든_같은_요청(got): + assert got["closed"]["ok"] and got["open"]["ok"] + assert got["closed"]["failed"] == [] and got["open"]["failed"] == [] + assert got["closed"]["lines"] == got["open"]["lines"] + body = _confirm_body(got["closed"]["lines"]) + assert body == { + "filter_key": "csf", + "method": "tin", + "smooth": True, + "surface_model_id": 7, + "irregular_stations": [], + "uphill_overrides": [{"chainage_m": 40, "side": "right"}], + } + # 상단측 세션 · 종단 보기 메모리 둘 다 비움(다음 [저장]이 옛 값을 다시 안 보냄) + assert got["closed"]["uphillAfter"] is None and got["open"]["uphillAfter"] is None + assert got["openMemoryAfter"] == 0 + + +def test_노선_캐시_없어도_같은_본문(got): + lines = got["noCache"]["lines"] + assert lines[0] == "GET /api/projects/P463/route/latest" + assert _confirm_body(lines) == _confirm_body(got["closed"]["lines"]) + + +def test_서버는_그_본문이면_비정규_측점_횡단을_맞춤(got): + body = _confirm_body(got["closed"]["lines"]) + assert RouteConfirmRequest(**body).can_regenerate() + assert not RouteConfirmRequest(uphill_overrides=body["uphill_overrides"]).can_regenerate() + + +def test_종단_보기_몫은_기본_창구_함수를_부름(): + actions = (ROOT / "B05_Profile" / "B05_Profile_UI_Page_Actions.ts").read_text("utf-8") + save = (ROOT / "A00_Common" / "design" / "design_save.ts").read_text("utf-8") + assert "await flushRouteParts(projectId);" in actions + assert "saveRouteParts(" not in actions + assert "return () => flushRouteParts(projectId);" in save diff --git a/resources/tester/test_443_save_stops.py b/resources/tester/test_443_save_stops.py index a0b6a2921..578d0b44e 100644 --- a/resources/tester/test_443_save_stops.py +++ b/resources/tester/test_443_save_stops.py @@ -72,8 +72,10 @@ def test_확정_하위_실패면_던짐(out): def test_종단_보기_상단측_차례도_같은_창구(): actions = (ROOT / "B05_Profile" / "B05_Profile_UI_Page_Actions.ts").read_text("utf-8") - assert "await saveRouteParts(projectId, {" in actions - assert "loadUphillOverrides(projectId)" in actions # 세션에서 읽음(메모리 옛 값 안 보냄) + # 기본 창구와 같은 함수(463) — 상단측은 세션에서 읽음(메모리 옛 값 안 보냄) + assert "await flushRouteParts(projectId);" in actions + fetch = (ROOT / "B05_Profile" / "B05_Profile_Api_Fetch.ts").read_text("utf-8") + assert "uphill_overrides: uphillOverridesOf(projectId)," in fetch assert "confirmRoute(" not in actions diff --git a/resources/tester/test_b06_save_channels.py b/resources/tester/test_b06_save_channels.py index 44a84ff11..b6a7c67ac 100644 --- a/resources/tester/test_b06_save_channels.py +++ b/resources/tester/test_b06_save_channels.py @@ -70,7 +70,7 @@ def test_저장이_창구를_다섯_다_거친다(): save = (ROOT / "A00_Common" / "design" / "design_save.ts").read_text(encoding="utf-8") for token in ( "flushCulvertOptions(", - "flushUphillOverrides(", + "flushRouteParts(", # 상단측 차례 = 종단 보기 몫과 같은 본문(463) "flushPendingPipes(", "flushPendingStructures(", ):