여러 개를 골라 공통 속성을 한 번에 바꾼다

커밋 규약은 그대로다 — BindRow 는 이미 다중 대상이었고(스냅샷 1회 → 선택 전체 순회)
바뀐 것은 "어떤 행을 만드는가"뿐이다. 새 커밋 경로를 만들지 않았다.

## 막혀 있던 곳

타입이 하나라도 다르면 타입 전용 섹션이 통째로 사라졌다(RebuildCore 의 items.All(i => i.Type == type)).
그런데 여러 개를 함께 고쳐야 하는 속성 — 읽기 전용·인쇄 출력·필수 입력·재조회 — 이 정확히
거기 있었고, 없는 이유를 알려 주는 표시도 없어 "이 도구로는 안 되는 일"로 보였다.

더 나쁜 것은 <b>탭</b>이었다. 탭 가시성을 행을 만들기 전에 descriptor 하나로 정하는 바람에
타입이 섞이면 데이터·동작 탭이 접히고 selectedTab 이 디자인으로 강제 복귀했다.
공통 섹션을 완벽하게 만들어도 그 뒤에 갇힌다 — 순서를 바꾸지 않으면 어떤 병합 규칙으로도 못 고친다.
그래서 '① 공통 속성 계산 → ② 탭 → ③ 섹션' 으로 재배치했다.

## 무엇을 공통으로 보는가 — BatchEditPolicy

키 이름이 같은 것으로는 부족하다. Editor·Choices(원소와 순서)·Default 까지 같아야 한 칸에 묶는다.
이 규칙 하나가 예외 코드 없이 함정들을 잡는다.

- 정렬(TextAlign): 라벨은 ContentAlignment 9값, 텍스트박스는 HorizontalAlignment 3값이다.
  이름만 보고 묶으면 "MiddleCenter" 가 텍스트박스에 들어가고 레거시는 그것을 읽지 못한다.
- 테두리(BorderStyle): 기본값이 Fixed3D 대 None 이다. 기본값이 갈리면 키가 없는 대상이
  <b>남의 기본값</b>으로 보이고, 갈렸는지 판정부터 틀어진다.
- 표시(visible/Visible): 라벨만 소문자다(레거시가 그림자 속성을 쓴다). 키가 달라 자연히 탈락한다.
  <b>별칭으로 묶지 않았다</b> — 묶으면 라벨에 대문자 키가 생겨 EMR 에서는 사라지는데
  우리 미리보기는 계속 보여 준다.
- 스키마를 모르는 타입(표 등)이 섞이면 빈 집합이다. 무엇이 공통인지 말할 근거가 없다.

그 위에 대상 집합을 보는 차단을 건다. 근거 대부분이 키 이름이 아니라 <b>누가 선택됐는가</b>에 있다.

- 유일해야 하는 것: 이름·서명 슬롯·탭 순서·링크 대상. 서명 슬롯이 같으면 전자동의서가
  보호자 칸에 환자 서명을 찍는다 — 종이에는 서명이 다 있어 아무도 의심하지 않는다.
- 남을 이름으로 가리키는 배선: 액션 대상 컨트롤·합산식·이벤트 매핑. 같은 값을 넣으면
  5번 문항을 누를 때 1번 문항이 비워지는 식으로 <b>다른 곳</b>이 깨진다.
- 되돌릴 수 없는 것: 일련번호 발급은 서식을 여는 것만으로 DB 카운터를 올린다.
  스냅샷으로 덮이지 않는 유일한 부류라 커밋 자체를 막는다.
- 같은 부모의 라디오 Checked: 레거시 라디오는 GroupName 이 없어 직계 부모가 문항인데,
  로더가 자식을 부모에 붙이기 <b>전에</b> 값을 대입해 형제가 꺼지지 않는다.
  한 문항에 답이 둘 켜진 채로 저장되고 그대로 열린다. 부모를 모르면 막는 쪽으로 뒀다.
- 선의 방향·굵기: 전용 편집기는 컨트롤마다 크기까지 함께 고치므로 허용하고,
  고급(원문) 경로만 막는다 — 거기서는 속성만 바뀌어 XML 은 Vertical 인데 경계는 가로인 선이 된다.

