Files
SheetMe/tests/SheetMe.Core.Tests/PreviewCheckStateTests.cs
MsystechandClaude Fable 5 8cc2d68ac1 미리보기를 눌러 볼 수 있게 하고, 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>
2026-08-20 15:07:51 +09:00

165 lines
5.4 KiB
C#

using Microsoft.VisualStudio.TestTools.UnitTesting;
using SheetMe.Core.Catalog;
using SheetMe.Core.Models;
namespace SheetMe.Core.Tests;
/// <summary>
/// 미리보기 클릭 — 라디오 배타가 <b>직계 부모 안에서만</b> 일어나는지가 핵심이다.
/// 레거시 RadioButton 은 GroupName 이 없어 부모 컨테이너가 문항 단위이고,
/// 운영 서식의 라디오 98.96% 가 그 형태다.
/// </summary>
[TestClass]
public class PreviewCheckStateTests
{
#region Helpers
private static ControlElement Radio(string id, bool checkedOn = false)
{
var element = new ControlElement { Id = id, Type = "RadioButton" };
element.Props.SetText("Checked", checkedOn ? "True" : "False");
return element;
}
private static ControlElement Check(string id, bool checkedOn = false)
{
var element = new ControlElement { Id = id, Type = "CheckBox" };
element.Props.SetText("Checked", checkedOn ? "True" : "False");
return element;
}
#endregion
#region Tests
[TestMethod]
public void 누르지_않았으면_서식의_기본값을_그대로_읽는다()
{
var state = new PreviewCheckState();
Assert.IsTrue(state.IsChecked(Radio("a", checkedOn: true)));
Assert.IsFalse(state.IsChecked(Radio("b")));
Assert.IsFalse(state.HasAny);
}
[TestMethod]
public void 라디오를_누르면_같은_부모의_다른_라디오가_꺼진다()
{
var first = Radio("a", checkedOn: true);
var second = Radio("b");
var third = Radio("c");
var siblings = new[] { first, second, third };
var state = new PreviewCheckState();
state.Click(second, siblings);
Assert.IsFalse(state.IsChecked(first));
Assert.IsTrue(state.IsChecked(second));
Assert.IsFalse(state.IsChecked(third));
}
[TestMethod]
public void 다른_부모의_라디오는_영향을_받지_않는다()
{
// 패널 1개 = 문항 1개. 옆 문항의 답이 지워지면 그 서식은 못 쓴다.
var mine = Radio("a");
var neighbour = Radio("z", checkedOn: true);
var state = new PreviewCheckState();
state.Click(mine, new[] { mine, Radio("b") });
Assert.IsTrue(state.IsChecked(neighbour));
}
[TestMethod]
public void 라디오는_다시_눌러도_꺼지지_않는다()
{
// WinForms 라디오는 자기를 눌러 해제하는 동작이 없다 — 끄는 것은 형제를 누르는 것뿐이다.
var radio = Radio("a");
var state = new PreviewCheckState();
state.Click(radio, new[] { radio });
state.Click(radio, new[] { radio });
Assert.IsTrue(state.IsChecked(radio));
}
[TestMethod]
public void 체크박스는_누를_때마다_반전한다()
{
var box = Check("a");
var state = new PreviewCheckState();
state.Click(box, new[] { box });
Assert.IsTrue(state.IsChecked(box));
state.Click(box, new[] { box });
Assert.IsFalse(state.IsChecked(box));
}
[TestMethod]
public void 체크박스는_형제_라디오를_끄지_않는다()
{
// 한 패널에 체크박스와 라디오가 섞인 서식이 있다(기타 항목 + 자유 입력).
var radio = Radio("r", checkedOn: true);
var box = Check("c");
var state = new PreviewCheckState();
state.Click(box, new[] { radio, box });
Assert.IsTrue(state.IsChecked(radio));
Assert.IsTrue(state.IsChecked(box));
}
[TestMethod]
public void 기본값이_체크인_체크박스를_누르면_풀린다()
{
var box = Check("a", checkedOn: true);
var state = new PreviewCheckState();
state.Click(box, new[] { box });
Assert.IsFalse(state.IsChecked(box));
}
[TestMethod]
public void 읽기전용은_눌리지_않는다()
{
// 레거시는 PreventEditing=True 면 OnClick 을 무시한다 — 값 대입만 가능하다.
var locked = Radio("a");
locked.Props.SetText("PreventEditing", "True");
var mate = Radio("b", checkedOn: true);
var state = new PreviewCheckState();
state.Click(locked, new[] { locked, mate });
Assert.IsFalse(PreviewCheckState.IsClickable(locked));
Assert.IsFalse(state.IsChecked(locked));
Assert.IsTrue(state.IsChecked(mate));
Assert.IsFalse(state.HasAny);
}
[TestMethod]
public void 누를_수_있는_타입은_체크류뿐이다()
{
Assert.IsTrue(PreviewCheckState.IsClickable(Radio("a")));
Assert.IsTrue(PreviewCheckState.IsClickable(Check("b")));
Assert.IsFalse(PreviewCheckState.IsClickable(new ControlElement { Id = "c", Type = "Label" }));
Assert.IsFalse(PreviewCheckState.IsClickable(new ControlElement { Id = "d", Type = "TextBox" }));
Assert.IsFalse(PreviewCheckState.IsClickable(new ControlElement { Id = "e", Type = "Panel" }));
}
[TestMethod]
public void 초기화하면_서식의_기본값으로_돌아간다()
{
var first = Radio("a", checkedOn: true);
var second = Radio("b");
var state = new PreviewCheckState();
state.Click(second, new[] { first, second });
state.Clear();
Assert.IsFalse(state.HasAny);
Assert.IsTrue(state.IsChecked(first));
Assert.IsFalse(state.IsChecked(second));
}
#endregion
}