라디오버튼도 패널로 묶는다 — 차단을 경고로 (운영 표준 패턴을 막고 있었다)

조사 워크플로(읽기 3 + 반박 검증 3)로 확정한 사실:
- 레거시 RadioButton 은 System.Windows.Forms.RadioButton 을 그대로 상속하고
  GroupName 류 속성이 소스·IL 어디에도 없다 → <b>직계 부모가 곧 배타 단위</b>
  (검증자가 실제 어셈블리를 로드해 Panel/GroupBox/MExpandablePanel/호스트가
   각각 독립 집합이고, 패널 밖으로 옮기면 배타가 즉시 끊김을 실행으로 확인)
- 운영 전수(디자인 1,270건 전량 덤프): 라디오 15,184개 중 <b>15,026개(98.96%)가
  패널 직계 자식</b>, 그룹 5,582개(평균 2.72 · 중앙값 2 · 최대 72),
  최상위 직속은 158개(1.04%)뿐. 그룹을 나타내는 속성은 0건 —
  즉 "패널 1개 = 문항 1개" 가 사실상의 저장 규약이다

그런데 우리는 라디오 Ctrl+G 를 <b>아예 막아</b> 두었다(GroupBlockReason ①) —
운영 서식의 98.96% 패턴을 우리 도구로는 만들 수 없었다는 뜻이다.
막은 이유("일부만 묶으면 택1이 갈라진다")는 타당하지만 그것은 경고할 일이지
기능을 없앨 일이 아니다. 오히려 막으면 한 페이지의 라디오가 전부 한 그룹이 되어
문항을 여러 개 만들 수 없다.

- 차단 제거, 대신 같은 자리에 <b>묶이지 않고 남는 라디오가 있을 때만</b>
  "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 <noreply@anthropic.com>
