-
Notifications
You must be signed in to change notification settings - Fork 12
Expand file tree
/
Copy pathNativeDockingSmokeFixture.cs
More file actions
1840 lines (1627 loc) · 82.3 KB
/
NativeDockingSmokeFixture.cs
File metadata and controls
1840 lines (1627 loc) · 82.3 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
using Microsoft.UI.Reactor;
using Microsoft.UI.Reactor.Core;
using Microsoft.UI.Reactor.Docking;
using Microsoft.UI.Reactor.Docking.Native;
using Microsoft.UI.Reactor.Layout;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
using static Microsoft.UI.Reactor.Factories;
namespace Microsoft.UI.Reactor.AppTests.Host.SelfTest.Fixtures;
/// <summary>
/// Spec 045 §2.1 / §2.2 / §2.16 — minimal smoke fixture for the Phase 2
/// native renderer. Asserts that <see cref="DockManager"/> mounts into a
/// Reactor-native subtree (FlexPanel + TabView) without depending on
/// WinUI.Dock controls. Mirrors <see cref="DockingSmokeFixtures"/> in
/// shape so the two renderers are reviewed side by side.
/// </summary>
internal static class NativeDockingSmokeFixtures
{
internal class TwoPaneMountUpdateUnmount(Harness h) : SelfTestFixtureBase(h)
{
public override async Task RunAsync()
{
var host = H.CreateHost();
// P2: register the native renderer. Same call site as the
// XAML wrapper; last registration wins on the same TElement.
DockingNativeInterop.Register(host.Reconciler);
var pane1 = new DockableContent(
Title: "Solution Explorer",
Content: TextBlock("native-solution-content"),
Key: "tool:solution");
var pane2 = new DockableContent(
Title: "Properties",
Content: TextBlock("native-properties-content"),
Key: "tool:properties");
host.Mount(_ => new DockManager
{
Layout = new DockSplit(
Orientation.Horizontal,
new DockNode[] { pane1, pane2 }),
});
await Harness.Render();
// The native renderer mounts a FlexPanel for the split.
var flexes = H.FindAllControls<FlexPanel>(_ => true);
H.Check("NativeDock_FlexPanelMounted", flexes.Count >= 1);
// The leaf renderer for a bare DockableContent inlines the
// Content element — text markers must appear in the visual
// tree.
H.Check("NativeDock_Pane1ContentRendered",
H.FindText("native-solution-content") is not null);
H.Check("NativeDock_Pane2ContentRendered",
H.FindText("native-properties-content") is not null);
// ── Update: swap one pane's content ─────────────────────
host.Mount(_ => new DockManager
{
Layout = new DockSplit(
Orientation.Horizontal,
new DockNode[]
{
pane1,
pane2 with { Content = TextBlock("native-properties-updated") },
}),
});
await Harness.Render();
H.Check("NativeDock_PaneContentUpdated",
H.FindText("native-properties-updated") is not null);
H.Check("NativeDock_PreviousContentReplaced",
H.FindText("native-properties-content") is null);
// ── Unmount: replace with a different element ───────────
host.Mount(_ => TextBlock("native-docking-unmounted"));
await Harness.Render();
H.Check("NativeDock_UnmountedCleanly",
H.FindText("native-docking-unmounted") is not null);
H.Check("NativeDock_NoFlexPanelAfterUnmount",
H.FindAllControls<FlexPanel>(_ => true).Count == 0);
}
}
/// <summary>
/// Mounts a tab group and verifies that the native renderer wires
/// the TabView control with the right tab headers and that swapping
/// the selected tab preserves the surrounding tree.
/// </summary>
internal class TabGroupRendersToTabView(Harness h) : SelfTestFixtureBase(h)
{
public override async Task RunAsync()
{
var host = H.CreateHost();
DockingNativeInterop.Register(host.Reconciler);
host.Mount(_ => new DockManager
{
Layout = new DockTabGroup(new[]
{
new DockableContent("Alpha", TextBlock("native-body-alpha"), Key: "k:a", CanClose: true),
new DockableContent("Beta", TextBlock("native-body-beta"), Key: "k:b"),
}),
});
await Harness.Render();
var tabs = H.FindAllControls<TabView>(_ => true);
H.Check("NativeDock_TabView_Mounted", tabs.Count >= 1);
var tab = tabs.FirstOrDefault();
H.Check("NativeDock_TabView_HasTwoTabs", tab?.TabItems.Count == 2);
// The selected (first) tab's content is rendered into the visual tree.
H.Check("NativeDock_TabView_FirstBodyRendered",
H.FindText("native-body-alpha") is not null);
host.Mount(_ => TextBlock("native-tabs-unmounted"));
await Harness.Render();
}
}
/// <summary>
/// Spec 045 §2.17 — asserts that a function component rendered inside
/// a docked pane sees the live <c>DockContext</c> slots: <c>UseDockHost</c>
/// returns a non-null model, <c>UsePane</c> returns identity matching
/// the enclosing leaf, <c>UseActivePaneKey</c> reflects the manager's
/// <c>ActiveDocument</c>, and <c>UseIsActivePane</c> flips correctly.
/// </summary>
internal class DockContextHooksResolveOnRealMount(Harness h) : SelfTestFixtureBase(h)
{
public override async Task RunAsync()
{
var host = H.CreateHost();
DockingNativeInterop.Register(host.Reconciler);
// Build the docking tree inside the mount lambda each call —
// matches the standard Reactor pattern where Content elements
// are constructed fresh per render. (Storing element refs
// outside Mount() means same-reference shallow-equality skips
// the consumer's re-render before context propagation runs.)
DockManager Build(bool alphaActive)
{
var alpha = new DockableContent(
Title: "Alpha",
Key: "k:alpha",
Content: Memo(ctx =>
{
var dockHost = ctx.UseDockHost();
var pane = ctx.UsePane();
var isActive = ctx.UseIsActivePane();
return VStack(
TextBlock($"alpha-host:{(dockHost is null ? "null" : "ok")}"),
TextBlock($"alpha-pane-title:{pane.Title}"),
TextBlock($"alpha-pane-key:{pane.Key}"),
TextBlock($"alpha-active:{isActive}"));
}));
var beta = new DockableContent(
Title: "Beta",
Key: "k:beta",
Content: Memo(ctx =>
{
var isActive = ctx.UseIsActivePane();
return TextBlock($"beta-active:{isActive}");
}));
return new DockManager
{
Layout = new DockTabGroup(new[] { alpha, beta }),
ActiveDocument = alphaActive ? alpha : beta,
};
}
host.Mount(_ => Build(alphaActive: true));
await Harness.Render();
H.Check("DockHooks_Host_Resolved",
H.FindText("alpha-host:ok") is not null);
H.Check("DockHooks_Pane_TitleResolved",
H.FindText("alpha-pane-title:Alpha") is not null);
H.Check("DockHooks_Pane_KeyResolved",
H.FindText("alpha-pane-key:k:alpha") is not null);
H.Check("DockHooks_IsActivePane_TrueWhenActive",
H.FindText("alpha-active:True") is not null);
host.Mount(_ => Build(alphaActive: false));
await Harness.Render();
H.Check("DockHooks_IsActivePane_FlipsOnActiveChange",
H.FindText("alpha-active:False") is not null);
host.Mount(_ => TextBlock("hooks-done"));
await Harness.Render();
}
}
/// <summary>
/// Spec 045 §2.5 — side strip + side popup. Pinning a pane to the
/// LeftSide renders a button strip; clicking the button opens a
/// light-dismiss Popup with the pane's content. Click the button
/// again (or close the popup) collapses it.
/// </summary>
internal class SidePopupExpandsAndCollapses(Harness h) : SelfTestFixtureBase(h)
{
public override async Task RunAsync()
{
var host = H.CreateHost();
DockingNativeInterop.Register(host.Reconciler);
DockManager Build() => new()
{
Layout = new DockTabGroup(new[]
{
new DockableContent("Center", TextBlock("center-body"), Key: "k:center"),
}),
LeftSide = new[]
{
new DockableContent(
Title: "Outline",
Key: "k:outline",
Content: TextBlock("outline-popup-body"),
CanPin: true),
},
};
host.Mount(_ => Build());
await Harness.Render();
// Strip button rendered with the pane title.
var stripButton = H.FindButton("Outline");
H.Check("SidePopup_StripButton_Rendered", stripButton is not null);
// No open popups initially. Use VisualTreeHelper.GetOpenPopups
// against the host's XamlRoot — WinUI hosts open Popups in a
// private PopupRoot that VTH child-walks don't traverse, so
// GetOpenPopups is the supported probe.
var xamlRoot = stripButton!.XamlRoot;
int OpenCount() => Microsoft.UI.Xaml.Media.VisualTreeHelper
.GetOpenPopupsForXamlRoot(xamlRoot).Count;
H.Check("SidePopup_NotOpenInitially", OpenCount() == 0);
// Click → popup opens.
H.ClickButton("Outline");
await Harness.Render();
H.Check("SidePopup_OpensOnClick", OpenCount() >= 1);
// Click again → toggles closed.
H.ClickButton("Outline");
await Harness.Render();
H.Check("SidePopup_TogglesClosedOnRepeatClick", OpenCount() == 0);
host.Mount(_ => TextBlock("side-popup-done"));
await Harness.Render();
}
}
/// <summary>
/// Spec 045 §2.6 — floating windows are real Reactor windows. Tear a
/// pane out via the programmatic API; assert that a new
/// <see cref="Microsoft.UI.Reactor.ReactorWindow"/> is registered and
/// closing it removes it from the tracker.
/// </summary>
internal class FloatingWindowOpensAsRealWindow(Harness h) : SelfTestFixtureBase(h)
{
public override async Task RunAsync()
{
var host = H.CreateHost();
DockingNativeInterop.Register(host.Reconciler);
host.Mount(_ => new DockManager
{
Layout = new DockTabGroup(new[]
{
new DockableContent("Center", TextBlock("center-body"), Key: "k:center"),
}),
});
await Harness.Render();
var baselineCount = DockFloatingTracker.Count;
var pane = new DockableContent(
Title: "Output (floating)",
Key: "k:output-floating",
Content: TextBlock("floating-pane-body"));
// The harness opens its own primary Window outside ReactorApp's
// registry, so a fixture-spawned floating window can otherwise
// become the framework's PrimaryWindow and trip
// ShutdownPolicy.OnPrimaryWindowClosed when the test closes it.
// Pin the policy to None for the duration of this fixture.
var savedPolicy = ReactorApp.ShutdownPolicy;
ReactorApp.ShutdownPolicy = ShutdownPolicy.Explicit;
try
{
var floatingWindow = DockFloatingWindow.Open(pane, width: 600, height: 400);
await Harness.Render();
H.Check("FloatingWindow_OpenedAsRealReactorWindow",
floatingWindow is not null);
H.Check("FloatingWindow_RegisteredWithTracker",
DockFloatingTracker.Count == baselineCount + 1);
H.Check("FloatingWindow_TrackerSnapshotIncludesIt",
DockFloatingTracker.Snapshot().Contains(floatingWindow));
// Close the floating window — tracker should drop it.
floatingWindow!.Close();
await Harness.Render();
H.Check("FloatingWindow_RemovedFromTrackerOnClose",
DockFloatingTracker.Count == baselineCount);
}
finally
{
ReactorApp.ShutdownPolicy = savedPolicy;
}
host.Mount(_ => TextBlock("floating-done"));
await Harness.Render();
}
}
/// <summary>
/// Spec 045 §2.10 — verify that the mounted host component registers
/// the chord delegates into <see cref="DockChordBridge"/> on render
/// and that the delegates remain invokable without throwing. The
/// state-mutation side-effect path (selectedIndexStore → re-render →
/// TabView SelectedIndex update) is exercised visually in the
/// showcase and locked down by `DockHostKeyboardTests` unit tests;
/// the sub-host the fixture mounts doesn't flush internal-state
/// re-renders through `Harness.Render`'s primary-host wait, so we
/// don't assert observable side effects here.
/// </summary>
internal class KeyboardChordsRegisteredOnMount(Harness h) : SelfTestFixtureBase(h)
{
public override async Task RunAsync()
{
var host = H.CreateHost();
DockingNativeInterop.Register(host.Reconciler);
var a = new DockableContent("Alpha", TextBlock("body-alpha"), Key: "k:a", CanClose: true);
var b = new DockableContent("Beta", TextBlock("body-beta"), Key: "k:b", CanClose: true);
var managerEl = new DockManager
{
Layout = new DockTabGroup(new[] { a, b }, SelectedIndex: 0),
ActiveDocument = a,
};
host.Mount(_ => managerEl);
await Harness.Render();
var chords = DockChordBridge.Get(managerEl);
H.Check("Chords_BridgeRegistered_OnMount", chords is not null);
H.Check("Chords_NextTab_DelegateNonNull", chords?.NextTab is not null);
H.Check("Chords_PrevTab_DelegateNonNull", chords?.PrevTab is not null);
H.Check("Chords_CloseActive_DelegateNonNull", chords?.CloseActive is not null);
H.Check("Chords_EnterDropMode_DelegateNonNull", chords?.EnterDropMode is not null);
// Observable side-effects on TabView.SelectedIndex live in
// selectedIndexStore (UseRef captured inside the host's
// Render closure). The harness's Render flushes the primary
// host's reconcile pass but does not flush the sub-host's
// bumpTick-driven re-render that NextTab/PrevTab schedule —
// the dictionary update path is locked down by
// DockHostKeyboardTests at the unit tier. Here we assert
// the harness-observable contract: chord delegates are
// wired and invocable without throwing.
chords?.NextTab();
chords?.PrevTab();
chords?.CloseActive();
chords?.EnterDropMode();
H.Check("Chords_Invocation_DoesNotThrow", true);
host.Mount(_ => TextBlock("chords-done"));
await Harness.Render();
}
}
/// <summary>
/// Spec 045 §2.14 — verify the drag-start gate refuses panes whose
/// <see cref="DockableContent.CanMove"/> is <c>false</c>. The gate
/// lives inside the host component's <c>HandleTabDragStarting</c>
/// closure, so this fixture asserts the contract indirectly: it
/// confirms that <see cref="DockDragSession.Begin"/> (the production
/// session-start path the component calls AFTER the gate) succeeds
/// when invoked directly, and documents that the gate's predicate
/// is verified via <see cref="DockableContent.CanMove"/> property
/// tests in <c>DockApiShapeTests</c> + <c>DocumentToolWindowTests</c>.
/// </summary>
internal class PermissionGate_PinnedPaneSurfaceCheck(Harness h) : SelfTestFixtureBase(h)
{
public override async Task RunAsync()
{
var host = H.CreateHost();
DockingNativeInterop.Register(host.Reconciler);
// CanMove is an init-only property on the base record, not on
// the positional P1 ctor — set it via 'with'.
var pinned = new DockableContent(
Title: "Pinned",
Content: TextBlock("body-pinned"),
Key: "k:pinned",
CanClose: false) with { CanMove = false };
var movable = new DockableContent(
Title: "Movable",
Content: TextBlock("body-movable"),
Key: "k:movable",
CanClose: true);
var managerEl = new DockManager
{
Layout = new DockTabGroup(new[] { pinned, movable }),
};
host.Mount(_ => managerEl);
await Harness.Render();
// Exercise the *production* drag-start gate via the bridge
// the host registers each render. This is the same predicate
// HandleTabDragStarting applies — refuses CanMove=false,
// accepts CanMove=true. The prior version of this fixture
// only asserted record-field values it constructed itself,
// never invoking the gate.
var gate = DockDragGateBridge.Get(managerEl);
H.Check("PermGate_BridgeRegistered", gate is not null);
if (gate is not null)
{
DockDragSession.ResetForTest();
bool acceptedPinned = gate(pinned, sourceTabIndex: 0);
H.Check("PermGate_RefusesPinnedPane", !acceptedPinned);
H.Check("PermGate_PinnedRefusal_NoSessionStarted",
DockDragSession.Current is null or { IsActive: false });
DockDragSession.ResetForTest();
bool acceptedMovable = gate(movable, sourceTabIndex: 1);
H.Check("PermGate_AcceptsMovablePane", acceptedMovable);
H.Check("PermGate_MovableAccept_StartsSession",
DockDragSession.Current is { IsActive: true });
DockDragSession.Current?.End();
}
host.Mount(_ => TextBlock("permgate-done"));
await Harness.Render();
}
}
/// <summary>
/// Spec 045 §2.18 — verifies the "component is the rehydrator" pattern:
/// app state holds a collection, the render lambda maps it through
/// <c>.Select</c> into <c>DockableContent</c> records, and adds /
/// removes / reorders propagate via keyed reconciliation. No
/// <c>DocumentsSource</c> binding API exists — Reactor's functional
/// composition is the data-to-tree mapping.
/// </summary>
internal class CompositionDrivenDocumentsRespectKeyedReconciliation(Harness h) : SelfTestFixtureBase(h)
{
public override async Task RunAsync()
{
var host = H.CreateHost();
DockingNativeInterop.Register(host.Reconciler);
// App-level state: a list of document descriptors. The
// wrapper component maps these to DockableContent each render.
// Mutating the list + re-mounting demonstrates that the
// mapping is just .Select — no separate template / binding
// surface is required.
var docs = new List<(string Id, string Title, string Body)>
{
("d1", "First", "body-first"),
("d2", "Second", "body-second"),
};
DockManager Build() => new DockManager
{
Layout = new DockTabGroup(
docs.Select(d => new DockableContent(
Title: d.Title,
Key: d.Id,
Content: TextBlock(d.Body),
CanClose: true)).ToArray()),
};
host.Mount(_ => Build());
await Harness.Render();
// Baseline: two tabs visible, both bodies in the tree.
var initialTabs = H.FindAllControls<TabView>(_ => true);
H.Check("DocsByComposition_InitialTwoTabs",
initialTabs.Count == 1 && initialTabs[0].TabItems.Count == 2);
H.Check("DocsByComposition_InitialFirstBodyRendered",
H.FindText("body-first") is not null);
// Capture the TabView reference for identity comparison after
// the state change.
var tabViewBefore = initialTabs[0];
// Add a document to app state, re-mount.
docs.Add(("d3", "Third", "body-third"));
host.Mount(_ => Build());
await Harness.Render();
var afterAddTabs = H.FindAllControls<TabView>(_ => true);
H.Check("DocsByComposition_AddedThirdTab",
afterAddTabs.Count == 1 && afterAddTabs[0].TabItems.Count == 3);
// Keyed reconciliation: same TabView instance preserved.
H.Check("DocsByComposition_TabViewIdentityPreservedOnAdd",
ReferenceEquals(afterAddTabs[0], tabViewBefore));
// Remove the middle document — third tab should remain, second's body gone.
docs.RemoveAt(1);
host.Mount(_ => Build());
await Harness.Render();
var afterRemoveTabs = H.FindAllControls<TabView>(_ => true);
H.Check("DocsByComposition_RemovedToTwoTabs",
afterRemoveTabs.Count == 1 && afterRemoveTabs[0].TabItems.Count == 2);
H.Check("DocsByComposition_RemovedBodyGone",
H.FindText("body-second") is null);
H.Check("DocsByComposition_SurvivingBodiesRendered",
H.FindText("body-first") is not null);
host.Mount(_ => TextBlock("composition-driven-done"));
await Harness.Render();
}
}
/// <summary>
/// Spec 045 §2.1 — programmatic splitter-drag fixture. Mounts an
/// IDE-style nested layout, fires <c>ResizeDelta</c> events directly
/// on the splitter controls (simulating a pointer drag), and asserts
/// that the FlexPanel's per-child <c>FlexGrow</c> attached value
/// shifts as expected. Isolates the render → reconcile → FlexPanel
/// pipeline from the pointer-capture / hit-test plumbing so failures
/// fingerprint quickly: if these pass and the showcase doesn't, the
/// bug is in <see cref="DockSplitterControl"/>'s pointer handling.
/// </summary>
internal class SplitterProgrammaticResizeAcrossRenders(Harness h) : SelfTestFixtureBase(h)
{
public override async Task RunAsync()
{
var host = H.CreateHost();
DockingNativeInterop.Register(host.Reconciler);
// IDE-style nested layout. Apps typically rebuild Layout each
// render — model that via a state counter the host bumps to
// force fresh DockSplit instances.
DockManager Build()
{
return new DockManager
{
Layout = new DockSplit(
Orientation.Vertical,
new DockNode[]
{
// Top row — horizontal split with two leaves.
new DockSplit(
Orientation.Horizontal,
new DockNode[]
{
new DockableContent("Editor",
TextBlock("editor-body"),
Key: "k:editor"),
new DockableContent("Tools",
TextBlock("tools-body"),
Key: "k:tools"),
}),
// Bottom row — horizontal split with two leaves.
new DockSplit(
Orientation.Horizontal,
new DockNode[]
{
new DockableContent("Output",
TextBlock("output-body"),
Key: "k:output"),
new DockableContent("Terminal",
TextBlock("terminal-body"),
Key: "k:terminal"),
}),
}),
};
}
host.Mount(_ => Build());
await Harness.Render();
// Discover the three splitter controls: 1 in outer (rows
// splitter — horizontal bar) + 1 in each inner split (column
// splitters — vertical bars). Distinguish by Direction.
var splitters = H.FindAllControls<DockSplitterControl>(_ => true);
H.Check("SplitProg_ThreeSplittersMounted", splitters.Count == 3);
var rowSplitter = splitters.FirstOrDefault(s => s.Direction == DockSplitterDirection.Rows);
var colSplitters = splitters.Where(s => s.Direction == DockSplitterDirection.Columns).ToList();
H.Check("SplitProg_RowSplitterFound", rowSplitter is not null);
H.Check("SplitProg_TwoColumnSplitters", colSplitters.Count == 2);
// Capture the initial grow values from each splitter's parent
// FlexPanel. Row direction parent splits vertically; column
// direction parents split horizontally.
double GrowOf(UIElement child) => FlexPanel.GetGrow(child);
double[] GrowsFor(DockSplitterControl s)
{
var parent = Microsoft.UI.Xaml.Media.VisualTreeHelper.GetParent(s) as FlexPanel;
if (parent is null) return [];
var result = new double[parent.Children.Count];
for (int i = 0; i < parent.Children.Count; i++)
result[i] = GrowOf(parent.Children[i]);
return result;
}
var beforeRowGrows = GrowsFor(rowSplitter!);
var beforeCol0Grows = GrowsFor(colSplitters[0]);
var beforeCol1Grows = GrowsFor(colSplitters[1]);
Console.WriteLine($"# beforeRow=[{string.Join(",", beforeRowGrows)}]");
Console.WriteLine($"# beforeCol0=[{string.Join(",", beforeCol0Grows)}]");
Console.WriteLine($"# beforeCol1=[{string.Join(",", beforeCol1Grows)}]");
H.Check("SplitProg_InitialRowsEqual",
beforeRowGrows.Length >= 3
&& Math.Abs(beforeRowGrows[0] - beforeRowGrows[2]) < 0.0001);
// ── Drag #1: shrink the row splitter's leading row by 100 DIP.
// Fire ResizeDelta directly with hostExtent matching the
// splitter's host. The control's OnDelta closure must compute
// a new ratio and trigger re-render.
FireResizeDelta(rowSplitter!, delta: 100, isFinal: false);
FireResizeDelta(rowSplitter!, delta: 0, isFinal: true);
await Harness.Render();
var afterRowGrows1 = GrowsFor(rowSplitter!);
Console.WriteLine($"# afterRowDrag1=[{string.Join(",", afterRowGrows1)}]");
H.Check("SplitProg_RowDragShiftedLeadingDown",
afterRowGrows1.Length >= 3 && afterRowGrows1[0] < beforeRowGrows[0] - 0.001);
// ── Drag #2 on the SAME splitter: shrink another 50 DIP.
// Verifies ratio accumulation across drags (not snap-back).
FireResizeDelta(rowSplitter!, delta: 50, isFinal: false);
FireResizeDelta(rowSplitter!, delta: 0, isFinal: true);
await Harness.Render();
var afterRowGrows2 = GrowsFor(rowSplitter!);
Console.WriteLine($"# afterRowDrag2=[{string.Join(",", afterRowGrows2)}]");
H.Check("SplitProg_RowDragCumulates",
afterRowGrows2[0] < afterRowGrows1[0] - 0.001);
// ── Drag the FIRST column splitter — should NOT affect the
// row splitter's ratios, nor the OTHER column splitter's.
FireResizeDelta(colSplitters[0], delta: 80, isFinal: false);
FireResizeDelta(colSplitters[0], delta: 0, isFinal: true);
await Harness.Render();
var col0After = GrowsFor(colSplitters[0]);
var col1After = GrowsFor(colSplitters[1]);
var rowAfterCol = GrowsFor(rowSplitter!);
Console.WriteLine($"# afterCol0Drag col0=[{string.Join(",", col0After)}] col1=[{string.Join(",", col1After)}] row=[{string.Join(",", rowAfterCol)}]");
H.Check("SplitProg_Col0DragShiftedLeading",
col0After[0] < beforeCol0Grows[0] - 0.001);
H.Check("SplitProg_Col1Untouched",
col1After.Length == beforeCol1Grows.Length
&& Math.Abs(col1After[0] - beforeCol1Grows[0]) < 0.0001);
H.Check("SplitProg_RowUntouchedByColDrag",
rowAfterCol.Length == afterRowGrows2.Length
&& Math.Abs(rowAfterCol[0] - afterRowGrows2[0]) < 0.0001);
// ── Force a re-render by re-mounting a fresh Build(). All
// DockSplit references change. Ratios MUST survive (the
// tree-position-key fix).
host.Mount(_ => Build());
await Harness.Render();
var splittersAfterRemount = H.FindAllControls<DockSplitterControl>(_ => true);
var rowAfterRemount = splittersAfterRemount.FirstOrDefault(s => s.Direction == DockSplitterDirection.Rows);
H.Check("SplitProg_RowSplitterStillPresentAfterRemount", rowAfterRemount is not null);
var rowGrowsAfterRemount = GrowsFor(rowAfterRemount!);
Console.WriteLine($"# afterRemount row=[{string.Join(",", rowGrowsAfterRemount)}]");
H.Check("SplitProg_RowRatiosSurvivedRemount",
rowGrowsAfterRemount.Length == afterRowGrows2.Length
&& Math.Abs(rowGrowsAfterRemount[0] - afterRowGrows2[0]) < 0.0001);
host.Mount(_ => TextBlock("split-prog-done"));
await Harness.Render();
}
/// <summary>
/// Fires the splitter's internal ResizeDelta event using the
/// splitter's live host extent. Bypasses pointer/keyboard.
/// </summary>
private static void FireResizeDelta(DockSplitterControl splitter, double delta, bool isFinal)
{
var hostExtent = splitter.GetHostExtent();
if (hostExtent < 1) hostExtent = 1000;
var args = new DockSplitterDeltaEventArgs(delta, splitter.Direction, hostExtent, isFinal);
splitter.RaiseResizeDeltaForTest(args);
}
}
/// <summary>
/// Spec 045 §2.1 — rapid-fire drag simulator. Fires many small
/// ResizeDelta events in quick succession (no render await between
/// them) to model what a real pointer drag does. If the ratios shift
/// smoothly cumulatively, the rewiring-during-render path is safe;
/// if they snap or freeze, the bug is in the closure-recapture flow
/// fired by mid-drag re-renders.
/// </summary>
internal class SplitterRapidFireDragSurvivesRerender(Harness h) : SelfTestFixtureBase(h)
{
public override async Task RunAsync()
{
var host = H.CreateHost();
DockingNativeInterop.Register(host.Reconciler);
DockManager Build() => new()
{
Layout = new DockSplit(
Orientation.Horizontal,
new DockNode[]
{
new DockableContent("L", TextBlock("l-body"), Key: "k:l"),
new DockableContent("R", TextBlock("r-body"), Key: "k:r"),
}),
};
host.Mount(_ => Build());
await Harness.Render();
var splitter = H.FindAllControls<DockSplitterControl>(_ => true).FirstOrDefault();
H.Check("SplitFire_SplitterMounted", splitter is not null);
FlexPanel? parent = Microsoft.UI.Xaml.Media.VisualTreeHelper.GetParent(splitter!) as FlexPanel;
H.Check("SplitFire_ParentIsFlexPanel", parent is not null);
double LeadingGrow() => FlexPanel.GetGrow(parent!.Children[0]);
var initial = LeadingGrow();
Console.WriteLine($"# initial leading grow={initial:F4}");
// Fire 20 incremental deltas with NO await between them. Each
// increment is 4 DIP. Total = 80 DIP. Mid-drag re-renders are
// queued; the closures must continue to find the right ratios.
for (int i = 0; i < 20; i++)
{
FireResizeDelta(splitter!, delta: 4, isFinal: false);
}
FireResizeDelta(splitter!, delta: 0, isFinal: true);
await Harness.Render();
var afterRapidGrow = LeadingGrow();
Console.WriteLine($"# afterRapid leading grow={afterRapidGrow:F4} (delta from initial={initial - afterRapidGrow:F4})");
// 80 DIP of accumulated drag on a ~945 DIP host should shift
// the leading ratio by ~80/945 ≈ 0.085. Allow some slack for
// the actual hostExtent the test runs in.
H.Check("SplitFire_LeadingShrankByAccumulatedDelta",
afterRapidGrow < initial - 0.01);
// Fire 20 MORE deltas — the ratios should continue to shift,
// NOT snap back, NOT freeze.
for (int i = 0; i < 20; i++)
{
FireResizeDelta(splitter!, delta: 4, isFinal: false);
}
FireResizeDelta(splitter!, delta: 0, isFinal: true);
await Harness.Render();
var afterSecond = LeadingGrow();
Console.WriteLine($"# afterSecond leading grow={afterSecond:F4}");
H.Check("SplitFire_SecondRapidBurstCumulates",
afterSecond < afterRapidGrow - 0.01);
// Reverse direction — drag the trailing child back.
for (int i = 0; i < 30; i++)
{
FireResizeDelta(splitter!, delta: -4, isFinal: false);
}
FireResizeDelta(splitter!, delta: 0, isFinal: true);
await Harness.Render();
var afterReverse = LeadingGrow();
Console.WriteLine($"# afterReverse leading grow={afterReverse:F4}");
H.Check("SplitFire_ReverseDragGrowsLeading",
afterReverse > afterSecond + 0.01);
host.Mount(_ => TextBlock("rapid-fire-done"));
await Harness.Render();
}
private static void FireResizeDelta(DockSplitterControl splitter, double delta, bool isFinal)
{
var hostExtent = splitter.GetHostExtent();
if (hostExtent < 1) hostExtent = 1000;
var args = new DockSplitterDeltaEventArgs(delta, splitter.Direction, hostExtent, isFinal);
splitter.RaiseResizeDeltaForTest(args);
}
}
/// <summary>
/// Spec 045 §2.3 — drop-target overlay smoke. Mounts a DockManager with
/// <c>ShowDropTargets = true</c>, asserts the 9 buttons are present in
/// the visual tree at minimum 44×44 DIP, drives a confirm via the
/// internal test hook, and verifies the model gets the
/// <see cref="DockTarget"/> the user picked. Verifies the dismiss
/// callback fires when the overlay is dismissed (Esc).
/// </summary>
internal class DropTargetOverlayShowsAndDismisses(Harness h) : SelfTestFixtureBase(h)
{
public override async Task RunAsync()
{
var host = H.CreateHost();
DockingNativeInterop.Register(host.Reconciler);
DockTarget? lastHover = null;
DockTarget? lastConfirmed = null;
int dismissCount = 0;
DockManager Build(bool show) => new()
{
Layout = new DockTabGroup(new[]
{
new DockableContent("Center", TextBlock("dt-center-body"), Key: "k:center"),
}),
ShowDropTargets = show,
OnDropTargetHovered = t => lastHover = t,
OnDropTargetConfirmed = t => lastConfirmed = t,
OnDropTargetsDismissed = () => dismissCount++,
};
// ── Initial mount with overlay OFF — no overlay control yet.
host.Mount(_ => Build(show: false));
await Harness.Render();
var noOverlay = H.FindAllControls<DockDropTargetOverlayControl>(_ => true);
H.Check("DropTarget_NotMountedWhenFlagFalse", noOverlay.Count == 0);
// ── Flip on — overlay mounts.
host.Mount(_ => Build(show: true));
await Harness.Render();
var overlays = H.FindAllControls<DockDropTargetOverlayControl>(_ => true);
H.Check("DropTarget_OverlayMounted", overlays.Count == 1);
var overlay = overlays[0];
// 9 target buttons + 1 preview rectangle ⇒ 10 children. Each
// button is a Border with Width == ButtonSizeDip (44).
var borders = new global::System.Collections.Generic.List<Microsoft.UI.Xaml.Controls.Border>();
foreach (var child in overlay.Children)
if (child is Microsoft.UI.Xaml.Controls.Border b) borders.Add(b);
var targetButtons = borders.FindAll(b =>
b.Width >= DockDropTargetOverlayControl.ButtonSizeDip - 0.001
&& b.Height >= DockDropTargetOverlayControl.ButtonSizeDip - 0.001
&& b.IsTabStop);
H.Check("DropTarget_NineButtonsRendered", targetButtons.Count == 9);
H.Check("DropTarget_ButtonsAtLeast44Dip",
targetButtons.TrueForAll(b => b.Width >= 44.0 && b.Height >= 44.0));
// ── Programmatically confirm SplitLeft via the test hook.
// The model callback should receive the same target.
overlay.ConfirmTargetForTest(DockTarget.SplitLeft);
await Harness.Render();
H.Check("DropTarget_ConfirmCallbackFired", lastConfirmed == DockTarget.SplitLeft);
// ── Programmatic hover updates preview rect + callback.
overlay.SetHoveredForTest(DockTarget.DockRight);
await Harness.Render();
H.Check("DropTarget_HoverCallbackFired", lastHover == DockTarget.DockRight);
var bounds = overlay.PreviewBounds;
H.Check("DropTarget_PreviewRectVisible",
bounds.Width > 0 && bounds.Height > 0);
// Right-edge strip should sit at the right of the overlay —
// i.e. its X is near (overlay.ActualWidth - bounds.Width).
if (overlay.ActualWidth > 0)
{
var expectedX = overlay.ActualWidth - bounds.Width;
H.Check("DropTarget_DockRightPreviewAtRightEdge",
Math.Abs(bounds.X - expectedX) < 1.0);
}
// ── Clear hover — preview hides.
overlay.SetHoveredForTest(null);
await Harness.Render();
var clearBounds = overlay.PreviewBounds;
H.Check("DropTarget_PreviewHidesOnNoHover", clearBounds.IsEmpty);
// ── Flip overlay OFF — control unmounts.
host.Mount(_ => Build(show: false));
await Harness.Render();
var gone = H.FindAllControls<DockDropTargetOverlayControl>(_ => true);
H.Check("DropTarget_UnmountedWhenFlagFlipsOff", gone.Count == 0);
host.Mount(_ => TextBlock("dt-overlay-done"));
await Harness.Render();
// dismissCount comes from the Esc path; not exercised here since
// the headless harness doesn't deliver real keystrokes. Kept
// as a sentinel so the callback wire-up doesn't go untested.
_ = dismissCount;
}
}
/// <summary>
/// Spec 045 §2.4 — smoke fixture for the drag pipeline. Simulates a
/// tab drag by directly beginning a <see cref="DockDragSession"/> and
/// then confirming a target on the overlay (using the §2.3 control's
/// test hook). Asserts the host mutates its layout per the target and
/// fires <c>OnContentDocked</c>. Bypasses real pointer events since
/// the headless harness doesn't deliver them.
/// </summary>
internal class DragSessionConfirmMutatesLayout(Harness h) : SelfTestFixtureBase(h)
{
public override async Task RunAsync()
{
DockDragSession.ResetForTest();
var host = H.CreateHost();
DockingNativeInterop.Register(host.Reconciler);
DockableContent? docked = null;
DockTarget? dockedAt = null;
var paneA = new DockableContent("Tab A", TextBlock("body-a"), Key: "h:a", CanClose: true);
var paneB = new DockableContent("Tab B", TextBlock("body-b"), Key: "h:b", CanClose: true);
host.Mount(_ => new DockManager
{
Layout = new DockTabGroup(new[] { paneA, paneB }),
OnContentDocked = args => { docked = args.Content; dockedAt = args.Target; },
});
await Harness.Render();
H.Check("DragMutate_TabViewMounted",
H.FindAllControls<TabView>(_ => true).Count == 1);
// ── Simulate drag begin (what TabDragStarting would fire).
var manager = new DockManager
{
Layout = new DockTabGroup(new[] { paneA, paneB }),
};
var session = DockDragSession.Begin(paneA, manager, sourceTabIndex: 0);
H.Check("DragMutate_SessionBegan", session is { IsActive: true });
H.Check("DragMutate_SourcePane", ReferenceEquals(session!.Source, paneA));
// Force overlay to appear by re-mounting with ShowDropTargets
// = true. (The §2.4 path flips this internally via dragActive
// state; the smoke harness can't deliver a real drag, so we
// exercise the overlay via the manager prop instead.)
host.Mount(_ => new DockManager
{
Layout = new DockTabGroup(new[] { paneA, paneB }),
ShowDropTargets = true,
OnContentDocked = args => { docked = args.Content; dockedAt = args.Target; },
});
await Harness.Render();
var overlay = H.FindAllControls<DockDropTargetOverlayControl>(_ => true).FirstOrDefault();
H.Check("DragMutate_OverlayMounted", overlay is not null);
// ── Confirm SplitRight. The host's OnConfirm closure looks at
// DockDragSession.Current and mutates the layout via override.
overlay!.ConfirmTargetForTest(DockTarget.SplitRight);
await Harness.Render();
H.Check("DragMutate_OnContentDocked_Fired", docked is not null);
H.Check("DragMutate_OnContentDocked_PaneMatches",
ReferenceEquals(docked, paneA));
H.Check("DragMutate_OnContentDocked_TargetMatches",
dockedAt == DockTarget.SplitRight);
// Session should be torn down after confirm.
H.Check("DragMutate_SessionEnded",
DockDragSession.Current is null || !DockDragSession.Current.IsActive);
// The host's effective layout (visible in the visual tree)
// should now be a horizontal split (the original group on the
// left + paneA on the right since the mutator moved it). The
// tab strip is still present for the remaining group.
await Harness.Render();
var flexes = H.FindAllControls<Microsoft.UI.Reactor.Layout.FlexPanel>(_ => true);
H.Check("DragMutate_LayoutBecameSplit", flexes.Count >= 1);
host.Mount(_ => TextBlock("drag-pipeline-done"));
await Harness.Render();
DockDragSession.ResetForTest();
}
}
/// <summary>
/// Visual demo fixture — mounts an IDE-style layout and drives each
/// splitter programmatically with paced delays so a human observer
/// can watch the panes resize step by step. Asserts the same as the
/// other splitter fixtures but with ~800 ms gaps between operations.
/// </summary>
/// <summary>
/// Spec 045 §2.3 per-group drop overlay visual demo. Mounts a 2×2
/// layout of four tab groups (G1..G4) plus a 5th "mover" doc that
/// starts inside G1. Walks the mover through every (group × target)
/// position — Center / SplitLeft / SplitRight / SplitTop /
/// SplitBottom — for each of the four groups (20 moves total),
/// resetting between moves so the human observer sees each landing
/// clearly. Uses <see cref="DockLayoutMutator.MovePaneToGroupTarget"/>