diff --git a/A00_Common/design/design_pipeline_cross.ts b/A00_Common/design/design_pipeline_cross.ts index 20b203ca0..667f504b8 100644 --- a/A00_Common/design/design_pipeline_cross.ts +++ b/A00_Common/design/design_pipeline_cross.ts @@ -1,14 +1,14 @@ /* ============================================================================= * A00_Common/design/design_pipeline_cross.ts - * 계산 묶음 cross 입력 모음(PLAN 346-2 붙임) — 저장 노선 상세의 측점마다 횡단 입력 · 얹힌 시설 제원을 + * 계산 묶음 cross 입력 모음(PLAN 346-2 붙임) — 저장 노선 상세의 측점마다 사용자 횡단 입력 · 카드 번호를 * **측점 열쇠**로 모음. 노선이 바뀌어도 같은 열쇠 측점(격자 번호 · 관 id · 구조물 id)이 그 입력을 받음. * 열쇠 = 저장 노선을 stations 단계와 같은 함수로 짚은 측점(`buildStations`)과 누가거리로 짝지음 — - * 묶음 측점과 같은 열쇠 규칙(관 = 저장 관 id). + * 묶음 측점과 같은 열쇠 규칙(관 = 저장 관 id). 초안 자리로 뜬 시설 측점 = 등록 열쇠(주인 id)로 짝(457). * * 넘기는 칸 = 서버 노선 저장(`CARRY_INPUT_KEYS`)과 같은 일곱(지반 유형 · 측구 종류 · 측구 선택 · 2단 경사 · - * 암 경계 · 암 경사 · 소단) + 사면 쉬프트(노견 확장) · 소단 깊이(417 · 서버 `ROUTE_CARRY_KEYS`) + 얹힌 시설 - * 맥락(포장 · 물넘이 기울임 · 세월교 노면 하강). 단면 유형 · 측구 방향은 새 지형이 정함 - * (`logic/B05_Profile_Pipeline_Detail.ts` `crossWithUphill`). 값 = 상세에 얹힌 설계 위에 세션 고침(측점 열쇠 칸 · + * 암 경계 · 암 경사 · 소단) + 사면 쉬프트(노견 확장) · 소단 깊이(417 · 서버 `ROUTE_CARRY_KEYS`) — 사용자 칸만. + * 그림 자료 · 카드 이름 · 얹힌 시설 맥락(포장 · 기울임 · 하강) · 단면 유형 · 측구 방향은 새 자리에서 다시 셈 + * (`logic/B05_Profile_Pipeline_Detail.ts` `crossWithShapes` · `draftSectionDetail` · `crossWithUphill` · 457). 값 = 상세에 얹힌 설계 위에 세션 고침(측점 열쇠 칸 · * 지반 유형 · 측구 · 2단 경사 · 암 경계 · 암 경사 · 사면 쉬프트 · 소단 깊이) — 노선 초안 동안 B06 고침은 저장 상세에 * 안 얹혀도 입력에 들어감(365-2 · 365-5 · 417). 부를 때마다 모음. * ========================================================================== */ @@ -25,21 +25,10 @@ import type { PipelineStation } from "./logic/design_pipeline_types"; export interface PipelineCrossSource { input: CrossInput; - /** 측점 열쇠 → 얹힌 시설 제원. */ + /** 측점 열쇠 → 카드 번호(457 · 그림 자료는 새 자리에서 다시 얹음). */ specs: Map; } -const SPEC_KEYS = [ - "structure", - "culvert", - "ford", - "box", - "ford_pavement", - "revetment", - "pavement", - "underdrain", -] as const; - /** 같은 측점으로 보는 누가거리 차(m) — 측점 열쇠 글 자릿수(0.01 m). */ const SAME_M = 0.01; @@ -58,13 +47,12 @@ export const forceCross = (): void => void (forced += 1); const finite = (value: unknown): number | null => typeof value === "number" && Number.isFinite(value) ? value : null; -/** 측점 하나의 넘기는 입력(cross 단계 `CrossStationInput` 꼴). */ +/** 측점 하나의 넘기는 사용자 입력(cross 단계 `CrossStationInput` 꼴 · 시설 맥락은 새 자리 그림에서 · 457). */ function carried(section: CrossSection, saved?: CrossSection["design"]): Record { const design = (saved ?? section.design ?? {}) as unknown as Record; const berm = design.berm as { width_m: number; interval_m: number; slope_deg: number; depth_m?: number } | undefined; const cutSlope = finite(design.cut_slope_ratio_user); - const drop = Number(section.ford?.overflow_depth_m); return { ...(typeof design.ground_type === "string" ? { groundType: design.ground_type } : {}), ...(typeof design.ditch_type === "string" ? { ditchType: design.ditch_type } : {}), @@ -82,10 +70,6 @@ function carried(section: CrossSection, saved?: CrossSection["design"]): Record< depthM: berm.depth_m, } : null, - paved: Boolean(section.pavement?.length) || Boolean(section.ford_pavement), - tilted: Boolean(section.ford_pavement || section.underdrain), - tiltPct: section.ford_pavement?.slope_pct ?? null, // 맹암거 = 표준 경사(371-3) - surfaceDropM: drop > 0 ? drop : 0, // 저장분 사면 쉬프트 · 소단 깊이(417 L4) — 세션 고침이 없어도 저장 뒤 그대로 ...sides(design, "shoulder_extend", "shoulderExtend"), ...sides(design, "berm_depth", "bermDepth"), @@ -147,29 +131,30 @@ function sessionCarried(projectId: string, routeId: number): CrossInput["byKey"] return out; } -/** 저장 상세 + 저장 노선 측점(열쇠) + 세션 고침(`routeId` 칸) → cross 입력 · 시설 제원 — 표준단면 · - * 상세가 없으면 null. */ +/** 초안 자리로 뜬 측점 → 처음 본 등록 열쇠 — 노선 초안 동안 등록 목록이 새 노선 것으로 바뀌어도 그대로(457). */ +const strayKeys = new WeakMap(); + +/** 저장 상세 + 저장 노선 측점(열쇠) + 세션 고침(`routeId` 칸) → cross 입력 · 카드 번호 — 표준단면 · + * 상세가 없으면 null. `keysOf` = 상세 측점마다 등록 열쇠(B06 측점 열쇠 목록 · 초안 자리 측점 짝 · 457). */ export function pipelineCrossSource( projectId: string, detail: SectionDetailResponse | null, savedStations: ReadonlyArray, routeId: number | null = null, + keysOf?: (routeId: number, detail: SectionDetailResponse) => string[], ): PipelineCrossSource | null { const standard = detail && effectiveStandardCross(projectId); if (!detail || !standard) return null; const byKey: CrossInput["byKey"] = {}; const specs = new Map(); - // 설계 칸 = 세션에 남긴 저장 상세(초안 · 다시 셈이 안 고친 정본 — 들어온 화면 · 고친 차례 무관 · 365-5) · - // 시설 제원 = 화면 상세(초안 형상을 얹은 것) + // 설계 칸 = 세션에 남긴 저장 상세(초안 · 다시 셈이 안 고친 정본 — 들어온 화면 · 고친 차례 무관 · 365-5) const stored = routeId === null ? null : savedDesigns(projectId, routeId); // 세션 다시 안 풂(387-7) const storedAt = (chainageM: number) => stored?.find((row) => Math.abs(row.chainage_m - chainageM) < SAME_M)?.design; const put = (key: string, section: CrossSection, at = section.chainage_m): void => { byKey[key] = carried(section, storedAt(at)); // 카드 번호 = 저장 카드 번호(같은 측점 열쇠 · 옮긴 관도 · 찾기 · 고름이 같은 id · 440) - const own: Record = { station_id: section.station_id }; - for (const name of SPEC_KEYS) if (section[name] !== undefined) own[name] = section[name]; - specs.set(key, own as SectionSpecs); + specs.set(key, { station_id: section.station_id }); }; const strays: CrossSection[] = []; for (const section of detail.cross_sections) { @@ -182,20 +167,25 @@ export function pipelineCrossSource( if (best && !byKey[best.key]) put(best.key, section); else if (!best && section.kind === "irregular") strays.push(section); } - // 초안 자리로 미리 뜬 관 · 구조물 측점(초안 둔 채 열기 — 상세가 초안 누가거리) = 남은 저장 시설 측점과 차례로 짝 · - // 수가 다르면 안 짝지음(349-0d 와 같은 규칙 · 들어온 화면 무관 같은 제원 · 365-5) - const left = savedStations.filter( - (station) => (station.kind === "pipe" || station.kind === "structure") && !byKey[station.key], - ); - left.sort((a, b) => a.chainageM - b.chainageM); - if (strays.length === left.length) - strays.forEach((section, i) => put(left[i].key, section, left[i].chainageM)); + // 초안 자리로 미리 뜬 관 · 구조물 측점(초안 둔 채 열기 — 상세가 초안 누가거리) = 등록 열쇠(주인 id)가 같은 저장 + // 시설 측점과 짝 — 차례로 짝짓지 않음(엇갈려 옮긴 관 · 457) · 모르는 열쇠는 버림 + const keys = strays.length && routeId !== null ? (keysOf?.(routeId, detail) ?? null) : null; + for (const section of strays) { + const index = detail.cross_sections.indexOf(section); + const key = strayKeys.get(section) ?? keys?.[index]; + if (!key) continue; + strayKeys.set(section, key); + const owner = savedStations.find( + (station) => station.key === key && (station.kind === "pipe" || station.kind === "structure"), + ); + if (owner && !byKey[key]) put(key, section, owner.chainageM); + } if (routeId !== null) for (const [key, entry] of Object.entries(sessionCarried(projectId, routeId))) byKey[key] = { ...byKey[key], ...entry }; return { // 암 경계 기본값 = 서버 config(화면이 문맥에서 기억) — 입력 없는 측점(초안 새 측점)도 서버처럼(349-0p) - // 제원 지문 — 보기가 나중에 얹은 시설 제원도 입력 차이로(고리가 다시 셈 · 들어온 화면 무관 같은 면적 · 365-5) + // 카드 번호 지문(365-5 · 시설 그림이 바뀌면 관 · 구조물 입력이 cross 를 다시 돌림 · 457) input: { standard, byKey, diff --git a/A00_Common/design/design_pipeline_page.ts b/A00_Common/design/design_pipeline_page.ts index 7adc76394..2a0d26553 100644 --- a/A00_Common/design/design_pipeline_page.ts +++ b/A00_Common/design/design_pipeline_page.ts @@ -86,9 +86,9 @@ import { routeRange, } from "../../B05_Profile/logic/B05_Profile_Pipeline_Grade"; import { - crossWithBerm, - crossWithUphill, + crossWithContext, draftSectionDetail, + shapeStatus, type CrossInput, } from "../../B05_Profile/logic/B05_Profile_Pipeline_Detail"; import { @@ -227,9 +227,7 @@ function createPagePipeline( let dropPending = false; /** 노선 초안 전 가게 관 · 구조물 — 초안 동안 follow 가 새 측점을 가게에 씀 · 초안을 버리면 이것으로. */ let beforeDraft: Facilities | null = null; - /** 묶음이 가게에 마지막으로 쓴 관 · 구조물 · 쓰는 동안(메아리 — 다시 입력으로 안 받음). */ - let written: Facilities | null = null; - let echo = false; + let echo = false; // 묶음이 가게에 쓰는 동안(메아리 — 다시 입력으로 안 받음) /** 되돌리기 · 다시하기로 바뀐 노선(356-4) — follow 가 계획고 편집을 옮기지 않고 그 걸음 사진(세션 노선 0 칸) * 그대로. 바뀐 구간 첫 계획선 맞춤 · 계획고 옮김은 새 조작 때만. */ let replay = false; @@ -268,10 +266,14 @@ function createPagePipeline( canon?.detail ?? null, savedStations, typeof routeId === "number" ? routeId : null, + options.stationKeys, // 초안 자리 측점 짝(457) ); if (source) crossSource = source; return source; }; + /** 지금 현황(follow 뒤 입력 = 가게 값) — 새 측점 그림 · 이름 · 시설 맥락을 다시 얹음(457). */ + const statusOf = (pipes: unknown, structures: unknown) => + shapeStatus(pipes ?? savedPipes, structures ?? savedStructures, types); const groundDeps = groundStageDeps(projectId); const crossStage = createCrossStage({ grid: groundDeps.grid }); // 미교차 넓힘 = 같은 지형 격자(349-0j) const followStage = createFollowStage({ @@ -352,9 +354,10 @@ function createPagePipeline( prev: ctx.prev && savedOut.has(ctx.prev) ? null : ctx.prev, inputs: { ...ctx.inputs, - cross: crossWithBerm( - crossWithUphill(ctx.inputs.cross as CrossInput | null, stations), + cross: crossWithContext( + ctx.inputs.cross as CrossInput | null, stations, + statusOf(ctx.inputs.pipes, ctx.inputs.structures), options.bermAt?.(ctx.inputs.structures) ?? null, ), }, @@ -485,6 +488,7 @@ function createPagePipeline( pipe.output("cross"), crossSource?.specs ?? new Map(), options.stationIntervalM ?? profile.policy.station_interval_m, + statusOf(pipe.input("pipes"), pipe.input("structures")), ) : null; if (shown) drafted.add(shown); @@ -509,17 +513,15 @@ function createPagePipeline( function facilitiesNow(): Facilities { return { pipes: store.get("pipes"), structures: store.get("structures") }; } - /** 가게에 씀 — 앞에 쓴 것과 같으면 안 씀(되돌리기가 세션을 되쓴 뒤에도 듣는 쪽은 다시 부름). */ + /** 가게에 씀 — 지금 가게 값과 같으면 안 씀(되돌리기가 가게를 되쓴 뒤 같은 조작도 씀 · 457). */ function writeFacilities(next: Facilities): void { echo = true; try { - if (!written || !same(written.pipes, next.pipes)) store.set("pipes", next.pipes); - if (!written || !same(written.structures, next.structures)) - store.set("structures", next.structures); + if (!same(store.get("pipes"), next.pipes)) store.set("pipes", next.pipes); + if (!same(store.get("structures"), next.structures)) store.set("structures", next.structures); } finally { echo = false; } - written = next; } const inputsNow = (): PipelineInputs => { diff --git a/A00_Common/design/design_pipeline_page_types.ts b/A00_Common/design/design_pipeline_page_types.ts index b3ee12e04..b2363f6cc 100644 --- a/A00_Common/design/design_pipeline_page_types.ts +++ b/A00_Common/design/design_pipeline_page_types.ts @@ -54,6 +54,8 @@ export interface PagePipelineOptions { /** 초안 전 가게 관 · 구조물(초안 둔 채 새로고침도 · 440-4) — 묶음이 초안을 버릴 때 되씀. */ seed?: (facilities: Pick) => void, ) => (stations: PipelineStation[]) => Promise; + /** 저장 상세 측점마다 등록 열쇠(B06 측점 열쇠 목록) — 초안 자리로 뜬 시설 측점의 입력 짝(457). */ + stationKeys?: (routeId: number, detail: SectionDetailResponse) => string[]; /** 고리가 `RECONCILE_ROUNDS` 바퀴 안에 안 맞음 — 「계산 불안정」 한 줄(365-2). */ onUnstable?: () => void; /** 소단 구간(지금 구조물 목록 → 누가거리마다 제원) — 초안 cross 가 따라간 구조물로 소단을 셈(365-5). */ diff --git a/A00_Common/design/design_pipeline_probe.ts b/A00_Common/design/design_pipeline_probe.ts index c653dd48a..06f3d1f48 100644 --- a/A00_Common/design/design_pipeline_probe.ts +++ b/A00_Common/design/design_pipeline_probe.ts @@ -19,9 +19,12 @@ import { probeStation } from "./logic/design_pipeline_probe_station"; import { groundStageDeps } from "../../B05_Profile/B05_Profile_Api_TerrainGrid"; import { createGroundStage } from "../../B05_Profile/logic/B05_Profile_Pipeline_RouteGround"; import { + crossWithShapes, crossWithUphill, + shapeStatus, type CrossInput, } from "../../B05_Profile/logic/B05_Profile_Pipeline_Detail"; +import { fetchStructureTypes } from "../../B05_Profile/B05_Profile_Api_Structures"; import { createCrossStage } from "../../B06_Section/logic/B06_Section_Cross_Stage"; import { pipelineCrossAt } from "../../B05_Profile/B05_Profile_UI_RouteEdit_Cross"; import type { CrossPreviewResponse } from "../../B05_Profile/B05_Profile_Api_Replan"; @@ -72,6 +75,13 @@ export async function pipelineCrossAtChainage( station = filled.stations[0] ?? null; } if (!station) return null; + // 시설 맥락 = 지금 현황을 그 측점에 얹은 그림에서(묶음 cross 와 같음 · 457) + const status = shapeStatus( + inputs.pipes, + inputs.structures, + await fetchStructureTypes(true).catch(() => []), + ); + const shaped = crossWithShapes(inputs.cross as CrossInput | null, [station], status); const out = { profile, ground: { stations: [station], longitudinal: { chainageM: [], z: [] } }, @@ -80,7 +90,7 @@ export async function pipelineCrossAtChainage( generation: 0, range: null, signal: new AbortController().signal, - inputs: { ...inputs, cross: crossWithUphill(inputs.cross as CrossInput | null, [station]) }, + inputs: { ...inputs, cross: crossWithUphill(shaped, [station]) }, out, before: {}, prev: null, diff --git a/A00_Common/design/design_shared.ts b/A00_Common/design/design_shared.ts index 2cfb77a9e..13552aaac 100644 --- a/A00_Common/design/design_shared.ts +++ b/A00_Common/design/design_shared.ts @@ -49,6 +49,7 @@ import { type CrossRefreshInput, } from "../../B06_Section/B06_Section_Cross_Refresh"; import { bermSpansFromStructures, bermSpecAt } from "../../B06_Section/B06_Section_UI_Page_Persist"; +import { registeredKeys, stationIntervalOf } from "../../B06_Section/B06_Section_Station_Keys"; import { detailCross } from "../../B05_Profile/logic/B05_Profile_Pipeline_Detail"; import type { CrossOutput, PipelineStation } from "./logic/design_pipeline_types"; @@ -245,6 +246,9 @@ function startPagePipeline(shared: DesignPageShared, profile: DesignProfile): vo }, ), plantedBase, // 저장 노선 종단 기준 = 입력이 심는 측점(386-3 · 보기 심기 무관) + // 저장 상세 측점마다 등록 열쇠 — 초안 자리로 뜬 시설 측점의 입력 짝(주인 id · 457) + stationKeys: (routeId, detail) => + registeredKeys(projectId, routeId, detail.cross_sections, stationIntervalOf(detail)), structureAreas: (detail) => { // 초안 상세에도 저장 안 한 조절(옹벽 · 세월 · BOX · 집수 · 417 M3) — 면적 얹기 앞 const routeId = routeIdOf(shared); diff --git a/B05_Profile/logic/B05_Profile_Pipeline_Detail.ts b/B05_Profile/logic/B05_Profile_Pipeline_Detail.ts index e3a3500dd..6f8f63de9 100644 --- a/B05_Profile/logic/B05_Profile_Pipeline_Detail.ts +++ b/B05_Profile/logic/B05_Profile_Pipeline_Detail.ts @@ -8,7 +8,9 @@ * `resources/tester/test_346_2_pipeline_cross_page.py`. * crossWithUphill cross 단계 입력에 단면 유형 기본값(상단측)을 얹음 — 서버 노선 저장이 단면 유형 · * 측구 방향을 새 지형으로 정하는 것과 같은 규칙(넘기는 칸 = 부르는 쪽 입력). - * draftSectionDetail ground · profile · cross → 상세(측점 · 지반 · 횡단 설계 · 얹힌 시설 제원). + * crossWithShapes 얹힌 시설 맥락(포장 · 기울임 · 하강) = 지금 현황을 새 측점에 다시 얹은 그림에서(457). + * draftSectionDetail ground · profile · cross → 상세(측점 · 지반 · 횡단 설계 · 그림 자료 · 카드 이름 = 지금 + * 현황을 새 측점에 다시 얹음 · 캐시 `withDraftShapes` 와 같은 `attachShapes` · 457). * * 순수 — 화면 · fetch · 세션 없음. * ========================================================================== */ @@ -26,24 +28,26 @@ import type { import type { ProfileAlignment } from "./B05_Profile_UI_Profile_Alignment"; import { toDesignProfile } from "./B05_Profile_UI_Profile_Data"; import { stationLabel } from "../../common_util/common_util_station"; +import { attachShapes, type AttachType } from "../../common_util/common_util_structure_attach"; +import { registryDefaults } from "../../common_util/common_util_culvert_sets"; +import { + planStations, + SAME_STATION_M, + type PlanPipe, + type PlanStructure, + type PlanType, +} from "../../common_util/common_util_structure_station"; import { stationIdOf } from "./B05_Profile_Pipeline_Grade"; -/** 측점에 얹힌 시설 제원(저장 상세에서 측점 열쇠로 옮김) — 낡음 판정 · 그림이 읽음 · 카드 번호(`station_id` · - * 있으면 저장 카드 번호를 이어 씀 · 440). */ -export type SectionSpecs = Partial< - Pick< - CrossSection, - | "station_id" - | "structure" - | "culvert" - | "ford" - | "box" - | "ford_pavement" - | "revetment" - | "pavement" - | "underdrain" - > ->; +/** 측점 열쇠로 이어 받는 카드 번호(`station_id` · 저장 카드 번호를 이어 씀 · 440) — 그림 자료 · 이름은 안 옮김(457). */ +export type SectionSpecs = Partial>; + +/** 지금 현황(follow 뒤 관 · 구조물) + 구조물 표 — 새 측점에 그림 · 이름 · 시설 맥락을 다시 얹는 재료(457). */ +export interface ShapeStatus { + pipes: ReadonlyArray; + structures: ReadonlyArray; + types: ReadonlyArray; +} /** cross 단계 입력 꼴(`B06_Section_Cross_Stage` `CrossStageInput`) — 칸은 그 단계 몫. */ export interface CrossInput { @@ -128,19 +132,6 @@ const kindOf = (station: PipelineStation): SectionStation["kind"] => ? "curve" : "irregular"; -/** 제원의 누가거리 = 새 측점(서버 부착은 따라간 관 · 구조물 자리 = 그 측점) — 옛 값이면 구조물 면적이 관 자리를 잘못 잡음(349-0n). */ -function atChainage(own: SectionSpecs, chainageM: number): SectionSpecs { - const out: Record = { ...own }; - for (const [name, spec] of Object.entries(own)) - if ( - spec && - typeof spec === "object" && - typeof (spec as { chainage_m?: unknown }).chainage_m === "number" - ) - out[name] = { ...spec, chainage_m: chainageM }; - return out as SectionSpecs; -} - /** 저장 상세 → 묶음 cross 출력(365-4 · 저장 노선 = 다시 셈 창구 한 벌이 고친 설계 그대로) — 측점이 상세와 * 다르면(갈아끼움 전) null. */ export function detailCross( @@ -181,7 +172,6 @@ export function stationSection( chainage_m: station.chainageM, label: stationLabel(station.chainageM, intervalM), kind: kindOf(station), - ...(own.structure ? { structure: own.structure } : {}), center_z: center, azimuth_deg: station.azimuthDeg, plan_radius_m: station.planRadiusM, @@ -215,8 +205,82 @@ export function stationSection( }; } +/** 측점마다 그림 자료 · 카드 이름 = 지금 현황을 그 자리에 다시 얹음(캐시 `withDraftShapes` · 이름 = 측점 계획 + * 제목 · 457) — 옛 측점 그림을 옮기지 않음. */ +function attachStatus(sections: CrossSection[], status: ShapeStatus): void { + const { pipes, structures, types } = status; + attachShapes(sections as unknown as Array>, pipes, structures, types); + const planned = planStations(pipes, structures, types, registryDefaults(types)); + for (const section of sections) { + const hit = planned.find((p) => Math.abs(p.chainage_m - section.chainage_m) < SAME_STATION_M); + if (hit) section.structure = hit.title; + else delete section.structure; + } +} + +/** 현황 묶음(입력 칸은 묶음 모양 `unknown` · 457) — 정의 옵션 · 이름이 빈 종류는 빈 목록 · 종류 id. */ +export const shapeStatus = ( + pipes: unknown, + structures: unknown, + types: ReadonlyArray, +): ShapeStatus => + ({ + pipes, + structures, + types: types.map((type) => ({ + ...type, + name: type.name ?? type.type_id, + options: type.options ?? [], + })), + }) as ShapeStatus; + +/** 그 측점 그림 자료 → 얹힌 시설 맥락(B06 `Cross_Refresh` 와 같은 규칙 · 서버 `tilt_ranges` · `ford_drop_at` 짝). */ +function shapeContext(section: CrossSection): Record { + const drop = Number(section.ford?.overflow_depth_m); + return { + paved: Boolean(section.pavement?.length) || Boolean(section.ford_pavement), + tilted: Boolean(section.ford_pavement || section.underdrain), + tiltPct: section.ford_pavement?.slope_pct ?? null, // 맹암거 = 표준 경사(371-3) + surfaceDropM: drop > 0 ? drop : 0, + }; +} + +/** 측점마다 시설 맥락(포장 · 물넘이 기울임 · 세월교 노면 하강) = 지금 현황을 새 측점에 다시 얹은 그림에서(457). */ +export function crossWithShapes( + input: CrossInput | null, + stations: ReadonlyArray, + status: ShapeStatus | null, +): CrossInput | null { + if (!input || !status) return input; + const sections = stations.map((station) => ({ chainage_m: station.chainageM }) as CrossSection); + attachShapes( + sections as unknown as Array>, + status.pipes, + status.structures, + status.types, + ); + const byKey: CrossInput["byKey"] = { ...input.byKey }; + stations.forEach((station, i) => { + byKey[station.key] = { ...byKey[station.key], ...shapeContext(sections[i]) }; + }); + return { ...input, byKey }; +} + +/** cross 단계 입력 = 시설 맥락(새 자리 그림) · 단면 유형 기본값(상단측) · 소단(지금 구조물) 얹음 — 페이지 묶음 cross. */ +export const crossWithContext = ( + input: CrossInput | null, + stations: ReadonlyArray, + status: ShapeStatus | null, + bermAt: BermAt | null, +): CrossInput | null => + crossWithBerm( + crossWithUphill(crossWithShapes(input, stations, status), stations), + stations, + bermAt, + ); + /** 묶음 출력 → 상세. 저장 상세의 종단 머리(이름)는 그대로 · 측점 · 지반 · 횡단 · 계획선 표본은 새 노선 - * (십자선 대체 높이도 초안 종단 · 413). */ + * (십자선 대체 높이도 초안 종단 · 413) · 그림 자료 · 카드 이름 = `status`(지금 현황)를 새 측점에 다시 얹음(457). */ export function draftSectionDetail( saved: SectionDetailResponse, ground: GroundOutput, @@ -224,15 +288,12 @@ export function draftSectionDetail( cross: CrossOutput | null, specs: ReadonlyMap, intervalM: number, + status: ShapeStatus | null = null, ): SectionDetailResponse { const sections: CrossSection[] = ground.stations.map((station) => - stationSection( - station, - cross, - atChainage(specs.get(station.key) ?? {}, station.chainageM), - intervalM, - ), + stationSection(station, cross, specs.get(station.key) ?? {}, intervalM), ); + if (status) attachStatus(sections, status); const { chainageM, z } = ground.longitudinal; return { longitudinal: { diff --git a/resources/tester/helper_365_page_pipeline.cjs b/resources/tester/helper_365_page_pipeline.cjs index 3679db75d..16b7b33c3 100644 --- a/resources/tester/helper_365_page_pipeline.cjs +++ b/resources/tester/helper_365_page_pipeline.cjs @@ -514,7 +514,10 @@ async function run() { st("pipe:p28", 28, "pipe"), st("end:EP", 40, "end"), ]; - const source = crossMod.pipelineCrossSource(C, screen, savedList, 7); + // 초안 자리 관 측점(27 m) = 등록 열쇠(주인 id)로 저장 관 측점과 짝(457 · 차례 짝 없음) + const source = crossMod.pipelineCrossSource(C, screen, savedList, 7, () => + savedList.map((s) => s.key), + ); const bare = crossMod.pipelineCrossSource( C, { ...screen, cross_sections: screen.cross_sections.filter((x) => x.chainage_m !== 27) }, @@ -534,6 +537,7 @@ async function run() { const viewFree = { grid20: source.input.byKey["grid:1"], pipeSpec: source.specs.get("pipe:p28") ?? null, + pipeCarried: Boolean(source.input.byKey["pipe:p28"]), specsDiffer: source.input.specsKey !== bare.input.specsKey, firstStored: firstPvi(planlineOf(() => savedBase.basePvi)), firstShaken: firstPvi(planlineOf(undefined)), diff --git a/resources/tester/helper_433_swap.cjs b/resources/tester/helper_433_swap.cjs index fd6cccf3c..0f0a0090d 100644 --- a/resources/tester/helper_433_swap.cjs +++ b/resources/tester/helper_433_swap.cjs @@ -9,6 +9,7 @@ const STUBS = { B05_Profile_Pipeline_Grade: { stationIdOf: idOf }, B05_Profile_UI_Profile_Data: { toDesignProfile: () => ({}) }, }; +const CATALOG = require("./helper_322_catalog_module.cjs"); const cache = new Map(); function load(full) { if (cache.has(full)) return cache.get(full).exports; @@ -18,6 +19,7 @@ function load(full) { const box = { exports: {} }; cache.set(full, box); const req = (r) => { + if (CATALOG.isCatalogRequest(r)) return CATALOG; // 형상 얹기(Pipeline_Detail · 457)가 끌어감 const name = r.split("/").pop(); if (STUBS[name]) return STUBS[name]; if (r.startsWith("@util/")) return load(path.join(ROOT, "common_util", `${r.slice(6)}.ts`)); diff --git a/resources/tester/helper_440_draft_card_ids.cjs b/resources/tester/helper_440_draft_card_ids.cjs index 1dfe816e7..903102821 100644 --- a/resources/tester/helper_440_draft_card_ids.cjs +++ b/resources/tester/helper_440_draft_card_ids.cjs @@ -6,7 +6,8 @@ const ROOT = path.resolve(__dirname, "..", ".."); const ts = require(path.join(ROOT, "config", "node_modules", "typescript")); const idOf = (m) => `station_${String(Math.round(m * 1000)).padStart(12, "0")}`; const REAL = - /(B05_Profile_Pipeline_Detail|B06_Section_Station_Swap|design_pipeline_cross|common_util_station_key)$/; + /(B05_Profile_Pipeline_Detail|B06_Section_Station_Swap|design_pipeline_cross|common_util_\w+)$/; +const CATALOG = require("./helper_322_catalog_module.cjs"); const STUBS = { B05_Profile_Pipeline_Grade: { stationIdOf: idOf }, common_util_station: { stationLabel: (m) => String(m) }, @@ -27,6 +28,9 @@ function load(full) { const box = { exports: {} }; cache.set(full, box); const req = (r) => { + if (CATALOG.isCatalogRequest(r)) return CATALOG; + if (r.endsWith(".json")) + return JSON.parse(fs.readFileSync(path.resolve(path.dirname(full), r))); const name = r.split("/").pop(); if (STUBS[name]) return STUBS[name]; if (REAL.test(r)) return load(path.resolve(path.dirname(full), r + ".ts")); @@ -83,6 +87,13 @@ const draftStations = [ const oldKeys = savedStations.map((s) => s.key); const source = crossMod.pipelineCrossSource("p1", saved, savedStations, null); +// 그림 자료 = 지금 현황(따라간 관 · 457) — 관 p35100 이 354 로 +const types = [{ type_id: "pipe", name: "배수관", options: [] }]; +const status = detailMod.shapeStatus( + [{ id: "p35100", facility: "pipe", chainage_m: 354, options: { pipe_diameter_mm: "800" } }], + [], + types, +); const draft = detailMod.draftSectionDetail( saved, { stations: draftStations, longitudinal: { chainageM: [0, 400], z: [0, 0] } }, @@ -90,6 +101,7 @@ const draft = detailMod.draftSectionDetail( null, source.specs, 20, + status, ); const swapped = swapMod.swapCrossSections(saved, draftStations, oldKeys, 20).detail; const ids = (detail) => detail.cross_sections.map((s) => s.station_id); @@ -106,6 +118,7 @@ const added = detailMod.draftSectionDetail( null, source.specs, 20, + status, ); console.log( JSON.stringify({ diff --git a/resources/tester/helper_457_draft_card.cjs b/resources/tester/helper_457_draft_card.cjs new file mode 100644 index 000000000..a8c8697f0 --- /dev/null +++ b/resources/tester/helper_457_draft_card.cjs @@ -0,0 +1,275 @@ +/* 457 헬퍼 — 노선 바뀜 뒤 초안 카드(묶음 새 상세) 그림 · 이름 · 시설 맥락 = 지금 현황을 새 측점에 다시 얹은 것. + * 진짜 TS(`design_pipeline_cross` · `B05_Profile_Pipeline_Detail` · 측점 · 따라감 · 형상 얹기) · 세션 창구만 가짜. + * 재현 E · F(시점 당김) · G(세션에서 놓은 BOX) · H(관 둘 엇갈려 옮김) — 결과 = 표준출력 JSON. 짝 = `test_457_draft_card.py`. */ +const fs = require("fs"); +const path = require("path"); +const ROOT = path.resolve(__dirname, "..", ".."); +const ts = require(path.join(ROOT, "config", "node_modules", "typescript")); +const CATALOG = require("./helper_322_catalog_module.cjs"); + +const STUBS = { + b_page_state: { readState: () => null }, + B06_Section_Section_Canon: { savedDesigns: () => null }, + B06_Section_UI_Standard_Panel: { + effectiveStandardCross: () => ({ road_width_m: 3 }), + readRockBoundaryDefault: () => null, + }, + B05_Profile_UI_Profile_Data: { toDesignProfile: () => ({}) }, +}; +const cache = new Map(); +function resolveTs(base) { + for (const c of [`${base}.ts`, base]) if (fs.existsSync(c) && fs.statSync(c).isFile()) return c; + throw new Error(`없음: ${base}`); +} +function load(full) { + if (cache.has(full)) return cache.get(full).exports; + const js = ts.transpileModule(fs.readFileSync(full, "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, + }).outputText; + const box = { exports: {} }; + cache.set(full, box); + const req = (r0) => { + if (CATALOG.isCatalogRequest(r0)) return CATALOG; + const r = r0.replace(/\.js$/, ""); + const stub = STUBS[r.split("/").pop()]; + if (stub) return stub; + if (r.endsWith(".json")) + return JSON.parse(fs.readFileSync(path.join(path.dirname(full), r), "utf8")); + if (r.startsWith("@util/")) return load(resolveTs(path.join(ROOT, "common_util", r.slice(6)))); + if (r.startsWith(".")) return load(resolveTs(path.join(path.dirname(full), r))); + return require(r); + }; + new Function("exports", "module", "require", js)(box.exports, box, req); + return box.exports; +} +const P = (...a) => path.join(ROOT, ...a); +const follow = load(P("common_util", "common_util_route_follow.ts")); +const SS = load(P("B05_Profile", "logic", "B05_Profile_Sections_Stations.ts")); +const AT = load(P("common_util", "common_util_structure_attach.ts")); +const ST = load(P("common_util", "common_util_structure_station.ts")); +const CS = load(P("common_util", "common_util_culvert_sets.ts")); +const PD = load(P("B05_Profile", "logic", "B05_Profile_Pipeline_Detail.ts")); +const CR = load(P("A00_Common", "design", "design_pipeline_cross.ts")); + +const def = JSON.parse( + fs.readFileSync(P("resources", "master_template", "구조물정의.json"), "utf8"), +); +const types = def.types + .filter((t) => t.enabled !== false) + .map((t) => ({ + type_id: t.type_id, + group: t.group, + placement: t.placement, + managed_by: t.managed_by, + name: t.name, + options: (t.options || []).map((o) => ({ key: o.key, label: o.label, default: o.default })), + })); +const SHAPES = AT.SHAPE_KEYS; +const idOf = (m) => `station_${String(Math.round(m * 1000)).padStart(12, "0")}`; +const kindOf = (s) => + s.kind === "end" + ? s.key.endsWith("EP") + ? "ep" + : "bp" + : s.kind === "grid" + ? "regular" + : s.kind === "curve" + ? "curve" + : "irregular"; +const ground = (s) => ({ + ...s, + ground: { centerZ: 100, left: [{ offsetM: 5, z: 101 }], right: [{ offsetM: 5, z: 99 }] }, +}); +const sectionsFrom = (S) => + S.map((s) => ({ + station_id: idOf(s.chainageM), + chainage_m: s.chainageM, + label: "", + kind: kindOf(s), + center_x: s.x, + center_y: s.y, + samples: [], + design: { design_elevation_m: 101 }, + })); +const xy = (L) => L.map(([x, y]) => ({ x, y })); +function at(L, ch) { + let walked = 0; + for (let i = 1; i < L.length; i += 1) { + const [a, b] = [L[i - 1], L[i]]; + const span = Math.hypot(b[0] - a[0], b[1] - a[1]); + if (walked + span >= ch) { + const t = (ch - walked) / span; + return [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t]; + } + walked += span; + } + return L[L.length - 1]; +} +const pipe = (id, facility, ch, L, options) => { + const [x, y] = at(L, ch); + return { id, facility, chainage_m: ch, x, y, options }; +}; +const wall = (L) => { + const [x, y] = at(L, 70); + return { + structure_id: "w1", + type_id: "retaining_wall", + chainage_m: 70, + start_m: 60, + end_m: 80, + x, + y, + options: { height_m: 2, side: "left" }, + }; +}; +/** 측점 그림 지문 — 얹힌 형상 열쇠 · 그 누가거리(구간이면 시작~끝). */ +const shapesOf = (s) => + SHAPES.filter((k) => s[k] !== undefined) + .map((k) => { + const v = s[k]; + const c = Array.isArray(v) + ? v.map((x) => `${x.start_m ?? x.chainage_m ?? ""}~${x.end_m ?? ""}`).join("/") + : v && (v.chainage_m ?? `${v.start_m ?? ""}~${v.end_m ?? ""}`); + return `${k}@${c}`; + }) + .join(" "); + +/** 정답 = 새 측점(구조물 없는 꼴)에 지금 현황(따라간 관 · 구조물)을 얹은 그림 · 측점 계획 이름(캐시 `withDraftShapes`). */ +function truthOf(S2, pipes, structures) { + const bare = S2.map((s) => ({ chainage_m: s.chainageM })); + AT.attachShapes(bare, pipes, structures, types); + const planned = ST.planStations(pipes, structures, types, CS.registryDefaults(types)); + return bare.map((s) => ({ + shapes: shapesOf(s), + name: planned.find((p) => Math.abs(p.chainage_m - s.chainage_m) < 0.005)?.title ?? null, + })); +} + +function scenario(L1, L2, saved, now, opts = {}) { + const S1 = SS.buildStations(L1, [], saved.pipes, saved.structures, { types, intervalM: 20 }); + // 저장 상세(서버) = 저장 측점에 저장 현황 그림 · 초안 둔 채 열기면 화면 상세가 초안 자리(`canon`) + const Scanon = opts.canon ?? S1; + const detail = { + longitudinal: { stations: [], design_profiles: [], length_m: 0, samples: [] }, + cross_sections: sectionsFrom(Scanon), + }; + AT.attachShapes( + detail.cross_sections, + opts.canonPipes ?? saved.pipes, + opts.canonStructures ?? saved.structures, + types, + ); + const pF = follow.projectPipePoints(now.pipes, xy(L2)); + const sF = follow.followStructures(now.structures, xy(L2), undefined, 20).map((m) => m.item); + const S2 = SS.buildStations(L2, [], pF, sF, { types, intervalM: 20 }).map(ground); + const keysOf = opts.keys ? () => opts.keys : undefined; + const source = CR.pipelineCrossSource("p457", detail, S1, 7, keysOf); + // 노선 초안 동안 등록 목록이 새 노선 것으로 바뀜 — 처음 본 열쇠 그대로여야 함 + const again = opts.keys + ? CR.pipelineCrossSource("p457", detail, S1, 7, () => [...opts.keys].reverse()) + : source; + const status = PD.shapeStatus + ? PD.shapeStatus(pF, sF, types) + : { pipes: pF, structures: sF, types }; + const card = PD.draftSectionDetail( + detail, + { stations: S2, longitudinal: { chainageM: [0], z: [0] } }, + {}, + null, + again.specs, + 20, + status, + ); + const truth = truthOf(S2, pF, sF); + const shaped = PD.crossWithShapes ? PD.crossWithShapes(source.input, S2, status) : source.input; + const ctx = (key) => { + const e = shaped.byKey[key] ?? {}; + return [Boolean(e.paved), Boolean(e.tilted), e.tiltPct ?? null, e.surfaceDropM ?? 0]; + }; + return { + rows: S2.map((s, i) => ({ + key: s.key, + m: s.chainageM, + card: shapesOf(card.cross_sections[i]), + truth: truth[i].shapes, + name: card.cross_sections[i].structure ?? null, + truthName: truth[i].name, + id: card.cross_sections[i].station_id, + ctx: ctx(s.key), + })), + canonIds: Object.fromEntries( + Scanon.map((s, i) => [s.key, detail.cross_sections[i].station_id]), + ), + sourceKeys: Object.fromEntries([...source.specs].map(([k, v]) => [k, v.station_id])), + specFields: [...new Set([...source.specs.values()].flatMap((v) => Object.keys(v)))], + }; +} + +const L1 = [ + [0, 0], + [100, 0], + [200, 60], +]; +const P1 = [ + pipe("p1", "pipe", 47, L1, { pipe_diameter_mm: "800" }), + pipe("fp", "ford_pavement", 125, L1, { ford_width_m: 12 }), +]; +const facilities = { pipes: P1, structures: [wall(L1)] }; +const out = {}; +out.E = scenario( + L1, + [ + [-13, 0], + [100, 0], + [200, 60], + ], + facilities, + facilities, +); +out.F = scenario( + L1, + [ + [-7, 0], + [100, 0], + [200, 60], + ], + facilities, + facilities, +); +out.G = scenario( + L1, + [ + [-7, 0], + [100, 0], + [200, 60], + ], + facilities, + { + pipes: [...P1, pipe("p9", "box_culvert", 90, L1, { body_width_m: 2 })], + structures: [wall(L1)], + }, +); +// H — 세션에서 관 둘을 엇갈려 옮김(저장 전) · 초안 둔 채 열기(화면 상세 = 옮긴 자리) → 시점 +7 +{ + const saved = [ + pipe("p1", "pipe", 47, L1, { pipe_diameter_mm: "800" }), + pipe("p2", "box_culvert", 90, L1, { body_width_m: 2 }), + ]; + const moved = [ + pipe("p1", "pipe", 110, L1, { pipe_diameter_mm: "800" }), + pipe("p2", "box_culvert", 30, L1, { body_width_m: 2 }), + ]; + const Snow = SS.buildStations(L1, [], moved, [], { types, intervalM: 20 }); + out.H = scenario( + L1, + [ + [-7, 0], + [100, 0], + [200, 60], + ], + { pipes: saved, structures: [] }, + { pipes: moved, structures: [] }, + { canon: Snow, canonPipes: moved, keys: Snow.map((s) => s.key) }, + ); +} +process.stdout.write(JSON.stringify(out)); diff --git a/resources/tester/test_365_page_pipeline.py b/resources/tester/test_365_page_pipeline.py index c99117c11..05c7b9bcd 100644 --- a/resources/tester/test_365_page_pipeline.py +++ b/resources/tester/test_365_page_pipeline.py @@ -170,10 +170,9 @@ def test_365_5_들어온_화면_무관(r): g = v["grid20"] # 설계 칸 = 세션 저장 상세(화면 상세가 굳힌 선택 false 대신 저장값) · 옛 측구 결과도 실음 assert g["ditchChoice"] is None and g["ditchEnabled"] is False - assert v["pipeSpec"] == { - "culvert": {"type": "pipe", "diameter_m": 0.8} - } # 초안 자리 관 = 차례 짝 - assert v["specsDiffer"] # 제원이 바뀌면 입력 지문도 바뀜(고리가 다시 셈) + # 초안 자리 관 = 등록 열쇠 짝 · 넘기는 것 = 카드 번호 · 사용자 칸만(그림은 새 자리에서 · 457) + assert v["pipeSpec"] == {} and v["pipeCarried"] # 짝 있음(시험 상세는 카드 번호 없음) + assert v["specsDiffer"] # 짝지은 측점이 바뀌면 입력 지문도 바뀜(고리가 다시 셈) assert v["firstStored"] == v["savedFirst"] and v["firstShaken"] != v["savedFirst"] diff --git a/resources/tester/test_457_draft_card.py b/resources/tester/test_457_draft_card.py new file mode 100644 index 000000000..c8b28ef82 --- /dev/null +++ b/resources/tester/test_457_draft_card.py @@ -0,0 +1,95 @@ +"""457 노선 바뀜 — 초안 카드 그림 · 이름 · 횡단 맥락 = 지금 현황을 새 측점에 다시 얹음(요구 270). + +재현 E · F(시점 당김 +13 · +7 — 배관 · 물넘이포장 · 옹벽) · G(세션에서 놓은 BOX) · +H(관 둘 엇갈려 옮김 · 초안 둔 채 열기). +① 모든 측점 카드 그림 · 이름 = 새 측점에 따라간 관 · 구조물을 얹은 것(캐시 `withDraftShapes` 한 벌) +② 시설마다 제 측점 하나 · 옛 측점 그림을 옮기지 않음 · 카드 번호만 열쇠로 이어 받음 +③ 시설 맥락(포장 · 기울임 · 하강) = 다시 얹은 그림에서 · 짝 = 등록 열쇠(차례 짝 없음 · 엇갈림 0) +④ 가게 쓰기 = 지금 가게 값과 견줌(되돌리기 뒤 같은 조작도 씀) +TS 셈은 `helper_457_draft_card.cjs`. + + ./venv/Scripts/python.exe -m pytest resources/tester/test_457_draft_card.py -q +""" + +import json +import subprocess +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +HELPER = Path(__file__).with_name("helper_457_draft_card.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 _row(rows: list, key: str) -> dict: + return next(r for r in rows if r["key"] == key) + + +@pytest.mark.parametrize("name", ["E", "F", "G", "H"]) +def test_카드_그림_이름이_새_자리_현황(got, name) -> None: + rows = got[name]["rows"] + bad = [(r["key"], r["card"], r["truth"]) for r in rows if r["card"] != r["truth"]] + assert not bad + names = [(r["key"], r["name"], r["truthName"]) for r in rows if r["name"] != r["truthName"]] + assert not names + + +@pytest.mark.parametrize("name", ["E", "F", "G", "H"]) +def test_카드_번호만_이어_받음(got, name) -> None: + assert got[name]["specFields"] == ["station_id"] + + +def test_E_물넘이포장은_제_측점에만(got) -> None: + rows = got["E"]["rows"] + fp = _row(rows, "pipe:fp") + assert "ford_pavement@" in fp["card"] + assert fp["ctx"][:2] == [True, True] # 포장 · 기울임 = 새 자리 그림 + no6 = _row(rows, "grid:6") # 120 m — 옛 물넘이 범위(119~131) · 새 범위(132~144) 밖 + assert "ford_pavement" not in no6["card"] and no6["name"] is None + assert no6["ctx"] == [False, False, None, 0] + pipes = [r for r in rows if "culvert@" in r["card"]] + assert [r["key"] for r in pipes] == ["pipe:p1"] # 배관 그림 = 관 측점 하나(No.3 아님) + + +def test_G_세션에서_놓은_BOX_카드(got) -> None: + box = _row(got["G"]["rows"], "pipe:p9") + assert "box@" in box["card"] and box["name"] + + +def test_H_엇갈려_옮긴_관_짝(got) -> None: + h = got["H"] + p1 = _row(h["rows"], "pipe:p1") + p2 = _row(h["rows"], "pipe:p2") + assert "culvert@" in p1["card"] and "box@" not in p1["card"] + assert "box@" in p2["card"] and "culvert@" not in p2["card"] + # 카드 번호 = 같은 관의 화면 상세 번호(등록 열쇠 짝 · 목록이 바뀌어도 처음 본 열쇠) + assert h["sourceKeys"]["pipe:p1"] == h["canonIds"]["pipe:p1"] + assert h["sourceKeys"]["pipe:p2"] == h["canonIds"]["pipe:p2"] + assert p1["id"] == h["canonIds"]["pipe:p1"] + + +def test_가게_쓰기는_지금_가게_값과_견줌() -> None: + page = (ROOT / "A00_Common" / "design" / "design_pipeline_page.ts").read_text(encoding="utf-8") + body = page[page.index("function writeFacilities") :] + body = body[: body.index("\n }\n")] + assert 'same(store.get("pipes"), next.pipes)' in body + assert 'same(store.get("structures"), next.structures)' in body + assert "written" not in page