미리보기를 눌러 볼 수 있게 하고, Ctrl+드래그를 복사로 바꾼다

사용자 지적 두 건. 둘 다 라디오 배타 그룹이 걸린 문제였다.

## 미리보기 클릭이 안 먹었다

레거시 미리보기(TK_PREVIEW)는 정적 그림이 아니라 런타임 모드라 눌러 보며 확인한다.
우리 미리보기는 그림이었다 — 그래서 <b>라디오가 배타로 묶였는지 확인할 방법이 없었다</b>.
패널로 묶었다고 믿고 저장했다가 임상 화면에서 두 개가 동시에 켜지는 것이
이 서식에서 가장 흔한 사고인데, 저장 전에 그걸 볼 수단이 없었던 것이다.

PreviewCheckState — 컨트롤 Id → 체크 상태 사전. 문서는 건드리지 않는다.
확인하려고 누른 것이 서식 수정이 되면 미리보기를 열었다 닫은 것만으로
저장할 내용이 생긴다. 렌더 시점(WithTagValue)에만 덮고, 창을 닫으면 사라진다.

배타는 직계 부모 안에서만. 레거시 RadioButton 은 WinForms 를 그대로 상속해
GroupName 이 없고(RadioButton.vb:7-8) 배타 판정을 부모 컨테이너에 맡긴다.
운영 실측도 라디오 15,184개 중 15,026개(98.96%)가 패널 직계 자식 — 패널 1개 = 문항 1개다.

- PreventEditing=True 는 안 눌린다(RadioButton.vb:271-279 — 값 대입만 되고 클릭은 무시).
  Locked 는 편집 보호일 뿐이라 눌린다.
- 라디오는 다시 눌러도 안 꺼진다. 끄는 것은 형제를 누르는 것뿐이다.
- 손으로 누른 값이 태그 값을 이긴다 — 그 칸을 직접 눌러 보는 중이므로 사용자의 손이 마지막이다.
- 환자를 바꾸거나 서식을 갈아타면 지운다. 안 지우면 손으로 누른 값이
  새 환자의 실제 값을 가려, 확인하려고 만든 창이 확인을 막는다.
- 이 창에서 인쇄하면 화면과 같은 상태로 나간다(같은 checks 를 Print 까지 넘긴다).

히트테스트는 WPF 가 아니라 모델 트리로 한다. 종이 비주얼은 템플릿이 만든 그림이라
컨트롤 하나가 여러 요소로 쪼개져 있고 일부는 히트테스트에서 빠져 있다.
캔버스의 HitTestControl 과 두 군데만 다르다 — Locked 를 보지 않고,
그려지지 않은 것(숨김·데이터소스·인쇄 제외)을 뺀다.

## Ctrl+드래그가 복사가 아니었다

예전에는 '선택에 더하고 이동'이었다(Ctrl 이 선택 추가 수정자였으니까).
그래서 같은 컨트롤을 여러 개 놓는 방법이 복사·붙여넣기뿐이었는데,
붙여넣기는 <b>항상 최상위 +12,+12</b> 에 떨어진다.
패널 안 라디오를 그렇게 복사하면 사본이 패널 밖으로 나가 배타 그룹이 깨진다 —
눈으로는 안 보이고 임상 화면에서만 드러나는 고장이다.

DuplicateSelectionInPlace — 제자리 복제. 부모를 유지하므로 사본이 같은 문항 안에 남는다.
드래그 문턱을 넘은 지점에서 복제한다(누름 시점에 하면 Ctrl+클릭만으로도 사본이 생긴다).
복제가 스냅샷을 이미 찍었으므로 이어지는 이동은 추가로 찍지 않는다 —
안 그러면 "복사 후 이동"이 Undo 두 번이 되고, 한 번만 누른 사용자는
원본 자리에 사본이 겹쳐 남은 상태를 본다.

Ctrl 은 이 제스처에서 더하기가 아니다. 합집합으로 두면 아까 선택해 둔 컨트롤까지
함께 복제돼 하나 집어 끌었는데 화면 다른 곳에 사본이 여럿 생긴다. Shift 는 그대로 더하기다.