이름 행은 items.Count == 1 을 <b>유지</b>했다. 푸는 것이 목표처럼 보였지만 반대다 —
풀면 두 번째 대상부터 이름이 충돌하는데, 그 거절이 모달을 띄우고도 커밋 루프를 멈추지 못한다.

## 고급(원문) 섹션을 다중 선택에도 열되, 판정을 대상 전체로

전에는 행의 종류·편집 여부·키 목록을 items[0] 하나로 정하면서 커밋은 선택 <b>전체</b>에 썼다.
그래서 이미지·중첩·참조 값을 지키던 읽기 전용 방어가 "처음 클릭한 컨트롤"에만 걸렸고,
클릭 순서에 따라 같은 조작이 안전해지거나 파괴적이 됐다.
게다가 GetText 가 문자열 아닌 값에 null 을 주므로 빈 값과 이미지 원문이 둘 다 "" 로 접혀
<b>갈렸다는 표시조차 나오지 않았다</b> — 경고 없이 base64 가 문자열로 교체된다.

이제 전부 갖고 전부 문자열일 때만 편집을 연다. 아니면 이유를 밝힌 읽기 전용 행이다
("3개 중 1개에만 있음", "대상마다 값의 형태가 다릅니다"). 없던 대상에 키를 만들면
레거시 서식생성기가 열 때마다 오류 모달을 띄우므로 만들지 않는다.
'속성 추가'는 단일 선택만 — 중복 검사·쓰기가 전부 대상 하나 기준이라 N개 중 1개에만 키가 생긴다.

## 함께 고친 것 — 다중 편집을 넓히기 전에 막아야 했던 것들

**갈린 토글을 한 번 누르면 꺼졌다.** 실측으로 확인했다(진짜 ToggleButton + 진짜 바인딩):
누르기 전 IsChecked=null → 커밋 "False" → 누른 뒤 False. 즉 굵게를 <b>켜려고</b> 누른 한 번이
선택 전체의 굵게를 껐다. WPF 는 불확정에서 IsThreeState 와 무관하게 false 로 간다.
게다가 세그먼트 토글에는 갈림 표시가 없어 '꺼짐'과 픽셀 단위로 같았다 — 무엇이 일어났는지
볼 수도 없었다. 갈린 상태의 클릭을 '켜기'로 읽고, {x:Null} 표시를 붙였다.

**갈린 값이 '없음'으로 보였다.** 쿼리·마스크·배선 행은 빈 칸이 아니라 "(쿼리 없음)" 같은
<b>단정</b>을 그린다. 서로 다른 쿼리를 든 둘을 골랐을 때 "쿼리 없음"이 나오면 빈 칸보다 나쁘다 —
없다고 믿고 새로 쓰면 양쪽 원본이 한꺼번에 사라진다. "(여러 값)" 으로 바꿨다.
정렬 격자도 갈리면 9칸이 전부 꺼져 '아직 안 고름'과 같았다 — 격자 오른쪽에 표식을 뒀다.

**안 바뀌었는데 문서가 '수정됨'이 됐다.** 갈린 숫자 칸에 "100px" 을 넣으면 스냅샷이 먼저
쌓이고 대상마다 파싱에 실패해 아무것도 안 바뀌었다. 그걸 지우려 누르는 Ctrl+Z 가 다음 문제를 밟는다.
RowBinding.Validate 를 두어 커밋 전에 거르고 칸을 되돌린다. 값이 이미 전부 같으면 스냅샷도 안 찍는다.

**Undo 한 번에 선택이 증발했다.** 문서 교체 후 재구성의 첫 줄이 Selection.Clear() 라,
'여럿 고르기 → 바꾸기 → 확인 → Ctrl+Z → 다시'라는 이 기능의 유일한 작업 흐름이
첫 되돌리기에서 끊겼다. Id 로 다시 찾아 선택을 복원한다.

**텍스트·항목 목록은 확인을 받는다.** 대상마다 다르던 고유값이 한 번에 사라지는 편집이다.
좌표·색·인쇄여부에는 붙이지 않았다 — 확인을 남발하면 정작 위험한 것도 습관적으로 넘긴다.

## 재현하지 못한 것

