From dfe13823771d8f5d36f6830c04c899f8577f92d2 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 6 Oct 2026 11:40:39 +0900 Subject: [PATCH] =?UTF-8?q?fix(structure):=20=ED=95=84=EC=88=98=20?= =?UTF-8?q?=EC=B9=B8=20=EB=B9=88=20=EC=98=9B=20=EA=B5=AC=EC=A1=B0=EB=AC=BC?= =?UTF-8?q?=EB=8F=84=20=EB=8B=A4=EB=A5=B8=20=EC=B9=B8=20=C2=B7=20=EC=B8=A1?= =?UTF-8?q?=EC=A0=90=20=EB=B0=94=EA=BF=88=20=EB=B0=94=EB=A1=9C=20=EB=B0=98?= =?UTF-8?q?=EC=98=81=20=C2=B7=20=EB=B9=88=20=EC=B9=B8=20=ED=91=9C=EC=8B=9C?= =?UTF-8?q?=20+=20=EC=95=88=EB=82=B4=20=ED=95=9C=20=EC=A4=84(54-12)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 실시간 반영이 빈 필수 칸에서 멈추고 포커스만 보내던 것 풂 · [추가] · 서버 [저장] 막기 그대로 - 빈 필수 칸 붉은 테두리 + 그 칸 상자 안 「필수 칸 비어 있음 — [저장] 전 채움」(B05 = B06) - 시험 test_54_12_live_required · 옛 「실시간 멈춤」 기대 한 줄 바꿈 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013rJwJMVL4EE31y77TB11AP --- .../B05_Profile_UI_Structures_Panel.ts | 5 +- .../B05_Profile_UI_Structures_Panel_Commit.ts | 3 +- ...B05_Profile_UI_Structures_Panel_Options.ts | 25 ++++ .../B05_Profile_UI_Style_Structures.css | 12 ++ .../tester/helper_54_12_live_required.cjs | 111 ++++++++++++++++++ resources/tester/test_54_12_live_required.py | 58 +++++++++ .../test_b05_structures_form_summary.py | 4 +- 7 files changed, 214 insertions(+), 4 deletions(-) create mode 100644 resources/tester/helper_54_12_live_required.cjs create mode 100644 resources/tester/test_54_12_live_required.py diff --git a/B05_Profile/B05_Profile_UI_Structures_Panel.ts b/B05_Profile/B05_Profile_UI_Structures_Panel.ts index 150f47e9f..4f3e2e137 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Panel.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Panel.ts @@ -25,6 +25,7 @@ import { type FacilityAttributes } from "./B05_Profile_UI_Drainage_Facility"; import { field, optionDefaults } from "./B05_Profile_UI_Structures_Fields"; import { hidesTypeSummary, + markRequired, paintStale, renderOptionFields as renderOptionFieldsInto, } from "./B05_Profile_UI_Structures_Panel_Options"; @@ -99,12 +100,14 @@ export function createStructuresSection( groupBoxes, } = form; /** 옵션 그룹 상자를 지금 종류 · 칸에 맞춰 다시 세움(PLAN 46-3) — 칸을 그린 뒤마다 부름. */ - const regroup = (): void => + const regroup = (): void => { groupBoxes.render( currentType(), [optionRow, facilityOptions.root], facilityFormKind(currentType()), ); + markRequired(optionInputs, optionRow); // 빈 필수 칸 안내 = 칸이 옮겨 간 상자 안(54-12) + }; /** 이 타입이 서브폼(계곡 통과 시설 부속)을 쓰는지 — 쓰면 레지스트리 옵션 칸 대신 * 서브폼이 그린다. 독립 기슭막이(D4)는 서브폼을 떠나 C군과 같은 레지스트리 옵션 diff --git a/B05_Profile/B05_Profile_UI_Structures_Panel_Commit.ts b/B05_Profile/B05_Profile_UI_Structures_Panel_Commit.ts index 06ab97d63..222588462 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Panel_Commit.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Panel_Commit.ts @@ -329,8 +329,9 @@ export async function commit(ctx: StructuresCommitContext, live = false): Promis } // 필수 옵션(미확정 기본값 없음)이 비어 있으면 추가하지 않는다 — 서버도 거절한다. // 상세(detail) 옵션은 폼에 없으므로 여기 걸리지 않는다(B06/B07에서 받는다). + // 실시간 반영은 막지 않음 — 빈 칸은 표시 · 안내만(`markRequired` · 54-12), 막기는 [추가] · 서버 [저장] const missing = ctx.optionInputs.find((entry) => entry.required && entry.isEmpty()); - if (missing) { + if (missing && !live) { missing.input.focus(); return; } diff --git a/B05_Profile/B05_Profile_UI_Structures_Panel_Options.ts b/B05_Profile/B05_Profile_UI_Structures_Panel_Options.ts index 703d97378..bed251a8b 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Panel_Options.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Panel_Options.ts @@ -112,8 +112,10 @@ export function renderOptionFields( // 상위 값이 바뀌면 하위 기본값 · 선택지 · 보이기를 먼저 맞추고 반영(48-4) input.addEventListener("change", () => { applyRules(option.key); + markRequired(optionInputs, optionRow); ctx.liveCommit(); }); + input.addEventListener("input", () => markRequired(optionInputs, optionRow)); all.push({ wrap, entry: { @@ -203,6 +205,29 @@ export function renderOptionFields( ctx.syncRangeDisplay(); } +/** 빈 필수 칸 표시(54-12) — 칸 테두리 + 그 칸이 든 상자(격자) 안 안내 한 줄. 칸이 그룹 상자로 옮겨 간 + * 뒤에도 맞게 패널이 칸 그리기 · 그룹 다시 세우기 · 값 바꿈마다 부름. 막지 않음(막기는 [추가] · [저장]). */ +export function markRequired( + optionInputs: OptionRenderContext["optionInputs"], + optionRow: HTMLElement, +): void { + const scope = optionRow.parentElement ?? optionRow; + scope.querySelectorAll(".b05-structure__required-note").forEach((note) => note.remove()); + scope + .querySelectorAll(".is-required-empty") + .forEach((input) => input.classList.remove("is-required-empty")); + for (const entry of optionInputs) { + if (!entry.required || !entry.isEmpty()) continue; + entry.input.classList.add("is-required-empty"); + const box = entry.input.closest("[data-option-keys]")?.parentElement; + if (!box || box.querySelector(":scope > .b05-structure__required-note")) continue; + const note = document.createElement("p"); + note.className = "b05-structure__required-note"; + note.textContent = "필수 칸 비어 있음 — [저장] 전 채움"; + box.append(note); + } +} + /** 고르기 칸 보기를 규칙 선택지로 — 빈 보기는 남기고, 지금 값이 목록 밖이면 그 값만 남겨 보임. */ function syncChoices( element: HTMLSelectElement, diff --git a/B05_Profile/B05_Profile_UI_Style_Structures.css b/B05_Profile/B05_Profile_UI_Style_Structures.css index 65ba8eb49..cde7f1c91 100644 --- a/B05_Profile/B05_Profile_UI_Style_Structures.css +++ b/B05_Profile/B05_Profile_UI_Style_Structures.css @@ -244,6 +244,18 @@ background: rgba(224, 91, 91, 0.08); } +/* 빈 필수 칸 — 칸 테두리 + 그 칸이 든 상자 안 안내 한 줄(54-12) */ +.b05-route__field .is-required-empty { + border-color: #e05b5b; +} + +.b05-structure__required-note { + grid-column: 1 / -1; + margin: 0; + color: #e05b5b; + font-size: var(--text-caption, 12px); +} + /* 측점 범위(계산 표시) — C군 등 길이 옵션 기반 구간 타입. 입력 칸이 아니라 기준 * 측점 + 길이로 계산된 "시작 ~ 종료"를 보여만 준다(2026-08-19 사용자 지시). * 글자 크기는 기준 측점 입력 칸과 동일(크롬 input 기본 13.333px — 패널 입력에 diff --git a/resources/tester/helper_54_12_live_required.cjs b/resources/tester/helper_54_12_live_required.cjs new file mode 100644 index 000000000..4caaa0424 --- /dev/null +++ b/resources/tester/helper_54_12_live_required.cjs @@ -0,0 +1,111 @@ +/* 54-12 빈 필수 칸 실시간 반영 헬퍼 — `_Structures_Panel_Commit.commit` 을 Node 로 돌려 + * 결과를 표준출력 JSON 한 벌로. 화면 그림은 안 씀(ORCA 가 따로 봄). + * TS 로더는 `helper_54_9_etc_pill.cjs` 와 같다. + */ +const fs = require("fs"); +const path = require("path"); +const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); + +const ROOT = path.join(__dirname, "..", ".."); +const cache = new Map(); +const session = new Map(); +const fakeStorage = { + getItem: (k) => session.get(k) ?? null, + setItem: (k, v) => session.set(k, String(v)), + removeItem: (k) => session.delete(k), +}; +globalThis.window = { sessionStorage: fakeStorage, localStorage: fakeStorage }; +globalThis.sessionStorage = fakeStorage; +globalThis.localStorage = fakeStorage; +const STUBS = { "@config/config_frontend": { API_BASE_URL: "/api", API_TIMEOUT_MS: 1000 } }; + +function loadTs(file) { + const full = path.resolve(file); + 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 }, + }).outputText; + const box = { exports: {} }; + cache.set(full, box.exports); + const localRequire = (request) => { + if (STUBS[request]) return STUBS[request]; + if (request.startsWith(".")) return loadTs(path.join(path.dirname(full), `${request}.ts`)); + if (request.startsWith("@")) return new Proxy({}, { get: () => () => {} }); + return require(request); + }; + new Function("exports", "module", "require", js)(box.exports, box, localRequire); + cache.set(full, box.exports); + return box.exports; +} + +const { commit } = loadTs( + path.join(ROOT, "B05_Profile", "B05_Profile_UI_Structures_Panel_Commit.ts"), +); + +const type = { + type_id: "stone_wall_test", + name: "돌쌓기(찰)", + group: "C", + placement: "point", + options: [ + { key: "height_m", label: "높이", input: "number" }, + { key: "back_length_m", label: "뒷길이", input: "number", required: true, phase: "detail" }, + ], +}; + +function run(live, height) { + const focused = []; + const entry = (key, value, required) => { + const input = { value, focus: () => focused.push(key), classList: { add() {} } }; + return { + key, + required, + input, + read: () => Number(input.value) || 0, + isEmpty: () => input.value.trim() === "", + }; + }; + const structures = [ + { + structure_id: "s1", + type_id: type.type_id, + placement: "point", + chainage_m: 80, + options: { height_m: 1.5 }, + }, + ]; + const changes = []; + const anchor = { read: () => 100, station: { focus: () => focused.push("anchor") } }; + const ctx = { + callbacks: { onChange: (next) => changes.push(next) }, + structures, + memoField: { value: "" }, + anchorFields: anchor, + startFields: anchor, + endFields: anchor, + optionRow: null, + optionInputs: [entry("height_m", String(height), false), entry("back_length_m", "", true)], + pipeFacilities: () => [], + interval: () => 20, + typeMap: () => new Map([[type.type_id, type]]), + currentType: () => type, + facilityFormKind: () => null, + hasComputedRange: () => false, + summary: () => null, + editingId: () => "s1", + groupBoxes: { get: () => ({}), shapes: () => ({}), shapeKeys: () => [] }, + renderList: () => {}, + cancelTempPipe: () => {}, + }; + return commit(ctx, live).then(() => ({ + changes: changes.length, + height: structures[0].options.height_m, + chainage: structures[0].chainage_m, + hasBack: "back_length_m" in structures[0].options, + focused, + })); +} + +Promise.all([run(true, 3.2), run(false, 3.2)]).then(([live, button]) => { + process.stdout.write(JSON.stringify({ live, button })); +}); diff --git a/resources/tester/test_54_12_live_required.py b/resources/tester/test_54_12_live_required.py new file mode 100644 index 000000000..fb41486a3 --- /dev/null +++ b/resources/tester/test_54_12_live_required.py @@ -0,0 +1,58 @@ +# -*- coding: utf-8 -*- +"""54-12 필수 칸(뒷길이 등)이 빈 옛 구조물 — 다른 칸 · 기준 측점 바꿈은 실시간 반영 · [추가]는 그대로 막음. + +Node 헬퍼가 `_Structures_Panel_Commit.commit` 을 돌림(`helper_54_12_live_required.cjs`). +빈 칸 표시 · 안내 한 줄은 ORCA 로 따로 봄. +""" + +import json +import os +import subprocess + +import pytest + +HERE = os.path.dirname(os.path.abspath(__file__)) +ROOT = os.path.abspath(os.path.join(HERE, "..", "..")) + + +@pytest.fixture(scope="module") +def out(): + proc = subprocess.run( + ["node", os.path.join(HERE, "helper_54_12_live_required.cjs")], + capture_output=True, + text=True, + encoding="utf-8", + timeout=60, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +def test_실시간_반영은_빈_필수_칸이_있어도_나간다(out): + live = out["live"] + assert live["changes"] == 1 + assert live["height"] == 3.2 + assert live["chainage"] == 100 + assert live["hasBack"] is False # 빈 칸은 0 으로 채우지 않음 + assert live["focused"] == [] # 포커스를 빈 칸으로 뺏지 않음 + + +def test_추가_수정_버튼은_종전대로_막는다(out): + button = out["button"] + assert button["changes"] == 0 + assert button["height"] == 1.5 + assert button["focused"] == ["back_length_m"] + + +def test_빈_필수_칸_표시는_패널_세_곳에서_부른다(): + with open( + os.path.join(ROOT, "B05_Profile", "B05_Profile_UI_Structures_Panel_Options.ts"), + encoding="utf-8", + ) as handle: + options = handle.read() + assert "필수 칸 비어 있음 — [저장] 전 채움" in options + assert options.count("markRequired(optionInputs, optionRow)") == 2 # change · input + with open( + os.path.join(ROOT, "B05_Profile", "B05_Profile_UI_Structures_Panel.ts"), encoding="utf-8" + ) as handle: + assert "markRequired(optionInputs, optionRow)" in handle.read() # 그룹 상자 다시 세운 뒤 diff --git a/resources/tester/test_b05_structures_form_summary.py b/resources/tester/test_b05_structures_form_summary.py index eca79410f..b75785843 100644 --- a/resources/tester/test_b05_structures_form_summary.py +++ b/resources/tester/test_b05_structures_form_summary.py @@ -116,8 +116,8 @@ def test_저장_기록에_집계_항목이_실리고_해제하면_칸이_빠짐( def test_집계_항목만_바뀌면_폼_검증과_무관하게_기록에_반영(out): - # 흠 2 — 필수 옵션이 비면 종전 commit 은 조용히 멈춤 - assert out["formBlocks"] is True + # 흠 2 — 필수 옵션이 비어도 실시간 반영은 나감(54-12 · 막기는 [추가] · [저장]) + assert out["formBlocks"] is False # 집계 항목 전용 반영은 그 검증을 안 거침 · 옵션은 그대로 assert out["summaryOnlyAssign"] == { "done": True,