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;
///