From d45b674e29a5756c5a54fff950adf57965c150e7 Mon Sep 17 00:00:00 2001 From: Msystech Date: Thu, 20 Aug 2026 16:24:16 +0900 Subject: [PATCH] =?UTF-8?q?=EC=97=AC=EB=9F=AC=20=EA=B0=9C=EB=A5=BC=20?= =?UTF-8?q?=EA=B3=A8=EB=9D=BC=20=EA=B3=B5=ED=86=B5=20=EC=86=8D=EC=84=B1?= =?UTF-8?q?=EC=9D=84=20=ED=95=9C=20=EB=B2=88=EC=97=90=20=EB=B0=94=EA=BE=BC?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 커밋 규약은 그대로다 — BindRow 는 이미 다중 대상이었고(스냅샷 1회 → 선택 전체 순회) 바뀐 것은 "어떤 행을 만드는가"뿐이다. 새 커밋 경로를 만들지 않았다. ## 막혀 있던 곳 타입이 하나라도 다르면 타입 전용 섹션이 통째로 사라졌다(RebuildCore 의 items.All(i => i.Type == type)). 그런데 여러 개를 함께 고쳐야 하는 속성 — 읽기 전용·인쇄 출력·필수 입력·재조회 — 이 정확히 거기 있었고, 없는 이유를 알려 주는 표시도 없어 "이 도구로는 안 되는 일"로 보였다. 더 나쁜 것은 이었다. 탭 가시성을 행을 만들기 전에 descriptor 하나로 정하는 바람에 타입이 섞이면 데이터·동작 탭이 접히고 selectedTab 이 디자인으로 강제 복귀했다. 공통 섹션을 완벽하게 만들어도 그 뒤에 갇힌다 — 순서를 바꾸지 않으면 어떤 병합 규칙으로도 못 고친다. 그래서 '① 공통 속성 계산 → ② 탭 → ③ 섹션' 으로 재배치했다. ## 무엇을 공통으로 보는가 — BatchEditPolicy 키 이름이 같은 것으로는 부족하다. Editor·Choices(원소와 순서)·Default 까지 같아야 한 칸에 묶는다. 이 규칙 하나가 예외 코드 없이 함정들을 잡는다. - 정렬(TextAlign): 라벨은 ContentAlignment 9값, 텍스트박스는 HorizontalAlignment 3값이다. 이름만 보고 묶으면 "MiddleCenter" 가 텍스트박스에 들어가고 레거시는 그것을 읽지 못한다. - 테두리(BorderStyle): 기본값이 Fixed3D 대 None 이다. 기본값이 갈리면 키가 없는 대상이 남의 기본값으로 보이고, 갈렸는지 판정부터 틀어진다. - 표시(visible/Visible): 라벨만 소문자다(레거시가 그림자 속성을 쓴다). 키가 달라 자연히 탈락한다. 별칭으로 묶지 않았다 — 묶으면 라벨에 대문자 키가 생겨 EMR 에서는 사라지는데 우리 미리보기는 계속 보여 준다. - 스키마를 모르는 타입(표 등)이 섞이면 빈 집합이다. 무엇이 공통인지 말할 근거가 없다. 그 위에 대상 집합을 보는 차단을 건다. 근거 대부분이 키 이름이 아니라 누가 선택됐는가에 있다. - 유일해야 하는 것: 이름·서명 슬롯·탭 순서·링크 대상. 서명 슬롯이 같으면 전자동의서가 보호자 칸에 환자 서명을 찍는다 — 종이에는 서명이 다 있어 아무도 의심하지 않는다. - 남을 이름으로 가리키는 배선: 액션 대상 컨트롤·합산식·이벤트 매핑. 같은 값을 넣으면 5번 문항을 누를 때 1번 문항이 비워지는 식으로 다른 곳이 깨진다. - 되돌릴 수 없는 것: 일련번호 발급은 서식을 여는 것만으로 DB 카운터를 올린다. 스냅샷으로 덮이지 않는 유일한 부류라 커밋 자체를 막는다. - 같은 부모의 라디오 Checked: 레거시 라디오는 GroupName 이 없어 직계 부모가 문항인데, 로더가 자식을 부모에 붙이기 전에 값을 대입해 형제가 꺼지지 않는다. 한 문항에 답이 둘 켜진 채로 저장되고 그대로 열린다. 부모를 모르면 막는 쪽으로 뒀다. - 선의 방향·굵기: 전용 편집기는 컨트롤마다 크기까지 함께 고치므로 허용하고, 고급(원문) 경로만 막는다 — 거기서는 속성만 바뀌어 XML 은 Vertical 인데 경계는 가로인 선이 된다. 이름 행은 items.Count == 1 을 유지했다. 푸는 것이 목표처럼 보였지만 반대다 — 풀면 두 번째 대상부터 이름이 충돌하는데, 그 거절이 모달을 띄우고도 커밋 루프를 멈추지 못한다. ## 고급(원문) 섹션을 다중 선택에도 열되, 판정을 대상 전체로 전에는 행의 종류·편집 여부·키 목록을 items[0] 하나로 정하면서 커밋은 선택 전체에 썼다. 그래서 이미지·중첩·참조 값을 지키던 읽기 전용 방어가 "처음 클릭한 컨트롤"에만 걸렸고, 클릭 순서에 따라 같은 조작이 안전해지거나 파괴적이 됐다. 게다가 GetText 가 문자열 아닌 값에 null 을 주므로 빈 값과 이미지 원문이 둘 다 "" 로 접혀 갈렸다는 표시조차 나오지 않았다 — 경고 없이 base64 가 문자열로 교체된다. 이제 전부 갖고 전부 문자열일 때만 편집을 연다. 아니면 이유를 밝힌 읽기 전용 행이다 ("3개 중 1개에만 있음", "대상마다 값의 형태가 다릅니다"). 없던 대상에 키를 만들면 레거시 서식생성기가 열 때마다 오류 모달을 띄우므로 만들지 않는다. '속성 추가'는 단일 선택만 — 중복 검사·쓰기가 전부 대상 하나 기준이라 N개 중 1개에만 키가 생긴다. ## 함께 고친 것 — 다중 편집을 넓히기 전에 막아야 했던 것들 **갈린 토글을 한 번 누르면 꺼졌다.** 실측으로 확인했다(진짜 ToggleButton + 진짜 바인딩): 누르기 전 IsChecked=null → 커밋 "False" → 누른 뒤 False. 즉 굵게를 켜려고 누른 한 번이 선택 전체의 굵게를 껐다. WPF 는 불확정에서 IsThreeState 와 무관하게 false 로 간다. 게다가 세그먼트 토글에는 갈림 표시가 없어 '꺼짐'과 픽셀 단위로 같았다 — 무엇이 일어났는지 볼 수도 없었다. 갈린 상태의 클릭을 '켜기'로 읽고, {x:Null} 표시를 붙였다. **갈린 값이 '없음'으로 보였다.** 쿼리·마스크·배선 행은 빈 칸이 아니라 "(쿼리 없음)" 같은 단정을 그린다. 서로 다른 쿼리를 든 둘을 골랐을 때 "쿼리 없음"이 나오면 빈 칸보다 나쁘다 — 없다고 믿고 새로 쓰면 양쪽 원본이 한꺼번에 사라진다. "(여러 값)" 으로 바꿨다. 정렬 격자도 갈리면 9칸이 전부 꺼져 '아직 안 고름'과 같았다 — 격자 오른쪽에 표식을 뒀다. **안 바뀌었는데 문서가 '수정됨'이 됐다.** 갈린 숫자 칸에 "100px" 을 넣으면 스냅샷이 먼저 쌓이고 대상마다 파싱에 실패해 아무것도 안 바뀌었다. 그걸 지우려 누르는 Ctrl+Z 가 다음 문제를 밟는다. RowBinding.Validate 를 두어 커밋 전에 거르고 칸을 되돌린다. 값이 이미 전부 같으면 스냅샷도 안 찍는다. **Undo 한 번에 선택이 증발했다.** 문서 교체 후 재구성의 첫 줄이 Selection.Clear() 라, '여럿 고르기 → 바꾸기 → 확인 → Ctrl+Z → 다시'라는 이 기능의 유일한 작업 흐름이 첫 되돌리기에서 끊겼다. Id 로 다시 찾아 선택을 복원한다. **텍스트·항목 목록은 확인을 받는다.** 대상마다 다르던 고유값이 한 번에 사라지는 편집이다. 좌표·색·인쇄여부에는 붙이지 않았다 — 확인을 남발하면 정작 위험한 것도 습관적으로 넘긴다. ## 재현하지 못한 것 조사에서 P0로 지목된 ComboBox 코어스 되쓰기(목록에 없는 값을 null 로 바꿔 소스에 되써서 세로선을 회전시킨다)는 재현되지 않았다. 목록 밖 값을 넣은 양성 대조에서도 되쓰기가 관측되지 않아, 이 진단은 그 경로에 둔감하다. 그래서 '되쓰기가 없다'를 주장하지 않는다. 대신 갈렸을 때 빈 문자열 자리를 목록에 만들어(EnsureMixedPlaceholder) 코어스가 성립할 조건 자체를 없앴다 — 덤으로 빈 칸이 "여러 값"으로 보인다. ## 판정 - 단위 시험 388 (BatchEditPolicy 20건 추가) - edit-smoke 339건 전건 통과 (일괄 편집 12건 · 혼합 표시 4건 추가) - --db-render P062 md5 8d683835f5d81e7bb41c79071d6bf954 불변 - --db-patient "김" · --dialog-shots · --cleartype · --db-filecfg 전건 통과 ## 남는 것 - 혼합 상태에서 '선택 전체를 빈 값으로' 는 못 한다 — ""→"" 가 커밋되지 않아서다. 하려면 커밋을 우회하는 새 경로가 필요하고 그건 별건이다. - 갈린 숫자 칸의 ↑/↓ 는 무동작이다(빈칸을 0으로 뭉개지 않으려는 기존 방어). 다중 선택은 좌표가 갈리는 것이 기본에 가까워 자주 보이지만 이번 범위 밖으로 뒀다. - 라벨의 대문자 Visible 을 PrintFilter 가 레거시와 다르게 읽는 문제는 지금도 있다. 이번 설계는 병합에서 라벨을 빼 새 사례를 만들지 않을 뿐, 기존 어긋남은 고치지 않았다. Co-Authored-By: Claude Fable 5 --- src/SheetMe.Core/Catalog/BatchEditPolicy.cs | 327 ++++++++++++++++++ .../Controls/ChoiceLabelConverter.cs | 25 ++ src/SheetMe.Designer/Diagnostics/EditSmoke.cs | 170 +++++++++ .../ViewModels/DesignerViewModel.cs | 49 ++- .../Inspector/InspectorViewModel.cs | 203 +++++++++-- .../ViewModels/Inspector/PropertyRows.cs | 80 ++++- src/SheetMe.Designer/Views/InspectorView.xaml | 45 ++- .../BatchEditPolicyTests.cs | 237 +++++++++++++ 8 files changed, 1106 insertions(+), 30 deletions(-) create mode 100644 src/SheetMe.Core/Catalog/BatchEditPolicy.cs create mode 100644 src/SheetMe.Designer/Controls/ChoiceLabelConverter.cs create mode 100644 tests/SheetMe.Core.Tests/BatchEditPolicyTests.cs diff --git a/src/SheetMe.Core/Catalog/BatchEditPolicy.cs b/src/SheetMe.Core/Catalog/BatchEditPolicy.cs new file mode 100644 index 0000000..23cd992 --- /dev/null +++ b/src/SheetMe.Core/Catalog/BatchEditPolicy.cs @@ -0,0 +1,327 @@ +using SheetMe.Core.Models; + +namespace SheetMe.Core.Catalog; + +/// +/// 여러 컨트롤을 함께 골랐을 때 무엇을 한 번에 바꿔도 되는가 — 판정 한 곳. +/// +/// 왜 순수 함수 한 곳인가. 차단 근거의 대부분이 키 이름이 아니라 대상 집합에 달려 있다 +/// (타입이 섞였는가, 같은 패널의 라디오가 둘인가, 값의 형태가 대상마다 다른가). +/// 이 판정을 UI 조건문으로 흩어 놓으면 큐레이션 경로에서 막은 것이 고급(원문) 경로로 그대로 새고, +/// 어느 쪽이 진짜 규칙인지 아무도 모르게 된다. 여기 모아 두면 DB 없이 검사할 수 있고, +/// 실사용 서식에서 반례가 0인 항목은 기대값을 0으로 고정할 수 있다. +/// +/// 기준은 레거시가 열 수 있는 XML 이다. 레거시 서식생성기는 타입에 없는 속성이나 변환 불가 값을 +/// 만나면 서식을 열 때마다 오류 모달을 띄운다. 그래서 "우리 화면에서 그럴듯한가"가 아니라 +/// "레거시가 이 XML 을 조용히 열 수 있는가"로 자른다. +/// +public static class BatchEditPolicy +{ + #region Member Fields + /// + /// 대상이 둘 이상이면 언제나 막는 키 — 값이 대상마다 달라야 의미가 있는 것들. + /// + /// 같은 값을 여럿에 복사하는 순간 뜻이 무너지는 세 부류다. + /// ① 유일해야 하는 식별자 — 이름, 서명 슬롯 번호, 탭 순서, 링크 대상. + /// ② 남을 이름으로 가리키는 배선 — 액션 대상 컨트롤, 합산식. 같은 값을 넣으면 + /// 5번 문항을 누를 때 1번 문항이 초기화되는 식으로 다른 곳이 깨진다. + /// ③ 되돌릴 수 없는 것 — 일련번호 발급은 서식을 여는 것만으로 DB 카운터를 올린다. + /// Undo 스냅샷으로 덮이지 않는 유일한 부류다. + /// + private static readonly HashSet AlwaysBlocked = new(StringComparer.Ordinal) + { + // ① 유일해야 하는 것 + "Name", + "SignatureIndex", // 전자동의서가 이 값으로 서명 슬롯을 배정한다 — 같으면 보호자 칸에 환자 서명이 간다 + "DisplaySequence", + "TabIndex", + "LinkToNewDesign", + "SMSWebControlName", + // 저장 시 writer 가 Bounds/Id 에서 다시 만들어 덮는 키 — 고쳐도 저장하면 되돌아간다 + "Location", + "Size", + "LocationOnBase", + // ② 남을 이름으로 가리키는 배선 + "DataActionTagControl", + "GetDataActionTagControl", + "GetDataActionTagControlChange", + "AssociatedControl", + "Formula", + "FormulaResultCheck", + "EventHandlerMappingTag", + // ③ 되돌릴 수 없는 것 — 일련번호 발급 + "SequenceCode", + "SequenceType", + "SequenceNumber", + "CheckSequenceAtSaving", + }; + + /// + /// 타입이 섞였을 때만 막는 키 — 같은 타입끼리는 정상 작업이다. + /// + /// TextAlign 이 대표다. 같은 이름이지만 어휘가 다르다 — 라벨·체크·라디오·버튼은 + /// ContentAlignment(TopLeft…BottomRight 9값), 텍스트박스·마스크박스는 + /// HorizontalAlignment(Left/Center/Right 3값)다. 섞어서 "MiddleCenter" 를 주면 + /// 텍스트박스 쪽에는 레거시가 읽을 수 없는 값이 들어간다. + /// + private static readonly HashSet BlockedWhenTypesDiffer = new(StringComparer.Ordinal) + { + "TextAlign", + // 태그는 타입마다 채우는 방식이 다르다(체크류는 Y/TRUE 판정, 글자류는 문자열) — + // 섞어서 같은 태그를 주면 한쪽은 값이, 다른 쪽은 체크가 되어 뜻이 갈린다 + "DataInterfaceTag", + }; + + /// + /// 고급(원문) 경로에서만 막는 키 — 큐레이션 경로는 속성과 크기를 함께 고치므로 안전하다. + /// + /// 선의 방향·굵기가 그렇다. 인스펙터의 전용 행은 컨트롤마다 LineGeometry 를 태워 + /// 각 선의 길이를 보존한 채 축을 바꾼다. 반면 원문 텍스트 행은 속성만 쓰고 크기를 그대로 두어, + /// XML 은 Vertical 인데 경계는 200×1 인 선이 된다 — EMR 은 그것을 1px 점으로 그린다. + /// + private static readonly HashSet BlockedInRawOnly = new(StringComparer.Ordinal) + { + "Orientation", + "BorderWidth", + }; + + /// + /// 타입마다 라벨이 갈리는 키의 중립 라벨 — 섞였을 때 한쪽 타입의 말을 쓰지 않는다. + /// 예를 들어 체크박스의 "기본 체크"를 라디오까지 포함한 선택에 그대로 쓰면 어색하다. + /// + private static readonly Dictionary NeutralLabels = new(StringComparer.Ordinal) + { + ["Checked"] = "기본값", + ["Text"] = "텍스트", + ["Items"] = "항목(줄바꿈 구분)", + }; + #endregion + + #region Types + /// 일괄 편집 허용 여부와, 막았다면 사람이 읽을 이유 + public readonly record struct BatchVerdict(bool Allowed, string? Reason) + { + /// 허용 + public static BatchVerdict Ok => new(true, null); + + /// 차단 — 이유는 인스펙터에 그대로 보인다 + public static BatchVerdict No(string reason) => new(false, reason); + } + #endregion + + #region Methods + /// + /// 선택된 타입들이 모두 갖는 속성 — 이것이 "공통 속성"이다. + /// + /// 키 이름만 같으면 안 된다. 편집기 종류·선택지·기본값까지 같아야 한 칸으로 묶을 수 있다. + /// 하나라도 다르면 그 칸의 값이 대상마다 다른 뜻이 되고, 사용자는 그 사실을 알 방법이 없다. + /// · TextAlign — 선택지가 9값 대 3값이라 탈락(섞으면 한쪽에 없는 값이 들어간다). + /// · BorderStyle — 기본값이 "Fixed3D" 대 "None" 이라 탈락. 기본값이 갈리면 + /// 키가 없는 대상이 남의 기본값으로 보이고, 갈렸는지 여부 판정부터 틀어진다. + /// · visible/Visible — 라벨만 소문자다(레거시가 그림자 속성을 쓴다). + /// 키 문자열이 다르므로 자연히 탈락한다. 별칭으로 묶지 않는다 — 묶으면 라벨에 + /// 대문자 키가 생겨 EMR 에서는 사라지는데 우리 미리보기는 계속 보여 준다. + /// + /// 스키마를 모르는 타입(표·격자 등 descriptor 가 없는 것)이 하나라도 섞이면 빈 집합이다. + /// 무엇이 공통인지 말할 근거가 없는데 추측해서 열면 그게 손상 경로가 된다. + /// + public static IReadOnlyList MergeCommonDefs(IReadOnlyList types) + { + if (types.Count == 0) + { + return Array.Empty(); + } + var descriptors = new List(types.Count); + foreach (var type in types) + { + if (ControlRegistry.Find(type) is not { } descriptor) + { + return Array.Empty(); + } + descriptors.Add(descriptor); + } + if (descriptors.Count == 1) + { + return descriptors[0].Properties; + } + + var merged = new List(); + // 기준은 첫 타입의 선언 순서 — 인스펙터 행 순서가 실행마다 달라지면 안 된다 + foreach (var def in descriptors[0].Properties) + { + var common = true; + var labelDiffers = false; + for (var i = 1; i < descriptors.Count && common; i++) + { + var other = descriptors[i].Properties.FirstOrDefault(p => string.Equals(p.Key, def.Key, StringComparison.Ordinal)); + if (other is null || other.Editor != def.Editor + || !SameChoices(def.Choices, other.Choices) + || !string.Equals(def.Default, other.Default, StringComparison.Ordinal)) + { + common = false; + break; + } + labelDiffers |= !string.Equals(def.Label, other.Label, StringComparison.Ordinal); + } + if (!common) + { + continue; + } + merged.Add(labelDiffers && NeutralLabels.TryGetValue(def.Key, out var neutral) + ? Relabel(def, neutral) + : def); + } + return merged; + } + + /// + /// 어느 타입엔가 있지만 공통으로 올리지 못한 키의 수 — 인스펙터가 "함께 못 바꾸는 것이 있다"고 알린다. + /// 조용히 빼면 사용자는 그 속성이 존재하지 않는다고 읽는다. + /// + public static int ExcludedCount(IReadOnlyList types) + { + if (types.Count < 2) + { + return 0; + } + var all = new HashSet(StringComparer.Ordinal); + foreach (var type in types) + { + if (ControlRegistry.Find(type) is not { } descriptor) + { + continue; + } + foreach (var def in descriptor.Properties) + { + all.Add(def.Key); + } + } + foreach (var def in MergeCommonDefs(types)) + { + all.Remove(def.Key); + } + return all.Count; + } + + /// + /// 이 키를 이 대상들에 한 번에 써도 되는가. + /// + /// PropBag 키 + /// 선택된 컨트롤 모델 + /// 고급(원문 텍스트) 경로인가 — 전용 편집기의 보정을 타지 않는다 + /// 컨트롤 → 직계 부모(최상위면 null). 라디오 배타 판정에 쓴다. + public static BatchVerdict IsBatchable(string key, IReadOnlyList targets, + bool viaAdvancedRaw = false, Func? parentOf = null) + { + // 대상이 하나면 단일 선택과 똑같이 둔다 — 이 기능이 기존 동작을 좁히면 안 된다 + if (targets.Count <= 1) + { + return BatchVerdict.Ok; + } + if (AlwaysBlocked.Contains(key)) + { + return BatchVerdict.No("대상마다 값이 달라야 하는 속성입니다"); + } + if (viaAdvancedRaw && BlockedInRawOnly.Contains(key)) + { + return BatchVerdict.No("원문으로 바꾸면 크기가 따라오지 않습니다 — 전용 편집기를 쓰세요"); + } + var types = targets.Select(t => t.Type).Distinct(StringComparer.Ordinal).Count(); + if (types > 1 && BlockedWhenTypesDiffer.Contains(key)) + { + return BatchVerdict.No("타입마다 값의 뜻이 다릅니다 — 타입별로 따로 고르세요"); + } + // 라디오의 기본 선택 — 같은 부모에 둘 이상이면 한 문항의 답이 둘이 된다. + // 레거시 라디오는 GroupName 이 없어 배타를 직계 부모에 맡기는데, 로더가 자식 속성을 + // 부모에 붙이기 전에 대입하므로 형제가 꺼지지 않는다 — 저장도 되고 그대로 열린다. + // 캔버스에는 아무 신호가 없고, 종이에는 ● 가 둘 찍힌다. + if (string.Equals(key, "Checked", StringComparison.Ordinal) && HasRadioSiblings(targets, parentOf)) + { + return BatchVerdict.No("같은 묶음의 라디오는 하나만 선택될 수 있습니다"); + } + return BatchVerdict.Ok; + } + + /// + /// 대상 전부가 이 키를 갖고, 전부 문자열 형태인가 — 고급(원문) 행을 열어도 되는 조건. + /// + /// 둘 다 필요하다. + /// · 전부 갖고 있어야 한다. 없던 대상에 키가 생기면 레거시 서식생성기가 + /// "이 타입에 없는 속성"이라며 열 때마다 모달을 띄운다. + /// · 전부 문자열이어야 한다. 이미지·중첩·참조 값은 텍스트로 바꾸는 순간 원문이 사라진다. + /// GetText 가 문자열이 아닌 값에 null 을 돌려주므로 빈 값과 이미지가 둘 다 "" 로 접혀 + /// 갈렸다는 표시조차 나오지 않는다 — 경고 없이 base64 원문이 교체된다. + /// + public static bool AllShareTextShape(string key, IReadOnlyList targets) + { + foreach (var target in targets) + { + if (!target.Props.Contains(key)) + { + return false; + } + if (target.Props.Get(key) is not (LegacyPropValue.TextValue or LegacyPropValue.NullValue)) + { + return false; + } + } + return true; + } + + /// 이 키를 가진 대상 수 — "3개 중 1개에만 있음" 안내용 + public static int CountHaving(string key, IReadOnlyList targets) + => targets.Count(t => t.Props.Contains(key)); + + /// 선택지 동일성 — 원소와 순서까지. 참조 비교로는 절대 판정할 수 없다. + private static bool SameChoices(string[]? left, string[]? right) + { + if (left is null || right is null) + { + return left is null && right is null; + } + return left.SequenceEqual(right, StringComparer.Ordinal); + } + + /// + /// 라벨만 바꾼 복제 — 는 record 가 아니라 with 를 쓸 수 없다. + /// 선택지 배열은 복사하지 않고 참조만 넘긴다(레지스트리의 공개 값이라 절대 수정하지 않는다). + /// + private static PropertyDef Relabel(PropertyDef def, string label) => new() + { + Key = def.Key, + Label = label, + Editor = def.Editor, + Choices = def.Choices, + Default = def.Default, + }; + + /// 같은 직계 부모를 공유하는 라디오가 둘 이상 들어 있는가 + private static bool HasRadioSiblings(IReadOnlyList targets, + Func? parentOf) + { + var seen = new List<(ControlElement? Parent, int Count)>(); + foreach (var target in targets) + { + if (!string.Equals(target.Type, "RadioButton", StringComparison.Ordinal)) + { + continue; + } + // 부모를 알 수 없으면(맵 미제공) 전부 같은 묶음으로 본다 — 모르면 막는 쪽이다. + // 여기서 틀리면 한 문항에 답이 둘 켜진 서식이 나가고, 그건 화면으로 확인할 수 없다. + var parent = parentOf?.Invoke(target); + var index = seen.FindIndex(entry => ReferenceEquals(entry.Parent, parent)); + if (index < 0) + { + seen.Add((parent, 1)); + continue; + } + if (seen[index].Count + 1 >= 2) + { + return true; + } + seen[index] = (parent, seen[index].Count + 1); + } + return false; + } + #endregion +} diff --git a/src/SheetMe.Designer/Controls/ChoiceLabelConverter.cs b/src/SheetMe.Designer/Controls/ChoiceLabelConverter.cs new file mode 100644 index 0000000..22dea2e --- /dev/null +++ b/src/SheetMe.Designer/Controls/ChoiceLabelConverter.cs @@ -0,0 +1,25 @@ +using System.Globalization; +using System.Windows.Data; + +namespace SheetMe.Designer.Controls; + +/// +/// 선택지 목록의 표시 글자 — 빈 항목만 "여러 값"으로 바꾼다. +/// +/// 값이 갈린 다중 선택에서 가 +/// 목록 맨 앞에 빈 문자열 자리를 만든다. 그대로 그리면 목록에 빈 줄이 하나 보일 뿐이라 +/// 무엇인지 알 수 없고, 실수로 고르면 "값을 지우는 것"으로 읽힌다. +/// 나머지 값은 레거시 원문 열거값 그대로 둔다 — 저장되는 문자열과 화면 글자가 같아야 +/// 사용자가 XML 을 열어 봤을 때 대조할 수 있다. +/// +public sealed class ChoiceLabelConverter : IValueConverter +{ + public object Convert(object value, Type targetType, object parameter, CultureInfo culture) + { + var text = value as string ?? string.Empty; + return text.Length == 0 ? "여러 값" : text; + } + + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) + => throw new NotSupportedException(); +} diff --git a/src/SheetMe.Designer/Diagnostics/EditSmoke.cs b/src/SheetMe.Designer/Diagnostics/EditSmoke.cs index e095264..5a71b9e 100644 --- a/src/SheetMe.Designer/Diagnostics/EditSmoke.cs +++ b/src/SheetMe.Designer/Diagnostics/EditSmoke.cs @@ -1026,6 +1026,176 @@ public static class EditSmoke Check("인스펙터: 커밋이 Undo 스텝", designer.Undo.CanUndo); } + // 15-b) 값이 갈린 행이 사용자가 건드리기 전에 커밋을 내지 않는가 — 진짜 컨트롤·진짜 바인딩으로. + // + // 여기서 실제로 확인한 것. 갈린 토글을 한 번 누르면 무엇이 되는지가 핵심이다. + // WPF ToggleButton 은 IsChecked 가 null 이면 IsThreeState 와 무관하게 false 로 간다. + // 고치기 전 실측: 누르기 전 IsChecked=null → 커밋 "False" → 누른 뒤 False. + // 즉 굵게를 켜려고 누른 한 번이 선택 전체의 굵게를 껐다. 세그먼트 토글에는 + // 갈림 표시도 없어(꺼짐과 픽셀 단위로 동일) 무엇이 일어났는지 볼 수도 없었다. + // + // 확인하지 못한 것. ComboBox.SelectedItem 의 코어스 되쓰기(목록에 없는 값을 null 로 + // 바꿔 소스에 되쓰는 것)는 이 진단에서 재현되지 않았다 — 목록 밖 값을 넣은 + // 양성 대조에서도 되쓰기가 관측되지 않아, 이 검사는 그 경로에 대해 둔감하다. + // 그래서 '되쓰기가 없다'를 여기서 주장하지 않는다. 대신 ChoiceRow 는 갈렸을 때 + // 빈 문자열 자리를 목록에 만들어(EnsureMixedPlaceholder) 코어스가 성립할 조건 자체를 없앤다. + { + var choiceRow = new ViewModels.Inspector.ChoiceRowViewModel("정렬", + new[] { "Horizontal", "Vertical" }); + var fired = new List(); + choiceRow.Commit = value => fired.Add(value ?? "(null)"); + // BindRow 가 갈린 값에 대해 하는 것과 같은 순서로 준비한다 + choiceRow.EnsureMixedPlaceholder(); + choiceRow.Initialize(null, isMixed: true); // 값이 갈린 상태 = 다중 선택의 기본형 + + var combo = new System.Windows.Controls.ComboBox { DataContext = choiceRow }; + combo.SetBinding(System.Windows.Controls.ComboBox.ItemsSourceProperty, + new System.Windows.Data.Binding(nameof(ViewModels.Inspector.ChoiceRowViewModel.Choices))); + combo.SetBinding(System.Windows.Controls.Primitives.Selector.SelectedItemProperty, + new System.Windows.Data.Binding(nameof(ViewModels.Inspector.PropertyRowViewModel.ValueText))); + // 바인딩을 실제로 활성화한다 — 붙이기만 하면 Selector 의 코어스가 아직 안 돈다 + combo.Measure(new System.Windows.Size(200, 30)); + combo.Arrange(new Rect(0, 0, 200, 30)); + combo.UpdateLayout(); + + Check("혼합: 갈린 선택지 행이 스스로 커밋하지 않는다", fired.Count == 0, + fired.Count == 0 ? "커밋 0회" : $"커밋 {fired.Count}회 — 값 [{string.Join(", ", fired)}]"); + + // 갈린 선택지 행에 '여러 값' 자리가 생겼는가 — 빈 칸이 '값 없음'과 구별되어야 한다 + Check("혼합: 갈린 선택지에 '여러 값' 자리가 생긴다", + choiceRow.Choices.Count == 3 && choiceRow.Choices[0].Length == 0, + $"[{string.Join(",", choiceRow.Choices.Select(c => c.Length == 0 ? "(빈칸)" : c))}]"); + + // 갈린 토글을 한 번 누르면 켜져야 한다. + // WPF ToggleButton.OnToggle 은 IsChecked==null 이면 IsThreeState 와 무관하게 false 로 간다 + // (isChecked.HasValue → null 이면 false). 그대로 두면 굵게를 켜려고 누른 한 번이 + // 선택 전체를 '굵게 끔' 으로 커밋한다 — 고치기 전 실측이 정확히 그랬다. + var styleToggle = new ViewModels.Inspector.ToggleRowViewModel("굵게"); + var styleFired = new List(); + styleToggle.Commit = value => styleFired.Add(value); + styleToggle.Initialize(null, isMixed: true); + var button = new System.Windows.Controls.Primitives.ToggleButton { DataContext = styleToggle }; + button.SetBinding(System.Windows.Controls.Primitives.ToggleButton.IsCheckedProperty, + new System.Windows.Data.Binding(nameof(ViewModels.Inspector.ToggleRowViewModel.IsOn))); + button.SetBinding(System.Windows.Controls.Primitives.ToggleButton.IsThreeStateProperty, + new System.Windows.Data.Binding(nameof(ViewModels.Inspector.PropertyRowViewModel.IsMixed)) + { Mode = System.Windows.Data.BindingMode.OneWay }); + button.Measure(new System.Windows.Size(40, 26)); + button.Arrange(new Rect(0, 0, 40, 26)); + var beforeClick = button.IsChecked; + // 실제 클릭과 같은 경로 — 자동화 피어의 Toggle 이 ToggleButton.OnToggle 을 부른다 + ((System.Windows.Automation.Provider.IToggleProvider) + new System.Windows.Automation.Peers.ToggleButtonAutomationPeer(button)).Toggle(); + Check("혼합: 갈린 토글을 한 번 누르면 켜진다(끄지 않는다)", + styleFired.Count == 1 && styleFired[0] == "True", + $"누르기 전 IsChecked={beforeClick?.ToString() ?? "null"}, " + + $"커밋 [{string.Join(",", styleFired)}], 누른 뒤 IsChecked={button.IsChecked?.ToString() ?? "null"}"); + Check("혼합: 갈린 선택지 행의 값이 되밀리지 않는다", choiceRow.ValueText.Length == 0 + && choiceRow.IsMixed, + $"ValueText='{choiceRow.ValueText}' IsMixed={choiceRow.IsMixed}"); + } + + // 15-c) 여러 개를 함께 고르고 공통 속성을 한 번에 바꾼다. + // + // 전에는 타입이 하나라도 다르면 타입 전용 섹션이 통째로 사라졌다. 그런데 여러 개를 + // 함께 고쳐야 하는 속성(읽기 전용·인쇄 출력·필수 입력)이 정확히 거기 있었고, + // 없는 이유를 알려 주는 표시도 없어 '이 도구로는 안 되는 일'로 보였다. + { + var bulk = new DesignerViewModel(new FormDesignDataBusiness().CreateNew()); + bulk.AddControlAt("Label", new Point(60, 60)); + bulk.AddControlAt("TextBox", new Point(60, 120)); + bulk.AddControlAt("CheckBox", new Point(60, 180)); + var all = bulk.Pages[0].Controls.ToList(); + bulk.Selection.Set(all, all[0]); + + var inspector = bulk.Inspector; + Check("일괄: 타입이 섞여도 공통 섹션이 나온다", + inspector.Rows.OfType() + .Any(s => s.Label.StartsWith("공통 속성", StringComparison.Ordinal)), + string.Join(" / ", inspector.Rows.OfType().Select(s => s.Label))); + + // 라벨에는 '읽기 전용'이 없다(입력 컨트롤이 아니라 레거시도 노출하지 않는다) — + // 교집합이 제대로 좁혀졌는지부터 본다 + Check("일괄: 한쪽에만 있는 속성은 공통에 오지 않는다", + !inspector.Rows.Any(r => r.Label == "읽기 전용"), + string.Join(" / ", inspector.Rows.Select(r => r.Label))); + + // 정렬은 타입마다 어휘가 다르다(라벨 9값 ContentAlignment vs 텍스트박스 3값 HorizontalAlignment). + // 함께 올리면 텍스트박스에 레거시가 못 읽는 값이 들어간다. + inspector.SelectedTab = ViewModels.Inspector.InspectorTab.Design; + Check("일괄: 뜻이 다른 정렬은 공통에 올리지 않는다", + !inspector.Rows.Any(r => r.Label == "정렬"), + string.Join(" / ", inspector.Rows.Select(r => r.Label))); + Check("일괄: 함께 못 바꾸는 것이 있다고 알린다", + inspector.Rows.OfType() + .Any(r => r.Label == "함께 바꿀 수 없음")); + + // 이름은 유일해야 한다 — 풀면 두 번째 대상부터 충돌 모달이 뜨고 + // 그 거절이 커밋 루프를 멈추지 못한다 + Check("일괄: 다중 선택에는 이름 행이 없다", + !inspector.Rows.Any(r => r.Label == "이름")); + + // 이 기능이 존재하는 이유 — 읽기 전용·필수 입력·재조회는 동작/데이터 탭에 있다. + // 전에는 타입이 섞이면 그 탭이 접혀, 공통 섹션을 아무리 잘 만들어도 닿을 수 없었다. + // 셋 다 '읽기 전용'을 갖는 조합으로 확인한다. + bulk.AddControlAt("ComboBox", new Point(60, 240)); + bulk.AddControlAt("DateTimePicker", new Point(60, 300)); + var editable = bulk.Pages[0].Controls + .Where(c => c.Type is "TextBox" or "ComboBox" or "DateTimePicker").ToList(); + bulk.Selection.Set(editable, editable[0]); + Check("일괄: 타입이 섞여도 동작 탭이 남는다", inspector.ShowBehaviorTab, + $"동작 {inspector.BehaviorCount}개"); + inspector.SelectedTab = ViewModels.Inspector.InspectorTab.Behavior; + var preventRow = inspector.Rows.OfType() + .FirstOrDefault(r => r.Label == "읽기 전용"); + Check("일괄: 동작 탭에 공통 행이 실제로 있다", preventRow is not null, + string.Join(" / ", inspector.Rows.Select(r => r.Label))); + + if (preventRow is not null) + { + preventRow.ValueText = "True"; + Check("일괄: 한 번 바꾸면 선택 전체에 들어간다", + editable.All(c => c.Model.Props.GetText("PreventEditing") == "True"), + string.Join(",", editable.Select(c => $"{c.Type}={c.Model.Props.GetText("PreventEditing")}"))); + Check("일괄: 대상이 몇 개든 Undo 는 한 스텝", bulk.Undo.CanUndo); + bulk.Undo.Undo(); + Check("일괄: Undo 한 번으로 전부 원복된다", + bulk.Pages[0].Controls.All(c => c.Model.Props.GetText("PreventEditing") is null), + string.Join(",", bulk.Pages[0].Controls.Select(c => c.Model.Props.GetText("PreventEditing") ?? "없음"))); + // 되돌린 뒤에도 선택이 살아 있어야 '바꾸기 → 확인 → 되돌리기 → 다시'가 이어진다. + // 전에는 문서 교체가 선택을 통째로 지워, 첫 되돌리기에서 흐름이 끊겼다. + Check("일괄: Undo 후에도 선택이 남는다", bulk.Selection.Items.Count == editable.Count, + $"{bulk.Selection.Items.Count}개 (기대 {editable.Count})"); + } + inspector.SelectedTab = ViewModels.Inspector.InspectorTab.Design; + + // 같은 타입만 고르면 그 타입의 전용 섹션이 그대로 나와야 한다(기존 동작 유지) + bulk.AddControlAt("TextBox", new Point(60, 360)); + var boxes = bulk.Pages[0].Controls.Where(c => c.Type == "TextBox").ToList(); + bulk.Selection.Set(boxes, boxes[0]); + Check("일괄: 같은 타입끼리는 전용 섹션 이름이 유지된다", + inspector.Rows.OfType() + .Any(s => s.Label == "텍스트박스"), + string.Join(" / ", inspector.Rows.OfType().Select(s => s.Label))); + Check("일괄: 같은 타입끼리는 정렬을 함께 바꿀 수 있다", + inspector.Rows.Any(r => r.Label == "정렬")); + + // 바뀐 것이 없으면 문서를 '수정됨'으로 만들지 않는다 — + // 전에는 스냅샷만 쌓여, 그것을 지우려 누른 Ctrl+Z 가 선택까지 지웠다 + var fresh = new DesignerViewModel(new FormDesignDataBusiness().CreateNew()); + fresh.AddControlAt("Label", new Point(60, 60)); + fresh.AddControlAt("Label", new Point(60, 120)); + fresh.Selection.Set(fresh.Pages[0].Controls.ToList(), fresh.Pages[0].Controls[0]); + fresh.Undo.MarkSaved(); + var xRow = (ViewModels.Inspector.NumberRowViewModel)fresh.Inspector.Rows + .OfType().First(p => p.LeftLabel == "X").Left; + xRow.ValueText = "100px"; // 숫자가 아니다 + Check("일괄: 숫자가 아니면 커밋하지 않는다(문서가 수정되지 않는다)", + !fresh.Undo.IsDirty, $"dirty={fresh.Undo.IsDirty}"); + Check("일괄: 거절한 값은 칸에 남지 않는다", xRow.ValueText != "100px", + $"칸='{xRow.ValueText}'"); + } + // 16) 인스펙터 — 이름 변경 var nameRow = designer.Inspector.Rows .OfType() diff --git a/src/SheetMe.Designer/ViewModels/DesignerViewModel.cs b/src/SheetMe.Designer/ViewModels/DesignerViewModel.cs index 8af438a..48194fb 100644 --- a/src/SheetMe.Designer/ViewModels/DesignerViewModel.cs +++ b/src/SheetMe.Designer/ViewModels/DesignerViewModel.cs @@ -577,11 +577,58 @@ public sealed class DesignerViewModel : ViewModelBase RefreshOverlay(); } - /// 문서 교체 후 재구성 — Undo 복원 경로 + /// + /// 문서 교체 후 재구성 — Undo 복원 경로. + /// + /// 선택을 되살린다. 재구성의 첫 줄이 Selection.Clear() 라, 전에는 되돌리기 한 번마다 + /// 선택이 통째로 증발했다. 여러 개를 함께 고쳐 보는 작업의 흐름이 + /// '여럿 고르기 → 바꾸기 → 눈으로 확인 → Ctrl+Z → 다른 값으로 다시' 인데 + /// 첫 되돌리기에서 끊기고, 마퀴로 만든 열두 개 선택을 매번 다시 만들어야 했다. + /// + /// 기준은 Id 다 — 문서 안에서 유일하고(IdGenerator.CollectUsed) 문서를 갈아끼워도 같다. + /// 못 찾은 것은 조용히 뺀다(삭제를 다시 실행한 경우처럼 정말 없어졌을 수 있다). + /// public void ReplaceDocument(FormDocument newDocument) { + var keepIds = Selection.Items.Select(vm => vm.Id).ToList(); + var primaryId = Selection.Primary?.Id; + document = newDocument; RebuildFromDocument(); + + if (keepIds.Count == 0) + { + return; + } + var wanted = new HashSet(keepIds, StringComparer.OrdinalIgnoreCase); + var found = new List(); + ControlViewModel? primary = null; + void Visit(IEnumerable controls) + { + foreach (var vm in controls) + { + if (wanted.Contains(vm.Id)) + { + found.Add(vm); + if (primaryId is not null && string.Equals(vm.Id, primaryId, StringComparison.OrdinalIgnoreCase)) + { + primary = vm; + } + } + if (vm is ContainerViewModel container) + { + Visit(container.Children); + } + } + } + foreach (var page in Pages) + { + Visit(page.Controls); + } + if (found.Count > 0) + { + Selection.Set(found, primary ?? found[0]); + } } /// 페이지 세로 오프셋 재계산 diff --git a/src/SheetMe.Designer/ViewModels/Inspector/InspectorViewModel.cs b/src/SheetMe.Designer/ViewModels/Inspector/InspectorViewModel.cs index f12b38e..238d384 100644 --- a/src/SheetMe.Designer/ViewModels/Inspector/InspectorViewModel.cs +++ b/src/SheetMe.Designer/ViewModels/Inspector/InspectorViewModel.cs @@ -388,7 +388,7 @@ public sealed class InspectorViewModel : ViewModelBase var items = designer.Selection.Items; if (items.Count == 0) { - RefreshTabCounts(null); + RefreshTabCounts(Array.Empty(), 0); if (designer.InspectedPage is { } page) { BuildPageRows(page); @@ -397,10 +397,22 @@ public sealed class InspectorViewModel : ViewModelBase return; } - // 같은 타입만 타입 전용 섹션을 낸다 — 탭 개수 계산도 이 조건을 따라야 어긋나지 않는다 - var type = items[0].Type; - var descriptor = items.All(i => i.Type == type) ? ControlRegistry.Find(type) : null; - RefreshTabCounts(descriptor); + // 편집할 속성 집합을 먼저 정하고, 그 결과로 탭을 정한다. + // + // 순서가 중요하다. 전에는 "타입이 전부 같은가"만 보고 탭 가시성을 정했는데, + // 타입이 섞이면 descriptor 가 null 이라 데이터·동작 탭이 통째로 접혔다. + // 그런데 여러 개를 함께 고쳐야 하는 속성(읽기 전용·필수 입력·재조회·데이터 배선)이 + // 정확히 그 두 탭에 있다 — 공통 섹션을 아무리 잘 만들어도 화면에는 글꼴과 좌표만 남고 + // 왜 없는지 알려 주는 표시도 없었다. + var models = items.Select(i => i.Model).ToList(); + var types = items.Select(i => i.Type).Distinct(StringComparer.Ordinal).ToList(); + var descriptor = types.Count == 1 ? ControlRegistry.Find(types[0]) : null; + // MergeCommonDefs 는 타입이 하나면 그 타입의 속성을 그대로, 스키마를 모르는 타입이 + // 섞이면 빈 집합을 돌려준다 — 여기서 갈래를 나눌 필요가 없다. + var merged = BatchEditPolicy.MergeCommonDefs(types) + .Where(def => BatchEditPolicy.IsBatchable(def.Key, models, viaAdvancedRaw: false, ParentModelOf).Allowed) + .ToList(); + RefreshTabCounts(merged, items.Count); if (selectedTab == InspectorTab.Design) { @@ -429,33 +441,46 @@ public sealed class InspectorViewModel : ViewModelBase AddFontStyleSegment(); } - // 타입 전용 — 선택 탭에 해당하는 속성만 + // 타입 전용(또는 타입이 섞였으면 공통) — 선택 탭에 해당하는 속성만 var curatedKeys = new HashSet(HandledKeys, StringComparer.Ordinal); - if (descriptor is not null) + if (merged.Count > 0) { // 큐레이션 키는 탭과 무관하게 전부 모아야 한다 — 고급 목록에서 중복 노출을 막는 용도라 // 탭으로 걸러 버리면 다른 탭 속성이 고급에 다시 나온다. - foreach (var def in descriptor.Properties) + foreach (var def in merged) { curatedKeys.Add(def.Key); } - var tabDefs = descriptor.Properties.Where(d => InspectorTabCatalog.TabOf(d) == selectedTab).ToList(); + var tabDefs = merged.Where(d => InspectorTabCatalog.TabOf(d) == selectedTab).ToList(); if (tabDefs.Count > 0) { - AddSection(descriptor.DisplayName); + // 섹션 제목에 한 타입의 이름을 쓰면 안 된다 — 라벨과 텍스트박스를 함께 고른 + // 선택에 '라벨'이라는 제목이 붙으면 텍스트박스는 안 바뀐다고 읽는다. + AddSection(types.Count == 1 && descriptor is not null + ? descriptor.DisplayName + : $"공통 속성 ({types.Count}종 타입)"); foreach (var def in tabDefs) { - AddDefRow(def, descriptor.Type); + // 태그 제안은 타입별로 후보가 갈린다 — 섞였으면 한쪽으로 편향시키지 않는다 + AddDefRow(def, types.Count == 1 ? types[0] : string.Empty, models); + } + // 함께 못 바꾸는 것이 있으면 말한다. 조용히 빼면 그 속성이 없다고 읽는다. + var excluded = BatchEditPolicy.ExcludedCount(types); + if (excluded > 0) + { + AddPlain(new ReadOnlyRowViewModel("함께 바꿀 수 없음", + $"타입이 달라 값의 뜻이 다른 속성 {excluded}개 — 타입별로 따로 고르세요")); } } } - // 전체 속성(고급) — 단일 선택 시 PropBag 의 나머지 레거시 속성 전부(레거시 PropertyGrid 등가). + // 전체 속성(고급) — PropBag 의 나머지 레거시 원문 속성(레거시 PropertyGrid 등가). // 레거시 원문 키는 분류 근거가 없어 어느 탭에도 속하지 않는다 — 탈출구이므로 모든 탭 아래에 둔다. - if (items.Count == 1) + // 표(Spread)가 섞이면 열지 않는다 — 격자 디자인이 별도 테이블에 있어 원문 편집의 파장을 모른다. + if (!items.Any(i => DesignerViewModel.IsSpread(i.Model))) { - BuildAdvancedRows(items[0], curatedKeys); + BuildAdvancedRows(items, curatedKeys); } OnPropertyChanged(nameof(IsTabEmpty)); @@ -469,11 +494,13 @@ public sealed class InspectorViewModel : ViewModelBase /// (데이터 탭도 6타입에서 빈다). 빈 탭을 남겨 두면 대부분의 컨트롤에서 "여긴 없습니다"만 /// 보게 되고, 사용자는 속성 하나를 찾아 빈 탭을 계속 눌러보게 된다 — 탭이 없느니만 못하다. /// - private void RefreshTabCounts(ControlDescriptor? descriptor) + private void RefreshTabCounts(IReadOnlyList typed, int selectionCount) { - var typed = descriptor?.Properties ?? (IReadOnlyList)Array.Empty(); - // 디자인 탭에는 공통 5 + 글꼴 6 이 항상 붙는다 → 선택이 있으면 절대 비지 않는다 - DesignCount = 11 + typed.Count(d => InspectorTabCatalog.TabOf(d) == InspectorTab.Design); + // 디자인 탭에는 공통 + 글꼴이 항상 붙는다 → 선택이 있으면 절대 비지 않는다. + // 다중 선택에는 이름 행이 없으므로(이름은 유일해야 한다) 하나 적다 — + // 전에는 11 로 굳어 있어 다중 선택에서 실제 행 수와 어긋났다. + DesignCount = (selectionCount == 1 ? 11 : 10) + + typed.Count(d => InspectorTabCatalog.TabOf(d) == InspectorTab.Design); DataCount = typed.Count(d => InspectorTabCatalog.TabOf(d) == InspectorTab.Data); BehaviorCount = typed.Count(d => InspectorTabCatalog.TabOf(d) == InspectorTab.Behavior); @@ -665,12 +692,41 @@ public sealed class InspectorViewModel : ViewModelBase }, () => designer.RefreshPageInheritance(page)); } - /// 고급 섹션 구성 — 접이식, raw 문자열 편집 + 속성 추가 - private void BuildAdvancedRows(ControlViewModel target, HashSet curatedKeys) + /// + /// 고급 섹션 구성 — 접이식, raw 문자열 편집 + 속성 추가. + /// + /// 다중 선택에서 위험한 곳이 여기다. 전에는 행의 종류·편집 여부·키 목록을 전부 + /// items[0] 하나로 정하면서 커밋은 선택 전체에 썼다. 그래서 이미지·중첩·참조 값을 + /// 지키던 읽기 전용 방어가 "처음 클릭한 컨트롤"에만 걸렸고, 클릭 순서에 따라 같은 조작이 + /// 안전해지거나 파괴적이 됐다. 게다가 GetText 는 문자열이 아닌 값에 null 을 돌려주므로 + /// 빈 값과 이미지 원문이 둘 다 "" 로 접혀 갈렸다는 표시조차 나오지 않았다. + /// + /// 그래서 판정을 대상 전체로 바꾼다 — 전부 갖고 있고 전부 문자열일 때만 편집을 연다. + /// 레거시 PropertyGrid 의 병합 규칙(이름과 타입이 모든 대상에 공통일 때만 행이 생긴다)과 같은 결이다. + /// + private void BuildAdvancedRows(IReadOnlyList targets, HashSet curatedKeys) { - var advancedKeys = target.Model.Props.Keys - .Where(k => !curatedKeys.Contains(k)) - .ToList(); + if (targets.Count == 0) + { + return; + } + var target = targets[0]; + var models = targets.Select(t => t.Model).ToList(); + var single = targets.Count == 1; + // 키 목록은 합집합이다(선언 순서 보존) — 대상마다 가진 키가 다를 수 있고, + // 첫 컨트롤만 훑으면 나머지가 가진 속성은 존재조차 보이지 않는다. + var advancedKeys = new List(); + var seenKeys = new HashSet(StringComparer.Ordinal); + foreach (var model in models) + { + foreach (var key in model.Props.Keys) + { + if (!curatedKeys.Contains(key) && seenKeys.Add(key)) + { + advancedKeys.Add(key); + } + } + } if (!showAdvanced) { @@ -692,6 +748,36 @@ public sealed class InspectorViewModel : ViewModelBase foreach (var key in advancedKeys) { + // 다중 선택은 대상 전체를 보고 판정한다 — 셋 다 통과해야 편집을 연다. + if (!single) + { + if (BatchEditPolicy.IsBatchable(key, models, viaAdvancedRaw: true, ParentModelOf) is { Allowed: false } verdict) + { + AddPlain(new ReadOnlyRowViewModel(key, $"(함께 바꿀 수 없음 — {verdict.Reason})")); + continue; + } + var having = BatchEditPolicy.CountHaving(key, models); + if (having < models.Count) + { + // 없던 대상에 키를 만들면 레거시 서식생성기가 열 때마다 '이 타입에 없는 속성' + // 이라며 모달을 띄운다 — 만들지 않는다 + AddPlain(new ReadOnlyRowViewModel(key, + $"({models.Count}개 중 {having}개에만 있음 — 함께 바꿀 수 없음)")); + continue; + } + if (!BatchEditPolicy.AllShareTextShape(key, models)) + { + AddPlain(new ReadOnlyRowViewModel(key, "(대상마다 값의 형태가 다릅니다 — 함께 바꿀 수 없음)")); + continue; + } + AddRow(new TextRowViewModel(key), new RowBinding + { + Get = vm => vm.Model.Props.GetText(key) ?? string.Empty, + Set = (vm, newValue) => vm.Model.Props.SetText(key, newValue), + }); + continue; + } + var value = target.Model.Props.Get(key); switch (value) { @@ -736,6 +822,13 @@ public sealed class InspectorViewModel : ViewModelBase } } + // 속성 추가는 단일 선택만. 중복 검사·쓰기·제안이 전부 대상 하나 기준이라 + // 다중 선택에서 누르면 N개 중 1개에만 키가 생기고, 나머지는 아무 표시 없이 그대로다. + if (!single) + { + return; + } + // 속성 추가 — 타입별 실사용 키를 제안한다(자유 입력도 그대로 열어 둔다). // 제안이 없으면 사용자가 철자를 외워 맞혀야 하고, 틀려도 경고가 없다 — // 라벨의 소문자 visible, PrintOutPut 의 대문자 P 같은 함정이 실재한다. @@ -752,7 +845,16 @@ public sealed class InspectorViewModel : ViewModelBase }, LegacyPropertyCatalog.SuggestFor(target.Type, target.Model.Props.Keys))); } - private void AddDefRow(PropertyDef def, string controlType) + /// + /// 컨트롤 모델 → 직계 부모 모델(최상위면 null). + /// + /// 라디오 배타 판정에 쓴다 — 레거시 라디오는 GroupName 이 없어 직계 부모가 곧 문항이다. + /// 선택 목록을 뒤져 같은 모델의 VM 을 찾고 그 Parent 를 돌려준다. + /// + private ControlElement? ParentModelOf(ControlElement model) + => designer.Selection.Items.FirstOrDefault(vm => ReferenceEquals(vm.Model, model))?.Parent?.Model; + + private void AddDefRow(PropertyDef def, string controlType, IReadOnlyList targets) { var binding = def.Editor == PropEditorKind.StringList ? new RowBinding @@ -769,6 +871,7 @@ public sealed class InspectorViewModel : ViewModelBase } vm.Model.Props.Set(def.Key, itemsValue); }, + ConfirmBatch = ConfirmDestructive(def.Key), } : new RowBinding { @@ -793,6 +896,7 @@ public sealed class InspectorViewModel : ViewModelBase vm.Model.Props.SetText(def.Key, value); } }, + ConfirmBatch = ConfirmDestructive(def.Key), }; PropertyRowViewModel row = def.Editor switch @@ -818,9 +922,25 @@ public sealed class InspectorViewModel : ViewModelBase PropEditorKind.DataTableField => new DataTableFieldRowViewModel(def.Label, DataTableNames), _ => new TextRowViewModel(def.Label), }; - AddRow(row, binding); + // 선의 방향·굵기는 값만 바꾸는 것이 아니라 크기까지 바꾼다(ApplyLineChange). + // 알리지 않으면 선택 핸들이 옛 사각형에 남아, 화면에서는 선이 돌았는데 테두리는 그대로다. + AddRow(row, binding, def.Key is "Orientation" or "BorderWidth" + ? () => designer.Selection.NotifyBoundsChanged() + : null); } + /// + /// 여러 대상의 고유값을 한꺼번에 덮는 편집에만 붙이는 확인 — 텍스트·항목 목록. + /// + /// 좌표·색·인쇄여부처럼 되돌리기 쉬운 것에는 붙이지 않는다. 확인을 남발하면 아무도 읽지 않고, + /// 정작 위험한 것을 물었을 때도 습관적으로 넘긴다. + /// + private static Func? ConfirmDestructive(string key) => key is "Text" or "Items" + ? count => Services.DialogService.Confirm("일괄 변경", + $"{count}개 컨트롤의 기존 값이 모두 같은 값으로 바뀝니다.", + "각자 다르게 들어 있던 값은 되살릴 수 없습니다(되돌리기로만 복구됩니다).") + : null; + /// /// 이 서식의 데이터소스(MDataTable) 이름 — 배선 편집기의 선택지. /// @@ -905,6 +1025,8 @@ public sealed class InspectorViewModel : ViewModelBase set(vm, Math.Round(number)); } }, + // 숫자가 아니면 커밋 자체를 막는다 — 전에는 스냅샷만 쌓이고 값은 하나도 안 바뀌었다 + Validate = text => double.TryParse(text, NumberStyles.Number, CultureInfo.InvariantCulture, out _), AffectsVisual = false, }; BindRow(row, binding, afterCommit: () => designer.Selection.NotifyBoundsChanged()); @@ -941,6 +1063,10 @@ public sealed class InspectorViewModel : ViewModelBase set(font, value); vm.Model.Props.SetText("Font", LegacyFormat.FormatFont(font)); }, + // 글꼴 크기는 양수만 — 0 이나 음수를 넣으면 Set 이 조용히 옛 크기를 유지해 + // "바꿨는데 안 바뀐다"가 된다. 커밋 전에 막고 칸을 되돌린다. + Validate = text => double.TryParse(text, NumberStyles.Number, CultureInfo.InvariantCulture, out var size) + && size > 0, }); } @@ -1030,6 +1156,12 @@ public sealed class InspectorViewModel : ViewModelBase // 목록 밖 값이 들어오면 ComboBox 가 null 로 코어스해 되쓰면서 속성을 지운다 — 표시 전에 편입시킨다. if (row is ChoiceRowViewModel choice) { + // 값이 갈리면 '여러 값' 자리를 먼저 만든다 — 그러면 빈 칸이 '값 없음'과 구별되고, + // 목록에 없는 값(빈 문자열)을 SelectedItem 에 물리는 상태 자체가 사라진다. + if (values.Count > 1) + { + choice.EnsureMixedPlaceholder(); + } choice.EnsureChoice(initial); } @@ -1042,6 +1174,27 @@ public sealed class InspectorViewModel : ViewModelBase { return; } + // ── 스냅샷 의 관문 셋. 순서가 중요하다: 여기서 걸러야 '바뀐 것이 없는데 + // 문서가 수정됨' 이 생기지 않고, 그래야 사용자가 그걸 지우려 Ctrl+Z 를 누를 일도 없다. + if (binding.Validate is { } validate && !validate(value)) + { + // 값으로 인정하지 않았으므로 칸을 원래 표시로 되돌린다 — 잘못된 문자열이 + // 다음 재구성까지 남아 값인 척하는 것을 막는다 + var current = designer.Selection.Items.Select(binding.Get).Distinct(StringComparer.Ordinal).ToList(); + row.Initialize(current.Count == 1 ? current[0] : null, isMixed: current.Count > 1); + return; + } + // 이미 전부 그 값이면 아무 일도 하지 않는다(스냅샷도 찍지 않는다) + var before = targets.Select(binding.Get).Distinct(StringComparer.Ordinal).ToList(); + if (before.Count == 1 && string.Equals(before[0], value, StringComparison.Ordinal)) + { + return; + } + if (targets.Count > 1 && binding.ConfirmBatch is { } confirm && !confirm(targets.Count)) + { + row.Initialize(before.Count == 1 ? before[0] : null, isMixed: before.Count > 1); + return; + } // 화살표 연타는 400ms 코얼레스로 한 스텝에 묶는다 — 커밋 1회가 문서 딥클론 1회라 // 그대로 두면 ↑ 스무 번에 스냅샷 스무 개가 쌓여 편집 이력이 밀려 나간다. if (row.CoalesceUndo) diff --git a/src/SheetMe.Designer/ViewModels/Inspector/PropertyRows.cs b/src/SheetMe.Designer/ViewModels/Inspector/PropertyRows.cs index a977766..20b4d6c 100644 --- a/src/SheetMe.Designer/ViewModels/Inspector/PropertyRows.cs +++ b/src/SheetMe.Designer/ViewModels/Inspector/PropertyRows.cs @@ -30,6 +30,15 @@ public abstract class PropertyRowViewModel : ViewModelBase /// 선택 대상들의 값이 서로 다른지 — "여러 값" 표시 public bool IsMixed { get; private set; } + /// + /// 값이 갈렸을 때 요약 칸에 쓰는 글귀 — 쿼리·마스크·배선처럼 값 대신 요약을 그리는 행용. + /// + /// 이 행들은 빈 칸이 아니라 "(쿼리 없음)"·"(마스크 없음)"·"(배선 없음)" 같은 단정을 그린다. + /// 서로 다른 쿼리를 든 컨트롤 둘을 골랐을 때 "쿼리 없음"이 나오면 그건 빈 칸보다 나쁘다 — + /// 없다고 믿고 새로 쓰면 양쪽의 원래 쿼리가 한꺼번에 사라진다. + /// + protected const string MixedSummary = "(여러 값)"; + /// /// 이 행이 품고 있는 '속성' 개수 — 섹션 헤더의 건수 표시용. /// 쌍 행·세그먼트 행은 한 줄이지만 속성은 여럿이라, 줄 수를 세면 헤더가 "공통 3"처럼 @@ -52,6 +61,11 @@ public abstract class PropertyRowViewModel : ViewModelBase get => valueText; set { + // null 을 문자열로 정규화한다. 뷰가 null 을 되밀 수 있는 경로가 있고 + // (ComboBox.SelectedItem 은 TwoWay 기본이라 목록에 없는 값을 null 로 코어스한다) + // 그 null 이 커밋까지 흘러가면 AddDefRow 의 value.Length 에서 NullReferenceException 이다. + // 아래 SetProperty 도 "" 와 null 을 다른 값으로 보아 무의미한 커밋을 한 번 내보낸다. + value ??= string.Empty; if (!SetProperty(ref valueText, value) || building) { return; @@ -365,10 +379,21 @@ public sealed class ToggleRowViewModel : PropertyRowViewModel get => IsMixed ? null : ValueText == "True"; set { - if (value is bool on) + if (value is not bool on) { - ValueText = on ? "True" : "False"; + return; } + // 갈린 상태에서 누르면 켠다. WPF ToggleButton.OnToggle 은 IsChecked 가 null 이면 + // IsThreeState 와 무관하게 false 로 간다(내부적으로 isChecked.HasValue — null 이면 false). + // 그대로 두면 굵게를 켜려고 누른 한 번이 선택 전체를 '굵게 끔'으로 커밋한다. + // 실측(edit-smoke): 누르기 전 IsChecked=null → 커밋 "False" → 누른 뒤 False. + // 갈린 상태에서 setter 에 false 가 오는 경로는 이 클릭뿐이므로(사용자가 '꺼짐'을 본 적이 + // 없다) 켜는 것으로 읽는다 — 디자이너 관례도 '혼합을 누르면 전부 켜기'다. + if (IsMixed && !on) + { + on = true; + } + ValueText = on ? "True" : "False"; } } @@ -402,6 +427,27 @@ public sealed class ChoiceRowViewModel : PropertyRowViewModel choices.Add(value); } } + + /// + /// 값이 갈린 다중 선택에서 '여러 값' 자리를 목록 맨 앞에 만든다(빈 문자열). + /// + /// 두 가지를 동시에 해결한다. + /// ① 보이게 한다. 갈린 값은 ValueText 가 "" 라 콤보가 빈 칸으로 보이는데, + /// 그건 '값 없음'과 구별되지 않는다. 목록에 자리가 있으면 "여러 값"으로 그릴 수 있다. + /// ② 코어스를 막는다. 는 IsNullOrEmpty 에서 빠져나가므로 + /// "" 를 편입하지 않는다. 목록에 없는 값을 SelectedItem 에 물리면 WPF Selector 가 + /// null 로 코어스하고 TwoWay 로 되쓸 수 있다 — 그 경로는 실측(edit-smoke)에서 재현되지 + /// 않았지만, 자리를 만들어 두면 코어스 자체가 성립하지 않으므로 따질 일이 없어진다. + /// + /// 사용자가 이 빈 항목을 다시 골라도 ""→"" 라 SetProperty 가 false 를 돌려주고 커밋이 없다. + /// + public void EnsureMixedPlaceholder() + { + if (!choices.Contains(string.Empty, StringComparer.Ordinal)) + { + choices.Insert(0, string.Empty); + } + } } /// @@ -613,7 +659,9 @@ public sealed class TagPickerRowViewModel : PropertyRowViewModel public sealed class MaskRowViewModel : PropertyRowViewModel { /// 이 마스크가 화면에 어떻게 보이는지 — 인스펙터에서 바로 확인 - public string Preview => ValueText.Length == 0 + public string Preview => IsMixed + ? MixedSummary + : ValueText.Length == 0 ? "(마스크 없음)" : SheetMe.Core.Serialization.LegacyMask.ToPromptDisplay(ValueText); @@ -648,6 +696,10 @@ public sealed class QueryRowViewModel : PropertyRowViewModel { get { + if (IsMixed) + { + return MixedSummary; + } var oneLine = ValueText.Replace("\r", " ").Replace("\n", " ").Trim(); return oneLine.Length == 0 ? "(쿼리 없음)" : oneLine.Length > 48 ? oneLine[..48] + "…" : oneLine; } @@ -843,6 +895,10 @@ public sealed class DataTableFieldRowViewModel : PropertyRowViewModel { get { + if (IsMixed) + { + return MixedSummary; + } if (ValueText.Length == 0) { return "(배선 없음)"; @@ -987,4 +1043,22 @@ public sealed class RowBinding /// 커밋 후 시각 재해석 필요 여부 public bool AffectsVisual { get; init; } = true; + + /// + /// 커밋 전 값 검사 — false 면 아무것도 하지 않고 칸을 원래 표시로 되돌린다. + /// + /// 숫자 칸이 그 예다. 전에는 "100px" 같은 문자열도 스냅샷을 먼저 찍은 뒤 대상마다 + /// TryParse 에 실패해 아무것도 안 바뀌었다 — 바뀐 것이 없는데 문서는 '수정됨'이 되고 + /// 잘못된 문자열은 다음 재구성까지 칸에 남아 값인 척했다. 다중 선택은 좌표·크기가 + /// 갈리는 것이 기본에 가까워 이 경로를 훨씬 자주 밟는다. + /// + public Func? Validate { get; init; } + + /// + /// 대상이 둘 이상일 때 커밋 전에 받는 확인 — false 면 커밋하지 않는다(인자는 대상 수). + /// + /// 대상마다 서로 다른 고유값이 한 번에 사라지는 편집에만 붙인다(텍스트·항목 목록). + /// 좌표·색·인쇄여부처럼 되돌리기 쉬운 것에는 붙이지 않는다 — 확인을 남발하면 아무도 안 읽는다. + /// + public Func? ConfirmBatch { get; init; } } diff --git a/src/SheetMe.Designer/Views/InspectorView.xaml b/src/SheetMe.Designer/Views/InspectorView.xaml index 1a17713..1cbb3bb 100644 --- a/src/SheetMe.Designer/Views/InspectorView.xaml +++ b/src/SheetMe.Designer/Views/InspectorView.xaml @@ -11,6 +11,7 @@ + + + + + + @@ -425,6 +438,15 @@ + + @@ -533,7 +555,28 @@ - + + + + + + + + + + + + diff --git a/tests/SheetMe.Core.Tests/BatchEditPolicyTests.cs b/tests/SheetMe.Core.Tests/BatchEditPolicyTests.cs new file mode 100644 index 0000000..1c37169 --- /dev/null +++ b/tests/SheetMe.Core.Tests/BatchEditPolicyTests.cs @@ -0,0 +1,237 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using SheetMe.Core.Catalog; +using SheetMe.Core.Models; + +namespace SheetMe.Core.Tests; + +/// +/// 여러 컨트롤을 함께 고쳤을 때 레거시가 못 여는 XML이 나오지 않는지 고정한다. +/// +/// 여기서 막는 것들은 전부 화면으로 확인할 수 없는 손상이다 — +/// 디자이너는 멀쩡해 보이고, EMR 에서만 다르게 나오거나 서식이 아예 안 열린다. +/// +[TestClass] +public class BatchEditPolicyTests +{ + #region Helpers + private static ControlElement Element(string type, string id = "c") + => new() { Id = id, Type = type }; + + private static IReadOnlyList KeysOf(IReadOnlyList 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()).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); + } + // 액션 태그 자체는 정상 작업이다 — 같은 문항의 라디오에 같은 액션이 붙는다 + 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 을 주므로 빈 값과 이미지가 둘 다 "" 로 접혀 + // 갈렸다는 표시조차 안 나온다 — 그래서 형태로 판정한다. + 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 +}