This commit is contained in:
Msystech
2026-08-20 14:44:20 +09:00
co-authored by Claude Fable 5
parent 345b2e1da1
commit d965b3f217
2 changed files with 67 additions and 15 deletions
+25 -5
View File
@@ -1070,16 +1070,36 @@ public static class EditSmoke
&& grp.Pages[0].Controls.Any(c => c.X == 240 && c.Y == 140), && grp.Pages[0].Controls.Any(c => c.X == 240 && c.Y == 140),
string.Join(" ", grp.Pages[0].Controls.Select(c => $"({c.X},{c.Y})"))); string.Join(" ", grp.Pages[0].Controls.Select(c => $"({c.X},{c.Y})")));
// 가드 ① 라디오는 부모가 곧 배타 단위 — 일부만 묶으면 '택1'이 갈라진다 // 라디오는 <b>패널이 곧 문항</b>이다 — 묶는 것이 정상이고 필요한 작업이다.
// 운영 실측: 라디오 9,935개 중 9,819개(98.8%)가 패널 자식, 그룹 3,625개
// (평균 2.74개 · "패널 1개 = 질문 1개"가 사실상의 저장 규약).
// 전에는 이 경로를 아예 막아 두어 <b>운영 서식의 표준 패턴을 만들 수 없었다</b>.
var radioDoc = new DesignerViewModel(business.CreateNew()); var radioDoc = new DesignerViewModel(business.CreateNew());
radioDoc.AddControlAt("RadioButton", new Point(100, 100)); radioDoc.AddControlAt("RadioButton", new Point(100, 100));
radioDoc.AddControlAt("RadioButton", new Point(100, 140)); radioDoc.AddControlAt("RadioButton", new Point(100, 140));
radioDoc.Selection.Set(radioDoc.Pages[0].Controls.ToList(), radioDoc.Pages[0].Controls[0]); radioDoc.Selection.Set(radioDoc.Pages[0].Controls.ToList(), radioDoc.Pages[0].Controls[0]);
var beforeRadio = radioDoc.Pages[0].Controls.Count;
radioDoc.GroupSelection(); radioDoc.GroupSelection();
Check("그룹 가드: 라디오는 묶이지 않는다", var radioPanel = radioDoc.Pages[0].Controls.OfType<ContainerViewModel>().FirstOrDefault(c => c.Type == "Panel");
radioDoc.Pages[0].Controls.Count == beforeRadio Check("그룹: 라디오도 패널로 묶인다", radioPanel is not null);
&& !radioDoc.Pages[0].Controls.Any(c => c.Type == "Panel")); Check("그룹: 묶인 라디오가 패널 자식이 된다",
radioPanel is not null && radioPanel.Children.Count == 2
&& radioPanel.Children.All(c => c.Type == "RadioButton"),
radioPanel is null ? "패널 없음" : $"자식 {radioPanel.Children.Count}개");
// 되묻는 경로(일부만 묶기)는 대화상자가 떠서 진단에서 끝까지 돌릴 수 없으므로
// 경고 <b>판정 자체</b>를 직접 부른다 — 이게 문항이 갈라지는 유일한 방어선이다.
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()); var zDoc = new DesignerViewModel(business.CreateNew());
@@ -1769,8 +1769,13 @@ public sealed class DesignerViewModel : ViewModelBase
=> Selection.Items.Count > 0 && Selection.Items.Select(PageOf).Distinct().Count() <= 1; => Selection.Items.Count > 0 && Selection.Items.Select(PageOf).Distinct().Count() <= 1;
/// <summary> /// <summary>
/// 선택 그룹화 — 동일 GroupId 부여(2개 이상, 최상위 컨트롤만) + 기본 이름 등록. /// 선택 그룹화 — 선택분(2개 이상, 최상위만)을 감싸는 <b>진짜 Panel</b> 을 만들어 자식으로 옮긴다.
/// 그래서 저장까지 살아남는다(레거시 XML 의 children="Controls" 중첩 Object).
/// 잠긴 컨트롤은 애초에 선택에 들어오지 않는다(SelectionService 가 거른다). /// 잠긴 컨트롤은 애초에 선택에 들어오지 않는다(SelectionService 가 거른다).
///
/// <b>라디오에 특히 중요하다</b>: 레거시는 GroupName 이 없어 직계 부모가 곧 배타 단위이고,
/// 운영 실측도 라디오 15,184개 중 15,026개(98.96%)가 패널 자식이다(그룹 5,582개, 평균 2.72개).
/// 즉 이 조작이 "문항 만들기" 다.
/// </summary> /// </summary>
public void GroupSelection() public void GroupSelection()
{ {
@@ -1784,6 +1789,15 @@ public sealed class DesignerViewModel : ViewModelBase
DialogService.Notify(DialogKind.Warning, "그룹", reason); DialogService.Notify(DialogKind.Warning, "그룹", reason);
return; return;
} }
// 라디오는 <b>패널이 곧 문항</b>이다 — 묶는 것이 정상이고 필요한 작업이다
// (운영 실측: 라디오 9,935개 중 9,819개(98.8%)가 패널 자식, 그룹 3,625개).
// 다만 한 문항의 일부만 묶으면 '하나만 고르기'가 둘로 갈라지므로 그때만 되묻는다.
if (RadioSplitWarning(page, targets) is { } warning
&& !DialogService.Confirm("그룹", warning,
"묶은 라디오끼리 하나만 선택되고, 남은 라디오는 별도 묶음이 됩니다."))
{
return;
}
// z 순서상 멤버가 차지한 구간 — 패널은 그 자리에 들어간다. // z 순서상 멤버가 차지한 구간 — 패널은 그 자리에 들어간다.
// WinForms 에서 형제는 남의 자식 사이에 낄 수 없어 멤버가 반드시 연속 블록이 되는데, // WinForms 에서 형제는 남의 자식 사이에 낄 수 없어 멤버가 반드시 연속 블록이 되는데,
@@ -1837,15 +1851,7 @@ public sealed class DesignerViewModel : ViewModelBase
/// </summary> /// </summary>
private string? GroupBlockReason(PageViewModel page, IReadOnlyList<ControlViewModel> targets) private string? GroupBlockReason(PageViewModel page, IReadOnlyList<ControlViewModel> 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(); var indices = targets.Select(vm => page.Model.Controls.IndexOf(vm.Model)).ToList();
if (indices.Any(i => i < 0)) if (indices.Any(i => i < 0))
{ {
@@ -1860,6 +1866,32 @@ public sealed class DesignerViewModel : ViewModelBase
return null; return null;
} }
/// <summary>
/// 라디오 문항이 갈라지는가 — 갈라지지 않으면 null.
///
/// <b>왜 되묻는가.</b> 레거시 RadioButton 은 WinForms 를 그대로 상속해
/// <c>GroupName</c> 이 없고 <b>직계 부모가 곧 배타 단위</b>다(RadioButton.vb:7-8).
/// 그래서 패널로 묶는 것 자체는 정상이고 필요한 작업이지만,
/// 같은 자리에 있던 라디오 중 <b>일부만</b> 묶으면 한 문항이 두 묶음으로 갈라져
/// EMR 에서 답이 둘 선택된다 — 캔버스에는 아무 변화도 안 보인 채로.
/// </summary>
internal string? RadioSplitWarning(PageViewModel page, IReadOnlyList<ControlViewModel> 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; private PageViewModel? PageOf2(int index) => index >= 0 && index < Pages.Count ? Pages[index] : null;
/// <summary> /// <summary>