조사에서 P0로 지목된 ComboBox 코어스 되쓰기(목록에 없는 값을 null 로 바꿔 소스에 되써서
세로선을 회전시킨다)는 <b>재현되지 않았다</b>. 목록 밖 값을 넣은 양성 대조에서도 되쓰기가
관측되지 않아, 이 진단은 그 경로에 둔감하다. 그래서 '되쓰기가 없다'를 주장하지 않는다.
대신 갈렸을 때 빈 문자열 자리를 목록에 만들어(EnsureMixedPlaceholder) 코어스가 성립할
조건 자체를 없앴다 — 덤으로 빈 칸이 "여러 값"으로 보인다.

## 판정

- 단위 시험 388 (BatchEditPolicy 20건 추가)
- edit-smoke 339건 전건 통과 (일괄 편집 12건 · 혼합 표시 4건 추가)
- --db-render P062 md5 8d683835f5d81e7bb41c79071d6bf954 불변
- --db-patient "김" · --dialog-shots · --cleartype · --db-filecfg 전건 통과

## 남는 것

- 혼합 상태에서 '선택 전체를 빈 값으로' 는 못 한다 — ""→"" 가 커밋되지 않아서다.
  하려면 커밋을 우회하는 새 경로가 필요하고 그건 별건이다.
- 갈린 숫자 칸의 ↑/↓ 는 무동작이다(빈칸을 0으로 뭉개지 않으려는 기존 방어).
  다중 선택은 좌표가 갈리는 것이 기본에 가까워 자주 보이지만 이번 범위 밖으로 뒀다.
- 라벨의 대문자 Visible 을 PrintFilter 가 레거시와 다르게 읽는 문제는 <b>지금도</b> 있다.
  이번 설계는 병합에서 라벨을 빼 새 사례를 만들지 않을 뿐, 기존 어긋남은 고치지 않았다.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Msystech
