fix(B05·B06): 집계 항목 지정이 폼 검증에 막혀 초안에 안 들어가던 흠 · 저장 전 B05→B06→B05 에서 관 지정이 옛값으로 돌아가던 흠(21-10 흠 2·3)
- 흠 2 고치는 구조물의 집계 항목 지정 · 해제는 필수 옵션 · 측점 검증을 거치지 않고 기록에 바로 반영(commitSummaryOnly) · 관 · 새 항목은 종전 흐름 - 흠 3 배수유역 화면이 들어올 때 세션 초안 관 목록이 있으면 그것으로 세움(초안이 없으면 종전대로 정본) - 시험 2개 추가(집계 항목 전용 반영 · 초안 우선 진입) Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TULoa94ZFL26KU6ZqVpjkF
This commit is contained in:
@@ -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<void> => run(() => fetchDetailPipePoints(projectId as string));
|
||||
/** 저장된 관 지점(없으면 자동 배치)을 불러온다. 화면에 들어올 때 1회. 저장 전 조작분(세션 초안)이
|
||||
* 있으면 그 관 목록으로 세운다 — B06 을 다녀와도 지정 · 옵션이 옛값으로 안 돌아감(21-10 흠 3). */
|
||||
const loadSaved = (): Promise<void> =>
|
||||
run(() => {
|
||||
const pending = readPendingPipes(projectId as string);
|
||||
return pending?.length
|
||||
? computeDetailBasins(projectId as string, pending)
|
||||
: fetchDetailPipePoints(projectId as string);
|
||||
});
|
||||
|
||||
/** 저장분까지 버리고 자동 배치로 되돌린다("초기화"). 화면만 되돌리면 다시 들어왔을 때
|
||||
* 옛 관이 살아난다(2026-08-02 사용자 보고). */
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<void> {
|
||||
const type = ctx.currentType();
|
||||
if (!type) return;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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(")
|
||||
|
||||
Reference in New Issue
Block a user