From 5f43ef85414a7e018fbb626d4e5b1a5348080df4 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 29 Sep 2026 13:44:39 +0900 Subject: [PATCH] =?UTF-8?q?fix(B05=C2=B7B06):=20=EC=A7=91=EA=B3=84=20?= =?UTF-8?q?=ED=95=AD=EB=AA=A9=20=EC=A7=80=EC=A0=95=EC=9D=B4=20=ED=8F=BC=20?= =?UTF-8?q?=EA=B2=80=EC=A6=9D=EC=97=90=20=EB=A7=89=ED=98=80=20=EC=B4=88?= =?UTF-8?q?=EC=95=88=EC=97=90=20=EC=95=88=20=EB=93=A4=EC=96=B4=EA=B0=80?= =?UTF-8?q?=EB=8D=98=20=ED=9D=A0=20=C2=B7=20=EC=A0=80=EC=9E=A5=20=EC=A0=84?= =?UTF-8?q?=20B05=E2=86=92B06=E2=86=92B05=20=EC=97=90=EC=84=9C=20=EA=B4=80?= =?UTF-8?q?=20=EC=A7=80=EC=A0=95=EC=9D=B4=20=EC=98=9B=EA=B0=92=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EB=8F=8C=EC=95=84=EA=B0=80=EB=8D=98=20=ED=9D=A0(21?= =?UTF-8?q?-10=20=ED=9D=A0=202=C2=B73)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 흠 2 고치는 구조물의 집계 항목 지정 · 해제는 필수 옵션 · 측점 검증을 거치지 않고 기록에 바로 반영(commitSummaryOnly) · 관 · 새 항목은 종전 흐름 - 흠 3 배수유역 화면이 들어올 때 세션 초안 관 목록이 있으면 그것으로 세움(초안이 없으면 종전대로 정본) - 시험 2개 추가(집계 항목 전용 반영 · 초안 우선 진입) Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01TULoa94ZFL26KU6ZqVpjkF --- B05_Profile/B05_Profile_UI_Drainage_Panel.ts | 13 +- .../B05_Profile_UI_Structures_Panel.ts | 13 +- .../B05_Profile_UI_Structures_Panel_Commit.ts | 16 ++ .../helper_b05_structures_form_summary.cjs | 166 +++++++++++++++--- .../test_b05_structures_form_summary.py | 24 +++ 5 files changed, 206 insertions(+), 26 deletions(-) diff --git a/B05_Profile/B05_Profile_UI_Drainage_Panel.ts b/B05_Profile/B05_Profile_UI_Drainage_Panel.ts index 5343a951..e48a0979 100644 --- a/B05_Profile/B05_Profile_UI_Drainage_Panel.ts +++ b/B05_Profile/B05_Profile_UI_Drainage_Panel.ts @@ -31,7 +31,7 @@ import { resampleRoute } from "../B04_PreProcess/B04_PreProcess_UI_RouteSamples" import { createPipeEditor } from "./B05_Profile_UI_Drainage_Pipes"; import { createFacilityStore } from "./B05_Profile_UI_Drainage_Facility"; import { mergeFacilityOptions } from "./B05_Profile_UI_Drainage_Facility_Merge"; -import { writePendingPipes } from "./B05_Profile_Api_Pipes_Draft"; +import { readPendingPipes, writePendingPipes } from "./B05_Profile_Api_Pipes_Draft"; import { createDrainageChrome } from "./B05_Profile_UI_Drainage_Chrome"; import { bindDrainageInteractions } from "./B05_Profile_UI_Drainage_Interact"; import type { RouteSpanBand } from "./B05_Profile_UI_Drainage_Spans"; @@ -419,8 +419,15 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra ); } - /** 저장된 관 지점(없으면 자동 배치)을 불러온다. 화면에 들어올 때 1회. */ - const loadSaved = (): Promise => run(() => fetchDetailPipePoints(projectId as string)); + /** 저장된 관 지점(없으면 자동 배치)을 불러온다. 화면에 들어올 때 1회. 저장 전 조작분(세션 초안)이 + * 있으면 그 관 목록으로 세운다 — B06 을 다녀와도 지정 · 옵션이 옛값으로 안 돌아감(21-10 흠 3). */ + const loadSaved = (): Promise => + run(() => { + const pending = readPendingPipes(projectId as string); + return pending?.length + ? computeDetailBasins(projectId as string, pending) + : fetchDetailPipePoints(projectId as string); + }); /** 저장분까지 버리고 자동 배치로 되돌린다("초기화"). 화면만 되돌리면 다시 들어왔을 때 * 옛 관이 살아난다(2026-08-02 사용자 보고). */ diff --git a/B05_Profile/B05_Profile_UI_Structures_Panel.ts b/B05_Profile/B05_Profile_UI_Structures_Panel.ts index eb4ab118..a8786505 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Panel.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Panel.ts @@ -32,6 +32,7 @@ import { } from "./B05_Profile_UI_Structures_Panel_Events"; import { commit as commitInto, + commitSummaryOnly, emit as emitFrom, loadForm as loadFormInto, loadPipeForm as loadPipeFormInto, @@ -358,6 +359,14 @@ export function createStructuresSection( }); } + /** 집계 항목 지정 · 해제 반영 — 고치는 구조물이면 폼 검증 없이 기록에 바로, 그 밖은 실시간 반영. */ + function commitSummary(): void { + applyingLive = true; + const done = commitSummaryOnly(panelContext); + applyingLive = false; + if (!done) liveCommit(); + } + function syncButtons(): void { primary.textContent = tempPipeChainage !== null @@ -478,12 +487,12 @@ export function createStructuresSection( void openItemPicker({ current: summary.get(), canCopy: true }).then((result) => { if (result.action === "cancel") return; summary.set(result.action === "apply" ? result.item : null); - liveCommit(); + commitSummary(); }); }, clear: () => { summary.set(null); - liveCommit(); + commitSummary(); }, }); body.insertBefore(field("메모", memoField), actions); diff --git a/B05_Profile/B05_Profile_UI_Structures_Panel_Commit.ts b/B05_Profile/B05_Profile_UI_Structures_Panel_Commit.ts index 0bec107d..7509b7ef 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Panel_Commit.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Panel_Commit.ts @@ -190,6 +190,22 @@ export function emit(ctx: StructuresCommitContext): void { ctx.callbacks.onChange([...ctx.structures]); } +/** 집계 항목만 바뀐 때 — 폼 검증(필수 옵션 · 측점)에 걸려 조용히 버려지지 않게, 고치는 중인 + * 구조물 기록에 곧바로 반영한다(PLAN 21-10 흠 2). 반영했으면 true — 관 · 새 항목은 false 라 + * 종전 흐름(`commit(true)` · [추가])을 따른다. */ +export function commitSummaryOnly(ctx: StructuresCommitContext): boolean { + const id = ctx.editingId(); + const index = id ? ctx.structures.findIndex((entry) => entry.structure_id === id) : -1; + if (index < 0 || ctx.tempPipeChainage() !== null) return false; + const next: StructureInstance = { ...ctx.structures[index] }; + const summary = ctx.summary(); + if (summary) next.summary_item = summary; + else delete next.summary_item; + ctx.structures[index] = next; + emit(ctx); + return true; +} + export async function commit(ctx: StructuresCommitContext, live = false): Promise { const type = ctx.currentType(); if (!type) return; diff --git a/resources/tester/helper_b05_structures_form_summary.cjs b/resources/tester/helper_b05_structures_form_summary.cjs index b7c40242..0d3fa761 100644 --- a/resources/tester/helper_b05_structures_form_summary.cjs +++ b/resources/tester/helper_b05_structures_form_summary.cjs @@ -49,7 +49,11 @@ class FakeNode { return this._text + this.children.map((c) => c.textContent).join(""); } append(...kids) { - kids.forEach((k) => this.children.push(typeof k === "string" ? Object.assign(new FakeNode("#text"), { _text: k }) : k)); + kids.forEach((k) => + this.children.push( + typeof k === "string" ? Object.assign(new FakeNode("#text"), { _text: k }) : k, + ), + ); } replaceChildren(...kids) { this.children = []; @@ -72,7 +76,13 @@ globalThis.Option = function Option(text, value) { }; globalThis.requestAnimationFrame = (fn) => fn(); const session = new Map(); -globalThis.window = { sessionStorage: { getItem: (k) => session.get(k) ?? null, setItem: (k, v) => session.set(k, String(v)), removeItem: (k) => session.delete(k) } }; +globalThis.window = { + sessionStorage: { + getItem: (k) => session.get(k) ?? null, + setItem: (k, v) => session.set(k, String(v)), + removeItem: (k) => session.delete(k), + }, +}; globalThis.sessionStorage = globalThis.window.sessionStorage; const STUBS = { @@ -110,38 +120,85 @@ const { renderOptionFields } = B05("B05_Profile_UI_Structures_Panel_Options"); const commitModule = B05("B05_Profile_UI_Structures_Panel_Commit"); const out = {}; -const A = { layer: "personal", owner: "삼성/홍길동", key: "SS000007", name: "돌기슭막이 메쌓기 1.2m" }; +const A = { + layer: "personal", + owner: "삼성/홍길동", + key: "SS000007", + name: "돌기슭막이 메쌓기 1.2m", +}; const B = { layer: "system", owner: null, key: "SS000004", name: "옹벽 콘크리트" }; // ── 순수 규칙 ─────────────────────────────────────────────────────────── -out.visible = [logic.isOptionVisible({}), logic.isOptionVisible({ visible: true }), logic.isOptionVisible({ visible: false })]; -const before = { type_id: "wall", options: { H: 2, stone_kind: "화강암", back_length_m: 0.6, memo_only: "x" } }; +out.visible = [ + logic.isOptionVisible({}), + logic.isOptionVisible({ visible: true }), + logic.isOptionVisible({ visible: false }), +]; +const before = { + type_id: "wall", + options: { H: 2, stone_kind: "화강암", back_length_m: 0.6, memo_only: "x" }, +}; out.mergeSame = logic.mergeStructureOptions(before, "wall", ["H", "stone_kind"], { H: 3 }); out.mergeCleared = logic.mergeStructureOptions(before, "wall", ["H", "stone_kind"], {}); out.mergeTypeChange = logic.mergeStructureOptions(before, "fence", ["H"], { H: 1 }); out.mergeNoPrev = logic.mergeStructureOptions(null, "wall", ["H"], { H: 9 }); out.mergePrevNoOptions = logic.mergeStructureOptions({ type_id: "wall" }, "wall", ["H"], { H: 9 }); -out.isItem = [item.isSummaryItem(A), item.isSummaryItem({ layer: "x", owner: null, key: "K", name: "n" }), item.isSummaryItem(null), item.isSummaryItem({ ...A, key: "" })]; +out.isItem = [ + item.isSummaryItem(A), + item.isSummaryItem({ layer: "x", owner: null, key: "K", name: "n" }), + item.isSummaryItem(null), + item.isSummaryItem({ ...A, key: "" }), +]; out.readItem = [item.readSummaryItem(A)?.key ?? null, item.readSummaryItem("nope")]; -out.same = [item.sameSummaryItem(A, { ...A, name: "이름만 바뀜" }), item.sameSummaryItem(A, B), item.sameSummaryItem(null, null), item.sameSummaryItem(A, null)]; +out.same = [ + item.sameSummaryItem(A, { ...A, name: "이름만 바뀜" }), + item.sameSummaryItem(A, B), + item.sameSummaryItem(null, null), + item.sameSummaryItem(A, null), +]; out.label = [item.summaryLabel(null), item.summaryLabel(A)]; out.tip = [item.summaryTooltip(null), item.summaryTooltip(A)]; // 관 계열 merge — 집계 항목 이어받기 · 지우기 const pipePrev = { facility: "pipe", options: { pipe_kind: "흄관", extra: 1 }, summary_item: A }; -out.pipeKeep = merge.mergeFacilityOptions(pipePrev, { facility: "pipe", options: { pipe_kind: "VR관" } }).summary_item ?? null; -out.pipeClear = merge.mergeFacilityOptions(pipePrev, { facility: "pipe", options: {}, summary_item: null }).summary_item; -out.pipeSet = merge.mergeFacilityOptions(pipePrev, { facility: "pipe", options: {}, summary_item: B }).summary_item.key; +out.pipeKeep = + merge.mergeFacilityOptions(pipePrev, { facility: "pipe", options: { pipe_kind: "VR관" } }) + .summary_item ?? null; +out.pipeClear = merge.mergeFacilityOptions(pipePrev, { + facility: "pipe", + options: {}, + summary_item: null, +}).summary_item; +out.pipeSet = merge.mergeFacilityOptions(pipePrev, { + facility: "pipe", + options: {}, + summary_item: B, +}).summary_item.key; // 집계 항목 줄 const row = buildSummaryRow(); const clicks = []; row.bind({ pick: () => clicks.push("pick"), clear: () => clicks.push("clear") }); const [labelNode, nameNode, pickBtn, clearBtn] = row.root.children; -out.rowEmpty = { name: nameNode.textContent, assigned: nameNode.classList.contains("is-assigned"), clearDisabled: clearBtn.disabled, get: row.get(), label: labelNode.textContent, pick: pickBtn.textContent, clear: clearBtn.textContent }; +out.rowEmpty = { + name: nameNode.textContent, + assigned: nameNode.classList.contains("is-assigned"), + clearDisabled: clearBtn.disabled, + get: row.get(), + label: labelNode.textContent, + pick: pickBtn.textContent, + clear: clearBtn.textContent, +}; row.set(A); -out.rowSet = { name: nameNode.textContent, assigned: nameNode.classList.contains("is-assigned"), clearDisabled: clearBtn.disabled, title: nameNode.title, key: row.get().key, nameShowsKey: nameNode.textContent.includes("SS000007") }; +out.rowSet = { + name: nameNode.textContent, + assigned: nameNode.classList.contains("is-assigned"), + clearDisabled: clearBtn.disabled, + title: nameNode.title, + key: row.get().key, + nameShowsKey: nameNode.textContent.includes("SS000007"), +}; pickBtn.click(); clearBtn.click(); out.clicks = clicks; @@ -150,11 +207,22 @@ out.rowCleared = { name: nameNode.textContent, clearDisabled: clearBtn.disabled // 목록 줄 — 지정 색 · 툴팁 const list = new FakeNode("ul"); -const types = new Map([["wall", { name: "옹벽" }], ["fence", { name: "울타리" }], ["pipe", { name: "배수관" }]]); +const types = new Map([ + ["wall", { name: "옹벽" }], + ["fence", { name: "울타리" }], + ["pipe", { name: "배수관" }], +]); renderStructureList({ list, structures: [ - { structure_id: "s1", type_id: "wall", placement: "point", chainage_m: 100, options: {}, summary_item: A }, + { + structure_id: "s1", + type_id: "wall", + placement: "point", + chainage_m: 100, + options: {}, + summary_item: A, + }, { structure_id: "s2", type_id: "fence", placement: "point", chainage_m: 200, options: {} }, ], pipeFacilities: [ @@ -168,10 +236,21 @@ renderStructureList({ onSelectStructure() {}, onSelectPipe() {}, }); -out.list = list.children.map((li) => ({ assigned: li.classList.contains("is-assigned"), title: li.title })); +out.list = list.children.map((li) => ({ + assigned: li.classList.contains("is-assigned"), + title: li.title, +})); // 옵션 칸 그리기 — visible:false 숨김 · 상세(detail) 는 includeDetail 일 때만 · 정의는 그대로 -const opt = (key, extra = {}) => ({ key, label: key, input: "number", choices: [], unit: null, default: null, ...extra }); +const opt = (key, extra = {}) => ({ + key, + label: key, + input: "number", + choices: [], + unit: null, + default: null, + ...extra, +}); const wallType = { type_id: "wall", options: [ @@ -206,7 +285,13 @@ out.definitionKept = wallType.options.map((o) => o.key); // 정의는 그대로 // [수정]/[추가] 저장 — 폼 값이 정본 기록으로 나가는 자리(Commit.commit)를 fake 폼으로 돌림 const saveType = { type_id: "wall", placement: "point", managed_by: null, options: [] }; -async function saveWith({ previous, summary, formOptions }) { +async function saveWith({ + previous, + summary, + formOptions, + requiredEmpty = false, + summaryOnly = false, +}) { const structures = previous ? [previous] : []; let summaryValue = summary; let changed = null; @@ -221,10 +306,10 @@ async function saveWith({ previous, summary, formOptions }) { facilityOptions: {}, optionInputs: Object.entries(formOptions).map(([key, value]) => ({ key, - required: false, - input: {}, + required: requiredEmpty, + input: { focus() {} }, read: () => value, - isEmpty: () => false, + isEmpty: () => requiredEmpty, })), pipeFacilities: () => [], interval: () => 20, @@ -252,8 +337,12 @@ async function saveWith({ previous, summary, formOptions }) { summary: () => summaryValue, setSummary: (value) => (summaryValue = value), }; + if (summaryOnly) { + const done = commitModule.commitSummaryOnly(ctx); + return { done, record: changed ? changed[0] : null }; + } await commitModule.commit(ctx, true); - return changed[0]; + return changed ? changed[0] : null; } const prev = () => ({ structure_id: "s1", @@ -278,6 +367,41 @@ async function main() { out.commitKept = { key: kept.summary_item?.key, options: kept.options }; const swapped = await saveWith({ previous: prev(), summary: B, formOptions: { H: 3 } }); out.commitSwapped = swapped.summary_item?.key; + // 집계 항목만 바뀐 때(PLAN 21-10 흠 2) — 필수 옵션이 비어 폼 검증에 걸려도 기록에 바로 반영 + const blocked = await saveWith({ + previous: prev(), + summary: B, + formOptions: { H: 3 }, + requiredEmpty: true, + }); + out.formBlocks = blocked === null; // 종전 흐름(commit)은 필수 옵션이 비면 아무것도 안 함 + const only = await saveWith({ + previous: prev(), + summary: B, + formOptions: { H: 3 }, + requiredEmpty: true, + summaryOnly: true, + }); + out.summaryOnlyAssign = { + done: only.done, + key: only.record?.summary_item?.key, + options: only.record?.options, + }; + const onlyClear = await saveWith({ + previous: prev(), + summary: null, + formOptions: { H: 3 }, + requiredEmpty: true, + summaryOnly: true, + }); + out.summaryOnlyClear = { done: onlyClear.done, hasKey: "summary_item" in onlyClear.record }; + const onlyNew = await saveWith({ + previous: null, + summary: B, + formOptions: { H: 3 }, + summaryOnly: true, + }); + out.summaryOnlyNew = onlyNew.done; // 새 항목은 [추가] 몫 — 반영 안 함 process.stdout.write(JSON.stringify(out)); } main(); diff --git a/resources/tester/test_b05_structures_form_summary.py b/resources/tester/test_b05_structures_form_summary.py index 991b102c..2b7f8d03 100644 --- a/resources/tester/test_b05_structures_form_summary.py +++ b/resources/tester/test_b05_structures_form_summary.py @@ -112,3 +112,27 @@ def test_저장_기록에_집계_항목이_실리고_해제하면_칸이_빠짐( "options": {"hidden_cost_key": "keep", "detail_only": 9, "H": 3}, } assert out["commitSwapped"] == "SS000004" + + +def test_집계_항목만_바뀌면_폼_검증과_무관하게_기록에_반영(out): + # 흠 2 — 필수 옵션이 비면 종전 commit 은 조용히 멈춤 + assert out["formBlocks"] is True + # 집계 항목 전용 반영은 그 검증을 안 거침 · 옵션은 그대로 + assert out["summaryOnlyAssign"] == { + "done": True, + "key": "SS000004", + "options": {"H": 2, "hidden_cost_key": "keep", "detail_only": 9}, + } + assert out["summaryOnlyClear"] == {"done": True, "hasKey": False} + assert out["summaryOnlyNew"] is False + + +def test_배수유역_화면은_들어올_때_세션_초안_관을_먼저_씀(): + # 흠 3 — B05→B06→B05 를 오가도 저장 전 지정 · 옵션이 옛값으로 안 돌아감 + source = open( + os.path.join(HERE, "..", "..", "B05_Profile", "B05_Profile_UI_Drainage_Panel.ts"), + encoding="utf-8", + ).read() + body = source.split("const loadSaved", 1)[1].split("/**", 1)[0] + assert "readPendingPipes(" in body and "computeDetailBasins(" in body + assert body.index("readPendingPipes(") < body.index("fetchDetailPipePoints(")