From 4d6deba846a08437849d0cc8756ae53f89c843b1 Mon Sep 17 00:00:00 2001 From: centwon Date: Sun, 20 Sep 2026 13:19:01 +0900 Subject: [PATCH] =?UTF-8?q?test:=20=EB=AA=A8=EB=94=94=ED=8C=8C=EC=9D=B4?= =?UTF-8?q?=EC=96=B4=20=EC=9D=98=EC=A1=B4=20=EC=A0=9C=EC=8A=A4=EC=B2=98?= =?UTF-8?q?=EB=A5=BC=20=EC=9E=90=EB=8F=99=20=EA=B2=80=EC=A6=9D=EC=97=90=20?= =?UTF-8?q?=EB=84=A3=EB=8A=94=EB=8B=A4=20=E2=80=94=20Ctrl=20=EB=B3=B5?= =?UTF-8?q?=EC=82=AC=20=EB=81=8C=EA=B8=B0=C2=B7Ctrl+=ED=81=B4=EB=A6=AD=20?= =?UTF-8?q?=EB=A7=81=ED=81=AC=C2=B7Shift=20=EC=84=A0=ED=83=9D=C2=B7Ctrl+?= =?UTF-8?q?=ED=9C=A0=20(1.3=203=EB=8B=A8=EA=B3=84)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 포인터 경로의 `Ctrl`/`Shift`는 실제 키보드를 읽는 정적 프로퍼티였고(`InputKeyboardSource`), 테스트는 그것을 정할 수 없어 이 동작들에 자동 검증이 **하나도 없었다**. 1단계에서 step으로 옮겨 놓았으므로 이제 구동된다. - 개체 끌기: 놓을 때 Ctrl이면 **복사**(원본 인스턴스가 문서에 남는 것으로 확인), 아니면 이동. - 텍스트 끌기: 놓을 때 Ctrl이면 복사 — 원본이 남고 "drag"가 둘이 된다. - Ctrl+클릭은 **포인터 아래** 링크를 열고, 평범한 클릭은 열지 않는다. - 뷰어(읽기 전용): 제자리 클릭은 링크를 열고, 링크 위를 **끌면 선택만** 된다(브라우저 규약). - 휠은 Ctrl일 때만 확대하고, 아닐 때는 처리하지 않는다(스크롤이 되어야 하므로). `OnCanvasPointerWheel`도 `PointerWheelCore(delta, ctrl)`로 분리 — 포인터 경로에 남아 있던 마지막 ambient 읽기였다. 테스트 seam 1개(`internal`): `RichEditor.LaunchOverride`. 어느 제스처가 링크를 여는지는 테스트 기계에서 브라우저를 띄우지 않고는 단정할 수 없었다. 결정(`IsLaunchableLink`)은 전부터 직접 시험돼 있고, 이제 거기 **닿는 경로**까지 덮는다. 반증 6종 전부 의도한 테스트만 빨강: 개체 드롭의 Ctrl 무시 · 텍스트 드롭의 Ctrl 무시 · Ctrl+클릭 분기 차단 · 뷰어 링크의 "선택 없음·슬롭 이내" 가드 제거 · 누르기의 Shift 무시 · 휠의 Ctrl 게이트 제거. ⚠ 휠 교란은 공유 에디터의 확대를 바꿔 좌표 테스트 하나를 함께 넘어뜨렸다(교란의 부수 효과, 제품 아님). 테스트 826 → 832. 공개 표면 변화 없음. 로드맵에 1~3단계 결과와 반증이 가르쳐 준 두 가지를 기록했다. Co-Authored-By: Claude Opus 5 --- Project_Roadmap.md | 27 ++- .../Controls/RichEditor.Hyperlink.cs | 12 +- .../Controls/RichEditor.Input.cs | 11 +- .../ControlPointerSequenceTests.cs | 175 +++++++++++++++++- 4 files changed, 216 insertions(+), 9 deletions(-) diff --git a/Project_Roadmap.md b/Project_Roadmap.md index 7089167..1576cc6 100644 --- a/Project_Roadmap.md +++ b/Project_Roadmap.md @@ -58,14 +58,35 @@ WinUI 3 + Win2D 리치텍스트 에디터. `AvaloniaRichEditor`의 WinUI 포트 ## 다음 우선순위 (1.3) -1. **포인터 순서를 재현하는 상호작용 테스트.** 1.2.0의 회귀 하나(표 그리기 놓기가 캡처를 먼저 놓아 모든 - 삽입이 취소됨)를 좌표 단위 테스트가 못 잡고 **실기가 잡았다**. 상류는 `InteractionHost`(헤드리스 창에 - 실제 클릭·드래그·키 주입)로 이 계층을 덮는다 — 포트에는 아직 없다. +1. ~~**포인터 순서를 재현하는 상호작용 테스트**~~ → **2026-09-20 1~3단계 완료**(아래 절). 남은 것은 + 프레임워크의 라우팅·히트테스트·포커스뿐이고, 그건 여전히 `fault-sweep` + 실기의 몫이다. 2. **실기에만 있는 검증 항목**: AltGr 자판(처리기 배선은 `KeyRoutedEventArgs`를 만들 수 없어 자동 검증 밖), IME 조합, 포커스·캐럿 깜빡임. 3. **상류 백포트 잔여**: 라운드31~32 이후 상류 변경분 대조. 4. 아래 "알려진 한계"의 미수정 항목. +### 포인터 파이프라인 (2026-09-20) — 1~3단계 완료, 테스트 815 → 832, 결함 1건(포트 전용), 반증 16종 +포인터 처리기가 이벤트에서 읽는 것은 **위치·오른쪽 버튼·모디파이어·캡처** 넷뿐이다. 그것을 `PointerStep`으로 +묶고 캡처를 `IPointerCapture`로 추상화해(`RichEditor.PointerPipeline.cs`) 처리기를 어댑터로 줄였다. 테스트는 +`FakeCapture`로 **누르기→캡처→이동→놓기→캡처상실**을 실제 순서로 구동한다 — 해제가 캡처 상실을 **동기로** +일으키는 것까지. 포인터 경로의 `Ctrl`/`Shift` 정적 키보드 읽기도 전부 step으로 옮겨, Ctrl 복사 끌기·Ctrl+클릭 +링크·Shift 선택·Ctrl+휠이 처음으로 자동 검증에 들어왔다. 공개 표면 변화 없음(전부 `internal`). +- **결함(포트 전용)**: **캡처를 잃은 텍스트 끌기가 무장된 채 남아 다음 클릭이 드롭을 수행했다.** 옛 주석은 + 이것을 의도라고 적고 있었다("끝내면 텍스트가 떨어진다") — *끝내기*와 *떨어뜨리기*는 다르다. 개체 끌기와 + 같은 규칙으로 `CancelTextDrag()`를 만들어 캡처 상실·문서 교체에서 부른다. 상류엔 문서 내 텍스트 끌기가 + 없어 대조할 선례가 없었다. +- ⚠ **반증이 가르쳐 준 두 가지**: ① **열 끌기 놓기 순서를 뒤집어도 아무것도 안 빨개진다** — 캡처 상실이 + 열 끌기는 *취소*가 아니라 *완료*하고 `FinishColumnResize`가 멱등이라 실제로 무해하다(상류와 같다). + 순서 규칙이 힘을 쓰는 곳은 **캡처 상실이 취소하는 경로**(표 그리기·개체 끌기)뿐이다. ② **가짜가 캡처 + 상실을 동기로 안 쏘면 깨진 순서도 통과한다** — 보증이 가짜의 충실도에 얹혀 있으므로 호출 순서 + `capture → release → lost`를 테스트가 직접 단정한다. +- ⚠ **하네스 함정(제품 아님)**: 공유 에디터에 **같은 좌표를 밀리초 간격으로** 누르면 다중 클릭이 되어 + 누르기가 끌기 무장 대신 단어 선택이 된다. 실패 메시지는 제품 결함처럼 보인다 — `Hosted`와 `NoRepeat`가 + `_lastPressTime`/`_clickCount`를 초기화한다. +- **테스트 seam 1개 추가**(`internal`): `RichEditor.LaunchOverride` — 어느 제스처가 링크를 여는지는 + 브라우저를 띄우지 않고는 단정할 수 없었다. 결정(`IsLaunchableLink`)은 전부터 직접 시험돼 있었고, 이제 + 거기 **닿는 경로**까지 덮는다. + ## 알려진 한계 (의도적) - **HWP는 RTF 중첩 표를 구현하지 않는다** — `\nestcell`을 버리고 중첩 셀 텍스트를 이어 붙인다. 우리 출력은 상류와 바이트 동일하고 Word는 정확히 읽는다. 포트 결함이 아니다. diff --git a/src/WinUIRichEditor/Controls/RichEditor.Hyperlink.cs b/src/WinUIRichEditor/Controls/RichEditor.Hyperlink.cs index d98b498..fe7021b 100644 --- a/src/WinUIRichEditor/Controls/RichEditor.Hyperlink.cs +++ b/src/WinUIRichEditor/Controls/RichEditor.Hyperlink.cs @@ -194,12 +194,22 @@ private TextPointer CaretJustPast(Paragraph p, int ch) /// Launches the hyperlink at the caret in the system browser, if any. public Task OpenLinkAtCaretAsync() => OpenUriAsync(CurrentLinkUri()); + // Test seam: set, the URI goes here instead of the system browser. WHICH gesture launches a link + // (Ctrl+click when editable, a plain click in a viewer, and neither on a drag) had no automated test + // at all, because asserting it means launching a browser on the test machine. The decision itself + // (IsLaunchableLink) is tested directly; this covers the path that reaches it. + internal Func? LaunchOverride { get; set; } + // Launches a specific absolute URI. Callers that captured the link at press time (the read-only // plain-click path) pass it in rather than re-reading the caret, which may have moved since. internal async Task OpenUriAsync(string? url) { if (!IsLaunchableLink(url, out var uri)) return; - try { await Windows.System.Launcher.LaunchUriAsync(uri); } + try + { + if (LaunchOverride is { } launch) await launch(uri); + else await Windows.System.Launcher.LaunchUriAsync(uri); + } catch (Exception ex) { RichEditorDiagnostics.Report(ex); } } diff --git a/src/WinUIRichEditor/Controls/RichEditor.Input.cs b/src/WinUIRichEditor/Controls/RichEditor.Input.cs index a3843cb..6654e80 100644 --- a/src/WinUIRichEditor/Controls/RichEditor.Input.cs +++ b/src/WinUIRichEditor/Controls/RichEditor.Input.cs @@ -505,12 +505,15 @@ private void SetCursorShape(InputSystemCursorShape shape) // Ctrl+wheel zoom (Word/browser convention): ~10% per notch, clamped by SetZoom, which also leaves // fit-to-width mode. Handled so the ScrollViewer doesn't scroll instead; a plain wheel falls through. private void OnCanvasPointerWheel(object sender, PointerRoutedEventArgs e) + => e.Handled = PointerWheelCore(e.GetCurrentPoint(_canvas).Properties.MouseWheelDelta, Ctrl); + + /// The wheel, driven by its two inputs. Returns whether it was handled — the ScrollViewer + /// scrolls when it was not (see RichEditor.PointerPipeline.cs for why the modifier is a parameter). + internal bool PointerWheelCore(int delta, bool ctrl) { - if (!Ctrl) return; - int delta = e.GetCurrentPoint(_canvas).Properties.MouseWheelDelta; - if (delta == 0) return; + if (!ctrl || delta == 0) return false; SetZoom(EffectiveZoom * Math.Pow(1.1, delta / 120.0)); - e.Handled = true; + return true; } // Capture can end without a release reaching the canvas (the window deactivated mid-drag, another element diff --git a/tests/WinUIRichEditor.Tests/ControlPointerSequenceTests.cs b/tests/WinUIRichEditor.Tests/ControlPointerSequenceTests.cs index 9a75e45..5776dd2 100644 --- a/tests/WinUIRichEditor.Tests/ControlPointerSequenceTests.cs +++ b/tests/WinUIRichEditor.Tests/ControlPointerSequenceTests.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections; using System.Collections.Generic; using System.Linq; @@ -319,6 +319,14 @@ private static Point TableMoveBorder(RichEditor ed, TableBlock tb) _ => "?", })); + // Two gestures at the same point inside ONE test are a double-click unless the timing is cleared — + // the press then selects a word instead of doing what the test is about (see Hosted). + private static void NoRepeat(RichEditor ed) + { + T.GetField("_lastPressTime", NP)!.SetValue(ed, DateTime.MinValue); + T.GetField("_clickCount", NP)!.SetValue(ed, 0); + } + private static void Select(RichEditor ed, Paragraph p, int from, int to) { T.GetField("_selStart", NP)!.SetValue(ed, new TextPointer(p, from)); @@ -487,6 +495,171 @@ public void ReplacingTheDocumentMidTextDrag_DisarmsIt() }); } + // ---- what the modifiers do (phase 3) ------------------------------------------------------------ + // These gestures had NO automated test before the pipeline: Ctrl and Shift were static reads of the + // real keyboard (InputKeyboardSource), which a test cannot set. They ride on the step now. + + [Fact] + public void CtrlAtTheDrop_CopiesTheTableInsteadOfMovingIt() + { + Hosted(ed => + { + Load(ed, Para("top"), Table(), Para("end")); + var tb = ed.Document!.Blocks.OfType().Single(); + var border = TableMoveBorder(ed, tb); + var cap = new FakeCapture(ed); + var drop = new Point(border.X, border.Y + 2000); + + ed.PointerPressedCore(Step(border, cap)); + ed.PointerMovedCore(Step(drop, cap, ctrl: true)); + ed.PointerReleasedCore(Step(drop, cap, ctrl: true)); + + // Copied, not moved: there are two tables and the ORIGINAL instance is still in the document. + Assert.Equal(2, ed.Document!.Blocks.OfType().Count()); + Assert.Contains(tb, ed.Document!.Blocks); + Assert.Equal("top,T,∅,T,end", Shape(ed)); // the drop's landing paragraph is left behind empty + }); + } + + [Fact] + public void CtrlAtTheDrop_CopiesTheDraggedTextInsteadOfMovingIt() + { + Hosted(ed => + { + Load(ed, Para("drag this text"), Para("target line")); + var paras = ed.Document!.Blocks.OfType().ToArray(); + Select(ed, paras[0], 0, 4); // "drag" + var inside = DocPointOf(ed, new TextPointer(paras[0], 2)); + var target = DocPointOf(ed, new TextPointer(paras[1], 6)); + var cap = new FakeCapture(ed); + + ed.PointerPressedCore(Step(inside, cap)); + Assert.True((bool)Field(ed, "_dragTextArmed")!, "the press inside the selection did not arm the drag"); + ed.PointerMovedCore(Step(target, cap, ctrl: true)); + ed.PointerReleasedCore(Step(target, cap, ctrl: true)); + + // A copy leaves the source intact and puts a second "drag" at the drop point. + string text = ed.GetPlainText(); + Assert.Contains("drag this text", text); + Assert.Equal(2, System.Text.RegularExpressions.Regex.Matches(text, "drag").Count); + }); + } + + [Fact] + public void CtrlClickOpensALink_APlainClickDoesNot_AndADragNeverDoes() + { + Hosted(ed => + { + var launched = new List(); + ed.LaunchOverride = u => { launched.Add(u.ToString()); return Task.CompletedTask; }; + try + { + var link = new Run { Text = "example", NavigateUri = "https://example.com/" }; + var p = new Paragraph { Inlines = { link } }; + Load(ed, p, Para("after")); + var para = ed.Document!.Blocks.OfType().First(); + var onLink = DocPointOf(ed, new TextPointer(para, 3)); + var cap = new FakeCapture(ed); + + // Plain click: the caret moves, nothing opens. + ed.PointerPressedCore(Step(onLink, cap)); + ed.PointerReleasedCore(Step(onLink, cap)); + Assert.Empty(launched); + + // Ctrl+click: opens the link under the POINTER. + NoRepeat(ed); + ed.PointerPressedCore(Step(onLink, cap, ctrl: true)); + ed.PointerReleasedCore(Step(onLink, cap, ctrl: true)); + Assert.Equal(new[] { "https://example.com/" }, launched); + } + finally { ed.LaunchOverride = null; } + }); + } + + [Fact] + public void InAViewer_APlainClickOpensTheLink_ButADragOverItOnlySelects() + { + Hosted(ed => + { + var launched = new List(); + ed.LaunchOverride = u => { launched.Add(u.ToString()); return Task.CompletedTask; }; + try + { + var p = new Paragraph { Inlines = { new Run { Text = "example link", NavigateUri = "https://example.com/" } } }; + Load(ed, p, Para("after")); + ed.IsReadOnly = true; + var para = ed.Document!.Blocks.OfType().First(); + var start = DocPointOf(ed, new TextPointer(para, 1)); + var far = DocPointOf(ed, new TextPointer(para, 10)); + var cap = new FakeCapture(ed); + + // A drag across the link selects it (browser convention) — and opens nothing. + ed.PointerPressedCore(Step(start, cap)); + ed.PointerMovedCore(Step(far, cap)); + ed.PointerReleasedCore(Step(far, cap)); + Assert.Empty(launched); + Assert.NotEqual(((TextPointer)Field(ed, "_selStart")!).Offset, + ((TextPointer)Field(ed, "_selEnd")!).Offset); // it selected instead + + // A click that stays put opens it. (Without this the second press at the same point is a + // DOUBLE-click, which selects the word — and a selection suppresses the launch.) + NoRepeat(ed); + ed.PointerPressedCore(Step(start, cap)); + ed.PointerReleasedCore(Step(start, cap)); + Assert.Equal(new[] { "https://example.com/" }, launched); + } + finally { ed.LaunchOverride = null; ed.IsReadOnly = false; } + }); + } + + [Fact] + public void ShiftPress_ExtendsTheSelectionFromWhereItWas() + { + Hosted(ed => + { + Load(ed, Para("first line here"), Para("second line here")); + var paras = ed.Document!.Blocks.OfType().ToArray(); + var a = DocPointOf(ed, new TextPointer(paras[0], 2)); + var b = DocPointOf(ed, new TextPointer(paras[1], 6)); + var cap = new FakeCapture(ed); + + ed.PointerPressedCore(Step(a, cap)); + ed.PointerReleasedCore(Step(a, cap)); + var start = (TextPointer)Field(ed, "_selStart")!; + Assert.Same(paras[0], start.Paragraph); + Assert.Equal(start.Offset, ((TextPointer)Field(ed, "_selEnd")!).Offset); // a plain click selects nothing + + ed.PointerPressedCore(Step(b, cap, shift: true)); + ed.PointerReleasedCore(Step(b, cap, shift: true)); + + // Shift moved only the far end: the anchor is still where the first click put it. + var end = (TextPointer)Field(ed, "_selEnd")!; + Assert.Same(paras[0], ((TextPointer)Field(ed, "_selStart")!).Paragraph); + Assert.Same(paras[1], end.Paragraph); + Assert.Equal(6, end.Offset); + }); + } + + [Fact] + public void TheWheel_ZoomsOnlyWithCtrl() + { + Hosted(ed => + { + Load(ed, Para("text")); + double at100 = ed.Zoom; + + Assert.False(ed.PointerWheelCore(120, ctrl: false), "a plain wheel was handled (the view could not scroll)"); + Assert.Equal(at100, ed.Zoom, 3); + + Assert.True(ed.PointerWheelCore(120, ctrl: true)); + Assert.True(ed.Zoom > at100, "Ctrl+wheel did not zoom in"); + Assert.True(ed.PointerWheelCore(-120, ctrl: true)); + Assert.Equal(at100, ed.Zoom, 2); + + Assert.False(ed.PointerWheelCore(0, ctrl: true)); // no notch, nothing to do + }); + } + // Control: the press core is the ordinary press too, not just the drag branches — a plain click moves // the caret and takes the pointer, so these tests are running the real path and not a drag-only corner. [Fact]