mirror of
https://github.com/microsoft/microsoft-ui-xaml.git
synced 2026-10-28 16:35:19 +08:00
## Fixes | Internal issue | Type | Change | |---|---|---| | https://task.ms/64475625 | Bug | Column headers are a templated, restylable `TableViewColumnHeader` with visual states | | https://task.ms/64475623 | Bug | Header typography comes from the header Style, not C++ local values | | https://task.ms/64143451 | Bug | Header column separators are always drawn (no new property); hide them by overriding `TabularSurfaceHeaderSeparatorBrush` | | https://task.ms/63996584 | Task | Column headers expose the UIA Transform pattern so UIA clients can resize columns | | https://task.ms/64475611 | Bug | Outer border uses new `TabularSurfaceOuterBorderBrush` (`CardStrokeColorDefault`); grid-line values were in #12349 | | https://task.ms/64475629 | Bug | Default group header uses theme tokens (SemiBold key style, 8px spacing); the item count inherits the header `Foreground` | | https://task.ms/64652961 | Task | Row recycling no longer regenerates cells or refreshes row chrome twice (scroll performance) | | https://task.ms/63790855 | Task | Sort: `CanSort` is enforced and `SortByColumn(column, None)` always clears; a sort through a source another table owns returns false; no spurious `Sorted`; an app-owned sort survives a `CanSort`/`CanUserSortColumns` opt-out | | https://task.ms/63787021 | Task | Cell automation-peer constructor validates its arguments; `BeginEdit` rolls back when it throws | Not changed: https://task.ms/64512732 (by design: Auto columns follow content width), https://task.ms/64475627 (design decision: keep chevron sort glyphs), https://task.ms/64513223 (investigation only; fix deferred). ## PR Type - [x] Bugfix - [x] Feature - [ ] Code style update (formatting, renaming) - [ ] Refactoring (no functional changes, no API changes) - [ ] Build related changes - [x] Documentation content changes - [ ] Other (please describe): ## Description Follow-up to #12349 (merged) and #12285. Column headers move from code-built Grids to a new templated `TableViewColumnHeader`, restylable through `TableView.ColumnHeaderStyle` or `TableViewColumn.HeaderStyle`. Header peers gain the UIA Transform pattern for column resize. Sort behavior is tightened around `CanSort`, opt-outs and shared `TableViewSource` ownership. Row recycling reuses cells. Outer border, header lines and the default group header follow Fluent tokens. Review-driven fixes included here: - The header Style's `TargetType` `TypeName` is kept alive while it is compared; a Style with only `BasedOn` typed is applied, and its setters are read before applying it seals the Style. Incompatible, untyped or cyclic `BasedOn` Styles are ignored with a debug diagnostic. - A header disabled by its Style reports `IsEnabled=false` and is not invokable through UIA. - UIA `Transform.Resize` converts physical pixels through the table's full visual scale (not only `RasterizationScale`), and a width change below the drag jitter deadband is written once, exactly. - Disabling a header in its High Contrast hover/pressed state keeps the disabled foreground (state order fixed); the disabled sort glyph follows the text brush across theme changes. - A `CanSort` opt-out made inside `BeginningEdit` is replayed after the edit it let open closes, and rebuilds headers even when a `Sorted` handler throws. - Header rebuilds caused by `Content`/`Header` or `Columns` changes keep focus in the header band, respect an app's own focus move, and release `UIElement` headers back to the app. - A disabled group header does not dim its item count twice. The count opacity follows `ContentTemplate` changes while the header is disabled. <details><summary>Product changes by file</summary> | File | +/- | What changed | Items | |---|---|---|---| | `TableView/TableView.cpp` (+ .h) | +684 / −382 | `RebuildHeaders` creates `TableViewColumnHeader` controls; applies `ColumnHeaderStyle`/`HeaderStyle` with TargetType/BasedOn checks; density MinHeight/Padding only when the Style doesn't set them<br>`ReleaseHeaderCells` and header-focus handling for removed/replaced columns, re-entrant rebuilds and app focus moves<br>Header bottom rule thickness set on `PART_HeaderRow`; header lines no longer follow `GridLinesVisibility`<br>Row prepare/clear refresh chrome once; `TryResizeColumnFromAutomation` writes small widths exactly | 64475625, 64475623, 64143451, 64652961, 63996584, 63790855 | | `TableView/TableViewColumnHeader.cpp` (+ .h; replaces `TableViewHeaderCell.h`) | +435 / −210 | New sealed templated `ContentControl` with read-only `Column`<br>Resolves `PART_ContentPresenter`, `PART_GridLine`, `PART_SortIndicatorHost`, `PART_ResizeGripperHost`; hosts the sort glyph and gripper<br>Drives `CommonStates`/`HighContrastStates`/`SortStates`; disabled sort glyph bound to the text brush | 64475625, 64475623 | | `TableView/TableViewRow.cpp` (+ .h) | +191 / −35 | Recycled rows keep their cells unless cell automation peers exist; chrome refreshed once; tooltips re-applied on recycle<br>`BeginCellEdit` restores display element, edit state and focus on throw | 64652961, 63787021 | | `TableView/TableView.xaml` | +128 / −18 | Default `TableViewColumnHeader` Style/template: typography setters, visual states, parts<br>Outer border uses `TabularSurfaceOuterBorderBrush`; header lines use `TabularSurfaceHeaderSeparatorBrush`<br>Group header: `TableViewGroupExpanderSpacing` (8), padding 8,8,12,8, key/count text styles, count inherits `Foreground` | 64475625, 64475623, 64475611, 64475629 | | `TableView/TableViewColumnHeaderAutomationPeer.cpp` (+ .h) | +127 / −9 | `ITransformProvider`: `Resize` converts physical px through the visual scale; `CanResize` tracks gripper availability (pinned → false)<br>`IsEnabled` follows the header; name read from the content presenter | 63996584, 64475625 | | `TableView/TableView_Sort.cpp` | +113 / −39 | `SortByColumn(None)` bypasses the `CanSort` gate; other sorts refused<br>Sort through a source owned by another table returns false; implicit clears keep the app's sort and raise no `Sorted`; app-lit columns left alone on opt-out | 63790855 | | `TableView/TableView_Layout.cpp` | +74 / −14 | Realized-row walk only when a visible Auto column exists<br>Transform `CanResize` changes raised for existing header peers | 64652961, 63996584 | | `TableView/TableView_Editing.cpp` | +63 / −15 | `BeginEdit` unwinds on throw; `CanSort` opt-out during `BeginningEdit` queued until the edit closes | 63787021, 63790855 | | `TableView/TableViewCellAutomationPeer.cpp` (+ .h, `TableViewCell.h`) | +48 / −0 | Public constructor throws `E_INVALIDARG` for null args, negative/out-of-range index or a foreign column; internal path uses `TrustedOwnerTag` | 63787021 | | `TableView/TableViewGroupHeader.cpp` (+ .h) | +39 / −0 | Default count's opacity is not applied twice when disabled | 64475629 | | `TableView/TableView.idl` | +31 / −13 | `TableViewColumnHeader`, `TableView.ColumnHeaderStyle`, `TableViewColumn.HeaderStyle`<br>`CanSort`/`SortByColumn`/`ToggleSortDirection`/`GridLinesVisibility` docs; cell-peer `E_INVALIDARG`; group-header parts | 64475625, 63790855, 64143451, 63787021 | | `TableView/TableView_Columns.cpp` | +19 / −0 | Releases removed columns' header cells; old `UIElement` `Header` returned to the app on change | 64475625 | | `TableView/TableViewColumn.cpp` | +7 / −0 | `HeaderStyle` change queues a header rebuild | 64475625 | | `TableView/TableViewSource.h` | +6 / −0 | `IsOwnedByOther` helper for sort sync | 63790855 | | `CommonStyles/TabularSurfaces_themeresources.xaml` | +5 / −2 | New `TabularSurfaceOuterBorderBrush` (Dark/Light/HC); group count opacity documented as secondary-over-primary text (0.77 Dark) | 64475611, 64475629 | | `TableView/TableView.vcxitems` | +2 / −1 | Swap `TableViewHeaderCell.h` for `TableViewColumnHeader.*` | 64475625 | | Generated / metadata (`Generated/TableView.properties.*`, `Generated/TableViewColumn.properties.*`, `dll-tabular/XamlMetadataProviderGenerated.h`) | +123 / −0 | Codegen for the new properties and type | 64475625 | **Total: 17 rows, 29 product files, +2117 / −738.** Also: 19 test files (+3718 / −32), 3 docs (+161 / −35), 6 sample files (+2 / −9). </details> ### New preview API (needs API review) - **`TableViewColumnHeader`**: sealed, templated `ContentControl`, created only by `TableView`. - Read-only `Column` property. - Template parts (optional): `PART_ContentPresenter`, `PART_GridLine`, `PART_SortIndicatorHost`, `PART_ResizeGripperHost`. - Visual state groups: `CommonStates`, `HighContrastStates`, `SortStates`. - **`TableView.ColumnHeaderStyle`**: Style applied to every column header. - **`TableViewColumn.HeaderStyle`**: per-column Style; wins over `TableView.ColumnHeaderStyle`. There is no `HeaderGridLinesVisibility` property. `GridLinesVisibility` affects body lines only; header lines are always drawn. To hide them, override `TabularSurfaceHeaderSeparatorBrush` with `Transparent`. ### Current Behavior - Headers are built in C++ with no template, no visual states and forced local typography, so apps can't restyle them. Header separators don't show. - Header peers report `HeaderItem` without Transform, so UIA clients can't resize columns. - `SortByColumn` ignores `CanSort`; `CanSort=false` on a sorted column leaves the sort in place; sorting through a source another table owns reports success; some implicit clears raise `Sorted(None)` while the app's sort remains. - The cell peer constructor accepts invalid arguments; a throw in `BeginEdit` leaves edit state half-open. - Recycling a row rebuilt every cell, refreshed chrome up to twice and invalidated the table's measure. - The outer border has no dedicated brush; the default group header uses raw weights and off-grid spacing. ### New Behavior - Headers use the templated `TableViewColumnHeader` (see API above); typography, padding and brushes come from Style setters. - Header peers implement `ITransformProvider` through the existing resize path (Min/Max clamping, scale conversion; pinned columns report `CanResize=false`). - `CanSort=false` blocks header clicks, `SortByColumn` (except `None`) and `ToggleSortDirection`, and clears the column's own active sort; sorts through a source owned by another table return false; `Sorted` is raised only when the table's sort state changes. - Cell peer constructor throws `E_INVALIDARG` on bad input; `BeginEdit` rolls back on failure. - Recycled rows reuse their cells unless cell automation peers exist; chrome refreshes once. > **Visible changes** > - Outer border uses `CardStrokeColorDefault`. > - Header lines are always shown, even when `GridLinesVisibility` is `None` or `Horizontal`. > - Group header restyled: 8px spacing; the count follows the header `Foreground`. > - Disabled header text and sort glyph dim. > - TableView samples no longer opt out of High Contrast backplates app-wide. ## Customer Impact Restylable column headers, visible header separators, Fluent-aligned outer border and group headers, UIA column resize for assistive technology, smoother scrolling and predictable sort events. ## Regression Potential - [ ] Low risk — isolated change, limited scope - [x] Medium risk — touches shared components or public APIs - [ ] High risk — architectural or breaking API change Header construction (focus restore, frozen columns, UIA peer, tooltips, resize, sort glyph) moved to a templated control. Sort event semantics are tighter: `CanSort` is enforced on programmatic sorts and fewer `Sorted` events fire. Outer border color and always-on header lines change visibly. Covered by new and existing tests. ## How Has This Been Tested? - [x] I have performed a self-review of my own code - [x] I have added tests to cover my changes - [x] Existing tests pass locally Full TableView API and interaction suites on test VMs: 877/882 pass at 224d10d5c; 3 Localization tests are [Ignore] and 2 intermittent failures passed 3/3 on rerun. Product, test apps and TableView sample apps build (x64 chk). Also covered: `VerifyDisabledGroupHeaderCountFollowsContentTemplateChanges`. Key new tests: - `VerifyColumnHeaderTemplateParts`, `VerifyColumnHeaderSortStates`, `VerifyColumnHeaderTypographyHasNoLocalValues` - `VerifyColumnHeaderStyleApplies`, `VerifyBasedOnOnlyColumnHeaderStyleApplies`, `VerifyIncompatibleColumnHeaderStyleIsIgnored` - `VerifyHeaderGridLinesShowWhateverTheBodyGridLines`, `VerifyTransparentHeaderSeparatorBrushHidesHeaderLines`, `VerifyGridLineAndOuterBorderBrushesMatchFluentStrokeTokens` - `VerifyDisablingHighContrastHoveredHeaderKeepsDisabledForeground`, `VerifyDefaultGroupHeaderCountInheritsForegroundThatFollowsStates` - `VerifyTransformResizeAccountsForAncestorScale`, `VerifyTransformResizeSmallWidthChangeWritesOnce`, `VerifyHeaderDisabledByStyleIsNotEnabledOrInvokable` - `VerifySortByColumnRespectsCanSort`, `VerifyImplicitClearKeepsSourceSort`, `VerifyUngatingKeepsAppLitColumnWithoutEvents`, `VerifySortOptOutDuringBeginningEditKeepsTheEditOpen` - `VerifyRecycledRowWithoutCellPeersReusesItsCells`, `VerifyHeaderFocusStaysInHeaderBandWhenColumnsReplaced`, `VerifyBeginEditThatThrowsRollsBackAndAllowsALaterEdit`, `VerifyCellPeerConstructorValidatesArguments` --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 94961bb8-d187-483c-bece-19e203752c67
1067 lines
54 KiB
C#
1067 lines
54 KiB
C#
// Copyright (c) Microsoft Corporation. All rights reserved.
|
|
// Licensed under the MIT License. See LICENSE in the project root for license information.
|
|
|
|
using System;
|
|
using System.Collections.Generic;
|
|
using System.Runtime.InteropServices;
|
|
using Common;
|
|
using Microsoft.UI.Xaml.Tests.MUXControls.InteractionTests.Infra;
|
|
using Microsoft.UI.Xaml.Tests.MUXControls.InteractionTests.Common;
|
|
|
|
using WEX.TestExecution;
|
|
using WEX.TestExecution.Markup;
|
|
using WEX.Logging.Interop;
|
|
|
|
using Microsoft.Windows.Apps.Test.Foundation;
|
|
using Microsoft.Windows.Apps.Test.Foundation.Controls;
|
|
using MUXTestInfra.Shared.Infra;
|
|
|
|
using Point = System.Drawing.Point;
|
|
|
|
using static Microsoft.UI.Xaml.Tests.MUXControls.InteractionTests.TableViewInteractionTestHelpers;
|
|
using static Microsoft.UI.Xaml.Tests.MUXControls.TableViewShared.TableViewTestPageFacts;
|
|
|
|
namespace Microsoft.UI.Xaml.Tests.MUXControls.InteractionTests
|
|
{
|
|
// TableView layout interaction tests: pointer resize, scrolling and frozen columns, and right-to-left.
|
|
//
|
|
// These run out of process against the real UIA provider tree. They own the *gesture route* into the
|
|
// column-sizing and scroll-sync contracts the API tests (TableView_Sizing_APITests.cs and API 4.x)
|
|
// already cover programmatically: the API test proves the state machine, this file proves real pointer
|
|
// and wheel input reaches it.
|
|
//
|
|
// Product finding #13 (measured this session) constrains every test here: an out-of-proc client that
|
|
// asks a TableViewRow peer for its children fail-fasts the app (0xC0000420 in Microsoft.UI.Xaml.dll —
|
|
// TableViewRowAutomationPeer::GetChildrenCore manufactures fresh cell peers per call). Therefore
|
|
// NOTHING below descends into a row. Column width and frozen-column position are read exclusively from
|
|
// the *header* peers, which enumerate safely, and from row-level bounding rectangles. Header peers are
|
|
// reached by the measured-safe descent: tableView.Children[0] is the header host whose children are the
|
|
// per-column header peers (.Name == the column header text).
|
|
//
|
|
// Resize is driven by coordinate-based pointer input derived from the header peer's BoundingRectangle
|
|
// (grab just inside its right edge, where the ResizeGripper straddles the column boundary) rather than
|
|
// by searching for the gripper element: whether a header peer's own children enumerate safely is
|
|
// UNVERIFIED, and a search that walked into a row would crash.
|
|
[TestClass]
|
|
public class TableViewLayoutInteractionTests
|
|
{
|
|
// One mouse-wheel notch. Matches the WHEEL_DELTA the input stack injects.
|
|
private const int mouseWheelDelta = 120;
|
|
|
|
[ClassInitialize]
|
|
[TestProperty("RunAs", "User")]
|
|
[TestProperty("Classification", "Integration")]
|
|
[TestProperty("Platform", "Any")]
|
|
[TestProperty("MUXControlsTestSuite", "SuiteB")]
|
|
public static void ClassInitialize(TestContext testContext)
|
|
{
|
|
TestEnvironment.Initialize(testContext);
|
|
}
|
|
|
|
[TestCleanup]
|
|
public void TestCleanup()
|
|
{
|
|
TestCleanupHelper.Cleanup();
|
|
RestartAppIfLongRunning();
|
|
}
|
|
|
|
#region 6. Pointer resize
|
|
|
|
[TestMethod]
|
|
[TestProperty("Description", "Verifies dragging a column's resize gripper with the pointer changes that column's width.")]
|
|
public void PointerResizeDragChangesColumnWidth()
|
|
{
|
|
// Derives from TableView-dev-spec.md:127 ("the table owns ... clamping the reported delta to
|
|
// MinWidth/MaxWidth, writing Width") and the resize Protocol paragraph (:129):
|
|
// BeginDrag -> DragDelta -> EndDrag, with TotalDelta applied against the captured anchor.
|
|
// API 4.x proves ResizeGripper.BeginDrag/TryDrag/EndDrag drive that write; this test proves a
|
|
// real pointer drag on the gripper reaches it. A failure means gripper hit-testing, pointer
|
|
// capture, or the manipulation->TryDrag handler is broken even though the width engine is fine.
|
|
using (var setup = new TestSetupHelper(PageName))
|
|
{
|
|
// Basic pivot is selected by default; its TableView authors CanUserResizeColumns="True"
|
|
// and every column defaults CanResize="true" (TableView.idl:140, :469).
|
|
UIObject tableView = FindElement.ById(BasicTable);
|
|
if (tableView == null)
|
|
{
|
|
Verify.Fail("BasicTableView was not found on the test page.");
|
|
return;
|
|
}
|
|
|
|
UIObject nameHeader = FindColumnHeader(tableView, "Name");
|
|
if (nameHeader == null)
|
|
{
|
|
Verify.Fail("The 'Name' column header peer was not found on the header host.");
|
|
return;
|
|
}
|
|
|
|
double widthBefore = nameHeader.BoundingRectangle.Width;
|
|
|
|
// Drag the boundary to the right by ~80px. Positive grows in reading order (dev-spec:131),
|
|
// which under LTR is rightward, so the column must get wider.
|
|
const int dragBy = 80;
|
|
DragColumnBoundary(nameHeader, dragBy, cancelWithEscape: false);
|
|
|
|
Wait.ForIdle();
|
|
double widthAfter = FindColumnHeader(tableView, "Name").BoundingRectangle.Width;
|
|
|
|
Log.Comment("Name column header width before={0}, after={1}, dragBy={2}.", widthBefore, widthAfter, dragBy);
|
|
|
|
// Compare the delta, not an absolute (bounding rectangles are screen-relative). Allow slack
|
|
// for the gripper width, the 0.5 DIP deadband, and manipulation rounding — the claim is that
|
|
// the drag reached the width engine and grew the column, not that it grew by exactly 80.
|
|
Verify.IsGreaterThan(
|
|
widthAfter,
|
|
widthBefore + 20.0,
|
|
"A rightward pointer drag on the gripper must widen the column (dev-spec:127 writes Width from the drag delta).");
|
|
}
|
|
}
|
|
|
|
[TestMethod]
|
|
[TestProperty("Description", "Verifies pressing Escape during a pointer resize drag restores the column's authored width.")]
|
|
public void PointerResizeEscapeCancelsResize()
|
|
{
|
|
// Derives from the Protocol paragraph at TableView-dev-spec.md:129: "DragCompleted.Canceled
|
|
// distinguishes a torn-down gesture (Escape ...) from a release: on cancel the host restores the
|
|
// authored GridLength." The Basic 'Name' column authors Width="160" (with MinWidth="60"), so a
|
|
// cancelled drag must return the header to ~160 regardless of how far the pointer travelled.
|
|
// A failure means Escape does not reach the gripper's cancel path, or the host applied the drag
|
|
// delta permanently instead of reverting to the authored value.
|
|
using (var setup = new TestSetupHelper(PageName))
|
|
{
|
|
UIObject tableView = FindElement.ById(BasicTable);
|
|
if (tableView == null)
|
|
{
|
|
Verify.Fail("BasicTableView was not found on the test page.");
|
|
return;
|
|
}
|
|
|
|
UIObject nameHeader = FindColumnHeader(tableView, "Name");
|
|
if (nameHeader == null)
|
|
{
|
|
Verify.Fail("The 'Name' column header peer was not found on the header host.");
|
|
return;
|
|
}
|
|
|
|
double widthBefore = nameHeader.BoundingRectangle.Width;
|
|
|
|
// Drag well past the deadband so the width is visibly changing mid-gesture, then Escape to
|
|
// cancel before releasing the pointer.
|
|
DragColumnBoundary(nameHeader, 90, cancelWithEscape: true);
|
|
|
|
Wait.ForIdle();
|
|
double widthAfter = FindColumnHeader(tableView, "Name").BoundingRectangle.Width;
|
|
|
|
Log.Comment("Name column header width before={0}, after cancelled drag={1}.", widthBefore, widthAfter);
|
|
|
|
// The authored width is what a cancel restores; the pre-drag width equals it, so the header
|
|
// must land back within a small tolerance of where it started.
|
|
Verify.IsLessThanOrEqual(
|
|
Math.Abs(widthAfter - widthBefore),
|
|
8.0,
|
|
"Escape during a pointer drag must restore the authored width (dev-spec:129 cancel semantics).");
|
|
}
|
|
}
|
|
|
|
[TestMethod]
|
|
[TestProperty("Description", "Verifies unloading the TableView mid-drag releases pointer capture and leaves no wedged resize state.")]
|
|
public void UnloadDuringPendingResizeLeavesNoWedgedState()
|
|
{
|
|
// Moved from API 16.4. A "pending resize" is a captured pointer between BeginDrag and EndDrag;
|
|
// the API can drive that lifecycle but only a real drag holds the pointer CAPTURE, which is the
|
|
// half that can wedge. dev-spec:129 lists "a canceled contact, the header rebuilt mid-drag" among
|
|
// the torn-down gestures the primitive must survive; an unload is exactly that teardown. Nothing
|
|
// in the peer surface opens or observes capture, so this is interaction-only.
|
|
//
|
|
// Shape: the DelayedUnloadButton removes the Basic table 1.5s after its click and re-adds it 1.5s
|
|
// later, so the unload lands while the pointer is captured mid-drag. Proof of "not wedged" is that
|
|
// a normal resize works again after the reload - identical to PointerResizeDragChangesColumnWidth.
|
|
// StatusTextBlock ("Unloaded"/"Reloaded") is page state used only as a timing story; it is never
|
|
// asserted - the assertion is the width change of the reloaded control.
|
|
using (var setup = new TestSetupHelper(PageName))
|
|
{
|
|
UIObject tableView = FindElement.ById(BasicTable);
|
|
if (tableView == null)
|
|
{
|
|
Verify.Fail("BasicTableView was not found on the test page.");
|
|
return;
|
|
}
|
|
|
|
UIObject nameHeader = FindColumnHeader(tableView, "Name");
|
|
if (nameHeader == null)
|
|
{
|
|
Verify.Fail("The 'Name' column header peer was not found on the header host.");
|
|
return;
|
|
}
|
|
|
|
UIObject unloadButton = FindElement.ById(DelayedUnloadButton);
|
|
if (unloadButton == null)
|
|
{
|
|
Verify.Fail("DelayedUnloadButton was not found on the test page.");
|
|
return;
|
|
}
|
|
|
|
// Arm the delayed unload (removes the table at +1.5s, re-adds it at +3.0s).
|
|
InputHelper.Tap(unloadButton);
|
|
|
|
// Begin a real resize drag and hold it open past the unload point.
|
|
var bounds = nameHeader.BoundingRectangle;
|
|
int grabOffsetX = (bounds.Width / 2) - 1;
|
|
int startX = bounds.Left + bounds.Width - 1;
|
|
int y = bounds.Top + (bounds.Height / 2);
|
|
|
|
InputHelper.LeftMouseButtonDown(nameHeader, grabOffsetX, 0);
|
|
InputHelper.MoveMouse(new Point(startX + 40, y));
|
|
|
|
// Hold with the pointer captured while the table is torn out from under the drag (~1.5s).
|
|
Wait.ForMilliseconds(2200);
|
|
|
|
// Continue and release into empty space; the moves use absolute points so nothing dereferences
|
|
// the now-detached header peer.
|
|
InputHelper.MoveMouse(new Point(startX + 80, y));
|
|
InputHelper.LeftMouseButtonUp();
|
|
Wait.ForIdle();
|
|
|
|
// Wait for the same instance to be re-added (reload at +3.0s from the click).
|
|
Wait.ForMilliseconds(2000);
|
|
Wait.ForIdle();
|
|
|
|
// The old UIObject is stale after the remove/add; re-query the reloaded control.
|
|
UIObject reloaded = FindElement.ById(BasicTable);
|
|
if (reloaded == null)
|
|
{
|
|
Verify.Fail("BasicTableView did not come back after the delayed reload.");
|
|
return;
|
|
}
|
|
|
|
UIObject reloadedHeader = FindColumnHeader(reloaded, "Name");
|
|
if (reloadedHeader == null)
|
|
{
|
|
Verify.Fail("The 'Name' column header peer was not found after reload.");
|
|
return;
|
|
}
|
|
|
|
double widthBefore = reloadedHeader.BoundingRectangle.Width;
|
|
|
|
// A normal resize must succeed - if capture wedged, the gripper would ignore this drag.
|
|
DragColumnBoundary(reloadedHeader, 80, cancelWithEscape: false);
|
|
Wait.ForIdle();
|
|
|
|
double widthAfter = FindColumnHeader(FindElement.ById(BasicTable), "Name").BoundingRectangle.Width;
|
|
|
|
Log.Comment("After mid-drag unload/reload: Name width before={0}, after normal resize={1}.", widthBefore, widthAfter);
|
|
|
|
Verify.IsGreaterThan(
|
|
widthAfter,
|
|
widthBefore + 20.0,
|
|
"A resize after a mid-drag unload/reload must still widen the column - i.e. no capture wedged the gripper (dev-spec:129).");
|
|
}
|
|
}
|
|
|
|
[TestMethod]
|
|
[TestProperty("Description", "Verifies losing the gripper's pointer capture mid-drag cancels the resize and leaves the gripper usable.")]
|
|
public void PointerResizeCaptureLostMidDragCancelsResize()
|
|
{
|
|
HoldResizeThroughDelayedTeardown(DelayedGripperCaptureLossButton, pressOnly: false,
|
|
"Gripper capture released", null, "Losing capture mid-drag");
|
|
}
|
|
|
|
[TestMethod]
|
|
[TestProperty("Description", "Verifies disabling the table while the gripper holds the pointer cancels the resize and leaves the gripper usable once re-enabled.")]
|
|
public void PointerResizeDisabledWhileCapturedCancelsResize()
|
|
{
|
|
HoldResizeThroughDelayedTeardown(DelayedDisableButton, pressOnly: false,
|
|
"Disabled", "Enabled", "Disabling while captured");
|
|
}
|
|
|
|
[TestMethod]
|
|
[TestProperty("Description", "Verifies unloading the table while a gripper press holds capture, before any drag starts, leaves the gripper usable after reload.")]
|
|
public void PointerPressOnGripperThenUnloadLeavesNoWedgedState()
|
|
{
|
|
HoldResizeThroughDelayedTeardown(DelayedUnloadButton, pressOnly: true,
|
|
"Unloaded", "Reloaded", "Unloading after a press");
|
|
}
|
|
|
|
private static void HoldResizeThroughDelayedTeardown(string teardownButtonId, bool pressOnly, string teardownStatus, string restoredStatus, string scenario)
|
|
{
|
|
using (var setup = new TestSetupHelper(PageName))
|
|
{
|
|
UIObject nameHeader = FindColumnHeader(FindElement.ById(BasicTable), "Name");
|
|
if (nameHeader == null)
|
|
{
|
|
Verify.Fail("The 'Name' column header peer was not found on the header host.");
|
|
return;
|
|
}
|
|
|
|
var teardown = FindElement.ById<Button>(teardownButtonId);
|
|
if (teardown == null)
|
|
{
|
|
Verify.Fail(teardownButtonId + " was not found on the test page.");
|
|
return;
|
|
}
|
|
|
|
double widthBefore = nameHeader.BoundingRectangle.Width;
|
|
var bounds = nameHeader.BoundingRectangle;
|
|
int grabOffsetX = (bounds.Width / 2) - 1;
|
|
int startX = bounds.Left + bounds.Width - 1;
|
|
int y = bounds.Top + (bounds.Height / 2);
|
|
|
|
InputHelper.LeftMouseButtonDown(nameHeader, grabOffsetX, 0);
|
|
try
|
|
{
|
|
if (!pressOnly)
|
|
{
|
|
InputHelper.MoveMouse(new Point(startX + 40, y));
|
|
}
|
|
// UIA, not a click: the pointer stays pressed on the gripper.
|
|
teardown.Invoke();
|
|
if (!WaitForStatusText(teardownStatus))
|
|
{
|
|
Verify.Fail("The page did not report '" + teardownStatus + "' while the pointer was held.");
|
|
return;
|
|
}
|
|
Wait.ForIdle();
|
|
if (!pressOnly)
|
|
{
|
|
InputHelper.MoveMouse(new Point(startX + 80, y));
|
|
}
|
|
}
|
|
finally
|
|
{
|
|
InputHelper.LeftMouseButtonUp();
|
|
Wait.ForIdle();
|
|
}
|
|
|
|
if (restoredStatus != null)
|
|
{
|
|
if (!WaitForStatusText(restoredStatus))
|
|
{
|
|
Verify.Fail("The page did not report '" + restoredStatus + "' after the teardown.");
|
|
return;
|
|
}
|
|
Wait.ForIdle();
|
|
}
|
|
|
|
UIObject header = FindColumnHeader(FindElement.ById(BasicTable), "Name");
|
|
if (header == null)
|
|
{
|
|
Verify.Fail("The 'Name' column header peer was not found after the teardown.");
|
|
return;
|
|
}
|
|
|
|
double widthAfterTeardown = header.BoundingRectangle.Width;
|
|
Log.Comment("{0}: Name width before={1}, after the release={2}.", scenario, widthBefore, widthAfterTeardown);
|
|
Verify.IsLessThanOrEqual(Math.Abs(widthAfterTeardown - widthBefore), 8.0,
|
|
scenario + " must cancel the resize: the abandoned drag must not keep its width.");
|
|
|
|
DragColumnBoundary(header, 80, cancelWithEscape: false);
|
|
Wait.ForIdle();
|
|
double widthAfterResize = FindColumnHeader(FindElement.ById(BasicTable), "Name").BoundingRectangle.Width;
|
|
Log.Comment("{0}: Name width after a normal resize={1}.", scenario, widthAfterResize);
|
|
Verify.IsGreaterThan(widthAfterResize, widthAfterTeardown + 20.0,
|
|
scenario + " must leave the gripper usable: a later resize must widen the column.");
|
|
}
|
|
}
|
|
|
|
[StructLayout(LayoutKind.Sequential)]
|
|
private struct CURSORINFO
|
|
{
|
|
public int cbSize;
|
|
public int flags;
|
|
public IntPtr hCursor;
|
|
public int ptX;
|
|
public int ptY;
|
|
}
|
|
|
|
[DllImport("user32.dll", SetLastError = true)]
|
|
private static extern bool GetCursorInfo(ref CURSORINFO pci);
|
|
|
|
[DllImport("user32.dll")]
|
|
private static extern IntPtr LoadCursor(IntPtr hInstance, IntPtr lpCursorName);
|
|
|
|
[DllImport("user32.dll")]
|
|
private static extern IntPtr GetForegroundWindow();
|
|
|
|
[DllImport("user32.dll")]
|
|
private static extern bool SetForegroundWindow(IntPtr hWnd);
|
|
|
|
[DllImport("user32.dll")]
|
|
private static extern IntPtr GetAncestor(IntPtr hWnd, uint gaFlags);
|
|
|
|
[DllImport("user32.dll")]
|
|
private static extern uint GetDpiForWindow(IntPtr hWnd);
|
|
|
|
private const uint GA_ROOT = 2;
|
|
private const int CURSOR_SHOWING = 0x1;
|
|
private static readonly IntPtr IDC_ARROW = new IntPtr(32512);
|
|
private static readonly IntPtr IDC_SIZEWE = new IntPtr(32644);
|
|
|
|
// Age has no HeaderToolTip, whose popup could open under the pointer and own the cursor.
|
|
private const string CursorTestColumn = "Age";
|
|
|
|
// Null while the system cursor is hidden or suppressed (e.g. the session has no mouse).
|
|
private static IntPtr? ReadCursor()
|
|
{
|
|
var info = new CURSORINFO { cbSize = Marshal.SizeOf<CURSORINFO>() };
|
|
Verify.IsTrue(GetCursorInfo(ref info), "GetCursorInfo must succeed.");
|
|
return (info.flags & CURSOR_SHOWING) != 0 ? info.hCursor : (IntPtr?)null;
|
|
}
|
|
|
|
// The cursor is updated asynchronously after the pointer moves, so poll until several
|
|
// consecutive samples agree rather than accepting a single transient match. Returns false,
|
|
// reporting the test Skipped, when the cursor is not showing: there is no cursor to verify.
|
|
private static bool VerifyCursorBecomes(IntPtr expected, string message, int requiredMatches = 3)
|
|
{
|
|
var samples = new List<string>();
|
|
var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(2);
|
|
IntPtr? cursor;
|
|
int matches = 0;
|
|
while (true)
|
|
{
|
|
cursor = ReadCursor();
|
|
var sample = cursor.HasValue ? string.Format("0x{0:X}", cursor.Value.ToInt64()) : "hidden";
|
|
if (samples.Count == 0 || samples[samples.Count - 1] != sample)
|
|
{
|
|
samples.Add(sample);
|
|
}
|
|
matches = cursor == expected ? matches + 1 : 0;
|
|
if (matches >= requiredMatches || DateTime.UtcNow >= deadline)
|
|
{
|
|
break;
|
|
}
|
|
Wait.ForMilliseconds(50);
|
|
}
|
|
|
|
if (matches < requiredMatches && !cursor.HasValue)
|
|
{
|
|
Log.Result(TestResult.Skipped, string.Format("{0} The system cursor is not showing; sampled [{1}].", message, string.Join(", ", samples)));
|
|
return false;
|
|
}
|
|
|
|
Verify.IsTrue(matches >= requiredMatches,
|
|
string.Format("{0} Expected 0x{1:X} for {2} consecutive samples; sampled [{3}].",
|
|
message, expected.ToInt64(), requiredMatches, string.Join(", ", samples)));
|
|
return true;
|
|
}
|
|
|
|
private static double GetAppWindowScale()
|
|
{
|
|
uint dpi = GetDpiForWindow(GetAncestor(TestEnvironment.Application.CoreWindow.NativeWindowHandle, GA_ROOT));
|
|
return dpi == 0 ? 1.0 : dpi / 96.0;
|
|
}
|
|
|
|
private static void EnsureAppWindowForeground()
|
|
{
|
|
IntPtr window = GetAncestor(TestEnvironment.Application.CoreWindow.NativeWindowHandle, GA_ROOT);
|
|
var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(2);
|
|
while (GetForegroundWindow() != window && DateTime.UtcNow < deadline)
|
|
{
|
|
SetForegroundWindow(window);
|
|
Wait.ForMilliseconds(100);
|
|
}
|
|
Verify.AreEqual(window, GetForegroundWindow(), "The test app window must be in the foreground.");
|
|
}
|
|
|
|
[TestMethod]
|
|
[TestProperty("Description", "Verifies the system cursor is the west-east size cursor while the mouse rests, moves or drags on a column gripper.")]
|
|
public void PointerOverGripperShowsResizeCursor()
|
|
{
|
|
using (var setup = new TestSetupHelper(PageName))
|
|
{
|
|
UIObject tableView = FindElement.ById(BasicTable);
|
|
if (tableView == null)
|
|
{
|
|
Verify.Fail("BasicTableView was not found on the test page.");
|
|
return;
|
|
}
|
|
|
|
UIObject header = FindColumnHeader(tableView, CursorTestColumn);
|
|
if (header == null)
|
|
{
|
|
Verify.Fail(string.Format("The '{0}' column header peer was not found on the header host.", CursorTestColumn));
|
|
return;
|
|
}
|
|
|
|
EnsureAppWindowForeground();
|
|
IntPtr sizeWE = LoadCursor(IntPtr.Zero, IDC_SIZEWE);
|
|
IntPtr arrow = LoadCursor(IntPtr.Zero, IDC_ARROW);
|
|
|
|
var bounds = header.BoundingRectangle;
|
|
// The 8-DIP gripper is right-aligned in the header cell; aim at its centre.
|
|
int gripperX = bounds.Left + bounds.Width - (int)Math.Round(4.0 * GetAppWindowScale());
|
|
int edgeX = bounds.Left + bounds.Width - 1;
|
|
int y = bounds.Top + (bounds.Height / 2);
|
|
var centre = new Point(bounds.Left + (bounds.Width / 2), y);
|
|
|
|
InputHelper.MoveMouse(centre);
|
|
Wait.ForIdle();
|
|
if (!VerifyCursorBecomes(arrow, "Away from the gripper the cursor must be the arrow."))
|
|
{
|
|
return;
|
|
}
|
|
|
|
foreach (int x in new[] { gripperX, gripperX - 1, gripperX + 1, gripperX, gripperX - 1 })
|
|
{
|
|
InputHelper.MoveMouse(new Point(x, y));
|
|
Wait.ForIdle();
|
|
if (!VerifyCursorBecomes(sizeWE, string.Format("Over the gripper at x={0} the cursor must be and stay the west-east size cursor.", x), requiredMatches: 5))
|
|
{
|
|
return;
|
|
}
|
|
}
|
|
|
|
// The gripper travels with the edge, so the pointer stays over it.
|
|
InputHelper.LeftMouseButtonDown(header, gripperX - centre.X, 0);
|
|
try
|
|
{
|
|
InputHelper.MoveMouse(new Point(edgeX + 20, y));
|
|
InputHelper.MoveMouse(new Point(edgeX + 40, y));
|
|
Wait.ForIdle();
|
|
if (!VerifyCursorBecomes(sizeWE, "The west-east size cursor must hold during the drag."))
|
|
{
|
|
return;
|
|
}
|
|
}
|
|
finally
|
|
{
|
|
InputHelper.LeftMouseButtonUp();
|
|
Wait.ForIdle();
|
|
}
|
|
|
|
InputHelper.MoveMouse(new Point(FindColumnHeader(FindElement.ById(BasicTable), CursorTestColumn).BoundingRectangle.Left + 10, y));
|
|
Wait.ForIdle();
|
|
VerifyCursorBecomes(arrow, "Leaving the gripper must restore the arrow.");
|
|
}
|
|
}
|
|
|
|
[TestMethod]
|
|
[TestProperty("Description", "Verifies Escape during a pointer resize releases the gripper's mouse capture, so the pointer stops showing the resize cursor away from the gripper.")]
|
|
public void PointerResizeEscapeReleasesCapture()
|
|
{
|
|
using (var setup = new TestSetupHelper(PageName))
|
|
{
|
|
UIObject tableView = FindElement.ById(BasicTable);
|
|
if (tableView == null)
|
|
{
|
|
Verify.Fail("BasicTableView was not found on the test page.");
|
|
return;
|
|
}
|
|
|
|
UIObject header = FindColumnHeader(tableView, CursorTestColumn);
|
|
if (header == null)
|
|
{
|
|
Verify.Fail(string.Format("The '{0}' column header peer was not found on the header host.", CursorTestColumn));
|
|
return;
|
|
}
|
|
|
|
EnsureAppWindowForeground();
|
|
IntPtr sizeWE = LoadCursor(IntPtr.Zero, IDC_SIZEWE);
|
|
IntPtr arrow = LoadCursor(IntPtr.Zero, IDC_ARROW);
|
|
|
|
var bounds = header.BoundingRectangle;
|
|
int gripperOffset = (bounds.Width / 2) - (int)Math.Round(4.0 * GetAppWindowScale());
|
|
int edgeX = bounds.Left + bounds.Width - 1;
|
|
int y = bounds.Top + (bounds.Height / 2);
|
|
var table = tableView.BoundingRectangle;
|
|
// Over the body, well away from the gripper.
|
|
var awayY = Math.Min(bounds.Bottom + (3 * bounds.Height), table.Bottom - 10);
|
|
|
|
InputHelper.LeftMouseButtonDown(header, gripperOffset, 0);
|
|
try
|
|
{
|
|
InputHelper.MoveMouse(new Point(edgeX + 20, y));
|
|
InputHelper.MoveMouse(new Point(edgeX + 40, awayY));
|
|
Wait.ForIdle();
|
|
if (!VerifyCursorBecomes(sizeWE, "Precondition: the captured drag must hold the resize cursor away from the gripper."))
|
|
{
|
|
return;
|
|
}
|
|
|
|
KeyboardHelper.PressKey(Key.Escape);
|
|
Wait.ForIdle();
|
|
InputHelper.MoveMouse(new Point(edgeX + 30, awayY));
|
|
Wait.ForIdle();
|
|
if (!VerifyCursorBecomes(arrow, "After Escape the gripper must release capture, so the body's arrow cursor returns before the button is released."))
|
|
{
|
|
return;
|
|
}
|
|
}
|
|
finally
|
|
{
|
|
InputHelper.LeftMouseButtonUp();
|
|
Wait.ForIdle();
|
|
}
|
|
|
|
UIObject resized = FindColumnHeader(FindElement.ById(BasicTable), CursorTestColumn);
|
|
double widthBefore = resized.BoundingRectangle.Width;
|
|
DragColumnBoundary(resized, 80, cancelWithEscape: false);
|
|
Wait.ForIdle();
|
|
double widthAfter = FindColumnHeader(FindElement.ById(BasicTable), CursorTestColumn).BoundingRectangle.Width;
|
|
Log.Comment("After the canceled drag: {0} width before={1}, after a normal resize={2}.", CursorTestColumn, widthBefore, widthAfter);
|
|
Verify.IsGreaterThan(widthAfter, widthBefore + 20.0, "A resize after an Escape-canceled drag must still widen the column.");
|
|
}
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region 7. Scrolling
|
|
|
|
[TestMethod]
|
|
[TestProperty("Description", "Verifies a non-frozen header scrolls horizontally when the body is scrolled by pointer.")]
|
|
public void HorizontalScrollKeepsHeaderAligned()
|
|
{
|
|
// Derives from TableView-dev-spec.md "Sticky headers": the control drives PART_HeaderScroller's
|
|
// horizontal offset from PART_BodyScroller.ViewChanged, so the header band tracks horizontal body
|
|
// scrolling. This test proves real pointer scroll moves the non-frozen header; a failure means the
|
|
// header<->body horizontal sync never runs off the input path.
|
|
//
|
|
// Asserts on the page-published HeaderH offset, not a header peer's rectangle (see ReadScrollOffsets).
|
|
//
|
|
// LIMITATION (finding #13): the plan item's full claim is that headers move "in lockstep with
|
|
// cells". Reading an individual cell's x requires descending into a row peer, which crashes the
|
|
// app, so the cell half is unreachable out-of-proc. This asserts the header tracks the body scroll,
|
|
// which is the observable half.
|
|
using (var setup = new TestSetupHelper(PageName))
|
|
{
|
|
if (!SelectPivotItem(ScrollingPivotItem))
|
|
{
|
|
Verify.Fail("Could not select the 'Scrolling' pivot item.");
|
|
return;
|
|
}
|
|
|
|
UIObject tableView = FindElement.ById(ScrollingTable);
|
|
if (tableView == null)
|
|
{
|
|
Verify.Fail("ScrollingTableView was not found on the test page.");
|
|
return;
|
|
}
|
|
|
|
string offsetsBefore = ReadScrollOffsets();
|
|
double bodyBefore = ReadScrollOffsetComponent(offsetsBefore, "H");
|
|
double headerBefore = ReadScrollOffsetComponent(offsetsBefore, "HeaderH");
|
|
Verify.IsFalse(double.IsNaN(bodyBefore), "The page must publish the body scroller's H offset.");
|
|
Verify.IsFalse(double.IsNaN(headerBefore), "The page must publish the header scroller's HeaderH offset.");
|
|
|
|
// The Scrolling table is 520px wide with ~840px of columns, so it overflows horizontally.
|
|
// Horizontal scrolling is driven by dragging the body scroller's horizontal ScrollBar thumb:
|
|
// that is the only real pointer route the mouse has here. Shift+wheel does NOT work - measured,
|
|
// it scrolls VERTICALLY instead (offsets went H=0;V=39) - and MITA exposes no horizontal wheel.
|
|
if (!DragHorizontalScrollBar(tableView, 30))
|
|
{
|
|
Verify.Fail("The body scroller's horizontal ScrollBar was not found.");
|
|
return;
|
|
}
|
|
|
|
string offsetsAfter = ReadScrollOffsets();
|
|
double bodyAfter = ReadScrollOffsetComponent(offsetsAfter, "H");
|
|
double headerAfter = ReadScrollOffsetComponent(offsetsAfter, "HeaderH");
|
|
|
|
Log.Comment("Body H {0}->{1}; header HeaderH {2}->{3}. Raw offsets '{4}' -> '{5}'.",
|
|
bodyBefore, bodyAfter, headerBefore, headerAfter, offsetsBefore, offsetsAfter);
|
|
|
|
// Precondition: the body must have scrolled horizontally, or the header assertion is vacuous.
|
|
Verify.IsGreaterThan(
|
|
bodyAfter,
|
|
bodyBefore,
|
|
"Dragging the horizontal ScrollBar thumb must scroll PART_BodyScroller horizontally; an unchanged H means the input never reached the scroller.");
|
|
|
|
Verify.IsGreaterThan(
|
|
headerAfter,
|
|
headerBefore,
|
|
"The header band must track the body's horizontal scroll (dev-spec Sticky headers): PART_HeaderScroller's offset must advance with the body's.");
|
|
|
|
// The sync is an offset match, not merely movement in the same direction - the spec skips
|
|
// near-equal offsets (<0.5px) to avoid ViewChanged ping-pong, so allow that tolerance.
|
|
Verify.IsLessThan(
|
|
Math.Abs(headerAfter - bodyAfter),
|
|
1.0,
|
|
string.Format("The header offset ({0}) must track the body offset ({1}) within the sync tolerance.", headerAfter, bodyAfter));
|
|
}
|
|
}
|
|
|
|
[TestMethod]
|
|
[TestProperty("Description", "Verifies the header row stays pinned vertically while the body is scrolled by pointer.")]
|
|
public void VerticalScrollKeepsHeaderSticky()
|
|
{
|
|
// Derives from TableView-dev-spec.md "Sticky headers" / header/body layout: the header band is a
|
|
// separate scroller from the body, so vertical body scroll must not move the header down or off.
|
|
// A failure means the header participates in vertical scrolling and slides away from the top.
|
|
using (var setup = new TestSetupHelper(PageName))
|
|
{
|
|
if (!SelectPivotItem(ScrollingPivotItem))
|
|
{
|
|
Verify.Fail("Could not select the 'Scrolling' pivot item.");
|
|
return;
|
|
}
|
|
|
|
UIObject tableView = FindElement.ById(ScrollingTable);
|
|
if (tableView == null)
|
|
{
|
|
Verify.Fail("ScrollingTableView was not found on the test page.");
|
|
return;
|
|
}
|
|
|
|
UIObject frozenHeader = FindColumnHeader(tableView, "FrozenName");
|
|
if (frozenHeader == null)
|
|
{
|
|
Verify.Fail("The 'FrozenName' column header peer was not found on the header host.");
|
|
return;
|
|
}
|
|
|
|
double topBefore = frozenHeader.BoundingRectangle.Top;
|
|
|
|
// Precondition material: the body must actually scroll, or "the header did not move" is vacuous.
|
|
// A row peer's Name came back empty when this was measured, so the page publishes
|
|
// PART_BodyScroller's own offsets instead.
|
|
string offsetsBefore = ReadScrollOffsets();
|
|
|
|
// 200 items in a 300px-tall table: several wheel notches scroll the body well past a viewport.
|
|
// The header must not follow.
|
|
WheelAtPoint(CentreOf(tableView), -3 * mouseWheelDelta);
|
|
|
|
// No ElementCache.Clear() here - see HorizontalScrollKeepsHeaderAligned: clearing forces a
|
|
// Refresh() that reads .Name across the whole tree and trips finding #13. The freshness this
|
|
// negative assertion needs comes from FindColumnHeader's live walk of tableView.Children.
|
|
double topAfter = FindColumnHeader(tableView, "FrozenName").BoundingRectangle.Top;
|
|
string offsetsAfter = ReadScrollOffsets();
|
|
|
|
Log.Comment("Header Top before={0}, after vertical scroll={1}. Body offsets '{2}' -> '{3}'.",
|
|
topBefore, topAfter, offsetsBefore, offsetsAfter);
|
|
|
|
Verify.AreNotEqual(
|
|
offsetsBefore,
|
|
offsetsAfter,
|
|
"The wheel must scroll PART_BodyScroller vertically; identical offsets would make the assertion below vacuous.");
|
|
|
|
Verify.IsLessThanOrEqual(
|
|
Math.Abs(topAfter - topBefore),
|
|
4.0,
|
|
"The header row must stay pinned at the top during vertical scroll (dev-spec Sticky headers).");
|
|
}
|
|
}
|
|
|
|
[TestMethod]
|
|
[TestProperty("Description", "Verifies a FrozenEdge.Leading column stays pinned while unfrozen columns scroll horizontally under pointer input.")]
|
|
public void FrozenColumnStaysPinnedUnderPointerScroll()
|
|
{
|
|
// Derives from TableView-dev-spec.md "Frozen leading columns": leading-frozen header and
|
|
// body cells are counter-translated against the horizontal scroll offset, so a FrozenEdge.Leading
|
|
// column keeps its on-screen x while non-frozen columns shift. FrozenName authors
|
|
// FrozenEdge="Leading" on the Scrolling table. API 4.x sets the offset directly via ChangeView; this
|
|
// test drives a real pointer drag so the frozen layout runs off the input path. A failure means frozen
|
|
// pinning is applied only from the programmatic scroll path.
|
|
//
|
|
// Asserts on page-published geometry, not peer rectangles; see ReadScrollOffsets for *X vs *T.
|
|
using (var setup = new TestSetupHelper(PageName))
|
|
{
|
|
if (!SelectPivotItem(ScrollingPivotItem))
|
|
{
|
|
Verify.Fail("Could not select the 'Scrolling' pivot item.");
|
|
return;
|
|
}
|
|
|
|
UIObject tableView = FindElement.ById(ScrollingTable);
|
|
if (tableView == null)
|
|
{
|
|
Verify.Fail("ScrollingTableView was not found on the test page.");
|
|
return;
|
|
}
|
|
|
|
string offsetsBefore = ReadScrollOffsets();
|
|
double hBefore = ReadScrollOffsetComponent(offsetsBefore, "H");
|
|
double frozenXBefore = ReadScrollOffsetComponent(offsetsBefore, "FrozenX");
|
|
double scrollXBefore = ReadScrollOffsetComponent(offsetsBefore, "ScrollX");
|
|
double bodyFrozenXBefore = ReadScrollOffsetComponent(offsetsBefore, "BodyFrozenX");
|
|
double bodyScrollXBefore = ReadScrollOffsetComponent(offsetsBefore, "BodyScrollX");
|
|
|
|
Verify.IsFalse(
|
|
double.IsNaN(hBefore) ||
|
|
double.IsNaN(frozenXBefore) || double.IsNaN(scrollXBefore) ||
|
|
double.IsNaN(bodyFrozenXBefore) || double.IsNaN(bodyScrollXBefore),
|
|
string.Format(
|
|
"The page must publish H and both the header and body frozen/unfrozen x before the scroll; got '{0}'.",
|
|
offsetsBefore));
|
|
|
|
// Shift+wheel is NOT a horizontal scroll here - measured, it scrolls vertically - so the drag of
|
|
// the body scroller's horizontal ScrollBar thumb is the real pointer route.
|
|
if (!DragHorizontalScrollBar(tableView, 30))
|
|
{
|
|
Verify.Fail("The body scroller's horizontal ScrollBar was not found.");
|
|
return;
|
|
}
|
|
|
|
Wait.ForIdle();
|
|
|
|
string offsetsAfter = ReadScrollOffsets();
|
|
double hAfter = ReadScrollOffsetComponent(offsetsAfter, "H");
|
|
double frozenXAfter = ReadScrollOffsetComponent(offsetsAfter, "FrozenX");
|
|
double scrollXAfter = ReadScrollOffsetComponent(offsetsAfter, "ScrollX");
|
|
double bodyFrozenXAfter = ReadScrollOffsetComponent(offsetsAfter, "BodyFrozenX");
|
|
double bodyScrollXAfter = ReadScrollOffsetComponent(offsetsAfter, "BodyScrollX");
|
|
double bodyFrozenTAfter = ReadScrollOffsetComponent(offsetsAfter, "BodyFrozenT");
|
|
|
|
Log.Comment("Body offsets '{0}' -> '{1}'.", offsetsBefore, offsetsAfter);
|
|
|
|
double scrolledBy = hAfter - hBefore;
|
|
|
|
Verify.IsGreaterThan(
|
|
scrolledBy,
|
|
10.0,
|
|
"Dragging the horizontal ScrollBar thumb must scroll PART_BodyScroller right by a measurable amount; otherwise the pinning assertions below are vacuous.");
|
|
|
|
// Precondition: unfrozen body content moves by exactly -scroll (also validates the measurement).
|
|
Verify.IsLessThanOrEqual(
|
|
Math.Abs((bodyScrollXAfter - bodyScrollXBefore) + scrolledBy),
|
|
2.0,
|
|
string.Format(
|
|
"The unfrozen 'ScrollCity' body cell must shift left by the scroll amount ({0}); it moved by {1}.",
|
|
scrolledBy,
|
|
bodyScrollXAfter - bodyScrollXBefore));
|
|
|
|
// Without the counter-translation this value tracks bodyScrollX, failing by ~scrolledBy.
|
|
Verify.IsLessThanOrEqual(
|
|
Math.Abs(bodyFrozenXAfter - bodyFrozenXBefore),
|
|
2.0,
|
|
string.Format(
|
|
"A FrozenEdge.Leading body cell must stay pinned while the body scrolls horizontally (dev-spec:185); its rendered x went {0} -> {1} under a {2}px scroll.",
|
|
bodyFrozenXBefore,
|
|
bodyFrozenXAfter,
|
|
scrolledBy));
|
|
|
|
// The pin must come from the counter-translation, not from a failed layout.
|
|
Verify.IsLessThanOrEqual(
|
|
Math.Abs(bodyFrozenTAfter - hAfter),
|
|
2.0,
|
|
string.Format(
|
|
"The frozen body cell's counter-translation ({0}) must match the horizontal scroll offset ({1}) it cancels (dev-spec:185).",
|
|
bodyFrozenTAfter,
|
|
hAfter));
|
|
|
|
// Header cells are pinned too; same contract and unfrozen precondition on the header band.
|
|
Verify.IsLessThanOrEqual(
|
|
Math.Abs((scrollXAfter - scrollXBefore) + scrolledBy),
|
|
2.0,
|
|
string.Format(
|
|
"The unfrozen 'ScrollCity' header cell must shift left by the scroll amount ({0}); it moved by {1}.",
|
|
scrolledBy,
|
|
scrollXAfter - scrollXBefore));
|
|
|
|
Verify.IsLessThanOrEqual(
|
|
Math.Abs(frozenXAfter - frozenXBefore),
|
|
2.0,
|
|
string.Format(
|
|
"The 'FrozenName' header cell must stay pinned with its body cells (dev-spec:185); its rendered x went {0} -> {1}.",
|
|
frozenXBefore,
|
|
frozenXAfter));
|
|
}
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region 8. Right-to-left
|
|
|
|
[TestMethod]
|
|
[TestProperty("Description", "Verifies a pointer resize drag is direction-mirrored under RTL: a leftward drag widens the leading column.")]
|
|
public void RightToLeftResizeMirrors()
|
|
{
|
|
// Derives from TableView-dev-spec.md:131: "positive always grows in reading order ... only the
|
|
// horizontal axis mirrors under RTL." Under RTL the leading column (RtlName) is laid out at the
|
|
// screen RIGHT and its gripper sits on its screen-LEFT edge, so the drag that WIDENS it moves LEFT
|
|
// (-x) - the mirror of the LTR rightward widen proven by PointerResizeDragChangesColumnWidth. The
|
|
// sign is the whole point: a failure means the primitive did not mirror the horizontal axis and a
|
|
// leftward drag shrinks (or does nothing to) the column under RTL.
|
|
using (var setup = new TestSetupHelper(PageName))
|
|
{
|
|
if (!SelectPivotItem(RtlPivotItem))
|
|
{
|
|
Verify.Fail("Could not select the 'Rtl' pivot item.");
|
|
return;
|
|
}
|
|
|
|
UIObject tableView = FindElement.ById(RtlTable);
|
|
if (tableView == null)
|
|
{
|
|
Verify.Fail("RtlTableView was not found on the test page.");
|
|
return;
|
|
}
|
|
|
|
UIObject rtlNameHeader = FindColumnHeader(tableView, "RtlName");
|
|
if (rtlNameHeader == null)
|
|
{
|
|
Verify.Fail("The 'RtlName' column header peer was not found on the header host.");
|
|
return;
|
|
}
|
|
|
|
UIObject rtlCityHeader = FindColumnHeader(tableView, "RtlCity");
|
|
if (rtlCityHeader == null)
|
|
{
|
|
Verify.Fail("The 'RtlCity' column header peer was not found on the header host.");
|
|
return;
|
|
}
|
|
|
|
double nameWidthBefore = rtlNameHeader.BoundingRectangle.Width;
|
|
double cityWidthBefore = rtlCityHeader.BoundingRectangle.Width;
|
|
|
|
// Diagnostic: log every header's bounds so the actual RTL layout order, and therefore the real
|
|
// column boundary the gripper straddles, is on record. Header peers are not row peers, so this
|
|
// does not trip finding #13.
|
|
foreach (UIObject header in tableView.Children[0].Children)
|
|
{
|
|
Log.Comment("RTL header '{0}' bounds={1}.", header.Name, header.BoundingRectangle);
|
|
}
|
|
|
|
// The gripper is an 8px band straddling a column boundary, and which SIDE of the boundary it
|
|
// occupies is the placement question this test exists to pin. Pressing "1px inside my own
|
|
// header" therefore begs the question: it lands on the band only if the band is placed the way
|
|
// the test assumed. So press the boundary itself, from RtlName's side first and RtlCity's side
|
|
// second, and report which side responded and which column moved. Only a gesture that cannot
|
|
// miss the affordance can prove anything about its direction.
|
|
//
|
|
// Under RTL, RtlName is the reading-order-FIRST column and is laid out at the screen RIGHT, so
|
|
// the boundary it shares with RtlCity is RtlName's screen-LEFT edge, and the drag that widens
|
|
// RtlName moves LEFT (-x) - the mirror of the LTR rightward widen.
|
|
int boundaryX = rtlNameHeader.BoundingRectangle.Left;
|
|
const int dragBy = 80;
|
|
|
|
DragAtAbsoluteX(rtlNameHeader, boundaryX + 2, -dragBy);
|
|
double nameAfterLeadingSide = FindColumnHeader(tableView, "RtlName").BoundingRectangle.Width;
|
|
double cityAfterLeadingSide = FindColumnHeader(tableView, "RtlCity").BoundingRectangle.Width;
|
|
Log.Comment("Boundary x={0}, pressed RtlName's side (+2), dragged {1}px: RtlName {2}->{3}, RtlCity {4}->{5}.",
|
|
boundaryX, -dragBy, nameWidthBefore, nameAfterLeadingSide, cityWidthBefore, cityAfterLeadingSide);
|
|
|
|
// Take every observation BEFORE the assertion: a failed Verify is terminal in this suite, so
|
|
// anything logged after it is lost on the run that most needs it.
|
|
double nameAfterTrailingSide = nameAfterLeadingSide;
|
|
double cityAfterTrailingSide = cityAfterLeadingSide;
|
|
if (Math.Abs(nameAfterLeadingSide - nameWidthBefore) < 1.0
|
|
&& Math.Abs(cityAfterLeadingSide - cityWidthBefore) < 1.0)
|
|
{
|
|
DragAtAbsoluteX(FindColumnHeader(tableView, "RtlCity"), boundaryX - 2, -dragBy);
|
|
nameAfterTrailingSide = FindColumnHeader(tableView, "RtlName").BoundingRectangle.Width;
|
|
cityAfterTrailingSide = FindColumnHeader(tableView, "RtlCity").BoundingRectangle.Width;
|
|
Log.Comment("Nothing moved; pressed RtlCity's side (-2), dragged {0}px: RtlName {1}->{2}, RtlCity {3}->{4}.",
|
|
-dragBy, nameWidthBefore, nameAfterTrailingSide, cityWidthBefore, cityAfterTrailingSide);
|
|
}
|
|
|
|
// Last resort: the header's screen-RIGHT edge is the table's OUTER edge under RTL and borders
|
|
// no column at all. A width change there means the gripper was left on the column's physical
|
|
// right edge, i.e. placed as if the layout were still LTR.
|
|
double nameAfterOuterEdgeProbe = nameAfterTrailingSide;
|
|
if (Math.Abs(nameAfterTrailingSide - nameWidthBefore) < 1.0)
|
|
{
|
|
var nameBounds = FindColumnHeader(tableView, "RtlName").BoundingRectangle;
|
|
DragAtAbsoluteX(FindColumnHeader(tableView, "RtlName"), nameBounds.Left + nameBounds.Width - 2, dragBy);
|
|
nameAfterOuterEdgeProbe = FindColumnHeader(tableView, "RtlName").BoundingRectangle.Width;
|
|
Log.Comment("Still nothing; probed the table's OUTER edge with a rightward drag: RtlName {0}->{1}.",
|
|
nameWidthBefore, nameAfterOuterEdgeProbe);
|
|
}
|
|
|
|
double widthAfter = Math.Max(nameAfterLeadingSide, nameAfterTrailingSide);
|
|
|
|
Verify.IsGreaterThan(
|
|
widthAfter,
|
|
nameWidthBefore + 20.0,
|
|
"Under RTL a LEFTWARD pointer drag on the RtlName/RtlCity boundary must WIDEN RtlName, the reading-order-first column (dev-spec:131 horizontal mirror).");
|
|
}
|
|
}
|
|
|
|
[TestMethod]
|
|
[TestProperty("Description", "Verifies keyboard resize is direction-mirrored under RTL: Alt+Left widens the column and Alt+Right shrinks it, without moving header focus.")]
|
|
public void RightToLeftKeyboardResizeMirrors()
|
|
{
|
|
// Previously named RightToLeftKeyboardNavigationMirrors.
|
|
// dev-spec:133 - TryKeyboardStep owns direction and the RTL mirror; dev-spec:131 - positive grows in
|
|
// reading order, so under RTL the forward chord is Alt+LEFT. The Alt+Arrow chord is the #11820 model
|
|
// (spec debt: dev-spec:135 still says bare Left/Right). Focus is re-checked so a chord that navigates
|
|
// instead of resizing cannot pass on a clipped header rectangle.
|
|
using (var setup = new TestSetupHelper(PageName))
|
|
{
|
|
if (!SelectPivotItem(RtlPivotItem))
|
|
{
|
|
Verify.Fail("Could not select the 'Rtl' pivot item.");
|
|
return;
|
|
}
|
|
|
|
UIObject tableView = FindElement.ById(RtlTable);
|
|
if (tableView == null)
|
|
{
|
|
Verify.Fail("RtlTableView was not found on the test page.");
|
|
return;
|
|
}
|
|
|
|
UIObject rtlNameHeader = FindColumnHeader(tableView, "RtlName");
|
|
if (rtlNameHeader == null)
|
|
{
|
|
Verify.Fail("The 'RtlName' column header peer was not found on the header host.");
|
|
return;
|
|
}
|
|
|
|
rtlNameHeader.SetFocus();
|
|
Wait.ForIdle();
|
|
if (!rtlNameHeader.HasKeyboardFocus)
|
|
{
|
|
Verify.Fail("The 'RtlName' header did not take keyboard focus; the resize keyboard step cannot be exercised.");
|
|
return;
|
|
}
|
|
|
|
double widthStart = rtlNameHeader.BoundingRectangle.Width;
|
|
|
|
// Alt+Left = reading-order-forward under RTL => widen.
|
|
KeyboardHelper.PressKey(Key.Left, ModifierKey.Alt, numPresses: 6);
|
|
Wait.ForIdle();
|
|
UIObject afterLeft = FindColumnHeader(tableView, "RtlName");
|
|
double widthAfterLeft = afterLeft.BoundingRectangle.Width;
|
|
bool focusKeptLeft = afterLeft.HasKeyboardFocus;
|
|
|
|
Log.Comment("RTL keyboard resize: width start={0}, after Alt+Left x6={1}, focus kept={2}.", widthStart, widthAfterLeft, focusKeptLeft);
|
|
|
|
Verify.IsTrue(focusKeptLeft, "Alt+Left must resize RtlName, not move focus to another header.");
|
|
Verify.IsGreaterThan(
|
|
widthAfterLeft,
|
|
widthStart + 2.0,
|
|
"Under RTL, Alt+Left on the header must WIDEN the column (dev-spec:133 RTL mirror + dev-spec:131 reading-order growth).");
|
|
|
|
// Alt+Right = reading-order-backward under RTL => shrink.
|
|
KeyboardHelper.PressKey(Key.Right, ModifierKey.Alt, numPresses: 6);
|
|
Wait.ForIdle();
|
|
UIObject afterRight = FindColumnHeader(tableView, "RtlName");
|
|
double widthAfterRight = afterRight.BoundingRectangle.Width;
|
|
|
|
Log.Comment("RTL keyboard resize: after Alt+Right x6={0} (was {1}), focus kept={2}.", widthAfterRight, widthAfterLeft, afterRight.HasKeyboardFocus);
|
|
|
|
Verify.IsTrue(afterRight.HasKeyboardFocus, "Alt+Right must resize RtlName, not move focus to another header.");
|
|
Verify.IsLessThan(
|
|
widthAfterRight,
|
|
widthAfterLeft - 2.0,
|
|
"Under RTL, Alt+Right on the header must SHRINK the column - the mirror of Alt+Left (dev-spec:133).");
|
|
}
|
|
}
|
|
|
|
#endregion
|
|
|
|
// ---- helpers ---------------------------------------------------------------------------------
|
|
|
|
// Presses at an ABSOLUTE screen x on the header band and drags dx pixels horizontally, releasing at the
|
|
// end. anchorHeader only supplies the peer to press through and the y centre - the x comes from the
|
|
// caller, so the gesture is aimed at a measured boundary rather than at an assumed edge of an element.
|
|
//
|
|
// MITA offsets are CENTRE-relative, not top-left relative, which is why the offset is computed against
|
|
// the anchor's centre. Getting this backwards silently presses the middle of the header, where there is
|
|
// no gripper, and reads as a product failure.
|
|
private static void DragAtAbsoluteX(UIObject anchorHeader, int absoluteX, int dx)
|
|
{
|
|
var bounds = anchorHeader.BoundingRectangle;
|
|
int centreX = bounds.Left + (bounds.Width / 2);
|
|
int y = bounds.Top + (bounds.Height / 2);
|
|
|
|
InputHelper.LeftMouseButtonDown(anchorHeader, absoluteX - centreX, 0);
|
|
|
|
// Move in a couple of steps so the manipulation is recognized past the 0.5 DIP deadband.
|
|
InputHelper.MoveMouse(new Point(absoluteX + (dx / 2), y));
|
|
InputHelper.MoveMouse(new Point(absoluteX + dx, y));
|
|
InputHelper.LeftMouseButtonUp();
|
|
Wait.ForIdle();
|
|
}
|
|
}
|
|
}
|