선택 단위가 패널이므로 자식을 그냥 집으면 <b>문항이 통째로</b> 복사되고,
더블클릭으로 들어간 뒤 집으면 <b>그 선택지만</b> 복사돼 같은 패널에 남는다.
둘 다 필요한 동작이라 둘 다 시험한다.

## 판정

- 단위 시험 368 (PreviewCheckState 10건 추가)
- edit-smoke 320건 전건 통과 (Ctrl+드래그 복사 9건, 미리보기 클릭 5건 추가)
  · 20-5f 의 옛 단언 2건은 의미가 바뀌어 교체했다("합집합으로 함께 움직인다" → "복사된다")
  · 20-5c(빈 선택 Ctrl+드래그)가 남기는 사본을 되돌린다 — 안 하면 뒤따르는 개수 검사가 흔들린다
- --db-render P062 md5 8d683835f5d81e7bb41c79071d6bf954 불변
- --db-patient "김" 39건 · --dialog-shots · --cleartype · --db-filecfg 전건 통과

시험을 처음 쓸 때 자식을 Children[0]/[1] 로 집었는데 자식 VM 은 z 순서로 온다.
이미 켜진 라디오를 누르는 시험이 되어 "문서가 바뀌었다"는 거짓 실패가 났다 — Id 로 집는다.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Msystech
2026-08-20 15:07:51 +09:00
co-authored by Claude Fable 5
parent d965b3f217
commit 8cc2d68ac1
8 changed files with 686 additions and 20 deletions
@@ -1413,6 +1413,71 @@ public sealed class DesignerViewModel : ViewModelBase
RebuildLayerRows();
}
/// <summary>
/// 선택분을 <b>제자리</b>에 복제하고 복제본을 선택한다 — Ctrl+드래그 복사가 쓴다.
///
/// <b>붙여넣기와 다른 점</b>: 오프셋을 주지 않고, 원래 부모(컨테이너 자식이면 그 컨테이너)
/// 안에 넣는다. 그래서 라디오를 Ctrl+드래그로 복사하면 <b>같은 패널 안</b>에 남아
/// 같은 문항의 선택지가 하나 늘어난다 — 패널 밖으로 튀어나가면 배타 묶음이 갈라진다.
///
/// 표(Spread)는 복제하지 않는다(격자가 서식코드+이름으로 별도 테이블에서 오므로
/// 복제본에는 격자가 없다 — <see cref="FilterPastable"/> 와 같은 이유).
/// 복제에 실패하면(전부 표였다) false — 호출부가 이동으로 되돌린다.
/// </summary>
public bool DuplicateSelectionInPlace()
{
var sources = Selection.Items.ToList();
if (sources.Count == 0)
{
return false;
}
var (pastable, _) = FilterPastable(sources.Select(vm => vm.Model).ToList());
if (pastable.Count == 0)
{
return false;
}
Undo.Snapshot();
var usedIds = IdGenerator.CollectUsed(document);
var groupRemap = new Dictionary<string, string>(StringComparer.Ordinal);
var copies = new List<ControlViewModel>();
foreach (var vm in sources)
{
if (!pastable.Contains(vm.Model))
{
continue;
}
var page = PageOf(vm);
if (page is null)
{
continue;
}
var clone = vm.Model.Clone();
AssignNewIds(clone, usedIds);
RemapGroupId(clone, groupRemap);
if (vm.Parent is ContainerViewModel parent)
{
// 컨테이너 자식 — 같은 부모 안, 같은 상대좌표에 둔다
parent.Model.Children.Insert(0, clone);
var childVm = DocumentMapper.CreateControl(clone, parent.EffectiveFont, parent.Foreground);
childVm.Parent = parent;
parent.Children.Add(childVm);
copies.Add(childVm);
}
else
{
page.Model.Controls.Insert(0, clone);
copies.Add(CreateAndAttach(clone, page, attachToEnd: true));
}
}
if (copies.Count == 0)
{
return false;
}
Selection.Set(copies, copies.LastOrDefault());
RebuildLayerRows();
return true;
}
/// <summary>복제본의 그룹 식별자를 새 값으로 치환 — 같은 붙여넣기 안에서는 같은 그룹을 유지한다</summary>
private void RemapGroupId(ControlElement element, Dictionary<string, string> remap)
{