2026-08-20 16:24:16 +09:00
co-authored by Claude Fable 5
parent 8cc2d68ac1
commit d45b674e29
8 changed files with 1106 additions and 30 deletions
@@ -0,0 +1,237 @@
using Microsoft.VisualStudio.TestTools.UnitTesting;
using SheetMe.Core.Catalog;
using SheetMe.Core.Models;
namespace SheetMe.Core.Tests;
/// <summary>
/// 여러 컨트롤을 함께 고쳤을 때 <b>레거시가 못 여는 XML</b>이 나오지 않는지 고정한다.
///
/// 여기서 막는 것들은 전부 화면으로 확인할 수 없는 손상이다 —
/// 디자이너는 멀쩡해 보이고, EMR 에서만 다르게 나오거나 서식이 아예 안 열린다.
/// </summary>
[TestClass]
public class BatchEditPolicyTests
{
#region Helpers
private static ControlElement Element(string type, string id = "c")
=> new() { Id = id, Type = type };
private static IReadOnlyList<string> KeysOf(IReadOnlyList<string> types)
=> BatchEditPolicy.MergeCommonDefs(types).Select(d => d.Key).ToList();
#endregion
#region Tests -
[TestMethod]
public void _하나면_그_타입의_속성을_그대로_준다()
{
var label = ControlRegistry.Find("Label");
Assert.IsNotNull(label);
CollectionAssert.AreEqual(
label.Properties.Select(d => d.Key).ToList(),
KeysOf(new[] { "Label" }).ToList());
}
[TestMethod]
public void _선택지가_달라_공통에_오지_않는다()
{
// 라벨은 ContentAlignment 9값(TopLeft…), 텍스트박스는 HorizontalAlignment 3값(Left/Center/Right).
// 키 이름만 비교하는 구현으로 되돌아가면 "MiddleCenter" 가 텍스트박스에 들어가고,
// 그 값은 레거시가 읽지 못한다.
CollectionAssert.DoesNotContain(KeysOf(new[] { "Label", "TextBox" }).ToList(), "TextAlign");
}
[TestMethod]
public void _키는_대소문자로_갈린다()
{
// 라벨만 소문자 visible 이다(레거시가 그림자 속성을 쓴다). 별칭으로 묶으면
// 라벨에 대문자 키가 생겨 EMR 에서는 사라지는데 우리 미리보기는 계속 보여 준다.
var mixed = KeysOf(new[] { "Label", "TextBox" }).ToList();
CollectionAssert.DoesNotContain(mixed, "visible");
CollectionAssert.DoesNotContain(mixed, "Visible");
}
[TestMethod]
public void _모르는_타입이_섞이면_공통이_없다()
{
// 무엇이 공통인지 말할 근거가 없다 — 추측해서 열면 그게 손상 경로가 된다
Assert.AreEqual(0, KeysOf(new[] { "Label", "Spread" }).Count);
}
[TestMethod]
public void _정의를_가진_공통_속성은_병합된다()
{
// 공통 7종은 `=> new()` 식 본문 프로퍼티라 접근할 때마다 새 인스턴스다.
// def 참조를 비교하는 구현이면 전부 '다르다'가 나와 이 검사가 깨진다.
CollectionAssert.Contains(KeysOf(new[] { "Label", "TextBox" }).ToList(), "PrintOutPut");
}
[TestMethod]
public void _없으면_빈_집합이다()
{
Assert.AreEqual(0, BatchEditPolicy.MergeCommonDefs(Array.Empty<string>()).Count);
}
[TestMethod]
public void _못_올린_것의_개수를_센다()
{
// 조용히 빼면 사용자는 그 속성이 존재하지 않는다고 읽는다 — 개수를 알려 줘야 한다
Assert.IsTrue(BatchEditPolicy.ExcludedCount(new[] { "Label", "TextBox" }) > 0);
Assert.AreEqual(0, BatchEditPolicy.ExcludedCount(new[] { "Label" }));
}
#endregion
#region Tests -
[TestMethod]
public void _하나면_아무것도_막지_않는다()
{
// 이 기능이 기존 단일 선택 동작을 좁히면 안 된다
var one = new[] { Element("Label") };
foreach (var key in new[] { "Name", "SignatureIndex", "Formula", "SequenceCode", "TextAlign" })
{
Assert.IsTrue(BatchEditPolicy.IsBatchable(key, one).Allowed, key);
}
}
[TestMethod]
public void _하는_키는_함께_못_바꾼다()
{
var two = new[] { Element("Label", "a"), Element("Label", "b") };
foreach (var key in new[] { "Name", "SignatureIndex", "DisplaySequence", "TabIndex", "LinkToNewDesign" })
{
Assert.IsFalse(BatchEditPolicy.IsBatchable(key, two).Allowed, key);
}
}
[TestMethod]
public void _이름으로_가리키는_배선은_함께_못_바꾼다()
{
var two = new[] { Element("RadioButton", "a"), Element("RadioButton", "b") };
foreach (var key in new[] { "Formula", "DataActionTagControl", "AssociatedControl", "EventHandlerMappingTag" })
{
Assert.IsFalse(BatchEditPolicy.IsBatchable(key, two).Allowed, key);
}
// 액션 태그 <b>자체</b>는 정상 작업이다 — 같은 문항의 라디오에 같은 액션이 붙는다
Assert.IsTrue(BatchEditPolicy.IsBatchable("DataActionTag", two).Allowed);
}
[TestMethod]
public void _발급은_함께_못_바꾼다()
{
// 서식을 여는 것만으로 DB 카운터가 오른다 — Undo 로 덮이지 않는 유일한 부류다
var two = new[] { Element("Label", "a"), Element("Label", "b") };
foreach (var key in new[] { "SequenceCode", "SequenceType", "SequenceNumber", "CheckSequenceAtSaving" })
{
Assert.IsFalse(BatchEditPolicy.IsBatchable(key, two).Allowed, key);
}
}
[TestMethod]
public void _타입이_섞였을_때만_막는다()
{
var sameType = new[] { Element("TextBox", "a"), Element("TextBox", "b") };
var mixedType = new[] { Element("Label", "a"), Element("TextBox", "b") };
Assert.IsTrue(BatchEditPolicy.IsBatchable("TextAlign", sameType).Allowed);
Assert.IsFalse(BatchEditPolicy.IsBatchable("TextAlign", mixedType).Allowed);
}
[TestMethod]
public void _방향은_원문_경로에서만_막는다()
{
// 전용 편집기는 컨트롤마다 크기까지 함께 고치므로(ApplyLineChange) 다중 선택에서도 옳다.
// 원문 텍스트 행은 속성만 써서 XML 은 Vertical 인데 경계는 가로인 선을 만든다.
var lines = new[] { Element("Line", "a"), Element("Line", "b") };
Assert.IsTrue(BatchEditPolicy.IsBatchable("Orientation", lines).Allowed);
Assert.IsFalse(BatchEditPolicy.IsBatchable("Orientation", lines, viaAdvancedRaw: true).Allowed);
Assert.IsFalse(BatchEditPolicy.IsBatchable("BorderWidth", lines, viaAdvancedRaw: true).Allowed);
}
[TestMethod]
public void _묶음의_라디오는_기본_선택을_함께_못_바꾼다()
{
// 레거시 라디오는 GroupName 이 없어 직계 부모가 문항이다. 둘 다 켜면 한 문항의 답이 둘이 되고,
// 로더가 부모에 붙이기 전에 값을 대입하므로 열 때 정리되지도 않는다.
var parent = Element("Panel", "p");
var first = Element("RadioButton", "r1");
var second = Element("RadioButton", "r2");
var targets = new[] { first, second };
Assert.IsFalse(BatchEditPolicy.IsBatchable("Checked", targets, parentOf: _ => parent).Allowed);
}
[TestMethod]
public void _다른_라디오는_함께_바꿀_수_있다()
{
var left = Element("Panel", "p1");
var right = Element("Panel", "p2");
var first = Element("RadioButton", "r1");
var second = Element("RadioButton", "r2");
var allowed = BatchEditPolicy.IsBatchable("Checked", new[] { first, second },
parentOf: element => ReferenceEquals(element, first) ? left : right).Allowed;
Assert.IsTrue(allowed);
}
[TestMethod]
public void _배타가_없어_함께_바꿀_수_있다()
{
var parent = Element("Panel", "p");
var targets = new[] { Element("CheckBox", "c1"), Element("CheckBox", "c2") };
Assert.IsTrue(BatchEditPolicy.IsBatchable("Checked", targets, parentOf: _ => parent).Allowed);
}
[TestMethod]
public void _모르면_라디오를_막는다()
{
// 모르면 막는 쪽이다 — 여기서 틀리면 한 문항에 답이 둘 켜진 서식이 나가고
// 그것은 화면으로 확인할 수 없다
var targets = new[] { Element("RadioButton", "r1"), Element("RadioButton", "r2") };
Assert.IsFalse(BatchEditPolicy.IsBatchable("Checked", targets).Allowed);
}
#endregion
#region Tests - ()
[TestMethod]
public void _갖고_전부_문자열일_때만_원문을_연다()
{
var first = Element("Label", "a");
var second = Element("Label", "b");
first.Props.SetText("Tooltip", "가");
second.Props.SetText("Tooltip", "나");
Assert.IsTrue(BatchEditPolicy.AllShareTextShape("Tooltip", new[] { first, second }));
}
[TestMethod]
public void _형태가_다르면_원문을_열지_않는다()
{
// 이미지 원문(BinaryValue)을 문자열로 덮으면 복구할 수 없다.
// GetText 가 문자열 아닌 값에 null 을 주므로 빈 값과 이미지가 둘 다 "" 로 접혀
// <b>갈렸다는 표시조차</b> 안 나온다 — 그래서 형태로 판정한다.
var text = Element("PictureBox", "a");
var binary = Element("PictureBox", "b");
text.Props.SetText("Image", string.Empty);
binary.Props.Set("Image", new LegacyPropValue.BinaryValue { Base64 = "AQID" });
// 어느 쪽을 먼저 골랐든 결과가 같아야 한다 — 클릭 순서로 안전성이 갈리면 안 된다
Assert.IsFalse(BatchEditPolicy.AllShareTextShape("Image", new[] { text, binary }));
Assert.IsFalse(BatchEditPolicy.AllShareTextShape("Image", new[] { binary, text }));
}
[TestMethod]
public void _가진_키는_원문을_열지_않는다()
{
// 없던 대상에 키가 생기면 레거시 서식생성기가 열 때마다 오류 모달을 띄운다
var has = Element("Label", "a");
var lacks = Element("Label", "b");
has.Props.SetText("Score", "5");
Assert.IsFalse(BatchEditPolicy.AllShareTextShape("Score", new[] { has, lacks }));
Assert.AreEqual(1, BatchEditPolicy.CountHaving("Score", new[] { has, lacks }));
}
#endregion
}