From 9b4abecf9dd5cdbd6d12055dbcc9e062f2ec980b Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 3 Sep 2026 09:50:14 +0900 Subject: [PATCH] =?UTF-8?q?refactor(B05/B06):=20=EC=9C=A0=ED=86=A0?= =?UTF-8?q?=EA=B3=A1=EC=84=A0=20=EC=9D=BC=EC=9B=90=ED=99=94=20=E2=80=94=20?= =?UTF-8?q?=EB=B3=B4=EB=8A=94=20=EB=85=B8=EC=84=A0=C2=B7=ED=9A=A1=EB=8B=A8?= =?UTF-8?q?=20=EC=9E=AC=EA=B3=84=EC=82=B0=20=EC=B0=BD=EA=B5=AC=C2=B7?= =?UTF-8?q?=EB=82=A1=EC=9D=8C=20=ED=8C=90=EC=A0=95=20=ED=86=B5=ED=95=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 같은 데이터를 두 화면에 보여 주는 기능인데 세 곳이 갈려 값이 달랐다(실측: 절토(자연) B06 3,704.6㎥ ↔ B05 4,526.8㎥). ① 보는 노선 — B05는 최신 경로, B06은 최신 **확정** 경로를 열어 노선을 다시 탐색한 프로젝트에서 서로 다른 노선을 봤다(route 126 DRAFT ↔ 125 CONFIRMED, 같은 20m 측점 성토 4.82㎡ ↔ 85.9㎡). `get_workflow_route_context()`(최신 경로) 신설해 B06 화면 context가 그것을 쓴다. 납품 도면(B07)이 쓰는 확정 전용 창구는 그대로 둔다. ② 횡단 재계산 — 호출이 두 벌이라 인자가 갈렸다(B06만 표준 단면값·암 경계 오프셋 전달, 보존하는 사용자 부속값도 2개 ↔ 7개). `B06_Section_Cross_Refresh.refreshCrossDesigns()` 한 창구로 모으고 세션 편집값은 저장소에서 직접 읽어 패널 없는 B05도 같은 값을 보낸다. ③ 낡음 판정 — 「옛 암 2단계 필드 누락」 조건이 B06 페이지에만 있어 B05는 재계산을 건너뛰었다. 공용 `staleDesignChainages()` 안으로 옮겨 두 화면이 같은 시점에 같은 조치를 한다. 검증 — 공용 브라우저 실측: 두 화면 모두 route 126, 요약줄 문자열 완전 일치 (`절토(자연) 4,526.8㎥ · 성토 14,881.8㎥ · 토취 10,213.3㎥ · 최종 누가토량 −10,213.3㎥`). pytest 370 passed·17 skipped(일원화 검사 4건 신설), typecheck·prettier·ruff 통과. Co-Authored-By: Claude Opus 5 (1M context) --- B05_Profile/B05_Profile_UI_Profile_Preview.ts | 37 +++----- B06_Section/B06_Section_Cross_Refresh.ts | 93 +++++++++++++++++++ B06_Section/B06_Section_Repository.py | 31 ++++++- B06_Section/B06_Section_Router.py | 6 +- B06_Section/B06_Section_UI_Page.ts | 52 +++-------- B06_Section/B06_Section_UI_Page_Persist.ts | 22 +++++ B06_Section/B06_Section_UI_Section_Common.ts | 15 ++- B06_Section/B06_Section_UI_Standard_Panel.ts | 11 ++- 8 files changed, 195 insertions(+), 72 deletions(-) create mode 100644 B06_Section/B06_Section_Cross_Refresh.ts diff --git a/B05_Profile/B05_Profile_UI_Profile_Preview.ts b/B05_Profile/B05_Profile_UI_Profile_Preview.ts index 5434af36..b6c74daa 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Preview.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Preview.ts @@ -10,7 +10,7 @@ * ========================================================================== */ import type { AlignmentEdits } from "./B05_Profile_UI_Profile_Alignment"; -import { previewCrossDesigns } from "../B06_Section/B06_Section_Api_Fetch"; +import { refreshCrossDesigns } from "../B06_Section/B06_Section_Cross_Refresh"; import type { SectionDetailResponse } from "../B06_Section/B06_Section_Api_Fetch"; export interface CrossPreviewContext { @@ -43,29 +43,20 @@ export function createCrossPreview(ctx: CrossPreviewContext): CrossPreview { const routeId = ctx.routeId(); if (!detail || routeId === null) return; const current = (seq += 1); - // full_designs — 설계선 좌표까지 통째로 받아야 3D 코리도가 편집 즉시 정확한 - // 형상으로 재빌드된다(2026-08-23 사용자 지시). 암 경계는 백엔드가 세션값이 없으면 - // DB 저장 echo를 폴백으로 쓰므로 그대로 유지된다. - void previewCrossDesigns(ctx.projectId, routeId, ctx.edits(), undefined, { - fullDesigns: true, + // 재계산은 B06과 **같은 창구**를 쓴다 — 표준 단면값·암 경계 오프셋이 빠지면 서버가 + // 다른 설계를 그려 같은 데이터가 두 화면에서 다른 값이 된다(2026-09-03 일원화). + const isCurrent = (): boolean => + current === seq && ctx.detail() === detail && ctx.routeId() === routeId; + void refreshCrossDesigns({ + projectId: ctx.projectId, + routeId, + detail, + edits: ctx.edits(), + // 늦게 온 옛 응답이 새 설계를 덮지 않게 반영 직전에 한 번 더 확인한다. + shouldApply: isCurrent, }) - .then((next) => { - const live = ctx.detail(); - if (current !== seq || !live || ctx.routeId() !== routeId) return; - const designByChainage = new Map( - next.designs.map((entry) => [entry.chainage_m.toFixed(3), entry.design]), - ); - for (const section of live.cross_sections) { - const full = designByChainage.get(section.chainage_m.toFixed(3)); - if (!full || !section.design) continue; - // 전체 교체(설계선 포함) — B06 reconcile과 같은 패턴으로 사용자 부속값은 보존. - section.design = { - ...(full as NonNullable), - inlet_structure: section.design.inlet_structure, - basin_adjust: section.design.basin_adjust, - }; - } - ctx.onApplied(); + .then((updated) => { + if (updated.length) ctx.onApplied(); }) .catch(() => { /* 프리뷰 실패는 무시 — 화면의 계획선은 그대로 두고 다음 편집에서 다시 시도한다. */ diff --git a/B06_Section/B06_Section_Cross_Refresh.ts b/B06_Section/B06_Section_Cross_Refresh.ts new file mode 100644 index 00000000..95021b69 --- /dev/null +++ b/B06_Section/B06_Section_Cross_Refresh.ts @@ -0,0 +1,93 @@ +/* ============================================================================= + * B06_Section_Cross_Refresh.ts + * 「현재 계획선에 맞춘 횡단 재계산」 **단일 창구** — B05·B06이 같은 입력으로 같은 결과를 본다. + * + * ── 왜 필요한가 (2026-09-03 사용자 지시) ───────────────────────────── + * 유토곡선은 두 화면이 같은 데이터를 보여 주는 기능인데, 재계산 호출이 두 벌이라 값이 + * 갈렸다. 실측 — 같은 프로젝트·같은 시점에 B06 `절토(자연) 3,704.6㎥` ↔ B05 `4,526.8㎥`. + * 원인은 인자였다: + * · B06: `previewCrossDesigns(..., standardPanel.getValues(), { fullDesigns, rockBoundaryOffsets })` + * · B05: `previewCrossDesigns(..., undefined, { fullDesigns })` + * 표준 단면값과 암 경계 오프셋이 빠지면 서버가 다른 설계를 그려 단면적이 달라진다. + * 보존하는 사용자 부속값 목록도 서로 달라(B05는 2개, B06은 7개) B05를 거치면 기슭막이 + * 조정 같은 값이 사라졌다. + * + * 그래서 **입력 수집·서버 호출·제자리 반영**을 여기 한 곳으로 모은다. 세션 편집값은 + * 패널이 아니라 세션 저장소에서 직접 읽으므로, 패널이 없는 B05도 B06과 같은 값을 보낸다. + * ========================================================================== */ + +import { previewCrossDesigns } from "./B06_Section_Api_Fetch"; +import type { CrossSection, SectionDetailResponse } from "./B06_Section_Api_Fetch"; +import { readRockBoundarySession } from "./B06_Section_UI_Page_Persist"; +import { readStandardCrossSession } from "./B06_Section_UI_Standard_Panel"; + +/** 계획선 편집 델타 — B05 `AlignmentEdits`와 저장분 `profile_alignment.edits`가 같은 모양이다. */ +export interface CrossRefreshEdits { + station_offsets: Record; + curve_radii: Record; +} + +export interface CrossRefreshInput { + projectId: string; + routeId: number; + /** 제자리 갱신 대상 — 공유 캐시가 들고 있는 그 객체여야 두 화면이 같이 따라온다. */ + detail: SectionDetailResponse; + edits: CrossRefreshEdits; + /** + * 응답이 도착한 시점에 **아직 이 결과를 써도 되는지** 묻는다(false면 반영하지 않는다). + * 끌기 중에는 요청이 겹치므로, 늦게 온 옛 응답이 새 설계를 덮지 않게 하는 문지기다. + */ + shouldApply?: () => boolean; +} + +/** 서버 설계로 갈아 끼워도 **살려 두는 사용자 부속값** — 화면 조작으로만 생기는 값이다. */ +function preserveUserFields( + next: NonNullable, + previous: CrossSection["design"], +): NonNullable { + if (!previous) return next; + return { + ...next, + inlet_structure: previous.inlet_structure, + basin_adjust: previous.basin_adjust, + revet_adjust: previous.revet_adjust, + extra_wall_counts: previous.extra_wall_counts, + extra_spans: previous.extra_spans, + revet_link_detached: previous.revet_link_detached, + revet_follow_grade: previous.revet_follow_grade, + }; +} + +/** + * 전 측점 횡단을 현재 계획선으로 다시 계산해 `detail.cross_sections[].design`을 제자리 교체한다. + * 돌려주는 값은 실제로 바뀐 측점의 누가거리 목록 — 호출한 쪽이 그 카드만 다시 그리면 된다. + */ +export async function refreshCrossDesigns(input: CrossRefreshInput): Promise { + const { projectId, routeId, detail, edits, shouldApply } = input; + // full_designs — 설계선 좌표까지 받아야 3D 코리도·횡단도가 편집 즉시 같은 형상이 된다. + const response = await previewCrossDesigns( + projectId, + routeId, + edits, + readStandardCrossSession(projectId) ?? undefined, + { + fullDesigns: true, + rockBoundaryOffsets: readRockBoundarySession(projectId, routeId), + }, + ); + if (shouldApply && !shouldApply()) return []; + const designByChainage = new Map( + response.designs.map((entry) => [entry.chainage_m.toFixed(3), entry.design]), + ); + const updated: number[] = []; + for (const section of detail.cross_sections) { + const next = designByChainage.get(section.chainage_m.toFixed(3)); + if (!next) continue; + section.design = preserveUserFields( + next as NonNullable, + section.design, + ); + updated.push(section.chainage_m); + } + return updated; +} diff --git a/B06_Section/B06_Section_Repository.py b/B06_Section/B06_Section_Repository.py index f5f175d9..72807284 100644 --- a/B06_Section/B06_Section_Repository.py +++ b/B06_Section/B06_Section_Repository.py @@ -25,17 +25,18 @@ def _validate_stage_path(relative_path: str) -> str: return normalized.as_posix() -async def get_confirmed_route_context( - connection: aiomysql.Connection, project_id: UUID +async def _route_context( + connection: aiomysql.Connection, project_id: UUID, confirmed_only: bool ) -> dict[str, Any] | None: - """프로젝트의 최신 확정 경로와 연결된 지표면 좌표계를 조회한다. + """경로 하나와 연결된 지표면 좌표계를 조회한다(정렬은 최신 우선). surface_models.crs_epsg가 NULL이면(분석에 사용한 입력 파일에 좌표계가 없던 경우) 같은 프로젝트 input_files의 감지된 좌표계로 폴백한다. """ + status_filter = "AND r.status = 'CONFIRMED'" if confirmed_only else "" async with connection.cursor(aiomysql.DictCursor) as cursor: await cursor.execute( - """ + f""" SELECT r.id AS route_id, COALESCE( sm.crs_epsg, @@ -47,7 +48,7 @@ async def get_confirmed_route_context( ) AS crs_epsg FROM routes r LEFT JOIN surface_models sm ON sm.id = r.surface_model_id - WHERE r.project_id = %s AND r.status = 'CONFIRMED' + WHERE r.project_id = %s {status_filter} ORDER BY r.computed_at DESC, r.id DESC LIMIT 1 """, @@ -62,6 +63,26 @@ async def get_confirmed_route_context( } +async def get_confirmed_route_context( + connection: aiomysql.Connection, project_id: UUID +) -> dict[str, Any] | None: + """최신 **확정** 경로 — 납품 도면(B07)처럼 확정본만 봐야 하는 곳이 쓴다.""" + return await _route_context(connection, project_id, confirmed_only=True) + + +async def get_workflow_route_context( + connection: aiomysql.Connection, project_id: UUID +) -> dict[str, Any] | None: + """워크플로 화면이 보는 경로 = **최신 경로**(확정 여부 무관). + + B05는 `get_latest_route()`로 최신 경로를 열고, B06은 확정 경로만 열어서 노선을 다시 + 탐색한 프로젝트에서 두 화면이 **다른 노선**을 봤다(2026-09-03 실측: B05 route 126 + DRAFT / B06 route 125 CONFIRMED — 같은 20m 측점의 성토가 4.82㎡ ↔ 85.9㎡). 같은 + 데이터를 두 창으로 보여 주는 구조이므로 경로 선택 규칙을 최신 경로 하나로 맞춘다. + """ + return await _route_context(connection, project_id, confirmed_only=False) + + async def get_latest_section_options( connection: aiomysql.Connection, project_id: UUID ) -> dict[str, Any] | None: diff --git a/B06_Section/B06_Section_Router.py b/B06_Section/B06_Section_Router.py index 0ce22413..c939680c 100644 --- a/B06_Section/B06_Section_Router.py +++ b/B06_Section/B06_Section_Router.py @@ -26,7 +26,7 @@ from B06_Section.B06_Section_Repository import ( count_cross_sections, create_longitudinal_section, delete_sections_for_route, - get_confirmed_route_context, + get_workflow_route_context, get_cross_section_designs, get_latest_section_options, get_longitudinal_section, @@ -104,7 +104,9 @@ async def get_section_context(project_id: UUID) -> SectionContextResponse | JSON pool = get_db_pool() try: async with pool.acquire() as connection: - route_context = await get_confirmed_route_context(connection, project_id) + # 화면이 보는 경로 = 최신 경로(확정 여부 무관) — B05와 같은 규칙이어야 + # 두 화면이 같은 노선의 같은 값을 본다(2026-09-03 일원화). + route_context = await get_workflow_route_context(connection, project_id) surface_params = await get_surface_confirmation_params(connection, str(project_id)) # 임도 종류(projects.road_type) — B05가 계획선 법정 기준을 정하는 데 쓴다. async with connection.cursor() as cursor: diff --git a/B06_Section/B06_Section_UI_Page.ts b/B06_Section/B06_Section_UI_Page.ts index 7568e7b3..bccbab34 100644 --- a/B06_Section/B06_Section_UI_Page.ts +++ b/B06_Section/B06_Section_UI_Page.ts @@ -15,15 +15,16 @@ import { computeCrossDesign, fetchSectionContext, getSections, - previewCrossDesigns, type SectionContextResponse, type SectionDetailResponse, type StandardCrossSection, } from "./B06_Section_Api_Fetch"; import { createStationControls } from "./B06_Section_UI_Page_Station_Controls"; +import { refreshCrossDesigns } from "./B06_Section_Cross_Refresh"; import { confirmCurrentSections, createRockBoundaryStore, + rockBoundarySessionKey, saveCurrentSections, type SectionPersistContext, } from "./B06_Section_UI_Page_Persist"; @@ -284,16 +285,9 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise { */ async function reconcileStaleDesigns(): Promise { if (!sectionDetail || !projectId || currentRouteId === null) return; - // 계획고 어긋남은 B05와 **같은 규칙**으로 판정한다(공용 staleDesignChainages). - // 옛 암 2단계 필드 누락은 B06 전용 조건이라 여기서 더한다. - const staleByPlan = new Set(staleDesignChainages(sectionDetail)); - const stale = sectionDetail.cross_sections.filter((section) => { - const design = section.design; - if (!design) return false; - if (design.geometry_preset === "rock" && design.two_stage_slope === undefined) return true; - return staleByPlan.has(section.chainage_m); - }); - if (!stale.length) return; + // 낡음 판정은 B05와 **같은 규칙** 하나뿐이다(공용 staleDesignChainages — 계획고 어긋남 + + // 옛 암 2단계 필드 누락). 조건이 갈리면 같은 데이터가 두 화면에서 다른 값이 된다. + if (!staleDesignChainages(sectionDetail).length) return; // 진입 정합은 화면을 잠그지 않는다 — 카드가 도착하는 대로 조용히 갱신된다 // (CLAUDE.md 5장). try { @@ -309,33 +303,15 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise { station_offsets: alignment?.edits?.station_offsets ?? {}, curve_radii: alignment?.edits?.curve_radii ?? {}, }; - const response = await previewCrossDesigns( + // 재계산은 B05와 **같은 창구**를 쓴다 — 인자가 갈리면 같은 데이터가 두 화면에서 + // 다른 값이 된다(2026-09-03 사용자 지시로 일원화). + const updated = await refreshCrossDesigns({ projectId, - currentRouteId, + routeId: currentRouteId, + detail: sectionDetail, edits, - standardPanel?.getValues(), - { fullDesigns: true, rockBoundaryOffsets: Object.fromEntries(rockOffsets) }, - ); - const designByChainage = new Map( - response.designs.map((entry) => [entry.chainage_m.toFixed(3), entry.design]), - ); - for (const section of sectionDetail.cross_sections) { - const next = designByChainage.get(section.chainage_m.toFixed(3)); - if (next) { - // full_designs 응답은 설계 전체(설계선 좌표 포함)라 통째로 교체한다. - section.design = { - ...(next as NonNullable), - inlet_structure: section.design?.inlet_structure, - basin_adjust: section.design?.basin_adjust, - revet_adjust: section.design?.revet_adjust, - extra_wall_counts: section.design?.extra_wall_counts, - extra_spans: section.design?.extra_spans, - revet_link_detached: section.design?.revet_link_detached, - revet_follow_grade: section.design?.revet_follow_grade, - }; - sectionView.refreshCard(section.chainage_m); - } - } + }); + for (const chainageM of updated) sectionView.refreshCard(chainageM); } catch (error) { const detail = error instanceof Error ? ` ${error.message}` : ""; showToast(`${L("B06_Design_Failed")}${detail}`, "error"); @@ -399,7 +375,9 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise { // 암 경계선 오프셋(측점별) 세션 저장소는 저장 흐름 모듈이 맡는다(2026-09-02 분리). const rockStore = createRockBoundaryStore({ sessionKey: () => - projectId && currentRouteId !== null ? `b06:rockb:${projectId}:${currentRouteId}` : null, + projectId && currentRouteId !== null + ? rockBoundarySessionKey(projectId, currentRouteId) + : null, detail: () => sectionDetail, refreshCard: (chainageM) => sectionView.refreshCard(chainageM), recompute: (chainageM) => recomputeIfRock(chainageM), diff --git a/B06_Section/B06_Section_UI_Page_Persist.ts b/B06_Section/B06_Section_UI_Page_Persist.ts index 765edcfa..1117d351 100644 --- a/B06_Section/B06_Section_UI_Page_Persist.ts +++ b/B06_Section/B06_Section_UI_Page_Persist.ts @@ -25,6 +25,28 @@ import { computeHaulPlan } from "@util/common_util_mass_haul_balance"; import { balloonOffsetsPayload } from "@util/common_util_mass_haul_balance_view"; import { L } from "./B06_Section_UI_Page_Common"; +/** + * 암 경계선 오프셋 세션 키 — 저장소와 재계산 창구가 **같은 자리**를 보게 정의처를 하나로 둔다. + * (`B06_Section_Cross_Refresh` 가 패널 없는 B05에서도 같은 값을 읽어 서버로 보낸다.) + */ +export function rockBoundarySessionKey(projectId: string, routeId: number): string { + return `b06:rockb:${projectId}:${routeId}`; +} + +/** 세션에 쌓인 암 경계선 오프셋(측점키 → m). 없거나 손상되면 빈 객체. */ +export function readRockBoundarySession( + projectId: string, + routeId: number, +): Record { + try { + const raw = window.sessionStorage.getItem(rockBoundarySessionKey(projectId, routeId)); + const parsed = raw ? (JSON.parse(raw) as unknown) : null; + return parsed && typeof parsed === "object" ? (parsed as Record) : {}; + } catch { + return {}; + } +} + /** 암 경계선 오프셋 저장소 — 값(Map)과 조정창 제어기를 함께 낸다. */ export interface RockBoundaryStore { /** 측점키(누가거리 2자리) → 오프셋(m). `buildCrossPatches` 가 그대로 읽는다. */ diff --git a/B06_Section/B06_Section_UI_Section_Common.ts b/B06_Section/B06_Section_UI_Section_Common.ts index a6416965..9f8016fb 100644 --- a/B06_Section/B06_Section_UI_Section_Common.ts +++ b/B06_Section/B06_Section_UI_Section_Common.ts @@ -81,16 +81,27 @@ export function validElevation( export function staleDesignChainages( detail: { longitudinal: { design_profiles?: DesignProfile[] }; - cross_sections: Array<{ chainage_m: number; design?: { design_elevation_m: number } | null }>; + cross_sections: Array<{ + chainage_m: number; + design?: { + design_elevation_m: number; + geometry_preset?: string; + two_stage_slope?: boolean; + } | null; + }>; }, toleranceM = 1e-3, ): number[] { const profiles = detail.longitudinal.design_profiles; - if (!profiles?.length) return []; return detail.cross_sections .filter((section) => { const design = section.design; if (!design) return false; + // 옛 암 측점: 2단계 절토 경사 필드가 없으면 절토 면적이 최신 엔진과 다르다. + // 이 조건이 B06 페이지에만 있어 B05는 재계산을 건너뛰었고, 같은 데이터인데 두 화면의 + // 절토량이 갈렸다(2026-09-03 실측 228.52㎡ ↔ 270.12㎡). 판정을 여기 한 곳으로 모은다. + if (design.geometry_preset === "rock" && design.two_stage_slope === undefined) return true; + if (!profiles?.length) return false; const planned = designElevationAt(profiles, section.chainage_m); return planned !== undefined && Math.abs(planned - design.design_elevation_m) > toleranceM; }) diff --git a/B06_Section/B06_Section_UI_Standard_Panel.ts b/B06_Section/B06_Section_UI_Standard_Panel.ts index 1f22ce0c..c1d39870 100644 --- a/B06_Section/B06_Section_UI_Standard_Panel.ts +++ b/B06_Section/B06_Section_UI_Standard_Panel.ts @@ -52,8 +52,13 @@ function cloneDefaults(defaults: StandardCrossSection): StandardCrossSection { return JSON.parse(JSON.stringify(defaults)) as StandardCrossSection; } -/** 세션에 저장된 편집값을 읽는다. 없거나 손상 시 null. */ -function readSession(projectId: string): StandardCrossSection | null { +/** + * 세션에 저장된 편집값을 읽는다. 없거나 손상 시 null. + * + * 패널 밖에서도 필요하다 — 횡단 재계산 단일 창구(`B06_Section_Cross_Refresh`)가 B05처럼 + * 패널이 없는 화면에서도 **같은 표준 단면값**을 서버로 보내야 두 화면 결과가 같다. + */ +export function readStandardCrossSession(projectId: string): StandardCrossSection | null { try { const raw = window.sessionStorage.getItem(sessionKey(projectId)); return raw ? (JSON.parse(raw) as StandardCrossSection) : null; @@ -170,7 +175,7 @@ export function createStandardPanel( onApplyAll?: () => void | Promise, ): StandardPanelController { // 세션값 우선, 없으면 config 기본값. defaults는 복원 기준으로 보존한다. - const sessionValue = readSession(projectId); + const sessionValue = readStandardCrossSession(projectId); const hadSession = sessionValue !== null; const state: StandardCrossSection = sessionValue ?? cloneDefaults(defaults);