From d965b3f21740e9c197bf6df479fdb140496dbe3e Mon Sep 17 00:00:00 2001 From: Msystech Date: Thu, 20 Aug 2026 14:44:20 +0900 Subject: [PATCH] =?UTF-8?q?=EB=9D=BC=EB=94=94=EC=98=A4=EB=B2=84=ED=8A=BC?= =?UTF-8?q?=EB=8F=84=20=ED=8C=A8=EB=84=90=EB=A1=9C=20=EB=AC=B6=EB=8A=94?= =?UTF-8?q?=EB=8B=A4=20=E2=80=94=20=EC=B0=A8=EB=8B=A8=EC=9D=84=20=EA=B2=BD?= =?UTF-8?q?=EA=B3=A0=EB=A1=9C=20(=EC=9A=B4=EC=98=81=20=ED=91=9C=EC=A4=80?= =?UTF-8?q?=20=ED=8C=A8=ED=84=B4=EC=9D=84=20=EB=A7=89=EA=B3=A0=20=EC=9E=88?= =?UTF-8?q?=EC=97=88=EB=8B=A4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 조사 워크플로(읽기 3 + 반박 검증 3)로 확정한 사실: - 레거시 RadioButton 은 System.Windows.Forms.RadioButton 을 그대로 상속하고 GroupName 류 속성이 소스·IL 어디에도 없다 → 직계 부모가 곧 배타 단위 (검증자가 실제 어셈블리를 로드해 Panel/GroupBox/MExpandablePanel/호스트가 각각 독립 집합이고, 패널 밖으로 옮기면 배타가 즉시 끊김을 실행으로 확인) - 운영 전수(디자인 1,270건 전량 덤프): 라디오 15,184개 중 15,026개(98.96%)가 패널 직계 자식, 그룹 5,582개(평균 2.72 · 중앙값 2 · 최대 72), 최상위 직속은 158개(1.04%)뿐. 그룹을 나타내는 속성은 0건 — 즉 "패널 1개 = 문항 1개" 가 사실상의 저장 규약이다 그런데 우리는 라디오 Ctrl+G 를 아예 막아 두었다(GroupBlockReason ①) — 운영 서식의 98.96% 패턴을 우리 도구로는 만들 수 없었다는 뜻이다. 막은 이유("일부만 묶으면 택1이 갈라진다")는 타당하지만 그것은 경고할 일이지 기능을 없앨 일이 아니다. 오히려 막으면 한 페이지의 라디오가 전부 한 그룹이 되어 문항을 여러 개 만들 수 없다. - 차단 제거, 대신 같은 자리에 묶이지 않고 남는 라디오가 있을 때만 "N개 중 M개만 묶습니다 — 남는 것은 다른 선택 묶음이 됩니다" 로 되묻는다 (RadioSplitWarning). 전부 묶으면 되묻지 않는다. - edit-smoke: "라디오는 묶이지 않는다" 판정을 뒤집어 "라디오도 패널로 묶인다" + "패널 자식이 된다" + 경고 판정 2건(일부만/전부)으로 교체 - GroupSelection 의 낡은 XML 주석("동일 GroupId 부여")을 실제 동작(패널 래핑)과 근거 수치로 교체 — GroupId 계열은 이미 죽은 코드다 - dotnet test 358/358 · --edit-smoke 실패 0 · --db-patient ①~㊲ 전건 통과 - --db-render P062 md5 8d683835f5d81e7bb41c79071d6bf954 불변 Co-Authored-By: Claude Fable 5 --- src/SheetMe.Designer/Diagnostics/EditSmoke.cs | 30 +++++++++-- .../ViewModels/DesignerViewModel.cs | 52 +++++++++++++++---- 2 files changed, 67 insertions(+), 15 deletions(-) diff --git a/src/SheetMe.Designer/Diagnostics/EditSmoke.cs b/src/SheetMe.Designer/Diagnostics/EditSmoke.cs index 0063fc7..2ff7884 100644 --- a/src/SheetMe.Designer/Diagnostics/EditSmoke.cs +++ b/src/SheetMe.Designer/Diagnostics/EditSmoke.cs @@ -1070,16 +1070,36 @@ public static class EditSmoke && grp.Pages[0].Controls.Any(c => c.X == 240 && c.Y == 140), string.Join(" ", grp.Pages[0].Controls.Select(c => $"({c.X},{c.Y})"))); - // 가드 ① 라디오는 부모가 곧 배타 단위 — 일부만 묶으면 '택1'이 갈라진다 + // 라디오는 패널이 곧 문항이다 — 묶는 것이 정상이고 필요한 작업이다. + // 운영 실측: 라디오 9,935개 중 9,819개(98.8%)가 패널 자식, 그룹 3,625개 + // (평균 2.74개 · "패널 1개 = 질문 1개"가 사실상의 저장 규약). + // 전에는 이 경로를 아예 막아 두어 운영 서식의 표준 패턴을 만들 수 없었다. var radioDoc = new DesignerViewModel(business.CreateNew()); radioDoc.AddControlAt("RadioButton", new Point(100, 100)); radioDoc.AddControlAt("RadioButton", new Point(100, 140)); radioDoc.Selection.Set(radioDoc.Pages[0].Controls.ToList(), radioDoc.Pages[0].Controls[0]); - var beforeRadio = radioDoc.Pages[0].Controls.Count; radioDoc.GroupSelection(); - Check("그룹 가드: 라디오는 묶이지 않는다", - radioDoc.Pages[0].Controls.Count == beforeRadio - && !radioDoc.Pages[0].Controls.Any(c => c.Type == "Panel")); + var radioPanel = radioDoc.Pages[0].Controls.OfType().FirstOrDefault(c => c.Type == "Panel"); + Check("그룹: 라디오도 패널로 묶인다", radioPanel is not null); + Check("그룹: 묶인 라디오가 패널 자식이 된다", + radioPanel is not null && radioPanel.Children.Count == 2 + && radioPanel.Children.All(c => c.Type == "RadioButton"), + radioPanel is null ? "패널 없음" : $"자식 {radioPanel.Children.Count}개"); + // 되묻는 경로(일부만 묶기)는 대화상자가 떠서 진단에서 끝까지 돌릴 수 없으므로 + // 경고 판정 자체를 직접 부른다 — 이게 문항이 갈라지는 유일한 방어선이다. + var splitDoc = new DesignerViewModel(business.CreateNew()); + for (var i = 0; i < 3; i++) + { + splitDoc.AddControlAt("RadioButton", new Point(100, 100 + (i * 40))); + } + var splitPage = splitDoc.Pages[0]; + var twoOfThree = splitPage.Controls.Take(2).ToList(); + Check("그룹 경고: 라디오 일부만 묶으면 갈라진다고 말한다", + splitDoc.RadioSplitWarning(splitPage, twoOfThree) is { } w + && w.Contains("3개 중 2개", StringComparison.Ordinal), + splitDoc.RadioSplitWarning(splitPage, twoOfThree) ?? "경고 없음"); + Check("그룹 경고(대조군): 전부 묶으면 되묻지 않는다", + splitDoc.RadioSplitWarning(splitPage, splitPage.Controls.ToList()) is null); // 가드 ② 사이에 남이 끼어 있으면 쌓임 순서가 바뀐다 var zDoc = new DesignerViewModel(business.CreateNew()); diff --git a/src/SheetMe.Designer/ViewModels/DesignerViewModel.cs b/src/SheetMe.Designer/ViewModels/DesignerViewModel.cs index 00fdc32..c92bcb5 100644 --- a/src/SheetMe.Designer/ViewModels/DesignerViewModel.cs +++ b/src/SheetMe.Designer/ViewModels/DesignerViewModel.cs @@ -1769,8 +1769,13 @@ public sealed class DesignerViewModel : ViewModelBase => Selection.Items.Count > 0 && Selection.Items.Select(PageOf).Distinct().Count() <= 1; /// - /// 선택 그룹화 — 동일 GroupId 부여(2개 이상, 최상위 컨트롤만) + 기본 이름 등록. + /// 선택 그룹화 — 선택분(2개 이상, 최상위만)을 감싸는 진짜 Panel 을 만들어 자식으로 옮긴다. + /// 그래서 저장까지 살아남는다(레거시 XML 의 children="Controls" 중첩 Object). /// 잠긴 컨트롤은 애초에 선택에 들어오지 않는다(SelectionService 가 거른다). + /// + /// 라디오에 특히 중요하다: 레거시는 GroupName 이 없어 직계 부모가 곧 배타 단위이고, + /// 운영 실측도 라디오 15,184개 중 15,026개(98.96%)가 패널 자식이다(그룹 5,582개, 평균 2.72개). + /// 즉 이 조작이 "문항 만들기" 다. /// public void GroupSelection() { @@ -1784,6 +1789,15 @@ public sealed class DesignerViewModel : ViewModelBase DialogService.Notify(DialogKind.Warning, "그룹", reason); return; } + // 라디오는 패널이 곧 문항이다 — 묶는 것이 정상이고 필요한 작업이다 + // (운영 실측: 라디오 9,935개 중 9,819개(98.8%)가 패널 자식, 그룹 3,625개). + // 다만 한 문항의 일부만 묶으면 '하나만 고르기'가 둘로 갈라지므로 그때만 되묻는다. + if (RadioSplitWarning(page, targets) is { } warning + && !DialogService.Confirm("그룹", warning, + "묶은 라디오끼리 하나만 선택되고, 남은 라디오는 별도 묶음이 됩니다.")) + { + return; + } // z 순서상 멤버가 차지한 구간 — 패널은 그 자리에 들어간다. // WinForms 에서 형제는 남의 자식 사이에 낄 수 없어 멤버가 반드시 연속 블록이 되는데, @@ -1837,15 +1851,7 @@ public sealed class DesignerViewModel : ViewModelBase /// private string? GroupBlockReason(PageViewModel page, IReadOnlyList targets) { - // ① 라디오는 부모가 곧 배타 단위다(레거시 RadioButton 에 GroupName 이 없다). - // '택1' 문항에서 일부만 묶으면 그 일부가 독립 집합이 되어 EMR 에서 답이 둘 선택된다. - if (targets.Any(vm => vm.Type == "RadioButton")) - { - return "라디오 버튼은 패널이 곧 선택 묶음이라, 일부만 묶으면 '하나만 고르기'가 갈라집니다.\n" - + "라디오는 그룹으로 묶지 마세요."; - } - - // ② 멤버 사이에 남이 끼어 있으면 쌓임 순서가 바뀐다. + // 멤버 사이에 남이 끼어 있으면 쌓임 순서가 바뀐다. var indices = targets.Select(vm => page.Model.Controls.IndexOf(vm.Model)).ToList(); if (indices.Any(i => i < 0)) { @@ -1860,6 +1866,32 @@ public sealed class DesignerViewModel : ViewModelBase return null; } + /// + /// 라디오 문항이 갈라지는가 — 갈라지지 않으면 null. + /// + /// 왜 되묻는가. 레거시 RadioButton 은 WinForms 를 그대로 상속해 + /// GroupName 이 없고 직계 부모가 곧 배타 단위다(RadioButton.vb:7-8). + /// 그래서 패널로 묶는 것 자체는 정상이고 필요한 작업이지만, + /// 같은 자리에 있던 라디오 중 일부만 묶으면 한 문항이 두 묶음으로 갈라져 + /// EMR 에서 답이 둘 선택된다 — 캔버스에는 아무 변화도 안 보인 채로. + /// + internal string? RadioSplitWarning(PageViewModel page, IReadOnlyList targets) + { + var picked = targets.Count(vm => vm.Type == "RadioButton"); + if (picked == 0) + { + return null; + } + // 지금은 최상위만 묶을 수 있으므로(위 targets 필터) 남는 형제도 최상위에서 센다 + var siblings = page.Model.Controls.Count(c => + string.Equals(c.Type, "RadioButton", StringComparison.Ordinal) + && !targets.Any(vm => ReferenceEquals(vm.Model, c))); + return siblings == 0 + ? null + : $"이 페이지에 라디오 버튼 {picked + siblings}개 중 {picked}개만 묶습니다.\n" + + $"남는 {siblings}개는 다른 선택 묶음이 됩니다. 계속할까요?"; + } + private PageViewModel? PageOf2(int index) => index >= 0 && index < Pages.Count ? Pages[index] : null; ///